From 640bebcba75006e728e965ea0de16f04ee9365b7 Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 5 Nov 2025 14:59:29 -0800 Subject: [PATCH 01/13] alpine: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- alpine/purl.go | 82 ++++++++++++++++++++++++++++++++++++++++++ alpine/purl_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 alpine/purl.go create mode 100644 alpine/purl_test.go diff --git a/alpine/purl.go b/alpine/purl.go new file mode 100644 index 000000000..527a57221 --- /dev/null +++ b/alpine/purl.go @@ -0,0 +1,82 @@ +package alpine + +import ( + "context" + "strconv" + "strings" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Alpine APKs. + PURLType = "apk" + // PURLNamespace is the namespace of Alpine APKs. + PURLNamespace = "alpine" + // PURLDistroQualifier is the qualifier key for the distribution. + PURLDistroQualifier = "distro" +) + +// GeneratePURL generates a PURL for an Alpine APK package in the format: +// pkg:apk/alpine/@?arch=&distro=- +func GeneratePURL(ctx context.Context, r *claircore.IndexRecord) (packageurl.PackageURL, error) { + var distro string + if r.Distribution != nil { + distro = r.Distribution.Name + "-" + r.Distribution.Version + } + return packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: r.Package.Name, + Version: r.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": r.Package.Arch, + PURLDistroQualifier: distro, + }), + }, nil +} + +// ParsePURL parses a PURL for an Alpine APK package into a list of IndexRecords. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + d := distoToDistribution(purl.Qualifiers.Map()[PURLDistroQualifier]) + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Kind: claircore.BINARY, + Arch: purl.Qualifiers.Map()["arch"], + }, + Distribution: d, + }, + }, nil +} + +// DistributionFromPURL converts a PURL string to a *claircore.Distribution. +// distro strings are expected to be in the form "alpine-". +// The distro format is discussed here: https://github.com/package-url/purl-spec/issues/423 +func distoToDistribution(distro string) *claircore.Distribution { + // split the distro string into name and version + d := strings.Split(distro, "-") + if d[1] == edgeDist.VersionID { + return edgeDist + } + v := strings.Split(d[1], ".") + if len(v) < 2 { + // There are some cases where the version is 3 parts but the patch doesn't + // influence addressability so we can ignore it. + return nil + } + maj, err := strconv.Atoi(v[0]) + if err != nil { + return nil + } + min, err := strconv.Atoi(v[1]) + if err != nil { + return nil + } + dist := stableRelease{maj, min}.Distribution() + return dist +} diff --git a/alpine/purl_test.go b/alpine/purl_test.go new file mode 100644 index 000000000..c3d5cbcef --- /dev/null +++ b/alpine/purl_test.go @@ -0,0 +1,86 @@ +package alpine + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordAlpine(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "busybox", + Version: "1.36.1-r0", + Arch: "x86_64", + Kind: claircore.BINARY, + PackageDB: "apk:/busybox", + Filepath: "/bin/busybox", + }, + Distribution: &claircore.Distribution{ + Name: "Alpine Linux", + PrettyName: "Alpine Linux v3.18", + Version: "3.18", + DID: "alpine", + }, + }, + }, + { + name: "edge", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "busybox", + Version: "1.36.1-r0", + Arch: "x86_64", + Kind: claircore.BINARY, + PackageDB: "apk:/busybox", + Filepath: "/bin/busybox", + }, + Distribution: &claircore.Distribution{ + Name: "Alpine Linux", + PrettyName: "Alpine Linux edge", + Version: "edge", + DID: "alpine", + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff([]*claircore.IndexRecord{tc.ir}, got, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-want +got):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore PackageDB and Filepath as they are not currently used in the matching. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), + // The version is what we save when indexing, the versionID is what we parse + // from the vulnerability database. Neither are used in the matching, the DID, + // DistributionName, and DistributionPrettyName are used. + cmpopts.IgnoreFields(claircore.Distribution{}, "VersionID", "Version"), +} From 75183dfb2c48c162b2ef912b11f73814c6dd1854 Mon Sep 17 00:00:00 2001 From: crozzy Date: Thu, 6 Nov 2025 12:01:39 -0800 Subject: [PATCH 02/13] debian: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. This patch minimally changes the matching logic to ensure the version code name isn't needed. Signed-off-by: crozzy --- debian/matcher.go | 2 +- debian/purl.go | 69 +++++++++++++++++++++++++++++++++++++++++++++ debian/purl_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 debian/purl.go create mode 100644 debian/purl_test.go diff --git a/debian/matcher.go b/debian/matcher.go index b13754086..28f590adf 100644 --- a/debian/matcher.go +++ b/debian/matcher.go @@ -40,7 +40,7 @@ func (*Matcher) Query() []driver.MatchConstraint { return []driver.MatchConstraint{ driver.DistributionDID, driver.DistributionName, - driver.DistributionVersion, + driver.DistributionVersionID, } } diff --git a/debian/purl.go b/debian/purl.go new file mode 100644 index 000000000..9cf612fcc --- /dev/null +++ b/debian/purl.go @@ -0,0 +1,69 @@ +package debian + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Debian packages. + PURLType = "deb" + // PURLNamespace is the namespace of Debian packages. + PURLNamespace = "debian" + // PURLDistroQualifier is the qualifier key for the distribution. + PURLDistroQualifier = "distro" +) + +// GeneratePURL generates a PURL for a Debian package in the format: +// pkg:deb/debian/@?arch=&distro=debian- +func GeneratePURL(ctx context.Context, r *claircore.IndexRecord) (packageurl.PackageURL, error) { + var distro string + if r.Distribution != nil { + // This completely ignores the version code name e.g. "debian-13". + distro = "debian-" + r.Distribution.VersionID + } + return packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: r.Package.Name, + Version: r.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": r.Package.Arch, + PURLDistroQualifier: distro, + }), + }, nil +} + +// ParsePURL parses a PURL for a Debian package into a list of IndexRecords. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + dq := purl.Qualifiers.Map()[PURLDistroQualifier] + distroParts := strings.SplitN(dq, "-", 2) + if len(distroParts) != 2 { + return nil, fmt.Errorf("invalid distro PURL: %s", dq) + } + _, err := strconv.Atoi(distroParts[1]) + if err != nil { + return nil, fmt.Errorf("invalid distro version: %s", distroParts[1]) + } + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + Name: "Debian GNU/Linux", + VersionID: distroParts[1], + DID: "debian", + }, + }, + }, nil +} diff --git a/debian/purl_test.go b/debian/purl_test.go new file mode 100644 index 000000000..c342a68e2 --- /dev/null +++ b/debian/purl_test.go @@ -0,0 +1,68 @@ +package debian + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordDebian(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6", + Arch: "x86_64", + Kind: claircore.BINARY, + PackageDB: "deb:/var/lib/dpkg/status", + }, + Distribution: &claircore.Distribution{ + Name: "Debian GNU/Linux", + VersionID: "11", + VersionCodeName: "bullseye", + DID: "debian", + Version: "11 (bullseye)", + PrettyName: "Debian GNU/Linux 11 (bullseye)", + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + fmt.Println(p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff([]*claircore.IndexRecord{tc.ir}, got, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-want +got):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore PackageDB and Filepath as they are not currently used in the matching. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), + // The distribution that we can decipher from the PURL only has the fields that + // are used in the matching; DID, Name and VersionID. + cmpopts.IgnoreFields(claircore.Distribution{}, "VersionCodeName", "Version", "PrettyName"), +} From 9cf64b22a0a1419444618fdef8dfdee816fd7667 Mon Sep 17 00:00:00 2001 From: crozzy Date: Thu, 6 Nov 2025 15:09:41 -0800 Subject: [PATCH 03/13] ubuntu: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. This patch minimally changes the matching logic to ensure the version code name isn't needed. Signed-off-by: crozzy --- ubuntu/matcher.go | 2 +- ubuntu/purl.go | 65 +++++++++++++++++++++++++++++++++++++++++++ ubuntu/purl_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 ubuntu/purl.go create mode 100644 ubuntu/purl_test.go diff --git a/ubuntu/matcher.go b/ubuntu/matcher.go index ed6793ad0..ee0f3dece 100644 --- a/ubuntu/matcher.go +++ b/ubuntu/matcher.go @@ -40,7 +40,7 @@ func (*Matcher) Query() []driver.MatchConstraint { return []driver.MatchConstraint{ driver.DistributionDID, driver.DistributionName, - driver.DistributionVersion, + driver.DistributionVersionID, } } diff --git a/ubuntu/purl.go b/ubuntu/purl.go new file mode 100644 index 000000000..f88c343c5 --- /dev/null +++ b/ubuntu/purl.go @@ -0,0 +1,65 @@ +package ubuntu + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Ubuntu packages. + PURLType = "deb" + // PURLNamespace is the namespace of Ubuntu packages. + PURLNamespace = "ubuntu" + // PURLDistroQualifier is the qualifier key for the distribution. + PURLDistroQualifier = "distro" +) + +// GeneratePURL generates a PURL for a Ubuntu package in the format: +// pkg:deb/ubuntu/@?arch=&distro=ubuntu- +func GeneratePURL(ctx context.Context, r *claircore.IndexRecord) (packageurl.PackageURL, error) { + var distro string + if r.Distribution != nil { + // This completely ignores the version code name e.g. "ubuntu-24.04". + distro = "ubuntu-" + r.Distribution.VersionID + } + return packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: r.Package.Name, + Version: r.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": r.Package.Arch, + PURLDistroQualifier: distro, + }), + }, nil +} + +// ParsePURL parses a PURL for a Ubuntu package into a list of IndexRecords. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + dq := purl.Qualifiers.Map()[PURLDistroQualifier] + distroParts := strings.SplitN(dq, "-", 2) + if len(distroParts) != 2 { + return nil, fmt.Errorf("invalid distro PURL: %s", dq) + } + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + Name: "Ubuntu", + DID: "ubuntu", + VersionID: distroParts[1], + PrettyName: "Ubuntu " + distroParts[1], + }, + }, + }, nil +} diff --git a/ubuntu/purl_test.go b/ubuntu/purl_test.go new file mode 100644 index 000000000..e926b5127 --- /dev/null +++ b/ubuntu/purl_test.go @@ -0,0 +1,68 @@ +package ubuntu + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordDebian(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6", + Arch: "x86_64", + Kind: claircore.BINARY, + PackageDB: "deb:/var/lib/dpkg/status", + }, + Distribution: &claircore.Distribution{ + Name: "Ubuntu", + DID: "ubuntu", + VersionID: "24.04", + PrettyName: "Ubuntu 24.04", + VersionCodeName: "noble", + Version: "24.04 (Noble)", + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + fmt.Println(p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff([]*claircore.IndexRecord{tc.ir}, got, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-want +got):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore PackageDB and Filepath as they are not currently used in the matching. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), + // The distribution that we can decipher from the PURL only has the fields that + // are used in the matching; DID, Name and VersionID. + cmpopts.IgnoreFields(claircore.Distribution{}, "VersionCodeName", "Version", "PrettyName"), +} From ba922adcf856017183e2856619765af00ea26569 Mon Sep 17 00:00:00 2001 From: crozzy Date: Mon, 10 Nov 2025 13:24:52 -0800 Subject: [PATCH 04/13] java: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to Maven PURLs and back. The group ID is extracted from the Package.Name and used as the PURL Namespace. Signed-off-by: crozzy --- java/purl.go | 47 ++++++++++++++++++++ java/purl_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 java/purl.go create mode 100644 java/purl_test.go diff --git a/java/purl.go b/java/purl.go new file mode 100644 index 000000000..10b520a65 --- /dev/null +++ b/java/purl.go @@ -0,0 +1,47 @@ +package java + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Java packages. + PURLType = "maven" +) + +// GeneratePURL generates a Maven PURL for a given [claircore.IndexRecord]. +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + // The PURL examples in the spec show that the group ID is used + // as the namespace for Maven PURLs, so split the package name on the colon. + // https://github.com/package-url/purl-spec?tab=readme-ov-file#some-purl-examples + parts := strings.SplitN(ir.Package.Name, ":", 2) + if len(parts) != 2 { + return packageurl.PackageURL{}, fmt.Errorf("invalid package name: %s", ir.Package.Name) + } + return packageurl.PackageURL{ + Type: PURLType, + Namespace: parts[0], + Name: parts[1], + Version: ir.Package.Version, + }, nil +} + +// ParsePURL parses a Maven PURL into a list of [claircore.IndexRecord]s. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Namespace + ":" + purl.Name, + Version: purl.Version, + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, nil +} diff --git a/java/purl_test.go b/java/purl_test.go new file mode 100644 index 000000000..d3fb08484 --- /dev/null +++ b/java/purl_test.go @@ -0,0 +1,106 @@ +package java + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordJava(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "org.apache.commons:commons-lang3", + Version: "3.12.0", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "different-version", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "com.fasterxml.jackson.core:jackson-databind", + Version: "2.17.1", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "org.slf4j:slf4j-api", + Version: "2.0.12", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: "org.slf4j", + Name: "slf4j-api", + Version: "2.0.12", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore fields that are not part of the PURL round-trip for Java. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), +} From 0918eb59e72d4c42a089db9503523b9fc3a5b48b Mon Sep 17 00:00:00 2001 From: crozzy Date: Mon, 10 Nov 2025 14:20:18 -0800 Subject: [PATCH 05/13] python: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- python/purl.go | 46 +++++++++++++++ python/purl_test.go | 137 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 python/purl.go create mode 100644 python/purl_test.go diff --git a/python/purl.go b/python/purl.go new file mode 100644 index 000000000..82270068f --- /dev/null +++ b/python/purl.go @@ -0,0 +1,46 @@ +package python + +import ( + "context" + "fmt" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/pkg/pep440" +) + +const ( + // PURLType is the type of package URL for Python packages. + PURLType = "pypi" +) + +// GeneratePURL generates a PyPI PURL for a given [claircore.IndexRecord]. +// Example: pkg:pypi/django@1.11.1 +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + return packageurl.PackageURL{ + Type: PURLType, + Name: ir.Package.Name, + Version: ir.Package.Version, + }, nil +} + +// ParsePURL parses a PyPI PURL into a list of [claircore.IndexRecord]s. +// The matcher needs the NormalizedVersion to be set, and it to be pep440. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + v, err := pep440.Parse(purl.Version) + if err != nil { + return nil, fmt.Errorf("python: unable to parse version: %w", err) + } + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: v.String(), + NormalizedVersion: v.Version(), + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, nil +} diff --git a/python/purl_test.go b/python/purl_test.go new file mode 100644 index 000000000..b70eb59f6 --- /dev/null +++ b/python/purl_test.go @@ -0,0 +1,137 @@ +package python + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/pkg/pep440" +) + +func TestRoundTripIndexRecordPython(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + wantErr bool + }{ + { + name: "urllib3", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "urllib3", + Version: "2.2.1", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "django", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "django", + Version: "1.11.1", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "bad-version", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "django", + Version: "something-invalid", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Align expected NormalizedVersion with GeneratePURL/ParsePURL behaviour. + // This helps the testcases stay simple. + if v, err := pep440.Parse(tc.ir.Package.Version); err == nil { + tc.ir.Package.NormalizedVersion = v.Version() + } + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + + got, err := ParsePURL(ctx, p) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParsePURL unexpected error: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "requests", + Version: "2.31.0", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Name: "requests", + Version: "2.31.0", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Ensure NormalizedVersion is set since GeneratePURL uses it. + if v, err := pep440.Parse(tc.ir.Package.Version); err == nil { + tc.ir.Package.NormalizedVersion = v.Version() + } + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore fields not relevant to PURL round-trip for Python. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), +} From 8edbeeb197ca8dede458517427eba38aab1ca23d Mon Sep 17 00:00:00 2001 From: crozzy Date: Tue, 11 Nov 2025 09:57:34 -0800 Subject: [PATCH 06/13] nodejs: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- nodejs/purl.go | 46 +++++++++++++++ nodejs/purl_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 nodejs/purl.go create mode 100644 nodejs/purl_test.go diff --git a/nodejs/purl.go b/nodejs/purl.go new file mode 100644 index 000000000..9d3ad9b63 --- /dev/null +++ b/nodejs/purl.go @@ -0,0 +1,46 @@ +package nodejs + +import ( + "context" + "fmt" + + "github.com/Masterminds/semver" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Node.js packages. + PURLType = "npm" +) + +// GeneratePURL generates a Node.js PURL for a given [claircore.IndexRecord]. +// Example: pkg:npm/express@4.18.2 +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + return packageurl.PackageURL{ + Type: PURLType, + Name: ir.Package.Name, + Version: ir.Package.Version, + }, nil +} + +// ParsePURL parses a Node.js PURL into a list of [claircore.IndexRecord]s. +// The matcher needs the NormalizedVersion to be set. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + v, err := semver.NewVersion(purl.Version) + if err != nil { + return nil, fmt.Errorf("nodejs: unable to parse version: %w", err) + } + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Kind: claircore.BINARY, + Version: v.String(), + NormalizedVersion: claircore.FromSemver(v), + }, + Repository: &Repository, + }, + }, nil +} diff --git a/nodejs/purl_test.go b/nodejs/purl_test.go new file mode 100644 index 000000000..6a250e896 --- /dev/null +++ b/nodejs/purl_test.go @@ -0,0 +1,136 @@ +package nodejs + +import ( + "context" + "testing" + + "github.com/Masterminds/semver" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordNodeJS(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + wantErr bool + }{ + { + name: "express", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "express", + Version: "4.18.2", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "lodash", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "lodash", + Version: "4.17.21", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "bad-version", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "left-pad", + Version: "not-a-version", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Align expected NormalizedVersion with GeneratePURL/ParsePURL behaviour. + if v, err := semver.NewVersion(tc.ir.Package.Version); err == nil { + tc.ir.Package.NormalizedVersion = claircore.FromSemver(v) + } + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + + got, err := ParsePURL(ctx, p) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParsePURL unexpected error: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "express", + Version: "4.18.2", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Name: "express", + Version: "4.18.2", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Ensure NormalizedVersion is set since GeneratePURL uses it. + if v, err := semver.NewVersion(tc.ir.Package.Version); err == nil { + tc.ir.Package.NormalizedVersion = claircore.FromSemver(v) + } + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore fields not relevant to PURL round-trip for Node.js. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), +} From 6d42441bd24862757abe776baedf412f69cf8343 Mon Sep 17 00:00:00 2001 From: crozzy Date: Tue, 11 Nov 2025 13:27:12 -0800 Subject: [PATCH 07/13] ruby: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- ruby/purl.go | 39 +++++++++++++++++ ruby/purl_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 ruby/purl.go create mode 100644 ruby/purl_test.go diff --git a/ruby/purl.go b/ruby/purl.go new file mode 100644 index 000000000..7dc49a1f6 --- /dev/null +++ b/ruby/purl.go @@ -0,0 +1,39 @@ +package ruby + +import ( + "context" + + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for Ruby packages. + PURLType = "gem" +) + +// GeneratePURL generates a Ruby PURL for a given [claircore.IndexRecord]. +// Example: pkg:gem/rails@6.1.0 +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + return packageurl.PackageURL{ + Type: PURLType, + Name: ir.Package.Name, + Version: ir.Package.Version, + }, nil +} + +// ParsePURL parses a Ruby PURL into a list of [claircore.IndexRecord]s. +// The matcher needs the NormalizedVersion to be set. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, nil +} diff --git a/ruby/purl_test.go b/ruby/purl_test.go new file mode 100644 index 000000000..4190149a1 --- /dev/null +++ b/ruby/purl_test.go @@ -0,0 +1,106 @@ +package ruby + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordRuby(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "rails", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "rails", + Version: "6.1.0", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + { + name: "rack", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "rack", + Version: "2.2.8", + Kind: claircore.BINARY, + }, + Repository: &Repository, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "rails", + Version: "6.1.0", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Name: "rails", + Version: "6.1.0", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore fields not relevant to PURL round-trip for Ruby. + cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"), +} From 40e4893365e6bd71c0438f1ac8b6d0eaaa043513 Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 12 Nov 2025 08:31:21 -0800 Subject: [PATCH 08/13] rhcc: add purl generator and parser TODO(crozzy) revisit this, I'm not sure it's fully correct. Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- rhel/rhcc/purl.go | 73 ++++++++++++++++++++ rhel/rhcc/purl_test.go | 150 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 rhel/rhcc/purl.go create mode 100644 rhel/rhcc/purl_test.go diff --git a/rhel/rhcc/purl.go b/rhel/rhcc/purl.go new file mode 100644 index 000000000..04328c8cb --- /dev/null +++ b/rhel/rhcc/purl.go @@ -0,0 +1,73 @@ +package rhcc + +import ( + "context" + + "github.com/Masterminds/semver" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +const ( + // PURLType is the type of package URL for Red Hat Container Catalog packages. + PURLType = "oci" +) + +// GenerateOCIPURL generates an OCI PURL for a given [claircore.IndexRecord]. +// Example: +// pkg:oci/ubi@sha256:dbc1e98d14a022542e45b5f22e0206d3f86b5bdf237b58ee7170c9ddd1b3a283?repository_url=registry.access.redhat.com/ubi9/ubi +func GenerateOCIPURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + purl := packageurl.PackageURL{ + Type: PURLType, + Name: ir.Package.Name, + Version: ir.Package.Version, // TODO(crozzy) What should we put here? + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": ir.Package.Arch, + "tag": ir.Package.Version, + }), + } + if ir.Repository != nil && ir.Repository.Name != GoldRepo.Name { + purl.Qualifiers = append( + purl.Qualifiers, + packageurl.Qualifier{Key: "container_cpe", Value: ir.Repository.CPE.String()}, + ) + } + return purl, nil +} + +// ParseOCIPURL parses an OCI PURL into a list of [claircore.IndexRecord]s. +// The matcher needs the NormalizedVersion to be set. +func ParseOCIPURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + semver, err := semver.NewVersion(purl.Version) + if err != nil { + return nil, err + } + ir := &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: purl.Name, + Version: semver.String(), + Arch: purl.Qualifiers.Map()["arch"], + RepositoryHint: "rhcc", + }, + } + + ir.Repository = &GoldRepo + if containerCPE, ok := purl.Qualifiers.Map()["container_cpe"]; ok { + cpe, err := cpe.Unbind(containerCPE) + if err != nil { + return nil, err + } + ir.Repository = &claircore.Repository{ + CPE: cpe, + Name: cpe.String(), + Key: RepositoryKey, + } + } + tag := purl.Qualifiers.Map()["tag"] + if tag != "" { + ir.Package.Version = tag + } + return []*claircore.IndexRecord{ir}, nil +} diff --git a/rhel/rhcc/purl_test.go b/rhel/rhcc/purl_test.go new file mode 100644 index 000000000..7f4861293 --- /dev/null +++ b/rhel/rhcc/purl_test.go @@ -0,0 +1,150 @@ +package rhcc + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +func TestRoundTripIndexRecordOCI(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "goldrepo-no-container-cpe", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "ubi", + Version: "v9.3.1", + Arch: "x86_64", + RepositoryHint: "rhcc", + }, + Repository: &GoldRepo, + }, + }, + { + name: "with-container-cpe", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "ubi-micro", + Version: "v9.3.1", + Arch: "x86_64", + RepositoryHint: "rhcc", + }, + Repository: &claircore.Repository{ + CPE: cpe.MustUnbind("cpe:/o:redhat:enterprise_linux:9::baseos"), + Name: "cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*", + Key: RepositoryKey, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GenerateOCIPURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateOCIPURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParseOCIPURL(ctx, p) + if err != nil { + t.Fatalf("ParseOCIPURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGenerateOCIPURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic-goldrepo", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "ubi", + Version: "v9.3.1", + Arch: "amd64", + }, + Repository: &GoldRepo, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Name: "ubi", + Version: "v9.3.1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "amd64"}, + {Key: "tag", Value: "v9.3.1"}, + }, + }, + }, + { + name: "with-container-cpe", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "ubi", + Version: "v9.3.1", + Arch: "amd64", + }, + Repository: &claircore.Repository{ + CPE: cpe.MustUnbind("cpe:/o:redhat:enterprise_linux:9::baseos"), + Name: "cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*", + Key: RepositoryKey, + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Name: "ubi", + Version: "v9.3.1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "amd64"}, + {Key: "tag", Value: "v9.3.1"}, + {Key: "container_cpe", Value: "cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*"}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GenerateOCIPURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateOCIPURL: %v", err) + } + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore Distribution field as there isn't currently a serialized format + // and it is not currently used in the matching. + cmpopts.IgnoreFields(claircore.IndexRecord{}, "Distribution"), + cmpCPE, +} + +var cmpCPE = cmp.FilterPath( + func(p cmp.Path) bool { return p.Last().String() == ".CPE" }, + cmp.Comparer(func(a, b cpe.WFN) bool { return a.String() == b.String() }), +) From 955441910b1ad54190b9adcca2d0a7e13a6c4c2a Mon Sep 17 00:00:00 2001 From: crozzy Date: Thu, 13 Nov 2025 15:36:52 -0800 Subject: [PATCH 09/13] suse: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Uses distro qualifier to pass DID-VERSION but also supports distro_cpe as SUSE is a distro that includes a CPE in their os-release file. Signed-off-by: crozzy --- suse/factory.go | 24 ++++--- suse/purl.go | 93 +++++++++++++++++++++++++++ suse/purl_test.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 suse/purl.go create mode 100644 suse/purl_test.go diff --git a/suse/factory.go b/suse/factory.go index 62737aad0..c73c61644 100644 --- a/suse/factory.go +++ b/suse/factory.go @@ -124,26 +124,34 @@ var releases sync.Map func mkELDist(oURL, ver string) *claircore.Distribution { name := strings.TrimSuffix(oURL, ".xml.gz") - v, _ := releases.LoadOrStore(name, &claircore.Distribution{ + v, _ := releases.LoadOrStore(name, ELDist(ver)) + return v.(*claircore.Distribution) +} + +func mkLeapDist(oURL, ver string) *claircore.Distribution { + name := strings.TrimSuffix(oURL, ".xml.gz") + v, _ := releases.LoadOrStore(name, leapDist(ver)) + return v.(*claircore.Distribution) +} + +func ELDist(ver string) *claircore.Distribution { + return &claircore.Distribution{ Name: "SLES", DID: "sles", Version: ver, VersionID: ver, PrettyName: "SUSE Linux Enterprise Server " + ver, - }) - return v.(*claircore.Distribution) + } } -func mkLeapDist(oURL, ver string) *claircore.Distribution { - name := strings.TrimSuffix(oURL, ".xml.gz") - v, _ := releases.LoadOrStore(name, &claircore.Distribution{ +func leapDist(ver string) *claircore.Distribution { + return &claircore.Distribution{ Name: "openSUSE Leap", DID: "opensuse-leap", Version: ver, VersionID: ver, PrettyName: "openSUSE Leap " + ver, - }) - return v.(*claircore.Distribution) + } } // FactoryConfig is the configuration accepted by the Factory. diff --git a/suse/purl.go b/suse/purl.go new file mode 100644 index 000000000..c9e98b382 --- /dev/null +++ b/suse/purl.go @@ -0,0 +1,93 @@ +package suse + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +const ( + // PURLType is the type of package URL for RPM packages. + PURLType = "rpm" + // PURLNamespace is the namespace of SUSE RPMs. + PURLNamespace = "opensuse" +) + +// GeneratePURL generates an RPM PURL for a given [claircore.IndexRecord]. +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + p := packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: ir.Package.Name, + Version: ir.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": ir.Package.Arch, + }), + } + if ir.Distribution != nil { + // We don't persist the CPE in the Distribution but try it first in case it's available. + if c := ir.Distribution.CPE.String(); c != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro_cpe", + Value: c, + }) + } + + if ir.Distribution.DID != "" && ir.Distribution.VersionID != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro", + Value: ir.Distribution.DID + "-" + ir.Distribution.VersionID, + }) + } + } + return p, nil +} + +// ParsePURL parses an RPM PURL into a list of [claircore.IndexRecord]s. +// Preference order for distribution: +// 1. distro_cpe qualifier (converted to a [claircore.Distribution]) +// 2. fallback to "distro" qualifier in the form "-" converted to a [claircore.Distribution] +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + var dist *claircore.Distribution + // First try and parse a distro CPE. + if dc := purl.Qualifiers.Map()["distro_cpe"]; dc != "" { + if wf, err := cpe.Unbind(dc); err == nil { + if d, err := cpeToDist(wf); err == nil && d != nil { + dist = d + } + } + } + // Fallback to legacy "distro" qualifier. + if dist == nil { + distroQualifier := purl.Qualifiers.Map()["distro"] + parts := strings.SplitN(distroQualifier, "-", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid distro qualifier: %s", distroQualifier) + } + switch parts[0] { + case "sles": + dist = ELDist(parts[1]) + case "opensuse-leap": + dist = leapDist(parts[1]) + default: + return nil, fmt.Errorf("invalid distro name: %s", parts[0]) + } + + } + + return []*claircore.IndexRecord{ + { + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: dist, + }, + }, nil +} diff --git a/suse/purl_test.go b/suse/purl_test.go new file mode 100644 index 000000000..d35dcfaec --- /dev/null +++ b/suse/purl_test.go @@ -0,0 +1,160 @@ +package suse + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +func TestRoundTripIndexRecord(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "opensuse-leap-with-cpe", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "zlib", + Version: "1.2.11-150500.59.68.1", + Arch: "x86_64", + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + CPE: cpe.MustUnbind("cpe:2.3:o:opensuse:leap:15.5"), + Name: "openSUSE Leap", + VersionID: "15.5", + Version: "15.5", + DID: "opensuse-leap", + PrettyName: "openSUSE Leap 15.5", + }, + }, + }, + { + name: "sles-fallback-distro", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1-150300.51.1", + Arch: "aarch64", + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + Name: "SLES", + VersionID: "12", + Version: "12", + DID: "sles", + PrettyName: "SUSE Linux Enterprise Server 12", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateRPMPURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParseRPMPURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "with-distro-cpe", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "zlib", + Version: "1.2.11-150500.59.68.1", + Arch: "x86_64", + }, + Distribution: &claircore.Distribution{ + CPE: cpe.MustUnbind("cpe:2.3:o:opensuse:leap:15.5"), + Name: "openSUSE Leap", + VersionID: "15.5", + DID: "opensuse", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "zlib", + Version: "1.2.11-150500.59.68.1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "x86_64"}, + {Key: "distro_cpe", Value: "cpe:2.3:o:opensuse:leap:15.5:*:*:*:*:*:*:*"}, + {Key: "distro", Value: "opensuse-15.5"}, + }, + }, + }, + { + name: "fallback-distro-qualifier", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1-150300.51.1", + Arch: "aarch64", + }, + Distribution: &claircore.Distribution{ + Name: "SUSE Linux Enterprise Server", + VersionID: "12", + DID: "suse", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "bash", + Version: "5.1-150300.51.1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "aarch64"}, + {Key: "distro", Value: "suse-12"}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateRPMPURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore Distribution field differences for round-trip; only assert package fields. + cmpopts.IgnoreFields(claircore.Distribution{}, "PrettyName", "CPE"), +} From 6d203283f0c984bcad5ec5a87f5e78c60b1e73cc Mon Sep 17 00:00:00 2001 From: crozzy Date: Fri, 14 Nov 2025 11:40:07 -0800 Subject: [PATCH 10/13] aws: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Uses distro qualifier to pass DID-VERSION but also supports distro_cpe as amazon linux is a distro that includes a CPE in their os-release file. Signed-off-by: crozzy --- aws/distributionscanner.go | 6 +- aws/distributionscanner_test.go | 45 ++++++----- aws/purl.go | 89 +++++++++++++++++++++ aws/purl_test.go | 134 ++++++++++++++++++++++++++++++++ aws/releases.go | 28 +++++-- 5 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 aws/purl.go create mode 100644 aws/purl_test.go diff --git a/aws/distributionscanner.go b/aws/distributionscanner.go index bd3fd0b19..b237cfd16 100644 --- a/aws/distributionscanner.go +++ b/aws/distributionscanner.go @@ -79,7 +79,7 @@ func (ds *DistributionScanner) Scan(ctx context.Context, l *claircore.Layer) ([] return nil, nil } for _, buff := range files { - dist := ds.parse(buff) + dist := parse(buff) if dist != nil { return []*claircore.Distribution{dist}, nil } @@ -87,11 +87,11 @@ func (ds *DistributionScanner) Scan(ctx context.Context, l *claircore.Layer) ([] return []*claircore.Distribution{}, nil } -// parse attempts to match all AWS release regexp and returns the associated +// Parse attempts to match all AWS release regexp and returns the associated // distribution if it exists. // // separated into its own method to aid testing. -func (ds *DistributionScanner) parse(buff *bytes.Buffer) *claircore.Distribution { +func parse(buff *bytes.Buffer) *claircore.Distribution { for _, ur := range awsRegexes { if ur.regexp.Match(buff.Bytes()) { dist := releaseToDist(ur.release) diff --git a/aws/distributionscanner_test.go b/aws/distributionscanner_test.go index ec251ba75..161238ae2 100644 --- a/aws/distributionscanner_test.go +++ b/aws/distributionscanner_test.go @@ -72,52 +72,51 @@ SUPPORT_END="2028-03-01"`) func TestDistributionScanner(t *testing.T) { table := []struct { - name string - release Release - osRelease []byte + name string + release Release + osRelease []byte prettyDistName string }{ { - name: "AL1", - release: AmazonLinux1, - osRelease: AL1v201609OSRelease, + name: "AL1", + release: AmazonLinux1, + osRelease: AL1v201609OSRelease, prettyDistName: "Amazon Linux AMI 2018.03", }, { - name: "AL1", - release: AmazonLinux1, - osRelease: AL1v201703OSRelease, + name: "AL1", + release: AmazonLinux1, + osRelease: AL1v201703OSRelease, prettyDistName: "Amazon Linux AMI 2018.03", }, { - name: "AL1", - release: AmazonLinux1, - osRelease: AL1v201709OSRelease, + name: "AL1", + release: AmazonLinux1, + osRelease: AL1v201709OSRelease, prettyDistName: "Amazon Linux AMI 2018.03", }, { - name: "AL1", - release: AmazonLinux1, - osRelease: AL1v201803OSRelease, + name: "AL1", + release: AmazonLinux1, + osRelease: AL1v201803OSRelease, prettyDistName: "Amazon Linux AMI 2018.03", }, { - name: "AL2", - release: AmazonLinux2, - osRelease: AL2OSRelease, + name: "AL2", + release: AmazonLinux2, + osRelease: AL2OSRelease, prettyDistName: "Amazon Linux 2", }, { - name: "AL2023", - release: AmazonLinux2023, - osRelease: AL2023OSRelease, + name: "AL2023", + release: AmazonLinux2023, + osRelease: AL2023OSRelease, prettyDistName: "Amazon Linux 2023", }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { - scanner := DistributionScanner{} - dist := scanner.parse(bytes.NewBuffer(tt.osRelease)) + dist := parse(bytes.NewBuffer(tt.osRelease)) cmpDist := releaseToDist(tt.release) if !cmp.Equal(dist, cmpDist) { t.Fatalf("%v", cmp.Diff(dist, cmpDist)) diff --git a/aws/purl.go b/aws/purl.go new file mode 100644 index 000000000..b8abc8644 --- /dev/null +++ b/aws/purl.go @@ -0,0 +1,89 @@ +package aws + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +const ( + // PURLType is the type of package URL for RPM packages. + PURLType = "rpm" + // PURLNamespace is the namespace of AWS RPMs. + PURLNamespace = "aws" +) + +// GeneratePURL generates an RPM PURL for a given [claircore.IndexRecord]. +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + p := packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: ir.Package.Name, + Version: ir.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": ir.Package.Arch, + }), + } + if ir.Distribution != nil { + // We don't persist the CPE in the Distribution but try it first in case it's available. + if c := ir.Distribution.CPE.String(); c != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro_cpe", + Value: c, + }) + } + + if ir.Distribution.DID != "" && ir.Distribution.VersionID != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro", + Value: "amzn-" + ir.Distribution.VersionID, + }) + } + } + return p, nil +} + +// ParsePURL parses an RPM PURL into a list of [claircore.IndexRecord]s. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + ir := &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{}, + } + // Prefer a distro CPE if provided. + if dc := purl.Qualifiers.Map()["distro_cpe"]; dc != "" { + if wf, err := cpe.Unbind(dc); err == nil { + ir.Distribution = cpeToDistribution(wf) + return []*claircore.IndexRecord{ir}, nil + } + } + + // Fallback to legacy distro qualifier parsing: "Name-VersionID". + distroQualifier := purl.Qualifiers.Map()["distro"] + distroParts := strings.SplitN(distroQualifier, "-", 2) + if len(distroParts) != 2 { + return nil, fmt.Errorf("invalid distro PURL: %s", distroQualifier) + } + ver := distroParts[1] + if ver == AL1Dist.Version { + ir.Distribution = AL1Dist + } else { + ir.Distribution = &claircore.Distribution{ + Name: "Amazon Linux", + DID: ID, + Version: ver, + VersionID: ver, + PrettyName: "Amazon Linux " + ver, + CPE: cpe.MustUnbind("cpe:o:amazon:amazon_linux:" + ver), + } + } + return []*claircore.IndexRecord{ir}, nil +} diff --git a/aws/purl_test.go b/aws/purl_test.go new file mode 100644 index 000000000..062ee6c21 --- /dev/null +++ b/aws/purl_test.go @@ -0,0 +1,134 @@ +package aws + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" +) + +func TestRoundTripIndexRecordAWS(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "amzn2", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "curl", + Version: "7.79.1-2.amzn2.0.2", + Arch: "x86_64", + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + Name: "Amazon Linux AMI", + VersionID: "2018.03", + DID: "amzn", + Version: "2018.03", + PrettyName: "Amazon Linux AMI 2018.03", + CPE: cpe.MustUnbind("cpe:/o:amazon:linux:2018.03:ga"), + }, + }, + }, + { + name: "amzn2023", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.16-6.amzn2023.0.4", + Arch: "aarch64", + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{ + Name: "Amazon Linux", + VersionID: "2023", + DID: "amzn", + Version: "2023", + PrettyName: "Amazon Linux 2023", + CPE: cpe.MustUnbind("cpe:2.3:o:amazon:amazon_linux:2023:*:*:*:*:*:*:*"), + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateRPMPURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParseRPMPURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}, purlCmp); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "amzn2-basic", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "curl", + Version: "7.79.1-2.amzn2.0.2", + Arch: "x86_64", + }, + Distribution: &claircore.Distribution{ + Name: "Amazon Linux", + VersionID: "2023", + Version: "2023", + DID: "amzn", + }, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "curl", + Version: "7.79.1-2.amzn2.0.2", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "x86_64"}, + {Key: "distro", Value: "amzn-2023"}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GenerateRPMPURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} + +var purlCmp = cmp.Options{ + // Ignore CPE field as it is not currently used in the matching. + // cmpopts.IgnoreFields(claircore.Distribution{}, "CPE"), +} diff --git a/aws/releases.go b/aws/releases.go index 055105584..456d12241 100644 --- a/aws/releases.go +++ b/aws/releases.go @@ -4,14 +4,14 @@ import ( "fmt" "github.com/quay/claircore" - "github.com/quay/claircore/pkg/cpe" + "github.com/quay/claircore/toolkit/types/cpe" ) type Release string const ( - AmazonLinux1 Release = "AL1" - AmazonLinux2 Release = "AL2" + AmazonLinux1 Release = "AL1" + AmazonLinux2 Release = "AL2" AmazonLinux2023 Release = "AL2023" // os-release name ID field consistently available on official amazon linux images ID = "amzn" @@ -20,8 +20,8 @@ const ( func (r Release) mirrorlist() string { //doc:url updater const ( - al1 = "http://repo.us-west-2.amazonaws.com/2018.03/updates/x86_64/mirror.list" - al2 = "https://cdn.amazonlinux.com/2/core/latest/x86_64/mirror.list" + al1 = "http://repo.us-west-2.amazonaws.com/2018.03/updates/x86_64/mirror.list" + al2 = "https://cdn.amazonlinux.com/2/core/latest/x86_64/mirror.list" al2023 = "https://cdn.amazonlinux.com/al2023/core/mirrors/latest/x86_64/mirror.list" ) switch r { @@ -62,7 +62,6 @@ var AL2023Dist = &claircore.Distribution{ CPE: cpe.MustUnbind("cpe:2.3:o:amazon:amazon_linux:2023"), } - func releaseToDist(release Release) *claircore.Distribution { switch release { case AmazonLinux1: @@ -76,3 +75,20 @@ func releaseToDist(release Release) *claircore.Distribution { return &claircore.Distribution{} } } + +// cpeToDistribution constructs a Distribution from a [cpe.WFN]. +func cpeToDistribution(wf cpe.WFN) *claircore.Distribution { + ver := wf.Attr[cpe.Version].String() + if ver == AL1Dist.Version { + return AL1Dist + } else { + return &claircore.Distribution{ + Name: "Amazon Linux", + DID: ID, + Version: ver, + VersionID: ver, + PrettyName: "Amazon Linux " + ver, + CPE: wf, + } + } +} From 5050c7010b650aa716aa9e87767a25d1f4c888f9 Mon Sep 17 00:00:00 2001 From: crozzy Date: Fri, 14 Nov 2025 12:07:38 -0800 Subject: [PATCH 11/13] oracle: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- oracle/purl.go | 60 ++++++++++++++++++++ oracle/purl_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 oracle/purl.go create mode 100644 oracle/purl_test.go diff --git a/oracle/purl.go b/oracle/purl.go new file mode 100644 index 000000000..24e3c1336 --- /dev/null +++ b/oracle/purl.go @@ -0,0 +1,60 @@ +package oracle + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for RPM packages. + PURLType = "rpm" + // PURLNamespace is the namespace of Oracle RPMs. + PURLNamespace = "oracle" +) + +// GeneratePURL generates an RPM PURL for a given [claircore.IndexRecord]. +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + p := packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: ir.Package.Name, + Version: ir.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": ir.Package.Arch, + }), + } + if ir.Distribution != nil { + if ir.Distribution.DID != "" && ir.Distribution.VersionID != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro", + Value: "oracle-" + ir.Distribution.VersionID, + }) + } + } + return p, nil +} + +// ParsePURL parses an RPM PURL into a list of [claircore.IndexRecord]s. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + ir := &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{}, + } + distroQualifier := purl.Qualifiers.Map()["distro"] + distroParts := strings.SplitN(distroQualifier, "-", 2) + if len(distroParts) != 2 { + return nil, fmt.Errorf("invalid distro PURL: %s", distroQualifier) + } + release := Release(distroParts[1]) + ir.Distribution = releaseToDist(release) + return []*claircore.IndexRecord{ir}, nil +} diff --git a/oracle/purl_test.go b/oracle/purl_test.go new file mode 100644 index 000000000..bbe2735f6 --- /dev/null +++ b/oracle/purl_test.go @@ -0,0 +1,130 @@ +package oracle + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordOracle(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "ol9", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6.el9", + Arch: "x86_64", + Kind: claircore.BINARY, + }, + Distribution: nineDist, + }, + }, + { + name: "ol7", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "coreutils", + Version: "8.22-24.oe1", + Arch: "aarch64", + Kind: claircore.BINARY, + }, + Distribution: sevenDist, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic-ol9", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6.el9", + Arch: "x86_64", + }, + Distribution: releaseToDist(Nine), + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "bash", + Version: "5.1.8-6.el9", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "x86_64"}, + {Key: "distro", Value: "oracle-9"}, + }, + }, + }, + { + name: "basic-ol7", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "coreutils", + Version: "8.22-24.oe1", + Arch: "aarch64", + }, + Distribution: releaseToDist(Seven), + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "coreutils", + Version: "8.22-24.oe1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "aarch64"}, + {Key: "distro", Value: "oracle-7"}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} From b82da58162d67e4413cf644485ffc09b2ccf2f90 Mon Sep 17 00:00:00 2001 From: crozzy Date: Fri, 14 Nov 2025 13:06:38 -0800 Subject: [PATCH 12/13] photon: add purl generator and parser Add GeneratePURL and ParsePURL to translate from IndexRecord to PURL and back. Signed-off-by: crozzy --- photon/purl.go | 59 ++++++++++++++++++++ photon/purl_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ photon/releases.go | 13 +++++ 3 files changed, 202 insertions(+) create mode 100644 photon/purl.go create mode 100644 photon/purl_test.go diff --git a/photon/purl.go b/photon/purl.go new file mode 100644 index 000000000..b6c5e1a00 --- /dev/null +++ b/photon/purl.go @@ -0,0 +1,59 @@ +package photon + +import ( + "context" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + "github.com/quay/claircore" +) + +const ( + // PURLType is the type of package URL for RPM packages. + PURLType = "rpm" + // PURLNamespace is the namespace of photon RPMs. + PURLNamespace = "photon" +) + +// GeneratePURL generates an RPM PURL for a given [claircore.IndexRecord]. +func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) { + p := packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: ir.Package.Name, + Version: ir.Package.Version, + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "arch": ir.Package.Arch, + }), + } + if ir.Distribution != nil { + if ir.Distribution.DID != "" && ir.Distribution.VersionID != "" { + p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{ + Key: "distro", + Value: "photon-" + ir.Distribution.VersionID, + }) + } + } + return p, nil +} + +// ParsePURL parses an RPM PURL into a list of [claircore.IndexRecord]s. +func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) { + ir := &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: purl.Name, + Version: purl.Version, + Arch: purl.Qualifiers.Map()["arch"], + Kind: claircore.BINARY, + }, + Distribution: &claircore.Distribution{}, + } + distroQualifier := purl.Qualifiers.Map()["distro"] + distroParts := strings.SplitN(distroQualifier, "-", 2) + if len(distroParts) != 2 { + return nil, fmt.Errorf("invalid distro PURL: %s", distroQualifier) + } + ir.Distribution = versionToDist(distroParts[1]) + return []*claircore.IndexRecord{ir}, nil +} diff --git a/photon/purl_test.go b/photon/purl_test.go new file mode 100644 index 000000000..a193c4111 --- /dev/null +++ b/photon/purl_test.go @@ -0,0 +1,130 @@ +package photon + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/package-url/packageurl-go" + + "github.com/quay/claircore" +) + +func TestRoundTripIndexRecordPhoton(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + ir *claircore.IndexRecord + }{ + { + name: "photon 1.0", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6.el9", + Arch: "x86_64", + Kind: claircore.BINARY, + }, + Distribution: photon1Dist, + }, + }, + { + name: "photon 2.0", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "coreutils", + Version: "8.22-24.oe1", + Arch: "aarch64", + Kind: claircore.BINARY, + }, + Distribution: photon2Dist, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", p.String()) + got, err := ParsePURL(ctx, p) + if err != nil { + t.Fatalf("ParsePURL: %v", err) + } + if diff := cmp.Diff(got, []*claircore.IndexRecord{tc.ir}); diff != "" { + t.Fatalf("round-trip mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestGeneratePURL(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + ir *claircore.IndexRecord + want packageurl.PackageURL + }{ + { + name: "basic-ol9", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bash", + Version: "5.1.8-6.el9", + Arch: "x86_64", + }, + Distribution: photon1Dist, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "bash", + Version: "5.1.8-6.el9", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "x86_64"}, + {Key: "distro", Value: "photon-1.0"}, + }, + }, + }, + { + name: "basic-ol7", + ir: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "coreutils", + Version: "8.22-24.oe1", + Arch: "aarch64", + }, + Distribution: photon2Dist, + }, + want: packageurl.PackageURL{ + Type: PURLType, + Namespace: PURLNamespace, + Name: "coreutils", + Version: "8.22-24.oe1", + Qualifiers: packageurl.Qualifiers{ + {Key: "arch", Value: "aarch64"}, + {Key: "distro", Value: "photon-2.0"}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := GeneratePURL(ctx, tc.ir) + if err != nil { + t.Fatalf("GeneratePURL: %v", err) + } + t.Logf("generated PURL: %s", got.String()) + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Fatalf("purl mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/photon/releases.go b/photon/releases.go index 8468aa6c5..ed2f8bb92 100644 --- a/photon/releases.go +++ b/photon/releases.go @@ -49,3 +49,16 @@ func releaseToDist(r Release) *claircore.Distribution { return &claircore.Distribution{} } } + +func versionToDist(v string) *claircore.Distribution { + switch v { + case "1.0": + return photon1Dist + case "2.0": + return photon2Dist + case "3.0": + return photon3Dist + default: + return nil + } +} From 212f2c16c91106fb03434b11d0e74825b9c67d70 Mon Sep 17 00:00:00 2001 From: crozzy Date: Fri, 14 Nov 2025 14:06:25 -0800 Subject: [PATCH 13/13] photon: add distro versions 4 and 5 There are now versions 4 and 5 that should be referenced in the code because this is not an updater that supports dynamic distribution discovery (yet). Signed-off-by: crozzy --- photon/distributionscanner.go | 10 ++++++++ photon/distributionscanner_test.go | 28 ++++++++++++++++++++ photon/purl.go | 2 +- photon/releases.go | 41 ++++++++++++++++++------------ 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/photon/distributionscanner.go b/photon/distributionscanner.go index b6bceae60..af0116611 100644 --- a/photon/distributionscanner.go +++ b/photon/distributionscanner.go @@ -46,6 +46,16 @@ var photonRegexes = []photonRegex{ // regex for /etc/os-release regexp: regexp.MustCompile(`^.*"VMware Photon OS"\sVERSION="3.0"`), }, + { + release: Photon4, + // regex for /etc/os-release + regexp: regexp.MustCompile(`^.*"VMware Photon OS"\sVERSION="4.0"`), + }, + { + release: Photon5, + // regex for /etc/os-release + regexp: regexp.MustCompile(`^.*"VMware Photon OS"\sVERSION="5.0"`), + }, } var ( diff --git a/photon/distributionscanner_test.go b/photon/distributionscanner_test.go index 6d2fc9d67..1598439da 100644 --- a/photon/distributionscanner_test.go +++ b/photon/distributionscanner_test.go @@ -34,6 +34,24 @@ ANSI_COLOR="1;34" HOME_URL="https://vmware.github.io/photon/" BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) +var photon4OSRelease []byte = []byte(`NAME="VMware Photon OS" +VERSION="4.0" +ID=photon +VERSION_ID="4.0" +PRETTY_NAME="VMware Photon OS/Linux" +ANSI_COLOR="1;34" +HOME_URL="https://vmware.github.io/photon/" +BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) + +var photon5OSRelease []byte = []byte(`NAME="VMware Photon OS" +VERSION="5.0" +ID=photon +VERSION_ID="5.0" +PRETTY_NAME="VMware Photon OS/Linux" +ANSI_COLOR="1;34" +HOME_URL="https://vmware.github.io/photon/" +BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) + func TestDistributionScanner(t *testing.T) { table := []struct { name string @@ -55,6 +73,16 @@ func TestDistributionScanner(t *testing.T) { release: Photon3, osRelease: photon3OSRelease, }, + { + name: "photon 4.0", + release: Photon4, + osRelease: photon4OSRelease, + }, + { + name: "photon 5.0", + release: Photon5, + osRelease: photon5OSRelease, + }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { diff --git a/photon/purl.go b/photon/purl.go index b6c5e1a00..b8ecd5192 100644 --- a/photon/purl.go +++ b/photon/purl.go @@ -54,6 +54,6 @@ func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.In if len(distroParts) != 2 { return nil, fmt.Errorf("invalid distro PURL: %s", distroQualifier) } - ir.Distribution = versionToDist(distroParts[1]) + ir.Distribution = releaseToDist(Release(distroParts[1])) return []*claircore.IndexRecord{ir}, nil } diff --git a/photon/releases.go b/photon/releases.go index ed2f8bb92..51bebf8a8 100644 --- a/photon/releases.go +++ b/photon/releases.go @@ -7,9 +7,11 @@ type Release string // These are some known Releases. const ( - Photon1 Release = `photon1` - Photon2 Release = `photon2` - Photon3 Release = `photon3` + Photon1 Release = `1.0` + Photon2 Release = `2.0` + Photon3 Release = `3.0` + Photon4 Release = `4.0` + Photon5 Release = `5.0` ) var photon1Dist = &claircore.Distribution{ @@ -36,6 +38,22 @@ var photon3Dist = &claircore.Distribution{ DID: "photon", } +var photon4Dist = &claircore.Distribution{ + Name: "VMware Photon OS", + Version: "4.0", + VersionID: "4.0", + PrettyName: "VMware Photon OS/Linux", + DID: "photon", +} + +var photon5Dist = &claircore.Distribution{ + Name: "VMware Photon OS", + Version: "5.0", + VersionID: "5.0", + PrettyName: "VMware Photon OS/Linux", + DID: "photon", +} + func releaseToDist(r Release) *claircore.Distribution { switch r { case Photon1: @@ -44,21 +62,12 @@ func releaseToDist(r Release) *claircore.Distribution { return photon2Dist case Photon3: return photon3Dist + case Photon4: + return photon4Dist + case Photon5: + return photon5Dist default: // return empty dist return &claircore.Distribution{} } } - -func versionToDist(v string) *claircore.Distribution { - switch v { - case "1.0": - return photon1Dist - case "2.0": - return photon2Dist - case "3.0": - return photon3Dist - default: - return nil - } -}