Files
pad/internal/diff/bug3839_no_panic_test.go
T
xarmian 09ecc5e02f fix(diff): version writers never diff text that is not valid UTF-8 (BUG-3839) (#2200)
* fix(diff): version writers never diff text that is not valid UTF-8 (BUG-3839)

go-diff works on runes and slices its input at byte offsets derived from
them, so text that is not valid UTF-8 near an edit either panics in
CreatePatch (slice bounds out of range) or builds a patch that does not
reproduce the old text: "hello world" -> "hello \xff world" reverse-applied
to "hello d", with no error. Any invalid byte does it, lone \xff and \x80 as
well as truncated sequences.

diff.ReversePatch returns (patch, ok), false when either side is not valid
UTF-8, and every version writer stores the full body in that case: the item
edit and the throttled rebase (items.go), the import's attachment remap
(attachments.go) and the legacy document edit (documents.go). The full body
is stored losslessly on SQLite; Postgres refuses invalid UTF-8 in the
column, so there the body write fails first and nothing is versioned. No
existing row is rewritten.

Requests cannot deliver such text (encoding/json coerces it, artifact import
rejects it, the materializer emits U+FFFD for lone surrogates), but SQLite
rows from older releases or direct edits can hold it, and an item like that
500ed on every save.

Tests: diff unit pins for both shapes, a census that production code calls
only ReversePatch, and a store pin per caller on SQLite and Postgres. With
the UTF-8 check removed every SQLite leg panics or stores a wrong version,
and the census names the reintroduced call.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(diff): LineCounts, FormatDiffSummary and Unified never panic on invalid UTF-8 (BUG-3839)

Pins the 200k-case fuzz measurement deterministically: the panic shapes as
seeds plus 20k generated pairs from the same seed-1 generator, with an
instrument showing the inputs reach go-diff's PatchApply panic.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(diff): ApplyPatch returns an error where go-diff would panic (BUG-3839)

ApplyPatch refuses a base or a decoded patch text that is not valid UTF-8,
and turns a go-diff PatchApply panic into an error. The second covers valid
text: a well-formed patch applied over a base it was not made from panics in
about 1.5% of measured pairs, and the version walk does that after it skips
a row. Every caller (the item and document walks, the throttled-edit rebase,
the attachment remap, the tie diagnostic) already treats the error as a
patch that failed, so the row reads as patch_failed instead of a 500.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(store): the remap pin asserts the row it leaves over an invalid body (BUG-3839)

With ApplyPatch refusing an invalid base, the remap cannot resolve a diff
row over the invalid current body: it remaps the patch text in place, and
the row reads as patch_failed. The pin asserts exactly that; it is red when
the base check is removed (the row is then re-diffed from a fuzzy body).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* refactor(diff): recover wraps only the go-diff call, and the remap pin is named for patch_failed (BUG-3839)

Lead rulings on BUG-3839 checkpoint 11.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(diff): the unchecked-patch census resolves the import name (BUG-3839)

Matches any selector on the file's local name for the diff package (alias,
method value), bare identifiers under a dot import, and skips web,
node_modules, .git and vendor only at the module root. A self-test runs the
matcher over each form.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(store): History past a broken row does not panic (BUG-3839)

The main-reproduction pin: a broken newest row, then an older row applied
over a base it was not made from. Red on main (d95f90850, slice bounds
panic), green here.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(diff): ApplyPatch refuses a result that is not valid UTF-8 (BUG-3839)

