Pad/internal/editor/line_index_fuzz_test.go
Greg Pomerantz 5205906de4 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.
2026-08-17 12:43:17 -04:00

220 lines
6.8 KiB
Go

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
}