Skip to content
4 changes: 4 additions & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ func addNodeIfMissing(g *Graph, node *Dependency) error {
if existing, ok := g.Node(node.ID); ok && existing != nil {
existing.Relationship = MergeDependencyRelationship(existing.Relationship, node.Relationship)
mergeDependencyLocations(existing, clone.Locations)
// One node is one package. Where two records of it disagree about
// where it came from, the merge settles to no origin rather than
// to whichever arrived first.
existing.Origin = ReconcileOrigin(existing.Origin, clone.Origin)
}
return nil
}
Expand Down
6 changes: 5 additions & 1 deletion dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ type Dependency struct {
Copyright string `json:"copyright,omitempty"`
FoundBy string `json:"found_by,omitempty"`
ResolvedURL string `json:"resolved_url,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
// Origin is where this dependency was resolved from, as recorded by the
// detector that read the manifest. Read it through Origin.Normalized().
Origin *PackageOrigin `json:"origin,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`

// Matched is true when the referenced package was enriched by a matcher.
Matched bool `json:"matched,omitempty"`
Expand Down Expand Up @@ -194,6 +197,7 @@ func (d *Dependency) Clone() *Dependency {
}
}
}
clone.Origin = d.Origin.Clone()
clone.Metadata = cloneAnyMap(d.Metadata)
return &clone
}
Expand Down
105 changes: 105 additions & 0 deletions fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package sdk

import (
"encoding/json"
"net/url"
"strings"
"testing"
)

Expand Down Expand Up @@ -147,3 +149,106 @@ func requireFuzzGraphValid(t *testing.T, graph *Graph) {
return true
})
}

// FuzzPackageOrigin drives the origin rule with arbitrary lockfile-derived
// strings: detectors pass raw manifest fields straight through, so whatever a
// repository can put in a lockfile reaches these constructors.
func FuzzPackageOrigin(f *testing.F) {
f.Add("https://registry.npmjs.org/react/-/react-18.2.0.tgz", "")
f.Add("https://github.com/owner/repo.git", "9f8e7d6c5b4a3928176554433221100ffeeddcc0")
f.Add("https://github.com/example/helper?rev=main#abc123", "v1.2.3")
f.Add("https://user:s3cret@nexus.corp/repo/pkg.tgz", "main")
f.Add("git+ssh://git@github.com/owner/repo.git#9f8e7d6", "9f8e7d6")
f.Add("file:///home/someone/wheels/pkg.whl", "")
f.Add("/Users/someone/src/project", "")
f.Add("http://0#0", "0")
f.Add("http://0/0#\x02", "\x02")
f.Add("%./0", "%")
f.Add("https://", "")
f.Add("https://:8080/pkg.tgz", "")
f.Add("https://registry.example.test/", "")

f.Fuzz(func(t *testing.T, rawURL, revision string) {
artifact, repository := ArtifactOrigin(rawURL), RepositoryOrigin(rawURL, revision)
assertPublishableOrigin(t, artifact)
assertPublishableOrigin(t, repository)
// Reading back what was written must reach the same conclusion, and
// reconciling a record with itself must not change it.
assertPublishableOrigin(t, repository.Normalized())
// Normalizing an already-normalized origin must be a fixed point.
if once, twice := repository.Normalized(), repository.Normalized().Normalized(); !sameOrigin(once, twice) {
t.Fatalf("normalizing twice changed the origin: %+v then %+v", once, twice)
}
if settled, again := ReconcileOrigin(repository, repository), repository.Normalized(); !sameOrigin(settled, again) {
t.Fatalf("reconciling a record with itself changed it: %+v then %+v", again, settled)
}
// A disagreement is recorded rather than resolved, whatever the inputs.
normalized := artifact.Normalized()
if other := ArtifactOrigin("https://registry.example.test/other/pkg-1.0.0.tgz"); normalized != nil && normalized.ArtifactURL != other.ArtifactURL {
if settled := ReconcileOrigin(artifact, other); !settled.Empty() {
t.Fatalf("two different origins settled on %+v", settled)
}
}
})
}

