Data-corruption test suite: differential fuzz + atomicity contract
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.
This commit is contained in:
parent
b24aa26446
commit
5205906de4
|
|
@ -41,11 +41,13 @@ const (
|
|||
// therefore no stale-disk re-read (the old fixed-slot model could re-read a
|
||||
// shifted tail chunk from disk and clobber in-memory edits).
|
||||
//
|
||||
// Chunks grow/shrink with edits; Insert splits any chunk that grows past
|
||||
// twice the target size (see Insert), so the per-edit copy cost stays bounded
|
||||
// by O(chunkSize) even under sustained typing at one spot. Shrunken (even
|
||||
// empty) chunks are left in place: the chunk count never grows with deletes,
|
||||
// all readers walk actual lengths, and removing chunks would be pure churn.
|
||||
// Chunks grow/shrink with edits; Insert re-chunks any result that grows past
|
||||
// twice the target size into pieces of at most chunkSize (see Insert), so
|
||||
// after EVERY edit no chunk exceeds 2*chunkSize and the per-edit copy cost
|
||||
// stays bounded by O(chunkSize) even under sustained typing at one spot or a
|
||||
// large paste. Shrunken (even empty) chunks are left in place: the chunk
|
||||
// count never grows with deletes, all readers walk actual lengths, and
|
||||
// removing chunks would be pure churn.
|
||||
type ChunkedBuffer struct {
|
||||
filename string
|
||||
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
|
||||
|
|
@ -325,14 +327,15 @@ func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
|
|||
}
|
||||
|
||||
// Insert inserts text at byte position pos, splicing only the affected
|
||||
// chunk. If the result grows past twice the target chunk size (sustained
|
||||
// typing at one spot, or a large paste), the chunk is split in half so that
|
||||
// chunks stay O(chunkSize) and every future edit remains a bounded
|
||||
// O(chunkSize) copy. The split cut is an arbitrary byte offset, like the
|
||||
// chunk boundaries created by SetContent: chunk boundaries may fall inside
|
||||
// multi-byte sequences, which is fine because every reader reassembles whole
|
||||
// windows from chunk bytes (windows are always line-aligned, i.e. on rune
|
||||
// boundaries).
|
||||
// chunk. If the spliced result grows past twice the target chunk size
|
||||
// (sustained typing at one spot, or a large paste), it is re-chunked into
|
||||
// pieces of at most chunkSize, so the invariant "no chunk exceeds 2*chunkSize
|
||||
// after any edit" holds universally (a single halving would leave chunks up
|
||||
// to ~P/2 for a paste of size P). Piece boundaries are arbitrary byte
|
||||
// offsets, like the chunk boundaries created by SetContent: chunk boundaries
|
||||
// may fall inside multi-byte sequences, which is fine because every reader
|
||||
// reassembles whole windows from chunk bytes (windows are always
|
||||
// line-aligned, i.e. on rune boundaries).
|
||||
func (cb *ChunkedBuffer) Insert(pos int, text string) {
|
||||
if len(text) == 0 {
|
||||
return
|
||||
|
|
@ -345,37 +348,41 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) {
|
|||
pos = 0
|
||||
}
|
||||
idx, local := cb.chunkForPos(pos)
|
||||
var newChunk []byte
|
||||
if idx < 0 {
|
||||
// Empty buffer: chunk the new text directly so a large first paste
|
||||
// does not create one oversized chunk.
|
||||
cb.chunks = nil
|
||||
for start := 0; start < len(text); start += cb.chunkSize {
|
||||
end := start + cb.chunkSize
|
||||
if end > len(text) {
|
||||
end = len(text)
|
||||
}
|
||||
cb.chunks = append(cb.chunks, []byte(text[start:end]))
|
||||
}
|
||||
cb.fileLen = int64(len(text))
|
||||
cb.markDirtyChunk(0)
|
||||
return
|
||||
// Empty buffer: the spliced result is just the new text.
|
||||
newChunk = []byte(text)
|
||||
} else {
|
||||
chunk := cb.chunks[idx]
|
||||
newChunk = make([]byte, 0, len(chunk)+len(text))
|
||||
newChunk = append(newChunk, chunk[:local]...)
|
||||
newChunk = append(newChunk, text...)
|
||||
newChunk = append(newChunk, chunk[local:]...)
|
||||
}
|
||||
chunk := cb.chunks[idx]
|
||||
newChunk := make([]byte, 0, len(chunk)+len(text))
|
||||
newChunk = append(newChunk, chunk[:local]...)
|
||||
newChunk = append(newChunk, text...)
|
||||
newChunk = append(newChunk, chunk[local:]...)
|
||||
if len(newChunk) > 2*cb.chunkSize {
|
||||
// Split in half and insert the second half after idx.
|
||||
cut := len(newChunk) / 2
|
||||
second := make([]byte, len(newChunk)-cut)
|
||||
copy(second, newChunk[cut:])
|
||||
newChunk = newChunk[:cut]
|
||||
cb.chunks = append(cb.chunks, nil)
|
||||
copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove)
|
||||
cb.chunks[idx+1] = second
|
||||
// Re-chunk the oversized result in place of the affected chunk (or as
|
||||
// the whole buffer when it was empty) so every piece is <= chunkSize;
|
||||
// this keeps "no chunk exceeds 2*chunkSize" true after ANY edit,
|
||||
// including large pastes.
|
||||
out := make([][]byte, 0, len(cb.chunks)+len(newChunk)/cb.chunkSize)
|
||||
if idx >= 0 {
|
||||
out = append(out, cb.chunks[:idx]...)
|
||||
}
|
||||
for start := 0; start < len(newChunk); start += cb.chunkSize {
|
||||
end := min(start+cb.chunkSize, len(newChunk))
|
||||
piece := make([]byte, end-start)
|
||||
copy(piece, newChunk[start:end])
|
||||
out = append(out, piece)
|
||||
}
|
||||
if idx >= 0 {
|
||||
out = append(out, cb.chunks[idx+1:]...)
|
||||
}
|
||||
cb.chunks = out
|
||||
} else if idx < 0 {
|
||||
cb.chunks = [][]byte{newChunk}
|
||||
} else {
|
||||
cb.chunks[idx] = newChunk
|
||||
}
|
||||
cb.chunks[idx] = newChunk
|
||||
cb.fileLen += int64(len(text))
|
||||
cb.markDirtyChunk(idx)
|
||||
}
|
||||
|
|
|
|||
341
internal/editor/chunked_buffer_fuzz_test.go
Normal file
341
internal/editor/chunked_buffer_fuzz_test.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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) }
|
||||
219
internal/editor/line_index_fuzz_test.go
Normal file
219
internal/editor/line_index_fuzz_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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
|
||||
}
|
||||
202
internal/editor/state_api_fuzz_test.go
Normal file
202
internal/editor/state_api_fuzz_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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
|
||||
}
|
||||
198
internal/io/pool/real/filesystem_test.go
Normal file
198
internal/io/pool/real/filesystem_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package real
|
||||
|
||||
// Data-integrity tests for the persistence layer's atomicity contract. The
|
||||
// editor's corruption protection against crashes rests on WriteFileAtomic's
|
||||
// write-sibling-temp-then-rename scheme; these tests pin the observable
|
||||
// consequences of that contract:
|
||||
//
|
||||
// - a written file is byte-exact (no truncation/padding at any size),
|
||||
// - a concurrent reader NEVER observes a torn file: every read returns
|
||||
// exactly one of the known complete payloads (the "crash/kill mid-write
|
||||
// leaves a complete snapshot" property, without needing a real crash),
|
||||
// - a FAILED write leaves the original file byte-identical,
|
||||
// - a stale leftover temp file is consumed by the next successful write.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func randomPayload(t *testing.T, seed int64, size int) []byte {
|
||||
t.Helper()
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
b := make([]byte, size)
|
||||
rng.Read(b)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestWriteFileAtomic_RoundTripExact(t *testing.T) {
|
||||
for _, size := range []int{0, 1, 2, 65537, 5 * 1024 * 1024} {
|
||||
d := t.TempDir()
|
||||
fs := NewRealFileSystem(d)
|
||||
data := randomPayload(t, int64(size), size)
|
||||
if err := fs.WriteFileAtomic("/rt.txt", data); err != nil {
|
||||
t.Fatalf("size %d: write: %v", size, err)
|
||||
}
|
||||
got, err := fs.ReadFile("/rt.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("size %d: read: %v", size, err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("size %d: round trip mismatch (got %d bytes)", size, len(got))
|
||||
}
|
||||
// Overwrite with different content of the same size.
|
||||
data2 := randomPayload(t, int64(size)+1, size)
|
||||
if err := fs.WriteFileAtomic("/rt.txt", data2); err != nil {
|
||||
t.Fatalf("size %d: overwrite: %v", size, err)
|
||||
}
|
||||
got, err = fs.ReadFile("/rt.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("size %d: read after overwrite: %v", size, err)
|
||||
}
|
||||
if !bytes.Equal(got, data2) {
|
||||
t.Fatalf("size %d: overwrite mismatch", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFileAtomic_ConcurrentReader_NeverTorn hammers the file with two
|
||||
// alternating 2 MB payloads while a reader spins; every single read must
|
||||
// return exactly one of the two payloads in full. Any torn read (a mix, a
|
||||
// truncation, a padding) is a data-corruption failure.
|
||||
func TestWriteFileAtomic_ConcurrentReader_NeverTorn(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
fs := NewRealFileSystem(d)
|
||||
const size = 2 * 1024 * 1024
|
||||
payloadA := randomPayload(t, 1, size)
|
||||
payloadB := randomPayload(t, 2, size)
|
||||
|
||||
if err := fs.WriteFileAtomic("/torn.txt", payloadA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
reads atomic.Int64
|
||||
torn atomic.Int64
|
||||
tornDetail string // written only by the reader, read after wg.Wait()
|
||||
)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
data, err := fs.ReadFile("/torn.txt")
|
||||
if err != nil {
|
||||
continue // transient (rename boundary); next read decides
|
||||
}
|
||||
reads.Add(1)
|
||||
if !bytes.Equal(data, payloadA) && !bytes.Equal(data, payloadB) {
|
||||
torn.Store(1)
|
||||
at := 0
|
||||
ref := payloadA
|
||||
if len(data) < len(ref) {
|
||||
ref = data
|
||||
}
|
||||
for at < len(data) && at < len(ref) && data[at] == ref[at] {
|
||||
at++
|
||||
}
|
||||
tornDetail = "first diff at " + itoa(at) + " (read " + itoa(len(data)) + " bytes, want " + itoa(size) + ")"
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
const iters = 150
|
||||
for i := 0; i < iters; i++ {
|
||||
payload := payloadA
|
||||
if i%2 == 1 {
|
||||
payload = payloadB
|
||||
}
|
||||
if err := fs.WriteFileAtomic("/torn.txt", payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if torn.Load() == 1 {
|
||||
t.Fatalf("torn read observed: %s", tornDetail) // safe: reader exited (wg.Wait)
|
||||
}
|
||||
if n := reads.Load(); n < 20 {
|
||||
t.Fatalf("only %d concurrent reads happened; test not meaningful", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFileAtomic_FailedWriteLeavesOriginalIntact verifies the crash
|
||||
// property directly: when the write cannot complete (read-only directory),
|
||||
// the original file is untouched, byte for byte.
|
||||
func TestWriteFileAtomic_FailedWriteLeavesOriginalIntact(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root: permission bits are bypassed")
|
||||
}
|
||||
d := t.TempDir()
|
||||
fs := NewRealFileSystem(d)
|
||||
original := []byte("original content that must survive\n")
|
||||
if err := fs.WriteFileAtomic("/f.txt", original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(d, 0o755) }()
|
||||
|
||||
if err := fs.WriteFileAtomic("/f.txt", []byte("new content")); err == nil {
|
||||
t.Fatal("expected write to fail in read-only directory")
|
||||
}
|
||||
got, err := fs.ReadFile("/f.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, original) {
|
||||
t.Fatalf("failed write modified the original file: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFileAtomic_StaleTempFileIsConsumed verifies a crash-leftover temp
|
||||
// file (garbage from an interrupted write) does not poison the next
|
||||
// successful write and does not survive it.
|
||||
func TestWriteFileAtomic_StaleTempFileIsConsumed(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
fs := NewRealFileSystem(d)
|
||||
if err := os.WriteFile(filepath.Join(d, ".f.txt.tmp"), []byte("stale garbage from a crashed write"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := []byte("fresh content")
|
||||
if err := fs.WriteFileAtomic("/f.txt", fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := fs.ReadFile("/f.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, fresh) {
|
||||
t.Fatalf("target file != fresh content: %q", got)
|
||||
}
|
||||
entries, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == ".f.txt.tmp" {
|
||||
t.Fatal("stale temp file was not consumed by the successful write")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
395
internal/test/e2e/real_file_fuzz_test.go
Normal file
395
internal/test/e2e/real_file_fuzz_test.go
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
package e2e_test
|
||||
|
||||
// End-to-end data-corruption fuzz against the REAL filesystem: random edit
|
||||
// sequences (through the production edit entry points, including IME replace
|
||||
// ranges relative to the visible window) are mirrored on a shadow model;
|
||||
// after every batch the on-disk file must equal the model byte-for-byte
|
||||
// (forced via Flush), and the visible IME window must equal the model's slice
|
||||
// at its recorded start (a window/content desync is what made IME edits land
|
||||
// in the wrong place historically — Phase 3). Finally a SECOND logic instance
|
||||
// (simulating an app restart) reopens the file and must see exactly the
|
||||
// model, with a consistent line index — proving the full
|
||||
// edit -> persist -> reload -> re-render data path is lossless.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/editor"
|
||||
"pad/internal/io/pool/real"
|
||||
"pad/internal/test/e2e"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
var fuzzWords = []string{
|
||||
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog",
|
||||
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot",
|
||||
}
|
||||
|
||||
// buildFuzzFileContent builds a deterministic multi-line document mixing
|
||||
// ASCII and multi-byte content.
|
||||
func buildFuzzFileContent(seed int64, nLines int) string {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
var sb strings.Builder
|
||||
for i := 0; i < nLines; i++ {
|
||||
sb.WriteString(fmt.Sprintf("L%03d ", i))
|
||||
n := rng.Intn(8) + 3
|
||||
for w := 0; w < n; w++ {
|
||||
sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))])
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
switch i % 17 {
|
||||
case 0:
|
||||
sb.WriteString("中文内容 ")
|
||||
case 1:
|
||||
sb.WriteString("héllo wörld ")
|
||||
case 2:
|
||||
sb.WriteString("😀 emoji ")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// loadRealFile opens /name in h and waits until the load is fully applied
|
||||
// (content + line index), the same air-tight predicate the other real-file
|
||||
// tests use.
|
||||
func loadRealFile(t *testing.T, h *e2e.Harness, name string) {
|
||||
t.Helper()
|
||||
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
|
||||
t.Fatalf("GoToBrowser: %v", err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil {
|
||||
t.Fatalf("OpenFile: %v", err)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
v, err := h.Inspect(func(st *editor.State) any {
|
||||
cb := st.Editor.ChunkedBuffer
|
||||
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
|
||||
return false
|
||||
}
|
||||
full, err := cb.FullContent()
|
||||
return err == nil && int64(len(full)) == cb.FileLen() &&
|
||||
cb.LineIndex.Size == cb.FileLen()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
}
|
||||
if v.(bool) {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for real file to load")
|
||||
}
|
||||
|
||||
// compactMismatch is a short failure message for two differing strings.
|
||||
func compactMismatch(got, want string) string {
|
||||
at := 0
|
||||
for at < len(got) && at < len(want) && got[at] == want[at] {
|
||||
at++
|
||||
}
|
||||
return fmt.Sprintf("first diff at byte %d (got %d bytes, want %d)\ngot [%d:%d]=%q\nwant [%d:%d]=%q",
|
||||
at, len(got), len(want),
|
||||
lo(at, 40), hi(at, 40), seg(got, at, 40),
|
||||
lo(at, 40), hi(at, 40), seg(want, at, 40))
|
||||
}
|
||||
|
||||
func TestRealFile_Fuzz_EditSaveReload(t *testing.T) {
|
||||
const (
|
||||
name = "fuzz.txt"
|
||||
batches = 15
|
||||
opsPer = 6
|
||||
)
|
||||
content := buildFuzzFileContent(11, 400) // ~15 KB
|
||||
dir := t.TempDir()
|
||||
diskPath := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
|
||||
h.Run()
|
||||
defer h.Cleanup()
|
||||
loadRealFile(t, h, name)
|
||||
|
||||
model := content
|
||||
rng := rand.New(rand.NewSource(77))
|
||||
|
||||
for batch := 0; batch < batches; batch++ {
|
||||
for j := 0; j < opsPer; j++ {
|
||||
switch rng.Intn(100) {
|
||||
case 0: // 30%: insert at a random rune-aligned position
|
||||
pos := randomRunePos(rng, model)
|
||||
text := randomFuzzText(rng, 1, 24)
|
||||
if err := h.WithState(func(st *editor.State) {
|
||||
st.Editor.CursorPosition = pos
|
||||
editor.HandleInsert(text)
|
||||
}); err != nil {
|
||||
t.Fatalf("batch %d op %d: insert: %v", batch, j, err)
|
||||
}
|
||||
model = model[:pos] + text + model[pos:]
|
||||
|
||||
case 1: // 15%: backspace
|
||||
pos := randomRunePos(rng, model)
|
||||
if err := h.WithState(func(st *editor.State) {
|
||||
st.Editor.CursorPosition = pos
|
||||
editor.HandleBackspace()
|
||||
}); err != nil {
|
||||
t.Fatalf("batch %d op %d: backspace: %v", batch, j, err)
|
||||
}
|
||||
if pos > 0 {
|
||||
model = model[:modelRuneBack(model, pos)] + model[pos:]
|
||||
}
|
||||
|
||||
case 2: // 15%: delete
|
||||
pos := randomRunePos(rng, model)
|
||||
if err := h.WithState(func(st *editor.State) {
|
||||
st.Editor.CursorPosition = pos
|
||||
editor.HandleDelete()
|
||||
}); err != nil {
|
||||
t.Fatalf("batch %d op %d: delete: %v", batch, j, err)
|
||||
}
|
||||
if pos < len(model) {
|
||||
model = model[:pos] + model[modelRuneNext(model, pos):]
|
||||
}
|
||||
|
||||
default: // 40%: IME replace, WINDOW-relative rune range (the real
|
||||
// contract: the IME only knows the visible snippet).
|
||||
// Force a fresh layout frame first: WithState edits do not
|
||||
// emit frames, so the window would otherwise be stale.
|
||||
fb := h.FrameCount()
|
||||
h.SendInput([]ui.InputEvent{})
|
||||
if _, ferr := h.WaitForFrameCount(fb+1, 5*time.Second); ferr != nil {
|
||||
t.Fatalf("batch %d op %d: no frame: %v", batch, j, ferr)
|
||||
}
|
||||
type win struct {
|
||||
Start int
|
||||
Text string
|
||||
}
|
||||
raw, err := h.Inspect(func(st *editor.State) any {
|
||||
return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch %d op %d: inspect: %v", batch, j, err)
|
||||
}
|
||||
w := raw.(win)
|
||||
if w.Text == "" {
|
||||
t.Fatalf("batch %d op %d: IME window empty after load+frames", batch, j)
|
||||
}
|
||||
// Window consistency: the snippet must be exactly the model's
|
||||
// slice at the recorded start.
|
||||
if w.Start < 0 || w.Start+len(w.Text) > len(model) ||
|
||||
w.Text != model[w.Start:w.Start+len(w.Text)] {
|
||||
t.Fatalf("batch %d op %d: IME window desync: start=%d len=%d; %s",
|
||||
batch, j, w.Start, len(w.Text),
|
||||
compactMismatch(w.Text, model[w.Start:min(len(model), w.Start+len(w.Text))]))
|
||||
}
|
||||
wr := countRunes(w.Text)
|
||||
ra := rng.Intn(wr + 1)
|
||||
rb := ra + rng.Intn(wr-ra+1)
|
||||
text := randomFuzzText(rng, 0, 12)
|
||||
if err := h.WithState(func(st *editor.State) {
|
||||
editor.HandleReplaceRange(ra, rb, text)
|
||||
}); err != nil {
|
||||
t.Fatalf("batch %d op %d: replace: %v", batch, j, err)
|
||||
}
|
||||
bs := w.Start + runeToByte(w.Text, ra)
|
||||
be := w.Start + runeToByte(w.Text, rb)
|
||||
model = model[:bs] + text + model[be:]
|
||||
}
|
||||
}
|
||||
|
||||
// Force a fresh layout frame so IMEWindowText reflects the new model,
|
||||
// then verify the window once per batch.
|
||||
before := h.FrameCount()
|
||||
h.SendInput([]ui.InputEvent{})
|
||||
if _, err := h.WaitForFrameCount(before+1, 5*time.Second); err != nil {
|
||||
t.Fatalf("batch %d: no frame after input: %v", batch, err)
|
||||
}
|
||||
w, err := h.Inspect(func(st *editor.State) any {
|
||||
return struct {
|
||||
Start int
|
||||
Text string
|
||||
}{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch %d: inspect: %v", batch, err)
|
||||
}
|
||||
ws := w.(struct {
|
||||
Start int
|
||||
Text string
|
||||
})
|
||||
if ws.Text != "" && (ws.Start < 0 || ws.Start+len(ws.Text) > len(model) ||
|
||||
ws.Text != model[ws.Start:ws.Start+len(ws.Text)]) {
|
||||
t.Fatalf("batch %d: window desync after edits: start=%d len=%d", batch, ws.Start, len(ws.Text))
|
||||
}
|
||||
|
||||
// Persistence: forced flush, then the on-disk file must be EXACTLY
|
||||
// the model (no lost bytes, no duplication, no reorder).
|
||||
if err := h.Flush(); err != nil {
|
||||
t.Fatalf("batch %d: flush: %v", batch, err)
|
||||
}
|
||||
if disk := readDisk(t, diskPath); disk != model {
|
||||
t.Fatalf("batch %d: disk != model: %s", batch, compactMismatch(disk, model))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Restart simulation: a fresh logic instance (new process state) ---
|
||||
h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
|
||||
h2.Run()
|
||||
defer h2.Cleanup()
|
||||
loadRealFile(t, h2, name)
|
||||
|
||||
got, err := h2.FullContent()
|
||||
if err != nil {
|
||||
t.Fatalf("restart FullContent: %v", err)
|
||||
}
|
||||
if got != model {
|
||||
t.Fatalf("after restart: buffer != model: %s", compactMismatch(got, model))
|
||||
}
|
||||
if disk := readDisk(t, diskPath); disk != model {
|
||||
t.Fatalf("after restart: disk != model: %s", compactMismatch(disk, model))
|
||||
}
|
||||
|
||||
// Line index after reload must match a fresh recomputation.
|
||||
diag, err := h2.Inspect(func(st *editor.State) any {
|
||||
cb := st.Editor.ChunkedBuffer
|
||||
li := cb.LineIndex
|
||||
if li == nil {
|
||||
return "nil index"
|
||||
}
|
||||
want := freshLineOffsets(model)
|
||||
if len(li.Offsets) != len(want) || li.Size != int64(len(model)) {
|
||||
return fmt.Sprintf("size=%d lines=%d, want size=%d lines=%d", li.Size, len(li.Offsets), len(model), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if li.Offsets[i] != want[i] {
|
||||
return fmt.Sprintf("offset %d: got %d want %d", i, li.Offsets[i], want[i])
|
||||
}
|
||||
}
|
||||
return "ok"
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("restart inspect: %v", err)
|
||||
}
|
||||
if diag.(string) != "ok" {
|
||||
t.Fatalf("after restart: line index inconsistent: %s", diag)
|
||||
}
|
||||
|
||||
// No temp files left behind by the atomic writes.
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("stray temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// randomRunePos returns a random rune-start byte offset in s.
|
||||
func randomRunePos(rng *rand.Rand, s string) int {
|
||||
runeStarts := []int{0}
|
||||
for i := 0; i < len(s); {
|
||||
_, size := decodeRuneAt(s[i:])
|
||||
i += size
|
||||
runeStarts = append(runeStarts, i)
|
||||
}
|
||||
return runeStarts[rng.Intn(len(runeStarts))]
|
||||
}
|
||||
|
||||
func decodeRuneAt(s string) (rune, int) {
|
||||
if len(s) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
c := s[0]
|
||||
switch {
|
||||
case c < 0x80:
|
||||
return rune(c), 1
|
||||
case c < 0xC0:
|
||||
// Invalid lead (should not happen with valid content); treat as 1
|
||||
// byte so the fuzzer never advances past it in a loop.
|
||||
return rune(c), 1
|
||||
case c < 0xE0:
|
||||
return rune(c), 2
|
||||
case c < 0xF0:
|
||||
return rune(c), 3
|
||||
default:
|
||||
return rune(c), 4
|
||||
}
|
||||
}
|
||||
|
||||
// modelRuneBack returns the byte offset of the start of the rune ending at
|
||||
// pos (pos must be a rune start > 0).
|
||||
func modelRuneBack(s string, pos int) int {
|
||||
i := pos - 1
|
||||
for i > 0 && s[i] >= 0x80 && s[i] < 0xC0 {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// modelRuneNext returns the byte offset after the rune starting at pos.
|
||||
func modelRuneNext(s string, pos int) int {
|
||||
_, size := decodeRuneAt(s[pos:])
|
||||
return pos + size
|
||||
}
|
||||
|
||||
func countRunes(s string) int {
|
||||
n := 0
|
||||
for i := 0; i < len(s); {
|
||||
_, size := decodeRuneAt(s[i:])
|
||||
i += size
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runeToByte returns the byte offset of the n-th rune start in s (n may be
|
||||
// the rune count, yielding len(s)).
|
||||
func runeToByte(s string, n int) int {
|
||||
pos := 0
|
||||
for i := 0; i < n && pos < len(s); i++ {
|
||||
_, size := decodeRuneAt(s[pos:])
|
||||
pos += size
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func randomFuzzText(rng *rand.Rand, min, max int) string {
|
||||
n := rng.Intn(max-min+1) + min
|
||||
sb := strings.Builder{}
|
||||
for i := 0; i < n; i++ {
|
||||
switch rng.Intn(10) {
|
||||
case 0:
|
||||
sb.WriteString("中文")
|
||||
case 1:
|
||||
sb.WriteString("é😀")
|
||||
case 2:
|
||||
sb.WriteByte('\n')
|
||||
default:
|
||||
sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))])
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// freshLineOffsets computes the ground-truth line-start offsets for s.
|
||||
func freshLineOffsets(s string) []int32 {
|
||||
off := []int32{0}
|
||||
for i, b := range s {
|
||||
if b == '\n' {
|
||||
off = append(off, int32(i+1))
|
||||
}
|
||||
}
|
||||
return off
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user