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.
220 lines
6.8 KiB
Go
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
|
|
}
|