Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/cmd/analyzer_inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPath, projectModelDir string) {
for _, approxPath := range paths {
absApproxPath := log.AbsPathOrExit(approxPath, "dataflow-approximations")
absApproxPath := log.AbsPathOrExit(approxPath, "java-models")
compiledPath, err := compileApproximationsIfNeeded(absApproxPath, analyzerJarPath, projectModelDir)
if err != nil {
out.Fatalf("Approximation compilation failed: %s", err)
Expand All @@ -17,6 +17,6 @@ func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPa

func addPassthroughApproximations(b *AnalyzerBuilder, paths []string) {
for _, passthrough := range paths {
b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-approximations"))
b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-models"))
}
}
39 changes: 26 additions & 13 deletions cli/cmd/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,33 @@ func currentCompileBuilder(projectPath string) *utils.OpentaintCommandBuilder {
// dockerCompileSuggestion builds the "try Docker-based compilation" fallback hint.
func dockerCompileSuggestion() output.Suggestion {
return output.Suggestion{
Description: dockerFallbackHintPrefix + "compilation:",
Description: "If the required Java is missing, set JAVA_HOME or compile in a container instead:",
Command: utils.BuildCompileCommandWithDocker(currentCompileBuilder(""), ProjectPath, OutputProjectModelPath),
}
}

// compileCmd represents the compile command
var compileCmd = &cobra.Command{
Use: "compile project",
Short: "Compile your Java or Kotlin project",
Use: "compile <project>",
Short: "Compile a project into a reusable project model",
Args: cobra.ExactArgs(1), // require exactly one argument
Long: `This command takes a required path to the project, automatically detects Java/Kotlin build system, modules and dependencies and compiles project model.
Long: `Compile a project into a project model that you can scan many times. OpenTaint finds the build system, collects the modules and dependencies, and builds the project.

Arguments:
project - Path to a project to compile (required)
`,
The project argument is the path to the project root. It is required. Use --output to set the project model directory. This directory must not exist before the command runs.

Later scans can use the model without a new build. This makes repeated scans fast.

Before your first compile, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`,
Example: ` # Compile the current directory into a project model
opentaint compile . -o ./model

# Make sure the inputs are correct, without a build
opentaint compile . -o ./model --dry-run

# Recipe: compile one time, then scan with different settings
opentaint compile ./my-app -o ./model
opentaint scan --project-model ./model --ruleset ./team-rules -o team.sarif
opentaint scan --project-model ./model --severity error -o errors.sarif`,
Annotations: map[string]string{"PrintConfig": "true"},
Run: func(cmd *cobra.Command, args []string) {
ProjectPath = args[0]
Expand All @@ -72,33 +84,34 @@ Arguments:
sb.Line()
}
sb.FieldNode("Project", absProjectRoot).
FieldNode("Output project model", absOutputProjectModelPath).
FieldNode("Project model", absOutputProjectModelPath).
FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))).
Render()

if DryRunCompile {
out.Blank()
failOnInvalidInputs(func() error { return validation.ValidateCompileInputs(absProjectRoot, absOutputProjectModelPath) })
runDryRun("Compilation")
runDryRun("compilation")
return
}

autobuilderJarPath, err := ensureAutobuilderAvailable()
if err != nil {
out.Fatalf("Native compile preparation failed: %s", err)
failf("Native compile preparation failed: %s", err)
}

compileJavaRunner := newAutobuilderJavaRunner()
if _, err := compileJavaRunner.EnsureJava(); err != nil {
out.Fatalf("Failed to resolve Java for compilation: %s", err)
failf("Failed to resolve Java for compilation: %s", err)
}

if err := out.RunWithSpinner("Compiling project model", func() error {
return compile(absProjectRoot, absOutputProjectModelPath, autobuilderJarPath, compileJavaRunner)
}); err == nil {
out.Blank()
printCompileSummary(absOutputProjectModelPath)
suggest("To scan project run", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath))
out.Successf("Compilation completed.")
suggest("To scan the compiled project model, run:", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath))
} else {
out.InteractiveBlank()
failWith(1, fmt.Sprintf("Native compile has failed: %s", err), dockerCompileSuggestion())
Expand All @@ -109,7 +122,7 @@ Arguments:
func init() {
rootCmd.AddCommand(compileCmd)

compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the result project model`)
compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the project model directory to create (required, must not exist)`)
_ = compileCmd.MarkFlagRequired("output")
compileCmd.Flags().BoolVar(&DryRunCompile, "dry-run", false, "Validate inputs and show what would run without compiling")
compileCmd.Flags().StringVar(&CompileLogFile, "log-file", "", "Path to the log file (default: <cache-dir>/logs/<timestamp>.log)")
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/compile_approximations.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
// bundles approximation support sources (OpentaintNdUtil, ArgumentTypeContext).
const approxClassesJarPrefix = "opentaint-dataflow-approximations/"

// compileApproximationsIfNeeded checks whether a --dataflow-approximations directory
// compileApproximationsIfNeeded checks whether a --java-models directory
// contains .java source files. If so, it compiles them using javac (with the
// analyzer JAR + project dependencies on the classpath) and returns the path to
// the compiled .class output directory. If the directory already contains only
Expand Down
47 changes: 45 additions & 2 deletions cli/cmd/dry_run.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,56 @@
package cmd

import "fmt"
import (
"os"
"strings"
)

func failOnInvalidInputs(validate func() error) {
if err := validate(); err != nil {
out.Fatalf("Input validation failed: %s", err)
}
}

// runDryRun prints the standard dry-run tail: a status line naming the skipped
// action, then a suggestion to repeat the same invocation without --dry-run.
func runDryRun(skippedAction string) {
out.Print(fmt.Sprintf("Dry run mode. Inputs validated. %s skipped.", skippedAction))
out.Printf("Dry run complete. Inputs validated, %s skipped.", skippedAction)
suggest("To run for real, run:", rerunWithoutDryRun())
}

// rerunWithoutDryRun reconstructs the current invocation with the --dry-run
// flag removed, so the dry-run tail can suggest the real run verbatim. It works
// from os.Args, which keeps it correct for every command that shares this tail
// (scan, compile, project, test rule reachability).
func rerunWithoutDryRun() string {
args := []string{"opentaint"}
for _, arg := range os.Args[1:] {
if arg == "--dry-run" || strings.HasPrefix(arg, "--dry-run=") {
continue
}
args = append(args, shellQuote(arg))
}
return strings.Join(args, " ")
}

// shellQuote single-quotes an argument that would break when copy-pasted into
// a shell. Only arguments made of known-inert characters pass through
// unchanged, so globs, variables, and separators survive the round trip.
func shellQuote(arg string) string {
if arg != "" && !strings.ContainsFunc(arg, shellUnsafe) {
return arg
}
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}

// shellUnsafe reports whether a character can change the meaning of an
// unquoted shell word. The safe set mirrors Python's shlex.quote.
func shellUnsafe(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return false
case strings.ContainsRune("_@%+=:,./-", r):
return false
}
return true
}
36 changes: 36 additions & 0 deletions cli/cmd/exit_codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import (
"fmt"

"github.com/seqra/opentaint/internal/analyzer"
)

