|
@@ -1,715 +1,295 @@
|
|
|
package model
|
|
|
|
|
|
import (
|
|
|
- "bytes"
|
|
|
- "cmp"
|
|
|
- "fmt"
|
|
|
- "log/slog"
|
|
|
- "path/filepath"
|
|
|
- "slices"
|
|
|
+ "reflect"
|
|
|
"strings"
|
|
|
"testing"
|
|
|
)
|
|
|
|
|
|
-type fields struct {
|
|
|
- host, namespace, model, tag, build string
|
|
|
- digest string
|
|
|
-}
|
|
|
-
|
|
|
-func fieldsFromName(p Name) fields {
|
|
|
- return fields{
|
|
|
- host: p.parts[PartHost],
|
|
|
- namespace: p.parts[PartNamespace],
|
|
|
- model: p.parts[PartModel],
|
|
|
- tag: p.parts[PartTag],
|
|
|
- build: p.parts[PartBuild],
|
|
|
- digest: p.parts[PartDigest],
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-var testNames = map[string]fields{
|
|
|
- "mistral:latest": {model: "mistral", tag: "latest"},
|
|
|
- "mistral": {model: "mistral"},
|
|
|
- "mistral:30B": {model: "mistral", tag: "30B"},
|
|
|
- "mistral:7b": {model: "mistral", tag: "7b"},
|
|
|
- "mistral:7b+Q4_0": {model: "mistral", tag: "7b", build: "Q4_0"},
|
|
|
- "mistral+KQED": {model: "mistral", build: "KQED"},
|
|
|
- "mistral.x-3:7b+Q4_0": {model: "mistral.x-3", tag: "7b", build: "Q4_0"},
|
|
|
- "mistral:7b+q4_0": {model: "mistral", tag: "7b", build: "q4_0"},
|
|
|
- "llama2": {model: "llama2"},
|
|
|
- "user/model": {namespace: "user", model: "model"},
|
|
|
- "example.com/ns/mistral:7b+Q4_0": {host: "example.com", namespace: "ns", model: "mistral", tag: "7b", build: "Q4_0"},
|
|
|
- "example.com/ns/mistral:7b+X": {host: "example.com", namespace: "ns", model: "mistral", tag: "7b", build: "X"},
|
|
|
- "localhost:5000/ns/mistral": {host: "localhost:5000", namespace: "ns", model: "mistral"},
|
|
|
-
|
|
|
- // invalid digest
|
|
|
- "mistral:latest@invalid256-": {},
|
|
|
- "mistral:latest@-123": {},
|
|
|
- "mistral:latest@!-123": {},
|
|
|
- "mistral:latest@1-!": {},
|
|
|
- "mistral:latest@": {},
|
|
|
-
|
|
|
- // resolved
|
|
|
- "x@sha123-12": {model: "x", digest: "sha123-12"},
|
|
|
- "@sha456-22": {digest: "sha456-22"},
|
|
|
- "@sha456-1": {},
|
|
|
- "@@sha123-22": {},
|
|
|
-
|
|
|
- // preserves case for build
|
|
|
- "x+b": {model: "x", build: "b"},
|
|
|
-
|
|
|
- // invalid (includes fuzzing trophies)
|
|
|
- " / / : + ": {},
|
|
|
- " / : + ": {},
|
|
|
- " : + ": {},
|
|
|
- " + ": {},
|
|
|
- " : ": {},
|
|
|
- " / ": {},
|
|
|
- " /": {},
|
|
|
- "/ ": {},
|
|
|
- "/": {},
|
|
|
- ":": {},
|
|
|
- "+": {},
|
|
|
-
|
|
|
- // (".") in namepsace is not allowed
|
|
|
- "invalid.com/7b+x": {},
|
|
|
-
|
|
|
- "invalid:7b+Q4_0:latest": {},
|
|
|
- "in valid": {},
|
|
|
- "invalid/y/z/foo": {},
|
|
|
- "/0": {},
|
|
|
- "0 /0": {},
|
|
|
- "0 /": {},
|
|
|
- "0/": {},
|
|
|
- ":/0": {},
|
|
|
- "+0/00000": {},
|
|
|
- "0+.\xf2\x80\xf6\x9d00000\xe5\x99\xe6\xd900\xd90\xa60\x91\xdc0\xff\xbf\x99\xe800\xb9\xdc\xd6\xc300\x970\xfb\xfd0\xe0\x8a\xe1\xad\xd40\x9700\xa80\x980\xdd0000\xb00\x91000\xfe0\x89\x9b\x90\x93\x9f0\xe60\xf7\x84\xb0\x87\xa5\xff0\xa000\x9a\x85\xf6\x85\xfe\xa9\xf9\xe9\xde00\xf4\xe0\x8f\x81\xad\xde00\xd700\xaa\xe000000\xb1\xee0\x91": {},
|
|
|
- "0//0": {},
|
|
|
- "m+^^^": {},
|
|
|
- "file:///etc/passwd": {},
|
|
|
- "file:///etc/passwd:latest": {},
|
|
|
- "file:///etc/passwd:latest+u": {},
|
|
|
-
|
|
|
- ":x": {},
|
|
|
- "+x": {},
|
|
|
- "x+": {},
|
|
|
-
|
|
|
- // Disallow ("\.+") in any part to prevent path traversal anywhere
|
|
|
- // we convert the name to a path.
|
|
|
- "../etc/passwd": {},
|
|
|
- ".../etc/passwd": {},
|
|
|
- "./../passwd": {},
|
|
|
- "./0+..": {},
|
|
|
-
|
|
|
- strings.Repeat("a", MaxNamePartLen): {model: strings.Repeat("a", MaxNamePartLen)},
|
|
|
- strings.Repeat("a", MaxNamePartLen+1): {},
|
|
|
-}
|
|
|
-
|
|
|
-func TestIsValidNameLen(t *testing.T) {
|
|
|
- if IsValidNamePart(PartNamespace, strings.Repeat("a", MaxNamePartLen+1)) {
|
|
|
- t.Errorf("unexpectedly valid long name")
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// TestConsecutiveDots tests that consecutive dots are not allowed in any
|
|
|
-// part, to avoid path traversal. There also are some tests in testNames, but
|
|
|
-// this test is more exhaustive and exists to emphasize the importance of
|
|
|
-// preventing path traversal.
|
|
|
-func TestNameConsecutiveDots(t *testing.T) {
|
|
|
- for i := 1; i < 10; i++ {
|
|
|
- s := strings.Repeat(".", i)
|
|
|
- if i > 1 {
|
|
|
- if g := ParseNameFill(s, FillNothing).DisplayLong(); g != "" {
|
|
|
- t.Errorf("ParseName(%q) = %q; want empty string", s, g)
|
|
|
- }
|
|
|
- } else {
|
|
|
- if g := ParseNameFill(s, FillNothing).DisplayLong(); g != s {
|
|
|
- t.Errorf("ParseName(%q) = %q; want %q", s, g, s)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestNameParts(t *testing.T) {
|
|
|
- var p Name
|
|
|
- if w, g := int(NumParts), len(p.parts); w != g {
|
|
|
- t.Errorf("Parts() = %d; want %d", g, w)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestNamePartString(t *testing.T) {
|
|
|
- if g := PartKind(-2).String(); g != "Unknown" {
|
|
|
- t.Errorf("Unknown part = %q; want %q", g, "Unknown")
|
|
|
- }
|
|
|
- for kind, name := range kindNames {
|
|
|
- if g := kind.String(); g != name {
|
|
|
- t.Errorf("%s = %q; want %q", kind, g, name)
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestParseName(t *testing.T) {
|
|
|
- for baseName, want := range testNames {
|
|
|
- for _, prefix := range []string{"", "https://", "http://"} {
|
|
|
- // We should get the same results with or without the
|
|
|
- // http(s) prefixes
|
|
|
- s := prefix + baseName
|
|
|
-
|
|
|
- t.Run(s, func(t *testing.T) {
|
|
|
- name := ParseNameFill(s, FillNothing)
|
|
|
- got := fieldsFromName(name)
|
|
|
- if got != want {
|
|
|
- t.Errorf("ParseName(%q) = %q; want %q", s, got, want)
|
|
|
- }
|
|
|
-
|
|
|
- // test round-trip
|
|
|
- if !ParseNameFill(name.DisplayLong(), FillNothing).EqualFold(name) {
|
|
|
- t.Errorf("ParseName(%q).String() = %s; want %s", s, name.DisplayLong(), baseName)
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestParseNameFill(t *testing.T) {
|
|
|
- cases := []struct {
|
|
|
- in string
|
|
|
- fill string
|
|
|
- want string
|
|
|
- }{
|
|
|
- {"mistral", "example.com/library/?:latest+Q4_0", "example.com/library/mistral:latest+Q4_0"},
|
|
|
- {"mistral", "example.com/library/?:latest", "example.com/library/mistral:latest"},
|
|
|
- {"llama2:x", "example.com/library/?:latest+Q4_0", "example.com/library/llama2:x+Q4_0"},
|
|
|
-
|
|
|
- // Invalid
|
|
|
- {"", "example.com/library/?:latest+Q4_0", ""},
|
|
|
- {"llama2:?", "example.com/library/?:latest+Q4_0", ""},
|
|
|
- }
|
|
|
-
|
|
|
- for _, tt := range cases {
|
|
|
- t.Run(tt.in, func(t *testing.T) {
|
|
|
- name := ParseNameFill(tt.in, tt.fill)
|
|
|
- if g := name.DisplayLong(); g != tt.want {
|
|
|
- t.Errorf("ParseName(%q, %q) = %q; want %q", tt.in, tt.fill, g, tt.want)
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- t.Run("invalid fill", func(t *testing.T) {
|
|
|
- defer func() {
|
|
|
- if recover() == nil {
|
|
|
- t.Fatal("expected panic")
|
|
|
- }
|
|
|
- }()
|
|
|
- ParseNameFill("x", "^")
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-func TestParseNameHTTPDoublePrefixStrip(t *testing.T) {
|
|
|
- cases := []string{
|
|
|
- "http://https://valid.com/valid/valid:latest",
|
|
|
- "https://http://valid.com/valid/valid:latest",
|
|
|
- }
|
|
|
- for _, s := range cases {
|
|
|
- t.Run(s, func(t *testing.T) {
|
|
|
- name := ParseNameFill(s, FillNothing)
|
|
|
- if name.IsValid() {
|
|
|
- t.Errorf("expected invalid path; got %#v", name)
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
-}
|
|
|
+const (
|
|
|
+ part80 = "88888888888888888888888888888888888888888888888888888888888888888888888888888888"
|
|
|
+ part350 = "33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333"
|
|
|
+)
|
|
|
|
|
|
-func TestCompleteWithAndWithoutBuild(t *testing.T) {
|
|
|
+func TestParseNameParts(t *testing.T) {
|
|
|
cases := []struct {
|
|
|
in string
|
|
|
- complete bool
|
|
|
- completeNoBuild bool
|
|
|
- }{
|
|
|
- {"", false, false},
|
|
|
- {"incomplete/mistral:7b+x", false, false},
|
|
|
- {"incomplete/mistral:7b+Q4_0", false, false},
|
|
|
- {"incomplete:7b+x", false, false},
|
|
|
- {"complete.com/x/mistral:latest+Q4_0", true, true},
|
|
|
- {"complete.com/x/mistral:latest", false, true},
|
|
|
- }
|
|
|
-
|
|
|
- for _, tt := range cases {
|
|
|
- t.Run(tt.in, func(t *testing.T) {
|
|
|
- p := ParseNameFill(tt.in, FillNothing)
|
|
|
- t.Logf("ParseName(%q) = %#v", tt.in, p)
|
|
|
- if g := p.IsComplete(); g != tt.complete {
|
|
|
- t.Errorf("Complete(%q) = %v; want %v", tt.in, g, tt.complete)
|
|
|
- }
|
|
|
- if g := p.IsCompleteNoBuild(); g != tt.completeNoBuild {
|
|
|
- t.Errorf("CompleteNoBuild(%q) = %v; want %v", tt.in, g, tt.completeNoBuild)
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- // Complete uses Parts which returns a slice, but it should be
|
|
|
- // inlined when used in Complete, preventing any allocations or
|
|
|
- // escaping to the heap.
|
|
|
- allocs := testing.AllocsPerRun(1000, func() {
|
|
|
- keep(ParseNameFill("complete.com/x/mistral:latest+Q4_0", FillNothing).IsComplete())
|
|
|
- })
|
|
|
- if allocs > 0 {
|
|
|
- t.Errorf("Complete allocs = %v; want 0", allocs)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestNameLogValue(t *testing.T) {
|
|
|
- cases := []string{
|
|
|
- "example.com/library/mistral:latest+Q4_0",
|
|
|
- "mistral:latest",
|
|
|
- "mistral:7b+Q4_0",
|
|
|
- }
|
|
|
- for _, s := range cases {
|
|
|
- t.Run(s, func(t *testing.T) {
|
|
|
- var b bytes.Buffer
|
|
|
- log := slog.New(slog.NewTextHandler(&b, nil))
|
|
|
- name := ParseNameFill(s, FillNothing)
|
|
|
- log.Info("", "name", name)
|
|
|
- want := fmt.Sprintf("name=%s", name.GoString())
|
|
|
- got := b.String()
|
|
|
- if !strings.Contains(got, want) {
|
|
|
- t.Errorf("expected log output to contain %q; got %q", want, got)
|
|
|
- }
|
|
|
- })
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestNameGoString(t *testing.T) {
|
|
|
- cases := []struct {
|
|
|
- name string
|
|
|
- in string
|
|
|
- wantString string
|
|
|
- wantGoString string // default is tt.in
|
|
|
+ want Name
|
|
|
+ wantValidDigest bool
|
|
|
}{
|
|
|
{
|
|
|
- name: "Complete Name",
|
|
|
- in: "example.com/library/mistral:latest+Q4_0",
|
|
|
- wantGoString: "example.com/library/mistral:latest+Q4_0@?",
|
|
|
+ in: "host/namespace/model:tag",
|
|
|
+ want: Name{
|
|
|
+ Host: "host",
|
|
|
+ Namespace: "namespace",
|
|
|
+ Model: "model",
|
|
|
+ Tag: "tag",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ in: "host/namespace/model",
|
|
|
+ want: Name{
|
|
|
+ Host: "host",
|
|
|
+ Namespace: "namespace",
|
|
|
+ Model: "model",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ in: "namespace/model",
|
|
|
+ want: Name{
|
|
|
+ Namespace: "namespace",
|
|
|
+ Model: "model",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ in: "model",
|
|
|
+ want: Name{
|
|
|
+ Model: "model",
|
|
|
+ },
|
|
|
},
|
|
|
{
|
|
|
- name: "Short Name",
|
|
|
- in: "mistral:latest",
|
|
|
- wantGoString: "?/?/mistral:latest+?@?",
|
|
|
+ in: "h/nn/mm:t",
|
|
|
+ want: Name{
|
|
|
+ Host: "h",
|
|
|
+ Namespace: "nn",
|
|
|
+ Model: "mm",
|
|
|
+ Tag: "t",
|
|
|
+ },
|
|
|
},
|
|
|
{
|
|
|
- name: "Long Name",
|
|
|
- in: "library/mistral:latest",
|
|
|
- wantGoString: "?/library/mistral:latest+?@?",
|
|
|
+ in: part80 + "/" + part80 + "/" + part80 + ":" + part80,
|
|
|
+ want: Name{
|
|
|
+ Host: part80,
|
|
|
+ Namespace: part80,
|
|
|
+ Model: part80,
|
|
|
+ Tag: part80,
|
|
|
+ },
|
|
|
},
|
|
|
{
|
|
|
- name: "Case Preserved",
|
|
|
- in: "Library/Mistral:Latest",
|
|
|
- wantGoString: "?/Library/Mistral:Latest+?@?",
|
|
|
+ in: part350 + "/" + part80 + "/" + part80 + ":" + part80,
|
|
|
+ want: Name{
|
|
|
+ Host: part350,
|
|
|
+ Namespace: part80,
|
|
|
+ Model: part80,
|
|
|
+ Tag: part80,
|
|
|
+ },
|
|
|
},
|
|
|
{
|
|
|
- name: "With digest",
|
|
|
- in: "Library/Mistral:Latest@sha256-123456",
|
|
|
- wantGoString: "?/Library/Mistral:Latest+?@sha256-123456",
|
|
|
+ in: "@digest",
|
|
|
+ want: Name{
|
|
|
+ RawDigest: "digest",
|
|
|
+ },
|
|
|
+ wantValidDigest: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ in: "model@sha256:" + validSHA256Hex,
|
|
|
+ want: Name{
|
|
|
+ Model: "model",
|
|
|
+ RawDigest: "sha256:" + validSHA256Hex,
|
|
|
+ },
|
|
|
+ wantValidDigest: true,
|
|
|
},
|
|
|
}
|
|
|
|
|
|
for _, tt := range cases {
|
|
|
- t.Run(tt.name, func(t *testing.T) {
|
|
|
- p := ParseNameFill(tt.in, FillNothing)
|
|
|
- tt.wantGoString = cmp.Or(tt.wantGoString, tt.in)
|
|
|
- if g := fmt.Sprintf("%#v", p); g != tt.wantGoString {
|
|
|
- t.Errorf("GoString() = %q; want %q", g, tt.wantGoString)
|
|
|
+ t.Run(tt.in, func(t *testing.T) {
|
|
|
+ got := parseName(tt.in)
|
|
|
+ if !reflect.DeepEqual(got, tt.want) {
|
|
|
+ t.Errorf("parseName(%q) = %v; want %v", tt.in, got, tt.want)
|
|
|
}
|
|
|
- })
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestDisplayLongest(t *testing.T) {
|
|
|
- g := ParseNameFill("example.com/library/mistral:latest+Q4_0", FillNothing).DisplayLongest()
|
|
|
- if g != "example.com/library/mistral:latest" {
|
|
|
- t.Errorf("got = %q; want %q", g, "example.com/library/mistral:latest")
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func TestDisplayShortest(t *testing.T) {
|
|
|
- cases := []struct {
|
|
|
- in string
|
|
|
- mask string
|
|
|
- want string
|
|
|
- wantPanic bool
|
|
|
- }{
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "example.com/library/_:latest", "mistral", false},
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "example.com/_/_:latest", "library/mistral", false},
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "", "example.com/library/mistral", false},
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "", "example.com/library/mistral", false},
|
|
|
-
|
|
|
- // case-insensitive
|
|
|
- {"Example.com/library/mistral:latest+Q4_0", "example.com/library/_:latest", "mistral", false},
|
|
|
- {"example.com/Library/mistral:latest+Q4_0", "example.com/library/_:latest", "mistral", false},
|
|
|
- {"example.com/library/Mistral:latest+Q4_0", "example.com/library/_:latest", "Mistral", false},
|
|
|
- {"example.com/library/mistral:Latest+Q4_0", "example.com/library/_:latest", "mistral", false},
|
|
|
- {"example.com/library/mistral:Latest+q4_0", "example.com/library/_:latest", "mistral", false},
|
|
|
-
|
|
|
- // zero value
|
|
|
- {"", MaskDefault, "", true},
|
|
|
-
|
|
|
- // invalid mask
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "example.com/mistral", "", true},
|
|
|
-
|
|
|
- // DefaultMask
|
|
|
- {"registry.ollama.ai/library/mistral:latest+Q4_0", MaskDefault, "mistral", false},
|
|
|
-
|
|
|
- // Auto-Fill
|
|
|
- {"x", "example.com/library/_:latest", "x", false},
|
|
|
- {"x", "example.com/library/_:latest+Q4_0", "x", false},
|
|
|
- {"x/y:z", "a.com/library/_:latest+Q4_0", "x/y:z", false},
|
|
|
- {"x/y:z", "a.com/library/_:latest+Q4_0", "x/y:z", false},
|
|
|
- }
|
|
|
-
|
|
|
- for _, tt := range cases {
|
|
|
- t.Run("", func(t *testing.T) {
|
|
|
- defer func() {
|
|
|
- if tt.wantPanic {
|
|
|
- if recover() == nil {
|
|
|
- t.Errorf("expected panic")
|
|
|
- }
|
|
|
- }
|
|
|
- }()
|
|
|
-
|
|
|
- p := ParseNameFill(tt.in, FillNothing)
|
|
|
- t.Logf("ParseName(%q) = %#v", tt.in, p)
|
|
|
- if g := p.DisplayShortest(tt.mask); g != tt.want {
|
|
|
- t.Errorf("got = %q; want %q", g, tt.want)
|
|
|
+ if got.Digest().IsValid() != tt.wantValidDigest {
|
|
|
+ t.Errorf("parseName(%q).Digest().IsValid() = %v; want %v", tt.in, got.Digest().IsValid(), tt.wantValidDigest)
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func TestParseNameAllocs(t *testing.T) {
|
|
|
- allocs := testing.AllocsPerRun(1000, func() {
|
|
|
- keep(ParseNameFill("example.com/mistral:7b+Q4_0", FillNothing))
|
|
|
- })
|
|
|
- if allocs > 0 {
|
|
|
- t.Errorf("ParseName allocs = %v; want 0", allocs)
|
|
|
- }
|
|
|
+var testCases = map[string]bool{ // name -> valid
|
|
|
+ "host/namespace/model:tag": true,
|
|
|
+ "host/namespace/model": false,
|
|
|
+ "namespace/model": false,
|
|
|
+ "model": false,
|
|
|
+ "@sha256-1000000000000000000000000000000000000000000000000000000000000000": false,
|
|
|
+ "model@sha256-1000000000000000000000000000000000000000000000000000000000000000": false,
|
|
|
+ "model@sha256:1000000000000000000000000000000000000000000000000000000000000000": false,
|
|
|
+
|
|
|
+ // long (but valid)
|
|
|
+ part80 + "/" + part80 + "/" + part80 + ":" + part80: true,
|
|
|
+ part350 + "/" + part80 + "/" + part80 + ":" + part80: true,
|
|
|
+
|
|
|
+ "h/nn/mm:t@sha256-1000000000000000000000000000000000000000000000000000000000000000": true, // bare minimum part sizes
|
|
|
+ "h/nn/mm:t@sha256:1000000000000000000000000000000000000000000000000000000000000000": true, // bare minimum part sizes
|
|
|
+
|
|
|
+ "m": false, // model too short
|
|
|
+ "n/mm:": false, // namespace too short
|
|
|
+ "h/n/mm:t": false, // namespace too short
|
|
|
+ "@t": false, // digest too short
|
|
|
+ "mm@d": false, // digest too short
|
|
|
+
|
|
|
+ // invalids
|
|
|
+ "^": false,
|
|
|
+ "mm:": false,
|
|
|
+ "/nn/mm": false,
|
|
|
+ "//": false,
|
|
|
+ "//mm": false,
|
|
|
+ "hh//": false,
|
|
|
+ "//mm:@": false,
|
|
|
+ "00@": false,
|
|
|
+ "@": false,
|
|
|
+
|
|
|
+ // not starting with alphanum
|
|
|
+ "-hh/nn/mm:tt@dd": false,
|
|
|
+ "hh/-nn/mm:tt@dd": false,
|
|
|
+ "hh/nn/-mm:tt@dd": false,
|
|
|
+ "hh/nn/mm:-tt@dd": false,
|
|
|
+ "hh/nn/mm:tt@-dd": false,
|
|
|
+
|
|
|
+ "": false,
|
|
|
+
|
|
|
+ // hosts
|
|
|
+ "host:https/namespace/model:tag": true,
|
|
|
+
|
|
|
+ // colon in non-host part before tag
|
|
|
+ "host/name:space/model:tag": false,
|
|
|
}
|
|
|
|
|
|
-func BenchmarkParseName(b *testing.B) {
|
|
|
- b.ReportAllocs()
|
|
|
-
|
|
|
- for range b.N {
|
|
|
- keep(ParseNameFill("example.com/mistral:7b+Q4_0", FillNothing))
|
|
|
+func TestNameparseNameDefault(t *testing.T) {
|
|
|
+ const name = "xx"
|
|
|
+ n := ParseName(name)
|
|
|
+ got := n.String()
|
|
|
+ want := "registry.ollama.ai/library/xx:latest"
|
|
|
+ if got != want {
|
|
|
+ t.Errorf("parseName(%q).String() = %q; want %q", name, got, want)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func FuzzParseNameFromFilepath(f *testing.F) {
|
|
|
- f.Add("example.com/library/mistral/7b/Q4_0")
|
|
|
- f.Add("example.com/../mistral/7b/Q4_0")
|
|
|
- f.Add("example.com/x/../7b/Q4_0")
|
|
|
- f.Add("example.com/x/../7b")
|
|
|
- f.Fuzz(func(t *testing.T, s string) {
|
|
|
- name := ParseNameFromFilepath(s, FillNothing)
|
|
|
- if strings.Contains(s, "..") && !name.IsZero() {
|
|
|
- t.Fatalf("non-zero value for path with '..': %q", s)
|
|
|
- }
|
|
|
- if name.IsValid() == name.IsZero() {
|
|
|
- t.Errorf("expected valid path to be non-zero value; got %#v", name)
|
|
|
- }
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-func FuzzParseName(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.Add(":x")
|
|
|
- f.Add("@sha256-123456")
|
|
|
- f.Add("example.com/mistral:latest+Q4_0@sha256-123456")
|
|
|
- f.Add(":@!@")
|
|
|
- f.Add("...")
|
|
|
- f.Fuzz(func(t *testing.T, s string) {
|
|
|
- r0 := ParseNameFill(s, FillNothing)
|
|
|
-
|
|
|
- if strings.Contains(s, "..") && !r0.IsZero() {
|
|
|
- t.Fatalf("non-zero value for path with '..': %q", s)
|
|
|
- }
|
|
|
-
|
|
|
- if !r0.IsValid() && !r0.IsResolved() {
|
|
|
- if !r0.EqualFold(Name{}) {
|
|
|
- t.Errorf("expected invalid path to be zero value; got %#v", r0)
|
|
|
- }
|
|
|
- t.Skipf("invalid path: %q", s)
|
|
|
+func TestNameIsValid(t *testing.T) {
|
|
|
+ var numStringTests int
|
|
|
+ for s, want := range testCases {
|
|
|
+ n := parseName(s)
|
|
|
+ t.Logf("n: %#v", n)
|
|
|
+ got := n.IsValid()
|
|
|
+ if got != want {
|
|
|
+ t.Errorf("parseName(%q).IsValid() = %v; want %v", s, got, want)
|
|
|
}
|
|
|
|
|
|
- for _, p := range r0.parts {
|
|
|
- if len(p) > MaxNamePartLen {
|
|
|
- t.Errorf("part too long: %q", p)
|
|
|
+ // Test roundtrip with String
|
|
|
+ if got {
|
|
|
+ got := parseName(s).String()
|
|
|
+ if got != s {
|
|
|
+ t.Errorf("parseName(%q).String() = %q; want %q", s, got, s)
|
|
|
}
|
|
|
+ numStringTests++
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- if !strings.EqualFold(r0.DisplayLong(), s) {
|
|
|
- t.Errorf("String() did not round-trip with case insensitivity: %q\ngot = %q\nwant = %q", s, r0.DisplayLong(), s)
|
|
|
- }
|
|
|
-
|
|
|
- r1 := ParseNameFill(r0.DisplayLong(), FillNothing)
|
|
|
- if !r0.EqualFold(r1) {
|
|
|
- t.Errorf("round-trip mismatch: %+v != %+v", r0, r1)
|
|
|
- }
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-func TestNameStringAllocs(t *testing.T) {
|
|
|
- name := ParseNameFill("example.com/ns/mistral:latest+Q4_0", FillNothing)
|
|
|
- allocs := testing.AllocsPerRun(1000, func() {
|
|
|
- keep(name.DisplayLong())
|
|
|
- })
|
|
|
- if allocs > 1 {
|
|
|
- t.Errorf("String allocs = %v; want 0", allocs)
|
|
|
+ if numStringTests == 0 {
|
|
|
+ t.Errorf("no tests for Name.String")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func TestNamePath(t *testing.T) {
|
|
|
+func TestNameIsValidPart(t *testing.T) {
|
|
|
cases := []struct {
|
|
|
- in string
|
|
|
- want string
|
|
|
+ kind partKind
|
|
|
+ s string
|
|
|
+ want bool
|
|
|
}{
|
|
|
- {"example.com/library/mistral:latest+Q4_0", "example.com/library/mistral:latest"},
|
|
|
-
|
|
|
- // incomplete
|
|
|
- {"example.com/library/mistral:latest", "example.com/library/mistral:latest"},
|
|
|
- {"", ""},
|
|
|
+ {kind: kindHost, s: "", want: false},
|
|
|
+ {kind: kindHost, s: "a", want: true},
|
|
|
+ {kind: kindHost, s: "a.", want: true},
|
|
|
+ {kind: kindHost, s: "a.b", want: true},
|
|
|
+ {kind: kindHost, s: "a:123", want: true},
|
|
|
+ {kind: kindHost, s: "a:123/aa/bb", want: false},
|
|
|
+ {kind: kindNamespace, s: "bb", want: true},
|
|
|
+ {kind: kindNamespace, s: "a.", want: false},
|
|
|
+ {kind: kindModel, s: "-h", want: false},
|
|
|
+ {kind: kindDigest, s: "sha256-1000000000000000000000000000000000000000000000000000000000000000", want: true},
|
|
|
}
|
|
|
for _, tt := range cases {
|
|
|
- t.Run(tt.in, func(t *testing.T) {
|
|
|
- p := ParseNameFill(tt.in, FillNothing)
|
|
|
- t.Logf("ParseName(%q) = %#v", tt.in, p)
|
|
|
- if g := p.DisplayURLPath(); g != tt.want {
|
|
|
- t.Errorf("got = %q; want %q", g, tt.want)
|
|
|
+ t.Run(tt.s, func(t *testing.T) {
|
|
|
+ got := isValidPart(tt.kind, tt.s)
|
|
|
+ if got != tt.want {
|
|
|
+ t.Errorf("isValidPart(%s, %q) = %v; want %v", tt.kind, tt.s, got, tt.want)
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
+
|
|
|
}
|
|
|
|
|
|
-func TestNameFilepath(t *testing.T) {
|
|
|
- cases := []struct {
|
|
|
- in string
|
|
|
- want string
|
|
|
- wantNoBuild string
|
|
|
- }{
|
|
|
- {
|
|
|
- in: "example.com/library/mistral:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral/latest/Q4_0",
|
|
|
- wantNoBuild: "example.com/library/mistral/latest",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "Example.Com/Library/Mistral:Latest+Q4_0",
|
|
|
- want: "example.com/library/mistral/latest/Q4_0",
|
|
|
- wantNoBuild: "example.com/library/mistral/latest",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "Example.Com/Library/Mistral:Latest+Q4_0",
|
|
|
- want: "example.com/library/mistral/latest/Q4_0",
|
|
|
- wantNoBuild: "example.com/library/mistral/latest",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library/mistral:latest",
|
|
|
- want: "example.com/library/mistral/latest",
|
|
|
- wantNoBuild: "example.com/library/mistral/latest",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "",
|
|
|
- want: "",
|
|
|
- wantNoBuild: "",
|
|
|
- },
|
|
|
+func FuzzName(f *testing.F) {
|
|
|
+ for s := range testCases {
|
|
|
+ f.Add(s)
|
|
|
}
|
|
|
- for _, tt := range cases {
|
|
|
- t.Run(tt.in, func(t *testing.T) {
|
|
|
- p := ParseNameFill(tt.in, FillNothing)
|
|
|
- t.Logf("ParseName(%q) = %#v", tt.in, p)
|
|
|
- g := p.Filepath()
|
|
|
- g = filepath.ToSlash(g)
|
|
|
- if g != tt.want {
|
|
|
- t.Errorf("got = %q; want %q", g, tt.want)
|
|
|
+ f.Fuzz(func(t *testing.T, s string) {
|
|
|
+ n := parseName(s)
|
|
|
+ if n.IsValid() {
|
|
|
+ parts := [...]string{n.Host, n.Namespace, n.Model, n.Tag, n.RawDigest}
|
|
|
+ for _, part := range parts {
|
|
|
+ if part == ".." {
|
|
|
+ t.Errorf("unexpected .. as valid part")
|
|
|
+ }
|
|
|
+ if len(part) > 350 {
|
|
|
+ t.Errorf("part too long: %q", part)
|
|
|
+ }
|
|
|
}
|
|
|
- g = p.FilepathNoBuild()
|
|
|
- g = filepath.ToSlash(g)
|
|
|
- if g != tt.wantNoBuild {
|
|
|
- t.Errorf("got = %q; want %q", g, tt.wantNoBuild)
|
|
|
+ if n.String() != s {
|
|
|
+ t.Errorf("String() = %q; want %q", n.String(), s)
|
|
|
}
|
|
|
- })
|
|
|
- }
|
|
|
+ }
|
|
|
+
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
-func TestParseNameFilepath(t *testing.T) {
|
|
|
- cases := []struct {
|
|
|
- in string
|
|
|
- fill string // default is FillNothing
|
|
|
- want string
|
|
|
- }{
|
|
|
- {
|
|
|
- in: "example.com/library/mistral/latest/Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library/mistral/latest",
|
|
|
- fill: "?/?/?:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library/mistral",
|
|
|
- fill: "?/?/?:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/^/mistral/latest/Q4_0",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library/mistral/../Q4_0",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "example.com/library/mistral/latest/Q4_0/extra",
|
|
|
- want: "",
|
|
|
- },
|
|
|
+const validSHA256Hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
|
+
|
|
|
+func TestParseDigest(t *testing.T) {
|
|
|
+ cases := map[string]bool{
|
|
|
+ "sha256-1000000000000000000000000000000000000000000000000000000000000000": true,
|
|
|
+ "sha256:1000000000000000000000000000000000000000000000000000000000000000": true,
|
|
|
+ "sha256:0000000000000000000000000000000000000000000000000000000000000000": false,
|
|
|
+
|
|
|
+ "sha256:" + validSHA256Hex: true,
|
|
|
+ "sha256-" + validSHA256Hex: true,
|
|
|
+
|
|
|
+ "": false,
|
|
|
+ "sha134:" + validSHA256Hex: false,
|
|
|
+ "sha256:" + validSHA256Hex + "x": false,
|
|
|
+ "sha256:x" + validSHA256Hex: false,
|
|
|
+ "sha256-" + validSHA256Hex + "x": false,
|
|
|
+ "sha256-x": false,
|
|
|
}
|
|
|
- for _, tt := range cases {
|
|
|
- t.Run(tt.in, func(t *testing.T) {
|
|
|
- in := strings.ReplaceAll(tt.in, "/", string(filepath.Separator))
|
|
|
- fill := cmp.Or(tt.fill, FillNothing)
|
|
|
- want := ParseNameFill(tt.want, fill)
|
|
|
- if g := ParseNameFromFilepath(in, fill); !g.EqualFold(want) {
|
|
|
- t.Errorf("got = %q; want %q", g.DisplayLong(), tt.want)
|
|
|
+
|
|
|
+ for s, want := range cases {
|
|
|
+ t.Run(s, func(t *testing.T) {
|
|
|
+ d := ParseDigest(s)
|
|
|
+ if d.IsValid() != want {
|
|
|
+ t.Errorf("ParseDigest(%q).IsValid() = %v; want %v", s, d.IsValid(), want)
|
|
|
+ }
|
|
|
+ norm := strings.ReplaceAll(s, ":", "-")
|
|
|
+ if d.IsValid() && d.String() != norm {
|
|
|
+ t.Errorf("ParseDigest(%q).String() = %q; want %q", s, d.String(), norm)
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func TestParseNameFromPath(t *testing.T) {
|
|
|
+func TestDigestString(t *testing.T) {
|
|
|
cases := []struct {
|
|
|
in string
|
|
|
want string
|
|
|
- fill string // default is FillNothing
|
|
|
}{
|
|
|
- {
|
|
|
- in: "example.com/library/mistral:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/library/mistral:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/library/mistral",
|
|
|
- want: "example.com/library/mistral",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/library/mistral",
|
|
|
- fill: "?/?/?:latest+Q4_0",
|
|
|
- want: "example.com/library/mistral:latest+Q4_0",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/library",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/",
|
|
|
- want: "",
|
|
|
- },
|
|
|
- {
|
|
|
- in: "/example.com/^/mistral/latest",
|
|
|
- want: "",
|
|
|
- },
|
|
|
+ {in: "sha256:" + validSHA256Hex, want: "sha256-" + validSHA256Hex},
|
|
|
+ {in: "sha256-" + validSHA256Hex, want: "sha256-" + validSHA256Hex},
|
|
|
+ {in: "", want: "unknown-0000000000000000000000000000000000000000000000000000000000000000"},
|
|
|
+ {in: "blah-100000000000000000000000000000000000000000000000000000000000000", want: "unknown-0000000000000000000000000000000000000000000000000000000000000000"},
|
|
|
}
|
|
|
+
|
|
|
for _, tt := range cases {
|
|
|
t.Run(tt.in, func(t *testing.T) {
|
|
|
- fill := cmp.Or(tt.fill, FillNothing)
|
|
|
- if g := ParseNameFromURLPath(tt.in, fill); g.DisplayLong() != tt.want {
|
|
|
- t.Errorf("got = %q; want %q", g.DisplayLong(), tt.want)
|
|
|
+ d := ParseDigest(tt.in)
|
|
|
+ if d.String() != tt.want {
|
|
|
+ t.Errorf("ParseDigest(%q).String() = %q; want %q", tt.in, d.String(), tt.want)
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
-func ExampleName_MapHash() {
|
|
|
- m := map[uint64]bool{}
|
|
|
-
|
|
|
- // key 1
|
|
|
- m[ParseNameFill("mistral:latest+q4", FillNothing).MapHash()] = true
|
|
|
- m[ParseNameFill("miSTRal:latest+Q4", FillNothing).MapHash()] = true
|
|
|
- m[ParseNameFill("mistral:LATest+Q4", FillNothing).MapHash()] = true
|
|
|
-
|
|
|
- // key 2
|
|
|
- m[ParseNameFill("mistral:LATest", FillNothing).MapHash()] = true
|
|
|
-
|
|
|
- fmt.Println(len(m))
|
|
|
- // Output:
|
|
|
- // 2
|
|
|
-}
|
|
|
-
|
|
|
-func ExampleName_CompareFold_sort() {
|
|
|
- names := []Name{
|
|
|
- ParseNameFill("mistral:latest", FillNothing),
|
|
|
- ParseNameFill("mistRal:7b+q4", FillNothing),
|
|
|
- ParseNameFill("MIstral:7b", FillNothing),
|
|
|
- }
|
|
|
-
|
|
|
- slices.SortFunc(names, Name.CompareFold)
|
|
|
-
|
|
|
- for _, n := range names {
|
|
|
- fmt.Println(n.DisplayLong())
|
|
|
- }
|
|
|
-
|
|
|
- // Output:
|
|
|
- // MIstral:7b
|
|
|
- // mistRal:7b+q4
|
|
|
- // mistral:latest
|
|
|
-}
|
|
|
-
|
|
|
-func ExampleName_completeAndResolved() {
|
|
|
- for _, s := range []string{
|
|
|
- "x/y/z:latest+q4_0@sha123-abc",
|
|
|
- "x/y/z:latest+q4_0",
|
|
|
- "@sha123-abc",
|
|
|
- } {
|
|
|
- name := ParseNameFill(s, FillNothing)
|
|
|
- fmt.Printf("complete:%v resolved:%v digest:%s\n", name.IsComplete(), name.IsResolved(), name.Digest())
|
|
|
- }
|
|
|
-
|
|
|
- // Output:
|
|
|
- // complete:true resolved:true digest:sha123-abc
|
|
|
- // complete:true resolved:false digest:
|
|
|
- // complete:false resolved:true digest:sha123-abc
|
|
|
-}
|
|
|
-
|
|
|
-func ExampleName_DisplayShortest() {
|
|
|
- name := ParseNameFill("example.com/jmorganca/mistral:latest+Q4_0", FillNothing)
|
|
|
-
|
|
|
- fmt.Println(name.DisplayShortest("example.com/jmorganca/_:latest"))
|
|
|
- fmt.Println(name.DisplayShortest("example.com/_/_:latest"))
|
|
|
- fmt.Println(name.DisplayShortest("example.com/_/_:_"))
|
|
|
- fmt.Println(name.DisplayShortest("_/_/_:_"))
|
|
|
-
|
|
|
- // Default
|
|
|
- name = ParseNameFill("registry.ollama.ai/library/mistral:latest+Q4_0", FillNothing)
|
|
|
- fmt.Println(name.DisplayShortest(""))
|
|
|
-
|
|
|
- // Output:
|
|
|
- // mistral
|
|
|
- // jmorganca/mistral
|
|
|
- // jmorganca/mistral:latest
|
|
|
- // example.com/jmorganca/mistral:latest
|
|
|
- // mistral
|
|
|
-}
|
|
|
-
|
|
|
-func keep[T any](v T) T { return v }
|