update_test.go

  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}