diff --git a/packageurl.go b/packageurl.go index 0c882ef..136f81b 100644 --- a/packageurl.go +++ b/packageurl.go @@ -27,7 +27,6 @@ import ( "errors" "fmt" "net/url" - "path" "regexp" "sort" "strings" @@ -112,41 +111,47 @@ var ( TypeSWID = "swid" // TypeSwift is pkg:swift purl TypeSwift = "swift" + // TypeOTP is a pkg:otp purl. + TypeOTP = "otp" + // TypeVSCodeExtension is a pkg:vscode-extension purl. + TypeVSCodeExtension = "vscode-extension" // 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: {}, - TypeCocoapods: {}, - TypeComposer: {}, - TypeConan: {}, - TypeConda: {}, - TypeCpan: {}, - TypeCran: {}, - TypeDebian: {}, - TypeDocker: {}, - TypeGem: {}, - TypeGeneric: {}, - TypeGithub: {}, - TypeGolang: {}, - TypeHackage: {}, - TypeHex: {}, - TypeHuggingface: {}, - TypeMaven: {}, - TypeMLFlow: {}, - TypeNPM: {}, - TypeNuget: {}, - TypeOCI: {}, - TypePub: {}, - TypePyPi: {}, - TypeQpkg: {}, - TypeRPM: {}, - TypeSWID: {}, - TypeSwift: {}, + TypeAlpm: {}, + TypeApk: {}, + TypeBitbucket: {}, + TypeBitnami: {}, + TypeCargo: {}, + TypeCocoapods: {}, + TypeComposer: {}, + TypeConan: {}, + TypeConda: {}, + TypeCpan: {}, + TypeCran: {}, + TypeDebian: {}, + TypeDocker: {}, + TypeGem: {}, + TypeGeneric: {}, + TypeGithub: {}, + TypeGolang: {}, + TypeHackage: {}, + TypeHex: {}, + TypeHuggingface: {}, + TypeMaven: {}, + TypeMLFlow: {}, + TypeNPM: {}, + TypeNuget: {}, + TypeOCI: {}, + TypePub: {}, + TypePyPi: {}, + TypeQpkg: {}, + TypeRPM: {}, + TypeSWID: {}, + TypeSwift: {}, + TypeOTP: {}, + TypeVSCodeExtension: {}, } TypeApache = "apache" @@ -268,26 +273,59 @@ func (q Qualifier) String() string { type Qualifiers []Qualifier // urlQuery returns a raw URL query with all the qualifiers as keys + values. -func (q Qualifiers) urlQuery() (rawQuery string) { - v := make(url.Values) - for _, qq := range q { - v.Add(qq.Key, qq.Value) +func (q Qualifiers) urlQuery() string { + if len(q) == 0 { + return "" + } + 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 { + b.WriteByte('%') + b.WriteByte(hexUpper[c>>4]) + b.WriteByte(hexUpper[c&0x0f]) + } } - return v.Encode() +} + +// 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 }) + sort.Sort(qualifiersSortable(q)) return q } @@ -324,13 +362,13 @@ func (qq *Qualifiers) Normalize() error { // Empty values are equivalent to the key being omitted from the PackageURL. continue } - key := strings.ToLower(q.Key) + key := toLowerASCII(q.Key) if !validQualifierKey(key) { return fmt.Errorf("invalid qualifier key: %q", key) } normedQQ = append(normedQQ, Qualifier{key, q.Value}) } - sort.Slice(normedQQ, func(i, j int) bool { return normedQQ[i].Key < normedQQ[j].Key }) + sort.Sort(qualifiersSortable(normedQQ)) 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) @@ -340,6 +378,36 @@ func (qq *Qualifiers) Normalize() error { return nil } +// qualifiersSortable implements sort.Interface for Qualifiers to avoid reflection. +type qualifiersSortable Qualifiers + +func (q qualifiersSortable) Len() int { return len(q) } +func (q qualifiersSortable) Less(i, j int) bool { return q[i].Key < q[j].Key } +func (q qualifiersSortable) Swap(i, j int) { q[i], q[j] = q[j], q[i] } + +// toLowerASCII returns s with all ASCII uppercase letters converted to lowercase. +// It avoids allocation if s is already lowercase. +func toLowerASCII(s string) string { + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + // Found an uppercase letter, need to convert + b := make([]byte, len(s)) + copy(b, s[:i]) + for ; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + b[i] = c + 32 + } else { + b[i] = c + } + } + return string(b) + } + } + return s +} + // PackageURL is the struct representation of the parts that make a package url type PackageURL struct { Type string @@ -367,30 +435,46 @@ 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. func (p *PackageURL) ToString() string { - u := &url.URL{ - Scheme: "pkg", - RawQuery: p.Qualifiers.urlQuery(), - Fragment: p.Subpath, - } - - paths := []string{p.Type} - // we need to escape each segment by itself, so that we don't escape "/" in the namespace. - for _, segment := range strings.Split(p.Namespace, "/") { - if segment == "" { - continue + var b strings.Builder + // Estimate capacity for typical purl + 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) + + // Write namespace segments, escaping each one + if p.Namespace != "" { + start := 0 + for i := 0; i <= len(p.Namespace); i++ { + if i == len(p.Namespace) || p.Namespace[i] == '/' { + if i > start { + b.WriteByte('/') + escapeToBuilder(&b, p.Namespace[start:i]) + } + start = i + 1 + } } - paths = append(paths, escape(segment)) } - nameWithVersion := escape(p.Name) + b.WriteByte('/') + escapeToBuilder(&b, p.Name) + if p.Version != "" { - nameWithVersion += "@" + escape(p.Version) + b.WriteByte('@') + escapeToBuilder(&b, p.Version) } - paths = append(paths, nameWithVersion) + if len(p.Qualifiers) > 0 { + b.WriteByte('?') + b.WriteString(p.Qualifiers.urlQuery()) + } + + if p.Subpath != "" { + b.WriteByte('#') + escapeSubpath(&b, p.Subpath) + } - u.Opaque = strings.Join(paths, "/") - return u.String() + return b.String() } func (p PackageURL) String() string { @@ -399,32 +483,47 @@ func (p PackageURL) String() string { // FromString parses a valid package url string into a PackageURL structure func FromString(purl string) (PackageURL, error) { - u, err := url.Parse(purl) - if err != nil { - return PackageURL{}, fmt.Errorf("failed to parse as URL: %w", err) + // Check scheme + if len(purl) < 4 || toLowerASCII(purl[:4]) != "pkg:" { + return PackageURL{}, fmt.Errorf("purl scheme is not \"pkg\": %q", purl) } - if u.Scheme != "pkg" { - return PackageURL{}, fmt.Errorf("purl scheme is not \"pkg\": %q", u.Scheme) + remainder := purl[4:] + + // Handle pkg:/ and pkg:// formats by stripping leading slashes + for len(remainder) > 0 && remainder[0] == '/' { + remainder = remainder[1:] } - p := u.Opaque - // if a purl starts with pkg:/ or even pkg://, we need to fall back to host + path. - if p == "" { - p = strings.TrimPrefix(path.Join(u.Host, u.Path), "/") + // Extract fragment (subpath) + var subpath string + if idx := strings.IndexByte(remainder, '#'); idx != -1 { + subpath = remainder[idx+1:] + remainder = remainder[:idx] } - typ, p, ok := strings.Cut(p, "/") + // Extract query string (qualifiers) + var rawQuery string + if idx := strings.IndexByte(remainder, '?'); idx != -1 { + rawQuery = remainder[idx+1:] + remainder = remainder[:idx] + } + + // Extract type + typ, remainder, ok := strings.Cut(remainder, "/") if !ok { return PackageURL{}, fmt.Errorf("purl is missing type or name") } - typ = strings.ToLower(typ) + typ = toLowerASCII(typ) - qualifiers, err := parseQualifiers(u.RawQuery) + // Parse qualifiers + qualifiers, err := parseQualifiers(rawQuery) if err != nil { return PackageURL{}, fmt.Errorf("invalid qualifiers: %w", err) } - namespace, name, version, err := separateNamespaceNameVersion(p) + + // Parse namespace, name, version + namespace, name, version, err := separateNamespaceNameVersion(remainder) if err != nil { return PackageURL{}, err } @@ -435,7 +534,7 @@ func FromString(purl string) (PackageURL, error) { Namespace: namespace, Name: name, Version: version, - Subpath: u.Fragment, + Subpath: subpath, } err = pURL.Normalize() @@ -467,7 +566,7 @@ func (p *PackageURL) Normalize() error { Namespace: typeAdjustNamespace(typ, namespace), Name: typeAdjustName(typ, p.Name, p.Qualifiers), Version: typeAdjustVersion(typ, p.Version), - Qualifiers: p.Qualifiers, + Qualifiers: typeAdjustQualifiers(typ, p.Qualifiers), Subpath: subpath, } return validCustomRules(*p) @@ -484,6 +583,80 @@ func escape(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +// escapeToBuilder escapes a string in purl-compatible way and writes it to the builder. +// This is more efficient than escape() when building a larger string. +func escapeToBuilder(b *strings.Builder, s string) { + // Check if we need to escape at all + needsEscape := false + for i := 0; i < len(s); i++ { + if !isPurlSafe(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 isPurlSafe(c) { + b.WriteByte(c) + } else { + b.WriteByte('%') + b.WriteByte(hexUpper[c>>4]) + b.WriteByte(hexUpper[c&0x0f]) + } + } +} + +// isPurlSafe reports whether c can appear unencoded in a purl path segment. +// This includes RFC 3986 unreserved characters, most sub-delimiters, and ":" +// but excludes "@" (purl version separator) and "+" (must be encoded per purl spec). +func isPurlSafe(c byte) bool { + // unreserved: A-Z a-z 0-9 - . _ ~ + // sub-delims (excluding +): ! $ & ' ( ) * , ; = + // also allowed in pchar: : + // NOT safe: @ (purl version separator), + (must be %2B), / ? # (URL structure) + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' || + c == '!' || c == '$' || c == '&' || c == '\'' || + c == '(' || c == ')' || c == '*' || + c == ',' || c == ';' || c == '=' || c == ':' +} + +// escapeSubpath escapes a subpath, handling segments separated by '/'. +// In subpaths, '+' must be encoded as %2B (unlike in path segments). +func escapeSubpath(b *strings.Builder, s string) { + for i := 0; i < len(s); i++ { + c := s[i] + if c == '/' { + b.WriteByte('/') + } else if isSubpathSafe(c) { + b.WriteByte(c) + } else { + b.WriteByte('%') + b.WriteByte(hexUpper[c>>4]) + b.WriteByte(hexUpper[c&0x0f]) + } + } +} + +// isSubpathSafe reports whether c can appear unencoded in a purl subpath segment. +// This is similar to isPurlSafe but '+' must be encoded in subpaths. +func isSubpathSafe(c byte) bool { + // Same as isPurlSafe but '+' is NOT safe in subpath + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' || + c == '!' || c == '$' || c == '&' || c == '\'' || + c == '(' || c == ')' || c == '*' || + c == ',' || c == ';' || c == '=' || c == ':' +} + +const hexUpper = "0123456789ABCDEF" + func separateNamespaceNameVersion(path string) (ns, name, version string, err error) { name = path @@ -605,6 +778,11 @@ func typeAdjustVersion(purlType, version string) string { return version } +// Make any purl type-specific adjustments to qualifiers. +func typeAdjustQualifiers(_ string, qualifiers Qualifiers) Qualifiers { + return qualifiers +} + // 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 { @@ -625,13 +803,47 @@ 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. 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. func validType(typ string) bool { - return TypePattern.MatchString(typ) + 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 } // validCustomRules evaluates additional rules for each package url type, as specified in the package-url specification. @@ -640,38 +852,39 @@ func validCustomRules(p PackageURL) error { q := p.Qualifiers.Map() switch p.Type { case TypeConan: - if p.Namespace != "" { - 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") - } + // Conan user and channel qualifiers must appear together. + _, hasUser := q["user"] + _, hasChannel := q["channel"] + if hasUser && !hasChannel { + return errors.New("conan purl with 'user' qualifier must also have 'channel' qualifier") + } + if hasChannel && !hasUser { + // channel without user: check if namespace is present to serve as the "user" + if p.Namespace == "" { + return errors.New("conan purl with 'channel' qualifier requires 'user' qualifier or namespace") } } + // If namespace is present but no qualifiers at all, it's ambiguous/invalid. + if p.Namespace != "" && len(p.Qualifiers) == 0 { + return errors.New("conan purl with namespace requires qualifiers (at minimum 'channel')") + } case TypeCpan: - if p.Namespace != "" { - // The purl refers to a CPAN distribution. - publisher := p.Namespace - if publisher != strings.ToUpper(publisher) { - return errors.New("a cpan distribution namespace must be all uppercase") - } - distName := p.Name - if strings.Contains(distName, "::") { - return errors.New("a cpan distribution name must not contain '::'") - } - } else { - // The purl refers to a CPAN module. - moduleName := p.Name - if strings.Contains(moduleName, "-") { - return errors.New("a cpan module name may not contain dashes") - } + // CPAN namespace (author/publisher) is required. + if p.Namespace == "" { + return errors.New("a cpan purl must have a namespace (the CPAN ID of the author/publisher)") + } + // Namespace must be uppercase. + if p.Namespace != strings.ToUpper(p.Namespace) { + return errors.New("a cpan namespace must be all uppercase") + } + // Name must not contain '::' (that's module syntax, not distribution). + if strings.Contains(p.Name, "::") { + return errors.New("a cpan distribution name must not contain '::'") + } + case TypeJulia: + // Julia requires a uuid qualifier. + if _, ok := q["uuid"]; !ok { + return errors.New("a julia purl must have a 'uuid' qualifier") } case TypeSwift: if p.Namespace == "" { @@ -684,6 +897,14 @@ func validCustomRules(p PackageURL) error { if p.Version == "" { return errors.New("version is required") } + case TypeOTP: + if p.Namespace != "" { + return errors.New("namespace is not allowed for otp purls") + } + case TypeVSCodeExtension: + if p.Namespace == "" { + return errors.New("namespace is required for vscode-extension purls") + } } return nil } diff --git a/packageurl_bench_test.go b/packageurl_bench_test.go new file mode 100644 index 0000000..48d02ad --- /dev/null +++ b/packageurl_bench_test.go @@ -0,0 +1,298 @@ +package packageurl + +import ( + "testing" +) + +// Sample purls of varying complexity +var ( + simplePurl = "pkg:npm/lodash@4.17.21" + namespacePurl = "pkg:maven/org.apache.commons/commons-lang3@3.12.0" + qualifiersPurl = "pkg:npm/%40angular/core@16.0.0?repository_url=https://registry.npmjs.org" + complexPurl = "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25&repository_url=http://example.com" + subpathPurl = "pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c#src/main/java" + fullPurl = "pkg:deb/debian/dpkg@1.19.0.4?arch=amd64&distro=stretch&repository_url=http://deb.debian.org#subpath/to/file" +) + +// Pre-parsed PackageURL structs for ToString benchmarks +var ( + simplePackageURL = PackageURL{ + Type: "npm", + Name: "lodash", + Version: "4.17.21", + } + namespacePackageURL = PackageURL{ + Type: "maven", + Namespace: "org.apache.commons", + Name: "commons-lang3", + Version: "3.12.0", + } + qualifiersPackageURL = PackageURL{ + Type: "npm", + Namespace: "@angular", + Name: "core", + Version: "16.0.0", + Qualifiers: Qualifiers{ + {Key: "repository_url", Value: "https://registry.npmjs.org"}, + }, + } + complexPackageURL = PackageURL{ + Type: "rpm", + Namespace: "fedora", + Name: "curl", + Version: "7.50.3-1.fc25", + Qualifiers: Qualifiers{ + {Key: "arch", Value: "i386"}, + {Key: "distro", Value: "fedora-25"}, + {Key: "repository_url", Value: "http://example.com"}, + }, + } + fullPackageURL = PackageURL{ + Type: "deb", + Namespace: "debian", + Name: "dpkg", + Version: "1.19.0.4", + Qualifiers: Qualifiers{ + {Key: "arch", Value: "amd64"}, + {Key: "distro", Value: "stretch"}, + {Key: "repository_url", Value: "http://deb.debian.org"}, + }, + Subpath: "subpath/to/file", + } +) + +// FromString benchmarks - parsing +func BenchmarkFromString_Simple(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(simplePurl) + } +} + +func BenchmarkFromString_Namespace(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(namespacePurl) + } +} + +func BenchmarkFromString_Qualifiers(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(qualifiersPurl) + } +} + +func BenchmarkFromString_Complex(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(complexPurl) + } +} + +func BenchmarkFromString_Subpath(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(subpathPurl) + } +} + +func BenchmarkFromString_Full(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = FromString(fullPurl) + } +} + +// ToString benchmarks - serialization +func BenchmarkToString_Simple(b *testing.B) { + p := simplePackageURL + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ToString() + } +} + +func BenchmarkToString_Namespace(b *testing.B) { + p := namespacePackageURL + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ToString() + } +} + +func BenchmarkToString_Qualifiers(b *testing.B) { + p := qualifiersPackageURL + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ToString() + } +} + +func BenchmarkToString_Complex(b *testing.B) { + p := complexPackageURL + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ToString() + } +} + +func BenchmarkToString_Full(b *testing.B) { + p := fullPackageURL + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ToString() + } +} + +// Normalize benchmarks +func BenchmarkNormalize_Simple(b *testing.B) { + for i := 0; i < b.N; i++ { + p := simplePackageURL + _ = p.Normalize() + } +} + +func BenchmarkNormalize_Complex(b *testing.B) { + for i := 0; i < b.N; i++ { + p := complexPackageURL + _ = p.Normalize() + } +} + +func BenchmarkNormalize_Full(b *testing.B) { + for i := 0; i < b.N; i++ { + p := fullPackageURL + _ = p.Normalize() + } +} + +// Roundtrip benchmarks (parse + serialize) +func BenchmarkRoundtrip_Simple(b *testing.B) { + for i := 0; i < b.N; i++ { + p, _ := FromString(simplePurl) + _ = p.ToString() + } +} + +func BenchmarkRoundtrip_Complex(b *testing.B) { + for i := 0; i < b.N; i++ { + p, _ := FromString(complexPurl) + _ = p.ToString() + } +} + +func BenchmarkRoundtrip_Full(b *testing.B) { + for i := 0; i < b.N; i++ { + p, _ := FromString(fullPurl) + _ = p.ToString() + } +} + +// Qualifier operations +func BenchmarkQualifiersFromMap_Small(b *testing.B) { + m := map[string]string{ + "arch": "amd64", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = QualifiersFromMap(m) + } +} + +func BenchmarkQualifiersFromMap_Medium(b *testing.B) { + m := map[string]string{ + "arch": "amd64", + "distro": "debian-10", + "repository_url": "http://example.com", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = QualifiersFromMap(m) + } +} + +func BenchmarkQualifiersFromMap_Large(b *testing.B) { + m := map[string]string{ + "arch": "amd64", + "distro": "debian-10", + "repository_url": "http://example.com", + "checksum": "sha256:abc123", + "vcs_url": "git+https://github.com/foo/bar", + "download_url": "https://example.com/download", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = QualifiersFromMap(m) + } +} + +func BenchmarkQualifiers_Map_Small(b *testing.B) { + q := Qualifiers{{Key: "arch", Value: "amd64"}} + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = q.Map() + } +} + +func BenchmarkQualifiers_Map_Medium(b *testing.B) { + q := Qualifiers{ + {Key: "arch", Value: "amd64"}, + {Key: "distro", Value: "debian-10"}, + {Key: "repository_url", Value: "http://example.com"}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = q.Map() + } +} + +func BenchmarkQualifiers_Map_Large(b *testing.B) { + q := Qualifiers{ + {Key: "arch", Value: "amd64"}, + {Key: "distro", Value: "debian-10"}, + {Key: "repository_url", Value: "http://example.com"}, + {Key: "checksum", Value: "sha256:abc123"}, + {Key: "vcs_url", Value: "git+https://github.com/foo/bar"}, + {Key: "download_url", Value: "https://example.com/download"}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = q.Map() + } +} + +// Qualifier String/Normalize +func BenchmarkQualifiers_String(b *testing.B) { + q := complexPackageURL.Qualifiers + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = q.String() + } +} + +func BenchmarkQualifiers_Normalize(b *testing.B) { + for i := 0; i < b.N; i++ { + q := Qualifiers{ + {Key: "Arch", Value: "amd64"}, + {Key: "DISTRO", Value: "debian-10"}, + {Key: "repository_url", Value: "http://example.com"}, + } + _ = q.Normalize() + } +} + +// Validation benchmarks +func BenchmarkValidQualifierKey(b *testing.B) { + keys := []string{"arch", "repository_url", "vcs_url", "checksum.sha256"} + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, k := range keys { + _ = validQualifierKey(k) + } + } +} + +func BenchmarkValidType(b *testing.B) { + types := []string{"npm", "maven", "pypi", "golang", "deb", "rpm"} + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, t := range types { + _ = validType(t) + } + } +} diff --git a/testdata/purl-spec b/testdata/purl-spec index 9041aa7..a1c3647 160000 --- a/testdata/purl-spec +++ b/testdata/purl-spec @@ -1 +1 @@ -Subproject commit 9041aa74236686b23153652f8cd3862eef8c33a9 +Subproject commit a1c36474e44408f84f5c23da524f1e2f997d20cd