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 }