From 66909298bb2b1c59ab54b9d7c90ebf3b2b0a501f Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:02:45 +0200 Subject: [PATCH] fix(scanner): Track Rust embedded file dependencies Resolve literal include_str! and include_bytes! targets through the configured exact-file index while preserving conservative include! behavior. Co-Authored-By: GPT-5.6 Sol --- scanner/astgrep.go | 8 ++++- scanner/rustgraph.go | 24 +++++++++++-- scanner/rustinclude_test.go | 70 ++++++++++++++++++++++++++++--------- scanner/sg-rules/rust.yml | 7 ++++ 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/scanner/astgrep.go b/scanner/astgrep.go index b5ef71a..58052b9 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" || m.RuleID == "rust-cargo-rerun-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-embedded-file-imports" || m.RuleID == "rust-cargo-rerun-imports" { var path string var explicitTarget string kind := "rust-path" @@ -471,6 +471,12 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F explicitTarget = pathVar.Text path, _ = parseRustStringLiteral(explicitTarget) } + case "rust-embedded-file-imports": + kind = "rust-embedded-file" + if pathVar, ok := m.MetaVariables.Single["PATH"]; ok { + explicitTarget = pathVar.Text + path, _ = parseRustStringLiteral(explicitTarget) + } } if path != "" { if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-cargo-rerun-imports" { diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go index b302700..a292bf2 100644 --- a/scanner/rustgraph.go +++ b/scanner/rustgraph.go @@ -485,7 +485,7 @@ func resolveRustModuleDeclaration(root string, parent rustModuleLocation, declar return resolved, filepath.Clean(filepath.Join(parent.childBase, declaration.name)) } -func resolveRustExplicitModule(root, declaringFile, literal string) string { +func resolveRustLiteralPath(root, declaringFile, literal string) string { value, ok := parseRustStringLiteral(literal) if !ok || value == "" { return "" @@ -495,12 +495,20 @@ func resolveRustExplicitModule(root, declaringFile, literal string) string { path = filepath.Join(root, filepath.Dir(declaringFile), path) } rel, ok := projectRelativePath(root, path) - if !ok || !strings.EqualFold(filepath.Ext(rel), ".rs") { + if !ok { return "" } return filepath.Clean(rel) } +func resolveRustExplicitModule(root, declaringFile, literal string) string { + target := resolveRustLiteralPath(root, declaringFile, literal) + if !strings.EqualFold(filepath.Ext(target), ".rs") { + return "" + } + return target +} + // resolveRustInclude resolves include!(...) relative to the declaring file. // byExact also indexes files under their extension-stripped key, so accept // only when the target itself is indexed exactly once. @@ -521,6 +529,14 @@ func resolveRustInclude(root, declaringFile, literal string, idx *fileIndex) str return target } +func resolveRustEmbeddedFile(root, declaringFile, literal string, idx *fileIndex) string { + target := resolveRustLiteralPath(root, declaringFile, literal) + if target == "" || len(idx.byExact[target]) != 1 { + return "" + } + return target +} + func parseRustStringLiteral(literal string) (string, bool) { literal = strings.TrimSpace(literal) if strings.HasPrefix(literal, "r") { @@ -681,6 +697,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-embedded-file": + if target := resolveRustEmbeddedFile(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) diff --git a/scanner/rustinclude_test.go b/scanner/rustinclude_test.go index ce2f36a..4fb15fd 100644 --- a/scanner/rustinclude_test.go +++ b/scanner/rustinclude_test.go @@ -10,12 +10,12 @@ import ( ) func TestAstGrepRustLiteralIncludeExtraction(t *testing.T) { - astScanner, err := NewAstGrepScanner() + scanner, err := NewAstGrepScanner() if err != nil { t.Fatal(err) } - t.Cleanup(astScanner.Close) - if !astScanner.Available() { + t.Cleanup(scanner.Close) + if !scanner.Available() { t.Skip("ast-grep not available") } @@ -25,11 +25,17 @@ include!(r#"nested/raw.rs"#); const VALUE: i32 = include!("nested/value.rs"); include!(concat!("generated", ".rs")); include_str!("data.txt"); -include_bytes!("data.bin"); +include_bytes!(r#"assets/data.bin"#); +include_str!(concat!("data", ".txt")); +include_bytes!(env!("DATA_BIN")); ` if err := os.WriteFile(filepath.Join(root, "lib.rs"), []byte(source), 0o644); err != nil { t.Fatal(err) } + astScanner, err := NewAstGrepScanner() + if err != nil { + t.Fatal(err) + } outcome, err := astScanner.ScanDirectory(context.Background(), root) if err != nil { t.Fatal(err) @@ -40,7 +46,7 @@ include_bytes!("data.bin"); for _, analysis := range outcome.Analyses { imports = append(imports, analysis.Imports...) for _, ref := range analysis.References { - if ref.Kind == "rust-include" { + if ref.Kind == "rust-include" || ref.Kind == "rust-embedded-file" { ref.Line = 0 got = append(got, ref) } @@ -48,6 +54,8 @@ include_bytes!("data.bin"); } sort.Slice(got, func(i, j int) bool { return got[i].Path < got[j].Path }) want := []ImportReference{ + {Path: "assets/data.bin", Kind: "rust-embedded-file", ExplicitTarget: `r#"assets/data.bin"#`}, + {Path: "data.txt", Kind: "rust-embedded-file", ExplicitTarget: `"data.txt"`}, {Path: "generated.rs", Kind: "rust-include", ExplicitTarget: `"generated.rs"`}, {Path: "nested/raw.rs", Kind: "rust-include", ExplicitTarget: `r#"nested/raw.rs"#`}, {Path: "nested/value.rs", Kind: "rust-include", ExplicitTarget: `"nested/value.rs"`}, @@ -56,7 +64,7 @@ include_bytes!("data.bin"); t.Fatalf("literal Rust include references = %#v, want %#v", got, want) } sort.Strings(imports) - if want := []string{"generated.rs", "nested/raw.rs", "nested/value.rs"}; !reflect.DeepEqual(imports, want) { + if want := []string{"assets/data.bin", "data.txt", "generated.rs", "nested/raw.rs", "nested/value.rs"}; !reflect.DeepEqual(imports, want) { t.Fatalf("literal Rust include imports = %#v, want %#v", imports, want) } } @@ -93,17 +101,45 @@ func TestRustLiteralIncludesResolveConservatively(t *testing.T) { } } -func TestResolveRustIncludeRequiresRealIndexedFile(t *testing.T) { +func TestRustEmbeddedFilesResolveConservatively(t *testing.T) { root := t.TempDir() - // bindings.rs.in is indexed under the bindings.rs key though no such - // file exists; the include must stay unresolved. - idx := buildFileIndex([]FileInfo{{Path: "src/bindings.rs.in"}, {Path: "src/main.rs"}}, "") - if target := resolveRustInclude(root, "src/main.rs", `"bindings.rs"`, idx); target != "" { - t.Fatalf("phantom include target = %q, want unresolved", target) - } - // With both files present, the edge to the real file survives. - idx = buildFileIndex([]FileInfo{{Path: "src/bindings.rs"}, {Path: "src/bindings.rs.in"}, {Path: "src/main.rs"}}, "") - if target := resolveRustInclude(root, "src/main.rs", `"bindings.rs"`, idx); target != "src/bindings.rs" { - t.Fatalf("include target = %q, want src/bindings.rs", target) + writeRustCargoFixture(t, root, map[string]string{ + ".codemap/config.json": `{"only":["rs","txt","bin"],"exclude":["assets/excluded.bin"]}`, + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn root() {}\n", + "src/data.txt": "embedded text\n", + "assets/data.bin": "embedded bytes\n", + "assets/excluded.bin": "excluded bytes\n", + }) + analyses := []FileAnalysis{{ + Path: "src/lib.rs", Language: "rust", References: []ImportReference{ + {Path: "data.txt", Kind: "rust-embedded-file", ExplicitTarget: `"data.txt"`}, + {Path: "../assets/data.bin", Kind: "rust-embedded-file", ExplicitTarget: `r#"../assets/data.bin"#`}, + {Path: "../assets/excluded.bin", Kind: "rust-embedded-file", ExplicitTarget: `"../assets/excluded.bin"`}, + {Path: "lib.rs", Kind: "rust-embedded-file", ExplicitTarget: `"lib.rs"`}, + {Path: "missing.txt", Kind: "rust-embedded-file", ExplicitTarget: `"missing.txt"`}, + {Path: "../../outside.txt", Kind: "rust-embedded-file", ExplicitTarget: `"../../outside.txt"`}, + {Path: "dynamic", Kind: "rust-embedded-file", ExplicitTarget: `concat!("data", ".txt")`}, + }, + }} + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, nil) + if err != nil { + t.Fatal(err) + } + want := []string{"assets/data.bin", "src/data.txt"} + if got := sortedImports(graph, "src/lib.rs"); !reflect.DeepEqual(got, want) { + t.Fatalf("embedded Rust file imports = %#v, want %#v", got, want) + } +} + +func TestResolveRustIncludeRequiresOneIndexedFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join("src", "generated.rs") + idx := buildFileIndex([]FileInfo{{Path: path}, {Path: path}}, "") + if target := resolveRustInclude(root, "src/lib.rs", `"generated.rs"`, idx); target != "" { + t.Fatalf("multiply indexed include target = %q, want unresolved", target) + } + if target := resolveRustEmbeddedFile(root, "src/lib.rs", `"generated.rs"`, idx); target != "" { + t.Fatalf("multiply indexed embedded-file target = %q, want unresolved", target) } } diff --git a/scanner/sg-rules/rust.yml b/scanner/sg-rules/rust.yml index af00e93..c2d9a67 100644 --- a/scanner/sg-rules/rust.yml +++ b/scanner/sg-rules/rust.yml @@ -60,6 +60,13 @@ language: rust rule: pattern: include!($PATH) --- +id: rust-embedded-file-imports +language: rust +rule: + any: + - pattern: include_str!($PATH) + - pattern: include_bytes!($PATH) +--- id: rust-cargo-rerun-imports language: rust rule: