package fileops import ( "errors" "io" "os" "path/filepath" "strings" "testing" ) var errAtomicReplacementTest = errors.New("atomic replacement test failure") func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) { root := t.TempDir() dst := filepath.Join(root, "out", "value.txt") if err := WriteFileAtomic(dst, []byte("one"), 0o644); err != nil { t.Fatalf("WriteFileAtomic(first) error = %v", err) } if err := WriteFileAtomic(dst, []byte("two"), 0o644); err != nil { t.Fatalf("WriteFileAtomic(second) error = %v", err) } data, err := os.ReadFile(dst) if err != nil { t.Fatalf("ReadFile() error = %v", err) } if string(data) != "two" { t.Fatalf("file content = %q, want %q", string(data), "two") } assertNoMatchingTempFiles(t, filepath.Dir(dst), "."+filepath.Base(dst)+".tmp-") } func TestWriteFileAtomicCleansTempFileOnInstallFailure(t *testing.T) { root := t.TempDir() blockedPath := filepath.Join(root, "blocked") if err := os.MkdirAll(blockedPath, 0o755); err != nil { t.Fatalf("MkdirAll(blockedPath) error = %v", err) } err := WriteFileAtomic(blockedPath, []byte("data"), 0o644) if err == nil { t.Fatal("WriteFileAtomic() error = nil, want install failure") } assertNoMatchingTempFiles(t, root, ".blocked.tmp-") } func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "source.txt") dst := filepath.Join(root, "out", "copied.txt") if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil { t.Fatalf("WriteFile(source) error = %v", err) } checksum, err := CopyFileAtomicWithChecksum(src, dst, 0o644) if err != nil { t.Fatalf("CopyFileAtomicWithChecksum() error = %v", err) } wantChecksum := "6e5c3f239e28cc315d57b2fcfc24169369c44a25802c0616a6d7081707fd24df" if checksum != wantChecksum { t.Fatalf("checksum = %q, want %q", checksum, wantChecksum) } data, err := os.ReadFile(dst) if err != nil { t.Fatalf("ReadFile(destination) error = %v", err) } if string(data) != "copied-data" { t.Fatalf("destination content = %q, want %q", string(data), "copied-data") } assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-") } func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "source.txt") if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil { t.Fatalf("WriteFile(source) error = %v", err) } blockedPath := filepath.Join(root, "blocked") if err := os.MkdirAll(blockedPath, 0o755); err != nil { t.Fatalf("MkdirAll(blockedPath) error = %v", err) } err := CopyFileAtomic(src, blockedPath, 0o644) if err == nil { t.Fatal("CopyFileAtomic() error = nil, want install failure") } assertNoMatchingTempFiles(t, root, ".blocked.tmp-") } func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) { root := t.TempDir() tmpPath := filepath.Join(root, ".payload.tmp") dst := filepath.Join(root, "out", "payload.json") if err := os.WriteFile(tmpPath, []byte("{\"ok\":true}\n"), 0o600); err != nil { t.Fatalf("WriteFile(temp) error = %v", err) } if err := InstallDownloadedTempFile(tmpPath, dst, 0o644); err != nil { t.Fatalf("InstallDownloadedTempFile() error = %v", err) } if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { t.Fatalf("temp file still exists: stat err = %v", err) } info, err := os.Stat(dst) if err != nil { t.Fatalf("Stat(destination) error = %v", err) } if info.Mode().Perm() != 0o644 { t.Fatalf("destination mode = %o, want 644", info.Mode().Perm()) } } func TestWriteFileAtomicRejectsSymlinkedDestinationAncestor(t *testing.T) { root := t.TempDir() outside := t.TempDir() sentinel := filepath.Join(outside, "sentinel.txt") if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil { t.Fatalf("WriteFile(sentinel) error = %v", err) } if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil { t.Skipf("Symlink unavailable: %v", err) } err := WriteFileAtomic(filepath.Join(root, "redirect", "output.txt"), []byte("new"), 0o640) if err == nil { t.Fatal("WriteFileAtomic() error = nil, want symlink ancestor rejection") } data, err := os.ReadFile(sentinel) if err != nil { t.Fatalf("ReadFile(sentinel) error = %v", err) } if string(data) != "unchanged" { t.Fatalf("outside sentinel = %q, want unchanged", data) } if _, err := os.Stat(filepath.Join(outside, "output.txt")); !os.IsNotExist(err) { t.Fatalf("outside output exists: stat err = %v", err) } } func TestWriteFileAtomicReplacesLeafSymlinkWithoutFollowingIt(t *testing.T) { root := t.TempDir() outside := filepath.Join(t.TempDir(), "outside.txt") if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil { t.Fatalf("WriteFile(outside) error = %v", err) } destination := filepath.Join(root, "output.txt") if err := os.Symlink(outside, destination); err != nil { t.Skipf("Symlink unavailable: %v", err) } if err := WriteFileAtomic(destination, []byte("inside"), 0o640); err != nil { t.Fatalf("WriteFileAtomic() error = %v", err) } outsideData, err := os.ReadFile(outside) if err != nil { t.Fatalf("ReadFile(outside) error = %v", err) } if string(outsideData) != "outside" { t.Fatalf("outside file = %q, want unchanged", outsideData) } info, err := os.Lstat(destination) if err != nil || info.Mode()&os.ModeSymlink != 0 { t.Fatalf("destination was not replaced with a regular file: info=%v err=%v", info, err) } } func TestDownloadAndInstallRejectsSymlinkedDestinationAncestor(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil { t.Skipf("Symlink unavailable: %v", err) } err := DownloadAndInstall(filepath.Join(root, "redirect", "output.txt"), 0o640, func(io.Writer) error { return nil }) if err == nil { t.Fatal("DownloadAndInstall() error = nil, want symlink ancestor rejection") } entries, err := os.ReadDir(outside) if err != nil { t.Fatalf("ReadDir(outside) error = %v", err) } if len(entries) != 0 { t.Fatalf("outside destination received entries: %v", entries) } } func TestReplaceFileAtomicOrdersDurableOperations(t *testing.T) { events := make([]string, 0, 8) tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events} ops := recordingAtomicReplacementOperations(&events, tmp) err := replaceFileAtomicWithOperations( "/work/result", []byte("replacement"), ReplaceFileOptions{ Mode: 0o640, BeforeRename: func() error { events = append(events, "check") return nil }, }, ops, ) if err != nil { t.Fatalf("replaceFileAtomicWithOperations() error = %v", err) } want := []string{"create", "write", "chmod:rw-r-----", "sync", "close", "check", "rename", "sync-directory"} if !equalStrings(events, want) { t.Fatalf("operation order = %v, want %v", events, want) } } func TestReplaceFileAtomicFailuresCleanUninstalledTemporaryFile(t *testing.T) { tests := []struct { name string configure func(*recordingTemporaryFile, *atomicReplacementOperations) wantErr error wantRemove bool }{ { name: "short write", configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) { tmp.shortWrite = true }, wantErr: io.ErrShortWrite, wantRemove: true, }, { name: "write", configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) { tmp.writeErr = errAtomicReplacementTest }, wantErr: errAtomicReplacementTest, wantRemove: true, }, { name: "sync", configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) { tmp.syncErr = errAtomicReplacementTest }, wantErr: errAtomicReplacementTest, wantRemove: true, }, { name: "rename", configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) { ops.rename = func(string, string) error { return errAtomicReplacementTest } }, wantErr: errAtomicReplacementTest, wantRemove: true, }, { name: "directory sync", configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) { ops.syncDirectory = func(string) error { return ErrDirectorySyncUnsupported } }, wantErr: ErrDirectorySyncUnsupported, wantRemove: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { events := make([]string, 0, 8) tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events} ops := recordingAtomicReplacementOperations(&events, tmp) test.configure(tmp, &ops) err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops) if !errors.Is(err, test.wantErr) { t.Fatalf("replaceFileAtomicWithOperations() error = %v, want %v", err, test.wantErr) } gotRemove := containsString(events, "remove") if gotRemove != test.wantRemove { t.Fatalf("remove temporary file = %t, want %t; operations = %v", gotRemove, test.wantRemove, events) } }) } } func TestReplaceFileAtomicReportsCleanupFailureWithPrimaryFailure(t *testing.T) { events := make([]string, 0, 5) tmp := &recordingTemporaryFile{ name: "/work/.result.tmp-1", events: &events, writeErr: errAtomicReplacementTest, } ops := recordingAtomicReplacementOperations(&events, tmp) cleanupErr := errors.New("temporary cleanup failed") ops.remove = func(string) error { events = append(events, "remove") return cleanupErr } err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops) if !errors.Is(err, errAtomicReplacementTest) { t.Fatalf("replacement error = %v, want write failure", err) } if !errors.Is(err, cleanupErr) { t.Fatalf("replacement error = %v, want cleanup failure", err) } } func TestInstallDownloadedTempFileOrdersDurableOperations(t *testing.T) { events := make([]string, 0, 4) ops := atomicReplacementOperations{ chmod: func(string, os.FileMode) error { events = append(events, "chmod") return nil }, syncFile: func(string) error { events = append(events, "sync-file") return nil }, rename: func(string, string) error { events = append(events, "rename") return nil }, syncDirectory: func(string) error { events = append(events, "sync-directory") return nil }, } if err := installDownloadedTempFileWithOperations("/work/.download.tmp", "/work/result", 0o640, ops); err != nil { t.Fatalf("installDownloadedTempFileWithOperations() error = %v", err) } want := []string{"chmod", "sync-file", "rename", "sync-directory"} if !equalStrings(events, want) { t.Fatalf("operation order = %v, want %v", events, want) } } type recordingTemporaryFile struct { name string events *[]string shortWrite bool writeErr error syncErr error } func (f *recordingTemporaryFile) Write(data []byte) (int, error) { *f.events = append(*f.events, "write") if f.writeErr != nil { return 0, f.writeErr } if f.shortWrite { return len(data) - 1, nil } return len(data), nil } func (f *recordingTemporaryFile) Name() string { return f.name } func (f *recordingTemporaryFile) Sync() error { *f.events = append(*f.events, "sync") return f.syncErr } func (f *recordingTemporaryFile) Close() error { *f.events = append(*f.events, "close") return nil } func recordingAtomicReplacementOperations(events *[]string, tmp temporaryFile) atomicReplacementOperations { return atomicReplacementOperations{ createTemp: func(string, string) (temporaryFile, error) { *events = append(*events, "create") return tmp, nil }, chmod: func(_ string, mode os.FileMode) error { *events = append(*events, "chmod:"+mode.Perm().String()[1:]) return nil }, rename: func(string, string) error { *events = append(*events, "rename") return nil }, remove: func(string) error { *events = append(*events, "remove") return nil }, syncDirectory: func(string) error { *events = append(*events, "sync-directory") return nil }, } } func containsString(values []string, target string) bool { for _, value := range values { if value == target { return true } } return false } func equalStrings(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir(%q) error = %v", dir, err) } for _, e := range entries { if strings.HasPrefix(e.Name(), prefix) { t.Fatalf("unexpected temp file residue: %s", filepath.Join(dir, e.Name())) } } }