From 5205906de4a0aa8326ca0e7b8097ab986bd1fb44 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 17 Aug 2026 12:43:17 -0400 Subject: [PATCH] Data-corruption test suite: differential fuzz + atomicity contract Adds the corruption-proofing layer for the edit/persist path: - chunked_buffer_fuzz_test.go: differential fuzz of ChunkedBuffer Insert/Delete against a plain []byte shadow model (arbitrary byte positions/text, chunk sizes 1..64KB, 500-2000 ops each) verifying FileLen, FullContent, Content probes and the chunk-size invariant after every op; rune-aligned variant adds UTF-8 validity and an independent RuneIndexToByte oracle. - line_index_fuzz_test.go: differential fuzz of the incremental LineIndex updates against a full-recomputation oracle (mixed, no-newline, single-line, CRLF, trailing-newline shapes), plus a trailing-empty-line structural invariant test. - state_api_fuzz_test.go: differential fuzz of the production edit entry points (HandleInsert/Backspace/Delete/ReplaceRange, incl. selection variants) checking content, UTF-8 validity, chunk invariant, line index and exact cursor after every op. - real_file_fuzz_test.go (e2e): random edit sequences (incl. window-relative IME replaces) against the REAL filesystem with per-batch IME-window-consistency checks, forced-flush disk byte-comparison, then a second logic instance (restart simulation) must reload byte-identical content with a fresh line index; asserts no stray temp files. - filesystem_test.go (real): atomicity contract - exact round trip at edge sizes, concurrent reader never sees a torn file across 150 alternating 2MB writes, failed write (read-only dir) leaves the original byte-identical, stale temp file is consumed. Fixes a real invariant violation the fuzzing exposed: Insert halved an oversized spliced result once, so a large paste into a non-empty buffer left chunks up to ~P/2 (8x target at 1MB/64KB), breaking the documented 'no chunk > 2x target' invariant. Insert now re-chunks the oversized result into pieces of at most chunkSize, making the invariant hold after every edit. Mutation-tested: dropping one byte in Insert and one entry in UpdateLineIndexAfterInsert are both caught by the fuzz suite. --- internal/editor/chunked_buffer.go | 87 +++-- internal/editor/chunked_buffer_fuzz_test.go | 341 +++++++++++++++++ internal/editor/line_index_fuzz_test.go | 219 +++++++++++ internal/editor/state_api_fuzz_test.go | 202 ++++++++++ internal/io/pool/real/filesystem_test.go | 198 ++++++++++ internal/test/e2e/real_file_fuzz_test.go | 395 ++++++++++++++++++++ 6 files changed, 1402 insertions(+), 40 deletions(-) create mode 100644 internal/editor/chunked_buffer_fuzz_test.go create mode 100644 internal/editor/line_index_fuzz_test.go create mode 100644 internal/editor/state_api_fuzz_test.go create mode 100644 internal/io/pool/real/filesystem_test.go create mode 100644 internal/test/e2e/real_file_fuzz_test.go diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 0de7bfb..8e4831e 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -41,11 +41,13 @@ const ( // therefore no stale-disk re-read (the old fixed-slot model could re-read a // shifted tail chunk from disk and clobber in-memory edits). // -// Chunks grow/shrink with edits; Insert splits any chunk that grows past -// twice the target size (see Insert), so the per-edit copy cost stays bounded -// by O(chunkSize) even under sustained typing at one spot. Shrunken (even -// empty) chunks are left in place: the chunk count never grows with deletes, -// all readers walk actual lengths, and removing chunks would be pure churn. +// Chunks grow/shrink with edits; Insert re-chunks any result that grows past +// twice the target size into pieces of at most chunkSize (see Insert), so +// after EVERY edit no chunk exceeds 2*chunkSize and the per-edit copy cost +// stays bounded by O(chunkSize) even under sustained typing at one spot or a +// large paste. Shrunken (even empty) chunks are left in place: the chunk +// count never grows with deletes, all readers walk actual lengths, and +// removing chunks would be pure churn. type ChunkedBuffer struct { filename string chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary @@ -325,14 +327,15 @@ func (cb *ChunkedBuffer) markDirtyChunk(idx int) { } // Insert inserts text at byte position pos, splicing only the affected -// chunk. If the result grows past twice the target chunk size (sustained -// typing at one spot, or a large paste), the chunk is split in half so that -// chunks stay O(chunkSize) and every future edit remains a bounded -// O(chunkSize) copy. The split cut is an arbitrary byte offset, like the -// chunk boundaries created by SetContent: chunk boundaries may fall inside -// multi-byte sequences, which is fine because every reader reassembles whole -// windows from chunk bytes (windows are always line-aligned, i.e. on rune -// boundaries). +// chunk. If the spliced result grows past twice the target chunk size +// (sustained typing at one spot, or a large paste), it is re-chunked into +// pieces of at most chunkSize, so the invariant "no chunk exceeds 2*chunkSize +// after any edit" holds universally (a single halving would leave chunks up +// to ~P/2 for a paste of size P). Piece boundaries are arbitrary byte +// offsets, like the chunk boundaries created by SetContent: chunk boundaries +// may fall inside multi-byte sequences, which is fine because every reader +// reassembles whole windows from chunk bytes (windows are always +// line-aligned, i.e. on rune boundaries). func (cb *ChunkedBuffer) Insert(pos int, text string) { if len(text) == 0 { return @@ -345,37 +348,41 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) { pos = 0 } idx, local := cb.chunkForPos(pos) + var newChunk []byte if idx < 0 { - // Empty buffer: chunk the new text directly so a large first paste - // does not create one oversized chunk. - cb.chunks = nil - for start := 0; start < len(text); start += cb.chunkSize { - end := start + cb.chunkSize - if end > len(text) { - end = len(text) - } - cb.chunks = append(cb.chunks, []byte(text[start:end])) - } - cb.fileLen = int64(len(text)) - cb.markDirtyChunk(0) - return + // Empty buffer: the spliced result is just the new text. + newChunk = []byte(text) + } else { + chunk := cb.chunks[idx] + newChunk = make([]byte, 0, len(chunk)+len(text)) + newChunk = append(newChunk, chunk[:local]...) + newChunk = append(newChunk, text...) + newChunk = append(newChunk, chunk[local:]...) } - chunk := cb.chunks[idx] - newChunk := make([]byte, 0, len(chunk)+len(text)) - newChunk = append(newChunk, chunk[:local]...) - newChunk = append(newChunk, text...) - newChunk = append(newChunk, chunk[local:]...) if len(newChunk) > 2*cb.chunkSize { - // Split in half and insert the second half after idx. - cut := len(newChunk) / 2 - second := make([]byte, len(newChunk)-cut) - copy(second, newChunk[cut:]) - newChunk = newChunk[:cut] - cb.chunks = append(cb.chunks, nil) - copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove) - cb.chunks[idx+1] = second + // Re-chunk the oversized result in place of the affected chunk (or as + // the whole buffer when it was empty) so every piece is <= chunkSize; + // this keeps "no chunk exceeds 2*chunkSize" true after ANY edit, + // including large pastes. + out := make([][]byte, 0, len(cb.chunks)+len(newChunk)/cb.chunkSize) + if idx >= 0 { + out = append(out, cb.chunks[:idx]...) + } + for start := 0; start < len(newChunk); start += cb.chunkSize { + end := min(start+cb.chunkSize, len(newChunk)) + piece := make([]byte, end-start) + copy(piece, newChunk[start:end]) + out = append(out, piece) + } + if idx >= 0 { + out = append(out, cb.chunks[idx+1:]...) + } + cb.chunks = out + } else if idx < 0 { + cb.chunks = [][]byte{newChunk} + } else { + cb.chunks[idx] = newChunk } - cb.chunks[idx] = newChunk cb.fileLen += int64(len(text)) cb.markDirtyChunk(idx) } diff --git a/internal/editor/chunked_buffer_fuzz_test.go b/internal/editor/chunked_buffer_fuzz_test.go new file mode 100644 index 0000000..7fdd25a --- /dev/null +++ b/internal/editor/chunked_buffer_fuzz_test.go @@ -0,0 +1,341 @@ +package editor + +// Differential fuzzing for ChunkedBuffer: every random edit is mirrored on a +// plain []byte shadow model, and the buffer's observable state (FileLen, +// FullContent, Content probes, chunk-size invariant, and in the rune-aligned +// variant UTF-8 validity + RuneIndexToByte) must match the model after EVERY +// operation. This is the data-corruption guard for the edit path: any chunk +// math bug (wrong splice, dropped/duplicated bytes, drifted prefix sums, +// broken re-chunking) surfaces as a mismatch at the exact failing operation. + +import ( + "math/rand" + "strconv" + "strings" + "testing" + "unicode/utf8" +) + +// fuzzRunePool mixes 1-, 2-, 3- and 4-byte runes so random text crosses every +// UTF-8 width, plus the line-structure characters. +var fuzzRunePool = []rune{ + 'a', 'z', '0', '9', ' ', '\t', '.', ',', '!', + '\n', '\r', + 'é', // 2 bytes + '中', '日', '本', // 3 bytes + '😀', // 4 bytes +} + +// fuzzRuneString returns a random valid UTF-8 string of exactly n runes. +func fuzzRuneString(rng *rand.Rand, n int) string { + var sb strings.Builder + sb.Grow(n * 2) + for i := 0; i < n; i++ { + sb.WriteRune(fuzzRunePool[rng.Intn(len(fuzzRunePool))]) + } + return sb.String() +} + +// modelInsert returns m with text spliced in at pos (the shadow-model op). +func modelInsert(m []byte, pos int, text []byte) []byte { + out := make([]byte, 0, len(m)+len(text)) + out = append(out, m[:pos]...) + out = append(out, text...) + return append(out, m[pos:]...) +} + +// modelDelete returns m with [a, b) removed (the shadow-model op). +func modelDelete(m []byte, a, b int) []byte { + out := make([]byte, 0, len(m)-(b-a)) + out = append(out, m[:a]...) + return append(out, m[b:]...) +} + +// runeStartPositions returns the byte offset of every rune start in m (which +// must be valid UTF-8), ending with len(m). Used to pick rune-aligned +// positions for the rune-aligned fuzz variant. +func runeStartPositions(m []byte) []int { + positions := []int{0} + for pos := 0; pos < len(m); { + _, size := utf8.DecodeRune(m[pos:]) + pos += size + positions = append(positions, pos) + } + return positions +} + +// runeStartToByteOracle is an INDEPENDENT (utf8.DecodeRune based) +// implementation of "byte offset of the n-th rune", used as the oracle for +// ChunkedBuffer.RuneIndexToByte. Returns len(m) if n is past the end. +func runeStartToByteOracle(m []byte, n int) int { + if n <= 0 { + return 0 + } + pos := 0 + for i := 0; i < n && pos < len(m); i++ { + _, size := utf8.DecodeRune(m[pos:]) + pos += size + } + if pos > len(m) { + return len(m) + } + return pos +} + +// fuzzChunkSize returns a random insert size in bytes (byte variant) or runes +// (rune variant): mostly tiny (typing), sometimes large (paste), up to max. +func fuzzInsertSize(rng *rand.Rand, max int) int { + switch rng.Intn(100) { + case 0: + return 1 + case 1: + return rng.Intn(4) + 1 // 1..4 + case 2: + return rng.Intn(30) + 5 // 5..34 + case 3: + return rng.Intn(170) + 35 // 35..204 + default: + return rng.Intn(max) + 1 // 1..max (large paste) + } +} + +// fuzzChunkedBuffer runs the differential fuzz with the given chunk size. +// runeAligned restricts every edit boundary to rune starts and inserted text +// to valid UTF-8, which keeps the model valid UTF-8 and enables the +// RuneIndexToByte oracle + validity checks; the byte variant allows arbitrary +// byte positions and bytes (the buffer is a byte structure; rune granularity +// is enforced by the state layer, covered in state_api_fuzz_test.go). +func fuzzChunkedBuffer(t *testing.T, chunkSize, ops, maxText int, runeAligned bool) { + t.Helper() + seed := int64(chunkSize) * 1000 + if runeAligned { + seed++ + } + rng := rand.New(rand.NewSource(seed)) + cb := NewChunkedBuffer("/fuzz.txt", chunkSize, nil, "") + + // Seed content. + var model []byte + if runeAligned { + model = []byte(fuzzRuneString(rng, rng.Intn(150)+1)) + } else { + n := rng.Intn(150) + 1 + model = make([]byte, n) + for i := range model { + model[i] = byte(rng.Intn(256)) + } + } + cb.SetContent(model) + + // The model cap keeps the per-op FullContent comparison cheap while the + // text size keeps hitting the re-chunking path for the chunk size. + modelCap := maxText * 2 + if modelCap < 2048 { + modelCap = 2048 + } + if modelCap > 512*1024 { + modelCap = 512 * 1024 + } + + compact := func(got string, i int) { + at := 0 + for at < len(got) && at < len(model) && got[at] == model[at] { + at++ + } + t.Fatalf("op %d: FullContent mismatch (got %d bytes, model %d bytes, first diff at %d)", + i, len(got), len(model), at) + } + + for i := 0; i < ops; i++ { + // Keep the model bounded: trim back with a large delete if needed. + if len(model) > modelCap { + a := 0 + b := len(model) / 2 + if runeAligned { + pos := runeStartPositions(model) + a = pos[len(pos)/4] + b = pos[len(pos)/2] + } + cb.Delete(a, b-a) + model = modelDelete(model, a, b) + } + + op := rng.Intn(10) + switch { + case op < 4: // insert + var pos int + var text string + if runeAligned { + p := runeStartPositions(model) + pos = p[rng.Intn(len(p))] + text = fuzzRuneString(rng, fuzzInsertSize(rng, maxText)) + } else { + pos = rng.Intn(len(model) + 1) + text = string(makeRandomBytes(rng, fuzzInsertSize(rng, maxText))) + } + cb.Insert(pos, text) + model = modelInsert(model, pos, []byte(text)) + + case op < 7: // delete + if len(model) == 0 { + continue + } + var a, b int + if runeAligned { + p := runeStartPositions(model) + a = p[rng.Intn(len(p))] + b = p[rng.Intn(len(p))] + if a > b { + a, b = b, a + } + if a == b { + continue + } + } else { + a = rng.Intn(len(model) + 1) + b = a + rng.Intn(len(model)-a+1) + if a == b { + continue + } + } + cb.Delete(a, b-a) + model = modelDelete(model, a, b) + + default: // replace = delete + insert, exactly as the production + // edit composition does (state.go HandleReplaceRange). + var a, b int + var text string + if runeAligned { + p := runeStartPositions(model) + a = p[rng.Intn(len(p))] + b = p[rng.Intn(len(p))] + if a > b { + a, b = b, a + } + text = fuzzRuneString(rng, fuzzInsertSize(rng, maxText)) + } else { + a = rng.Intn(len(model) + 1) + b = a + rng.Intn(len(model)-a+1) + text = string(makeRandomBytes(rng, fuzzInsertSize(rng, maxText))) + } + if b > a { + cb.Delete(a, b-a) + model = modelDelete(model, a, b) + } + if text != "" { + cb.Insert(a, text) + model = modelInsert(model, a, []byte(text)) + } + } + + // --- Invariants after every op --------------------------------- + if cb.FileLen() != int64(len(model)) { + t.Fatalf("op %d: FileLen=%d, model=%d bytes", i, cb.FileLen(), len(model)) + } + + // Content probes: random spans + fixed edge spans that cross the + // first/last chunk boundaries. + for j := 0; j < 3; j++ { + a := rng.Intn(len(model) + 1) + b := a + rng.Intn(len(model)-a+1) + if got := cb.Content(a, b); got != string(model[a:b]) { + t.Fatalf("op %d: Content(%d,%d) mismatch (got %d bytes)", i, a, b, len(got)) + } + } + edge := 3*chunkSize + 5 + if edge > len(model) { + edge = len(model) + } + for _, span := range [][2]int{{0, 0}, {0, len(model)}, {len(model), len(model)}, {0, edge}, {len(model) - edge, len(model)}} { + if got := cb.Content(span[0], span[1]); got != string(model[span[0]:span[1]]) { + t.Fatalf("op %d: Content(%d,%d) edge mismatch", i, span[0], span[1]) + } + } + + // Full reconciliation (every op while small, sampled when large). + if len(model) <= 32*1024 || i%8 == 0 { + full, err := cb.FullContent() + if err != nil { + t.Fatalf("op %d: FullContent: %v", i, err) + } + if full != string(model) { + compact(full, i) + } + } + + // Chunk-size invariant: holds after ANY edit, including large pastes. + assertMaxChunkSize(t, cb) + + if runeAligned { + // Rune-aligned edits must never break UTF-8. + if !utf8.Valid(model) { + t.Fatalf("op %d: model is not valid UTF-8 after rune-aligned edit", i) + } + // RuneIndexToByte oracle (3 random rune indices per op). + nr := utf8.RuneCount(model) + for j := 0; j < 3; j++ { + n := rng.Intn(nr + 1) + if got, want := cb.RuneIndexToByte(n), runeStartToByteOracle(model, n); got != want { + t.Fatalf("op %d: RuneIndexToByte(%d)=%d, oracle=%d", i, n, got, want) + } + } + } + } +} + +func makeRandomBytes(rng *rand.Rand, n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(rng.Intn(256)) + } + return b +} + +// TestChunkedBuffer_Fuzz_ByteLevel runs arbitrary byte-level edits (positions +// and bytes may split UTF-8; the buffer is a byte structure) against the +// shadow model. The chunk-size sweep includes tiny sizes where every edit +// crosses chunk boundaries and re-chunking. +func TestChunkedBuffer_Fuzz_ByteLevel(t *testing.T) { + cases := []struct { + chunkSize int + ops int + maxText int + }{ + {1, 2000, 64}, + {3, 2000, 128}, + {7, 2000, 256}, + {64, 1500, 2048}, + {256, 1200, 8192}, + {64 * 1024, 500, 256 * 1024}, + } + for _, c := range cases { + t.Run("cs"+itoa(c.chunkSize), func(t *testing.T) { + fuzzChunkedBuffer(t, c.chunkSize, c.ops, c.maxText, false) + }) + } +} + +// TestChunkedBuffer_Fuzz_RuneAligned runs rune-aligned edits with valid UTF-8 +// text (the contract the state layer enforces in production) and additionally +// checks UTF-8 validity and the RuneIndexToByte oracle after every op. +func TestChunkedBuffer_Fuzz_RuneAligned(t *testing.T) { + cases := []struct { + chunkSize int + ops int + maxText int + }{ + {1, 2000, 64}, + {3, 2000, 128}, + {7, 2000, 256}, + {64, 1500, 2048}, + {256, 1200, 8192}, + {64 * 1024, 500, 256 * 1024}, + } + for _, c := range cases { + t.Run("cs"+itoa(c.chunkSize), func(t *testing.T) { + fuzzChunkedBuffer(t, c.chunkSize, c.ops, c.maxText, true) + }) + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/internal/editor/line_index_fuzz_test.go b/internal/editor/line_index_fuzz_test.go new file mode 100644 index 0000000..f5df68d --- /dev/null +++ b/internal/editor/line_index_fuzz_test.go @@ -0,0 +1,219 @@ +package editor + +// Differential fuzzing for the incrementally-maintained LineIndex: every +// random edit is applied to the buffer AND replayed on the LineIndex with the +// exact production composition (UpdateLineIndexAfterDelete then +// UpdateLineIndexAfterInsert for a replace), and after every op the index must +// equal a full recomputation from the current content (lineStartOffsets +// oracle). LineIndex drift never corrupts the file (disk writes derive only +// from chunks), but it silently corrupts every line-based UI feature +// (scroll window, page keys, tap mapping, status bar), so it gets the same +// differential treatment as the buffer itself. + +import ( + "bytes" + "fmt" + "math/rand" + "strings" + "testing" + + "pad/internal/io/pool/types" +) + +// buildFuzzContent generates nLines pseudo-random lines (seeded) with a mix +// of ASCII, CJK and multi-byte content; noNewlines/crlf shape the line +// structure for the edge cases. +func buildFuzzContent(rng *rand.Rand, nLines int, noNewlines, crlf bool) string { + var sb strings.Builder + for i := 0; i < nLines; i++ { + sb.WriteString(fuzzRuneString(rng, rng.Intn(40)+1)) + if i < nLines-1 || (crlf && i == nLines-1) { + if !noNewlines { + if crlf { + sb.WriteString("\r\n") + } else { + sb.WriteByte('\n') + } + } + } + } + if noNewlines { + sb.WriteString(fuzzRuneString(rng, 20)) + } + return sb.String() +} + +func fuzzLineIndex(t *testing.T, label string, initial string, ops int) { + t.Helper() + chunkSize := 32 + rng := rand.New(rand.NewSource(int64(len(initial))*7919 + 13)) + cb := NewChunkedBuffer("/lineidx.txt", chunkSize, nil, "") + var model []byte = []byte(initial) + cb.SetContent(model) + // Build the index the way the app does, then mutate it incrementally. + cb.LineIndex = types.NewLineIndex(lineStartOffsets(model), 0, int64(len(model))) + + modelCap := 32 * 1024 + for i := 0; i < ops; i++ { + if len(model) > modelCap { + // Trim back (rune-aligned) to keep the oracle cheap. + p := runeStartPositions(model) + a, b := p[len(p)/4], p[len(p)/2] + cb.Delete(a, b-a) + cb.UpdateLineIndexAfterDelete(a, b) + model = modelDelete(model, a, b) + } + + // One random replace op (covers insert: b==a, and delete: text==""). + p := runeStartPositions(model) + a := p[rng.Intn(len(p))] + b := p[rng.Intn(len(p))] + if a > b { + a, b = b, a + } + var text string + switch rng.Intn(10) { + case 0: + text = "" // pure delete + case 1: + text = fuzzRuneString(rng, rng.Intn(30)+1) // no newlines + default: + // Text that adds structure: newlines / CRLF / CJK. + text = fuzzRuneString(rng, rng.Intn(40)+1) + switch rng.Intn(3) { + case 0: + text += "\n" + fuzzRuneString(rng, rng.Intn(10)) + case 1: + text = "\n" + text + case 2: + text += "\r\n" + } + } + + // Production composition order (state.go HandleReplaceRange): + if b > a { + cb.Delete(a, b-a) + model = modelDelete(model, a, b) + cb.UpdateLineIndexAfterDelete(a, b) + } + if text != "" { + cb.Insert(a, text) + model = modelInsert(model, a, []byte(text)) + cb.UpdateLineIndexAfterInsert(a, text) + } + + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("%s op %d", label, i)) + } +} + +func TestLineIndex_Fuzz_RandomEdits(t *testing.T) { + const ops = 3000 + cases := []struct { + name string + content string + }{ + {"mixedLines", buildFuzzContent(rand.New(rand.NewSource(1)), 80, false, false)}, + {"trailingNewline", buildFuzzContent(rand.New(rand.NewSource(2)), 60, false, false) + "\n"}, + {"noNewlines", fuzzRuneString(rand.New(rand.NewSource(3)), 800)}, + {"singleLine", "only one line, no newlines at all, fairly long"}, + {"crlfLines", buildFuzzContent(rand.New(rand.NewSource(4)), 60, false, true)}, + {"emptyStart", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fuzzLineIndex(t, c.name, c.content, ops) + }) + } +} + +// TestLineIndex_Fuzz_TrailingEmptyLine pins the invariant that a file ending +// in '\n' has a trailing empty line in the index, across random edits that +// add/remove the final newline. +func TestLineIndex_Fuzz_TrailingEmptyLine(t *testing.T) { + rng := rand.New(rand.NewSource(5)) + cb := NewChunkedBuffer("/trail.txt", 16, nil, "") + model := []byte("a\nb\nc\n") + cb.SetContent(model) + cb.LineIndex = types.NewLineIndex(lineStartOffsets(model), 0, int64(len(model))) + if cb.LineIndex.LineCount() != 4 { + t.Fatalf("initial LineCount=%d, want 4 (trailing empty line)", cb.LineIndex.LineCount()) + } + for i := 0; i < 1500; i++ { + // Randomly add/remove the final newline and edit around the end. + op := rng.Intn(4) + switch op { + case 0: // ensure ends with newline + if len(model) > 0 && model[len(model)-1] != '\n' { + pos := len(model) + cb.Insert(pos, "\n") + model = modelInsert(model, pos, []byte("\n")) + cb.UpdateLineIndexAfterInsert(pos, "\n") + } + case 1: // strip trailing newlines + oldLen := len(model) + end := oldLen + for end > 0 && model[end-1] == '\n' { + end-- + } + if end < oldLen { + cb.Delete(end, oldLen-end) + model = modelDelete(model, end, oldLen) + cb.UpdateLineIndexAfterDelete(end, oldLen) + } + case 2: // append a line at the end + pos := len(model) + text := fuzzRuneString(rng, rng.Intn(10)+1) + "\n" + cb.Insert(pos, text) + model = modelInsert(model, pos, []byte(text)) + cb.UpdateLineIndexAfterInsert(pos, text) + case 3: // delete the last line + if last := bytesLastLineStart(model); last >= 0 { + oldLen := len(model) + cb.Delete(last, oldLen-last) + model = modelDelete(model, last, oldLen) + cb.UpdateLineIndexAfterDelete(last, oldLen) + } + } + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d", i)) + // Structural invariant: line count = newlines + 1, and a trailing '\n' + // shows up as the index's last offset landing exactly at len(model) + // (the trailing empty line). + endsNL := len(model) > 0 && model[len(model)-1] == '\n' + wantLines := 1 + utf8RuneCountNewlines(model) + if cb.LineIndex.LineCount() != wantLines { + t.Fatalf("op %d: LineCount=%d, want %d", i, cb.LineIndex.LineCount(), wantLines) + } + offs := cb.LineIndex.Offsets + lastIsEnd := offs[len(offs)-1] == int32(len(model)) + // The final line starts at EOF iff the file is empty or ends in '\n' + // (i.e. the final line is empty). + wantLastIsEnd := len(model) == 0 || endsNL + if lastIsEnd != wantLastIsEnd { + t.Fatalf("op %d: last line start at %d, len %d, endsNL=%v", i, offs[len(offs)-1], len(model), endsNL) + } + } +} + +// bytesLastLineStart returns the byte offset of the start of the last line in +// m (the position after the previous '\n', or 0), or -1 if m is empty. +func bytesLastLineStart(m []byte) int { + if len(m) == 0 { + return -1 + } + if i := bytes.LastIndexByte(m, '\n'); i >= 0 { + return i + 1 + } + return 0 +} + +// utf8RuneCountNewlines counts '\n' bytes (byte scan; '\n' cannot appear +// inside a multi-byte UTF-8 sequence). +func utf8RuneCountNewlines(m []byte) int { + n := 0 + for _, b := range m { + if b == '\n' { + n++ + } + } + return n +} diff --git a/internal/editor/state_api_fuzz_test.go b/internal/editor/state_api_fuzz_test.go new file mode 100644 index 0000000..15c33d5 --- /dev/null +++ b/internal/editor/state_api_fuzz_test.go @@ -0,0 +1,202 @@ +package editor + +// Differential fuzzing of the production edit entry points (the layer that +// enforces rune granularity): HandleInsert, HandleBackspace, HandleDelete and +// HandleReplaceRange, including selection-aware variants, are driven with +// random operations against a shadow model. After every op we check: +// +// - buffer content == model (no bytes lost/duplicated/reordered), +// - content is still valid UTF-8 (rune granularity held), +// - the chunk-size invariant (no chunk > 2x target), +// - the LineIndex equals a full recomputation (oracle), +// - the cursor stays within the file. +// +// This catches corruption that only appears at the API boundary: rune-width +// bugs (utf8BackspaceWidth/utf8AdvanceWidth), selection handling, and the +// IME rune->byte conversion, none of which the raw buffer fuzz sees. + +import ( + "fmt" + "math/rand" + "testing" + "unicode/utf8" + + "pad/internal/io/pool/types" +) + +func fuzzStateAPI(t *testing.T, initial string, chunkSize, ops int) { + t.Helper() + st := newChunkedState(t, initial, chunkSize) + cb := st.Editor.ChunkedBuffer + cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(initial)), 0, int64(len(initial))) + + model := initial + cursor := len(model) + rng := rand.New(rand.NewSource(4242)) + modelCap := 32 * 1024 + + setSelection := func(a, b int) { + st.Editor.SelectionAnchor = a + st.Editor.SelectionStart = a + st.Editor.SelectionEnd = b + } + + for i := 0; i < ops; i++ { + if len(model) > modelCap { + p := runeStartPositions([]byte(model)) + a, b := p[len(p)/4], p[len(p)/2] + setSelection(a, b) + HandleBackspace() // deletes the selection + model = model[:a] + model[b:] + cursor = a + st.Editor.CursorPosition = a + } + + pos := func() int { + p := runeStartPositions([]byte(model)) + return p[rng.Intn(len(p))] + } + switch rng.Intn(100) { + case 0: // 28%: plain insert at a random position + c := pos() + text := fuzzRuneString(rng, fuzzInsertSize(rng, 300)) + st.Editor.CursorPosition = c + HandleInsert(text) + model = model[:c] + text + model[c:] + cursor = c + len(text) + case 1: // 12%: plain backspace + c := pos() + st.Editor.CursorPosition = c + cursor = c + HandleBackspace() + if c > 0 { + w := utf8BackspaceWidth(model[max0(c-4):c]) + model = model[:c-w] + model[c:] + cursor = c - w + } + case 2: // 12%: plain delete (cursor stays put) + c := pos() + st.Editor.CursorPosition = c + cursor = c + HandleDelete() + if c < len(model) { + w := utf8AdvanceWidth(model[c:min4(c+4, len(model))]) + model = model[:c] + model[c+w:] + } + case 3: // 8%: backspace with live selection + if len(model) == 0 { + continue + } + a, b := randomSelection(rng, model) + setSelection(a, b) + st.Editor.CursorPosition = pos() + HandleBackspace() + model = model[:a] + model[b:] + cursor = a + case 4: // 8%: delete with live selection + if len(model) == 0 { + continue + } + a, b := randomSelection(rng, model) + setSelection(a, b) + st.Editor.CursorPosition = pos() + HandleDelete() + model = model[:a] + model[b:] + cursor = a + case 5: // 8%: insert with live selection (replace selection) + if len(model) == 0 { + continue + } + a, b := randomSelection(rng, model) + text := fuzzRuneString(rng, fuzzInsertSize(rng, 100)) + setSelection(a, b) + st.Editor.CursorPosition = pos() + HandleInsert(text) + model = model[:a] + text + model[b:] + cursor = a + len(text) + default: // 24%: IME replace at absolute rune indices (empty window + // => the whole buffer is the window, as in newChunkedState). + nr := utf8.RuneCountInString(model) + ra := rng.Intn(nr + 1) + rb := ra + rng.Intn(nr-ra+1) + text := fuzzRuneString(rng, fuzzInsertSize(rng, 300)) + bs := runeStartToByteOracle([]byte(model), ra) + be := runeStartToByteOracle([]byte(model), rb) + HandleReplaceRange(ra, rb, text) + model = model[:bs] + text + model[be:] + cursor = bs + len(text) + } + + // --- Invariants after every op -------------------------------- + full, err := cb.FullContent() + if err != nil { + t.Fatalf("op %d: FullContent: %v", i, err) + } + if full != model { + at := 0 + for at < len(full) && at < len(model) && full[at] == model[at] { + at++ + } + t.Fatalf("op %d: buffer != model (got %d, want %d bytes, first diff at %d)", + i, len(full), len(model), at) + } + if !utf8.ValidString(full) { + t.Fatalf("op %d: buffer is not valid UTF-8", i) + } + assertMaxChunkSize(t, cb) + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d", i)) + if got := st.Editor.CursorPosition; got != cursor { + t.Fatalf("op %d: cursor=%d, want %d (len %d)", i, got, cursor, len(model)) + } + } +} + +func TestStateAPI_Fuzz_EditOps(t *testing.T) { + initial := fuzzRuneString(rand.New(rand.NewSource(7)), 300) + + "\nsecond line\n第三行\n" + fuzzRuneString(rand.New(rand.NewSource(8)), 300) + fuzzStateAPI(t, initial, 32, 2000) +} + +func TestStateAPI_Fuzz_SmallFile(t *testing.T) { + // Tiny file, tiny chunks: every edit is at or across a boundary. + initial := "ab\ncd\n" + fuzzStateAPI(t, initial, 3, 1500) +} + +// randomSelection returns a rune-aligned [a,b) with a < b suitable for +// setting a live selection (selActive requires end > start). If the model is +// empty it returns (0, 0) and the caller must skip the selection op. +func randomSelection(rng *rand.Rand, model string) (int, int) { + if len(model) == 0 { + return 0, 0 + } + p := runeStartPositions([]byte(model)) + if len(p) < 2 { + return 0, len(model) + } + for { + a := p[rng.Intn(len(p))] + b := p[rng.Intn(len(p))] + if a == b { + continue + } + if a > b { + a, b = b, a + } + return a, b + } +} + +func max0(n int) int { + if n < 0 { + return 0 + } + return n +} + +func min4(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/io/pool/real/filesystem_test.go b/internal/io/pool/real/filesystem_test.go new file mode 100644 index 0000000..6554db8 --- /dev/null +++ b/internal/io/pool/real/filesystem_test.go @@ -0,0 +1,198 @@ +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) + if err := os.WriteFile(filepath.Join(d, ".f.txt.tmp"), []byte("stale garbage from a crashed write"), 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) + } + for _, e := range entries { + if e.Name() == ".f.txt.tmp" { + t.Fatal("stale temp file was not consumed by the successful write") + } + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/internal/test/e2e/real_file_fuzz_test.go b/internal/test/e2e/real_file_fuzz_test.go new file mode 100644 index 0000000..f5993ac --- /dev/null +++ b/internal/test/e2e/real_file_fuzz_test.go @@ -0,0 +1,395 @@ +package e2e_test + +// End-to-end data-corruption fuzz against the REAL filesystem: random edit +// sequences (through the production edit entry points, including IME replace +// ranges relative to the visible window) are mirrored on a shadow model; +// after every batch the on-disk file must equal the model byte-for-byte +// (forced via Flush), and the visible IME window must equal the model's slice +// at its recorded start (a window/content desync is what made IME edits land +// in the wrong place historically — Phase 3). Finally a SECOND logic instance +// (simulating an app restart) reopens the file and must see exactly the +// model, with a consistent line index — proving the full +// edit -> persist -> reload -> re-render data path is lossless. + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "pad/internal/editor" + "pad/internal/io/pool/real" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +var fuzzWords = []string{ + "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", +} + +// buildFuzzFileContent builds a deterministic multi-line document mixing +// ASCII and multi-byte content. +func buildFuzzFileContent(seed int64, nLines int) string { + rng := rand.New(rand.NewSource(seed)) + var sb strings.Builder + for i := 0; i < nLines; i++ { + sb.WriteString(fmt.Sprintf("L%03d ", i)) + n := rng.Intn(8) + 3 + for w := 0; w < n; w++ { + sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))]) + sb.WriteByte(' ') + } + switch i % 17 { + case 0: + sb.WriteString("中文内容 ") + case 1: + sb.WriteString("héllo wörld ") + case 2: + sb.WriteString("😀 emoji ") + } + sb.WriteString("\n") + } + return sb.String() +} + +// loadRealFile opens /name in h and waits until the load is fully applied +// (content + line index), the same air-tight predicate the other real-file +// tests use. +func loadRealFile(t *testing.T, h *e2e.Harness, name string) { + t.Helper() + if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil { + t.Fatalf("GoToBrowser: %v", err) + } + time.Sleep(100 * time.Millisecond) + if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil { + t.Fatalf("OpenFile: %v", err) + } + for i := 0; i < 100; i++ { + v, err := h.Inspect(func(st *editor.State) any { + cb := st.Editor.ChunkedBuffer + if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { + return false + } + full, err := cb.FullContent() + return err == nil && int64(len(full)) == cb.FileLen() && + cb.LineIndex.Size == cb.FileLen() + }) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if v.(bool) { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("timed out waiting for real file to load") +} + +// compactMismatch is a short failure message for two differing strings. +func compactMismatch(got, want string) string { + at := 0 + for at < len(got) && at < len(want) && got[at] == want[at] { + at++ + } + return fmt.Sprintf("first diff at byte %d (got %d bytes, want %d)\ngot [%d:%d]=%q\nwant [%d:%d]=%q", + at, len(got), len(want), + lo(at, 40), hi(at, 40), seg(got, at, 40), + lo(at, 40), hi(at, 40), seg(want, at, 40)) +} + +func TestRealFile_Fuzz_EditSaveReload(t *testing.T) { + const ( + name = "fuzz.txt" + batches = 15 + opsPer = 6 + ) + content := buildFuzzFileContent(11, 400) // ~15 KB + dir := t.TempDir() + diskPath := filepath.Join(dir, name) + if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) + h.Run() + defer h.Cleanup() + loadRealFile(t, h, name) + + model := content + rng := rand.New(rand.NewSource(77)) + + for batch := 0; batch < batches; batch++ { + for j := 0; j < opsPer; j++ { + switch rng.Intn(100) { + case 0: // 30%: insert at a random rune-aligned position + pos := randomRunePos(rng, model) + text := randomFuzzText(rng, 1, 24) + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + editor.HandleInsert(text) + }); err != nil { + t.Fatalf("batch %d op %d: insert: %v", batch, j, err) + } + model = model[:pos] + text + model[pos:] + + case 1: // 15%: backspace + pos := randomRunePos(rng, model) + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + editor.HandleBackspace() + }); err != nil { + t.Fatalf("batch %d op %d: backspace: %v", batch, j, err) + } + if pos > 0 { + model = model[:modelRuneBack(model, pos)] + model[pos:] + } + + case 2: // 15%: delete + pos := randomRunePos(rng, model) + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + editor.HandleDelete() + }); err != nil { + t.Fatalf("batch %d op %d: delete: %v", batch, j, err) + } + if pos < len(model) { + model = model[:pos] + model[modelRuneNext(model, pos):] + } + + default: // 40%: IME replace, WINDOW-relative rune range (the real + // contract: the IME only knows the visible snippet). + // Force a fresh layout frame first: WithState edits do not + // emit frames, so the window would otherwise be stale. + fb := h.FrameCount() + h.SendInput([]ui.InputEvent{}) + if _, ferr := h.WaitForFrameCount(fb+1, 5*time.Second); ferr != nil { + t.Fatalf("batch %d op %d: no frame: %v", batch, j, ferr) + } + type win struct { + Start int + Text string + } + raw, err := h.Inspect(func(st *editor.State) any { + return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText} + }) + if err != nil { + t.Fatalf("batch %d op %d: inspect: %v", batch, j, err) + } + w := raw.(win) + if w.Text == "" { + t.Fatalf("batch %d op %d: IME window empty after load+frames", batch, j) + } + // Window consistency: the snippet must be exactly the model's + // slice at the recorded start. + if w.Start < 0 || w.Start+len(w.Text) > len(model) || + w.Text != model[w.Start:w.Start+len(w.Text)] { + t.Fatalf("batch %d op %d: IME window desync: start=%d len=%d; %s", + batch, j, w.Start, len(w.Text), + compactMismatch(w.Text, model[w.Start:min(len(model), w.Start+len(w.Text))])) + } + wr := countRunes(w.Text) + ra := rng.Intn(wr + 1) + rb := ra + rng.Intn(wr-ra+1) + text := randomFuzzText(rng, 0, 12) + if err := h.WithState(func(st *editor.State) { + editor.HandleReplaceRange(ra, rb, text) + }); err != nil { + t.Fatalf("batch %d op %d: replace: %v", batch, j, err) + } + bs := w.Start + runeToByte(w.Text, ra) + be := w.Start + runeToByte(w.Text, rb) + model = model[:bs] + text + model[be:] + } + } + + // Force a fresh layout frame so IMEWindowText reflects the new model, + // then verify the window once per batch. + before := h.FrameCount() + h.SendInput([]ui.InputEvent{}) + if _, err := h.WaitForFrameCount(before+1, 5*time.Second); err != nil { + t.Fatalf("batch %d: no frame after input: %v", batch, err) + } + w, err := h.Inspect(func(st *editor.State) any { + return struct { + Start int + Text string + }{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText} + }) + if err != nil { + t.Fatalf("batch %d: inspect: %v", batch, err) + } + ws := w.(struct { + Start int + Text string + }) + if ws.Text != "" && (ws.Start < 0 || ws.Start+len(ws.Text) > len(model) || + ws.Text != model[ws.Start:ws.Start+len(ws.Text)]) { + t.Fatalf("batch %d: window desync after edits: start=%d len=%d", batch, ws.Start, len(ws.Text)) + } + + // Persistence: forced flush, then the on-disk file must be EXACTLY + // the model (no lost bytes, no duplication, no reorder). + if err := h.Flush(); err != nil { + t.Fatalf("batch %d: flush: %v", batch, err) + } + if disk := readDisk(t, diskPath); disk != model { + t.Fatalf("batch %d: disk != model: %s", batch, compactMismatch(disk, model)) + } + } + + // --- Restart simulation: a fresh logic instance (new process state) --- + h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) + h2.Run() + defer h2.Cleanup() + loadRealFile(t, h2, name) + + got, err := h2.FullContent() + if err != nil { + t.Fatalf("restart FullContent: %v", err) + } + if got != model { + t.Fatalf("after restart: buffer != model: %s", compactMismatch(got, model)) + } + if disk := readDisk(t, diskPath); disk != model { + t.Fatalf("after restart: disk != model: %s", compactMismatch(disk, model)) + } + + // Line index after reload must match a fresh recomputation. + diag, err := h2.Inspect(func(st *editor.State) any { + cb := st.Editor.ChunkedBuffer + li := cb.LineIndex + if li == nil { + return "nil index" + } + want := freshLineOffsets(model) + if len(li.Offsets) != len(want) || li.Size != int64(len(model)) { + return fmt.Sprintf("size=%d lines=%d, want size=%d lines=%d", li.Size, len(li.Offsets), len(model), len(want)) + } + for i := range want { + if li.Offsets[i] != want[i] { + return fmt.Sprintf("offset %d: got %d want %d", i, li.Offsets[i], want[i]) + } + } + return "ok" + }) + if err != nil { + t.Fatalf("restart inspect: %v", err) + } + if diag.(string) != "ok" { + t.Fatalf("after restart: line index inconsistent: %s", diag) + } + + // No temp files left behind by the atomic writes. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("stray temp file left behind: %s", e.Name()) + } + } +} + +// randomRunePos returns a random rune-start byte offset in s. +func randomRunePos(rng *rand.Rand, s string) int { + runeStarts := []int{0} + for i := 0; i < len(s); { + _, size := decodeRuneAt(s[i:]) + i += size + runeStarts = append(runeStarts, i) + } + return runeStarts[rng.Intn(len(runeStarts))] +} + +func decodeRuneAt(s string) (rune, int) { + if len(s) == 0 { + return 0, 0 + } + c := s[0] + switch { + case c < 0x80: + return rune(c), 1 + case c < 0xC0: + // Invalid lead (should not happen with valid content); treat as 1 + // byte so the fuzzer never advances past it in a loop. + return rune(c), 1 + case c < 0xE0: + return rune(c), 2 + case c < 0xF0: + return rune(c), 3 + default: + return rune(c), 4 + } +} + +// modelRuneBack returns the byte offset of the start of the rune ending at +// pos (pos must be a rune start > 0). +func modelRuneBack(s string, pos int) int { + i := pos - 1 + for i > 0 && s[i] >= 0x80 && s[i] < 0xC0 { + i-- + } + return i +} + +// modelRuneNext returns the byte offset after the rune starting at pos. +func modelRuneNext(s string, pos int) int { + _, size := decodeRuneAt(s[pos:]) + return pos + size +} + +func countRunes(s string) int { + n := 0 + for i := 0; i < len(s); { + _, size := decodeRuneAt(s[i:]) + i += size + n++ + } + return n +} + +// runeToByte returns the byte offset of the n-th rune start in s (n may be +// the rune count, yielding len(s)). +func runeToByte(s string, n int) int { + pos := 0 + for i := 0; i < n && pos < len(s); i++ { + _, size := decodeRuneAt(s[pos:]) + pos += size + } + return pos +} + +func randomFuzzText(rng *rand.Rand, min, max int) string { + n := rng.Intn(max-min+1) + min + sb := strings.Builder{} + for i := 0; i < n; i++ { + switch rng.Intn(10) { + case 0: + sb.WriteString("中文") + case 1: + sb.WriteString("é😀") + case 2: + sb.WriteByte('\n') + default: + sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))]) + sb.WriteByte(' ') + } + } + return sb.String() +} + +// freshLineOffsets computes the ground-truth line-start offsets for s. +func freshLineOffsets(s string) []int32 { + off := []int32{0} + for i, b := range s { + if b == '\n' { + off = append(off, int32(i+1)) + } + } + return off +}