mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
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) 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.
85 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|