Pad/internal/editor/state_api_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

203 lines
5.6 KiB
Go

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
}