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"), +} 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, + } + } +} 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"), +} 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"), +} 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"), +} 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) + } + }) + } +} 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 new file mode 100644 index 000000000..b8ecd5192 --- /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 = releaseToDist(Release(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..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,6 +62,10 @@ 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{} 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"), +} 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() }), +) 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"), +} 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"), +} 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"), +}