// analyzerExitCodeRows renders the forwarded analyzer exit codes (252-255) as
// help rows. The row text is generated from analyzer.ExitMessage so the
// documented codes can never drift from the runtime failure messages.
func analyzerExitCodeRows() string {
rows := ""
for _, code := range []int{analyzer.ExitException, analyzer.ExitOOM, analyzer.ExitTimeout, analyzer.ExitConfigError} {
rows += fmt.Sprintf("\n %-3d %s", code, analyzer.ExitMessage(code))
}
return rows
}

// scanExitCodesHelp renders the exit-codes block for commands that forward
// analyzer exit codes but have no test-failure code (scan, test rule
// reachability).
func scanExitCodesHelp(completedLine string) string {
return `Exit codes:
0 ` + completedLine + `
1 General failure (configuration or infrastructure error)` + analyzerExitCodeRows()
}

// testExitCodesHelp renders the exit-codes block for the test-run commands,
// which add exit code 2 for sample failures.
func testExitCodesHelp(passedLine string) string {
return `Exit codes:
0 ` + passedLine + `
1 General failure (configuration or infrastructure error)
2 One or more tests failed (false negatives, false positives, or skipped samples)` + analyzerExitCodeRows()
}
42 changes: 42 additions & 0 deletions cli/cmd/flag_alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"strings"

"github.com/spf13/pflag"
)

