Make atomic file replacement crash durable

This commit is contained in:
2026-08-10 17:44:38 +00:00
parent 1dccf5f140
commit 59f3fe3d1d
13 changed files with 502 additions and 233 deletions

View File

@@ -1,12 +1,16 @@
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")
@@ -111,6 +115,231 @@ func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
}
}
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)