233 lines
6.4 KiB
Go
233 lines
6.4 KiB
Go
//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()
|
|
}
|