From c35a827c0b469814dfc9aca6784b62a8b35b2ec4 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Wed, 7 Oct 2026 16:01:40 +0530 Subject: [PATCH 1/2] Support per-dependency package identities in Parse --- README.md | 26 ++++-- internal/core/types.go | 24 +++++- internal/npm/deno.go | 19 ++++- manifests.go | 35 ++++++-- package_identity_test.go | 172 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 package_identity_test.go diff --git a/README.md b/README.md index ea7f6f2..decfd9f 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,8 @@ type Dependency struct { Integrity string // Opaque verification value, when available Direct bool // True if declared directly, false if transitive PURL string // Package URL (pkg:ecosystem/name@version) + Ecosystem string // PURL type when it differs from the file's ecosystem + NoPURL bool // Source-only entry with no known package identity RegistryURL string // Source registry URL (if non-default) Source Source // Explicit Git, path, or ecosystem source override } @@ -257,6 +259,18 @@ convert the value with knowledge of the source format. When a dependency comes from a non-default registry, the PURL includes a `repository_url` qualifier (e.g., `pkg:npm/foo@1.0.0?repository_url=https://npm.mycompany.com/`). Default registries like registry.npmjs.org, pypi.org, and rubygems.org are not included in the PURL. +A file can refer to packages from more than one ecosystem, such as `npm:` +imports in `deno.json`. `Parse` resolves each dependency's PURL in this order: + +1. `NoPURL` leaves the PURL empty. Source-only entries, such as direct download + URLs, keep their location in `Source` without an invented package identity. +2. A PURL supplied by the parser is preserved as written. +3. Otherwise the PURL is built from `Ecosystem`, or from the file's ecosystem + when `Ecosystem` is empty. Manifest PURLs omit the version. Lockfile and + supplement PURLs include it. + +`Declaration` follows the same order and always omits the version. + ### Declaration ```go @@ -265,8 +279,10 @@ type Declaration struct { Version string // Version requirement as written in the manifest Scope Scope // runtime, development, test, build, optional Direct bool // Direct rather than generated or transitive - PURL string // Versionless Package URL - Location string // Opaque parser-defined identity within the manifest + PURL string // Versionless Package URL + Ecosystem string // PURL type when it differs from the file's ecosystem + NoPURL bool // Source-only entry with no known package identity + Location string // Opaque parser-defined identity within the manifest Source Source // Explicit source override as written } ``` @@ -276,9 +292,9 @@ merging, interpolation, or other effective-model resolution. Consumers can use `Location` to match the same logical entry across edits, but should not parse its ecosystem-specific value. A declaration PURL omits the version because the raw requirement may be a range or property expression. When a parser supplies -its own PURL, `Parse` preserves it so one manifest can refer to packages from -different ecosystems. Otherwise `Parse` builds the PURL from the parser's -ecosystem. +its own PURL or `Ecosystem`, `Parse` uses it so one manifest can refer to +packages from different ecosystems. Otherwise `Parse` builds the PURL from the +parser's ecosystem. `Direct` distinguishes explicit requirements from generated or transitive entries when the source format records that distinction, such as `go.mod`. diff --git a/internal/core/types.go b/internal/core/types.go index 815d0ac..d532efe 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -53,9 +53,19 @@ type Dependency struct { Scope Scope // Integrity is an opaque verification value derived from the manifest or // lockfile. Its digest encoding is ecosystem-specific. - Integrity string - Direct bool - PURL string + Integrity string + Direct bool + // PURL identifies the package. Parse preserves a parser-supplied value and + // otherwise builds it from Ecosystem, Name and, for lockfiles and + // supplements, Version. + PURL string + // Ecosystem is the PURL type of this dependency when it differs from the + // file's ecosystem, such as an npm package imported from deno.json. Empty + // means the file's ecosystem. + Ecosystem string + // NoPURL marks a source-only entry, such as a direct download URL, whose + // package identity is unknown. Parse leaves its PURL empty. + NoPURL bool RegistryURL string // Source is set only when this dependency has an explicit source override. // It is intentionally separate from RegistryURL because paths and Git @@ -76,7 +86,13 @@ type Declaration struct { Direct bool // PURL identifies the declared package without a version. Parsers may set // it when a file can contain references from more than one ecosystem. - PURL string + PURL string + // Ecosystem is the PURL type of this declaration when it differs from the + // file's ecosystem. Empty means the file's ecosystem. + Ecosystem string + // NoPURL marks a source-only entry whose package identity is unknown. + // Parse leaves its PURL empty. + NoPURL bool Location string Source Source } diff --git a/internal/npm/deno.go b/internal/npm/deno.go index a351bcc..df3223a 100644 --- a/internal/npm/deno.go +++ b/internal/npm/deno.go @@ -34,10 +34,11 @@ func (p *denoJSONParser) Parse(filename string, content []byte) (*core.Result, e name, version := parseDenoSpec(spec) if name != "" { deps = append(deps, core.Dependency{ - Name: name, - Version: version, - Scope: core.Runtime, - Direct: true, + Name: name, + Version: version, + Scope: core.Runtime, + Direct: true, + Ecosystem: denoSpecEcosystem(spec), }) } } @@ -95,6 +96,7 @@ func (p *denoLockParser) Parse(filename string, content []byte) (*core.Result, e Scope: core.Runtime, Integrity: pkg.Integrity, Direct: false, + Ecosystem: "npm", }) } @@ -123,6 +125,15 @@ func parseDenoSpec(spec string) (name, version string) { return spec, "" } +// denoSpecEcosystem returns the package ecosystem of an npm: import, or empty +// so the dependency keeps the deno ecosystem. +func denoSpecEcosystem(spec string) string { + if strings.HasPrefix(spec, "npm:") { + return "npm" + } + return "" +} + // parseNPMSpec parses an npm spec like "chalk@5.3.0" or "chalk". func parseNPMSpec(spec string) (name, version string) { // Handle scoped packages diff --git a/manifests.go b/manifests.go index bb86b4b..627b80b 100644 --- a/manifests.go +++ b/manifests.go @@ -16,6 +16,8 @@ package manifests import ( + "cmp" + "github.com/git-pkgs/manifests/internal/core" "github.com/git-pkgs/purl" ) @@ -140,11 +142,7 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro // Generate PURLs for all dependencies for i := range res.Dependencies { - version := "" - if kind == Lockfile || kind == Supplement { - version = res.Dependencies[i].Version - } - res.Dependencies[i].PURL = makePURL(eco, res.Dependencies[i].Name, version, res.Dependencies[i].RegistryURL) + res.Dependencies[i].PURL = dependencyPURL(eco, kind, res.Dependencies[i]) } for i := range res.Declarations { res.Declarations[i].PURL = declarationPURL(eco, res.Declarations[i]) @@ -165,13 +163,34 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro }, nil } -// declarationPURL preserves a parser-supplied package identity or builds one -// from the parser's ecosystem. +// dependencyPURL preserves a parser-supplied package identity or builds one +// from the dependency's ecosystem, falling back to the parser's. Only +// lockfiles and supplements include the version. +func dependencyPURL(ecosystem string, kind Kind, dep core.Dependency) string { + if dep.NoPURL { + return "" + } + if dep.PURL != "" { + return dep.PURL + } + version := "" + if kind == Lockfile || kind == Supplement { + version = dep.Version + } + return makePURL(cmp.Or(dep.Ecosystem, ecosystem), dep.Name, version, dep.RegistryURL) +} + +// declarationPURL preserves a parser-supplied package identity or builds a +// versionless one from the declaration's ecosystem, falling back to the +// parser's. func declarationPURL(ecosystem string, declaration core.Declaration) string { + if declaration.NoPURL { + return "" + } if declaration.PURL != "" { return declaration.PURL } - return makePURL(ecosystem, declaration.Name, "", "") + return makePURL(cmp.Or(declaration.Ecosystem, ecosystem), declaration.Name, "", "") } // makePURL creates a Package URL for a dependency. diff --git a/package_identity_test.go b/package_identity_test.go new file mode 100644 index 0000000..bdd8ced --- /dev/null +++ b/package_identity_test.go @@ -0,0 +1,172 @@ +package manifests + +import ( + "os" + "path/filepath" + "testing" + + "github.com/git-pkgs/manifests/internal/core" +) + +// identityTestParser returns one entry for each way a parser can describe a +// package identity, so tests can check how Parse resolves them. +type identityTestParser struct{} + +func (identityTestParser) Parse(string, []byte) (*core.Result, error) { + jar := Source{Kind: SourceURL, Value: "https://example.com/lib.jar"} + return &core.Result{ + Dependencies: []core.Dependency{ + {Name: "org.example:fallback", Version: "1.0.0"}, + {Name: "lodash", Version: "4.17.21", Ecosystem: "npm"}, + {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool@2.0.0"}, + {Name: "lib.jar", Version: "3.0.0", NoPURL: true, Source: jar}, + {Name: "ignored", Version: "4.0.0", NoPURL: true, PURL: "pkg:generic/ignored@4.0.0"}, + }, + Declarations: []core.Declaration{ + {Name: "org.example:fallback", Version: "1.0.0", Location: "fallback"}, + {Name: "lodash", Version: "4.17.21", Ecosystem: "npm", Location: "lodash"}, + {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool", Location: "tool"}, + {Name: "lib.jar", Version: "3.0.0", NoPURL: true, Source: jar, Location: "lib.jar"}, + {Name: "ignored", Version: "4.0.0", NoPURL: true, PURL: "pkg:generic/ignored", Location: "ignored"}, + }, + }, nil +} + +func init() { + core.Register("maven", core.Manifest, identityTestParser{}, core.ExactMatch("identity-test.manifest")) + core.Register("maven", core.Lockfile, identityTestParser{}, core.ExactMatch("identity-test.lock")) +} + +func TestDependencyPackageIdentity(t *testing.T) { + // https://github.com/git-pkgs/manifests/issues/111 + tests := []struct { + filename string + want []string + }{ + {"identity-test.manifest", []string{ + "pkg:maven/org.example/fallback", + "pkg:npm/lodash", + "pkg:generic/tool@2.0.0", + "", + "", + }}, + {"identity-test.lock", []string{ + "pkg:maven/org.example/fallback@1.0.0", + "pkg:npm/lodash@4.17.21", + "pkg:generic/tool@2.0.0", + "", + "", + }}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + result, err := Parse(tt.filename, nil) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if result.Ecosystem != "maven" { + t.Errorf("Ecosystem = %q, want maven", result.Ecosystem) + } + if len(result.Dependencies) != len(tt.want) { + t.Fatalf("got %d dependencies, want %d", len(result.Dependencies), len(tt.want)) + } + for i, dep := range result.Dependencies { + if dep.PURL != tt.want[i] { + t.Errorf("%s PURL = %q, want %q", dep.Name, dep.PURL, tt.want[i]) + } + } + if got := result.Dependencies[3].Source.Value; got != "https://example.com/lib.jar" { + t.Errorf("lib.jar Source.Value = %q, want the download URL", got) + } + }) + } +} + +func TestDeclarationPackageIdentity(t *testing.T) { + // https://github.com/git-pkgs/manifests/issues/111 + want := []string{ + "pkg:maven/org.example/fallback", + "pkg:npm/lodash", + "pkg:generic/tool", + "", + "", + } + + for _, filename := range []string{"identity-test.manifest", "identity-test.lock"} { + t.Run(filename, func(t *testing.T) { + result, err := Parse(filename, nil) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if len(result.Declarations) != len(want) { + t.Fatalf("got %d declarations, want %d", len(result.Declarations), len(want)) + } + for i, declaration := range result.Declarations { + if declaration.PURL != want[i] { + t.Errorf("%s PURL = %q, want %q", declaration.Name, declaration.PURL, want[i]) + } + } + }) + } +} + +func TestManifestDependencyAndDeclarationIdentitiesMatch(t *testing.T) { + // Without a parser-supplied PURL, a manifest dependency and its declaration + // resolve to the same versionless identity. + result, err := Parse("identity-test.manifest", nil) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + for _, i := range []int{0, 1, 3, 4} { + dep, declaration := result.Dependencies[i], result.Declarations[i] + if dep.PURL != declaration.PURL { + t.Errorf("%s dependency PURL %q != declaration PURL %q", dep.Name, dep.PURL, declaration.PURL) + } + } +} + +func TestDenoNPMImportPURLs(t *testing.T) { + // https://github.com/git-pkgs/manifests/issues/111 + tests := []struct { + path string + want map[string]string + }{ + {"testdata/npm/deno.json", map[string]string{ + "chalk": "pkg:npm/chalk", + "lodash": "pkg:npm/lodash", + "@std/path": "pkg:deno/%40std%2Fpath", + "@std/fs": "pkg:deno/%40std%2Ffs", + }}, + {"testdata/npm/deno.lock", map[string]string{ + "chalk": "pkg:npm/chalk@5.3.0", + "lodash": "pkg:npm/lodash@4.17.21", + "@std/path": "pkg:deno/%40std%2Fpath@1.0.6", + "@std/fs": "pkg:deno/%40std%2Ffs@1.0.3", + }}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + content, err := os.ReadFile(tt.path) + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + result, err := Parse(filepath.Base(tt.path), content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if result.Ecosystem != "deno" { + t.Errorf("Ecosystem = %q, want deno", result.Ecosystem) + } + if len(result.Dependencies) != len(tt.want) { + t.Fatalf("got %d dependencies, want %d", len(result.Dependencies), len(tt.want)) + } + for _, dep := range result.Dependencies { + if want := tt.want[dep.Name]; dep.PURL != want { + t.Errorf("%s PURL = %q, want %q", dep.Name, dep.PURL, want) + } + } + }) + } +} From 3d7af2a2edb2d07cd5cb5ed450076e0974395e57 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Tue, 13 Oct 2026 08:31:05 +0530 Subject: [PATCH 2/2] Set Ecosystem on every parsed dependency and declaration --- README.md | 35 ++++++++++++++++----------- internal/core/types.go | 14 +++++++---- lock_capacity_test.go | 20 ++++++++-------- manifests.go | 24 +++++++++++++++---- npm_test.go | 22 ++++++++--------- package_identity_test.go | 52 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 120 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index decfd9f..3b0f125 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ type Dependency struct { Integrity string // Opaque verification value, when available Direct bool // True if declared directly, false if transitive PURL string // Package URL (pkg:ecosystem/name@version) - Ecosystem string // PURL type when it differs from the file's ecosystem + Ecosystem string // Ecosystem name, in the same form as ParseResult.Ecosystem NoPURL bool // Source-only entry with no known package identity RegistryURL string // Source registry URL (if non-default) Source Source // Explicit Git, path, or ecosystem source override @@ -260,30 +260,37 @@ convert the value with knowledge of the source format. When a dependency comes from a non-default registry, the PURL includes a `repository_url` qualifier (e.g., `pkg:npm/foo@1.0.0?repository_url=https://npm.mycompany.com/`). Default registries like registry.npmjs.org, pypi.org, and rubygems.org are not included in the PURL. A file can refer to packages from more than one ecosystem, such as `npm:` -imports in `deno.json`. `Parse` resolves each dependency's PURL in this order: +imports in `deno.json`. A parser marks those entries by setting `Ecosystem` to +an ecosystem name in the same form as `ParseResult.Ecosystem`. `Parse` sets +`Ecosystem` on every dependency, using the file's ecosystem when the parser did +not set one, so consumers never need to fall back to `ParseResult.Ecosystem`. +Entries with `NoPURL` keep an empty `Ecosystem` because they have no package +identity. + +`Parse` resolves each dependency's PURL in this order: 1. `NoPURL` leaves the PURL empty. Source-only entries, such as direct download URLs, keep their location in `Source` without an invented package identity. -2. A PURL supplied by the parser is preserved as written. -3. Otherwise the PURL is built from `Ecosystem`, or from the file's ecosystem - when `Ecosystem` is empty. Manifest PURLs omit the version. Lockfile and - supplement PURLs include it. +2. A PURL supplied by the parser is preserved as written. A parser that + supplies a PURL from another ecosystem should also set `Ecosystem`. +3. Otherwise the PURL is built from `Ecosystem`. Manifest PURLs omit the + version. Lockfile and supplement PURLs include it. -`Declaration` follows the same order and always omits the version. +`Declaration` follows the same rules and its PURL always omits the version. ### Declaration ```go type Declaration struct { - Name string // Package name - Version string // Version requirement as written in the manifest - Scope Scope // runtime, development, test, build, optional - Direct bool // Direct rather than generated or transitive + Name string // Package name + Version string // Version requirement as written in the manifest + Scope Scope // runtime, development, test, build, optional + Direct bool // Direct rather than generated or transitive PURL string // Versionless Package URL - Ecosystem string // PURL type when it differs from the file's ecosystem + Ecosystem string // Ecosystem name, in the same form as ParseResult.Ecosystem NoPURL bool // Source-only entry with no known package identity Location string // Opaque parser-defined identity within the manifest - Source Source // Explicit source override as written + Source Source // Explicit source override as written } ``` @@ -294,7 +301,7 @@ its ecosystem-specific value. A declaration PURL omits the version because the raw requirement may be a range or property expression. When a parser supplies its own PURL or `Ecosystem`, `Parse` uses it so one manifest can refer to packages from different ecosystems. Otherwise `Parse` builds the PURL from the -parser's ecosystem. +file's ecosystem and records that ecosystem in `Ecosystem`. `Direct` distinguishes explicit requirements from generated or transitive entries when the source format records that distinction, such as `go.mod`. diff --git a/internal/core/types.go b/internal/core/types.go index d532efe..fc011fa 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -59,9 +59,11 @@ type Dependency struct { // otherwise builds it from Ecosystem, Name and, for lockfiles and // supplements, Version. PURL string - // Ecosystem is the PURL type of this dependency when it differs from the - // file's ecosystem, such as an npm package imported from deno.json. Empty - // means the file's ecosystem. + // Ecosystem is the dependency's ecosystem name, in the same form as + // ParseResult.Ecosystem. A parser sets it when the package belongs to a + // different ecosystem than the file, such as an npm package imported from + // deno.json. Parse fills in the file's ecosystem otherwise and leaves it + // empty when NoPURL is set. Ecosystem string // NoPURL marks a source-only entry, such as a direct download URL, whose // package identity is unknown. Parse leaves its PURL empty. @@ -87,8 +89,10 @@ type Declaration struct { // PURL identifies the declared package without a version. Parsers may set // it when a file can contain references from more than one ecosystem. PURL string - // Ecosystem is the PURL type of this declaration when it differs from the - // file's ecosystem. Empty means the file's ecosystem. + // Ecosystem is the declaration's ecosystem name, in the same form as + // ParseResult.Ecosystem. A parser sets it when the package belongs to a + // different ecosystem than the file. Parse fills in the file's ecosystem + // otherwise and leaves it empty when NoPURL is set. Ecosystem string // NoPURL marks a source-only entry whose package identity is unknown. // Parse leaves its PURL empty. diff --git a/lock_capacity_test.go b/lock_capacity_test.go index 76f3915..4b8a8cf 100644 --- a/lock_capacity_test.go +++ b/lock_capacity_test.go @@ -16,18 +16,18 @@ func TestLockDependencyCapacity(t *testing.T) { want []Dependency }{ {"composer.lock", `{"packages":[{"name":"vendor/runtime","version":"1.2.3","dist":{"shasum":"abc","sha256":"def"}}],"packages-dev":[{"name":"vendor/dev","version":"2.0.0","dist":{"shasum":"abc"}}]}`, []Dependency{ - {Name: "vendor/runtime", Version: "1.2.3", Scope: Runtime, Integrity: "sha256-def", PURL: "pkg:composer/vendor/runtime@1.2.3"}, - {Name: "vendor/dev", Version: "2.0.0", Scope: Development, Integrity: "sha1-abc", PURL: "pkg:composer/vendor/dev@2.0.0"}, + {Name: "vendor/runtime", Version: "1.2.3", Scope: Runtime, Integrity: "sha256-def", PURL: "pkg:composer/vendor/runtime@1.2.3", Ecosystem: "composer"}, + {Name: "vendor/dev", Version: "2.0.0", Scope: Development, Integrity: "sha1-abc", PURL: "pkg:composer/vendor/dev@2.0.0", Ecosystem: "composer"}, }}, {"packages.lock.json", `{"version":1,"dependencies":{"net8.0":{"Alpha":{"type":"Direct","resolved":"1.2.3","contentHash":"abc"}},"net9.0":{"Alpha":{"type":"Direct","resolved":"1.2.3","contentHash":"abc"},"Beta":{"type":"Transitive","resolved":"2.0.0"}},"net10.0":{"Gamma":{"type":"Transitive","resolved":"3.0.0"}}}}`, []Dependency{ - {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", Direct: true, PURL: "pkg:nuget/Alpha@1.2.3"}, - {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0"}, - {Name: "Gamma", Version: "3.0.0", Scope: Runtime, PURL: "pkg:nuget/Gamma@3.0.0"}, + {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", Direct: true, PURL: "pkg:nuget/Alpha@1.2.3", Ecosystem: "nuget"}, + {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0", Ecosystem: "nuget"}, + {Name: "Gamma", Version: "3.0.0", Scope: Runtime, PURL: "pkg:nuget/Gamma@3.0.0", Ecosystem: "nuget"}, }}, {"project.assets.json", `{"targets":{"net8.0":{"Alpha/1.2.3":{"type":"package"},"App/1.0":{"type":"project"},"invalid":{"type":"package"}},"net9.0":{"Alpha/1.2.3":{"type":"package"},"Beta/2.0.0":{"type":"package"}},"net10.0":{"Gamma/3.0.0":{"type":"package"}}},"libraries":{"Alpha/1.2.3":{"sha512":"abc"}}}`, []Dependency{ - {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", PURL: "pkg:nuget/Alpha@1.2.3"}, - {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0"}, - {Name: "Gamma", Version: "3.0.0", Scope: Runtime, PURL: "pkg:nuget/Gamma@3.0.0"}, + {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", PURL: "pkg:nuget/Alpha@1.2.3", Ecosystem: "nuget"}, + {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0", Ecosystem: "nuget"}, + {Name: "Gamma", Version: "3.0.0", Scope: Runtime, PURL: "pkg:nuget/Gamma@3.0.0", Ecosystem: "nuget"}, }}, } for _, name := range []string{"example.deps.json", "Project.lock.json"} { @@ -35,8 +35,8 @@ func TestLockDependencyCapacity(t *testing.T) { name, content string want []Dependency }{name, `{"libraries":{"Alpha/1.2.3":{"type":"package","sha512":"abc"},"Beta/2.0.0":{"type":"package"},"App/1.0":{"type":"project"},"invalid":{"type":"package"}}}`, []Dependency{ - {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", PURL: "pkg:nuget/Alpha@1.2.3"}, - {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0"}, + {Name: "Alpha", Version: "1.2.3", Scope: Runtime, Integrity: "sha512-abc", PURL: "pkg:nuget/Alpha@1.2.3", Ecosystem: "nuget"}, + {Name: "Beta", Version: "2.0.0", Scope: Runtime, PURL: "pkg:nuget/Beta@2.0.0", Ecosystem: "nuget"}, }}) } for _, tt := range tests { diff --git a/manifests.go b/manifests.go index 627b80b..f422924 100644 --- a/manifests.go +++ b/manifests.go @@ -140,12 +140,16 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro res = &core.Result{} } - // Generate PURLs for all dependencies + // Resolve the ecosystem and PURL of every dependency and declaration for i := range res.Dependencies { - res.Dependencies[i].PURL = dependencyPURL(eco, kind, res.Dependencies[i]) + dep := &res.Dependencies[i] + dep.PURL = dependencyPURL(eco, kind, *dep) + dep.Ecosystem = entryEcosystem(eco, dep.Ecosystem, dep.NoPURL) } for i := range res.Declarations { - res.Declarations[i].PURL = declarationPURL(eco, res.Declarations[i]) + declaration := &res.Declarations[i] + declaration.PURL = declarationPURL(eco, *declaration) + declaration.Ecosystem = entryEcosystem(eco, declaration.Ecosystem, declaration.NoPURL) } return &ParseResult{ @@ -163,6 +167,16 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro }, nil } +// entryEcosystem returns the ecosystem a parser set on a dependency or +// declaration, falling back to the parser's. Entries without a package +// identity have no ecosystem. +func entryEcosystem(ecosystem, entry string, noPURL bool) string { + if noPURL { + return "" + } + return cmp.Or(entry, ecosystem) +} + // dependencyPURL preserves a parser-supplied package identity or builds one // from the dependency's ecosystem, falling back to the parser's. Only // lockfiles and supplements include the version. @@ -177,7 +191,7 @@ func dependencyPURL(ecosystem string, kind Kind, dep core.Dependency) string { if kind == Lockfile || kind == Supplement { version = dep.Version } - return makePURL(cmp.Or(dep.Ecosystem, ecosystem), dep.Name, version, dep.RegistryURL) + return makePURL(entryEcosystem(ecosystem, dep.Ecosystem, false), dep.Name, version, dep.RegistryURL) } // declarationPURL preserves a parser-supplied package identity or builds a @@ -190,7 +204,7 @@ func declarationPURL(ecosystem string, declaration core.Declaration) string { if declaration.PURL != "" { return declaration.PURL } - return makePURL(cmp.Or(declaration.Ecosystem, ecosystem), declaration.Name, "", "") + return makePURL(entryEcosystem(ecosystem, declaration.Ecosystem, false), declaration.Name, "", "") } // makePURL creates a Package URL for a dependency. diff --git a/npm_test.go b/npm_test.go index f2a0378..2500dc1 100644 --- a/npm_test.go +++ b/npm_test.go @@ -105,10 +105,10 @@ func TestNPMLockfileNestedDependencies(t *testing.T) { t.Fatal(err) } want := []Dependency{ - {Name: "@scope/leaf", Version: "3.0.0", Scope: Development, Integrity: "sha512-leaf", PURL: "pkg:npm/%40scope/leaf@3.0.0"}, - {Name: "parent", Version: "1.0.0", Scope: Development, PURL: "pkg:npm/parent@1.0.0"}, - {Name: "shared", Version: "2.0.0", Scope: Optional, PURL: "pkg:npm/shared@2.0.0"}, - {Name: "shared", Version: "4.0.0", Scope: Runtime, PURL: "pkg:npm/shared@4.0.0"}, + {Name: "@scope/leaf", Version: "3.0.0", Scope: Development, Integrity: "sha512-leaf", PURL: "pkg:npm/%40scope/leaf@3.0.0", Ecosystem: "npm"}, + {Name: "parent", Version: "1.0.0", Scope: Development, PURL: "pkg:npm/parent@1.0.0", Ecosystem: "npm"}, + {Name: "shared", Version: "2.0.0", Scope: Optional, PURL: "pkg:npm/shared@2.0.0", Ecosystem: "npm"}, + {Name: "shared", Version: "4.0.0", Scope: Runtime, PURL: "pkg:npm/shared@4.0.0", Ecosystem: "npm"}, } sort.Slice(result.Dependencies, func(i, j int) bool { a, b := result.Dependencies[i], result.Dependencies[j] @@ -159,9 +159,9 @@ func TestNPMLockfileLineIteration(t *testing.T) { t.Fatal(err) } want := []Dependency{ - {Name: "direct", Version: "1.0.0", Scope: Runtime, Direct: true, Integrity: integrity, PURL: "pkg:npm/direct@1.0.0"}, - {Name: "@scope/nested", Version: "2.0.0", Scope: Development, PURL: "pkg:npm/%40scope/nested@2.0.0"}, - {Name: "last", Version: "3.0.0", Scope: Optional, PURL: "pkg:npm/last@3.0.0"}, + {Name: "direct", Version: "1.0.0", Scope: Runtime, Direct: true, Integrity: integrity, PURL: "pkg:npm/direct@1.0.0", Ecosystem: "npm"}, + {Name: "@scope/nested", Version: "2.0.0", Scope: Development, PURL: "pkg:npm/%40scope/nested@2.0.0", Ecosystem: "npm"}, + {Name: "last", Version: "3.0.0", Scope: Optional, PURL: "pkg:npm/last@3.0.0", Ecosystem: "npm"}, } if !reflect.DeepEqual(result.Dependencies, want) { t.Fatal("dependency fields or order differ") @@ -281,8 +281,8 @@ func TestNPMLockfileRootEntryLast(t *testing.T) { t.Fatal(err) } want := []Dependency{ - {Name: "direct", Version: "1.0.0", Scope: Runtime, Direct: true, PURL: "pkg:npm/direct@1.0.0"}, - {Name: "transitive", Version: "2.0.0", Scope: Runtime, PURL: "pkg:npm/transitive@2.0.0"}, + {Name: "direct", Version: "1.0.0", Scope: Runtime, Direct: true, PURL: "pkg:npm/direct@1.0.0", Ecosystem: "npm"}, + {Name: "transitive", Version: "2.0.0", Scope: Runtime, PURL: "pkg:npm/transitive@2.0.0", Ecosystem: "npm"}, } if !reflect.DeepEqual(result.Dependencies, want) { t.Fatalf("got %+v, want %+v", result.Dependencies, want) @@ -302,8 +302,8 @@ func TestNPMV1LockfilePackagesDependency(t *testing.T) { } }`) want := []Dependency{ - {Name: "is-number", Version: "7.0.0", Scope: Runtime, PURL: "pkg:npm/is-number@7.0.0"}, - {Name: "packages", Version: "0.0.8", Scope: Runtime, PURL: "pkg:npm/packages@0.0.8"}, + {Name: "is-number", Version: "7.0.0", Scope: Runtime, PURL: "pkg:npm/is-number@7.0.0", Ecosystem: "npm"}, + {Name: "packages", Version: "0.0.8", Scope: Runtime, PURL: "pkg:npm/packages@0.0.8", Ecosystem: "npm"}, } for _, filename := range []string{"package-lock.json", "npm-shrinkwrap.json"} { t.Run(filename, func(t *testing.T) { diff --git a/package_identity_test.go b/package_identity_test.go index bdd8ced..2b3e213 100644 --- a/package_identity_test.go +++ b/package_identity_test.go @@ -18,14 +18,14 @@ func (identityTestParser) Parse(string, []byte) (*core.Result, error) { Dependencies: []core.Dependency{ {Name: "org.example:fallback", Version: "1.0.0"}, {Name: "lodash", Version: "4.17.21", Ecosystem: "npm"}, - {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool@2.0.0"}, + {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool@2.0.0", Ecosystem: "generic"}, {Name: "lib.jar", Version: "3.0.0", NoPURL: true, Source: jar}, {Name: "ignored", Version: "4.0.0", NoPURL: true, PURL: "pkg:generic/ignored@4.0.0"}, }, Declarations: []core.Declaration{ {Name: "org.example:fallback", Version: "1.0.0", Location: "fallback"}, {Name: "lodash", Version: "4.17.21", Ecosystem: "npm", Location: "lodash"}, - {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool", Location: "tool"}, + {Name: "tool", Version: "2.0.0", PURL: "pkg:generic/tool", Ecosystem: "generic", Location: "tool"}, {Name: "lib.jar", Version: "3.0.0", NoPURL: true, Source: jar, Location: "lib.jar"}, {Name: "ignored", Version: "4.0.0", NoPURL: true, PURL: "pkg:generic/ignored", Location: "ignored"}, }, @@ -37,6 +37,10 @@ func init() { core.Register("maven", core.Lockfile, identityTestParser{}, core.ExactMatch("identity-test.lock")) } +// identityTestEcosystems lists the ecosystem Parse resolves for each entry +// returned by identityTestParser. NoPURL entries have no ecosystem. +var identityTestEcosystems = []string{"maven", "npm", "generic", "", ""} + func TestDependencyPackageIdentity(t *testing.T) { // https://github.com/git-pkgs/manifests/issues/111 tests := []struct { @@ -75,6 +79,9 @@ func TestDependencyPackageIdentity(t *testing.T) { if dep.PURL != tt.want[i] { t.Errorf("%s PURL = %q, want %q", dep.Name, dep.PURL, tt.want[i]) } + if dep.Ecosystem != identityTestEcosystems[i] { + t.Errorf("%s Ecosystem = %q, want %q", dep.Name, dep.Ecosystem, identityTestEcosystems[i]) + } } if got := result.Dependencies[3].Source.Value; got != "https://example.com/lib.jar" { t.Errorf("lib.jar Source.Value = %q, want the download URL", got) @@ -106,6 +113,9 @@ func TestDeclarationPackageIdentity(t *testing.T) { if declaration.PURL != want[i] { t.Errorf("%s PURL = %q, want %q", declaration.Name, declaration.PURL, want[i]) } + if declaration.Ecosystem != identityTestEcosystems[i] { + t.Errorf("%s Ecosystem = %q, want %q", declaration.Name, declaration.Ecosystem, identityTestEcosystems[i]) + } } }) } @@ -123,6 +133,37 @@ func TestManifestDependencyAndDeclarationIdentitiesMatch(t *testing.T) { if dep.PURL != declaration.PURL { t.Errorf("%s dependency PURL %q != declaration PURL %q", dep.Name, dep.PURL, declaration.PURL) } + if dep.Ecosystem != declaration.Ecosystem { + t.Errorf("%s dependency Ecosystem %q != declaration Ecosystem %q", dep.Name, dep.Ecosystem, declaration.Ecosystem) + } + } +} + +func TestParseSetsFileEcosystemWhenParserDoesNotOptIn(t *testing.T) { + for _, path := range []string{"testdata/npm/package.json", "testdata/npm/package-lock.json"} { + t.Run(path, func(t *testing.T) { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + result, err := Parse(filepath.Base(path), content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if len(result.Dependencies) == 0 { + t.Fatal("expected dependencies") + } + for _, dep := range result.Dependencies { + if dep.Ecosystem != "npm" { + t.Errorf("%s Ecosystem = %q, want npm", dep.Name, dep.Ecosystem) + } + } + for _, declaration := range result.Declarations { + if declaration.Ecosystem != "npm" { + t.Errorf("%s declaration Ecosystem = %q, want npm", declaration.Name, declaration.Ecosystem) + } + } + }) } } @@ -166,6 +207,13 @@ func TestDenoNPMImportPURLs(t *testing.T) { if want := tt.want[dep.Name]; dep.PURL != want { t.Errorf("%s PURL = %q, want %q", dep.Name, dep.PURL, want) } + wantEcosystem := "deno" + if dep.Name == "chalk" || dep.Name == "lodash" { + wantEcosystem = "npm" + } + if dep.Ecosystem != wantEcosystem { + t.Errorf("%s Ecosystem = %q, want %q", dep.Name, dep.Ecosystem, wantEcosystem) + } } }) }