From c35a827c0b469814dfc9aca6784b62a8b35b2ec4 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Wed, 7 Oct 2026 16:01:40 +0530 Subject: [PATCH] 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) + } + } + }) + } +}