diff --git a/internal/detectors/cargo/detector.go b/internal/detectors/cargo/detector.go index 3c06d1f7..581bbcbb 100644 --- a/internal/detectors/cargo/detector.go +++ b/internal/detectors/cargo/detector.go @@ -231,8 +231,9 @@ func (d Detector) resolveLockWorkspace(req sdk.DetectionRequest, workingDir stri logger.Warn("cargo metadata failed for workspace lock; falling back to lockfile partitioning", zap.String("working_dir", workingDir)) } - rootManifest := parseCargoManifest(string(manifestRaw)) - members := readCargoLockMembers(workingDir, memberDirs) + workspaceVersion := parseCargoWorkspaceInheritedVersion(string(manifestRaw)) + rootManifest := applyWorkspaceVersion(parseCargoManifest(string(manifestRaw)), workspaceVersion) + members := readCargoLockMembers(workingDir, memberDirs, workspaceVersion) if len(members) == 0 { return sdk.DetectionResult{}, fmt.Errorf("cargo workspace members declared in Cargo.toml could not be read") } @@ -310,16 +311,37 @@ func metadataGraphWithMembers(raw []byte, scopeFilter sdk.Scope) (*sdk.Graph, [] // source-qualified ID -- and the resolve section's edges attach each // parent to the exact occurrence it depends on via this map. nodeIDByCargoID := make(map[string]string, len(packagesByID)) - for _, id := range sortedPackageIDs(packagesByID) { + insert := func(id string) error { pkg := packagesByID[id] node := packageNode(pkg, id, workspace) // Distinct cargo package IDs with different sources are two // resolutions of one name@version; the shared helper keeps both. surviving, err := detectors.EnsureOccurrence(g, node, strings.TrimSpace(pkg.Source)) if err != nil { - return nil, nil, err + return err } nodeIDByCargoID[id] = surviving.ID + return nil + } + // Workspace members insert first: when an external record collides with a + // member at one name@version, the project's own package keeps the plain + // node ID and the external record becomes the qualified occurrence, not + // the other way around by accident of sort order. + for _, id := range sortedWorkspaceMembers(workspace) { + if _, ok := packagesByID[id]; !ok { + continue + } + if err := insert(id); err != nil { + return nil, nil, err + } + } + for _, id := range sortedPackageIDs(packagesByID) { + if _, ok := workspace[id]; ok { + continue + } + if err := insert(id); err != nil { + return nil, nil, err + } } idFor := func(cargoID string, pkg metadataPackage) string { if nodeID, ok := nodeIDByCargoID[cargoID]; ok { @@ -477,10 +499,13 @@ type lockPackage struct { } type cargoManifest struct { - Name string - Version string - Dependencies []string - DevDependencies []string + Name string + Version string + // VersionInherited marks `version.workspace = true`: the manifest defers + // its version to the workspace root's [workspace.package] table. + VersionInherited bool + Dependencies []string + DevDependencies []string } func depGraphFromLock(lockRaw, manifestRaw []byte) (*sdk.Graph, error) { @@ -492,7 +517,10 @@ func depGraphFromLockWithScope(lockRaw, manifestRaw []byte, scopeFilter sdk.Scop if len(packages) == 0 { return nil, fmt.Errorf("cargo.lock does not contain any packages") } - manifest := parseCargoManifest(string(manifestRaw)) + // A root-only workspace carries [workspace.package] in this same manifest + // while its [package] declares `version.workspace = true`; resolve the + // inheritance here so both lock paths agree on the package's identity. + manifest := applyWorkspaceVersion(parseCargoManifest(string(manifestRaw)), parseCargoWorkspaceInheritedVersion(string(manifestRaw))) if manifest.Name == "" { return nil, fmt.Errorf("cargo.toml does not contain a package name") } @@ -510,16 +538,18 @@ func depGraphFromLockWithScope(lockRaw, manifestRaw []byte, scopeFilter sdk.Scop if err := g.AddNode(root); err != nil { return nil, fmt.Errorf("add root node: %w", err) } - index, err := buildLockIndex(g, packages, manifest.Name) + rootRecord := projectLockRecord(packages, manifest) + rootKey := qualifiedLockKey(rootRecord) + index, err := buildLockIndex(g, packages, rootRecord) if err != nil { return nil, err } rootID := root.ID for _, pkg := range packages { - if pkg.Name == manifest.Name { + if qualifiedLockKey(pkg) == rootKey { continue } - parentID, ok := index.resolve(strings.TrimSpace(pkg.Name + " " + pkg.Version + " (" + pkg.Source + ")")) + parentID, ok := index.resolve(qualifiedLockKey(pkg)) if !ok { continue } @@ -536,8 +566,19 @@ func depGraphFromLockWithScope(lockRaw, manifestRaw []byte, scopeFilter sdk.Scop } } } + // The root's own lock record names each direct dependency at whatever + // precision disambiguates it; prefer that over the manifest's bare name. + rootRefs := lockDependencyRefs(rootRecord) + resolveDirect := func(depName string) (string, bool) { + if ref, ok := rootRefs[depName]; ok { + if id, ok := index.resolve(ref); ok { + return id, true + } + } + return index.resolve(depName) + } for _, depName := range manifest.Dependencies { - nodeID, ok := index.resolve(depName) + nodeID, ok := resolveDirect(depName) if !ok || nodeID == rootID { continue } @@ -549,7 +590,7 @@ func depGraphFromLockWithScope(lockRaw, manifestRaw []byte, scopeFilter sdk.Scop } } for _, depName := range manifest.DevDependencies { - nodeID, ok := index.resolve(depName) + nodeID, ok := resolveDirect(depName) if !ok || nodeID == rootID { continue } @@ -647,9 +688,13 @@ func parseCargoLockPackages(text string) []lockPackage { if depLine == "]" { break } + // Keep the whole reference: Cargo.lock qualifies it with a + // version (and source) exactly when a bare name would be + // ambiguous, and truncating to the name resolved every + // reference to whichever same-named record came first. depLine = trimTomlString(depLine) if depLine != "" { - pkg.Dependencies = append(pkg.Dependencies, strings.Fields(depLine)[0]) + pkg.Dependencies = append(pkg.Dependencies, depLine) } } } @@ -685,7 +730,24 @@ func parseCargoManifest(text string) cargoManifest { manifest.Name = trimTomlString(value) } if key == "version" { - manifest.Version = trimTomlString(value) + // Inline-table form of workspace inheritance: + // version = { workspace = true }. + if strings.HasPrefix(value, "{") { + if strings.Contains(value, "workspace") && strings.Contains(value, "true") { + manifest.VersionInherited = true + } + } else { + manifest.Version = trimTomlString(value) + } + } + if key == "version.workspace" && trimTomlString(value) == "true" { + manifest.VersionInherited = true + } + case "package.version": + // Table form of workspace inheritance: [package.version] with + // workspace = true. + if key == "workspace" && trimTomlString(value) == "true" { + manifest.VersionInherited = true } case "dependencies": manifest.Dependencies = append(manifest.Dependencies, key) @@ -698,8 +760,32 @@ func parseCargoManifest(text string) cargoManifest { return manifest } +// trimTomlString decodes the string a TOML key's raw right-hand side names, +// tolerating an inline comment after the value ("1.2.3" # release). A basic +// or literal string is read to its closing quote (honoring \" escapes in +// basic strings, whose content is kept verbatim -- the values read here never +// carry escape sequences); a bare value is cut at the comment and trimmed. +// Trimming quotes off the whole remainder instead left the comment glued to +// the value. func trimTomlString(value string) string { - return strings.Trim(strings.TrimSpace(value), `"`) + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') { + quote := value[0] + for i := 1; i < len(value); i++ { + if value[i] == '\\' && quote == '"' { + i++ + continue + } + if value[i] == quote { + return value[1:i] + } + } + return strings.TrimPrefix(value, string(quote)) + } + if cut := strings.IndexByte(value, '#'); cut >= 0 { + value = value[:cut] + } + return strings.TrimSpace(value) } // Install prepares Cargo dependencies before graph resolution. diff --git a/internal/detectors/cargo/identity_test.go b/internal/detectors/cargo/identity_test.go new file mode 100644 index 00000000..a49b2a84 --- /dev/null +++ b/internal/detectors/cargo/identity_test.go @@ -0,0 +1,577 @@ +package cargo + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-sdk" +) + +// A workspace member and an unrelated crate can share a name. Membership used +// to be resolved by name alone, so whichever lock record was read last won: the +// member took the external record's version and ResolvedURL, and the external +// crate vanished from the graph (issue #399). Both must keep their own +// identity: the member under its manifest's version with no external source, +// and the external crate as an ordinary dependency node. +func TestCargoLockWorkspaceMemberNameCollisionKeepsBothIdentities(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "consumer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "helper 1.0.0 (git+https://github.com/external/helper?rev=main#aaaabbbbccccddddeeeeffff0000111122223333)", +] + +[[package]] +name = "demo" +version = "0.1.0" +dependencies = [ + "consumer", + "helper 0.1.0", +] + +[[package]] +name = "helper" +version = "0.1.0" + +[[package]] +name = "helper" +version = "1.0.0" +source = "git+https://github.com/external/helper?rev=main#aaaabbbbccccddddeeeeffff0000111122223333" +`) + root := cargoManifest{Name: "demo", Version: "0.1.0", Dependencies: []string{"consumer", "helper"}} + members := []cargoLockMember{{dir: "crates/helper", manifest: cargoManifest{Name: "helper", Version: "0.1.0"}}} + + graph, modules, rootID, err := depGraphFromLockWorkspace(lock, root, members, sdk.Scope("")) + if err != nil { + t.Fatalf("depGraphFromLockWorkspace() error = %v", err) + } + + member, ok := graph.Node("helper@0.1.0") + if !ok { + t.Fatalf("expected workspace member helper@0.1.0 in graph: %s", graph.PrettyString()) + } + if member.Type != sdk.PackageTypeApplication { + t.Fatalf("member type = %q, want application", member.Type) + } + if member.ResolvedURL != "" { + t.Fatalf("member ResolvedURL = %q, want empty", member.ResolvedURL) + } + if origin := originOf(member); !origin.Empty() { + t.Fatalf("member claims external origin %+v", origin) + } + if len(modules) != 1 || modules[0].rootID != member.ID { + t.Fatalf("modules = %+v, want the member module rooted at %q", modules, member.ID) + } + + external, ok := graph.Node("helper@1.0.0") + if !ok { + t.Fatalf("expected external helper@1.0.0 in graph: %s", graph.PrettyString()) + } + if external.Type == sdk.PackageTypeApplication { + t.Fatal("external crate must not be typed as an application") + } + if origin := originOf(external); origin.Repository != "https://github.com/external/helper" { + t.Fatalf("external origin = %+v, want the external repository", origin) + } + + // consumer resolved "helper 1.0.0 (git+...)": its edge must reach the + // external crate, not the member. + consumerDeps := directDependencyIDs(t, graph, "consumer@2.0.0") + if !consumerDeps[external.ID] || consumerDeps[member.ID] { + t.Fatalf("consumer dependencies = %v, want the external helper only", consumerDeps) + } + + // demo resolved "helper 0.1.0": its direct helper edge is the member. + rootDeps := directDependencyIDs(t, graph, rootID) + if !rootDeps[member.ID] { + t.Fatalf("root dependencies = %v, want the member helper", rootDeps) + } +} + +// Workspace version inheritance: a member manifest may declare no version at +// all. The member's lock record is still identifiable as the source-less +// record with its name, and a same-named external crate must not be mistaken +// for it. +func TestCargoLockWorkspaceMemberInheritedVersionResolvesOwnRecord(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "helper" +version = "0.1.0" + +[[package]] +name = "helper" +version = "1.0.0" +source = "git+https://github.com/external/helper#aaaabbbbccccddddeeeeffff0000111122223333" +`) + members := []cargoLockMember{{dir: "crates/helper", manifest: cargoManifest{Name: "helper"}}} + + graph, _, _, err := depGraphFromLockWorkspace(lock, cargoManifest{}, members, sdk.Scope("")) + if err != nil { + t.Fatalf("depGraphFromLockWorkspace() error = %v", err) + } + member, ok := graph.Node("helper@0.1.0") + if !ok { + t.Fatalf("expected member helper@0.1.0 (version from its lock record): %s", graph.PrettyString()) + } + if member.Type != sdk.PackageTypeApplication || member.ResolvedURL != "" { + t.Fatalf("member = %+v, want an application node with no external source", member) + } + if _, ok := graph.Node("helper@1.0.0"); !ok { + t.Fatalf("expected external helper@1.0.0 to stay in the graph: %s", graph.PrettyString()) + } +} + +// A member inheriting its version (`version.workspace = true`) must resolve +// the [workspace.package] version before lock-record matching: with the +// version known, a same-named source-less path dependency at another version +// cannot be claimed in its place. +func TestCargoLockWorkspaceInheritedVersionDisambiguatesSourcelessRecords(t *testing.T) { + original := cargoExecLookPath + cargoExecLookPath = func(string) (string, error) { return "", errors.New("cargo unavailable") } + t.Cleanup(func() { cargoExecLookPath = original }) + + root := t.TempDir() + write := func(rel, content string) { + t.Helper() + full := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + write("Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n") + write("crates/helper/Cargo.toml", "[package]\nname = \"helper\"\nversion.workspace = true\n") + // The lockfile also holds a source-less path dependency named helper at + // 0.1.0, declared before the member's own 1.0.0 record. + write("Cargo.lock", `version = 3 + +[[package]] +name = "helper" +version = "0.1.0" + +[[package]] +name = "helper" +version = "1.0.0" +`) + + result, err := Detector{}.ResolveGraph(context.Background(), sdk.DetectionRequest{ProjectPath: root}) + if err != nil { + t.Fatalf("ResolveGraph() error = %v", err) + } + entries := result.Graphs.Entries + if len(entries) != 1 { + t.Fatalf("expected one member entry, got %d", len(entries)) + } + graph := entries[0].Graph + member, ok := graph.Node("helper@1.0.0") + if !ok || member.Type != sdk.PackageTypeApplication { + t.Fatalf("expected member helper@1.0.0 as an application node: %s", graph.PrettyString()) + } +} + +// A member with no resolvable version and several same-named source-less lock +// records is genuinely ambiguous. No record may be claimed by file order -- +// that could hand the member another path package's identity -- so the member +// keeps its manifest identity and every lock record stays in the graph. +func TestCargoLockWorkspaceAmbiguousSourcelessRecordsClaimNothing(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "helper" +version = "0.1.0" + +[[package]] +name = "helper" +version = "0.2.0" +`) + members := []cargoLockMember{{dir: "crates/helper", manifest: cargoManifest{Name: "helper"}}} + + graph, modules, _, err := depGraphFromLockWorkspace(lock, cargoManifest{}, members, sdk.Scope("")) + if err != nil { + t.Fatalf("depGraphFromLockWorkspace() error = %v", err) + } + if len(modules) != 1 { + t.Fatalf("modules = %+v, want one member", modules) + } + member, ok := graph.Node(modules[0].rootID) + if !ok || member.Type != sdk.PackageTypeApplication || member.Version != "" { + t.Fatalf("member = %+v, want an application node under its manifest identity", member) + } + for _, id := range []string{"helper@0.1.0", "helper@0.2.0"} { + node, ok := graph.Node(id) + if !ok { + t.Fatalf("expected ambiguous record %q to stay in the graph: %s", id, graph.PrettyString()) + } + if node.Type == sdk.PackageTypeApplication { + t.Fatalf("ambiguous record %q must not be claimed as the member", id) + } + } +} + +// A root-only workspace keeps [workspace.package] in the same manifest as its +// [package]; the single-package lock path must resolve `version.workspace = +// true` from it so the application node and PURL carry the real version. +func TestCargoLockRootOnlyWorkspaceInheritsVersion(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "app" +version = "1.2.3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +`) + manifest := []byte(`[workspace] + +[workspace.package] +version = "1.2.3" + +[package] +name = "app" +version.workspace = true + +[dependencies] +serde = "1" +`) + + graph, err := depGraphFromLock(lock, manifest) + if err != nil { + t.Fatalf("depGraphFromLock() error = %v", err) + } + root, ok := graph.Node("app@1.2.3") + if !ok || !root.FirstParty { + t.Fatalf("expected first-party root app@1.2.3: %s", graph.PrettyString()) + } + if root.Version != "1.2.3" { + t.Fatalf("root version = %q, want the inherited 1.2.3", root.Version) + } + rootDeps := directDependencyIDs(t, graph, root.ID) + if !rootDeps["serde@1.0.210"] { + t.Fatalf("root dependencies = %v, want serde@1.0.210", rootDeps) + } +} + +// parseCargoManifest recognizes both spellings of workspace version +// inheritance and never records the inline table as a literal version. +func TestParseCargoManifestWorkspaceVersionInheritance(t *testing.T) { + cases := []struct { + name string + toml string + }{ + {"dotted key", "[package]\nname = \"helper\"\nversion.workspace = true\n"}, + {"inline table", "[package]\nname = \"helper\"\nversion = { workspace = true }\n"}, + {"dotted key with inline comment", "[package]\nname = \"helper\"\nversion.workspace = true # inherited\n"}, + {"section table", "[package]\nname = \"helper\"\n\n[package.version]\nworkspace = true\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + manifest := parseCargoManifest(tc.toml) + if !manifest.VersionInherited { + t.Fatal("expected VersionInherited to be set") + } + if manifest.Version != "" { + t.Fatalf("Version = %q, want empty until the workspace version is applied", manifest.Version) + } + }) + } + roots := []struct { + name string + toml string + }{ + {"section table", "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"2.5.0\"\n"}, + {"dotted key", "[workspace]\nmembers = [\"crates/*\"]\npackage.version = \"2.5.0\"\n"}, + {"inline comment after the value", "[workspace]\n\n[workspace.package]\nversion = \"2.5.0\" # release train\n"}, + {"literal string", "[workspace]\n\n[workspace.package]\nversion = '2.5.0'\n"}, + {"inline table", "[workspace]\npackage = { version = \"2.5.0\" }\n"}, + {"inline table with sibling keys", "[workspace]\npackage = { edition = \"2021\", version = \"2.5.0\", rust-version = \"1.70\" }\n"}, + } + for _, tc := range roots { + if version := parseCargoWorkspaceInheritedVersion(tc.toml); version != "2.5.0" { + t.Fatalf("parseCargoWorkspaceInheritedVersion(%s) = %q, want 2.5.0", tc.name, version) + } + } + // The same decoding applies to ordinary manifest values. + commented := parseCargoManifest("[package]\nname = \"helper\" # crate\nversion = \"1.2.3\" # release\n") + if commented.Name != "helper" || commented.Version != "1.2.3" { + t.Fatalf("manifest with inline comments = %+v, want name helper version 1.2.3", commented) + } +} + +// The cargo metadata path keys everything by cargo's source-qualified package +// IDs; a member and a same-named external crate at different versions must +// both survive with their own identity there too. +func TestCargoMetadataWorkspaceMemberNameCollisionKeepsBothIdentities(t *testing.T) { + metadata := []byte(`{ + "packages": [ + {"id": "path+file:///w/crates/helper#helper@0.1.0", "name": "helper", "version": "0.1.0", "source": null, "manifest_path": "/w/crates/helper/Cargo.toml"}, + {"id": "path+file:///w#demo@0.1.0", "name": "demo", "version": "0.1.0", "source": null, "manifest_path": "/w/Cargo.toml"}, + {"id": "git+https://github.com/external/helper?rev=main#helper@1.0.0", "name": "helper", "version": "1.0.0", "source": "git+https://github.com/external/helper?rev=main#aaaabbbbccccddddeeeeffff0000111122223333"}, + {"id": "registry+https://github.com/rust-lang/crates.io-index#consumer@2.0.0", "name": "consumer", "version": "2.0.0", "source": "registry+https://github.com/rust-lang/crates.io-index"} + ], + "workspace_members": ["path+file:///w#demo@0.1.0", "path+file:///w/crates/helper#helper@0.1.0"], + "resolve": {"nodes": [ + {"id": "path+file:///w#demo@0.1.0", "deps": [ + {"name": "consumer", "pkg": "registry+https://github.com/rust-lang/crates.io-index#consumer@2.0.0", "dep_kinds": [{"kind": null, "target": null}]}, + {"name": "helper", "pkg": "path+file:///w/crates/helper#helper@0.1.0", "dep_kinds": [{"kind": null, "target": null}]} + ]}, + {"id": "registry+https://github.com/rust-lang/crates.io-index#consumer@2.0.0", "deps": [ + {"name": "helper", "pkg": "git+https://github.com/external/helper?rev=main#helper@1.0.0", "dep_kinds": [{"kind": null, "target": null}]} + ]} + ]} + }`) + + graph, err := depGraphFromMetadata(metadata) + if err != nil { + t.Fatalf("depGraphFromMetadata() error = %v", err) + } + member, ok := graph.Node("helper@0.1.0") + if !ok { + t.Fatalf("expected member helper@0.1.0: %s", graph.PrettyString()) + } + if member.Type != sdk.PackageTypeApplication || member.ResolvedURL != "" { + t.Fatalf("member = %+v, want an application node with no external source", member) + } + external, ok := graph.Node("helper@1.0.0") + if !ok { + t.Fatalf("expected external helper@1.0.0: %s", graph.PrettyString()) + } + if origin := originOf(external); origin.Repository != "https://github.com/external/helper" { + t.Fatalf("external origin = %+v, want the external repository", origin) + } + consumerDeps := directDependencyIDs(t, graph, "consumer@2.0.0") + if !consumerDeps[external.ID] || consumerDeps[member.ID] { + t.Fatalf("consumer dependencies = %v, want the external helper only", consumerDeps) + } +} + +// When the collision is exact -- one name@version resolved both as a workspace +// member and from a git remote -- the member keeps the plain node ID and the +// external record becomes the qualified occurrence, never the other way +// around: first-party code does not surrender its identity to sort order. +func TestCargoMetadataWorkspaceMemberKeepsPlainIDOnExactCollision(t *testing.T) { + metadata := []byte(`{ + "packages": [ + {"id": "path+file:///w/crates/helper#helper@1.0.0", "name": "helper", "version": "1.0.0", "source": null, "manifest_path": "/w/crates/helper/Cargo.toml"}, + {"id": "path+file:///w#demo@0.1.0", "name": "demo", "version": "0.1.0", "source": null, "manifest_path": "/w/Cargo.toml"}, + {"id": "git+https://github.com/external/helper#helper@1.0.0", "name": "helper", "version": "1.0.0", "source": "git+https://github.com/external/helper#aaaabbbbccccddddeeeeffff0000111122223333"} + ], + "workspace_members": ["path+file:///w#demo@0.1.0", "path+file:///w/crates/helper#helper@1.0.0"], + "resolve": {"nodes": []} + }`) + + graph, err := depGraphFromMetadata(metadata) + if err != nil { + t.Fatalf("depGraphFromMetadata() error = %v", err) + } + member, ok := graph.Node("helper@1.0.0") + if !ok { + t.Fatalf("expected plain helper@1.0.0 node: %s", graph.PrettyString()) + } + if member.Type != sdk.PackageTypeApplication { + t.Fatalf("helper@1.0.0 type = %q, want the workspace member under the plain ID", member.Type) + } + if origin := originOf(member); !origin.Empty() { + t.Fatalf("member claims external origin %+v", origin) + } + var externals int + graph.WalkNodes(func(dep *sdk.Dependency) bool { + if dep.Name == "helper" && dep.ID != member.ID { + externals++ + if origin := originOf(dep); origin.Repository != "https://github.com/external/helper" { + t.Fatalf("external occurrence origin = %+v, want the external repository", origin) + } + } + return true + }) + if externals != 1 { + t.Fatalf("external helper occurrences = %d, want 1", externals) + } +} + +// The single-package lock path has the same collision: a crate that merely +// shares the root package's name must not be dropped from the graph. +func TestCargoLockRootNameCollisionKeepsExternalCrate(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "consumer", +] + +[[package]] +name = "app" +version = "2.0.0" +source = "git+https://github.com/external/app#aaaabbbbccccddddeeeeffff0000111122223333" + +[[package]] +name = "consumer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "app 2.0.0 (git+https://github.com/external/app#aaaabbbbccccddddeeeeffff0000111122223333)", +] +`) + manifest := []byte("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nconsumer = \"2\"\n") + + graph, err := depGraphFromLock(lock, manifest) + if err != nil { + t.Fatalf("depGraphFromLock() error = %v", err) + } + root, ok := graph.Node("app@0.1.0") + if !ok || !root.FirstParty { + t.Fatalf("expected first-party root app@0.1.0: %s", graph.PrettyString()) + } + if origin := originOf(root); !origin.Empty() { + t.Fatalf("root claims external origin %+v", origin) + } + external, ok := graph.Node("app@2.0.0") + if !ok { + t.Fatalf("expected external app@2.0.0 to stay in the graph: %s", graph.PrettyString()) + } + consumerDeps := directDependencyIDs(t, graph, "consumer@2.0.0") + if !consumerDeps[external.ID] || consumerDeps[root.ID] { + t.Fatalf("consumer dependencies = %v, want the external app only", consumerDeps) + } +} + +// Cargo.lock writes version-qualified dependency references whenever two +// records share a name. Those references must resolve to the exact record, +// not to whichever same-named record appears first in the file. +func TestCargoLockVersionQualifiedDependencyRefsResolveExactly(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "left", + "right", +] + +[[package]] +name = "helper" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "helper" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "left" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "helper 1.0.0", +] + +[[package]] +name = "right" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "helper 2.0.0", +] +`) + manifest := []byte("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nleft = \"1\"\nright = \"1\"\n") + + graph, err := depGraphFromLock(lock, manifest) + if err != nil { + t.Fatalf("depGraphFromLock() error = %v", err) + } + leftDeps := directDependencyIDs(t, graph, "left@1.0.0") + if !leftDeps["helper@1.0.0"] || leftDeps["helper@2.0.0"] { + t.Fatalf("left dependencies = %v, want helper@1.0.0 only", leftDeps) + } + rightDeps := directDependencyIDs(t, graph, "right@1.0.0") + if !rightDeps["helper@2.0.0"] || rightDeps["helper@1.0.0"] { + t.Fatalf("right dependencies = %v, want helper@2.0.0 only", rightDeps) + } +} + +// Cargo qualifies a dependency reference with the source identity only: +// records pin the resolved commit ("git+URL#sha"), references omit it +// ("name version (git+URL)"). Such references must still resolve to the +// exact occurrence, or the edge to a git crate silently disappears whenever +// two same-named same-versioned records force source qualification. +func TestCargoLockGitRefsWithoutPreciseFragmentResolve(t *testing.T) { + lock := []byte(`version = 3 + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "consumer", +] + +[[package]] +name = "consumer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "helper 1.0.0 (git+https://github.com/b/helper)", +] + +[[package]] +name = "helper" +version = "1.0.0" +source = "git+https://github.com/a/helper#aaaabbbbccccddddeeeeffff0000111122223333" + +[[package]] +name = "helper" +version = "1.0.0" +source = "git+https://github.com/b/helper#bbbbccccddddeeeeffff00001111222233334444" +`) + manifest := []byte("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nconsumer = \"2\"\n") + + graph, err := depGraphFromLock(lock, manifest) + if err != nil { + t.Fatalf("depGraphFromLock() error = %v", err) + } + consumerDeps := directDependencyIDs(t, graph, "consumer@2.0.0") + if len(consumerDeps) != 1 { + t.Fatalf("consumer dependencies = %v, want exactly the b-remote helper occurrence", consumerDeps) + } + for id := range consumerDeps { + child, ok := graph.Node(id) + if !ok { + t.Fatalf("consumer dependency %q missing from graph", id) + } + if origin := originOf(child); origin.Repository != "https://github.com/b/helper" { + t.Fatalf("consumer edge reached %q with origin %+v, want the b-remote occurrence", id, origin) + } + } +} + +// directDependencyIDs returns the IDs a node points at, as a set. +func directDependencyIDs(t *testing.T, g *sdk.Graph, nodeID string) map[string]bool { + t.Helper() + deps, err := g.DirectDependencies(nodeID) + if err != nil { + t.Fatalf("dependencies of %q: %v", nodeID, err) + } + out := make(map[string]bool, len(deps)) + for _, dep := range deps { + if dep != nil { + out[dep.ID] = true + } + } + return out +} diff --git a/internal/detectors/cargo/lock_index.go b/internal/detectors/cargo/lock_index.go index e308b3f4..2a305e1c 100644 --- a/internal/detectors/cargo/lock_index.go +++ b/internal/detectors/cargo/lock_index.go @@ -27,12 +27,85 @@ type lockIndex struct { nodeID map[string]string } -// buildLockIndex creates graph nodes for every lock record (skipping the root -// package) and returns the index that resolves dependency strings to them. -func buildLockIndex(g *sdk.Graph, packages []lockPackage, rootName string) (*lockIndex, error) { +// qualifiedLockKey renders a lock record in the fully qualified reference form +// Cargo.lock itself uses ("name version (source)", trimmed when the record has +// no source). Both recording and resolving go through this one rendering so a +// record always finds its own node. +func qualifiedLockKey(pkg lockPackage) string { + return strings.TrimSpace(pkg.Name + " " + pkg.Version + " (" + pkg.Source + ")") +} + +// isProjectLockRecord reports whether pkg could be the lock record of the +// project's own package described by manifest. Matching by name alone +// conflated members with unrelated same-named crates (issue #399); the +// project's own records are path-local, so they never carry a source, and +// they must match the manifest's declared version. Workspace version +// inheritance can leave the manifest without a version -- callers resolve the +// inherited [workspace.package] version first where the root manifest is +// available, and projectLockRecord refuses to guess between candidates that +// remain ambiguous without one. +func isProjectLockRecord(pkg lockPackage, manifest cargoManifest) bool { + if manifest.Name == "" || pkg.Name != manifest.Name { + return false + } + if strings.TrimSpace(pkg.Source) != "" { + return false + } + return manifest.Version == "" || pkg.Version == manifest.Version +} + +// projectLockRecord returns the lock record owned by the project package +// described by manifest, or a record synthesized from the manifest when the +// lockfile holds none. When the manifest declares no version and several +// source-less records share its name at different versions, no candidate is +// claimed: guessing by file order could hand a member another path package's +// identity, and leaving every record in the graph is the recoverable error. +func projectLockRecord(packages []lockPackage, manifest cargoManifest) lockPackage { + matched := false + var record lockPackage + for _, pkg := range packages { + if !isProjectLockRecord(pkg, manifest) { + continue + } + if !matched { + record, matched = pkg, true + continue + } + if pkg.Version != record.Version { + return lockPackage{Name: manifest.Name, Version: manifest.Version} + } + } + if matched { + return record + } + return lockPackage{Name: manifest.Name, Version: manifest.Version} +} + +// lockDependencyRefs indexes a lock record's dependency reference strings by +// crate name, so a manifest's bare dependency name resolves at the precision +// the lockfile wrote -- "name", "name version", or "name version (source)". +func lockDependencyRefs(pkg lockPackage) map[string]string { + refs := make(map[string]string, len(pkg.Dependencies)) + for _, ref := range pkg.Dependencies { + fields := strings.Fields(ref) + if len(fields) == 0 { + continue + } + if _, ok := refs[fields[0]]; !ok { + refs[fields[0]] = ref + } + } + return refs +} + +// buildLockIndex creates graph nodes for every lock record except the root +// package's claimed record, and returns the index that resolves dependency +// strings to them. +func buildLockIndex(g *sdk.Graph, packages []lockPackage, rootRecord lockPackage) (*lockIndex, error) { index := &lockIndex{nodeID: make(map[string]string, len(packages)*3)} + rootKey := qualifiedLockKey(rootRecord) for _, pkg := range packages { - if pkg.Name == rootName { + if qualifiedLockKey(pkg) == rootKey { continue } node := packageNode(metadataPackage{Name: pkg.Name, Version: pkg.Version, Source: pkg.Source}, pkg.Name+"@"+pkg.Version, nil) @@ -45,14 +118,36 @@ func buildLockIndex(g *sdk.Graph, packages []lockPackage, rootName string) (*loc return index, nil } +// sourceWithoutPrecise strips the resolved-commit fragment from a git source. +// A record's source pins the commit ("git+URL#"), but Cargo qualifies +// dependency references with the source identity only ("name version +// (git+URL)") -- the fragment is not part of it. +func sourceWithoutPrecise(source string) string { + source = strings.TrimSpace(source) + if !strings.HasPrefix(source, "git+") { + return source + } + if cut := strings.IndexByte(source, '#'); cut >= 0 { + return source[:cut] + } + return source +} + // record registers every reference form that can name this occurrence, // first-wins so bare forms stay deterministic in file order. func (x *lockIndex) record(pkg lockPackage, nodeID string) { - for _, key := range []string{ + keys := []string{ pkg.Name, pkg.Name + " " + pkg.Version, - strings.TrimSpace(pkg.Name + " " + pkg.Version + " (" + pkg.Source + ")"), - } { + qualifiedLockKey(pkg), + } + // Dependency references qualify git sources without the precise commit + // fragment; register that rendering too, or references to same-named + // same-versioned git crates would resolve to nothing. + if stripped := sourceWithoutPrecise(pkg.Source); stripped != strings.TrimSpace(pkg.Source) { + keys = append(keys, qualifiedLockKey(lockPackage{Name: pkg.Name, Version: pkg.Version, Source: stripped})) + } + for _, key := range keys { if _, taken := x.nodeID[key]; !taken { x.nodeID[key] = nodeID } diff --git a/internal/detectors/cargo/origin_test.go b/internal/detectors/cargo/origin_test.go index a03e8978..20d01fb3 100644 --- a/internal/detectors/cargo/origin_test.go +++ b/internal/detectors/cargo/origin_test.go @@ -183,12 +183,9 @@ func TestCargoDuplicateCrateSameSourceKeepsOrigin(t *testing.T) { // credited to it, or the SBOM reports first-party code as coming from someone // else's repository. // -// This covers origin only. The same name collision also makes the member take -// the external record's version and ResolvedURL, and drops the external crate -// from the graph, because workspace membership is resolved by name alone. That -// is a separate identity defect, older than package origin and not fixed here; -// this test deliberately asserts nothing about it rather than pinning the -// current wrong shape as expected. +// This covers origin only; the identity half of the collision (the member's +// version, its ResolvedURL, and the external crate's own node) is covered by +// the tests in identity_test.go. func TestCargoWorkspaceMemberTakesNoExternalOrigin(t *testing.T) { lock := []byte(`version = 3 diff --git a/internal/detectors/cargo/parser_fuzz_test.go b/internal/detectors/cargo/parser_fuzz_test.go index 7a525122..a71c40be 100644 --- a/internal/detectors/cargo/parser_fuzz_test.go +++ b/internal/detectors/cargo/parser_fuzz_test.go @@ -7,6 +7,50 @@ import ( testutil "github.com/bomly-dev/bomly-sdk/testkit" ) +// FuzzDepGraphFromCargoLockWorkspace drives the workspace lock path end to +// end: the workspace manifest parsers (member patterns, [workspace.package] +// version inheritance), the member manifest parser, and the workspace graph +// builder — all of which consume untrusted repository files. +func FuzzDepGraphFromCargoLockWorkspace(f *testing.F) { + f.Add( + []byte("[[package]]\nname = \"helper\"\nversion = \"1.0.0\"\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.210\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"), + []byte("[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n"), + []byte("[package]\nname = \"helper\"\nversion.workspace = true\n\n[dependencies]\nserde = \"1\"\n"), + ) + f.Add( + []byte("[[package]]\nname = \"helper\"\nversion = \"0.1.0\"\n"), + []byte("[workspace]\nmembers = [\"crates/helper\"]\n"), + []byte("[package]\nname = \"helper\"\nversion = { workspace = true }\n"), + ) + f.Add([]byte("[[package]\n"), []byte("[workspace\nmembers = [\""), []byte("[package\nversion.works")) + f.Fuzz(func(t *testing.T, lockRaw, rootRaw, memberRaw []byte) { + if len(lockRaw)+len(rootRaw)+len(memberRaw) > testutil.MaxFuzzInputSize { + return + } + parse := func() (*sdk.Graph, error) { + workspaceVersion := parseCargoWorkspaceInheritedVersion(string(rootRaw)) + rootManifest := applyWorkspaceVersion(parseCargoManifest(string(rootRaw)), workspaceVersion) + member := cargoLockMember{ + dir: "crates/member", + manifest: applyWorkspaceVersion(parseCargoManifest(string(memberRaw)), workspaceVersion), + } + graph, _, _, err := depGraphFromLockWorkspace(lockRaw, rootManifest, []cargoLockMember{member}, sdk.Scope("")) + return graph, err + } + graph, err := parse() + if err == nil { + testutil.RequireFuzzGraphValid(t, graph) + } + if _, again := parse(); (err == nil) != (again == nil) { + t.Fatalf("parse determinism: first error = %v, second error = %v", err, again) + } + if first, second := parseCargoWorkspaceInheritedVersion(string(rootRaw)), parseCargoWorkspaceInheritedVersion(string(rootRaw)); first != second { + t.Fatalf("workspace version determinism: %q then %q", first, second) + } + _ = parseCargoWorkspaceMembers(string(rootRaw)) + }) +} + func FuzzDepGraphFromCargoLock(f *testing.F) { f.Add( []byte("[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n"), diff --git a/internal/detectors/cargo/workspace.go b/internal/detectors/cargo/workspace.go index 9fdbd9aa..568208ba 100644 --- a/internal/detectors/cargo/workspace.go +++ b/internal/detectors/cargo/workspace.go @@ -83,6 +83,70 @@ func parseCargoWorkspaceMembers(text string) []string { return members } +// parseCargoWorkspaceInheritedVersion extracts the [workspace.package] version +// that members inherit via `version.workspace = true`. TOML spells that table +// three ways -- a [workspace.package] section, a dotted key inside [workspace] +// (`package.version = "1.2.3"`), and an inline table +// (`package = { version = "1.2.3" }`) -- and all are read. Empty when the +// root manifest declares none. +func parseCargoWorkspaceInheritedVersion(text string) string { + section := "" + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(strings.Trim(line, "[]")) + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if (section == "workspace.package" && key == "version") || + (section == "workspace" && key == "package.version") { + return trimTomlString(value) + } + if section == "workspace" && key == "package" && strings.HasPrefix(value, "{") { + if version := inlineTableValue(value, "version"); version != "" { + return version + } + } + } + return "" +} + +// inlineTableValue reads one key's string value out of a TOML inline-table +// rendering ("{ version = \"1.2.3\", edition = \"2021\" }"). Keys match +// exactly, so "version" never reads a "rust-version" entry. +func inlineTableValue(table, key string) string { + table = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(table), "{")) + if end := strings.LastIndexByte(table, '}'); end >= 0 { + table = table[:end] + } + for _, fragment := range strings.Split(table, ",") { + k, v, ok := strings.Cut(fragment, "=") + if ok && strings.TrimSpace(k) == key { + return trimTomlString(strings.TrimSpace(v)) + } + } + return "" +} + +// applyWorkspaceVersion fills a manifest's version from the workspace root's +// [workspace.package] version when the manifest inherits it. Resolving the +// inherited version before lock-record matching keeps a member from being +// mistaken for a same-named source-less package at another version. +func applyWorkspaceVersion(manifest cargoManifest, workspaceVersion string) cargoManifest { + if manifest.Version == "" && manifest.VersionInherited && workspaceVersion != "" { + manifest.Version = workspaceVersion + } + return manifest +} + // expandCargoWorkspaceMemberDirs expands member patterns (exact dirs or // globs like "crates/*") against the workspace root, keeping directories // that contain a Cargo.toml. Returned paths are root-relative slash paths, @@ -160,19 +224,6 @@ func depGraphFromLockWorkspace(lockRaw []byte, rootManifest cargoManifest, membe if len(packages) == 0 { return nil, nil, "", fmt.Errorf("cargo.lock does not contain any packages") } - byName := make(map[string]lockPackage, len(packages)) - for _, pkg := range packages { - byName[pkg.Name] = pkg - } - applicationNames := map[string]struct{}{} - if rootManifest.Name != "" { - applicationNames[rootManifest.Name] = struct{}{} - } - for _, member := range members { - if member.manifest.Name != "" { - applicationNames[member.manifest.Name] = struct{}{} - } - } g := sdk.New() nodeFor := func(pkg lockPackage, application bool) *sdk.Dependency { @@ -198,37 +249,61 @@ func depGraphFromLockWorkspace(lockRaw []byte, rootManifest cargoManifest, membe } return node } - lockPackageFor := func(manifest cargoManifest) lockPackage { - if pkg, ok := byName[manifest.Name]; ok && pkg.Version != "" { - return pkg + // Each application root claims its own lock record -- matched by name, + // declared version, and the absence of a source (isProjectLockRecord) -- + // rather than by name alone, which credited a member with an unrelated + // same-named crate's version and source and dropped that crate from the + // graph entirely (issue #399). Only the claimed records are withheld from + // the ordinary dependency pass below; a same-named external crate keeps + // its own node. + type applicationRoot struct { + manifest cargoManifest + record lockPackage + id string + } + claimed := map[string]struct{}{} + applicationRefs := map[string]string{} + roots := make([]applicationRoot, 0, len(members)+1) + addRoot := func(manifest cargoManifest) (string, error) { + record := projectLockRecord(packages, manifest) + node := nodeFor(record, true) + if err := addNodeIfMissing(g, node); err != nil { + return "", err + } + claimed[qualifiedLockKey(record)] = struct{}{} + // Other lock records reference a member as "name" or "name version"; + // first-wins keeps resolution deterministic in declaration order. + for _, ref := range []string{record.Name, strings.TrimSpace(record.Name + " " + record.Version)} { + if _, taken := applicationRefs[ref]; !taken { + applicationRefs[ref] = node.ID + } } - return lockPackage{Name: manifest.Name, Version: manifest.Version} + roots = append(roots, applicationRoot{manifest: manifest, record: record, id: node.ID}) + return node.ID, nil } rootID := "" if rootManifest.Name != "" { - root := nodeFor(lockPackageFor(rootManifest), true) - if err := addNodeIfMissing(g, root); err != nil { + id, err := addRoot(rootManifest) + if err != nil { return nil, nil, "", err } - rootID = root.ID + rootID = id } modules := make([]cargoModuleGraph, 0, len(members)) - memberIDs := map[string]string{} for _, member := range members { if member.manifest.Name == "" { continue } - memberNode := nodeFor(lockPackageFor(member.manifest), true) - if err := addNodeIfMissing(g, memberNode); err != nil { + id, err := addRoot(member.manifest) + if err != nil { return nil, nil, "", err } - memberIDs[member.manifest.Name] = memberNode.ID - modules = append(modules, cargoModuleGraph{dir: member.dir, rootID: memberNode.ID}) + modules = append(modules, cargoModuleGraph{dir: member.dir, rootID: id}) } index := &lockIndex{nodeID: make(map[string]string, len(packages)*3)} for _, pkg := range packages { - if _, ok := applicationNames[pkg.Name]; ok { + if _, ok := claimed[qualifiedLockKey(pkg)]; ok { continue } node := nodeFor(pkg, false) @@ -239,30 +314,25 @@ func depGraphFromLockWorkspace(lockRaw []byte, rootManifest cargoManifest, membe index.record(pkg, surviving.ID) } - idFor := func(name string) (string, bool) { - if id, ok := memberIDs[name]; ok { + idFor := func(ref string) (string, bool) { + ref = strings.TrimSpace(ref) + if id, ok := applicationRefs[ref]; ok { return id, true } - if rootManifest.Name != "" && name == rootManifest.Name && rootID != "" { - return rootID, true - } - return index.resolve(name) + return index.resolve(ref) } // Transitive edges from the lockfile for non-application packages. for _, pkg := range packages { - if _, ok := applicationNames[pkg.Name]; ok { + if _, ok := claimed[qualifiedLockKey(pkg)]; ok { continue } - parentID, ok := idFor(strings.TrimSpace(pkg.Name + " " + pkg.Version + " (" + pkg.Source + ")")) - if !ok { - parentID, ok = idFor(pkg.Name) - } + parentID, ok := index.resolve(qualifiedLockKey(pkg)) if !ok { continue } - for _, depName := range pkg.Dependencies { - childID, ok := idFor(depName) + for _, depRef := range pkg.Dependencies { + childID, ok := idFor(depRef) if !ok || childID == rootID { continue } @@ -272,39 +342,40 @@ func depGraphFromLockWorkspace(lockRaw []byte, rootManifest cargoManifest, membe } } - // Direct edges + scopes for application roots from their manifests. - applyManifestEdges := func(parentID string, manifest cargoManifest) error { + // Direct edges + scopes for application roots from their manifests. A + // root's own lock record names each dependency at whatever precision + // disambiguates it; prefer that over the manifest's bare name. + applyManifestEdges := func(root applicationRoot) error { + refs := lockDependencyRefs(root.record) addDirect := func(names []string, scope sdk.Scope) error { for _, depName := range names { - childID, ok := idFor(depName) - if !ok || childID == parentID { + ref := depName + if qualified, ok := refs[depName]; ok { + ref = qualified + } + childID, ok := idFor(ref) + if !ok && ref != depName { + childID, ok = idFor(depName) + } + if !ok || childID == root.id { continue } if existing, ok := g.Node(childID); ok { existing.AddScope(scope) } - if err := g.AddEdge(parentID, childID); err != nil { - return fmt.Errorf("add Cargo direct dependency %q -> %q: %w", parentID, childID, err) + if err := g.AddEdge(root.id, childID); err != nil { + return fmt.Errorf("add Cargo direct dependency %q -> %q: %w", root.id, childID, err) } } return nil } - if err := addDirect(manifest.Dependencies, sdk.ScopeRuntime); err != nil { + if err := addDirect(root.manifest.Dependencies, sdk.ScopeRuntime); err != nil { return err } - return addDirect(manifest.DevDependencies, sdk.ScopeDevelopment) - } - if rootID != "" { - if err := applyManifestEdges(rootID, rootManifest); err != nil { - return nil, nil, "", err - } + return addDirect(root.manifest.DevDependencies, sdk.ScopeDevelopment) } - for _, member := range members { - memberID, ok := memberIDs[member.manifest.Name] - if !ok { - continue - } - if err := applyManifestEdges(memberID, member.manifest); err != nil { + for _, root := range roots { + if err := applyManifestEdges(root); err != nil { return nil, nil, "", err } } @@ -318,15 +389,16 @@ func depGraphFromLockWorkspace(lockRaw []byte, rootManifest cargoManifest, membe } // readCargoLockMembers parses each member directory's Cargo.toml, skipping -// unreadable or package-less members. -func readCargoLockMembers(workingDir string, memberDirs []string) []cargoLockMember { +// unreadable or package-less members. workspaceVersion is the root manifest's +// [workspace.package] version, filled into members that inherit it. +func readCargoLockMembers(workingDir string, memberDirs []string, workspaceVersion string) []cargoLockMember { members := make([]cargoLockMember, 0, len(memberDirs)) for _, dir := range memberDirs { raw, err := system.ReadRepositoryFile(filepath.Join(workingDir, filepath.FromSlash(dir), "Cargo.toml")) if err != nil { continue } - manifest := parseCargoManifest(string(raw)) + manifest := applyWorkspaceVersion(parseCargoManifest(string(raw)), workspaceVersion) if manifest.Name == "" { continue } diff --git a/scripts/run-fuzz.sh b/scripts/run-fuzz.sh index 36bf6147..f381e6de 100755 --- a/scripts/run-fuzz.sh +++ b/scripts/run-fuzz.sh @@ -9,6 +9,7 @@ FUZZTIME="${FUZZTIME:-60s}" targets=( "github.com/bomly-dev/bomly-cli/internal/config FuzzLoadFile" "github.com/bomly-dev/bomly-cli/internal/detectors/cargo FuzzDepGraphFromCargoLock" + "github.com/bomly-dev/bomly-cli/internal/detectors/cargo FuzzDepGraphFromCargoLockWorkspace" "github.com/bomly-dev/bomly-cli/internal/detectors/cocoapods FuzzDepGraphFromPodfileLock" "github.com/bomly-dev/bomly-cli/internal/detectors/composer FuzzDepGraphFromComposerLock" "github.com/bomly-dev/bomly-cli/internal/detectors/conan FuzzDepGraphFromConanJSON"