Over a base it was not made from, go-diff can succeed and cut a rune in
half: valid text in, "\x80" out (#2200 review r2, hunter). The rebase would
write that body (Postgres refuses it, failing the edit) and History would
show it. Pinned in the diff fuzz leg and in a mangled_result leg of the item
History, document History and throttled-rebase store tests, on both
backends; red when the check is removed. The doc comment now says an empty
patch returns its base unchecked, which is exact.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 10:24:24 -04:00

165 lines
5.4 KiB
Go

package diff
import (
"math/rand"
"strings"
"testing"
"unicode/utf8"
)
// BUG-3839: LineCounts, FormatDiffSummary and Unified are reached by text
// that may not be valid UTF-8 and are not guarded the way ReversePatch is. A
// 200k-case fuzz (seed 1, the generator below) found no panic in them while
// it found go-diff's PatchApply panicking at about 4%. This pins that
// measurement: the panic shapes as seeds, then the same generator, with an
// instrument showing the generator does reach the class.
var bug3839Pieces = []string{"\xff", "\x80", "\xc3", "\xe2\x82", "\xf0\x9f\x98", "\xed\xa0\x80", "é", "😀", "\n", "a", "b", " "}
// bug3839Pairs yields the seed shapes, then n generated pairs.
func bug3839Pairs(n int) [][2]string {
pairs := [][2]string{
{"b\xe2\x82b\xff", "b"},
{"\xff", ""},
{"", "\x80"},
{"\xe2\x82", "\xe2\x82\xac"},
{"\xf0\x9f\x98", "\xf0\x9f\x98\x80"},
{"\xed\xa0\x80", "a\xed\xa0\x80b"},
{"x\n\xff\ny\n", "x\n\xc3\ny\n"},
}
for _, tc := range bug3839Cases {
pairs = append(pairs, [2]string{tc.old, tc.new})
}
r := rand.New(rand.NewSource(1))
gen := func() string {
var b strings.Builder
for i, k := 0, r.Intn(12); i < k; i++ {
b.WriteString(bug3839Pieces[r.Intn(len(bug3839Pieces))])
}
return b.String()
}
for i := 0; i < n; i++ {
o, nw := gen(), gen()
if r.Intn(2) == 0 {
nw = o[:r.Intn(len(o)+1)] + gen() + o[r.Intn(len(o)+1):]
}
pairs = append(pairs, [2]string{o, nw})
}
return pairs
}
func bug3839Panics(f func()) (panicked bool) {
defer func() { panicked = recover() != nil }()
f()
return false
}
func TestBUG3839_DiffNeverPanicsOnInvalidUTF8(t *testing.T) {
pairs := bug3839Pairs(20000)
// Instrument: the same inputs reach go-diff's panic. PatchApply is what
// ApplyPatch wrapped before it refused invalid text.
reached := 0
for _, p := range pairs {
patches, err := dmp.PatchFromText("@@ -1,3 +1,4 @@\n a" + p[1] + "\n+b\n")
if err != nil {
continue
}
if bug3839Panics(func() { dmp.PatchApply(patches, p[0]) }) {
reached++
}
}
t.Logf("instrument: %d of %d inputs panic go-diff's PatchApply", reached, len(pairs))
if reached == 0 {
t.Fatal("instrument: no input reached go-diff's PatchApply panic, so a pass below shows nothing")
}
for _, p := range pairs {
for name, f := range map[string]func(){
"LineCounts": func() { LineCounts(p[0], p[1]) },
"FormatDiffSummary": func() { FormatDiffSummary(p[0], p[1]) },
"Unified": func() { Unified(p[0], p[1], 3) },
"ApplyPatch": func() { _, _ = ApplyPatch(p[0], "@@ -1,3 +1,4 @@\n a"+p[1]+"\n+b\n") },
} {
if bug3839Panics(f) {
t.Fatalf("%s(%q, %q) panicked", name, p[0], p[1])
}
}
}
}
// ApplyPatch refuses a base or a patch text that is not valid UTF-8 with an
// error, which the version walk reads as a patch that failed (BUG-3839).
func TestBUG3839_ApplyPatchRefusesInvalidUTF8(t *testing.T) {
valid := CreatePatch("ab", "abb")
for _, tc := range []struct{ name, base, patch string }{
{"base_fuzz_first", "b\xe2\x82b\xff", "@@ -1,3 +1,4 @@\n ab\n+b\n"},
{"base_lone_ff", "ab\xff", valid},
{"base_truncated", "ab\xe2\x82", valid},
{"patch_raw_byte", "ab", "@@ -1,2 +1,3 @@\n ab\n+\xff\n"},
{"patch_escaped_byte", "ab", "@@ -1,2 +1,3 @@\n ab\n+%FF\n"},
} {
t.Run(tc.name, func(t *testing.T) {
var err error
if bug3839Panics(func() { _, err = ApplyPatch(tc.base, tc.patch) }) {
t.Fatal("ApplyPatch panicked")
}
if err == nil {
t.Fatal("ApplyPatch accepted text that is not valid UTF-8")
}
})
}
if got, err := ApplyPatch("ab", valid); err != nil || got != "abb" {
t.Fatalf("valid patch: %q %v", got, err)
}
if got, err := ApplyPatch("a+b", CreatePatch("a+b", "a+b c%")); err != nil || got != "a+b c%" {
t.Fatalf("valid patch over + and %%: %q %v", got, err)
}
}
// go-diff panics on VALID text too, when a well-formed patch is applied over
// a base it was not made from and the base holds multi-byte runes. The version
// walk does that after skipping a row; ApplyPatch reports it as an error.
func TestBUG3839_ApplyPatchOverAnotherValidBaseNeverPanicsOrMangles(t *testing.T) {
r := rand.New(rand.NewSource(1))
pieces := []string{"é", "😀", "À", "\n", "a", "b", " ", "ab", "%", "+"}
gen := func() string {
var b strings.Builder
for i, k := 0, r.Intn(14); i < k; i++ {
b.WriteString(pieces[r.Intn(len(pieces))])
}
return b.String()
}
reached, mangled := 0, 0
for i := 0; i < 20000; i++ {
a, b, base := gen(), gen(), gen()
p := CreatePatch(a, b)
patches, err := dmp.PatchFromText(p)
if err != nil {
t.Fatal(err)
}
// Instrument: go-diff itself panics on this input.
if bug3839Panics(func() { dmp.PatchApply(patches, base) }) {
reached++
}
// Instrument: go-diff returns text that is not valid UTF-8.
bug3839Panics(func() {
if out, _ := dmp.PatchApply(patches, base); !utf8.ValidString(out) {
mangled++
}
})
var got string
var applyErr error
if bug3839Panics(func() { got, applyErr = ApplyPatch(base, p) }) {
t.Fatalf("ApplyPatch(%q, CreatePatch(%q, %q)) panicked", base, a, b)
}
if applyErr == nil && !utf8.ValidString(got) {
t.Fatalf("ApplyPatch(%q, CreatePatch(%q, %q)) returned %q, which is not valid UTF-8", base, a, b, got)
}
}
t.Logf("instrument: of 20000 inputs, %d panic go-diff's PatchApply and %d make it return invalid UTF-8", reached, mangled)
if reached == 0 || mangled == 0 {
t.Fatal("instrument: the inputs do not reach both go-diff failures, so a pass shows nothing")
}
}