// assertPublishableOrigin fails when an origin carries anything a published
// document must never show.
func assertPublishableOrigin(t *testing.T, origin *PackageOrigin) {
t.Helper()
normalized := origin.Normalized()
if normalized == nil {
return
}
if normalized.ArtifactURL != "" && normalized.Repository != "" {
t.Fatalf("origin names two locations at once: %+v", normalized)
}
if normalized.Revision != "" && normalized.Repository == "" {
t.Fatalf("revision %q recorded without a repository", normalized.Revision)
}
for _, raw := range []string{normalized.ArtifactURL, normalized.Repository} {
if raw == "" {
continue
}
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("published URL %q does not parse: %v", raw, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
t.Fatalf("published URL %q is not a web location", raw)
}
if parsed.Hostname() == "" {
t.Fatalf("published URL %q has no host", raw)
}
if parsed.User != nil {
t.Fatalf("published URL %q carries credentials", raw)
}
if parsed.Fragment != "" {
t.Fatalf("published URL %q carries a fragment", raw)
}
if strings.Trim(parsed.Path, "/") == "" {
t.Fatalf("published URL %q names a host root, not a package", raw)
}
}
if normalized.Repository != "" {
parsed, _ := url.Parse(normalized.Repository)
if parsed.RawQuery != "" || parsed.ForceQuery {
t.Fatalf("repository %q carries a query", normalized.Repository)
}
}
if !isValidOriginRevision(normalized.Revision) && normalized.Revision != "" {
t.Fatalf("revision %q would break a locator grammar", normalized.Revision)
}
}

// sameOrigin compares two origins that may be nil.
func sameOrigin(left, right *PackageOrigin) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return *left == *right
}
}
15 changes: 11 additions & 4 deletions matcherkit/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,22 @@ func RegistryPackagesForGraph(g *sdk.Graph, reg *sdk.PackageRegistry, target *sd
continue
}
dep.PackageRef = purl
if _, ok := seen[purl]; ok {
continue
}
seen[purl] = struct{}{}

pkg, ok := reg.Get(purl)
if !ok {
pkg = reg.Add(sdk.PackageFromDependency(dep))
} else if pkg != nil {
// A package is enriched once, but every occurrence of it still
// gets a say about where it came from: two manifests resolving
// one package from different places disagree, and the package
// must not report whichever was walked first.
pkg.Origin = sdk.ReconcileOrigin(pkg.Origin, dep.Origin)
}

if _, alreadySeen := seen[purl]; alreadySeen {
continue
}
seen[purl] = struct{}{}
if pkg != nil {
out = append(out, pkg)
}
Expand Down
72 changes: 72 additions & 0 deletions matcherkit/registry_origin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package matcherkit_test

import (
"testing"

sdk "github.com/bomly-dev/bomly-sdk"
"github.com/bomly-dev/bomly-sdk/matcherkit"
)

// node builds one dependency occurrence of a package with a chosen origin.
func node(t *testing.T, id, artifactURL string) *sdk.Dependency {
t.Helper()
dep := sdk.NewDependencyWithID(id, sdk.Dependency{
Coordinates: sdk.Coordinates{
Name: "lodash", Version: "4.17.21", Ecosystem: sdk.EcosystemNPM, PURL: "pkg:npm/lodash@4.17.21",
},
})
if artifactURL != "" {
dep.Origin = sdk.ArtifactOrigin(artifactURL)
}
return dep
}

// A package is enriched once however many dependencies reference it, but every
// occurrence still gets a say about where it came from -- otherwise the
// registry publishes whichever was walked first.
func TestRegistryPackagesReconcileOriginAcrossOccurrences(t *testing.T) {
const (
public = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
private = "https://npm.corp/mirror/lodash/-/lodash-4.17.21.tgz"
)

cases := []struct {
name string
left string
right string
want string
}{
{name: "occurrences agree", left: public, right: public, want: public},
{name: "occurrences disagree", left: public, right: private},
{name: "one occurrence says nothing", left: public, right: "", want: public},
{name: "a later occurrence fills the gap", left: "", right: public, want: public},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
g := sdk.New()
for _, dep := range []*sdk.Dependency{node(t, "web:lodash", tc.left), node(t, "api:lodash", tc.right)} {
if err := g.AddNode(dep); err != nil {
t.Fatal(err)
}
}

registry := sdk.NewPackageRegistry()
packages := matcherkit.RegistryPackagesForGraph(g, registry, nil)
if len(packages) != 1 {
t.Fatalf("registry packages = %d, want 1: the package is enriched once", len(packages))
}

origin := packages[0].Origin.Normalized()
if tc.want == "" {
if origin != nil {
t.Fatalf("origin = %+v, want none", origin)
}
return
}
if origin == nil || origin.ArtifactURL != tc.want {
t.Fatalf("origin = %+v, want %q", origin, tc.want)
}
})
}
}
Loading
Loading