// renamedStringArray backs a renamed flag and its deprecated alias. pflag's
// stringArrayValue replaces the bound slice on each flag's own first value, so
// two stock flags bound to one slice silently drop whatever the other spelling
// already collected. Appending unconditionally keeps the values of both
// spellings, in command-line order.
type renamedStringArray struct {
target *[]string
}

func (v renamedStringArray) String() string {
if len(*v.target) == 0 {
return ""
}
return "[" + strings.Join(*v.target, ",") + "]"
}

func (v renamedStringArray) Set(s string) error {
*v.target = append(*v.target, s)
return nil
}

func (v renamedStringArray) Type() string {
return "stringArray"
}

// addRenamedStringArrayFlag registers a flag under its new name and its
// deprecated old spelling, both accumulating into the same slice.
func addRenamedStringArrayFlag(fs *pflag.FlagSet, target *[]string, name, deprecated, usage string) {
fs.Var(renamedStringArray{target}, name, usage)
fs.Var(renamedStringArray{target}, deprecated, usage)
if err := fs.MarkDeprecated(deprecated, "use --"+name); err != nil {
panic(err)
}
}
56 changes: 56 additions & 0 deletions cli/cmd/flag_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"reflect"
"testing"

"github.com/spf13/pflag"
)

func parseRenamed(t *testing.T, args []string) []string {
t.Helper()
var target []string
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage")
if err := fs.Parse(args); err != nil {
t.Fatalf("parse %v: %v", args, err)
}
return target
}

func TestRenamedFlagAccumulatesAcrossBothSpellings(t *testing.T) {
cases := [][]string{
{"--passthrough-models", "a.yaml", "--passthrough-approximations", "b.yaml"},
{"--passthrough-approximations", "a.yaml", "--passthrough-models", "b.yaml"},
}
for _, args := range cases {
got := parseRenamed(t, args)
if len(got) != 2 {
t.Errorf("args %v: got %v, want both values kept", args, got)
}
}
}

func TestRenamedFlagKeepsRepeatsInOrder(t *testing.T) {
got := parseRenamed(t, []string{
"--passthrough-models", "a.yaml",
"--passthrough-models", "b.yaml",
"--passthrough-approximations", "c.yaml",
})
if want := []string{"a.yaml", "b.yaml", "c.yaml"}; !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}

func TestRenamedFlagAliasIsDeprecatedAndHidden(t *testing.T) {
var target []string
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage")
alias := fs.Lookup("passthrough-approximations")
if alias == nil || alias.Deprecated == "" {
t.Fatal("alias must be registered and marked deprecated")
}
if fs.Lookup("passthrough-models").Deprecated != "" {
t.Error("the new spelling must not be deprecated")
}
}
26 changes: 19 additions & 7 deletions cli/cmd/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,26 @@ type healthComponent struct {

var healthCmd = &cobra.Command{
Use: "health",
Short: "Show resolved dependency paths",
Long: `Show the on-disk paths OpenTaint uses for the autobuilder, analyzer,
built-in rules, and Java runtime.
Short: "Show dependency paths and report missing components",
Long: `Show the paths of the components on this computer. The components are the autobuilder, the analyzer, the built-in rules, and the Java runtime. The command shows if each component is present.

Use --autobuilder, --analyzer, --rules, or --runtime to select components. When
exactly one component is selected, only its path is printed. The command does
not download artifacts except built-in rules, which are fetched on demand.
To select components, use --autobuilder, --analyzer, --rules, or --runtime. With no flag, the command shows all four components. If you select exactly one component, only its path is printed. This output is good for scripts.

The exit code is non-zero when any selected component is missing.`,
Only the built-in rules are downloaded when they are missing. No other component is downloaded.

If a selected component is missing, the command exits with a code that is not zero. To download the missing components, run "opentaint pull".`,
Example: ` # Show all components and their paths
opentaint health

# Print only the analyzer JAR path, for a script
opentaint health --analyzer

# Make sure the Java runtime is present
opentaint health --runtime

# Recipe: use the built-in rules path in a script
RULES=$(opentaint health --rules)
opentaint scan . --ruleset "$RULES" --ruleset ./extra-rules -o report.sarif`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runHealth()
Expand Down Expand Up @@ -105,6 +116,7 @@ func runHealth() error {
}
sb.Render()
if len(missing) > 0 {
out.Suggest("To download the missing components, run:", "opentaint pull")
return fmt.Errorf("missing components: %s", strings.Join(missing, ", "))
}
return nil
Expand Down
Loading
Loading