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.
342 lines
9.5 KiB
Go
342 lines
9.5 KiB
Go
package editor
|
|
|
|
// Differential fuzzing for ChunkedBuffer: every random edit is mirrored on a
|
|
// plain []byte shadow model, and the buffer's observable state (FileLen,
|
|
// FullContent, Content probes, chunk-size invariant, and in the rune-aligned
|
|
// variant UTF-8 validity + RuneIndexToByte) must match the model after EVERY
|
|
// operation. This is the data-corruption guard for the edit path: any chunk
|
|
// math bug (wrong splice, dropped/duplicated bytes, drifted prefix sums,
|
|
// broken re-chunking) surfaces as a mismatch at the exact failing operation.
|
|
|
|
import (
|
|
"math/rand"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// fuzzRunePool mixes 1-, 2-, 3- and 4-byte runes so random text crosses every
|
|
// UTF-8 width, plus the line-structure characters.
|
|
var fuzzRunePool = []rune{
|
|
'a', 'z', '0', '9', ' ', '\t', '.', ',', '!',
|
|
'\n', '\r',
|
|
'é', // 2 bytes
|
|
'中', '日', '本', // 3 bytes
|
|
'😀', // 4 bytes
|
|
}
|
|
|
|
// fuzzRuneString returns a random valid UTF-8 string of exactly n runes.
|
|
func fuzzRuneString(rng *rand.Rand, n int) string {
|
|
var sb strings.Builder
|
|
sb.Grow(n * 2)
|
|
for i := 0; i < n; i++ {
|
|
sb.WriteRune(fuzzRunePool[rng.Intn(len(fuzzRunePool))])
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// modelInsert returns m with text spliced in at pos (the shadow-model op).
|
|
func modelInsert(m []byte, pos int, text []byte) []byte {
|
|
out := make([]byte, 0, len(m)+len(text))
|
|
out = append(out, m[:pos]...)
|
|
out = append(out, text...)
|
|
return append(out, m[pos:]...)
|
|
}
|
|
|
|
// modelDelete returns m with [a, b) removed (the shadow-model op).
|
|
func modelDelete(m []byte, a, b int) []byte {
|
|
out := make([]byte, 0, len(m)-(b-a))
|
|
out = append(out, m[:a]...)
|
|
return append(out, m[b:]...)
|
|
}
|
|
|
|
// runeStartPositions returns the byte offset of every rune start in m (which
|
|
// must be valid UTF-8), ending with len(m). Used to pick rune-aligned
|
|
// positions for the rune-aligned fuzz variant.
|
|
func runeStartPositions(m []byte) []int {
|
|
positions := []int{0}
|
|
for pos := 0; pos < len(m); {
|
|
_, size := utf8.DecodeRune(m[pos:])
|
|
pos += size
|
|
positions = append(positions, pos)
|
|
}
|
|
return positions
|
|
}
|
|
|
|
// runeStartToByteOracle is an INDEPENDENT (utf8.DecodeRune based)
|
|
// implementation of "byte offset of the n-th rune", used as the oracle for
|
|
// ChunkedBuffer.RuneIndexToByte. Returns len(m) if n is past the end.
|
|
func runeStartToByteOracle(m []byte, n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
pos := 0
|
|
for i := 0; i < n && pos < len(m); i++ {
|
|
_, size := utf8.DecodeRune(m[pos:])
|
|
pos += size
|
|
}
|
|
if pos > len(m) {
|
|
return len(m)
|
|
}
|
|
return pos
|
|
}
|
|
|
|
// fuzzChunkSize returns a random insert size in bytes (byte variant) or runes
|
|
// (rune variant): mostly tiny (typing), sometimes large (paste), up to max.
|
|
func fuzzInsertSize(rng *rand.Rand, max int) int {
|
|
switch rng.Intn(100) {
|
|
case 0:
|
|
return 1
|
|
case 1:
|
|
return rng.Intn(4) + 1 // 1..4
|
|
case 2:
|
|
return rng.Intn(30) + 5 // 5..34
|
|
case 3:
|
|
return rng.Intn(170) + 35 // 35..204
|
|
default:
|
|
return rng.Intn(max) + 1 // 1..max (large paste)
|
|
}
|
|
}
|
|
|
|
// fuzzChunkedBuffer runs the differential fuzz with the given chunk size.
|
|
// runeAligned restricts every edit boundary to rune starts and inserted text
|
|
// to valid UTF-8, which keeps the model valid UTF-8 and enables the
|
|
// RuneIndexToByte oracle + validity checks; the byte variant allows arbitrary
|
|
// byte positions and bytes (the buffer is a byte structure; rune granularity
|
|
// is enforced by the state layer, covered in state_api_fuzz_test.go).
|
|
func fuzzChunkedBuffer(t *testing.T, chunkSize, ops, maxText int, runeAligned bool) {
|
|
t.Helper()
|
|
seed := int64(chunkSize) * 1000
|
|
if runeAligned {
|
|
seed++
|
|
}
|
|
rng := rand.New(rand.NewSource(seed))
|
|
cb := NewChunkedBuffer("/fuzz.txt", chunkSize, nil, "")
|
|
|
|
// Seed content.
|
|
var model []byte
|
|
if runeAligned {
|
|
model = []byte(fuzzRuneString(rng, rng.Intn(150)+1))
|
|
} else {
|
|
n := rng.Intn(150) + 1
|
|
model = make([]byte, n)
|
|
for i := range model {
|
|
model[i] = byte(rng.Intn(256))
|
|
}
|
|
}
|
|
cb.SetContent(model)
|
|
|
|
// The model cap keeps the per-op FullContent comparison cheap while the
|
|
// text size keeps hitting the re-chunking path for the chunk size.
|
|
modelCap := maxText * 2
|
|
if modelCap < 2048 {
|
|
modelCap = 2048
|
|
}
|
|
if modelCap > 512*1024 {
|
|
modelCap = 512 * 1024
|
|
}
|
|
|
|
compact := func(got string, i int) {
|
|
at := 0
|
|
for at < len(got) && at < len(model) && got[at] == model[at] {
|
|
at++
|
|
}
|
|
t.Fatalf("op %d: FullContent mismatch (got %d bytes, model %d bytes, first diff at %d)",
|
|
i, len(got), len(model), at)
|
|
}
|
|
|
|
for i := 0; i < ops; i++ {
|
|
// Keep the model bounded: trim back with a large delete if needed.
|
|
if len(model) > modelCap {
|
|
a := 0
|
|
b := len(model) / 2
|
|
if runeAligned {
|
|
pos := runeStartPositions(model)
|
|
a = pos[len(pos)/4]
|
|
b = pos[len(pos)/2]
|
|
}
|
|
cb.Delete(a, b-a)
|
|
model = modelDelete(model, a, b)
|
|
}
|
|
|
|
op := rng.Intn(10)
|
|
switch {
|
|
case op < 4: // insert
|
|
var pos int
|
|
var text string
|
|
if runeAligned {
|
|
p := runeStartPositions(model)
|
|
pos = p[rng.Intn(len(p))]
|
|
text = fuzzRuneString(rng, fuzzInsertSize(rng, maxText))
|
|
} else {
|
|
pos = rng.Intn(len(model) + 1)
|
|
text = string(makeRandomBytes(rng, fuzzInsertSize(rng, maxText)))
|
|
}
|
|
cb.Insert(pos, text)
|
|
model = modelInsert(model, pos, []byte(text))
|
|
|
|
case op < 7: // delete
|
|
if len(model) == 0 {
|
|
continue
|
|
}
|
|
var a, b int
|
|
if runeAligned {
|
|
p := runeStartPositions(model)
|
|
a = p[rng.Intn(len(p))]
|
|
b = p[rng.Intn(len(p))]
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
if a == b {
|
|
continue
|
|
}
|
|
} else {
|
|
a = rng.Intn(len(model) + 1)
|
|
b = a + rng.Intn(len(model)-a+1)
|
|
if a == b {
|
|
continue
|
|
}
|
|
}
|
|
cb.Delete(a, b-a)
|
|
model = modelDelete(model, a, b)
|
|
|
|
default: // replace = delete + insert, exactly as the production
|
|
// edit composition does (state.go HandleReplaceRange).
|
|
var a, b int
|
|
var text string
|
|
if runeAligned {
|
|
p := runeStartPositions(model)
|
|
a = p[rng.Intn(len(p))]
|
|
b = p[rng.Intn(len(p))]
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
text = fuzzRuneString(rng, fuzzInsertSize(rng, maxText))
|
|
} else {
|
|
a = rng.Intn(len(model) + 1)
|
|
b = a + rng.Intn(len(model)-a+1)
|
|
text = string(makeRandomBytes(rng, fuzzInsertSize(rng, maxText)))
|
|
}
|
|
if b > a {
|
|
cb.Delete(a, b-a)
|
|
model = modelDelete(model, a, b)
|
|
}
|
|
if text != "" {
|
|
cb.Insert(a, text)
|
|
model = modelInsert(model, a, []byte(text))
|
|
}
|
|
}
|
|
|
|
// --- Invariants after every op ---------------------------------
|
|
if cb.FileLen() != int64(len(model)) {
|
|
t.Fatalf("op %d: FileLen=%d, model=%d bytes", i, cb.FileLen(), len(model))
|
|
}
|
|
|
|
// Content probes: random spans + fixed edge spans that cross the
|
|
// first/last chunk boundaries.
|
|
for j := 0; j < 3; j++ {
|
|
a := rng.Intn(len(model) + 1)
|
|
b := a + rng.Intn(len(model)-a+1)
|
|
if got := cb.Content(a, b); got != string(model[a:b]) {
|
|
t.Fatalf("op %d: Content(%d,%d) mismatch (got %d bytes)", i, a, b, len(got))
|
|
}
|
|
}
|
|
edge := 3*chunkSize + 5
|
|
if edge > len(model) {
|
|
edge = len(model)
|
|
}
|
|
for _, span := range [][2]int{{0, 0}, {0, len(model)}, {len(model), len(model)}, {0, edge}, {len(model) - edge, len(model)}} {
|
|
if got := cb.Content(span[0], span[1]); got != string(model[span[0]:span[1]]) {
|
|
t.Fatalf("op %d: Content(%d,%d) edge mismatch", i, span[0], span[1])
|
|
}
|
|
}
|
|
|
|
// Full reconciliation (every op while small, sampled when large).
|
|
if len(model) <= 32*1024 || i%8 == 0 {
|
|
full, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("op %d: FullContent: %v", i, err)
|
|
}
|
|
if full != string(model) {
|
|
compact(full, i)
|
|
}
|
|
}
|
|
|
|
// Chunk-size invariant: holds after ANY edit, including large pastes.
|
|
assertMaxChunkSize(t, cb)
|
|
|
|
if runeAligned {
|
|
// Rune-aligned edits must never break UTF-8.
|
|
if !utf8.Valid(model) {
|
|
t.Fatalf("op %d: model is not valid UTF-8 after rune-aligned edit", i)
|
|
}
|
|
// RuneIndexToByte oracle (3 random rune indices per op).
|
|
nr := utf8.RuneCount(model)
|
|
for j := 0; j < 3; j++ {
|
|
n := rng.Intn(nr + 1)
|
|
if got, want := cb.RuneIndexToByte(n), runeStartToByteOracle(model, n); got != want {
|
|
t.Fatalf("op %d: RuneIndexToByte(%d)=%d, oracle=%d", i, n, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeRandomBytes(rng *rand.Rand, n int) []byte {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = byte(rng.Intn(256))
|
|
}
|
|
return b
|
|
}
|
|
|
|
// TestChunkedBuffer_Fuzz_ByteLevel runs arbitrary byte-level edits (positions
|
|
// and bytes may split UTF-8; the buffer is a byte structure) against the
|
|
// shadow model. The chunk-size sweep includes tiny sizes where every edit
|
|
// crosses chunk boundaries and re-chunking.
|
|
func TestChunkedBuffer_Fuzz_ByteLevel(t *testing.T) {
|
|
cases := []struct {
|
|
chunkSize int
|
|
ops int
|
|
maxText int
|
|
}{
|
|
{1, 2000, 64},
|
|
{3, 2000, 128},
|
|
{7, 2000, 256},
|
|
{64, 1500, 2048},
|
|
{256, 1200, 8192},
|
|
{64 * 1024, 500, 256 * 1024},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run("cs"+itoa(c.chunkSize), func(t *testing.T) {
|
|
fuzzChunkedBuffer(t, c.chunkSize, c.ops, c.maxText, false)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChunkedBuffer_Fuzz_RuneAligned runs rune-aligned edits with valid UTF-8
|
|
// text (the contract the state layer enforces in production) and additionally
|
|
// checks UTF-8 validity and the RuneIndexToByte oracle after every op.
|
|
func TestChunkedBuffer_Fuzz_RuneAligned(t *testing.T) {
|
|
cases := []struct {
|
|
chunkSize int
|
|
ops int
|
|
maxText int
|
|
}{
|
|
{1, 2000, 64},
|
|
{3, 2000, 128},
|
|
{7, 2000, 256},
|
|
{64, 1500, 2048},
|
|
{256, 1200, 8192},
|
|
{64 * 1024, 500, 256 * 1024},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run("cs"+itoa(c.chunkSize), func(t *testing.T) {
|
|
fuzzChunkedBuffer(t, c.chunkSize, c.ops, c.maxText, true)
|
|
})
|
|
}
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|