Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
```
Expand All @@ -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`.
Expand Down
24 changes: 20 additions & 4 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
19 changes: 15 additions & 4 deletions internal/npm/deno.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}
}
Expand Down Expand Up @@ -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",
})
}

Expand Down Expand Up @@ -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
Expand Down
35 changes: 27 additions & 8 deletions manifests.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package manifests

import (
"cmp"

"github.com/git-pkgs/manifests/internal/core"
"github.com/git-pkgs/purl"
)
Expand Down Expand Up @@ -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])
Expand All @@ -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.
Expand Down
172 changes: 172 additions & 0 deletions package_identity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading