//go:build linux || freebsd // +build linux freebsd package output import ( "os" "path/filepath" "strings" "testing" ) // TestCloseAtomicSuccess verifies the happy path: temp file is renamed to // the final destination and no temp file remains. func TestCloseAtomicSuccess(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.txt") w, err := NewWriter(&WriterConfig{ Output: outputPath, Atomic: true, }) if err != nil { t.Fatalf("NewWriter: %v", err) } payload := []byte("hello world") if _, err := w.Write(payload); err != nil { t.Fatalf("Write: %v", err) } if err := w.Close(); err != nil { t.Fatalf("Close: %v", err) } got, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(got) != string(payload) { t.Errorf("payload = %q, want %q", got, payload) } // No leftover temp files in the directory. entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } for _, e := range entries { if strings.Contains(e.Name(), ".tmp.") { t.Errorf("temp file %q should have been renamed away", e.Name()) } } } // TestCloseKeepsTempOnRenameError is a regression guard for the BACKLOG // entry "Atomic write data loss on cross-filesystem rename". The previous // implementation called os.Remove(tempPath) on any rename error, deleting // the only copy of the downloaded data. After the fix, the temp file must // remain on disk so the user can recover the partial download manually. // // We force a rename error by pre-creating the destination as a directory — // os.Rename will refuse to overwrite a non-empty directory with a file. func TestCloseKeepsTempOnRenameError(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "output.txt") // Block the rename by making the target path a directory. if err := os.Mkdir(outputPath, 0755); err != nil { t.Fatalf("mkdir: %v", err) } w, err := NewWriter(&WriterConfig{ Output: outputPath, Atomic: true, }) if err != nil { t.Fatalf("NewWriter: %v", err) } payload := []byte("recoverable download data") if _, err := w.Write(payload); err != nil { t.Fatalf("Write: %v", err) } tempPath := w.tempFile.Name() if err := w.Close(); err == nil { t.Fatal("expected Close() to return an error when target is a directory") } // The temp file MUST still exist — the data must be recoverable. if _, err := os.Stat(tempPath); err != nil { t.Errorf("temp file %q should still exist after Close error, got: %v", tempPath, err) } // And it should still hold the full payload. got, err := os.ReadFile(tempPath) if err != nil { t.Fatalf("ReadFile(temp): %v", err) } if string(got) != string(payload) { t.Errorf("temp file payload = %q, want %q", got, payload) } } // TestCopyFileAndRemove covers the EXDEV fallback helper directly. We do // not have an easy way to mount two different filesystems in a unit test, // so we just verify the copy + remove behaviour on the same filesystem. func TestCopyFileAndRemove(t *testing.T) { dir := tempDir(t) src := filepath.Join(dir, "src.tmp") dst := filepath.Join(dir, "dst.bin") payload := []byte("cross-fs fallback payload") if err := os.WriteFile(src, payload, 0644); err != nil { t.Fatalf("WriteFile(src): %v", err) } if err := copyFileAndRemove(src, dst); err != nil { t.Fatalf("copyFileAndRemove: %v", err) } got, err := os.ReadFile(dst) if err != nil { t.Fatalf("ReadFile(dst): %v", err) } if string(got) != string(payload) { t.Errorf("dst = %q, want %q", got, payload) } if _, err := os.Stat(src); !os.IsNotExist(err) { t.Errorf("src should be removed, got stat err = %v", err) } } // TestCopyFileAndRemoveOverwritesExisting verifies the fallback truncates // any existing file at the destination (matching os.Rename semantics). func TestCopyFileAndRemoveOverwritesExisting(t *testing.T) { dir := tempDir(t) src := filepath.Join(dir, "src.tmp") dst := filepath.Join(dir, "dst.bin") if err := os.WriteFile(src, []byte("new"), 0644); err != nil { t.Fatalf("WriteFile(src): %v", err) } if err := os.WriteFile(dst, []byte("OLD CONTENT"), 0644); err != nil { t.Fatalf("WriteFile(dst): %v", err) } if err := copyFileAndRemove(src, dst); err != nil { t.Fatalf("copyFileAndRemove: %v", err) } got, err := os.ReadFile(dst) if err != nil { t.Fatalf("ReadFile(dst): %v", err) } if string(got) != "new" { t.Errorf("dst = %q, want %q", got, "new") } } // TestNewWriterCreateDirsMissingParent verifies the curl --create-dirs // equivalent: when CreateDirs is true and the parent directory of Output // does not exist, NewWriter must create the full chain via MkdirAll and // successfully write the file. This is the happy path for "goget // --output ./data/archives/file.zip" against a fresh tree. func TestNewWriterCreateDirsMissingParent(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "deeply", "nested", "subdir", "file.dat") w, err := NewWriter(&WriterConfig{ Output: outputPath, Atomic: true, CreateDirs: true, }) if err != nil { t.Fatalf("NewWriter: %v", err) } payload := []byte("created in a fresh dir tree") if _, err := w.Write(payload); err != nil { t.Fatalf("Write: %v", err) } if err := w.Close(); err != nil { t.Fatalf("Close: %v", err) } got, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(got) != string(payload) { t.Errorf("payload = %q, want %q", got, payload) } } // TestNewWriterCreateDirsDisabledLeavesError confirms that without // CreateDirs the writer still surfaces the underlying "parent dir missing" // error — CreateDirs is opt-in, not a silent behaviour change for existing // users. func TestNewWriterCreateDirsDisabledLeavesError(t *testing.T) { dir := tempDir(t) outputPath := filepath.Join(dir, "missing", "file.dat") _, err := NewWriter(&WriterConfig{ Output: outputPath, Atomic: true, CreateDirs: false, }) if err == nil { t.Fatal("expected NewWriter to fail when parent dir is missing and CreateDirs=false") } } // TestNewWriterCreateDirsIgnoredForStdout makes sure the - / stdout path is // untouched by the new flag: nothing to create, no spurious error. func TestNewWriterCreateDirsIgnoredForStdout(t *testing.T) { w, err := NewWriter(&WriterConfig{ Output: "-", Atomic: false, CreateDirs: true, }) if err != nil { t.Fatalf("NewWriter: %v", err) } if w.file != os.Stdout { t.Errorf("expected writer to point at stdout, got %v", w.file) } w.Close() }