Skip to content

Commit

Permalink
urn: add URN parser and helpers
Browse files Browse the repository at this point in the history
This sets up some support infrastructure to unify naming across
claircore. The goal is to be able to pass around names as strings
instead of interface objects.

Signed-off-by: Hank Donnay <[email protected]>
  • Loading branch information
hdonnay committed Apr 16, 2024
1 parent e8f9aff commit e26ece1
Show file tree
Hide file tree
Showing 11 changed files with 991 additions and 0 deletions.
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

0 comments on commit e26ece1

Please sign in to comment.