Files
zero/internal/tools/file_commit_write_failure_test.go
JATMNandAyush7614 88ec97d563 fix(tools): publish file writes atomically instead of truncating in place (#1214)
* fix(tools): publish file writes atomically instead of truncating in place

Stage replacements before publication so failed writes preserve existing
contents, successful renames detach hard-link siblings, and creates publish
without replacing raced files. Preserve modes and contained symlinks, with
the existing validated in-place fallback when replacement is refused.

Rebuild Ayush7614's complete change from Twigpine/zero#1159 on current main.
The net patch is unchanged; original authorship is retained.

Original-commit: d97d1cf7ae
Original-commit: 9b994c1903
Original-commit: 48f65ca634
Original-PR: https://github.com/Twigpine/zero/pull/1159
Fixes: #1156

* fix(tools): preserve atomic publication when replacement is refused

Keep regular-file preimages and hard-link siblings intact after rename errors. Record published creates even when staging cleanup fails while preserving the cleanup error. Make the permission regression fixture independent of umask.

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-09 12:25:24 -08:00

129 lines
4.7 KiB
Go

package tools
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func installFileWriteTempWrite(t *testing.T, write func(*os.File, string) error) {
t.Helper()
prior := fileWriteTempWrite
fileWriteTempWrite = write
t.Cleanup(func() { fileWriteTempWrite = prior })
}
// A WRITE THAT CANNOT COMPLETE MUST NOT TAKE THE FILE IT REPLACED WITH IT.
//
// Running out of disk space part-way through a write used to be fatal to the
// preimage: the overwrite truncated the observed file first, so the failed
// write left an empty husk where a working file stood. The replacement bytes
// are now written whole to a side file, so a failure there costs the side file
// and the observed bytes survive untouched. Each case reopens the target after
// the tool has run and checks the preimage is what still stands, and that no
// side file was left behind.
func TestOverwriteFailureKeepsThePreimageStanding(t *testing.T) {
for name, run := range map[string]func(string) Result{
"write overwrite": func(root string) Result {
return NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "content": "zero\n", "overwrite": true,
})
},
"edit": func(root string) Result {
return NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
})
},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "existing.txt")
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
t.Fatal(err)
}
// The write that stands in for ENOSPC fails after the file was
// observed, accepted and staged — the exact point the old
// truncate-then-write had nothing left to lose.
writeErr := errors.New("injected unrecoverable write failure")
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
result := run(root)
if result.Status != StatusError {
t.Fatalf("failed-write overwrite = %s, want error: %s", result.Status, result.Output)
}
got, err := os.ReadFile(target)
if err != nil || string(got) != "observed\n" {
t.Fatalf("preimage = %q, err=%v; the observed file must outlive a write that failed", got, err)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "existing.txt" {
t.Fatalf("root holds %d entries; a failed write must remove its side file", len(entries))
}
})
}
}
// A NEW FILE WHOSE WRITE FAILS MUST NOT APPEAR AT ALL. An exclusive create used
// to leave whatever the write had managed to write behind, half-written and
// complete-looking to anything that did not read past the visible length.
func TestCreateFailureCreatesNothing(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "created.txt")
writeErr := errors.New("injected unrecoverable write failure")
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "created.txt", "content": "zero\n",
})
if result.Status != StatusError {
t.Fatalf("failed-write create = %s, want error: %s", result.Status, result.Output)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("target = %v; a create whose bytes never made it must leave no file", statErr)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("root holds %d entries; a failed create must remove its side file", len(entries))
}
}
// AT THE LAYER THE COMMIT RUNS, the injected failure is reported as the
// failure it injected and nothing else, so a caller can distinguish a failed
// write from a refused write.
func TestCommitRootedOverwriteFailedWriteIsTheWriteError(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "existing.txt")
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
t.Fatal(err)
}
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
preimage := "observed\n"
writeErr := errors.New("injected unrecoverable write failure")
installFileWriteTempWrite(t, func(*os.File, string) error { return writeErr })
err = commitRootedFileContents(workspace, filepath.Join(root, "existing.txt"), "existing.txt", info, &preimage, "zero\n")
if !errors.Is(err, writeErr) {
t.Fatalf("commit error = %v, want the injected write failure", err)
}
got, readErr := os.ReadFile(target)
if readErr != nil || string(got) != "observed\n" {
t.Fatalf("preimage = %q, err=%v; the observed file must outlive a write that failed", got, readErr)
}
}