Files
pad/internal/store/items_revalidate.go
T
xarmian db8fb3aabf fix(store,server): an item write re-checks its fields under the seq lock when the schema it was validated against has moved (BUG-3407) (#1806)
* fix(store): an item write re-validates the keys it sets against the schema read under the workspace seq lock, so a schema change between the handler's validation and the lock no longer lets a forbidden value in (BUG-3407)

* fix(store): the fenced app write takes the workspace seq lock before reading the companion schema it validates against (BUG-3407, the app path)

* fix(store): a move re-validates its destination fields under the seq lock too (BUG-3407, codex r1)

* fix(store): the re-check judges reserved system metadata by no schema, as the handlers do, so a grandfathered declaration refuses nothing (BUG-3407, codex r2)

* refactor(store): drop a redundant reserved strip; UnmarshalItemFieldSchema already makes it, and the comment says why that decoder (BUG-3407)

* fix(apps): BUG-3407 becomes an optimistic schema compare: handlers pass the exact schema bytes they validated against, the store re-checks the set keys only when the schema under its lock differs, refusals answer 400; a census holds every field-writing handler to it (lead ruling, day 86)

* fix(store): with no ValidatedSchema the store does not decode the fields blob at all, so its own guards and contract are untouched for such callers (BUG-3407)

* fix(store): a stored fields blob that is not an object carries nothing, so a full write repairing it is compared as all-set rather than refused (BUG-3407, BUG-3163 repair path)

* fix(store,server): a supplied schema takes the seq lock and its movement is decided under the locks; the content-route and collab-snapshot paths answer the schema refusal with 400 (BUG-3407, codex r3)
2026-10-05 12:44:17 -04:00

111 lines
4.1 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"reflect"
"github.com/PerpetualSoftware/pad/internal/items"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3407: an item write is validated against its collection's schema in the
// handler, BEFORE the store takes the workspace seq lock. A schema change that
// commits in that window (an owner's UpdateCollection, an app upgrade's
// additive field) used to let the write store a value validated against the
// OLD schema: `color: "blue"` accepted as an undeclared key, then stored under
// a select that now allows only "red".
//
// Every schema writer takes the same workspace seq lock (UpdateCollection
// whenever the schema moves, for its relation reindex; the app upgrade in its
// provisioning transaction), so the schema read under the item write's own
// seq lock is the current one. The handler passes the exact schema bytes it
// validated against; the store compares them under the lock and re-validates
// only when they differ. An always-on re-check was tried first and refused:
// the store is not a validation layer, and internal flows write below the
// schema on purpose (lead ruling, day 86).
// revalidateIfSchemaMovedTx is the optimistic schema compare (lead ruling,
// day 86). validated is the schema, as the exact bytes read from the
// collection row, that the caller validated against; nil means the caller
// made no such claim and nothing is checked, which keeps the store's contract
// for internal flows that write below the schema on purpose. Otherwise the
// schema is read again here, under the workspace seq lock the caller holds,
// and compared byte for byte: unchanged, nothing to do; moved, the keys this
// write sets are re-validated against the new schema and a failure refuses
// the write before anything is written.
func (s *Store) revalidateIfSchemaMovedTx(tx *sql.Tx, collectionID string, validated *string, set map[string]any) error {
if validated == nil || len(set) == 0 {
return nil
}
var raw string
err := tx.QueryRow(s.q(`SELECT schema FROM collections WHERE id = ?`), collectionID).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return nil // a vanished collection is refused elsewhere, not here
}
if err != nil {
return fmt.Errorf("re-read collection schema under lock: %w", err)
}
if raw == *validated {
return nil
}
var schema models.CollectionSchema
// UnmarshalItemFieldSchema, not json.Unmarshal: it drops reserved
// declarations, so system metadata is judged by no schema, as on every
// handler path.
if err := models.UnmarshalItemFieldSchema([]byte(raw), &schema); err != nil {
return fmt.Errorf("decode collection schema under lock: %w", err)
}
// ValidatePartialFields normalizes empty relation lists in place.
cp := make(map[string]any, len(set))
for k, v := range set {
cp[k] = v
}
if err := items.ValidatePartialFields(cp, schema); err != nil {
return invalidf("the collection's schema changed while this write was in flight: %s", err.Error())
}
return nil
}
// decodeFieldsBlob decodes a stored or proposed fields blob for comparison.
func decodeFieldsBlob(blob string) (map[string]any, error) {
m := map[string]any{}
if blob == "" || blob == "{}" {
return m, nil
}
if err := models.DecodeJSONKeepingNumbers([]byte(blob), &m); err != nil {
return nil, err
}
return m, nil
}
// changedFieldKeys is the subset of next whose decoded value differs from
// prev: the keys a full `fields` write SETS, as opposed to carries. A key
// next removes is a deletion, marked with a nil value.
//
// A stored blob that is not a JSON object (a corrupt row a full write is
// repairing, BUG-3163) carries nothing: every key of next is set.
func changedFieldKeys(prevBlob, nextBlob string) (map[string]any, error) {
prev, err := decodeFieldsBlob(prevBlob)
if err != nil {
prev = map[string]any{}
}
next, err := decodeFieldsBlob(nextBlob)
if err != nil {
return nil, err
}
set := map[string]any{}
for k, v := range next {
if old, ok := prev[k]; !ok || !reflect.DeepEqual(old, v) {
set[k] = v
}
}
for k := range prev {
if _, ok := next[k]; !ok {
set[k] = nil
}
}
return set, nil
}