@@ -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+
546593func 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}
0 commit comments