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

urn: add URN parser and helpers #1323

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions .github/codecov.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ignore:
- "test" # Our test helpers largely do not have tests themselves.
- "**/*_string.go" # Ignore generated string implementations.
- "toolkit/urn/parser.go" # Generated file

coverage:
status:
Expand Down
1 change: 1 addition & 0 deletions toolkit/urn/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.ri
119 changes: 119 additions & 0 deletions toolkit/urn/compliance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package urn

import (
"strings"
"testing"
)

func TestCompliance(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
t.Run("Basic", parseOK(`urn:test:test`))
t.Run("NID", parseOK(`urn:test-T-0123456789:test`))
t.Run("NSS", parseOK(`urn:test:Test-0123456789()+,-.:=@;$_!*'`))
})
t.Run("Invalid", func(t *testing.T) {
t.Run("NID", func(t *testing.T) {
t.Run("TooLong", parseErr(`urn:`+strings.Repeat("a", 33)+`:test`))
t.Run("BadChars", parseErr(`urn:test//notOK:test`))
t.Run("None", parseErr(`urn::test`))
t.Run("HyphenStart", parseErr(`urn:-nid:test`))
t.Run("HyphenEnd", parseErr(`urn:nid-:test`))
})
t.Run("NSS", func(t *testing.T) {
t.Run("BadChar", parseErr("urn:test:null\x00null"))
})
})
t.Run("Equivalence", func(t *testing.T) {
// These test cases are ported out of the RFC.
t.Run("CaseInsensitive", allEqual(`urn:example:a123,z456`, `URN:example:a123,z456`, `urn:EXAMPLE:a123,z456`))
t.Run("Component", allEqual(`urn:example:a123,z456`, `urn:example:a123,z456?+abc`, `urn:example:a123,z456?=xyz`, `urn:example:a123,z456#789`))
t.Run("NSS", allNotEqual(`urn:example:a123,z456`, `urn:example:a123,z456/foo`, `urn:example:a123,z456/bar`, `urn:example:a123,z456/baz`))
t.Run("PercentDecoding", func(t *testing.T) {
p := []string{`urn:example:a123%2Cz456`, `URN:EXAMPLE:a123%2cz456`}
allEqual(p...)(t)
for _, p := range p {
allNotEqual(`urn:example:a123,z456`, p)(t)
}
})
t.Run("CaseSensitive", allNotEqual(`urn:example:a123,z456`, `urn:example:A123,z456`, `urn:example:a123,Z456`))
t.Run("PercentEncoding", func(t *testing.T) {
allNotEqual(`urn:example:a123,z456`, `urn:example:%D0%B0123,z456`)(t)
allEqual(`urn:example:а123,z456`, `urn:example:%D0%B0123,z456`)(t) // NB that's \u0430 CYRILLIC SMALL LETTER A
})
})
}

func parseOK(s string) func(*testing.T) {
u, err := Parse(s)
return func(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := u.R(); err != nil {
t.Error(err)
}
if _, err := u.Q(); err != nil {
t.Error(err)
}
}
}
func parseErr(s string) func(*testing.T) {
u, err := Parse(s)
return func(t *testing.T) {
t.Log(err)
if err != nil {
// OK
return
}
if _, err := u.R(); err == nil {
t.Fail()
}
if _, err := u.Q(); err == nil {
t.Fail()
}
}
}
func allEqual(s ...string) func(*testing.T) {
var err error
u := make([]URN, len(s))
for i, s := range s {
u[i], err = Parse(s)
if err != nil {
break
}
}
return func(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for i := range u {
for j := range u {
if !(&u[i]).Equal(&u[j]) {
t.Errorf("%v != %v", &u[i], &u[j])
}
}
}
}
}
func allNotEqual(s ...string) func(*testing.T) {
var err error
u := make([]URN, len(s))
for i, s := range s {
u[i], err = Parse(s)
if err != nil {
break
}
}
return func(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for i := range u {
for j := range u {
if i != j && (&u[i]).Equal(&u[j]) {
t.Errorf("%v == %v", &u[i], &u[j])
}
}
}
}
}
50 changes: 50 additions & 0 deletions toolkit/urn/escape.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package urn

// These functions are adapted out of the net/url package.
//
// URNs have slightly different rules.

// Copyright 2009 The Go Authors.

const upperhex = "0123456789ABCDEF"

