x/build/blob: add fuzz test for ParseRef

This commit is contained in:
Blake Mizerany 2024-04-03 23:36:18 -07:00
parent 4ea3e9efa6
commit d42c3f6be1
3 changed files with 73 additions and 47 deletions

View File

@ -2,17 +2,16 @@ package blob
import ( import (
"cmp" "cmp"
"fmt"
"iter" "iter"
"slices" "slices"
"strings" "strings"
) )
type Kind int type PartKind int
// Levels of concreteness // Levels of concreteness
const ( const (
Domain Kind = iota Domain PartKind = iota
Namespace Namespace
Name Name
Tag Tag
@ -35,43 +34,35 @@ type Ref struct {
// WithDomain returns a copy of r with the provided domain. If the provided // WithDomain returns a copy of r with the provided domain. If the provided
// domain is empty, it returns the short, unqualified copy of r. // domain is empty, it returns the short, unqualified copy of r.
func (r Ref) WithDomain(s string) Ref { func (r Ref) WithDomain(s string) Ref {
return with(r, Domain, s) r.domain = s
return r
} }
// WithNamespace returns a copy of r with the provided namespace. If the // WithNamespace returns a copy of r with the provided namespace. If the
// provided namespace is empty, it returns the short, unqualified copy of r. // provided namespace is empty, it returns the short, unqualified copy of r.
func (r Ref) WithNamespace(s string) Ref { func (r Ref) WithNamespace(s string) Ref {
return with(r, Namespace, s) r.namespace = s
return r
}
// WithName returns a copy of r with the provided name. If the provided
// name is empty, it returns the short, unqualified copy of r.
func (r Ref) WithName(s string) Ref {
r.name = s
return r
} }
func (r Ref) WithTag(s string) Ref { func (r Ref) WithTag(s string) Ref {
return with(r, Tag, s) r.tag = s
return r
} }
// WithBuild returns a copy of r with the provided build. If the provided // WithBuild returns a copy of r with the provided build. If the provided
// build is empty, it returns the short, unqualified copy of r. // build is empty, it returns the short, unqualified copy of r.
//
// The build is normalized to uppercase.
func (r Ref) WithBuild(s string) Ref { func (r Ref) WithBuild(s string) Ref {
return with(r, Build, s) r.build = strings.ToUpper(s)
}
func with(r Ref, kind Kind, value string) Ref {
if value != "" && !isValidPart(value) {
return Ref{}
}
switch kind {
case Domain:
r.domain = value
case Namespace:
r.namespace = value
case Name:
r.name = value
case Tag:
r.tag = value
case Build:
r.build = value
default:
panic(fmt.Sprintf("invalid completeness: %d", kind))
}
return r return r
} }
@ -190,15 +181,15 @@ func ParseRef(s string) Ref {
for kind, part := range Parts(s) { for kind, part := range Parts(s) {
switch kind { switch kind {
case Domain: case Domain:
r.domain = part r = r.WithDomain(part)
case Namespace: case Namespace:
r.namespace = part r = r.WithNamespace(part)
case Name: case Name:
r.name = part r.name = part
case Tag: case Tag:
r.tag = part r = r.WithTag(part)
case Build: case Build:
r.build = part r = r.WithBuild(part)
} }
} }
if !r.Valid() { if !r.Valid() {
@ -207,12 +198,8 @@ func ParseRef(s string) Ref {
return r return r
} }
func Parts(s string) iter.Seq2[Kind, string] { func Parts(s string) iter.Seq2[PartKind, string] {
return func(yield func(Kind, string) bool) { return func(yield func(PartKind, string) bool) {
if len(s) > 128 {
return
}
if strings.HasPrefix(s, "http://") { if strings.HasPrefix(s, "http://") {
s = s[len("http://"):] s = s[len("http://"):]
} }
@ -220,7 +207,11 @@ func Parts(s string) iter.Seq2[Kind, string] {
s = s[len("https://"):] s = s[len("https://"):]
} }
emit := func(kind Kind, value string) bool { if len(s) > 255 || len(s) == 0 {
return
}
yieldValid := func(kind PartKind, value string) bool {
if !isValidPart(value) { if !isValidPart(value) {
return false return false
} }
@ -234,7 +225,7 @@ func Parts(s string) iter.Seq2[Kind, string] {
switch state { switch state {
case Build: case Build:
v := strings.ToUpper(s[i+1 : j]) v := strings.ToUpper(s[i+1 : j])
if !emit(Build, v) { if !yieldValid(Build, v) {
return return
} }
state, j = Tag, i state, j = Tag, i
@ -244,7 +235,7 @@ func Parts(s string) iter.Seq2[Kind, string] {
case ':': case ':':
switch state { switch state {
case Build, Tag: case Build, Tag:
if emit(Tag, s[i+1:j]) { if yieldValid(Tag, s[i+1:j]) {
state, j = Tag, i state, j = Tag, i
} }
state, j = Name, i state, j = Name, i
@ -254,12 +245,12 @@ func Parts(s string) iter.Seq2[Kind, string] {
case '/': case '/':
switch state { switch state {
case Name, Tag, Build: case Name, Tag, Build:
if !emit(Name, s[i+1:j]) { if !yieldValid(Name, s[i+1:j]) {
return return
} }
state, j = Namespace, i state, j = Namespace, i
case Namespace: case Namespace:
if !emit(Namespace, s[i+1:j]) { if !yieldValid(Namespace, s[i+1:j]) {
return return
} }
state, j = Domain, i state, j = Domain, i
@ -272,11 +263,11 @@ func Parts(s string) iter.Seq2[Kind, string] {
// handle the first part based on final state // handle the first part based on final state
switch state { switch state {
case Domain: case Domain:
yield(Domain, s[:j]) yieldValid(Domain, s[:j])
case Namespace: case Namespace:
yield(Namespace, s[:j]) yieldValid(Namespace, s[:j])
default: default:
yield(Name, s[:j]) yieldValid(Name, s[:j])
} }
} }
} }
@ -316,7 +307,7 @@ func (r Ref) Valid() bool {
// isValidPart returns true if given part is valid ascii [a-zA-Z0-9_\.-] // isValidPart returns true if given part is valid ascii [a-zA-Z0-9_\.-]
func isValidPart(s string) bool { func isValidPart(s string) bool {
if len(s) == 0 { if s == "" {
return false return false
} }
for _, c := range []byte(s) { for _, c := range []byte(s) {

View File

@ -1,6 +1,9 @@
package blob package blob
import "testing" import (
"strings"
"testing"
)
// test refs // test refs
const ( const (
@ -21,6 +24,9 @@ var testRefs = map[string]Ref{
// invalid // invalid
"mistral:7b+Q4_0:latest": {}, "mistral:7b+Q4_0:latest": {},
"mi tral": {}, "mi tral": {},
// From fuzzing
"/0": {},
} }
func TestRefParts(t *testing.T) { func TestRefParts(t *testing.T) {
@ -90,9 +96,36 @@ func TestParseRefAllocs(t *testing.T) {
} }
func BenchmarkParseRef(b *testing.B) { func BenchmarkParseRef(b *testing.B) {
b.ReportAllocs()
var r Ref var r Ref
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
r = ParseRef("example.com/mistral:7b+Q4_0") r = ParseRef("example.com/mistral:7b+Q4_0")
} }
_ = r _ = r
} }
func FuzzParseRef(f *testing.F) {
f.Add("example.com/mistral:7b+Q4_0")
f.Add("example.com/mistral:7b+q4_0")
f.Add("example.com/mistral:7b+x")
f.Add("x/y/z:8n+I")
f.Fuzz(func(t *testing.T, s string) {
r0 := ParseRef(s)
if !r0.Valid() {
if r0 != (Ref{}) {
t.Errorf("expected invalid ref to be zero value; got %#v", r0)
}
t.Skipf("invalid ref: %q", s)
}
if !strings.EqualFold(r0.String(), s) {
t.Errorf("String() did not round-trip with case insensitivity: %q\ngot = %q\nwant = %q", s, r0.String(), s)
}
r1 := ParseRef(r0.String())
if r0 != r1 {
t.Errorf("round-trip mismatch: %q != %q", r0, r1)
}
})
}

View File

@ -0,0 +1,2 @@
go test fuzz v1
string("/0")