Files
goget/internal/output/output_test.go
T

1501 lines
36 KiB
Go
Raw Normal View History

//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)
}
}