//go:build linux || freebsd // +build linux freebsd package output import ( "os" "path/filepath" "strings" "sync" "testing" "time" ) // --- Helpers --- func tempDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "goget-output-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) return dir } func touchFile(t *testing.T, path string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatalf("failed to create file %s: %v", path, err) } f.Close() } // ============================================================================ // atomic.go tests // ============================================================================ func TestCreateTempFile(t *testing.T) { dir := tempDir(t) f, err := CreateTempFile(dir, "test-*.tmp") if err != nil { t.Fatalf("CreateTempFile failed: %v", err) } defer f.Close() defer os.Remove(f.Name()) if f == nil { t.Fatal("expected non-nil file") } if !strings.HasPrefix(filepath.Base(f.Name()), "test-") { t.Errorf("temp file name should start with 'test-', got %s", filepath.Base(f.Name())) } if !strings.HasSuffix(f.Name(), ".tmp") { t.Errorf("temp file name should end with '.tmp', got %s", filepath.Base(f.Name())) } if filepath.Dir(f.Name()) != dir { t.Errorf("temp file should be in dir %s, got %s", dir, filepath.Dir(f.Name())) } } func TestAtomicReplace(t *testing.T) { dir := tempDir(t) tempPath := filepath.Join(dir, "tempfile") finalPath := filepath.Join(dir, "finalfile") // Write content to temp file if err := os.WriteFile(tempPath, []byte("hello world"), 0644); err != nil { t.Fatalf("failed to write temp file: %v", err) } // Replace atomically if err := AtomicReplace(tempPath, finalPath); err != nil { t.Fatalf("AtomicReplace failed: %v", err) } // Check temp file no longer exists if _, err := os.Stat(tempPath); !os.IsNotExist(err) { t.Error("temp file should no longer exist after rename") } // Check final file exists with correct content data, err := os.ReadFile(finalPath) if err != nil { t.Fatalf("failed to read final file: %v", err) } if string(data) != "hello world" { t.Errorf("expected 'hello world', got '%s'", string(data)) } } func TestAtomicReplaceOverwrite(t *testing.T) { dir := tempDir(t) tempPath := filepath.Join(dir, "tempfile") finalPath := filepath.Join(dir, "finalfile") // Create final file with old content if err := os.WriteFile(finalPath, []byte("old content"), 0644); err != nil { t.Fatalf("failed to create final file: %v", err) } // Write new content to temp file if err := os.WriteFile(tempPath, []byte("new content"), 0644); err != nil { t.Fatalf("failed to write temp file: %v", err) } // Replace atomically if err := AtomicReplace(tempPath, finalPath); err != nil { t.Fatalf("AtomicReplace failed: %v", err) } data, err := os.ReadFile(finalPath) if err != nil { t.Fatalf("failed to read final file: %v", err) } if string(data) != "new content" { t.Errorf("expected 'new content', got '%s'", string(data)) } } func TestCleanupTempFile(t *testing.T) { dir := tempDir(t) // Create a temp file f, err := os.CreateTemp(dir, "cleanup-test-*") if err != nil { t.Fatalf("failed to create temp file: %v", err) } path := f.Name() f.Close() // Verify it exists if _, err := os.Stat(path); os.IsNotExist(err) { t.Fatal("temp file should exist before cleanup") } // Clean it up if err := CleanupTempFile(path); err != nil { t.Fatalf("CleanupTempFile failed: %v", err) } // Verify it's gone if _, err := os.Stat(path); !os.IsNotExist(err) { t.Error("temp file should not exist after cleanup") } } func TestCleanupTempFileNotExists(t *testing.T) { dir := tempDir(t) nonexistent := filepath.Join(dir, "nonexistent.tmp") err := CleanupTempFile(nonexistent) if err == nil { t.Error("expected error when removing nonexistent file") } if !os.IsNotExist(err) { t.Errorf("expected IsNotExist error, got %v", err) } } func TestGetTempDir(t *testing.T) { tests := []struct { name string path string expected string }{ { name: "normal file path", path: "/tmp/foo/bar.txt", expected: "/tmp/foo", }, { name: "just filename", path: "file.txt", expected: ".", }, { name: "root file", path: "/file.txt", expected: "/", }, { name: "nested directories", path: "/a/b/c/d/file.txt", expected: "/a/b/c/d", }, { name: "relative path", path: "some/relative/path/file.txt", expected: "some/relative/path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GetTempDir(tt.path) if got != tt.expected { t.Errorf("GetTempDir(%q) = %q, want %q", tt.path, got, tt.expected) } }) } } // ============================================================================ // resume.go tests // ============================================================================ func TestNewResumeMetadata(t *testing.T) { now := time.Now() rm := NewResumeMetadata("https://example.com/file", "abc123", "Mon, 02 Jan 2006 15:04:05 GMT", 1024, 2048) if rm == nil { t.Fatal("expected non-nil metadata") } if rm.URL != "https://example.com/file" { t.Errorf("URL = %q, want %q", rm.URL, "https://example.com/file") } if rm.ETag != "abc123" { t.Errorf("ETag = %q, want %q", rm.ETag, "abc123") } if rm.LastModified != "Mon, 02 Jan 2006 15:04:05 GMT" { t.Errorf("LastModified = %q, want %q", rm.LastModified, "Mon, 02 Jan 2006 15:04:05 GMT") } if rm.Downloaded != 1024 { t.Errorf("Downloaded = %d, want %d", rm.Downloaded, 1024) } if rm.Total != 2048 { t.Errorf("Total = %d, want %d", rm.Total, 2048) } if rm.Version != "1.0" { t.Errorf("Version = %q, want %q", rm.Version, "1.0") } if rm.LastWrite.Before(now) { t.Error("LastWrite should be after test start time") } if rm.LastWrite.After(time.Now()) { t.Error("LastWrite should not be in the future") } } func TestNewResumeMetadataEmptyValues(t *testing.T) { rm := NewResumeMetadata("https://example.com/file", "", "", 0, 0) if rm.ETag != "" { t.Errorf("ETag should be empty, got %q", rm.ETag) } if rm.LastModified != "" { t.Errorf("LastModified should be empty, got %q", rm.LastModified) } if rm.Downloaded != 0 { t.Errorf("Downloaded should be 0, got %d", rm.Downloaded) } if rm.Total != 0 { t.Errorf("Total should be 0, got %d", rm.Total) } } func TestSaveAndLoadResumeMetadata(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") metaPath := outputPath + ResumeMetaFileSuffix rm := NewResumeMetadata("https://example.com/file", "etag-value", "last-mod-value", 5000, 10000) // Save if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } // Check meta file exists if _, err := os.Stat(metaPath); os.IsNotExist(err) { t.Fatal("metadata file was not created") } // Load loaded, err := LoadResumeMetadata(outputPath) if err != nil { t.Fatalf("LoadResumeMetadata failed: %v", err) } if loaded == nil { t.Fatal("expected non-nil metadata") } // Verify fields if loaded.URL != rm.URL { t.Errorf("URL = %q, want %q", loaded.URL, rm.URL) } if loaded.ETag != rm.ETag { t.Errorf("ETag = %q, want %q", loaded.ETag, rm.ETag) } if loaded.LastModified != rm.LastModified { t.Errorf("LastModified = %q, want %q", loaded.LastModified, rm.LastModified) } if loaded.Downloaded != rm.Downloaded { t.Errorf("Downloaded = %d, want %d", loaded.Downloaded, rm.Downloaded) } if loaded.Total != rm.Total { t.Errorf("Total = %d, want %d", loaded.Total, rm.Total) } if loaded.Version != rm.Version { t.Errorf("Version = %q, want %q", loaded.Version, rm.Version) } } // TestSaveResumeMetadataHasSecurePermissions is a regression guard for the // BACKLOG entry "Resume metadata saved with 0644". The .goget.meta sidecar // stores the source URL (which may embed auth tokens in the query string) // along with ETag and Last-Modified — values that must not be world-readable // on multi-user systems. func TestSaveResumeMetadataHasSecurePermissions(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") metaPath := outputPath + ResumeMetaFileSuffix rm := NewResumeMetadata( "https://user:token@example.com/file?sig=secret", "etag-value", "last-mod-value", 100, 200, ) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save: %v", err) } info, err := os.Stat(metaPath) if err != nil { t.Fatalf("Stat: %v", err) } if mode := info.Mode().Perm(); mode != 0600 { t.Errorf("metadata file mode = %#o, want %#o", mode, 0600) } } func TestLoadResumeMetadataNoFile(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "nonexistent.dat") meta, err := LoadResumeMetadata(outputPath) if err != nil { t.Fatalf("LoadResumeMetadata should not error for missing file: %v", err) } if meta != nil { t.Error("expected nil metadata for nonexistent file") } } func TestLoadResumeMetadataCorrupt(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "corrupt.dat") metaPath := outputPath + ResumeMetaFileSuffix // Write invalid JSON if err := os.WriteFile(metaPath, []byte("{invalid json}"), 0644); err != nil { t.Fatalf("failed to write corrupt meta file: %v", err) } _, err := LoadResumeMetadata(outputPath) if err == nil { t.Fatal("expected error for corrupt metadata") } if !strings.Contains(err.Error(), "failed to parse metadata") { t.Errorf("expected parse error, got: %v", err) } } func TestDeleteResumeMetadata(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") metaPath := outputPath + ResumeMetaFileSuffix // Save metadata first rm := NewResumeMetadata("https://example.com/file", "", "", 100, 200) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } // Verify file exists if _, err := os.Stat(metaPath); os.IsNotExist(err) { t.Fatal("metadata file should exist before delete") } // Delete it if err := DeleteResumeMetadata(outputPath); err != nil { t.Fatalf("DeleteResumeMetadata failed: %v", err) } // Verify file is gone if _, err := os.Stat(metaPath); !os.IsNotExist(err) { t.Error("metadata file should not exist after delete") } } func TestDeleteResumeMetadataNoFile(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "nonexistent.dat") err := DeleteResumeMetadata(outputPath) if err != nil { t.Fatalf("DeleteResumeMetadata should not error for missing file: %v", err) } } func TestCanResumeValid(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file with some content data := make([]byte, 5000) for i := range data { data[i] = byte(i % 256) } if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } // Save metadata rm := NewResumeMetadata("https://example.com/file", "", "", 5000, 10000) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } // Check resume canResume, meta, err := CanResume(outputPath, "https://example.com/file") if err != nil { t.Fatalf("CanResume failed: %v", err) } if !canResume { t.Fatal("expected canResume = true") } if meta == nil { t.Fatal("expected non-nil metadata") } if meta.Downloaded != 5000 { t.Errorf("Downloaded = %d, want %d", meta.Downloaded, 5000) } } func TestCanResumeNoFile(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "nonexistent.dat") canResume, meta, err := CanResume(outputPath, "https://example.com/file") if err != nil { t.Fatalf("CanResume should not error for missing file: %v", err) } if canResume { t.Fatal("expected canResume = false for missing file") } if meta != nil { t.Fatal("expected nil metadata for missing file") } } func TestCanResumeNoMetadata(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file but no metadata data := make([]byte, 5000) if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } canResume, meta, err := CanResume(outputPath, "https://example.com/file") if err != nil { t.Fatalf("CanResume failed: %v", err) } if canResume { t.Fatal("expected canResume = false when no metadata") } if meta != nil { t.Fatal("expected nil metadata when no metadata file") } } func TestCanResumeURLMismatch(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file data := make([]byte, 5000) if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } // Save metadata with different URL rm := NewResumeMetadata("https://other.com/file", "", "", 5000, 10000) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } canResume, meta, err := CanResume(outputPath, "https://example.com/file") if err == nil { t.Fatal("expected error for URL mismatch") } if !strings.Contains(err.Error(), "url mismatch") { t.Errorf("expected url mismatch error, got: %v", err) } if canResume { t.Fatal("expected canResume = false on url mismatch") } if meta != nil { t.Fatal("expected nil metadata on error") } } func TestCanResumeSizeMismatch(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file with 5000 bytes data := make([]byte, 5000) if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } // Save metadata saying 3000 bytes (mismatch!) rm := NewResumeMetadata("https://example.com/file", "", "", 3000, 10000) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } canResume, meta, err := CanResume(outputPath, "https://example.com/file") if err == nil { t.Fatal("expected error for size mismatch") } if !strings.Contains(err.Error(), "size mismatch") { t.Errorf("expected size mismatch error, got: %v", err) } if canResume { t.Fatal("expected canResume = false on size mismatch") } if meta != nil { t.Fatal("expected nil metadata on error") } } func TestGetResumeInfo(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file with known size data := make([]byte, 5000) if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } rm := NewResumeMetadata("https://example.com/file", "etag-xyz", "last-mod-value", 5000, 10000) info, err := GetResumeInfo(rm, outputPath) if err != nil { t.Fatalf("GetResumeInfo failed: %v", err) } if info == nil { t.Fatal("expected non-nil ResumeInfo") } if info.DownloadedBytes != 5000 { t.Errorf("DownloadedBytes = %d, want %d", info.DownloadedBytes, 5000) } if info.ETag != "etag-xyz" { t.Errorf("ETag = %q, want %q", info.ETag, "etag-xyz") } if info.LastModified != "last-mod-value" { t.Errorf("LastModified = %q, want %q", info.LastModified, "last-mod-value") } if info.URL != "https://example.com/file" { t.Errorf("URL = %q, want %q", info.URL, "https://example.com/file") } if info.LastWrite.IsZero() { t.Error("LastWrite should not be zero") } } func TestGetResumeInfoNonexistentFile(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "nonexistent.dat") rm := NewResumeMetadata("https://example.com/file", "", "", 0, 0) _, err := GetResumeInfo(rm, outputPath) if err == nil { t.Fatal("expected error for nonexistent file") } } func TestFileExists(t *testing.T) { dir := tempDir(t) // File that exists existingFile := filepath.Join(dir, "exists.txt") touchFile(t, existingFile) if !FileExists(existingFile) { t.Error("FileExists should return true for existing file") } // File that doesn't exist if FileExists(filepath.Join(dir, "nonexistent.txt")) { t.Error("FileExists should return false for nonexistent file") } // Empty path if FileExists("") { t.Error("FileExists should return false for empty path") } } func TestGetFileSize(t *testing.T) { dir := tempDir(t) // File with known size filePath := filepath.Join(dir, "sized.txt") content := []byte("hello world, this is a test with 42 characters!!") if err := os.WriteFile(filePath, content, 0644); err != nil { t.Fatalf("failed to create file: %v", err) } size, err := GetFileSize(filePath) if err != nil { t.Fatalf("GetFileSize failed: %v", err) } if size != int64(len(content)) { t.Errorf("size = %d, want %d", size, len(content)) } // Empty file emptyPath := filepath.Join(dir, "empty.txt") touchFile(t, emptyPath) size, err = GetFileSize(emptyPath) if err != nil { t.Fatalf("GetFileSize failed for empty file: %v", err) } if size != 0 { t.Errorf("size for empty file = %d, want 0", size) } } func TestGetFileSizeNonexistent(t *testing.T) { dir := tempDir(t) _, err := GetFileSize(filepath.Join(dir, "nonexistent.txt")) if err == nil { t.Fatal("expected error for nonexistent file") } } func TestCleanupResumeFiles(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") metaPath := outputPath + ResumeMetaFileSuffix // Create output file data := make([]byte, 1000) if err := os.WriteFile(outputPath, data, 0644); err != nil { t.Fatalf("failed to create output file: %v", err) } // Save metadata rm := NewResumeMetadata("https://example.com/file", "", "", 1000, 2000) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } // Clean up both if err := CleanupResumeFiles(outputPath); err != nil { t.Fatalf("CleanupResumeFiles failed: %v", err) } // Verify both are gone if _, err := os.Stat(outputPath); !os.IsNotExist(err) { t.Error("output file should not exist after cleanup") } if _, err := os.Stat(metaPath); !os.IsNotExist(err) { t.Error("metadata file should not exist after cleanup") } } func TestCleanupResumeFilesNoOutput(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "nonexistent.dat") // Should not error even if output file doesn't exist err := CleanupResumeFiles(outputPath) if err != nil { t.Fatalf("CleanupResumeFiles should not error for missing output: %v", err) } } func TestCleanupResumeFilesOnlyOutput(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "download.dat") // Create output file only, no metadata touchFile(t, outputPath) if err := CleanupResumeFiles(outputPath); err != nil { t.Fatalf("CleanupResumeFiles failed: %v", err) } // Output file should be gone if _, err := os.Stat(outputPath); !os.IsNotExist(err) { t.Error("output file should not exist after cleanup") } // Metadata file should never have existed, but cleanup should be idempotent metaPath := outputPath + ResumeMetaFileSuffix if _, err := os.Stat(metaPath); !os.IsNotExist(err) { t.Error("metadata file should not exist") } } // ============================================================================ // writer.go tests // ============================================================================ func TestNewWriterDefault(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false // disable atomic for simpler test w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() if w == nil { t.Fatal("expected non-nil writer") } if w.config == nil { t.Fatal("config should not be nil") } if w.config.BufferSize != 32*1024 { t.Errorf("BufferSize = %d, want %d", w.config.BufferSize, 32*1024) } } func TestNewWriterNilConfig(t *testing.T) { dir := tempDir(t) origDir := dir // When cfg is nil, output is empty, so it goes to stdout w, err := NewWriter(nil) if err != nil { t.Fatalf("NewWriter(nil) failed: %v", err) } defer w.Close() if w == nil { t.Fatal("expected non-nil writer") } if w.config.BufferSize != 32*1024 { t.Errorf("BufferSize = %d, want %d", w.config.BufferSize, 32*1024) } if w.file != os.Stdout { t.Error("expected stdout file when cfg is nil") } // Cleanup os.RemoveAll(origDir) } func TestNewWriterStdout(t *testing.T) { cfg := DefaultWriterConfig() cfg.Output = "-" w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() if w.file != os.Stdout { t.Error("expected writer to use stdout") } if w.tempFile != nil { t.Error("expected no temp file for stdout output") } } func TestNewWriterFileExistsError(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "existing.dat") // Create file first touchFile(t, outputPath) cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false cfg.Resume = false _, err := NewWriter(cfg) if err == nil { t.Fatal("expected error when file already exists and resume is disabled") } if !strings.Contains(err.Error(), "file already exists") { t.Errorf("expected 'file already exists' error, got: %v", err) } } func TestWriterWrite(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() data := []byte("hello world") n, err := w.Write(data) if err != nil { t.Fatalf("Write failed: %v", err) } if n != len(data) { t.Errorf("written bytes = %d, want %d", n, len(data)) } if w.Written() != int64(len(data)) { t.Errorf("Written() = %d, want %d", w.Written(), len(data)) } } func TestWriterWriteMultiple(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() totalWritten := 0 chunks := []string{"hello ", "world ", "this ", "is ", "a ", "test"} for _, chunk := range chunks { n, err := w.Write([]byte(chunk)) if err != nil { t.Fatalf("Write(%q) failed: %v", chunk, err) } totalWritten += n } expected := "hello world this is a test" if w.Written() != int64(len(expected)) { t.Errorf("Written() = %d, want %d", w.Written(), len(expected)) } // Verify file content data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("failed to read output file: %v", err) } if string(data) != expected { t.Errorf("file content = %q, want %q", string(data), expected) } } func TestWriterProgressCallback(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") var mu sync.Mutex var calls []struct { current int64 total int64 speed float64 } cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false cfg.ProgressCallback = func(current, total int64, speed float64) { mu.Lock() defer mu.Unlock() calls = append(calls, struct { current int64 total int64 speed float64 }{current, total, speed}) } w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() // Write some data w.Write([]byte("first chunk ")) w.Write([]byte("second chunk")) w.Close() mu.Lock() callCount := len(calls) mu.Unlock() if callCount != 2 { t.Errorf("expected 2 callback calls, got %d", callCount) } } func TestWriterProgressCallbackValues(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") var lastCurrent, lastTotal int64 cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false cfg.ProgressCallback = func(current, total int64, speed float64) { lastCurrent = current lastTotal = total } w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() w.SetTotal(100) w.Write([]byte("hello")) // 5 bytes if lastCurrent != 5 { t.Errorf("callback current = %d, want %d", lastCurrent, 5) } if lastTotal != 100 { t.Errorf("callback total = %d, want %d", lastTotal, 100) } w.Write([]byte(" world")) // 6 bytes if lastCurrent != 11 { t.Errorf("callback current = %d, want %d", lastCurrent, 11) } } func TestWriterSetTotal(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() if w.Total() != 0 { t.Errorf("initial Total() = %d, want 0", w.Total()) } w.SetTotal(100500) if w.Total() != 100500 { t.Errorf("Total() = %d, want %d", w.Total(), 100500) } } func TestWriterClose(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } data := []byte("test data") w.Write(data) if err := w.Close(); err != nil { t.Fatalf("Close failed: %v", err) } // Verify file content content, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("failed to read output: %v", err) } if string(content) != string(data) { t.Errorf("content = %q, want %q", string(content), string(data)) } } func TestWriterCancel(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } w.Write([]byte("test data")) tempPath := w.tempFile.Name() // Cancel the write w.Cancel() // The temp file should be removed if _, err := os.Stat(tempPath); !os.IsNotExist(err) { t.Error("temp file should be removed after cancel") } // The final file should NOT exist (temp was cancelled before rename) if _, err := os.Stat(outputPath); !os.IsNotExist(err) { t.Error("output file should not exist after cancel") } } func TestWriterCancelNoTempFile(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } w.Write([]byte("test data")) // Cancel should not panic even without temp file w.Cancel() // File should still exist since atomic is false and close wasn't called to // delete it... actually, with atomic=false and cancel, nothing removes files, // but the context is cancelled. if _, err := os.Stat(outputPath); os.IsNotExist(err) { t.Error("output file should still exist after cancel when no temp file") } os.Remove(outputPath) } func TestWriterGetSpeed(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() // Speed should be 0 initially speed := w.GetSpeed() if speed != 0 { t.Errorf("initial speed = %f, want 0", speed) } // Write some data w.Write([]byte("test data")) // Speed should be > 0 after writing speed = w.GetSpeed() if speed <= 0 { t.Errorf("speed after write = %f, want > 0", speed) } } func TestWriterGetProgress(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() // Progress should be 0 when total is 0 if p := w.GetProgress(); p != 0 { t.Errorf("GetProgress() with no total = %f, want 0", p) } w.SetTotal(100) // Progress should be 0 before any writes if p := w.GetProgress(); p != 0 { t.Errorf("GetProgress() before write = %f, want 0", p) } w.Write([]byte("hello")) // 5 bytes -> 5% p := w.GetProgress() if p < 4.9 || p > 5.1 { t.Errorf("GetProgress() = %f, want ~5.0", p) } w.Write([]byte("hello")) // 10 bytes -> 10% p = w.GetProgress() if p < 9.9 || p > 10.1 { t.Errorf("GetProgress() = %f, want ~10.0", p) } // Write the remaining 90 bytes buf := make([]byte, 90) w.Write(buf) p = w.GetProgress() if p < 99.9 || p > 100.1 { t.Errorf("GetProgress() after full write = %f, want ~100.0", p) } } func TestWriterAtomicWrite(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } // Before close, output file should NOT exist (data is in temp) if _, err := os.Stat(outputPath); !os.IsNotExist(err) { t.Error("output file should not exist before Close with atomic write") } // Temp file should exist if w.tempFile == nil { t.Fatal("expected temp file for atomic write") } tempPath := w.tempFile.Name() if _, err := os.Stat(tempPath); os.IsNotExist(err) { t.Error("temp file should exist before Close") } // Write data w.Write([]byte("atomic data")) // Close triggers rename if err := w.Close(); err != nil { t.Fatalf("Close failed: %v", err) } // Temp file should be gone if _, err := os.Stat(tempPath); !os.IsNotExist(err) { t.Error("temp file should not exist after atomic rename") } // Output file should now exist with correct content data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("failed to read output file: %v", err) } if string(data) != "atomic data" { t.Errorf("content = %q, want %q", string(data), "atomic data") } } // TestWriterAtomicTempFileHasSecurePermissions is a regression guard for the // BACKLOG entry "Resume metadata saved with 0644" (second clause). While // the atomic download is in progress the temp file holds the entire payload, // which can include private data. The temp file must be owner-only until // it is renamed into place. func TestWriterAtomicTempFileHasSecurePermissions(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter: %v", err) } t.Cleanup(func() { w.Cancel() }) tempPath := w.tempFile.Name() info, err := os.Stat(tempPath) if err != nil { t.Fatalf("Stat(temp): %v", err) } if mode := info.Mode().Perm(); mode != 0600 { t.Errorf("temp file mode = %#o, want %#o", mode, 0600) } } func TestWriterAtomicWriteWithStdout(t *testing.T) { // When output is stdout, atomic mode should not create temp files cfg := DefaultWriterConfig() cfg.Output = "-" cfg.Atomic = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() if w.tempFile != nil { t.Error("no temp file should be created for stdout output") } if w.file != os.Stdout { t.Error("should write to stdout") } } func TestWriterResume(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "resume.dat") // Create initial partial download (50 bytes) initialData := make([]byte, 50) for i := range initialData { initialData[i] = byte(i) } if err := os.WriteFile(outputPath, initialData, 0644); err != nil { t.Fatalf("failed to create initial file: %v", err) } // Create metadata rm := NewResumeMetadata("https://example.com/file", "", "", 50, 100) if err := rm.Save(outputPath); err != nil { t.Fatalf("Save failed: %v", err) } // Open writer in resume mode cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false cfg.Resume = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } // Written should start at 50 (from existing file) if w.Written() != 50 { t.Errorf("Written() after resume open = %d, want 50", w.Written()) } // Write more data moreData := make([]byte, 50) for i := range moreData { moreData[i] = byte(i + 50) } n, err := w.Write(moreData) if err != nil { t.Fatalf("Write failed: %v", err) } if n != 50 { t.Errorf("written bytes = %d, want 50", n) } w.Close() // Verify total file is 100 bytes fileInfo, err := os.Stat(outputPath) if err != nil { t.Fatalf("failed to stat output: %v", err) } if fileInfo.Size() != 100 { t.Errorf("final file size = %d, want 100", fileInfo.Size()) } // Verify file content data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("failed to read output: %v", err) } if len(data) != 100 { t.Fatalf("data length = %d, want 100", len(data)) } for i := 0; i < 100; i++ { if data[i] != byte(i) { t.Errorf("data[%d] = %d, want %d", i, data[i], byte(i)) break } } } func TestWriterSetProgressCallback(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() // No callback initially w.Write([]byte("data")) // Set callback var called bool w.SetProgressCallback(func(current, total int64, speed float64) { called = true }) w.Write([]byte(" more data")) if !called { t.Error("expected callback to be called after SetProgressCallback") } } func TestWriterGetDuration(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() duration := w.GetDuration() if duration < 0 { t.Errorf("duration should be >= 0, got %v", duration) } // A tiny sleep to make duration non-zero time.Sleep(time.Millisecond) duration = w.GetDuration() if duration <= 0 { t.Errorf("duration should be > 0 after sleep, got %v", duration) } } func TestWriterConcurrentSafety(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "concurrent.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() w.Write([]byte("x")) w.Written() w.Total() w.GetSpeed() w.GetProgress() }() } wg.Wait() if w.Written() != 10 { t.Errorf("Written() = %d, want 10", w.Written()) } } func TestWriterAtomicThenCancel(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "atomic-cancel.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } tempPath := w.tempFile.Name() w.Write([]byte("data to cancel")) // Cancel should remove temp file w.Cancel() if _, err := os.Stat(tempPath); !os.IsNotExist(err) { t.Error("temp file should be removed after cancel") } if _, err := os.Stat(outputPath); !os.IsNotExist(err) { t.Error("output file should not exist after cancel") } } func TestWriterContextCancellation(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "context-cancel.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = true w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } defer w.Close() if w.ctx == nil { t.Fatal("expected non-nil context") } select { case <-w.ctx.Done(): t.Fatal("context should not be done initially") default: } w.Cancel() select { case <-w.ctx.Done(): // expected default: t.Fatal("context should be done after cancel") } } func TestWriterDoubleClose(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "double-close.dat") cfg := DefaultWriterConfig() cfg.Output = outputPath cfg.Atomic = false w, err := NewWriter(cfg) if err != nil { t.Fatalf("NewWriter failed: %v", err) } w.Write([]byte("data")) if err := w.Close(); err != nil { t.Fatalf("first Close failed: %v", err) } // Second close should not panic if err := w.Close(); err != nil { t.Logf("second Close returned: %v (acceptable)", err) } }