name_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. package model
  2. import (
  3. "path/filepath"
  4. "reflect"
  5. "runtime"
  6. "testing"
  7. )
  8. const (
  9. part80 = "88888888888888888888888888888888888888888888888888888888888888888888888888888888"
  10. part350 = "33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333"
  11. )
  12. func TestParseNameParts(t *testing.T) {
  13. cases := []struct {
  14. in string
  15. want Name
  16. wantFilepath string
  17. wantValidDigest bool
  18. }{
  19. {
  20. in: "registry.ollama.ai/library/dolphin-mistral:7b-v2.6-dpo-laser-q6_K",
  21. want: Name{
  22. Host: "registry.ollama.ai",
  23. Namespace: "library",
  24. Model: "dolphin-mistral",
  25. Tag: "7b-v2.6-dpo-laser-q6_K",
  26. },
  27. wantFilepath: filepath.Join("registry.ollama.ai", "library", "dolphin-mistral", "7b-v2.6-dpo-laser-q6_K"),
  28. },
  29. {
  30. in: "scheme://host:port/namespace/model:tag",
  31. want: Name{
  32. Host: "host:port",
  33. Namespace: "namespace",
  34. Model: "model",
  35. Tag: "tag",
  36. },
  37. wantFilepath: filepath.Join("host:port", "namespace", "model", "tag"),
  38. },
  39. {
  40. in: "host/namespace/model:tag",
  41. want: Name{
  42. Host: "host",
  43. Namespace: "namespace",
  44. Model: "model",
  45. Tag: "tag",
  46. },
  47. wantFilepath: filepath.Join("host", "namespace", "model", "tag"),
  48. },
  49. {
  50. in: "host:port/namespace/model:tag",
  51. want: Name{
  52. Host: "host:port",
  53. Namespace: "namespace",
  54. Model: "model",
  55. Tag: "tag",
  56. },
  57. wantFilepath: filepath.Join("host:port", "namespace", "model", "tag"),
  58. },
  59. {
  60. in: "host/namespace/model",
  61. want: Name{
  62. Host: "host",
  63. Namespace: "namespace",
  64. Model: "model",
  65. },
  66. wantFilepath: filepath.Join("host", "namespace", "model", "latest"),
  67. },
  68. {
  69. in: "host:port/namespace/model",
  70. want: Name{
  71. Host: "host:port",
  72. Namespace: "namespace",
  73. Model: "model",
  74. },
  75. wantFilepath: filepath.Join("host:port", "namespace", "model", "latest"),
  76. },
  77. {
  78. in: "namespace/model",
  79. want: Name{
  80. Namespace: "namespace",
  81. Model: "model",
  82. },
  83. wantFilepath: filepath.Join("registry.ollama.ai", "namespace", "model", "latest"),
  84. },
  85. {
  86. in: "model",
  87. want: Name{
  88. Model: "model",
  89. },
  90. wantFilepath: filepath.Join("registry.ollama.ai", "library", "model", "latest"),
  91. },
  92. {
  93. in: "h/nn/mm:t",
  94. want: Name{
  95. Host: "h",
  96. Namespace: "nn",
  97. Model: "mm",
  98. Tag: "t",
  99. },
  100. wantFilepath: filepath.Join("h", "nn", "mm", "t"),
  101. },
  102. {
  103. in: part80 + "/" + part80 + "/" + part80 + ":" + part80,
  104. want: Name{
  105. Host: part80,
  106. Namespace: part80,
  107. Model: part80,
  108. Tag: part80,
  109. },
  110. wantFilepath: filepath.Join(part80, part80, part80, part80),
  111. },
  112. {
  113. in: part350 + "/" + part80 + "/" + part80 + ":" + part80,
  114. want: Name{
  115. Host: part350,
  116. Namespace: part80,
  117. Model: part80,
  118. Tag: part80,
  119. },
  120. wantFilepath: filepath.Join(part350, part80, part80, part80),
  121. },
  122. {
  123. in: "@digest",
  124. want: Name{
  125. RawDigest: "digest",
  126. },
  127. wantValidDigest: false,
  128. },
  129. {
  130. in: "model@sha256:123",
  131. want: Name{
  132. Model: "model",
  133. RawDigest: "sha256:123",
  134. },
  135. wantValidDigest: true,
  136. },
  137. }
  138. for _, tt := range cases {
  139. t.Run(tt.in, func(t *testing.T) {
  140. got := ParseNameBare(tt.in)
  141. if !reflect.DeepEqual(got, tt.want) {
  142. t.Errorf("parseName(%q) = %v; want %v", tt.in, got, tt.want)
  143. }
  144. got = ParseName(tt.in)
  145. if tt.wantFilepath != "" && got.Filepath() != tt.wantFilepath {
  146. t.Errorf("parseName(%q).Filepath() = %q; want %q", tt.in, got.Filepath(), tt.wantFilepath)
  147. }
  148. })
  149. }
  150. }
  151. var testCases = map[string]bool{ // name -> valid
  152. "": false,
  153. "_why/_the/_lucky:_stiff": true,
  154. // minimal
  155. "h/n/m:t@d": true,
  156. "host/namespace/model:tag": true,
  157. "host/namespace/model": false,
  158. "namespace/model": false,
  159. "model": false,
  160. "@sha256-1000000000000000000000000000000000000000000000000000000000000000": false,
  161. "model@sha256-1000000000000000000000000000000000000000000000000000000000000000": false,
  162. "model@sha256:1000000000000000000000000000000000000000000000000000000000000000": false,
  163. // long (but valid)
  164. part80 + "/" + part80 + "/" + part80 + ":" + part80: true,
  165. part350 + "/" + part80 + "/" + part80 + ":" + part80: true,
  166. "h/nn/mm:t@sha256-1000000000000000000000000000000000000000000000000000000000000000": true, // bare minimum part sizes
  167. "h/nn/mm:t@sha256:1000000000000000000000000000000000000000000000000000000000000000": true, // bare minimum part sizes
  168. // unqualified
  169. "m": false,
  170. "n/m:": false,
  171. "h/n/m": false,
  172. "@t": false,
  173. "m@d": false,
  174. // invalids
  175. "^": false,
  176. "mm:": false,
  177. "/nn/mm": false,
  178. "//": false,
  179. "//mm": false,
  180. "hh//": false,
  181. "//mm:@": false,
  182. "00@": false,
  183. "@": false,
  184. // not starting with alphanum
  185. "-hh/nn/mm:tt@dd": false,
  186. "hh/-nn/mm:tt@dd": false,
  187. "hh/nn/-mm:tt@dd": false,
  188. "hh/nn/mm:-tt@dd": false,
  189. "hh/nn/mm:tt@-dd": false,
  190. // hosts
  191. "host:https/namespace/model:tag": true,
  192. // colon in non-host part before tag
  193. "host/name:space/model:tag": false,
  194. }
  195. func TestNameparseNameDefault(t *testing.T) {
  196. const name = "xx"
  197. n := ParseName(name)
  198. got := n.String()
  199. want := "registry.ollama.ai/library/xx:latest"
  200. if got != want {
  201. t.Errorf("parseName(%q).String() = %q; want %q", name, got, want)
  202. }
  203. }
  204. func TestNameIsValid(t *testing.T) {
  205. var numStringTests int
  206. for s, want := range testCases {
  207. n := ParseNameBare(s)
  208. got := n.IsValid()
  209. if got != want {
  210. t.Errorf("parseName(%q).IsValid() = %v; want %v", s, got, want)
  211. }
  212. // Test roundtrip with String
  213. if got {
  214. got := ParseNameBare(s).String()
  215. if got != s {
  216. t.Errorf("parseName(%q).String() = %q; want %q", s, got, s)
  217. }
  218. numStringTests++
  219. }
  220. }
  221. if numStringTests == 0 {
  222. t.Errorf("no tests for Name.String")
  223. }
  224. }
  225. func TestNameIsValidPart(t *testing.T) {
  226. cases := []struct {
  227. kind partKind
  228. s string
  229. want bool
  230. }{
  231. {kind: kindHost, s: "", want: false},
  232. {kind: kindHost, s: "a", want: true},
  233. {kind: kindHost, s: "a.", want: true},
  234. {kind: kindHost, s: "a.b", want: true},
  235. {kind: kindHost, s: "a:123", want: true},
  236. {kind: kindHost, s: "a:123/aa/bb", want: false},
  237. {kind: kindNamespace, s: "bb", want: true},
  238. {kind: kindNamespace, s: "a.", want: false},
  239. {kind: kindModel, s: "-h", want: false},
  240. {kind: kindDigest, s: "sha256-1000000000000000000000000000000000000000000000000000000000000000", want: true},
  241. }
  242. for _, tt := range cases {
  243. t.Run(tt.s, func(t *testing.T) {
  244. got := isValidPart(tt.kind, tt.s)
  245. if got != tt.want {
  246. t.Errorf("isValidPart(%s, %q) = %v; want %v", tt.kind, tt.s, got, tt.want)
  247. }
  248. })
  249. }
  250. }
  251. func TestFilepathAllocs(t *testing.T) {
  252. n := ParseNameBare("HOST/NAMESPACE/MODEL:TAG")
  253. allocs := testing.AllocsPerRun(1000, func() {
  254. n.Filepath()
  255. })
  256. var allowedAllocs float64 = 1
  257. if runtime.GOOS == "windows" {
  258. allowedAllocs = 3
  259. }
  260. if allocs > allowedAllocs {
  261. t.Errorf("allocs = %v; allowed %v", allocs, allowedAllocs)
  262. }
  263. }
  264. const (
  265. validSha256 = "sha256-1000000000000000000000000000000000000000000000000000000000000000"
  266. validSha256Old = "sha256:1000000000000000000000000000000000000000000000000000000000000000"
  267. )
  268. func TestParseDigest(t *testing.T) {
  269. cases := []struct {
  270. in string
  271. want string
  272. }{
  273. {"", ""}, // empty
  274. {"sha123-12", ""}, // invalid type
  275. {"sha256-", ""}, // invalid sum
  276. {"sha256-123", ""}, // invalid odd length sum
  277. {validSha256, validSha256},
  278. {validSha256Old, validSha256},
  279. }
  280. for _, tt := range cases {
  281. t.Run(tt.in, func(t *testing.T) {
  282. got, err := ParseDigest(tt.in)
  283. if err != nil {
  284. if tt.want != "" {
  285. t.Errorf("parseDigest(%q) = %v; want %v", tt.in, err, tt.want)
  286. }
  287. return
  288. }
  289. if got.String() != tt.want {
  290. t.Errorf("parseDigest(%q).String() = %q; want %q", tt.in, got, tt.want)
  291. }
  292. })
  293. }
  294. }
  295. func TestParseNameFromFilepath(t *testing.T) {
  296. cases := map[string]Name{
  297. filepath.Join("host", "namespace", "model", "tag"): {Host: "host", Namespace: "namespace", Model: "model", Tag: "tag"},
  298. filepath.Join("host:port", "namespace", "model", "tag"): {Host: "host:port", Namespace: "namespace", Model: "model", Tag: "tag"},
  299. filepath.Join("namespace", "model", "tag"): {},
  300. filepath.Join("model", "tag"): {},
  301. "model": {},
  302. filepath.Join("..", "..", "model", "tag"): {},
  303. filepath.Join("", "namespace", ".", "tag"): {},
  304. filepath.Join(".", ".", ".", "."): {},
  305. filepath.Join("/", "path", "to", "random", "file"): {},
  306. }
  307. for in, want := range cases {
  308. t.Run(in, func(t *testing.T) {
  309. got := ParseNameFromFilepath(in)
  310. if !reflect.DeepEqual(got, want) {
  311. t.Errorf("parseNameFromFilepath(%q) = %v; want %v", in, got, want)
  312. }
  313. })
  314. }
  315. }
  316. func TestDisplayShortest(t *testing.T) {
  317. cases := map[string]string{
  318. "registry.ollama.ai/library/model:latest": "model:latest",
  319. "registry.ollama.ai/library/model:tag": "model:tag",
  320. "registry.ollama.ai/namespace/model:tag": "namespace/model:tag",
  321. "host/namespace/model:tag": "host/namespace/model:tag",
  322. "host/library/model:tag": "host/library/model:tag",
  323. }
  324. for in, want := range cases {
  325. t.Run(in, func(t *testing.T) {
  326. got := ParseNameBare(in).DisplayShortest()
  327. if got != want {
  328. t.Errorf("parseName(%q).DisplayShortest() = %q; want %q", in, got, want)
  329. }
  330. })
  331. }
  332. }
  333. func FuzzName(f *testing.F) {
  334. for s := range testCases {
  335. f.Add(s)
  336. }
  337. f.Fuzz(func(t *testing.T, s string) {
  338. n := ParseNameBare(s)
  339. if n.IsValid() {
  340. parts := [...]string{n.Host, n.Namespace, n.Model, n.Tag, n.RawDigest}
  341. for _, part := range parts {
  342. if part == ".." {
  343. t.Errorf("unexpected .. as valid part")
  344. }
  345. if len(part) > 350 {
  346. t.Errorf("part too long: %q", part)
  347. }
  348. }
  349. if n.String() != s {
  350. t.Errorf("String() = %q; want %q", n.String(), s)
  351. }
  352. }
  353. })
  354. }
  355. func TestIsValidNamespace(t *testing.T) {
  356. cases := []struct {
  357. username string
  358. expected bool
  359. }{
  360. {"", false},
  361. {"a", true},
  362. {"a:b", false},
  363. {"a/b", false},
  364. {"a:b/c", false},
  365. {"a/b:c", false},
  366. {"a/b:c", false},
  367. {"a/b:c/d", false},
  368. {"a/b:c/d@e", false},
  369. {"a/b:c/d@sha256-100", false},
  370. {"himynameisjoe", true},
  371. {"himynameisreallyreallyreallyreallylongbutitshouldstillbevalid", true},
  372. }
  373. for _, tt := range cases {
  374. t.Run(tt.username, func(t *testing.T) {
  375. if got := IsValidNamespace(tt.username); got != tt.expected {
  376. t.Errorf("IsValidName(%q) = %v; want %v", tt.username, got, tt.expected)
  377. }
  378. })
  379. }
  380. }