diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 0285c7e..b5ef71a 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -421,7 +421,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } } - if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" { + if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" || m.RuleID == "rust-cargo-rerun-imports" { var path string var explicitTarget string kind := "rust-path" @@ -452,6 +452,11 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F // the raw braces into a false crate-root edge. kind = "rust-use" } + case "rust-cargo-rerun-imports": + kind = "rust-build-input" + if pathVar, ok := m.MetaVariables.Single["PATH"]; ok { + path, _ = parseRustBuildScriptInput(pathVar.Text) + } case "rust-askama-template-imports": kind = "rust-askama-template" if targetVar, ok := m.MetaVariables.Single["TARGET"]; ok && !rustAttributeAssigns(m.Text, "config") { @@ -468,7 +473,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F } } if path != "" { - if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" { + if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-cargo-rerun-imports" { fileMap[relPath].Imports = append(fileMap[relPath].Imports, path) } fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{ diff --git a/scanner/rustbuildscript.go b/scanner/rustbuildscript.go new file mode 100644 index 0000000..5d4536c --- /dev/null +++ b/scanner/rustbuildscript.go @@ -0,0 +1,46 @@ +package scanner + +import ( + "path/filepath" + "strings" +) + +func parseRustBuildScriptInput(literal string) (string, bool) { + value, ok := parseRustStringLiteral(literal) + if !ok { + return "", false + } + var path string + for _, prefix := range []string{"cargo:rerun-if-changed=", "cargo::rerun-if-changed="} { + if strings.HasPrefix(value, prefix) { + path = strings.TrimPrefix(value, prefix) + break + } + } + if path == "" || path != strings.TrimSpace(path) || strings.ContainsAny(path, "{}\r\n\x00") { + return "", false + } + return path, true +} + +func resolveRustBuildScriptInput(fromFile, input string, idx *fileIndex, workspace *rustWorkspaceIndex) string { + target, ok := workspace.targetForFile(fromFile, idx) + if !ok || target.kind != rustTargetCustomBuild || target.rootFile != filepath.Clean(fromFile) { + return "" + } + pkg, ok := workspace.packageForFile(fromFile) + if !ok || !pkg.authoritative { + return "" + } + + path := filepath.Clean(filepath.FromSlash(input)) + if filepath.IsAbs(path) || filepath.VolumeName(path) != "" || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) || strings.ContainsAny(input, "*?[") { + return "" + } + candidate := filepath.Clean(filepath.Join(pkg.root, path)) + files := idx.byExact[candidate] + if len(files) != 1 || files[0] != candidate || candidate == fromFile { + return "" + } + return candidate +} diff --git a/scanner/rustbuildscript_test.go b/scanner/rustbuildscript_test.go new file mode 100644 index 0000000..8654e27 --- /dev/null +++ b/scanner/rustbuildscript_test.go @@ -0,0 +1,152 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestAstGrepRustBuildScriptInputExtraction(t *testing.T) { + scanner, err := NewAstGrepScanner() + if err != nil { + t.Fatal(err) + } + t.Cleanup(scanner.Close) + if !scanner.Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + source := `fn main() { + println!("cargo:rerun-if-changed=schema.proto"); + println!("cargo::rerun-if-changed=assets/default.policy"); + // println!("cargo:rerun-if-changed=commented.proto"); + /* + println!("cargo:rerun-if-changed=blocked.proto"); + */ + println!("cargo:rerun-if-changed= schema.proto"); + println!("cargo:rerun-if-changed=schema.proto "); + println!("cargo:rerun-if-changed={path}"); + println!("cargo:rerun-if-changed={}", "schema.proto"); + println!("cargo:rerun-if-changed=schema.proto\nother.proto"); + println!("plain output without directive"); +} +const DOC: &str = r##" +println!("cargo:rerun-if-changed=documented.proto"); +"##; +` + if err := os.WriteFile(filepath.Join(root, "build.rs"), []byte(source), 0o644); err != nil { + t.Fatal(err) + } + astScanner, err := NewAstGrepScanner() + if err != nil { + t.Fatal(err) + } + t.Cleanup(astScanner.Close) + outcome, err := astScanner.ScanDirectory(context.Background(), root) + if err != nil { + t.Fatal(err) + } + + var got []ImportReference + for _, analysis := range outcome.Analyses { + for _, ref := range analysis.References { + if ref.Kind == "rust-build-input" { + ref.Line = 0 + got = append(got, ref) + } + } + } + for _, analysis := range outcome.Analyses { + for _, ref := range analysis.References { + if ref.Kind == "rust-build-input" && len(analysis.Imports) != 0 { + t.Fatalf("directive paths leaked into imports: %v", analysis.Imports) + } + } + } + sort.Slice(got, func(i, j int) bool { return got[i].Path < got[j].Path }) + want := []ImportReference{ + {Path: "assets/default.policy", Kind: "rust-build-input"}, + {Path: "schema.proto", Kind: "rust-build-input"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Cargo build input references = %#v, want %#v", got, want) + } +} + +func TestRustBuildScriptResolvesStaticCargoInputs(t *testing.T) { + root := t.TempDir() + absolute := filepath.Join(root, "outside.proto") + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"app\"]\n", + "outside.proto": "syntax = \"proto3\";\n", + "app/Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\nbuild = \"build.rs\"\n", + "app/build.rs": "fn main() {}\n", + "app/schema.proto": "syntax = \"proto3\";\n", + "app/assets/default.policy": "allow if true\n", + }) + + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackageWithTargets(root, "app", "app", []map[string]any{ + cargoTargetJSON(root, "app/build.rs", "build-script-build", rustTargetCustomBuild), + }, nil), + }) + graph, err := buildFileGraphFromAnalysesWithCargoMetadata( + context.Background(), + root, + []FileAnalysis{{Path: "app/build.rs", Language: "rust", References: []ImportReference{ + {Path: "schema.proto", Kind: "rust-build-input"}, + {Path: "assets/default.policy", Kind: "rust-build-input"}, + {Path: "assets", Kind: "rust-build-input"}, + {Path: "../outside.proto", Kind: "rust-build-input"}, + {Path: "missing.proto", Kind: "rust-build-input"}, + {Path: "*.proto", Kind: "rust-build-input"}, + {Path: absolute, Kind: "rust-build-input"}, + {Path: "build.rs", Kind: "rust-build-input"}, + }}}, + func(context.Context, string) ([]byte, error) { return metadata, nil }, + ) + if err != nil { + t.Fatal(err) + } + + got := append([]string(nil), graph.Imports["app/build.rs"]...) + for i := range got { + got[i] = filepath.ToSlash(got[i]) + } + sort.Strings(got) + want := []string{"app/assets/default.policy", "app/schema.proto"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("build script inputs = %#v, want %#v", got, want) + } +} + +func TestRustBuildScriptInputsRequireCustomBuildTarget(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn emit() {}\n", + "schema.proto": "syntax = \"proto3\";\n", + }) + + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackageWithTargets(root, ".", "app", []map[string]any{ + cargoTargetJSON(root, "src/lib.rs", "app", rustTargetLib), + }, nil), + }) + graph, err := buildFileGraphFromAnalysesWithCargoMetadata( + context.Background(), + root, + []FileAnalysis{{Path: filepath.Join("src", "lib.rs"), Language: "rust", References: []ImportReference{{Path: "schema.proto", Kind: "rust-build-input"}}}}, + func(context.Context, string) ([]byte, error) { return metadata, nil }, + ) + if err != nil { + t.Fatal(err) + } + if got := graph.Imports[filepath.Join("src", "lib.rs")]; len(got) != 0 { + t.Fatalf("non-build target inputs = %#v, want none", got) + } +} diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go index 192da7f..b302700 100644 --- a/scanner/rustgraph.go +++ b/scanner/rustgraph.go @@ -681,6 +681,10 @@ func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, w if target := resolveRustInclude(root, analysis.Path, ref.ExplicitTarget, idx); target != "" && target != analysis.Path { resolved = append(resolved, target) } + case "rust-build-input": + if target := resolveRustBuildScriptInput(analysis.Path, ref.Path, idx, workspace); target != "" && target != analysis.Path { + resolved = append(resolved, target) + } } } return dedupe(resolved) diff --git a/scanner/sg-rules/rust.yml b/scanner/sg-rules/rust.yml index 32c782e..af00e93 100644 --- a/scanner/sg-rules/rust.yml +++ b/scanner/sg-rules/rust.yml @@ -60,6 +60,14 @@ language: rust rule: pattern: include!($PATH) --- +id: rust-cargo-rerun-imports +language: rust +rule: + pattern: println!($PATH) +constraints: + PATH: + regex: 'cargo::?rerun-if-changed=' +--- id: rust-functions language: rust rule: