From 1c6ebfd6097bc552fc95a60480d1918607c0b991 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:05:55 +0100 Subject: [PATCH] vendor: github.com/package-url/packageurl-go v0.1.7 Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- client/client_export_metadata_test.go | 6 +- go.mod | 2 +- go.sum | 4 +- util/purl/image_test.go | 4 + .../package-url/packageurl-go/.gitignore | 2 + .../package-url/packageurl-go/.gitmodules | 3 + .../package-url/packageurl-go/.golangci.yaml | 5 +- .../package-url/packageurl-go/Makefile | 8 + .../package-url/packageurl-go/README.md | 20 +- .../package-url/packageurl-go/packageurl.go | 918 ++++++++++++++---- vendor/modules.txt | 4 +- 11 files changed, 783 insertions(+), 193 deletions(-) create mode 100644 vendor/github.com/package-url/packageurl-go/.gitmodules diff --git a/client/client_export_metadata_test.go b/client/client_export_metadata_test.go index 57a4a37dab54..1e77d7dc7325 100644 --- a/client/client_export_metadata_test.go +++ b/client/client_export_metadata_test.go @@ -184,7 +184,7 @@ func testAttestationBundle(t *testing.T, sb integration.Sandbox) { require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) require.Equal(t, map[string]any{"foo": "1"}, attest.Predicate) - name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsbundle@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) + name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsbundle@latest?platform=%s", registry, url.QueryEscape(platforms.Format(ps[i]))) subjects := []intoto.Subject{{ Name: name, Digest: map[string]string{ @@ -324,7 +324,7 @@ func testAttestationDefaultSubject(t *testing.T, sb integration.Sandbox) { require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) require.Equal(t, map[string]any{"success": true}, attest.Predicate) - name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsemptysubject@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) + name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsemptysubject@latest?platform=%s", registry, url.QueryEscape(platforms.Format(ps[i]))) subjects := []intoto.Subject{{ Name: name, Digest: map[string]string{ @@ -860,7 +860,7 @@ func testExportAttestations(t *testing.T, sb integration.Sandbox, ociArtifact bo if tagged, ok := named.(reference.Tagged); ok { version = tagged.Tag() } - p := fmt.Sprintf("pkg:docker/%s%s@%s?platform=%s", url.QueryEscape(registry), strings.TrimPrefix(name, registry), version, url.PathEscape(platforms.Format(ps[i]))) + p := fmt.Sprintf("pkg:docker/%s%s@%s?platform=%s", registry, strings.TrimPrefix(name, registry), version, url.PathEscape(platforms.Format(ps[i]))) purls[k] = p } diff --git a/go.mod b/go.mod index 2605a17e2cf3..6102b8d467b1 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/runtime-spec v1.3.0 github.com/opencontainers/selinux v1.15.1 - github.com/package-url/packageurl-go v0.1.1 + github.com/package-url/packageurl-go v0.1.7 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pkg/errors v0.9.1 github.com/pkg/profile v1.7.0 diff --git a/go.sum b/go.sum index 18e7a724bd6c..57e7b231e4c4 100644 --- a/go.sum +++ b/go.sum @@ -445,8 +445,8 @@ github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1: github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA= github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= -github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= -github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= +github.com/package-url/packageurl-go v0.1.7 h1:iFWg6tzAjLA6F/qX3M5nZaiMHJgc+p2zxVyr/fY+sZY= +github.com/package-url/packageurl-go v0.1.7/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= diff --git a/util/purl/image_test.go b/util/purl/image_test.go index 58ae6f96ea82..826c5764ac39 100644 --- a/util/purl/image_test.go +++ b/util/purl/image_test.go @@ -56,6 +56,10 @@ func TestRefToPURL(t *testing.T) { ref: "ghcr.io/foo/bar", expected: "pkg:docker/ghcr.io/foo/bar@latest", }, + { + ref: "localhost:5000/foo/bar:latest", + expected: "pkg:docker/localhost:5000/foo/bar@latest", + }, { ref: "busybox", platform: testPlatform, diff --git a/vendor/github.com/package-url/packageurl-go/.gitignore b/vendor/github.com/package-url/packageurl-go/.gitignore index a1338d68517e..b5b0dd3f7c34 100644 --- a/vendor/github.com/package-url/packageurl-go/.gitignore +++ b/vendor/github.com/package-url/packageurl-go/.gitignore @@ -12,3 +12,5 @@ # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ + +testdata/test-suite-data.json diff --git a/vendor/github.com/package-url/packageurl-go/.gitmodules b/vendor/github.com/package-url/packageurl-go/.gitmodules new file mode 100644 index 000000000000..ffc4fb716db9 --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/.gitmodules @@ -0,0 +1,3 @@ +[submodule "testdata/purl-spec"] + path = testdata/purl-spec + url = https://github.com/package-url/purl-spec diff --git a/vendor/github.com/package-url/packageurl-go/.golangci.yaml b/vendor/github.com/package-url/packageurl-go/.golangci.yaml index 73a5741c9270..490c7a0dc4bd 100644 --- a/vendor/github.com/package-url/packageurl-go/.golangci.yaml +++ b/vendor/github.com/package-url/packageurl-go/.golangci.yaml @@ -1,17 +1,14 @@ # individual linter configs go here -linters-settings: +linters-settings: {} # default linters are enabled `golangci-lint help linters` linters: disable-all: true enable: - - deadcode - errcheck - gosimple - govet - ineffassign - staticcheck - - structcheck - typecheck - unused - - varcheck \ No newline at end of file diff --git a/vendor/github.com/package-url/packageurl-go/Makefile b/vendor/github.com/package-url/packageurl-go/Makefile index f799baaeb050..4e7120be7499 100644 --- a/vendor/github.com/package-url/packageurl-go/Makefile +++ b/vendor/github.com/package-url/packageurl-go/Makefile @@ -1,8 +1,16 @@ .PHONY: test clean lint test: + git submodule update --init + git submodule update --remote go test -v -cover ./... +fuzz: + go test -fuzztime=1m -fuzz . + +clean: + find . -name "test-suite-data.json" | xargs rm -f + lint: go get -u golang.org/x/lint/golint golint -set_exit_status diff --git a/vendor/github.com/package-url/packageurl-go/README.md b/vendor/github.com/package-url/packageurl-go/README.md index b7fd200e79db..47856e700518 100644 --- a/vendor/github.com/package-url/packageurl-go/README.md +++ b/vendor/github.com/package-url/packageurl-go/README.md @@ -58,7 +58,7 @@ func main() { Testing using the normal ``go test`` command. Using ``make test`` will pull the test fixtures shared between all package-url projects and then execute the tests. ``` -$ make test +curl -Ls https://raw.githubusercontent.com/package-url/purl-spec/master/test-suite-data.json -o testdata/test-suite-data.json go test -v -cover ./... === RUN TestFromStringExamples --- PASS: TestFromStringExamples (0.00s) @@ -69,6 +69,22 @@ go test -v -cover ./... === RUN TestQualifiersMapConversion --- PASS: TestQualifiersMapConversion (0.00s) PASS -coverage: 90.7% of statements + github.com/package-url/packageurl-go coverage: 90.7% of statements ok github.com/package-url/packageurl-go 0.004s coverage: 90.7% of statements ``` + +## Fuzzing + +Fuzzing is done with standard [Go fuzzing](https://go.dev/doc/fuzz/), introduced in Go 1.18. + +Fuzz tests check for inputs that cause `FromString` to panic. + +Using `make fuzz` will run fuzz tests for one minute. + +To run fuzz tests longer: + +``` +go test -fuzztime=60m -fuzz . +``` + +Or omit `-fuzztime` entirely to run indefinitely. diff --git a/vendor/github.com/package-url/packageurl-go/packageurl.go b/vendor/github.com/package-url/packageurl-go/packageurl.go index 771ddc3727a6..518b0e16740f 100644 --- a/vendor/github.com/package-url/packageurl-go/packageurl.go +++ b/vendor/github.com/package-url/packageurl-go/packageurl.go @@ -28,7 +28,7 @@ import ( "fmt" "net/url" "regexp" - "sort" + "slices" "strings" ) @@ -39,18 +39,32 @@ var ( // '-' and '_' (period, dash and underscore). // - A key cannot start with a number. QualifierKeyPattern = regexp.MustCompile(`^[A-Za-z\.\-_][0-9A-Za-z\.\-_]*$`) + // TypePattern describes a valid type: + // + // - The type must be composed only of ASCII letters and numbers, '.', + // '+' and '-' (period, plus and dash). + // - A type cannot start with a number. + TypePattern = regexp.MustCompile(`^[A-Za-z\.\-\+][0-9A-Za-z\.\-\+]*$`) ) // These are the known purl types as defined in the spec. Some of these require // special treatment during parsing. // https://github.com/package-url/purl-spec#known-purl-types var ( + // TypeAlpm is a pkg:alpm purl. + TypeAlpm = "alpm" + // TypeApk is a pkg:apk purl. + TypeApk = "apk" // TypeBitbucket is a pkg:bitbucket purl. TypeBitbucket = "bitbucket" - // TypeCocoapods is a pkg:cocoapods purl. - TypeCocoapods = "cocoapods" + // TypeBitnami is a pkg:bitnami purl. + TypeBitnami = "bitnami" // TypeCargo is a pkg:cargo purl. TypeCargo = "cargo" + // TypeChromeExtension is a pkg:chrome-extension purl. + TypeChromeExtension = "chrome-extension" + // TypeCocoapods is a pkg:cocoapods purl. + TypeCocoapods = "cocoapods" // TypeComposer is a pkg:composer purl. TypeComposer = "composer" // TypeConan is a pkg:conan purl. @@ -75,24 +89,180 @@ var ( TypeHackage = "hackage" // TypeHex is a pkg:hex purl. TypeHex = "hex" + // TypeHuggingface is pkg:huggingface purl. + TypeHuggingface = "huggingface" + // TypeMLflow is pkg:mlflow purl. + TypeMLFlow = "mlflow" // TypeMaven is a pkg:maven purl. TypeMaven = "maven" // TypeNPM is a pkg:npm purl. TypeNPM = "npm" // TypeNuget is a pkg:nuget purl. TypeNuget = "nuget" - // TypeOCI is a pkg:oci purl + // TypeOCI is a pkg:oci purl. TypeOCI = "oci" + // TypeOTP is a pkg:otp purl. + TypeOTP = "otp" + // TypePub is a pkg:pub purl. + TypePub = "pub" // TypePyPi is a pkg:pypi purl. TypePyPi = "pypi" + // TypeQPKG is a pkg:qpkg purl. + TypeQpkg = "qpkg" // TypeRPM is a pkg:rpm purl. TypeRPM = "rpm" - // TypeSwift is pkg:swift purl + // TypeSWID is a pkg:swid purl. + TypeSWID = "swid" + // TypeSwift is a pkg:swift purl. TypeSwift = "swift" - // TypeHuggingface is pkg:huggingface purl. - TypeHuggingface = "huggingface" - // TypeMLflow is pkg:mlflow purl. - TypeMLFlow = "mlflow" + // TypeVcpkg is a pkg:vcpkg purl. + TypeVcpkg = "vcpkg" + // TypeVSCodeExtension is a pkg:vscode-extension purl. + TypeVSCodeExtension = "vscode-extension" + // TypeYocto is a pkg:yocto purl. + TypeYocto = "yocto" + + // KnownTypes is a map of types that are officially supported by the spec. + // See https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#known-purl-types + KnownTypes = map[string]struct{}{ + TypeAlpm: {}, + TypeApk: {}, + TypeBitbucket: {}, + TypeBitnami: {}, + TypeCargo: {}, + TypeChromeExtension: {}, + TypeCocoapods: {}, + TypeComposer: {}, + TypeConan: {}, + TypeConda: {}, + TypeCpan: {}, + TypeCran: {}, + TypeDebian: {}, + TypeDocker: {}, + TypeGem: {}, + TypeGeneric: {}, + TypeGithub: {}, + TypeGolang: {}, + TypeHackage: {}, + TypeHex: {}, + TypeHuggingface: {}, + TypeMaven: {}, + TypeMLFlow: {}, + TypeNPM: {}, + TypeNuget: {}, + TypeOCI: {}, + TypeOTP: {}, + TypePub: {}, + TypePyPi: {}, + TypeQpkg: {}, + TypeRPM: {}, + TypeSWID: {}, + TypeSwift: {}, + TypeVcpkg: {}, + TypeVSCodeExtension: {}, + TypeYocto: {}, + } + + TypeApache = "apache" + TypeAndroid = "android" + TypeAtom = "atom" + TypeBower = "bower" + TypeBrew = "brew" + TypeBuildroot = "buildroot" + TypeCarthage = "carthage" + TypeChef = "chef" + TypeChocolatey = "chocolatey" + TypeClojars = "clojars" + TypeCoreos = "coreos" + TypeCpan = "cpan" + TypeCtan = "ctan" + TypeCrystal = "crystal" + TypeDrupal = "drupal" + TypeDtype = "dtype" + TypeDub = "dub" + TypeElm = "elm" + TypeEclipse = "eclipse" + TypeGitea = "gitea" + TypeGitlab = "gitlab" + TypeGradle = "gradle" + TypeGuix = "guix" + TypeHaxe = "haxe" + TypeHelm = "helm" + TypeJulia = "julia" + TypeLua = "lua" + TypeMelpa = "melpa" + TypeMeteor = "meteor" + TypeNim = "nim" + TypeNix = "nix" + TypeOpam = "opam" + TypeOpenwrt = "openwrt" + TypeOsgi = "osgi" + TypeP2 = "p2" + TypePear = "pear" + TypePecl = "pecl" + TypePERL6 = "perl6" + TypePlatformio = "platformio" + TypeEbuild = "ebuild" + TypePuppet = "puppet" + TypeSourceforge = "sourceforge" + TypeSublime = "sublime" + TypeTerraform = "terraform" + TypeVagrant = "vagrant" + TypeVim = "vim" + TypeWORDPRESS = "wordpress" + + // CandidateTypes is a map of types that are not yet officially supported by the spec, + // but are being considered for inclusion. + // See https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#other-candidate-types-to-define + CandidateTypes = map[string]struct{}{ + TypeApache: {}, + TypeAndroid: {}, + TypeAtom: {}, + TypeBower: {}, + TypeBrew: {}, + TypeBuildroot: {}, + TypeCarthage: {}, + TypeChef: {}, + TypeChocolatey: {}, + TypeClojars: {}, + TypeCoreos: {}, + TypeCtan: {}, + TypeCrystal: {}, + TypeDrupal: {}, + TypeDtype: {}, + TypeDub: {}, + TypeElm: {}, + TypeEclipse: {}, + TypeGitea: {}, + TypeGitlab: {}, + TypeGradle: {}, + TypeGuix: {}, + TypeHaxe: {}, + TypeHelm: {}, + TypeJulia: {}, + TypeLua: {}, + TypeMelpa: {}, + TypeMeteor: {}, + TypeNim: {}, + TypeNix: {}, + TypeOpam: {}, + TypeOpenwrt: {}, + TypeOsgi: {}, + TypeP2: {}, + TypePear: {}, + TypePecl: {}, + TypePERL6: {}, + TypePlatformio: {}, + TypeEbuild: {}, + TypePuppet: {}, + TypeSourceforge: {}, + TypeSublime: {}, + TypeTerraform: {}, + TypeVagrant: {}, + TypeVim: {}, + TypeWORDPRESS: {}, + TypeYocto: {}, + } ) // Qualifier represents a single key=value qualifier in the package url @@ -101,27 +271,77 @@ type Qualifier struct { Value string } +// String returns a canonical string representation of the qualifier according to [SPEC]. +// +// [SPEC] https://github.com/package-url/purl-spec/blob/main/PURL-SPECIFICATION.rst#rules-for-each-purl-component func (q Qualifier) String() string { // A value must be a percent-encoded string - return fmt.Sprintf("%s=%s", q.Key, url.PathEscape(q.Value)) + var b strings.Builder + escapeQualifier(&b, q.Key) + b.WriteByte('=') + escapeQualifier(&b, q.Value) + return b.String() } // Qualifiers is a slice of key=value pairs, with order preserved as it appears // in the package URL. type Qualifiers []Qualifier +// String returns a canonical string representation of the qualifiers as keys + values. +// Canonical form requires qualifier keys to be lexicographically ordered. +// The leading `?` qualifier component delimiter is excluded. +func (q Qualifiers) String() string { + if len(q) == 0 { + return "" + } + slices.SortFunc(q, func(a, b Qualifier) int { return strings.Compare(a.Key, b.Key) }) + var b strings.Builder + // Estimate capacity: each qualifier needs key + "=" + value + "&" + b.Grow(len(q) * 32) + for i, qq := range q { + if i > 0 { + b.WriteByte('&') + } + escapeQualifier(&b, qq.Key) + b.WriteByte('=') + escapeQualifier(&b, qq.Value) + } + return b.String() +} + +// escapeQualifier escapes a qualifier key or value for use in the query string. +// Per purl spec, ':' is NOT encoded but most other special characters are. +func escapeQualifier(b *strings.Builder, s string) { + for i := 0; i < len(s); i++ { + c := s[i] + if isQualifierSafe(c) { + b.WriteByte(c) + } else { + writePercentEncodedByte(b, c) + } + } +} + +// isQualifierSafe reports whether c can appear unencoded in a purl qualifier. +// Per purl spec, ':' is allowed unencoded in qualifier values. +func isQualifierSafe(c byte) bool { + // Standard unreserved characters plus ':' + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' || c == ':' +} + // QualifiersFromMap constructs a Qualifiers slice from a string map. To get a // deterministic qualifier order (despite maps not providing any iteration order // guarantees) the returned Qualifiers are sorted in increasing order of key. func QualifiersFromMap(mm map[string]string) Qualifiers { - q := Qualifiers{} + q := make(Qualifiers, 0, len(mm)) for k, v := range mm { q = append(q, Qualifier{Key: k, Value: v}) } // sort for deterministic qualifier order - sort.Slice(q, func(i int, j int) bool { return q[i].Key < q[j].Key }) + slices.SortFunc(q, func(a, b Qualifier) int { return strings.Compare(a.Key, b.Key) }) return q } @@ -139,12 +359,57 @@ func (qq Qualifiers) Map() map[string]string { return m } -func (qq Qualifiers) String() string { - var kvPairs []string - for _, q := range qq { - kvPairs = append(kvPairs, q.String()) +func (qq *Qualifiers) Normalize() error { + qs := *qq + normedQQ := make(Qualifiers, 0, len(qs)) + for _, q := range qs { + if q.Key == "" { + return fmt.Errorf("key is missing from qualifier: %v", q) + } + if q.Value == "" { + // Empty values are equivalent to the key being omitted from the PackageURL. + continue + } + key := toLowerASCII(q.Key) + if !validQualifierKey(key) { + return fmt.Errorf("invalid qualifier key: %q", key) + } + normedQQ = append(normedQQ, Qualifier{key, q.Value}) } - return strings.Join(kvPairs, "&") + slices.SortFunc(normedQQ, func(a, b Qualifier) int { return strings.Compare(a.Key, b.Key) }) + for i := 1; i < len(normedQQ); i++ { + if normedQQ[i-1].Key == normedQQ[i].Key { + return fmt.Errorf("duplicate qualifier key: %q", normedQQ[i].Key) + } + } + *qq = normedQQ + return nil +} + +// toLowerASCII returns s with all ASCII uppercase letters converted to lowercase. +// It avoids allocation if s is already lowercase. +func toLowerASCII(s string) string { + needsConvert := -1 + for i := 0; i < len(s); i++ { + if c := s[i]; c >= 'A' && c <= 'Z' { + needsConvert = i + break + } + } + if needsConvert < 0 { + return s + } + + b := make([]byte, len(s)) + copy(b, s[:needsConvert]) + for i := needsConvert; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 32 + } + b[i] = c + } + return string(b) } // PackageURL is the struct representation of the parts that make a package url @@ -171,182 +436,373 @@ func NewPackageURL(purlType, namespace, name, version string, } } -// ToString returns the human-readable instance of the PackageURL structure. -// This is the literal purl as defined by the spec. +// ToString returns a canonical string representation of the qualifier according to [SPEC]. +// +// [SPEC] https://github.com/package-url/purl-spec/blob/main/PURL-SPECIFICATION.rst#rules-for-each-purl-component func (p *PackageURL) ToString() string { - // Start with the type and a colon - purl := fmt.Sprintf("pkg:%s/", p.Type) - // Add namespaces if provided + var b strings.Builder + // Estimate capacity for typical purl, including component delimiters. + b.Grow(4 + len(p.Type) + 1 + len(p.Namespace) + 1 + len(p.Name) + 1 + len(p.Version) + len(p.Subpath) + 32) + + b.WriteString("pkg:") + b.WriteString(p.Type) + + // Each namespace segment shall be a percent-encoded string. if p.Namespace != "" { - var ns []string - for _, item := range strings.Split(p.Namespace, "/") { - ns = append(ns, url.QueryEscape(item)) + start := 0 + for i := 0; i <= len(p.Namespace); i++ { + if i == len(p.Namespace) || p.Namespace[i] == '/' { + if i > start { + b.WriteByte('/') + writePercentEncodedString(&b, p.Namespace[start:i]) + } + start = i + 1 + } } - purl = purl + strings.Join(ns, "/") + "/" } - // The name is always required and must be a percent-encoded string - // Use url.QueryEscape instead of PathEscape, as it handles @ signs - purl = purl + url.QueryEscape(p.Name) - // If a version is provided, add it after the at symbol + + // A name shall be a percent-encoded string. + b.WriteByte('/') + writePercentEncodedString(&b, p.Name) + if p.Version != "" { - // A name must be a percent-encoded string - purl = purl + "@" + url.PathEscape(p.Version) + // A version shall be a percent-encoded string. + b.WriteByte('@') + writePercentEncodedString(&b, p.Version) } - // Iterate over qualifiers and make groups of key=value - var qualifiers []string - for _, q := range p.Qualifiers { - qualifiers = append(qualifiers, q.String()) + if len(p.Qualifiers) > 0 { + b.WriteByte('?') + b.WriteString(p.Qualifiers.String()) } - // If there are one or more key=value pairs, append on the package url - if len(qualifiers) != 0 { - purl = purl + "?" + strings.Join(qualifiers, "&") - } - // Add a subpath if available + + // Each subpath segment shall be a percent-encoded string. if p.Subpath != "" { - purl = purl + "#" + p.Subpath + b.WriteByte('#') + escapeSubpath(&b, p.Subpath) } - return purl + + return b.String() } func (p PackageURL) String() string { return p.ToString() } -// FromString parses a valid package url string into a PackageURL structure +// FromString parses a valid package url string into a [PackageURL]. func FromString(purl string) (PackageURL, error) { - initialIndex := strings.Index(purl, "#") - // Start with purl being stored in the remainder - remainder := purl - substring := "" - if initialIndex != -1 { - initialSplit := strings.SplitN(purl, "#", 2) - remainder = initialSplit[0] - rightSide := initialSplit[1] - rightSide = strings.TrimLeft(rightSide, "/") - rightSide = strings.TrimRight(rightSide, "/") - var rightSides []string - - for _, item := range strings.Split(rightSide, "/") { - item = strings.Replace(item, ".", "", -1) - item = strings.Replace(item, "..", "", -1) - if item != "" { - i, err := url.PathUnescape(item) - if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape path: %s", err) - } - rightSides = append(rightSides, i) - } - } - substring = strings.Join(rightSides, "/") - } - qualifiers := Qualifiers{} - index := strings.LastIndex(remainder, "?") - // If we don't have anything to split then return an empty result - if index != -1 { - qualifier := remainder[index+1:] - for _, item := range strings.Split(qualifier, "&") { - kv := strings.Split(item, "=") - key := strings.ToLower(kv[0]) - key, err := url.PathUnescape(key) - if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape qualifier key: %s", err) - } - if !validQualifierKey(key) { - return PackageURL{}, fmt.Errorf("invalid qualifier key: '%s'", key) - } - // TODO - // - If the `key` is `checksums`, split the `value` on ',' to create - // a list of `checksums` - if kv[1] == "" { - continue - } - value, err := url.PathUnescape(kv[1]) - if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape qualifier value: %s", err) - } - qualifiers = append(qualifiers, Qualifier{key, value}) + // Check scheme + if len(purl) < 4 || toLowerASCII(purl[:4]) != "pkg:" { + return PackageURL{}, fmt.Errorf("purl scheme is not \"pkg\": %q", purl) + } + + remainder := purl[4:] + + // Handle pkg:/ and pkg:// formats by stripping leading slashes + for len(remainder) > 0 && remainder[0] == '/' { + remainder = remainder[1:] + } + + // Extract fragment (subpath) + var subpath string + if idx := strings.IndexByte(remainder, '#'); idx != -1 { + // A subpath is a percent-encoded string and must be decoded like the + // other components (namespace, name, version). + decoded, err := percentDecodeSubpath(remainder[idx+1:]) + if err != nil { + return PackageURL{}, fmt.Errorf("error unescaping subpath: %w", err) } - remainder = remainder[:index] + subpath = decoded + remainder = remainder[:idx] } - nextSplit := strings.SplitN(remainder, ":", 2) - if len(nextSplit) != 2 || nextSplit[0] != "pkg" { - return PackageURL{}, errors.New("scheme is missing") + // Extract query string (qualifiers) + var rawQuery string + if idx := strings.IndexByte(remainder, '?'); idx != -1 { + rawQuery = remainder[idx+1:] + remainder = remainder[:idx] } - // leading slashes after pkg: are to be ignored (pkg://maven is - // equivalent to pkg:maven) - remainder = strings.TrimLeft(nextSplit[1], "/") - nextSplit = strings.SplitN(remainder, "/", 2) - if len(nextSplit) != 2 { - return PackageURL{}, errors.New("type is missing") + // Extract type + typ, remainder, ok := strings.Cut(remainder, "/") + if !ok { + return PackageURL{}, fmt.Errorf("purl is missing type or name") } - // purl type is case-insensitive, canonical form is lower-case - purlType := strings.ToLower(nextSplit[0]) - remainder = nextSplit[1] + typ = toLowerASCII(typ) - index = strings.LastIndex(remainder, "/") - name := typeAdjustName(purlType, remainder[index+1:], qualifiers) - version := "" + // Parse qualifiers + qualifiers, err := parseQualifiers(rawQuery) + if err != nil { + return PackageURL{}, fmt.Errorf("invalid qualifiers: %w", err) + } + + // Parse namespace, name, version + namespace, name, version, err := separateNamespaceNameVersion(typ, remainder) + if err != nil { + return PackageURL{}, err + } + + pURL := PackageURL{ + Qualifiers: qualifiers, + Type: typ, + Namespace: namespace, + Name: name, + Version: version, + Subpath: subpath, + } + + err = pURL.Normalize() + return pURL, err +} + +// Normalize converts p to its canonical form, returning an error if p is invalid. +func (p *PackageURL) Normalize() error { + typ := strings.ToLower(p.Type) + if !validType(typ) { + return fmt.Errorf("invalid type %q", typ) + } + namespace := strings.Trim(p.Namespace, "/") + if err := p.Qualifiers.Normalize(); err != nil { + return fmt.Errorf("invalid qualifiers: %v", err) + } + if p.Name == "" { + return errors.New("purl is missing name") + } + subpath := strings.Trim(p.Subpath, "/") + segs := strings.Split(p.Subpath, "/") + for i, s := range segs { + if (s == "." || s == "..") && i != 0 { + return fmt.Errorf("invalid Package URL subpath: %q", p.Subpath) + } + } + *p = PackageURL{ + Type: typ, + Namespace: typeAdjustNamespace(typ, namespace), + Name: typeAdjustName(typ, p.Name, p.Qualifiers), + Version: typeAdjustVersion(typ, p.Version), + Qualifiers: p.Qualifiers, + Subpath: subpath, + } + return validCustomRules(*p) +} + +// percentDecode percent-decodes a purl component according to [Encoding]. +// +// [Encoding] https://github.com/package-url/purl-spec/blob/main/PURL-SPECIFICATION.rst#character-encoding +func percentDecode(s string) (string, error) { + // Note: uses [url.PathUnescape] instead of [url.QueryUnescape] to treat '+' characters + // literally (not as space). + return url.PathUnescape(s) +} - atIndex := strings.Index(name, "@") - if atIndex != -1 { - v, err := url.PathUnescape(name[atIndex+1:]) +// percentDecodeSubpath percent-decodes a subpath by decoding each '/'-separated +// segment on its own, so an encoded slash inside a segment is not treated as a +// segment separator. This mirrors the per-segment decoding done for the namespace. +func percentDecodeSubpath(s string) (string, error) { + if !strings.Contains(s, "%") { + return s, nil + } + segments := strings.Split(s, "/") + for i, segment := range segments { + decoded, err := percentDecode(segment) if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape purl version: %s", err) + return "", err + } + segments[i] = decoded + } + return strings.Join(segments, "/"), nil +} + +// writePercentEncodedString percent-encodes s as a purl path segment and writes it to the builder. +func writePercentEncodedString(b *strings.Builder, s string) { + // Check if we need to escape at all + needsEscape := false + for i := 0; i < len(s); i++ { + if !isPathSegmentSafe(s[i]) { + needsEscape = true + break + } + } + if !needsEscape { + b.WriteString(s) + return + } + + // Need to escape - process character by character + for i := 0; i < len(s); i++ { + c := s[i] + if isPathSegmentSafe(c) { + b.WriteByte(c) + } else { + writePercentEncodedByte(b, c) } - version = typeAdjustVersion(purlType, v) + } +} - unecapeName, err := url.PathUnescape(name[:atIndex]) +// writePercentEncodedByte percent-encodes the given byte as per [RFC-3986] and writes the result to +// the supplied [strings.Builder]. +// +// [RFC-3986]: https://datatracker.ietf.org/doc/html/rfc3986#page-12 +func writePercentEncodedByte(b *strings.Builder, c byte) { + b.WriteByte('%') + b.WriteByte(hexUpper[c>>4]) + b.WriteByte(hexUpper[c&0x0f]) +} + +// isPathSegmentSafe reports whether c can appear unencoded in a purl path segment. +// Per the purl spec, the only characters that must NOT be percent-encoded are: +// alphanumerics, the unreserved punctuation '-', '.', '_', '~', and ':'. +// All other characters — including RFC 3986 sub-delimiters such as '(', ')', '!', +// '$', '&', "'", '*', ',', ';', '=' — must be percent-encoded. +// +// See https://ecma-tc54.github.io/ECMA-427/#sec-purl-specification-character-encoding +func isPathSegmentSafe(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' || c == ':' +} + +// escapeSubpath escapes a subpath, handling segments separated by '/'. +func escapeSubpath(b *strings.Builder, s string) { + for i := 0; i < len(s); i++ { + c := s[i] + if c == '/' { + b.WriteByte('/') + } else if isPathSegmentSafe(c) { + b.WriteByte(c) + } else { + writePercentEncodedByte(b, c) + } + } +} + +const hexUpper = "0123456789ABCDEF" + +// separateNamespaceNameVersion parses the /@ part of a purl (the +// remainder parameter) into its constituent components. It aims to follow the [HOW-TO-PARSE] +// procedure. +// +// [HOW-TO-PARSE]: https://github.com/package-url/purl-spec/blob/main/docs/how-to-parse.md +func separateNamespaceNameVersion(purlType string, remainder string) (ns, name, version string, err error) { + // NPM purls can have a namespace ("scope") that starts with an '@' character. + // For example, "pkg:npm/@babel/core". + // For any other purl type this indicates malformed purl input. + if purlType != TypeNPM && strings.HasPrefix(remainder, "@") { + return "", "", "", fmt.Errorf("purl is missing name") + } + // A leading '@' is only valid for npm when it introduces a scope, which + // requires a '/' to separate the scope from the name (for example, + // "pkg:npm/@babel/core"). A remainder like "@4.17.21" has no '/', so it is a + // bare scope with no name and must be rejected the same way as v0.1.3 did. + if purlType == TypeNPM && strings.HasPrefix(remainder, "@") && !strings.Contains(remainder, "/") { + return "", "", "", fmt.Errorf("purl is missing name") + } + + // Split the remainder once from right on '@'. + // The left side is the remainder. + if strings.LastIndex(remainder, "@") > 0 { + remainder, version = rightmostSplit(remainder, "@") + // Percent-decode the right side. This is the version. + version, err = percentDecode(version) if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape purl name: %s", err) + return "", "", "", fmt.Errorf("error unescaping version: %w", err) } - name = unecapeName } - var namespaces []string - if index != -1 { - remainder = remainder[:index] + // Split this once from right on '/'. + // The left side is the remainder. + remainder, name = rightmostSplit(remainder, "/") + // Percent-decode the right side. This is the name. + name, err = percentDecode(name) + if err != nil { + return "", "", "", fmt.Errorf("error unescaping name: %w", err) + } - for _, item := range strings.Split(remainder, "/") { - if item != "" { - unescaped, err := url.PathUnescape(item) - if err != nil { - return PackageURL{}, fmt.Errorf("failed to unescape path: %s", err) - } - namespaces = append(namespaces, unescaped) - } + // Split the remainder on '/'. + segments := strings.Split(remainder, "/") + nsSegments := []string{} + for _, segment := range segments { + // Discard any empty segment from that split. + if segment == "" { + continue } + // Percent-decode each segment. + nsSegment, err := percentDecode(segment) + if err != nil { + return "", "", "", fmt.Errorf("error unescaping namespace: %w", err) + } + nsSegments = append(nsSegments, nsSegment) } - namespace := strings.Join(namespaces, "/") - namespace = typeAdjustNamespace(purlType, namespace) + // Join segments back with a '/'. + ns = strings.Join(nsSegments, "/") - // Fail if name is empty at this point if name == "" { - return PackageURL{}, errors.New("name is required") + return "", "", "", fmt.Errorf("purl is missing name") } - err := validCustomRules(purlType, name, namespace, version, qualifiers) - if err != nil { - return PackageURL{}, err + return ns, name, version, nil +} + +// rightmostSplit splits the input path on a given delimiter such that the lhs returns the string to +// the left of the right-most delimiter and rhs return the string to the right of the right-most +// delimiter. For example, given path "github.com/package-url/packageurl-go" and delimiter "/" the +// lhs will be "github.com/package-url" and rhs will be "packageurl-go". +func rightmostSplit(path string, delim string) (lhs, rhs string) { + lastSepIdx := strings.LastIndex(path, delim) + rhs = path[lastSepIdx+1:] + if lastSepIdx >= 0 { + lhs = path[:lastSepIdx] } + return lhs, rhs +} - return PackageURL{ - Type: purlType, - Namespace: namespace, - Name: name, - Version: version, - Qualifiers: qualifiers, - Subpath: substring, - }, nil +func parseQualifiers(rawQuery string) (Qualifiers, error) { + // we need to parse the qualifiers ourselves and cannot rely on the `url.Query` type because + // that uses a map, meaning it's unordered. We want to keep the order of the qualifiers, so this + // function re-implements the `url.parseQuery` function based on our `Qualifier` type. Most of + // the code here is taken from `url.parseQuery`. + q := Qualifiers{} + for rawQuery != "" { + var key string + key, rawQuery, _ = strings.Cut(rawQuery, "&") + if strings.Contains(key, ";") { + return nil, fmt.Errorf("invalid semicolon separator in query") + } + if key == "" { + continue + } + // The key is the lowercase left side. + key, value, _ := strings.Cut(key, "=") + key = strings.ToLower(key) + + if !validQualifierKey(key) { + return nil, fmt.Errorf("invalid qualifier key: '%s'", key) + } + + // The value is the percent-decoded right side. + value, err := percentDecode(value) + if err != nil { + return nil, fmt.Errorf("error unescaping qualifier value %q", value) + } + + q = append(q, Qualifier{Key: key, Value: value}) + } + return q, nil } // Make any purl type-specific adjustments to the parsed namespace. // See https://github.com/package-url/purl-spec#known-purl-types func typeAdjustNamespace(purlType, ns string) string { switch purlType { - case TypeBitbucket, TypeDebian, TypeGithub, TypeGolang, TypeNPM, TypeRPM, TypeComposer: + case TypeAlpm, + TypeApk, + TypeBitbucket, + TypeBrew, + TypeComposer, + TypeDebian, + TypeGithub, + TypeGolang, + TypeRPM, + TypeQpkg: return strings.ToLower(ns) } return ns @@ -357,7 +813,16 @@ func typeAdjustNamespace(purlType, ns string) string { func typeAdjustName(purlType, name string, qualifiers Qualifiers) string { quals := qualifiers.Map() switch purlType { - case TypeBitbucket, TypeDebian, TypeGithub, TypeGolang, TypeNPM, TypeComposer: + case TypeAlpm, + TypeApk, + TypeBitbucket, + TypeBitnami, + TypeBrew, + TypeChromeExtension, + TypeComposer, + TypeDebian, + TypeGithub, + TypeGolang: return strings.ToLower(name) case TypePyPi: return strings.ToLower(strings.ReplaceAll(name, "_", "-")) @@ -380,16 +845,15 @@ func typeAdjustVersion(purlType, version string) string { // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow func adjustMlflowName(name string, qualifiers map[string]string) string { if repo, ok := qualifiers["repository_url"]; ok { - if strings.Contains(repo, "azureml") { - // Azure ML is case-sensitive and must be kept as-is - return name - } else if strings.Contains(repo, "databricks") { + if strings.Contains(repo, "databricks") { // Databricks is case-insensitive and must be lowercased return strings.ToLower(name) - } else { - // Unknown repository type, keep as-is - return name } + + // Azure ML is case-sensitive and must be kept as-is + // Unknown repository type, keep as-is + return name + } else { // No repository qualifier given, keep as-is return name @@ -397,41 +861,137 @@ func adjustMlflowName(name string, qualifiers map[string]string) string { } // validQualifierKey validates a qualifierKey against our QualifierKeyPattern. +// The key must be composed only of ASCII letters and numbers, '.', '-' and '_'. +// A key cannot start with a number. +// See https://ecma-tc54.github.io/ECMA-427/#sec-purl-specification-rules-qualifiers. func validQualifierKey(key string) bool { - return QualifierKeyPattern.MatchString(key) + if len(key) == 0 { + return false + } + // First character: must be a-z, A-Z, '.', '-', or '_' + c := key[0] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '.' || c == '-' || c == '_') { + return false + } + // Remaining characters: a-z, A-Z, 0-9, '.', '-', or '_' + for i := 1; i < len(key); i++ { + c = key[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') { + return false + } + } + return true +} + +// validType validates a type against our TypePattern. +// The type must be composed only of ASCII letters and numbers, '.', '+' and '-'. +// A type cannot start with a number. +// See https://ecma-tc54.github.io/ECMA-427/#sec-purl-specification-rules-type. +func validType(typ string) bool { + if len(typ) == 0 { + return false + } + // First character: must be a-z, A-Z, '.', '-', or '+' + c := typ[0] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '.' || c == '-' || c == '+') { + return false + } + // Remaining characters: a-z, A-Z, 0-9, '.', '-', or '+' + for i := 1; i < len(typ); i++ { + c = typ[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+') { + return false + } + } + return true +} + +// validChromeExtensionName checks the name against ^[a-z]{32}$. +func validChromeExtensionName(name string) bool { + if len(name) != 32 { + return false + } + for i := 0; i < len(name); i++ { + if c := name[i]; c < 'a' || c > 'z' { + return false + } + } + return true +} + +// validChromeExtensionVersion checks the version against ^\d+(\.\d+){0,3}$. +func validChromeExtensionVersion(version string) bool { + segments := 1 + digitsInSegment := 0 + for i := 0; i < len(version); i++ { + c := version[i] + if c >= '0' && c <= '9' { + digitsInSegment++ + continue + } + if c == '.' { + if digitsInSegment == 0 { + return false + } + segments++ + digitsInSegment = 0 + continue + } + return false + } + return digitsInSegment > 0 && segments <= 4 } // validCustomRules evaluates additional rules for each package url type, as specified in the package-url specification. // On success, it returns nil. On failure, a descriptive error will be returned. -func validCustomRules(purlType, name, ns, version string, qualifiers Qualifiers) error { - q := qualifiers.Map() - switch purlType { - case TypeConan: - if ns != "" { - if val, ok := q["channel"]; ok { - if val == "" { - return errors.New("the qualifier channel must be not empty if namespace is present") - } - } else { - return errors.New("channel qualifier does not exist") - } - } else { - if val, ok := q["channel"]; ok { - if val != "" { - return errors.New("namespace is required if channel is non empty") - } - } +func validCustomRules(p PackageURL) error { + switch p.Type { + case TypeChromeExtension: + if p.Namespace != "" { + return errors.New("a chrome-extension purl must not have a namespace") + } + if !validChromeExtensionName(p.Name) { + return errors.New("a chrome-extension name must be 32 lowercase ASCII letters") + } + if p.Version != "" && !validChromeExtensionVersion(p.Version) { + return errors.New("a chrome-extension version must be 1 to 4 dot-separated integers") + } + case TypeCpan: + // It MUST be written uppercase. + if strings.ToUpper(p.Namespace) != p.Namespace { + return errors.New("a cpan purl namespace must use uppercase characters") + } + + // A distribution name MUST NOT contain the string '::'. + distName := p.Name + if strings.Contains(distName, "::") { + return errors.New("a cpan distribution name must not contain '::'") + } + case TypeJulia: + // The spec prohibits a namespace. + if p.Namespace != "" { + return errors.New("a julia purl must not have a namespace") + } + // The spec requires the presence of a uuid qualifier. + if _, ok := p.Qualifiers.Map()["uuid"]; !ok { + return errors.New("a julia purl must have a uuid qualifier") + } + case TypeOTP: + // The spec prohibits a namespace. + if p.Namespace != "" { + return errors.New("an otp purl must not have a namespace") } case TypeSwift: - if ns == "" { + if p.Namespace == "" { return errors.New("namespace is required") } - if version == "" { - return errors.New("version is required") + case TypeVcpkg: + if p.Namespace != "" { + return errors.New("a vcpkg purl must not have a namespace") } - case TypeCran: - if version == "" { - return errors.New("version is required") + case TypeVSCodeExtension: + if p.Namespace == "" { + return errors.New("namespace is required") } } return nil diff --git a/vendor/modules.txt b/vendor/modules.txt index 65947c87d1b2..67ee990bb624 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -872,8 +872,8 @@ github.com/opencontainers/runtime-tools/validate/capabilities github.com/opencontainers/selinux/go-selinux github.com/opencontainers/selinux/go-selinux/label github.com/opencontainers/selinux/pkg/pwalkdir -# github.com/package-url/packageurl-go v0.1.1 -## explicit; go 1.17 +# github.com/package-url/packageurl-go v0.1.7 +## explicit; go 1.18 github.com/package-url/packageurl-go # github.com/pelletier/go-toml/v2 v2.4.3 ## explicit; go 1.21.0