Skip to content

Commit b33c146

Browse files
petergardfjallshibumi
authored andcommitted
parsing should support slashes in version names
1 parent 2c7e350 commit b33c146

2 files changed

Lines changed: 68 additions & 25 deletions

File tree

packageurl.go

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ func FromString(purl string) (PackageURL, error) {
447447
if err != nil {
448448
return PackageURL{}, fmt.Errorf("invalid qualifiers: %w", err)
449449
}
450-
namespace, name, version, err := separateNamespaceNameVersion(p)
450+
namespace, name, version, err := separateNamespaceNameVersion(typ, p)
451451
if err != nil {
452452
return PackageURL{}, err
453453
}
@@ -510,39 +510,86 @@ func percentEncode(s string) string {
510510
return replacer.Replace(s)
511511
}
512512

513-
func separateNamespaceNameVersion(path string) (ns, name, version string, err error) {
514-
name = path
515-
516-
if namespaceSep := strings.LastIndex(name, "/"); namespaceSep != -1 {
517-
ns, name = name[:namespaceSep], name[namespaceSep+1:]
513+
// percentDecode percent-decodes a purl component according to [Encoding].
514+
//
515+
// [Encoding] https://github.com/package-url/purl-spec/blob/main/PURL-SPECIFICATION.rst#character-encoding
516+
func percentDecode(s string) (string, error) {
517+
// Note: uses [url.PathUnescape] instead of [url.QueryUnescape] to treat '+' characters
518+
// literally (not as space).
519+
return url.PathUnescape(s)
520+
}
518521

519-
ns, err = url.PathUnescape(ns)
520-
if err != nil {
521-
return "", "", "", fmt.Errorf("error unescaping namespace: %w", err)
522-
}
522+
// separateNamespaceNameVersion parses the <namespace>/<name>@<version> part of a purl (the
523+
// remainder parameter) into its constituent components. It aims to follow the [HOW-TO-PARSE]
524+
// procedure.
525+
//
526+
// [HOW-TO-PARSE]: https://github.com/package-url/purl-spec/blob/main/docs/how-to-parse.md
527+
func separateNamespaceNameVersion(purlType string, remainder string) (ns, name, version string, err error) {
528+
// NPM purls can have a namespace ("scope") that starts with an '@' character.
529+
// For example, "pkg:npm/@babel/core".
530+
// For any other purl type this indicates malformed purl input.
531+
if purlType != TypeNPM && strings.HasPrefix(remainder, "@") {
532+
return "", "", "", fmt.Errorf("purl is missing name")
523533
}
524534

525-
if versionSep := strings.LastIndex(name, "@"); versionSep != -1 {
526-
name, version = name[:versionSep], name[versionSep+1:]
527-
528-
version, err = url.PathUnescape(version)
535+
// Split the remainder once from right on '@'.
536+
// The left side is the remainder.
537+
if strings.LastIndex(remainder, "@") > 0 {
538+
remainder, version = rightmostSplit(remainder, "@")
539+
// Percent-decode the right side. This is the version.
540+
version, err = percentDecode(version)
529541
if err != nil {
530542
return "", "", "", fmt.Errorf("error unescaping version: %w", err)
531543
}
532544
}
533545

534-
name, err = url.PathUnescape(name)
546+
// Split this once from right on '/'.
547+
// The left side is the remainder.
548+
remainder, name = rightmostSplit(remainder, "/")
549+
// Percent-decode the right side. This is the name.
550+
name, err = percentDecode(name)
535551
if err != nil {
536552
return "", "", "", fmt.Errorf("error unescaping name: %w", err)
537553
}
538554

555+
// Split the remainder on '/'.
556+
segments := strings.Split(remainder, "/")
557+
nsSegments := []string{}
558+
for _, segment := range segments {
559+
// Discard any empty segment from that split.
560+
if segment == "" {
561+
continue
562+
}
563+
// Percent-decode each segment.
564+
nsSegment, err := percentDecode(segment)
565+
if err != nil {
566+
return "", "", "", fmt.Errorf("error unescaping namespace: %w", err)
567+
}
568+
nsSegments = append(nsSegments, nsSegment)
569+
}
570+
// Join segments back with a '/'.
571+
ns = strings.Join(nsSegments, "/")
572+
539573
if name == "" {
540574
return "", "", "", fmt.Errorf("purl is missing name")
541575
}
542576

543577
return ns, name, version, nil
544578
}
545579

580+
// rightmostSplit splits the input path on a given delimiter such that the lhs returns the string to
581+
// the left of the right-most delimiter and rhs return the string to the right of the right-most
582+
// delimiter. For example, given path "github.com/package-url/packageurl-go" and delimiter "/" the
583+
// lhs will be "github.com/package-url" and rhs will be "packageurl-go".
584+
func rightmostSplit(path string, delim string) (lhs, rhs string) {
585+
lastSepIdx := strings.LastIndex(path, delim)
586+
rhs = path[lastSepIdx+1:]
587+
if lastSepIdx >= 0 {
588+
lhs = path[:lastSepIdx]
589+
}
590+
return lhs, rhs
591+
}
592+
546593
func parseQualifiers(rawQuery string) (Qualifiers, error) {
547594
// we need to parse the qualifiers ourselves and cannot rely on the `url.Query` type because
548595
// that uses a map, meaning it's unordered. We want to keep the order of the qualifiers, so this
@@ -558,25 +605,21 @@ func parseQualifiers(rawQuery string) (Qualifiers, error) {
558605
if key == "" {
559606
continue
560607
}
608+
// The key is the lowercase left side.
561609
key, value, _ := strings.Cut(key, "=")
562-
key, err := url.QueryUnescape(key)
563-
if err != nil {
564-
return nil, fmt.Errorf("error unescaping qualifier key %q", key)
565-
}
610+
key = strings.ToLower(key)
566611

567612
if !validQualifierKey(key) {
568613
return nil, fmt.Errorf("invalid qualifier key: '%s'", key)
569614
}
570615

571-
value, err = url.QueryUnescape(value)
616+
// The value is the percent-decoded right side.
617+
value, err := percentDecode(value)
572618
if err != nil {
573619
return nil, fmt.Errorf("error unescaping qualifier value %q", value)
574620
}
575621

576-
q = append(q, Qualifier{
577-
Key: strings.ToLower(key),
578-
Value: value,
579-
})
622+
q = append(q, Qualifier{Key: key, Value: value})
580623
}
581624
return q, nil
582625
}

testdata/purl-spec

0 commit comments

Comments
 (0)