feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
//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()
|
||||
}
|
||||
Reference in New Issue
Block a user