package real // Data-integrity tests for the persistence layer's atomicity contract. The // editor's corruption protection against crashes rests on WriteFileAtomic's // write-sibling-temp-then-rename scheme; these tests pin the observable // consequences of that contract: // // - a written file is byte-exact (no truncation/padding at any size), // - a concurrent reader NEVER observes a torn file: every read returns // exactly one of the known complete payloads (the "crash/kill mid-write // leaves a complete snapshot" property, without needing a real crash), // - a FAILED write leaves the original file byte-identical, // - a stale leftover temp file is consumed by the next successful write. import ( "bytes" "math/rand" "os" "path/filepath" "strconv" "sync" "sync/atomic" "testing" ) func randomPayload(t *testing.T, seed int64, size int) []byte { t.Helper() rng := rand.New(rand.NewSource(seed)) b := make([]byte, size) rng.Read(b) return b } func TestWriteFileAtomic_RoundTripExact(t *testing.T) { for _, size := range []int{0, 1, 2, 65537, 5 * 1024 * 1024} { d := t.TempDir() fs := NewRealFileSystem(d) data := randomPayload(t, int64(size), size) if err := fs.WriteFileAtomic("/rt.txt", data); err != nil { t.Fatalf("size %d: write: %v", size, err) } got, err := fs.ReadFile("/rt.txt") if err != nil { t.Fatalf("size %d: read: %v", size, err) } if !bytes.Equal(got, data) { t.Fatalf("size %d: round trip mismatch (got %d bytes)", size, len(got)) } // Overwrite with different content of the same size. data2 := randomPayload(t, int64(size)+1, size) if err := fs.WriteFileAtomic("/rt.txt", data2); err != nil { t.Fatalf("size %d: overwrite: %v", size, err) } got, err = fs.ReadFile("/rt.txt") if err != nil { t.Fatalf("size %d: read after overwrite: %v", size, err) } if !bytes.Equal(got, data2) { t.Fatalf("size %d: overwrite mismatch", size) } } } // TestWriteFileAtomic_ConcurrentReader_NeverTorn hammers the file with two // alternating 2 MB payloads while a reader spins; every single read must // return exactly one of the two payloads in full. Any torn read (a mix, a // truncation, a padding) is a data-corruption failure. func TestWriteFileAtomic_ConcurrentReader_NeverTorn(t *testing.T) { d := t.TempDir() fs := NewRealFileSystem(d) const size = 2 * 1024 * 1024 payloadA := randomPayload(t, 1, size) payloadB := randomPayload(t, 2, size) if err := fs.WriteFileAtomic("/torn.txt", payloadA); err != nil { t.Fatal(err) } stop := make(chan struct{}) var ( wg sync.WaitGroup reads atomic.Int64 torn atomic.Int64 tornDetail string // written only by the reader, read after wg.Wait() ) wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: } data, err := fs.ReadFile("/torn.txt") if err != nil { continue // transient (rename boundary); next read decides } reads.Add(1) if !bytes.Equal(data, payloadA) && !bytes.Equal(data, payloadB) { torn.Store(1) at := 0 ref := payloadA if len(data) < len(ref) { ref = data } for at < len(data) && at < len(ref) && data[at] == ref[at] { at++ } tornDetail = "first diff at " + itoa(at) + " (read " + itoa(len(data)) + " bytes, want " + itoa(size) + ")" return } } }() const iters = 150 for i := 0; i < iters; i++ { payload := payloadA if i%2 == 1 { payload = payloadB } if err := fs.WriteFileAtomic("/torn.txt", payload); err != nil { t.Fatal(err) } } close(stop) wg.Wait() if torn.Load() == 1 { t.Fatalf("torn read observed: %s", tornDetail) // safe: reader exited (wg.Wait) } if n := reads.Load(); n < 20 { t.Fatalf("only %d concurrent reads happened; test not meaningful", n) } } // TestWriteFileAtomic_FailedWriteLeavesOriginalIntact verifies the crash // property directly: when the write cannot complete (read-only directory), // the original file is untouched, byte for byte. func TestWriteFileAtomic_FailedWriteLeavesOriginalIntact(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: permission bits are bypassed") } d := t.TempDir() fs := NewRealFileSystem(d) original := []byte("original content that must survive\n") if err := fs.WriteFileAtomic("/f.txt", original); err != nil { t.Fatal(err) } if err := os.Chmod(d, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(d, 0o755) }() if err := fs.WriteFileAtomic("/f.txt", []byte("new content")); err == nil { t.Fatal("expected write to fail in read-only directory") } got, err := fs.ReadFile("/f.txt") if err != nil { t.Fatal(err) } if !bytes.Equal(got, original) { t.Fatalf("failed write modified the original file: got %q", got) } } // TestWriteFileAtomic_StaleTempFileIsConsumed verifies a crash-leftover temp // file (garbage from an interrupted write) does not poison the next // successful write and does not survive it. func TestWriteFileAtomic_StaleTempFileIsConsumed(t *testing.T) { d := t.TempDir() fs := NewRealFileSystem(d) // Stale temps in both the legacy deterministic pattern (older app // versions) and the current unique pattern (another/crashed process). stale := []string{".f.txt.tmp", ".f.txt.tmp.99999.7"} for _, name := range stale { if err := os.WriteFile(filepath.Join(d, name), []byte("stale garbage from a crashed write"), 0o644); err != nil { t.Fatal(err) } } // A different file's temp must be untouched. if err := os.WriteFile(filepath.Join(d, ".other.txt.tmp.99999.8"), []byte("not ours"), 0o644); err != nil { t.Fatal(err) } fresh := []byte("fresh content") if err := fs.WriteFileAtomic("/f.txt", fresh); err != nil { t.Fatal(err) } got, err := fs.ReadFile("/f.txt") if err != nil { t.Fatal(err) } if !bytes.Equal(got, fresh) { t.Fatalf("target file != fresh content: %q", got) } entries, err := os.ReadDir(d) if err != nil { t.Fatal(err) } seen := map[string]bool{} for _, e := range entries { seen[e.Name()] = true } if seen[".f.txt.tmp"] || seen[".f.txt.tmp.99999.7"] { t.Fatalf("stale temp file(s) were not consumed by the successful write: %v", seen) } if !seen[".other.txt.tmp.99999.8"] { t.Fatalf("another file's temp was removed: %v", seen) } } func itoa(n int) string { return strconv.Itoa(n) }