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.
203 lines
5.6 KiB
Go
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
|
|
}
|