Files
silo-server/internal/settingscontract/strictjson.go
T
QuickandClaude Opus 5 dcedbbdd88 fix(settings): close the review findings in the validator and the theme cache
Four defects the existing tests did not reach.

The web theme resolver compared the server's value against the appearance
cache and fell back when they agreed, but the mirroring effect writes the
server's value into that same cache — so the comparison held on the first
render and stopped holding on the second, reverting an explicitly chosen
theme to the default. The server's value is this account's own stored
choice, so it now simply wins. The regression test re-renders rather than
asserting on the first paint, which is why the original one passed.

golangci-lint's exclusions.paths is a path regex, not a directory list, so
a bare `web` also excluded internal/jellycompat/web_component.go,
internal/webhooksync/, internal/notifications/webhook*.go and eleven other
non-test files that were being linted before. Anchored.

json.Number is a string kind, so `"1.5"` unmarshalled into it happily and
Float64 parsed the quoted digits: a numeric setting validated as a JSON
string and NormalizeValue stored the quoted form into jsonb. Rejected.

The lone-surrogate check ran only on the object branch, so a lone surrogate
in ui.custom_css decoded to U+FFFD on SQLite and was refused outright by
Postgres jsonb — the two backends disagreeing about whether the same value
could be stored. Hoisted to cover every type.

The strict language-tag validation this branch added is correct, but it
rejects what the shipped Android client sends; the companion fix is
silo-android 4aeb78b4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:04:31 +00:00

182 lines
5.4 KiB
Go

package settingscontract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"unicode/utf16"
)
// This file holds the two checks encoding/json will not make for us. Both cover
// input that Go accepts by silently changing it, which is the dangerous shape
// for a contract whose whole promise is that every peer agrees on the bytes.
// rejectQuotedNumber reports an error if a value declared numeric arrived as a
// JSON string.
//
// json.Number is a string kind, so encoding/json unmarshals `"1.5"` into it
// without complaint and Float64/Int64 then parse the quoted digits happily. The
// value would validate, and NormalizeValue would store the quoted form into
// jsonb — where every consumer reading it as a number, and every client
// comparing canonical bytes, disagrees with the row.
func rejectQuotedNumber(raw []byte) error {
if len(raw) > 0 && raw[0] == '"' {
return errors.New("a numeric setting must not be sent as a JSON string")
}
return nil
}
// maxJSONDepth bounds the recursion in the duplicate-key scan. Setting values
// arrive from the network, and the deepest schema the contract declares nests
// three levels, so this is far above anything legitimate and still cannot be
// driven into a stack overflow.
const maxJSONDepth = 64
var errJSONTooDeep = fmt.Errorf("JSON nests deeper than %d levels", maxJSONDepth)
// rejectDuplicateKeys reports an error if any object in the document repeats a
// property name.
//
// encoding/json and the schema validator both keep the last occurrence, so
// {"fontSize":"small","fontSize":"large"} validates and stores "large" with no
// complaint. Which one wins is a property of the parser rather than of the
// contract: a client generated against a different JSON library can disagree
// about what it just sent, and the manifest's canonical form has no way to
// represent the duplicate at all. RFC 8785 leaves this to the caller, so the
// caller rejects it.
func rejectDuplicateKeys(raw []byte) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
token, err := decoder.Token()
if err != nil {
// Malformed JSON is the caller's problem to report, with its own
// message. Nothing here is a duplicate key.
return nil //nolint:nilerr // parse errors surface from the real decode
}
return scanDuplicateKeys(decoder, token, 0)
}
func scanDuplicateKeys(decoder *json.Decoder, token json.Token, depth int) error {
delim, ok := token.(json.Delim)
if !ok {
return nil
}
if depth >= maxJSONDepth {
return errJSONTooDeep
}
switch delim {
case '{':
seen := map[string]struct{}{}
for decoder.More() {
nameToken, err := decoder.Token()
if err != nil {
return nil //nolint:nilerr // as above
}
name, ok := nameToken.(string)
if !ok {
return nil
}
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("object repeats the property %q", name)
}
seen[name] = struct{}{}
valueToken, err := decoder.Token()
if err != nil {
return nil //nolint:nilerr // as above
}
if err := scanDuplicateKeys(decoder, valueToken, depth+1); err != nil {
return err
}
}
case '[':
for decoder.More() {
itemToken, err := decoder.Token()
if err != nil {
return nil //nolint:nilerr // as above
}
if err := scanDuplicateKeys(decoder, itemToken, depth+1); err != nil {
return err
}
}
}
// Consume the closing delimiter so the caller resumes in the right place.
if _, err := decoder.Token(); err != nil {
return nil //nolint:nilerr // as above
}
return nil
}
// rejectLoneSurrogates reports an error if any string literal contains a \u
// escape for an unpaired UTF-16 surrogate.
//
// encoding/json replaces one with U+FFFD and reports success, so the server
// would generate canonical bytes and an ETag for an artifact a conforming
// client must refuse: RFC 8785 requires canonicalization to terminate here.
// Silently substituting a replacement character also means the value read back
// is not the value written, which no setting should ever do.
func rejectLoneSurrogates(raw []byte) error {
inString := false
for i := 0; i < len(raw); i++ {
switch {
case !inString:
if raw[i] == '"' {
inString = true
}
case raw[i] == '"':
inString = false
case raw[i] == '\\':
if i+1 >= len(raw) {
return errors.New("string ends in an incomplete escape")
}
if raw[i+1] != 'u' {
// Any other two-character escape; skip both bytes.
i++
continue
}
code, err := parseHex4(raw, i+2)
if err != nil {
return err
}
i += 5
if !utf16.IsSurrogate(rune(code)) {
continue
}
if code >= 0xDC00 {
return fmt.Errorf(
"\\u%04X is a trailing surrogate with no leading surrogate before it", code)
}
// A leading surrogate must be followed immediately by a trailing one.
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return fmt.Errorf(
"\\u%04X is a leading surrogate with no trailing surrogate after it", code)
}
low, err := parseHex4(raw, i+3)
if err != nil {
return err
}
if low < 0xDC00 || low > 0xDFFF {
return fmt.Errorf(
"\\u%04X is followed by \\u%04X, which is not a trailing surrogate", code, low)
}
i += 6
}
}
return nil
}
func parseHex4(raw []byte, at int) (uint64, error) {
if at+4 > len(raw) {
return 0, errors.New("incomplete \\u escape")
}
code, err := strconv.ParseUint(string(raw[at:at+4]), 16, 32)
if err != nil {
return 0, fmt.Errorf("malformed \\u escape %q", raw[at:at+4])
}
return code, nil
}