1package update
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "context"
8 "errors"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14
15 "github.com/stretchr/testify/require"
16)
17
18func TestCheckForUpdate_Old(t *testing.T) {
19 info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"})
20 require.NoError(t, err)
21 require.NotNil(t, info)
22 require.True(t, info.Available())
23}
24
25func TestCheckForUpdate_Beta(t *testing.T) {
26 t.Run("current is stable", func(t *testing.T) {
27 info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
28 require.NoError(t, err)
29 require.NotNil(t, info)
30 require.False(t, info.Available())
31 })
32
33 t.Run("current is also beta", func(t *testing.T) {
34 info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"})
35 require.NoError(t, err)
36 require.NotNil(t, info)
37 require.True(t, info.Available())
38 })
39
40 t.Run("current is beta, latest isn't", func(t *testing.T) {
41 info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"})
42 require.NoError(t, err)
43 require.NotNil(t, info)
44 require.True(t, info.Available())
45 })
46}
47
48func TestCheckForUpdate_NetworkError(t *testing.T) {
49 t.Parallel()
50 _, err := Check(t.Context(), "0.19.0", errorClient{errors.New("network unreachable")})
51 require.Error(t, err)
52 require.Contains(t, err.Error(), "failed to fetch")
53}
54
55type testClient struct{ tag string }
56
57// Latest implements Client.
58func (t testClient) Latest(ctx context.Context) (*Release, error) {
59 return &Release{
60 TagName: t.tag,
61 HTMLURL: "https://example.org",
62 }, nil
63}
64
65type errorClient struct{ err error }
66
67// Latest implements Client.
68func (e errorClient) Latest(ctx context.Context) (*Release, error) {
69 return nil, e.err
70}
71
72func TestFindAsset(t *testing.T) {
73 t.Parallel()
74
75 // Create test assets matching goreleaser naming.
76 assets := []Asset{
77 {Name: "crush_0.19.2_Linux_x86_64.tar.gz", BrowserDownloadURL: "https://example.com/linux-amd64.tar.gz"},
78 {Name: "crush_0.19.2_Darwin_x86_64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-amd64.tar.gz"},
79 {Name: "crush_0.19.2_Darwin_arm64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-arm64.tar.gz"},
80 {Name: "crush_0.19.2_Windows_x86_64.zip", BrowserDownloadURL: "https://example.com/windows-amd64.zip"},
81 {Name: "crush_0.19.2_Linux_i386.tar.gz", BrowserDownloadURL: "https://example.com/linux-386.tar.gz"},
82 {Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
83 {Name: "crush_0.19.2_Linux_x86_64.tar.gz.sig", BrowserDownloadURL: "https://example.com/linux-amd64.tar.gz.sig"},
84 }
85
86 t.Run("finds correct asset for current platform", func(t *testing.T) {
87 t.Parallel()
88 asset, err := FindAsset(assets)
89 require.NoError(t, err)
90 require.NotNil(t, asset)
91
92 // Check that the asset matches our platform.
93 switch runtime.GOOS {
94 case "linux":
95 require.Contains(t, asset.Name, "Linux")
96 case "darwin":
97 require.Contains(t, asset.Name, "Darwin")
98 case "windows":
99 require.Contains(t, asset.Name, "Windows")
100 }
101
102 // Check that it's an archive, not a signature or checksum.
103 require.True(t, strings.HasSuffix(asset.Name, ".tar.gz") || strings.HasSuffix(asset.Name, ".zip"))
104 })
105
106 t.Run("returns error when no matching asset", func(t *testing.T) {
107 t.Parallel()
108 emptyAssets := []Asset{
109 {Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
110 }
111 asset, err := FindAsset(emptyAssets)
112 require.Error(t, err)
113 require.Nil(t, asset)
114 })
115}
116
117func TestIsDevelopment(t *testing.T) {
118 t.Parallel()
119
120 tests := []struct {
121 name string
122 version string
123 want bool
124 }{
125 {"devel version", "devel", true},
126 {"unknown version", "unknown", true},
127 {"dirty version", "0.19.0-dirty", true},
128 {"dirty with suffix", "0.19.0-10-g1234567-dirty", true},
129 {"go install pseudo-version", "v0.0.0-0.20251231235959-06c807842604", true},
130 {"git describe version", "v0.19.0-15-g1a2b3c4d", true},
131 {"git describe short hash", "0.19.0-3-gabcdef0", true},
132 {"git describe long hash", "v1.0.0-100-g0123456789ab", true},
133 {"stable version", "0.19.0", false},
134 {"pre-release beta", "0.19.0-beta.1", false},
135 {"pre-release rc", "0.19.0-rc.1", false},
136 {"pre-release alpha", "0.19.0-alpha.1", false},
137 {"major version", "1.0.0", false},
138 {"with v prefix", "v2.0.0", false},
139 }
140
141 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {
143 t.Parallel()
144 info := Info{Current: tt.version, Latest: "0.20.0"}
145 require.Equal(t, tt.want, info.IsDevelopment())
146 })
147 }
148}
149
150func TestAvailable(t *testing.T) {
151 t.Parallel()
152
153 tests := []struct {
154 name string
155 current string
156 latest string
157 want bool
158 }{
159 // Basic cases.
160 {"same version", "0.19.0", "0.19.0", false},
161 {"newer available", "0.19.0", "0.19.1", true},
162 {"older latest (no downgrade)", "0.19.1", "0.19.0", false},
163
164 // Pre-release handling.
165 {"rc to stable", "0.19.0-rc.1", "0.19.0", true},
166 {"stable to rc", "0.19.0", "0.20.0-rc.1", false},
167 {"alpha to beta", "0.19.0-alpha.1", "0.19.0-beta.1", true},
168 {"beta to rc", "0.19.0-beta.1", "0.19.0-rc.1", true},
169 {"same pre-release", "0.19.0-beta.1", "0.19.0-beta.1", false},
170
171 // Semver edge cases - multi-digit versions.
172 {"0.9.9 to 0.10.0", "0.9.9", "0.10.0", true},
173 {"0.19.0 to 0.19.10", "0.19.0", "0.19.10", true},
174 {"1.9.0 to 1.10.0", "1.9.0", "1.10.0", true},
175
176 // Major version bumps.
177 {"0.x to 1.0", "0.99.99", "1.0.0", true},
178 {"1.x to 2.0", "1.0.0", "2.0.0", true},
179
180 // With v prefix.
181 {"v prefix current", "v0.19.0", "0.19.1", true},
182 {"v prefix latest", "0.19.0", "v0.19.1", true},
183 {"v prefix both", "v0.19.0", "v0.19.1", true},
184
185 // Malformed versions should return false.
186 {"malformed current", "not-a-version", "0.19.0", false},
187 {"malformed latest", "0.19.0", "not-a-version", false},
188 {"both malformed", "bad", "worse", false},
189
190 // Build metadata is ignored in semver comparison.
191 {"build metadata only diff", "1.0.0+build.1", "1.0.0+build.2", false},
192 {"build metadata vs plain", "1.0.0", "1.0.0+build.1", false},
193 {"build metadata with newer version", "1.0.0+build.1", "1.0.1", true},
194
195 // Pre-release ordering edge cases.
196 {"prerelease alpha ordering", "1.0.0-alpha", "1.0.0-alpha.1", true},
197 {"prerelease numeric ordering", "1.0.0-1", "1.0.0-2", true},
198 {"prerelease alpha vs beta", "1.0.0-alpha.2", "1.0.0-beta.1", true},
199 }
200
201 for _, tt := range tests {
202 t.Run(tt.name, func(t *testing.T) {
203 t.Parallel()
204 info := Info{Current: tt.current, Latest: tt.latest}
205 require.Equal(t, tt.want, info.Available())
206 })
207 }
208}
209
210func TestParseChecksums(t *testing.T) {
211 t.Parallel()
212
213 // Valid SHA256 hashes for testing (64 hex characters).
214 hash1 := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
215 hash2 := "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"
216
217 tests := []struct {
218 name string
219 input string
220 want map[string]string
221 }{
222 {
223 name: "standard format",
224 input: hash1 + " crush_0.19.2_Linux_x86_64.tar.gz\n" + hash2 + " crush_0.19.2_Darwin_arm64.tar.gz\n",
225 want: map[string]string{
226 "crush_0.19.2_Linux_x86_64.tar.gz": hash1,
227 "crush_0.19.2_Darwin_arm64.tar.gz": hash2,
228 },
229 },
230 {
231 name: "empty lines",
232 input: hash1 + " file1.tar.gz\n\n" + hash2 + " file2.tar.gz\n",
233 want: map[string]string{
234 "file1.tar.gz": hash1,
235 "file2.tar.gz": hash2,
236 },
237 },
238 {
239 name: "extra fields ignored",
240 input: hash1 + " file1.tar.gz extra fields\n",
241 want: map[string]string{},
242 },
243 {
244 name: "single field ignored",
245 input: hash1 + "\n",
246 want: map[string]string{},
247 },
248 {
249 name: "whitespace variations",
250 input: hash1 + "\tfile1.tar.gz\n",
251 want: map[string]string{
252 "file1.tar.gz": hash1,
253 },
254 },
255 {
256 name: "invalid checksum length ignored",
257 input: "abc123 file1.tar.gz\n",
258 want: map[string]string{},
259 },
260 {
261 name: "invalid checksum hex ignored",
262 input: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz file1.tar.gz\n",
263 want: map[string]string{},
264 },
265 }
266
267 for _, tt := range tests {
268 t.Run(tt.name, func(t *testing.T) {
269 t.Parallel()
270 result := parseChecksumLines(tt.input)
271 require.Equal(t, tt.want, result)
272 })
273 }
274}
275
276func TestExtractTarGz(t *testing.T) {
277 t.Parallel()
278
279 t.Run("missing binary", func(t *testing.T) {
280 t.Parallel()
281 // Create a tar.gz with no crush binary.
282 tmpDir := t.TempDir()
283 archivePath := filepath.Join(tmpDir, "test.tar.gz")
284
285 f, err := os.Create(archivePath)
286 require.NoError(t, err)
287
288 gzw := gzip.NewWriter(f)
289 tw := tar.NewWriter(gzw)
290
291 // Add a random file, not crush.
292 content := []byte("not a binary")
293 hdr := &tar.Header{
294 Name: "other-file.txt",
295 Mode: 0o644,
296 Size: int64(len(content)),
297 }
298 require.NoError(t, tw.WriteHeader(hdr))
299 _, err = tw.Write(content)
300 require.NoError(t, err)
301
302 require.NoError(t, tw.Close())
303 require.NoError(t, gzw.Close())
304 require.NoError(t, f.Close())
305
306 _, err = extractTarGz(archivePath)
307 require.Error(t, err)
308 require.Contains(t, err.Error(), "not found")
309 })
310
311 t.Run("path traversal attempt", func(t *testing.T) {
312 t.Parallel()
313 tmpDir := t.TempDir()
314 archivePath := filepath.Join(tmpDir, "test.tar.gz")
315
316 f, err := os.Create(archivePath)
317 require.NoError(t, err)
318
319 gzw := gzip.NewWriter(f)
320 tw := tar.NewWriter(gzw)
321
322 // Add a file with path traversal attempt.
323 content := []byte("malicious")
324 hdr := &tar.Header{
325 Name: "../../../etc/passwd",
326 Mode: 0o644,
327 Size: int64(len(content)),
328 }
329 require.NoError(t, tw.WriteHeader(hdr))
330 _, err = tw.Write(content)
331 require.NoError(t, err)
332
333 require.NoError(t, tw.Close())
334 require.NoError(t, gzw.Close())
335 require.NoError(t, f.Close())
336
337 // Should not extract the malicious file and should fail to find binary.
338 _, err = extractTarGz(archivePath)
339 require.Error(t, err)
340 require.Contains(t, err.Error(), "not found")
341 })
342}
343
344func TestExtractZip(t *testing.T) {
345 t.Parallel()
346
347 t.Run("missing binary", func(t *testing.T) {
348 t.Parallel()
349 tmpDir := t.TempDir()
350 archivePath := filepath.Join(tmpDir, "test.zip")
351
352 f, err := os.Create(archivePath)
353 require.NoError(t, err)
354
355 zw := zip.NewWriter(f)
356
357 // Add a random file, not crush.
358 w, err := zw.Create("other-file.txt")
359 require.NoError(t, err)
360 _, err = w.Write([]byte("not a binary"))
361 require.NoError(t, err)
362
363 require.NoError(t, zw.Close())
364 require.NoError(t, f.Close())
365
366 _, err = extractZip(archivePath)
367 require.Error(t, err)
368 require.Contains(t, err.Error(), "not found")
369 })
370
371 t.Run("path traversal attempt", func(t *testing.T) {
372 t.Parallel()
373 tmpDir := t.TempDir()
374 archivePath := filepath.Join(tmpDir, "test.zip")
375
376 f, err := os.Create(archivePath)
377 require.NoError(t, err)
378
379 zw := zip.NewWriter(f)
380
381 // Add a file with path traversal attempt.
382 w, err := zw.Create("../../../etc/passwd")
383 require.NoError(t, err)
384 _, err = w.Write([]byte("malicious"))
385 require.NoError(t, err)
386
387 require.NoError(t, zw.Close())
388 require.NoError(t, f.Close())
389
390 // Should not extract the malicious file and should fail to find binary.
391 _, err = extractZip(archivePath)
392 require.Error(t, err)
393 require.Contains(t, err.Error(), "not found")
394 })
395}
396
397func TestApply(t *testing.T) {
398 t.Parallel()
399
400 t.Run("read-only directory", func(t *testing.T) {
401 t.Parallel()
402 if runtime.GOOS == "windows" {
403 t.Skip("chmod not reliable on Windows")
404 }
405
406 tmpDir := t.TempDir()
407
408 // Create a fake binary to apply.
409 binaryPath := filepath.Join(tmpDir, "new-binary")
410 require.NoError(t, os.WriteFile(binaryPath, []byte("new"), 0o755))
411
412 // Create a read-only directory.
413 readOnlyDir := filepath.Join(tmpDir, "readonly")
414 require.NoError(t, os.MkdirAll(readOnlyDir, 0o755))
415
416 // Create a fake executable in the read-only dir.
417 exePath := filepath.Join(readOnlyDir, "crush")
418 require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
419
420 // Make the directory read-only.
421 require.NoError(t, os.Chmod(readOnlyDir, 0o555))
422 t.Cleanup(func() {
423 // Restore permissions for cleanup.
424 os.Chmod(readOnlyDir, 0o755)
425 })
426
427 // checkWritePermission should fail.
428 err := checkWritePermission(readOnlyDir)
429 require.Error(t, err)
430 })
431
432 t.Run("successful copy", func(t *testing.T) {
433 t.Parallel()
434 tmpDir := t.TempDir()
435
436 src := filepath.Join(tmpDir, "src")
437 dst := filepath.Join(tmpDir, "dst")
438
439 content := []byte("test content")
440 require.NoError(t, os.WriteFile(src, content, 0o755))
441
442 require.NoError(t, copyFile(src, dst))
443
444 dstContent, err := os.ReadFile(dst)
445 require.NoError(t, err)
446 require.Equal(t, content, dstContent)
447 })
448}
449
450func TestInstallMethod_String(t *testing.T) {
451 t.Parallel()
452
453 tests := []struct {
454 method InstallMethod
455 want string
456 }{
457 {InstallMethodUnknown, "unknown"},
458 {InstallMethodBinary, "binary"},
459 {InstallMethodHomebrew, "Homebrew"},
460 {InstallMethodNPM, "npm"},
461 {InstallMethodAUR, "AUR"},
462 {InstallMethodNix, "Nix"},
463 {InstallMethodWinget, "winget"},
464 {InstallMethodScoop, "Scoop"},
465 {InstallMethodApt, "apt"},
466 {InstallMethodYum, "yum"},
467 {InstallMethodGoInstall, "go install"},
468 }
469
470 for _, tt := range tests {
471 t.Run(tt.want, func(t *testing.T) {
472 t.Parallel()
473 require.Equal(t, tt.want, tt.method.String())
474 })
475 }
476}
477
478func TestInstallMethod_CanSelfUpdate(t *testing.T) {
479 t.Parallel()
480
481 tests := []struct {
482 method InstallMethod
483 want bool
484 }{
485 {InstallMethodUnknown, true},
486 {InstallMethodBinary, true},
487 {InstallMethodHomebrew, false},
488 {InstallMethodNPM, false},
489 {InstallMethodAUR, false},
490 {InstallMethodNix, false},
491 {InstallMethodWinget, false},
492 {InstallMethodScoop, false},
493 {InstallMethodApt, false},
494 {InstallMethodYum, false},
495 {InstallMethodGoInstall, false},
496 }
497
498 for _, tt := range tests {
499 t.Run(tt.method.String(), func(t *testing.T) {
500 t.Parallel()
501 require.Equal(t, tt.want, tt.method.CanSelfUpdate())
502 })
503 }
504}
505
506func TestInstallMethod_UpdateInstructions(t *testing.T) {
507 t.Parallel()
508
509 tests := []struct {
510 method InstallMethod
511 contains string
512 }{
513 {InstallMethodHomebrew, "brew upgrade"},
514 {InstallMethodNPM, "npm update"},
515 {InstallMethodAUR, "yay -Syu crush-bin"},
516 {InstallMethodNix, "nix"},
517 {InstallMethodWinget, "winget upgrade"},
518 {InstallMethodScoop, "scoop update"},
519 {InstallMethodApt, "apt"},
520 {InstallMethodYum, "yum update"},
521 {InstallMethodGoInstall, "go install"},
522 }
523
524 for _, tt := range tests {
525 t.Run(tt.method.String(), func(t *testing.T) {
526 t.Parallel()
527 instructions := tt.method.UpdateInstructions()
528 require.Contains(t, instructions, tt.contains)
529 })
530 }
531
532 t.Run("unknown returns empty", func(t *testing.T) {
533 t.Parallel()
534 require.Empty(t, InstallMethodUnknown.UpdateInstructions())
535 })
536
537 t.Run("binary returns empty", func(t *testing.T) {
538 t.Parallel()
539 require.Empty(t, InstallMethodBinary.UpdateInstructions())
540 })
541}
542
543func TestDetectInstallMethod_GoInstall(t *testing.T) {
544 // Cannot use t.Parallel() with t.Setenv.
545
546 // Test that a path in GOPATH/bin is detected as go install.
547 tmpDir := t.TempDir()
548 gopathBin := filepath.Join(tmpDir, "go", "bin")
549 require.NoError(t, os.MkdirAll(gopathBin, 0o755))
550
551 exePath := filepath.Join(gopathBin, "crush")
552 t.Setenv("GOPATH", filepath.Join(tmpDir, "go"))
553
554 method := detectInstallMethod(exePath)
555 require.Equal(t, InstallMethodGoInstall, method)
556}
557
558func TestDetectInstallMethod_Nix(t *testing.T) {
559 t.Parallel()
560
561 // Test that a path in /nix/store is detected as Nix.
562 exePath := "/nix/store/abc123-crush-0.21.0/bin/crush"
563 method := detectInstallMethod(exePath)
564 require.Equal(t, InstallMethodNix, method)
565}
566
567func TestDetectInstallMethod_Unknown(t *testing.T) {
568 t.Parallel()
569
570 // Test that an unknown path returns Unknown.
571 exePath := "/some/random/path/crush"
572 method := detectInstallMethod(exePath)
573 require.Equal(t, InstallMethodUnknown, method)
574}
575
576func TestDetectInstallMethod_NPM(t *testing.T) {
577 t.Parallel()
578
579 tests := []string{
580 "/usr/local/lib/node_modules/@charmland/crush/bin/crush",
581 "/home/user/.npm-global/lib/node_modules/@charmland/crush/bin/crush",
582 "/Users/user/node_modules/.bin/crush",
583 }
584
585 for _, exePath := range tests {
586 t.Run(exePath, func(t *testing.T) {
587 t.Parallel()
588 method := detectInstallMethod(exePath)
589 require.Equal(t, InstallMethodNPM, method)
590 })
591 }
592}
593
594func TestDetectInstallMethod_Homebrew(t *testing.T) {
595 t.Parallel()
596
597 // Test paths that work across platforms (contain /Cellar/).
598 tests := []struct {
599 name string
600 exePath string
601 }{
602 {"Intel Mac Cellar", "/usr/local/Cellar/crush/0.21.0/bin/crush"},
603 {"Apple Silicon Cellar", "/opt/homebrew/Cellar/crush/0.21.0/bin/crush"},
604 {"Linux Homebrew Cellar", "/home/linuxbrew/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
605 {"Linux Homebrew user Cellar", "/home/user/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
606 }
607
608 for _, tt := range tests {
609 t.Run(tt.name, func(t *testing.T) {
610 t.Parallel()
611 method := detectInstallMethod(tt.exePath)
612 require.Equal(t, InstallMethodHomebrew, method)
613 })
614 }
615}
616
617func TestDetectInstallMethod_DefaultGoPath(t *testing.T) {
618 // Cannot use t.Parallel() with t.Setenv.
619
620 // Test detection with default GOPATH (~/go/bin) when GOPATH is not set.
621 tmpDir := t.TempDir()
622 home := filepath.Join(tmpDir, "home", "user")
623 gopathBin := filepath.Join(home, "go", "bin")
624 require.NoError(t, os.MkdirAll(gopathBin, 0o755))
625
626 // Unset GOPATH to test default detection.
627 t.Setenv("GOPATH", "")
628 t.Setenv("HOME", home)
629
630 exePath := filepath.Join(gopathBin, "crush")
631 method := detectInstallMethod(exePath)
632 require.Equal(t, InstallMethodGoInstall, method)
633}
634
635func TestIsValidSHA256(t *testing.T) {
636 t.Parallel()
637
638 tests := []struct {
639 name string
640 input string
641 want bool
642 }{
643 {"valid hash", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", true},
644 {"valid hash uppercase", "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", true},
645 {"too short", "abc123", false},
646 {"too long", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", false},
647 {"invalid hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", false},
648 {"empty", "", false},
649 }
650
651 for _, tt := range tests {
652 t.Run(tt.name, func(t *testing.T) {
653 t.Parallel()
654 require.Equal(t, tt.want, isValidSHA256(tt.input))
655 })
656 }
657}
658
659func TestParseDigest(t *testing.T) {
660 t.Parallel()
661
662 validHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
663
664 tests := []struct {
665 name string
666 input string
667 want string
668 }{
669 {"valid sha256 digest", "sha256:" + validHash, validHash},
670 {"missing prefix", validHash, ""},
671 {"wrong prefix", "sha1:" + validHash, ""},
672 {"invalid hash after prefix", "sha256:invalid", ""},
673 {"empty", "", ""},
674 }
675
676 for _, tt := range tests {
677 t.Run(tt.name, func(t *testing.T) {
678 t.Parallel()
679 require.Equal(t, tt.want, parseDigest(tt.input))
680 })
681 }
682}
683
684func TestCopyWithContext(t *testing.T) {
685 t.Parallel()
686
687 t.Run("successful copy", func(t *testing.T) {
688 t.Parallel()
689 src := strings.NewReader("hello world")
690 dst := &strings.Builder{}
691
692 n, err := copyWithContext(context.Background(), dst, src)
693 require.NoError(t, err)
694 require.Equal(t, int64(11), n)
695 require.Equal(t, "hello world", dst.String())
696 })
697
698 t.Run("cancelled context", func(t *testing.T) {
699 t.Parallel()
700 // Create a large source that will take multiple reads.
701 src := strings.NewReader(strings.Repeat("x", 100000))
702 dst := &strings.Builder{}
703
704 ctx, cancel := context.WithCancel(context.Background())
705 cancel() // Cancel immediately.
706
707 _, err := copyWithContext(ctx, dst, src)
708 require.ErrorIs(t, err, context.Canceled)
709 })
710
711 t.Run("empty source", func(t *testing.T) {
712 t.Parallel()
713 src := strings.NewReader("")
714 dst := &strings.Builder{}
715
716 n, err := copyWithContext(context.Background(), dst, src)
717 require.NoError(t, err)
718 require.Equal(t, int64(0), n)
719 })
720}