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) }