Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PackageURL.Normalize #65

Merged
merged 3 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 77 additions & 9 deletions packageurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ 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
Expand Down Expand Up @@ -307,6 +313,33 @@ func (qq Qualifiers) String() string {
return strings.Join(kvPairs, "&")
}

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 := strings.ToLower(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 })
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
}

// PackageURL is the struct representation of the parts that make a package url
type PackageURL struct {
Type string
Expand Down Expand Up @@ -399,13 +432,45 @@ func FromString(purl string) (PackageURL, error) {
pURL := PackageURL{
Qualifiers: qualifiers,
Type: typ,
Namespace: typeAdjustNamespace(typ, namespace),
Name: typeAdjustName(typ, name, qualifiers),
Version: typeAdjustVersion(typ, version),
Subpath: strings.Trim(u.Fragment, "/"),
Namespace: namespace,
Name: name,
Version: version,
Subpath: u.Fragment,
}

return pURL, validCustomRules(pURL)
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 _, s := range segs {
if s == "." || s == ".." {
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)
}

// escape the given string in a purl-compatible way.
Expand Down Expand Up @@ -483,10 +548,8 @@ func parseQualifiers(rawQuery string) (Qualifiers, error) {
}

q = append(q, Qualifier{
Key: strings.ToLower(key),
// only the first character needs to be lowercase. Note that pURL is always UTF8, so we
// don't need to care about unicode here.
Value: strings.ToLower(value[:1]) + value[1:],
Key: strings.ToLower(key),
Value: value,
})
}
return q, nil
Expand Down Expand Up @@ -568,6 +631,11 @@ func validQualifierKey(key string) bool {
return QualifierKeyPattern.MatchString(key)
}

// validType validates a type against our TypePattern.
func validType(typ string) bool {
return TypePattern.MatchString(typ)
}

// 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(p PackageURL) error {
Expand Down
233 changes: 231 additions & 2 deletions packageurl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"

Expand Down Expand Up @@ -159,8 +160,16 @@ func TestFromStringExamples(t *testing.T) {
t.Logf("%s: incorrect version: wanted: '%s', got '%s'", tc.Description, tc.Version, p.Version)
t.Fail()
}
if !reflect.DeepEqual(p.Qualifiers, tc.Qualifiers()) {
t.Logf("%s: incorrect qualifiers: wanted: '%#v', got '%#v'", tc.Description, tc.Qualifiers(), p.Qualifiers)
want := tc.Qualifiers()
sort.Slice(want, func(i, j int) bool {
return want[i].Key < want[j].Key
})
got := p.Qualifiers
sort.Slice(got, func(i, j int) bool {
return got[i].Key < got[j].Key
})
if !reflect.DeepEqual(want, got) {
t.Logf("%s: incorrect qualifiers: wanted: '%#v', got '%#v'", tc.Description, want, p.Qualifiers)
t.Fail()
}

Expand Down Expand Up @@ -315,3 +324,223 @@ func TestNameEscaping(t *testing.T) {
}

}

func TestQualifierMissingEqual(t *testing.T) {
input := "pkg:npm/test-pkg?key"
want := packageurl.PackageURL{
Type: "npm",
Name: "test-pkg",
Qualifiers: packageurl.Qualifiers{},
}
got, err := packageurl.FromString(input)
if err != nil {
t.Fatalf("FromString(%s): unexpected error: %v", input, err)
}
if !reflect.DeepEqual(want, got) {
t.Fatalf("FromString(%s): want %q got %q", input, want, got)
}
}

func TestNormalize(t *testing.T) {
testCases := []struct {
name string
input packageurl.PackageURL
want packageurl.PackageURL
wantErr bool
}{{
name: "type is case insensitive",
input: packageurl.PackageURL{
Type: "NpM",
Name: "pkg",
},
want: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{},
},
}, {
name: "type is manditory",
input: packageurl.PackageURL{
Name: "pkg",
},
wantErr: true,
}, {
name: "leading and traling / on namespace are trimmed",
input: packageurl.PackageURL{
Type: "npm",
Namespace: "/namespace/org/",
Name: "pkg",
},
want: packageurl.PackageURL{
Type: "npm",
Namespace: "namespace/org",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{},
},
}, {
name: "qualifiers with empty values are removed",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "k1", Value: "v1",
}, {
Key: "k2", Value: "",
}, {
Key: "k3", Value: "v3",
}},
},
want: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "k1", Value: "v1",
}, {
Key: "k3", Value: "v3",
}},
},
}, {
name: "qualifiers are sorted by key",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "k3", Value: "v3",
}, {
Key: "k2", Value: "v2",
}, {
Key: "k1", Value: "v1",
}},
},
want: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "k1", Value: "v1",
}, {
Key: "k2", Value: "v2",
}, {
Key: "k3", Value: "v3",
}},
},
}, {
name: "duplicate keys are invalid",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "k1", Value: "v1",
}, {
Key: "k1", Value: "v2",
}},
},
wantErr: true,
}, {
name: "keys are made lower case",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "KeY", Value: "v1",
}},
},
want: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{{
Key: "key", Value: "v1",
}},
},
}, {
name: "name is required",
input: packageurl.PackageURL{
Type: "npm",
},
wantErr: true,
}, {
name: "leading and traling / on subpath are trimmed",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Subpath: "/sub/path/",
},
want: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{},
Subpath: "sub/path",
},
}, {
name: "'.' is an invalid subpath segment",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Subpath: "/sub/./path/",
},
wantErr: true,
}, {
name: "'..' is an invalid subpath segment",
input: packageurl.PackageURL{
Type: "npm",
Name: "pkg",
Subpath: "/sub/../path/",
},
wantErr: true,
}, {
name: "known type namespace adjustments",
input: packageurl.PackageURL{
Type: "npm",
Namespace: "NaMeSpAcE",
Name: "pkg",
},
want: packageurl.PackageURL{
Type: "npm",
Namespace: "namespace",
Name: "pkg",
Qualifiers: packageurl.Qualifiers{},
},
}, {
name: "known type name adjustments",
input: packageurl.PackageURL{
Type: "npm",
Name: "nAmE",
},
want: packageurl.PackageURL{
Type: "npm",
Name: "name",
Qualifiers: packageurl.Qualifiers{},
},
}, {
name: "known type version adjustments",
input: packageurl.PackageURL{
Type: "huggingface",
Name: "name",
Version: "VeRsIoN",
},
want: packageurl.PackageURL{
Type: "huggingface",
Name: "name",
Version: "version",
Qualifiers: packageurl.Qualifiers{},
},
}}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := testCase.input
err := got.Normalize()
if err != nil && testCase.wantErr {
return
}
if err != nil && !testCase.wantErr {
t.Fatalf("Normalize(%s): unexpected error: %v", testCase.name, err)
}
if testCase.wantErr {
t.Fatalf("Normalize(%s): want error, got none", testCase.name)
}
if !reflect.DeepEqual(testCase.want, got) {
t.Fatalf("Normalize(%s):\nwant %#v\ngot %#v", testCase.name, testCase.want, got)
}
})
}
}
Loading