Files
pad/internal/server/bug2812_scan_bench_test.go
T
xarmian 7376023ff2 fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) (#1550)
* fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812)

The body gate and the store's JSON-column guard decoded into
map[string]any and walked the tree. The tree is a lossy model: it keeps
one value per repeated key, so a NUL in a shadowed copy passed (and the
typed decode could merge it into storage), and a number no float64
holds made the decode fail, so the gate answered "no NUL" for a body the
typed decode then accepted. Walking that tree also cost about 2x on any
body that merely mentioned an escape.

- internal/jsonscan wraps github.com/go-json-experiment/json/jsontext
  (pinned v0.0.0-20260820222146-c27c302e5fc3, BSD-3, The Go Authors).
  It is the module's only importer; its doc comment names the exit to
  stdlib encoding/json/jsontext. A differential test pins its acceptance
  to encoding/json v1's, which found that jsontext refuses a lone
  surrogate v1 accepts; that case falls back to v1's unquote.
- scanRequestBody reads every body once, recording a NUL and a repeated
  request member separately and reading to the end, so malformed input
  still reports nothing. Repeats count in the request's own structure,
  and not in caller data under JSON-encoded keys (BUG-2896 collapses
  those). Names are case-folded where the destination is a struct:
  decodeJSONBytes reflects its destination's TOP-LEVEL type, so a struct
  folds its members, a slice of structs each element's, and a map
  (admin settings, admin user limits) none. Below the top level only an
  exact repeat counts. decodeJSONBytes and both bundle paths refuse
  either.
- textguard's document walk and artifactIsBindableText read tokens too,
  so every layer refuses the shadowed NUL together: the one
  textguard.KnownGaps case moves into Corpus.
- The store's suspect path stays, redundant but correct, by ruling; its
  deletion is its own unit. Its instruments now pin what is true: no
  refused value is a suspect, and the remaining suspects are the
  doubled-backslash class the destination accepts.
- The map-model walk survives only as the differential's oracle.

* fix(server): update vendorHash and the Postgres-only suspect instruments for the token scan (BUG-2812)

The Postgres-backed legs of the suspect path fired when the shared
predicate learned to see a NUL behind a repeated key: that value is now a
violation everywhere, and the store's own guard refuses it before the
destination cast. The preflight legs now assert that, the unmigrated-
table fixture uses a doubled-backslash literal (still a suspect), and
PostgreSQL's native refusal stays measured by
TestNativePostgresAgreesWithTheCorpus.

vendorHash is the value this PR's Nix job recomputed.
2026-09-25 21:48:30 -04:00

85 lines
2.2 KiB
Go

package server
import (
"bytes"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BenchmarkPerf2812 is the instrument behind BUG-2812's before/after table: the
// whole request decode (body scan plus typed decode, decodeJSONBytes) on a
// small typical PATCH body and on two 1.54 MiB shapes, each with and without
// the harmless doubled-backslash text that makes the scan look for NULs. Run
// it against two trees INTERLEAVED on a quiet box; a sequential pair on a
// loaded one measured noise larger than the difference.
func perfProse(trigger bool) []byte {
var b strings.Builder
b.WriteString(`{"title":"t","fields_patch":{"status":"open"},"content":"`)
para := strings.Repeat("lorem ipsum dolor sit amet ", 40)
for b.Len() < 1_540_000 {
b.WriteString(para)
b.WriteString(`\n`)
}
if trigger {
b.WriteString(`\\u0000`)
}
b.WriteString(`"}`)
return []byte(b.String())
}
func perfItems(trigger bool) []byte {
var b bytes.Buffer
b.WriteString(`{"items":[`)
for i := 0; b.Len() < 1_540_000; i++ {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(`{"title":"item","status":"open","priority":"low","tags":["a","b"],"fields":"{\"status\":\"open\",\"n\":1}","content":"short body"}`)
}
if trigger {
b.WriteString(`,{"title":"note \\u0000"}`)
}
b.WriteString(`]}`)
return b.Bytes()
}
func perfSmall(trigger bool) []byte {
c := "moving on"
if trigger {
c = `the \\u0000 escape`
}
return []byte(`{"title":"Fix the thing","fields_patch":{"status":"in-progress","priority":"high"},"comment":"` + c + `"}`)
}
func BenchmarkPerf2812(b *testing.B) {
type items struct {
Items []models.ItemCreate `json:"items"`
}
for _, trig := range []bool{false, true} {
tn := "plain"
if trig {
tn = "trigger"
}
for _, c := range []struct {
name string
body []byte
dst func() any
}{
{"small", perfSmall(trig), func() any { return &models.ItemUpdate{} }},
{"prose-1.54MiB", perfProse(trig), func() any { return &models.ItemUpdate{} }},
{"members-1.54MiB", perfItems(trig), func() any { return &items{} }},
} {
b.Run(c.name+"/"+tn, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := decodeJSONBytes(c.body, c.dst()); err != nil {
b.Fatal(err)
}
}
})
}
}
}