// Escape only handles non-ASCII characters and leaves other validation to the
// parsers.
func escape(s string) string {
ct := 0
for i := 0; i < len(s); i++ {
c := s[i]
if c > 0x7F {
ct++
}
}

if ct == 0 {
return s
}

var buf [64]byte
var t []byte

required := len(s) + 2*ct
if required <= len(buf) {
t = buf[:required]
} else {
t = make([]byte, required)

Check warning on line 33 in toolkit/urn/escape.go

View check run for this annotation

Codecov / codecov/patch

toolkit/urn/escape.go#L33

Added line #L33 was not covered by tests
}

j := 0
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c > 0x7F:
t[j] = '%'
t[j+1] = upperhex[c>>4]
t[j+2] = upperhex[c&15]
j += 3
default:
t[j] = s[i]
j++
}
}
return string(t)
}
16 changes: 16 additions & 0 deletions toolkit/urn/generate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
set -e

for cmd in ragel-go gofmt sed; do
if ! command -v "$cmd" >/dev/null 2>&1; then
printf 'missing needed command: %s\n' "$cmd" >&2
exit 99
fi
done

ragel-go -s -p -F1 -o _parser.go parser.rl
trap 'rm _parser.go' EXIT
{
printf '// Code generated by ragel-go DO NOT EDIT.\n\n'
gofmt -s _parser.go
} > parser.go
46 changes: 46 additions & 0 deletions toolkit/urn/name.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package urn

import (
"net/url"
"strings"
)

// Name is a claircore name.
//
// Names are expected to be unique within a claircore system and comparable
// across instances. Names are hierarchical, moving from least specific to most
// specific.
//
// Any pointer fields are optional metadata that may not exist depending on the
// (System, Kind) pair.
type Name struct {
// System scopes to a claircore system or "mode", such as "indexer" or
// "updater".
System string
// Kind scopes to a specific type of object used within the System.
Kind string
// Name scopes to a specific object within the system.
Name string
// Version is the named object's version.
//
// Versions can be ordered with a lexical sort.
Version *string
}

// String implements fmt.Stringer.
func (n *Name) String() string {
v := url.Values{}
if n.Version != nil {
v.Set("version", *n.Version)
}
u := URN{
NID: `claircore`,
NSS: strings.Join(
[]string{n.System, n.Kind, n.Name},
":",
),
q: v.Encode(),
}

return u.String()
}
107 changes: 107 additions & 0 deletions toolkit/urn/name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package urn

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func TestName(t *testing.T) {
version := "1"
tt := []struct {
In string
Want Name
}{
// Weird cases first:
{
In: "urn:claircore:indexer:package:test?=version=1&version=999",
Want: Name{
System: "indexer",
Kind: "package",
Name: "test",
Version: &version,
},
},
{
In: "urn:claircore:indexer:package:test",
Want: Name{
System: "indexer",
Kind: "package",
Name: "test",
},
},
{
In: "urn:claircore:indexer:package:test?+resolve=something",
Want: Name{
System: "indexer",
Kind: "package",
Name: "test",
},
},
{
In: "urn:claircore:indexer:package:test#some_anchor",
Want: Name{
System: "indexer",
Kind: "package",
Name: "test",
},
},

// Some other exhaustive cases:
{
In: "urn:claircore:indexer:repository:test?=version=1",
Want: Name{
System: "indexer",
Kind: "repository",
Name: "test",
Version: &version,
},
},
{
In: "urn:claircore:indexer:distribution:test?=version=1",
Want: Name{
System: "indexer",
Kind: "distribution",
Name: "test",
Version: &version,
},
},
{
In: "urn:claircore:matcher:vulnerability:test?=version=1",
Want: Name{
System: "matcher",
Kind: "vulnerability",
Name: "test",
Version: &version,
},
},
{
In: "urn:claircore:matcher:enrichment:test?=version=1",
Want: Name{
System: "matcher",
Kind: "enrichment",
Name: "test",
Version: &version,
},
},
}

for _, tc := range tt {
t.Logf("parse: %q", tc.In)
u, err := Parse(tc.In)
if err != nil {
t.Error(err)
continue
}
got, err := u.Name()
if err != nil {
t.Error(err)
continue
}
want := tc.Want
t.Logf("name: %q", got.String())
if !cmp.Equal(&got, &want) {
t.Error(cmp.Diff(&got, &want))
}
}
}
Loading
Loading