diff --git a/packageurl.go b/packageurl.go index dc86d25..5801c51 100644 --- a/packageurl.go +++ b/packageurl.go @@ -200,18 +200,23 @@ func (p *PackageURL) ToString() string { Fragment: p.Subpath, } - nameWithVersion := url.PathEscape(p.Name) + 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 + } + paths = append(paths, escape(segment)) + } + + nameWithVersion := escape(p.Name) if p.Version != "" { - nameWithVersion += "@" + p.Version + nameWithVersion += "@" + escape(p.Version) } - // we use JoinPath and EscapedPath as the behavior for "/" is only correct with that. - // We don't want to escape "/", but want to escape all other characters that are necessary. - u = u.JoinPath(p.Type, p.Namespace, nameWithVersion) - // write the actual path into the "Opaque" block, so that the generated string at the end is - // pkg: and not pkg://. - u.Opaque, u.Path = u.EscapedPath(), "" + paths = append(paths, nameWithVersion) + u.Opaque = strings.Join(paths, "/") return u.String() } @@ -263,6 +268,44 @@ func FromString(purl string) (PackageURL, error) { return pURL, validCustomRules(pURL) } +// escape the given string in a purl-compatible way. +func escape(s string) string { + // for compatibility with other implementations and the purl-spec, we want to escape all + // characters, which is what "QueryEscape" does. The issue with QueryEscape is that it encodes + // " " (space) as "+", which is valid in a query, but invalid in a path (see + // https://stackoverflow.com/questions/2678551/when-should-space-be-encoded-to-plus-or-20) for + // context). + // To work around that, we replace the "+" signs with the path-compatible "%20". + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + +func getQualifiers(rawQuery string) (url.Values, error) { + qualifiers, err := url.ParseQuery(rawQuery) + if err != nil { + return nil, fmt.Errorf("could not parse qualifiers: %w", err) + } + + for k := range qualifiers { + if !validQualifierKey(k) { + return nil, fmt.Errorf("invalid qualifier key: %q", k) + } + + v := qualifiers.Get(k) + // only the first character needs to be lowercased. Note that pURL is alwyas UTF8, so we + // don't need to care about unicode here. + normalisedValue := strings.ToLower(v[:1]) + v[1:] + + if normalisedKey := strings.ToLower(k); normalisedKey != k { + qualifiers.Del(k) + qualifiers.Set(normalisedKey, normalisedValue) + } else if normalisedValue != v { + qualifiers.Set(k, normalisedValue) + } + } + + return qualifiers, nil +} + func separateNamespaceNameVersion(path string) (ns, name, version string, err error) { name = path diff --git a/packageurl_test.go b/packageurl_test.go index 2aac09c..4423c72 100644 --- a/packageurl_test.go +++ b/packageurl_test.go @@ -302,12 +302,12 @@ func TestQualifiersMapConversion(t *testing.T) { func TestNameEscaping(t *testing.T) { testCases := map[string]string{ - "abc": "pkg:abc", - "ab/c": "pkg:ab%2Fc", + "abc": "pkg:deb/abc", + "ab/c": "pkg:deb/ab%2Fc", } for name, output := range testCases { t.Run(name, func(t *testing.T) { - p := &packageurl.PackageURL{Name: name} + p := &packageurl.PackageURL{Type: "deb", Name: name} if s := p.ToString(); s != output { t.Fatalf("wrong escape. expected=%q, got=%q", output, s) }