Files
silo-server/internal/settingscontract/validate.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

1017 lines
33 KiB
Go

package settingscontract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// Validate checks every invariant the manifest schema cannot express: that
// resolution orders are consistent with allowed scopes, that defaults satisfy
// their own value schemas, that revision tags are internally ordered, and that
// policy constraints are applicable to the type they are declared on.
//
// A failure here is a defect in the checked-in contract. It is surfaced by the
// contract tests, and by startup if it somehow ships.
func (m *Manifest) Validate(objectSchemas map[string]*jsonschema.Schema) error {
var errs []error
if m.APIVersion < 1 {
errs = append(errs, fmt.Errorf("api_version must be at least 1, got %d", m.APIVersion))
}
if m.Revision < 1 {
errs = append(errs, fmt.Errorf("revision must be at least 1, got %d", m.Revision))
}
if len(m.Definitions) == 0 {
errs = append(errs, errors.New("manifest declares no definitions"))
}
for name, optionSet := range m.OptionSets {
if err := optionSet.validate(name, m.Revision); err != nil {
errs = append(errs, err)
}
}
for i := range m.Definitions {
def := &m.Definitions[i]
if err := def.validate(m.Revision, objectSchemas); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", def.Key, err))
}
if err := def.validatePresentation(m.OptionSets); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", def.Key, err))
}
}
return errors.Join(errs...)
}
func (s OptionSet) validate(name string, manifestRevision int) error {
var errs []error
if s.Type != TypeLanguageTag {
errs = append(errs, fmt.Errorf(
"option set %q has unsupported type %q", name, s.Type))
}
if len(s.Options) == 0 {
errs = append(errs, fmt.Errorf("option set %q has no options", name))
}
seen := make(map[string]struct{}, len(s.Options))
for _, option := range s.Options {
if option.IntroducedIn < 1 || option.IntroducedIn > manifestRevision {
errs = append(errs, fmt.Errorf(
"option set %q value %q has introduced_in %d outside 1..%d",
name, option.Value, option.IntroducedIn, manifestRevision))
}
if s.Type == TypeLanguageTag {
normalized, ok := NormalizeLanguageTag(option.Value)
if !ok {
errs = append(errs, fmt.Errorf(
"option set %q value %q is not a language tag", name, option.Value))
} else if normalized != option.Value {
errs = append(errs, fmt.Errorf(
"option set %q value %q is not canonical; use %q",
name, option.Value, normalized))
}
}
if _, duplicate := seen[option.Value]; duplicate {
errs = append(errs, fmt.Errorf(
"option set %q repeats value %q", name, option.Value))
}
seen[option.Value] = struct{}{}
}
return errors.Join(errs...)
}
func (d *Definition) validatePresentation(optionSets map[string]OptionSet) error {
var errs []error
if d.SuggestedOptions != "" {
optionSet, ok := optionSets[d.SuggestedOptions]
if !ok {
errs = append(errs, fmt.Errorf(
"suggested_options references unknown option set %q", d.SuggestedOptions))
} else if optionSet.Type != d.ValueSchema.Type {
errs = append(errs, fmt.Errorf(
"suggested_options %q has type %q, want %q",
d.SuggestedOptions, optionSet.Type, d.ValueSchema.Type))
}
}
if d.UnsetLabel != "" && !d.ValueSchema.Nullable {
errs = append(errs, errors.New("unset_label requires a nullable value schema"))
}
return errors.Join(errs...)
}
func (d *Definition) validate(manifestRevision int, objectSchemas map[string]*jsonschema.Schema) error {
var errs []error
if d.IntroducedIn < 1 || d.IntroducedIn > manifestRevision {
errs = append(errs, fmt.Errorf(
"introduced_in %d is outside 1..%d (the manifest revision)", d.IntroducedIn, manifestRevision))
}
errs = append(errs, d.validateScopes(manifestRevision)...)
errs = append(errs, d.validateResolutionOrder()...)
errs = append(errs, d.ValueSchema.validate(d.IntroducedIn, manifestRevision, objectSchemas)...)
errs = append(errs, d.validateDefault(objectSchemas)...)
errs = append(errs, d.validateConstraint()...)
return errors.Join(errs...)
}
func (d *Definition) validateScopes(manifestRevision int) []error {
var errs []error
if len(d.AllowedScopes) == 0 {
return []error{errors.New("allowed_scopes is empty")}
}
seen := make(map[Scope]struct{}, len(d.AllowedScopes))
for _, entry := range d.AllowedScopes {
if _, dup := seen[entry.Scope]; dup {
errs = append(errs, fmt.Errorf("allowed_scopes repeats %q", entry.Scope))
}
seen[entry.Scope] = struct{}{}
if entry.IntroducedIn != 0 {
if entry.IntroducedIn < d.IntroducedIn {
errs = append(errs, fmt.Errorf(
"scope %q claims introduced_in %d, before the definition's own %d",
entry.Scope, entry.IntroducedIn, d.IntroducedIn))
}
if entry.IntroducedIn > manifestRevision {
errs = append(errs, fmt.Errorf(
"scope %q claims introduced_in %d, after the manifest revision %d",
entry.Scope, entry.IntroducedIn, manifestRevision))
}
}
}
// Persistence and scope have to agree, or a client cannot tell where a value
// lives from the definition alone.
switch d.Persistence {
case PersistenceRemote:
for _, entry := range d.AllowedScopes {
if !entry.Scope.IsRemote() {
errs = append(errs, fmt.Errorf(
"remote setting allows non-remote scope %q", entry.Scope))
}
}
case PersistenceClientLocal:
if len(d.AllowedScopes) != 1 || d.AllowedScopes[0].Scope != ScopeClientLocal {
errs = append(errs, errors.New(
`client_local setting must declare exactly one scope, "client_local"`))
}
if d.ConstrainedBy != nil {
errs = append(errs, errors.New(
"client_local setting declares constrained_by, but the server never resolves it"))
}
default:
errs = append(errs, fmt.Errorf("unknown persistence %q", d.Persistence))
}
return errs
}
func (d *Definition) validateResolutionOrder() []error {
var errs []error
order := d.ResolutionOrder
if len(order) == 0 {
return []error{errors.New("resolution_order is empty")}
}
if order[len(order)-1] != ScopeDefault {
return []error{fmt.Errorf(
"resolution_order must end with %q, got %q", ScopeDefault, order[len(order)-1])}
}
seen := make(map[Scope]struct{}, len(order))
for _, scope := range order[:len(order)-1] {
if _, dup := seen[scope]; dup {
errs = append(errs, fmt.Errorf("resolution_order repeats %q", scope))
}
seen[scope] = struct{}{}
if scope == ScopeDefault {
errs = append(errs, errors.New(`resolution_order lists "default" before the end`))
continue
}
if !d.AllowsScope(scope) {
errs = append(errs, fmt.Errorf(
"resolution_order resolves %q, which is not in allowed_scopes", scope))
}
}
// Every scope a value can be written at must be reachable when reading it,
// or the setting accepts writes it will never honor.
for _, entry := range d.AllowedScopes {
if _, ok := seen[entry.Scope]; !ok {
errs = append(errs, fmt.Errorf(
"scope %q is writable but never read: missing from resolution_order", entry.Scope))
}
}
return errs
}
func (v *ValueSchema) validate(
definitionRevision, manifestRevision int,
objectSchemas map[string]*jsonschema.Schema,
) []error {
var errs []error
switch v.Type {
case TypeBoolean, TypeLanguageTag:
// No constraints beyond nullability.
case TypeInteger, TypeNumber:
if v.Minimum == nil || v.Maximum == nil {
errs = append(errs, fmt.Errorf("%s requires minimum and maximum", v.Type))
break
}
// A slice, not a map: ranging a map would order these errors randomly,
// so the same broken manifest would report differently from run to run.
for _, bound := range []struct {
label string
bound *Bound
widensUp bool
}{
{"minimum", v.Minimum, false},
{"maximum", v.Maximum, true},
} {
errs = append(errs, bound.bound.validate(
bound.label, bound.widensUp, definitionRevision, manifestRevision)...)
}
minimum, hasMinimum := v.Minimum.Current()
maximum, hasMaximum := v.Maximum.Current()
if hasMinimum && hasMaximum && minimum > maximum {
errs = append(errs, fmt.Errorf(
"minimum %g exceeds maximum %g", minimum, maximum))
}
if v.Step != nil && *v.Step <= 0 {
errs = append(errs, fmt.Errorf("step must be positive, got %g", *v.Step))
}
case TypeString:
if v.MaxLength == nil {
errs = append(errs, errors.New("string requires max_length"))
} else if *v.MaxLength < 1 {
errs = append(errs, fmt.Errorf("max_length must be positive, got %d", *v.MaxLength))
}
if v.MinLength != nil && v.MaxLength != nil && *v.MinLength > *v.MaxLength {
errs = append(errs, fmt.Errorf(
"min_length %d exceeds max_length %d", *v.MinLength, *v.MaxLength))
}
if v.Pattern != "" {
// Compiled once here and reused by every ValidateValue call.
// ValidateValue is documented as the per-request validation path,
// so recompiling the pattern on each call would put a regex
// compile on every settings write.
compiled, err := regexp.Compile(v.Pattern)
if err != nil {
errs = append(errs, fmt.Errorf("pattern does not compile: %w", err))
} else {
v.compiledPattern = compiled
}
}
case TypeEnum:
if len(v.Values) == 0 {
errs = append(errs, errors.New("enum requires at least one member"))
}
seen := make(map[string]struct{}, len(v.Values))
for _, member := range v.Values {
// Type-tagged, so a string member "3" and an integer member 3 are
// two distinct members rather than a reported duplicate.
token := enumToken(member.Value)
if _, dup := seen[token]; dup {
errs = append(errs, fmt.Errorf("enum repeats value %s", displayEnumValue(member.Value)))
}
seen[token] = struct{}{}
if member.IntroducedIn != 0 {
// Same lower bound validateScopes enforces: a member cannot
// claim to predate the definition that contains it, or a client
// filtering by revision would offer it to a server too old to
// have the setting at all.
if member.IntroducedIn < definitionRevision {
errs = append(errs, fmt.Errorf(
"enum member %s claims introduced_in %d, before the definition's own %d",
displayEnumValue(member.Value), member.IntroducedIn, definitionRevision))
}
if member.IntroducedIn > manifestRevision {
errs = append(errs, fmt.Errorf(
"enum member %s claims introduced_in %d, after the manifest revision %d",
displayEnumValue(member.Value), member.IntroducedIn, manifestRevision))
}
}
}
case TypeObject:
if v.SchemaRef == "" {
errs = append(errs, errors.New("object requires schema_ref"))
break
}
if _, ok := objectSchemas[v.SchemaRef]; !ok {
errs = append(errs, fmt.Errorf(
"schema_ref %q has no file under contracts/settings/v1/schemas", v.SchemaRef))
}
default:
errs = append(errs, fmt.Errorf("unknown value type %q", v.Type))
}
return errs
}
// validate checks a bound's history. widensUp says which direction is a
// widening for this bound: a maximum may only grow and a minimum may only
// shrink, because the manifest's widening rule says a later revision must
// accept every value an earlier one did. A bound that moved the other way is a
// narrowing, which needs a new key rather than a revision tag.
func (b *Bound) validate(label string, widensUp bool, definitionRevision, manifestRevision int) []error {
if b == nil || len(b.History) == 0 {
return []error{fmt.Errorf("%s has no value", label)}
}
var errs []error
previousRevision := 0
for i, entry := range b.History {
if i == 0 {
// The original bound may be written bare, which reads as "has held
// since the definition appeared".
if entry.IntroducedIn != 0 && entry.IntroducedIn != definitionRevision {
errs = append(errs, fmt.Errorf(
"%s history starts at revision %d, but the definition was introduced in %d",
label, entry.IntroducedIn, definitionRevision))
}
previousRevision = definitionRevision
} else {
if entry.IntroducedIn == 0 {
errs = append(errs, fmt.Errorf(
"%s history entry %d must declare introduced_in", label, i))
continue
}
if entry.IntroducedIn <= previousRevision {
errs = append(errs, fmt.Errorf(
"%s history is not ordered: entry %d claims introduced_in %d, at or before %d",
label, i, entry.IntroducedIn, previousRevision))
}
previousRevision = entry.IntroducedIn
}
if entry.IntroducedIn > manifestRevision {
errs = append(errs, fmt.Errorf(
"%s history entry %d claims introduced_in %d, after the manifest revision %d",
label, i, entry.IntroducedIn, manifestRevision))
}
if i > 0 {
previous := b.History[i-1].Value
if widensUp && entry.Value < previous {
errs = append(errs, fmt.Errorf(
"%s narrows from %g to %g at revision %d; a narrowing needs a new key",
label, previous, entry.Value, entry.IntroducedIn))
}
if !widensUp && entry.Value > previous {
errs = append(errs, fmt.Errorf(
"%s narrows from %g to %g at revision %d; a narrowing needs a new key",
label, previous, entry.Value, entry.IntroducedIn))
}
}
}
return errs
}
func (d *Definition) validateDefault(objectSchemas map[string]*jsonschema.Schema) []error {
raw := bytes.TrimSpace(d.DefaultValue)
if len(raw) == 0 {
return []error{errors.New("default_value is required; use null for a nullable setting")}
}
if bytes.Equal(raw, []byte("null")) {
if !d.ValueSchema.Nullable {
return []error{errors.New("default_value is null but the value schema is not nullable")}
}
return nil
}
if err := d.ValueSchema.ValidateValue(raw, objectSchemas); err != nil {
return []error{fmt.Errorf("default_value is invalid: %w", err)}
}
return nil
}
func (d *Definition) validateConstraint() []error {
if d.ConstrainedBy == nil {
return nil
}
var errs []error
switch d.ConstrainedBy.Constraint {
case ConstraintCeiling, ConstraintFloor:
// Capping a value only means something where values are comparable.
// Declaring a ceiling on an unordered enum silently does nothing, which
// is worse than refusing it.
ordered := d.ValueSchema.Type == TypeInteger ||
d.ValueSchema.Type == TypeNumber ||
(d.ValueSchema.Type == TypeEnum && d.ValueSchema.Ordered)
if !ordered {
errs = append(errs, fmt.Errorf(
"%s constraint requires a numeric type or an ordered enum, got %s",
d.ConstrainedBy.Constraint, d.ValueSchema.Type))
}
case ConstraintAllowlist, ConstraintLocked:
// Applicable to any type.
default:
errs = append(errs, fmt.Errorf("unknown constraint %q", d.ConstrainedBy.Constraint))
}
if strings.TrimSpace(d.ConstrainedBy.PolicyInput) == "" {
errs = append(errs, errors.New("constrained_by requires a policy_input"))
}
return errs
}
// ValidateValue checks a JSON value against this schema. It is the single
// validation path: the mutation endpoint, the migration, and the manifest's own
// default checks all use it, so a value that validates in one place validates
// everywhere.
func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[string]*jsonschema.Schema) error {
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) {
if v.Nullable {
return nil
}
return errors.New("null is not allowed for this setting")
}
// Both of these run for every type, not just the object branch: a value the
// decoder silently rewrites is stored verbatim, and the two backends do not
// agree on what they will store. Postgres jsonb rejects U+FFFD-producing
// input outright while SQLite's json_valid accepts it, so the same request
// succeeds on one deployment and fails on the other.
//
// Raw bytes and escapes are separate paths to the same substitution: an
// invalid UTF-8 byte arrives in the body as itself, a lone surrogate as a
// \u escape, and encoding/json turns both into U+FFFD while reporting
// success.
if !utf8.Valid(trimmed) {
return errors.New("value is not valid UTF-8")
}
if err := rejectLoneSurrogates(trimmed); err != nil {
return err
}
switch v.Type {
case TypeBoolean:
var value bool
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected a boolean: %w", err)
}
case TypeInteger:
var value json.Number
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected an integer: %w", err)
}
if err := rejectQuotedNumber(trimmed); err != nil {
return fmt.Errorf("expected an integer: %w", err)
}
parsed, err := value.Int64()
if err != nil {
return fmt.Errorf("expected an integer, got %s", value)
}
return v.checkRange(float64(parsed))
case TypeNumber:
var value json.Number
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected a number: %w", err)
}
if err := rejectQuotedNumber(trimmed); err != nil {
return fmt.Errorf("expected a number: %w", err)
}
parsed, err := value.Float64()
if err != nil {
return fmt.Errorf("expected a number, got %s", value)
}
return v.checkRange(parsed)
case TypeString:
var value string
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected a string: %w", err)
}
return v.checkString(value)
case TypeEnum:
var value any
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected an enum value: %w", err)
}
for _, member := range v.Values {
if enumMatches(value, member.Value) {
return nil
}
}
return fmt.Errorf("%s is not one of %s", displayEnumValue(value), v.enumTokens())
case TypeLanguageTag:
var value string
if err := strictUnmarshal(trimmed, &value); err != nil {
return fmt.Errorf("expected a language tag: %w", err)
}
if _, ok := NormalizeLanguageTag(value); !ok {
return fmt.Errorf("%q is not a well-formed BCP 47 language tag", value)
}
case TypeObject:
schema, ok := objectSchemas[v.SchemaRef]
if !ok {
return fmt.Errorf("no compiled schema for %q", v.SchemaRef)
}
// Ahead of the schema check, because jsonschema.UnmarshalJSON keeps the
// last of a repeated property and validates that. Which value survives
// would then depend on the parser rather than on the contract.
if err := rejectDuplicateKeys(trimmed); err != nil {
return err
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(trimmed))
if err != nil {
return fmt.Errorf("expected an object: %w", err)
}
if err := schema.Validate(doc); err != nil {
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
}
if err := validateObjectSemantics(v.SchemaRef, trimmed); err != nil {
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
}
default:
return fmt.Errorf("unknown value type %q", v.Type)
}
return nil
}
type navigationDocument struct {
Items []navigationItem `json:"items"`
}
type navigationItem struct {
Type string `json:"type"`
Destination string `json:"destination"`
LibraryID *int `json:"library_id"`
SectionID string `json:"section_id"`
CollectionID string `json:"collection_id"`
Label string `json:"label"`
}
type navigationIdentity struct {
Type string
Destination string
LibraryID int
HasLibraryID bool
SectionID string
CollectionID string
}
// validateObjectSemantics holds the few cross-item invariants JSON Schema
// cannot express. uniqueItems rejects byte-for-byte duplicate objects, but a
// renamed library is still the same destination and must not appear twice in a
// menu. Keeping this beside ValidateValue means API writes, migrations, and
// manifest defaults all enforce the same identity rule.
func validateObjectSemantics(schemaRef string, raw json.RawMessage) error {
if schemaRef != "primary-menu.json" && schemaRef != "navigation-shortcuts.json" {
return nil
}
var document navigationDocument
if err := strictUnmarshal(raw, &document); err != nil {
return fmt.Errorf("decoding navigation document: %w", err)
}
seen := make(map[navigationIdentity]int, len(document.Items))
for index, item := range document.Items {
identity, err := item.identity()
if err != nil {
return fmt.Errorf("items[%d]: %w", index, err)
}
if first, duplicate := seen[identity]; duplicate {
return fmt.Errorf("items[%d] repeats the destination from items[%d]", index, first)
}
seen[identity] = index
}
return nil
}
func (item navigationItem) identity() (navigationIdentity, error) {
identity := navigationIdentity{Type: item.Type}
switch item.Type {
case "builtin":
identity.Destination = item.Destination
case "library":
if item.LibraryID == nil {
return navigationIdentity{}, errors.New("library is missing library_id")
}
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
case "section":
if item.LibraryID == nil {
return navigationIdentity{}, errors.New("section is missing library_id")
}
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
identity.SectionID = item.SectionID
case "collection":
identity.CollectionID = item.CollectionID
if item.LibraryID != nil {
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
}
default:
return navigationIdentity{}, fmt.Errorf("unknown navigation item type %q", item.Type)
}
return identity, nil
}
// NormalizeValue validates a value and returns the form that should be stored.
//
// Anything that persists a value goes through here rather than ValidateValue,
// so the row that lands in the database is the one every client compares
// against. Only language tags differ from their input today; every other type
// is already canonical once it validates.
func (v *ValueSchema) NormalizeValue(
raw json.RawMessage,
objectSchemas map[string]*jsonschema.Schema,
) (json.RawMessage, error) {
if err := v.ValidateValue(raw, objectSchemas); err != nil {
return nil, err
}
trimmed := bytes.TrimSpace(raw)
if v.Type != TypeLanguageTag || bytes.Equal(trimmed, []byte("null")) {
return append(json.RawMessage(nil), trimmed...), nil
}
var tag string
if err := strictUnmarshal(trimmed, &tag); err != nil {
return nil, fmt.Errorf("expected a language tag: %w", err)
}
normalized, ok := NormalizeLanguageTag(tag)
if !ok {
return nil, fmt.Errorf("%q is not a well-formed BCP 47 language tag", tag)
}
encoded, err := json.Marshal(normalized)
if err != nil {
return nil, fmt.Errorf("encoding normalized language tag: %w", err)
}
return encoded, nil
}
// stepTolerance absorbs binary floating-point error when checking a value
// against a declared step. 0.05 is not exactly representable, so requiring an
// exact multiple would reject values every client can legitimately produce.
const stepTolerance = 1e-9
// StepAligned reports whether value sits on the grid of `step` anchored at
// `base`. A non-positive step imposes no constraint.
//
// Exported so the legacy settings registry in internal/api/handlers enforces
// exactly what the manifest declares instead of carrying a second,
// nearly-identical implementation — the duplication this contract exists to
// remove.
func StepAligned(value, base, step float64) bool {
if step <= 0 {
return true
}
steps := (value - base) / step
return math.Abs(steps-math.Round(steps))*step <= stepTolerance
}
// checkRange validates against the bounds this server enforces, which are
// always the newest in the history. Revision filtering is a client-side concern
// — the server accepts everything its own manifest allows.
func (v *ValueSchema) checkRange(value float64) error {
minimum, hasMinimum := v.Minimum.Current()
if hasMinimum && value < minimum {
return fmt.Errorf("%g is below the minimum %g", value, minimum)
}
maximum, hasMaximum := v.Maximum.Current()
if hasMaximum && value > maximum {
return fmt.Errorf("%g is above the maximum %g", value, maximum)
}
if v.Step != nil {
// Steps are counted from the minimum, which is the only origin every
// client's stepper agrees on.
base := 0.0
if hasMinimum {
base = minimum
}
if !StepAligned(value, base, *v.Step) {
return fmt.Errorf("%g is not a multiple of the step %g from %g",
value, *v.Step, base)
}
}
return nil
}
func (v *ValueSchema) checkString(value string) error {
length := len([]rune(value))
if v.MinLength != nil && length < *v.MinLength {
return fmt.Errorf("is shorter than the minimum %d characters", *v.MinLength)
}
if v.MaxLength != nil && length > *v.MaxLength {
return fmt.Errorf("is longer than the maximum %d characters", *v.MaxLength)
}
if v.Pattern != "" {
matcher := v.compiledPattern
if matcher == nil {
// Only reachable for a schema built in a test rather than loaded
// from the manifest, where validate() would have compiled it.
compiled, err := regexp.Compile(v.Pattern)
if err != nil {
return fmt.Errorf("pattern does not compile: %w", err)
}
matcher = compiled
}
if !matcher.MatchString(value) {
return fmt.Errorf("does not match %s", v.Pattern)
}
}
return nil
}
func (v *ValueSchema) enumTokens() string {
tokens := make([]string, 0, len(v.Values))
for _, member := range v.Values {
tokens = append(tokens, displayEnumValue(member.Value))
}
return strings.Join(tokens, ", ")
}
// enumMatches reports whether a decoded request value is the same JSON value as
// an enum member.
//
// Comparison is by JSON type and value rather than by formatted text.
// manifest.schema.json permits string, integer and boolean members, and
// comparing "%v" tokens would let the string "3" satisfy an integer member 3
// and the string "true" satisfy a boolean member — storing a wire value of a
// type every generated binding would then fail to decode.
func enumMatches(value, member any) bool {
switch got := value.(type) {
case string:
want, ok := member.(string)
return ok && got == want
case bool:
want, ok := member.(bool)
return ok && got == want
case json.Number:
return numberEqualsMember(got, member)
default:
return false
}
}
// numberEqualsMember compares numerically, so a member written 1e6 matches a
// request sending 1000000 and an integer member 3 matches 3.0. Manifest members
// decode without UseNumber and arrive as float64; values built in tests may
// already be json.Number.
func numberEqualsMember(value json.Number, member any) bool {
var want float64
switch typed := member.(type) {
case float64:
want = typed
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return false
}
want = parsed
default:
return false
}
got, err := value.Float64()
if err != nil {
return false
}
return got == want
}
// enumToken is a type-tagged identity for duplicate detection, so a string
// member and a numeric member that print the same are not conflated.
func enumToken(value any) string {
switch typed := value.(type) {
case string:
return "s:" + typed
case bool:
return "b:" + strconv.FormatBool(typed)
case float64:
return "n:" + strconv.FormatFloat(typed, 'g', -1, 64)
case json.Number:
if parsed, err := typed.Float64(); err == nil {
return "n:" + strconv.FormatFloat(parsed, 'g', -1, 64)
}
return "n:" + typed.String()
default:
return fmt.Sprintf("?:%v", value)
}
}
// displayEnumValue renders a member for an error message, quoting strings so a
// reader can tell "3" from 3.
func displayEnumValue(value any) string {
switch typed := value.(type) {
case string:
return strconv.Quote(typed)
case json.Number:
return typed.String()
default:
return fmt.Sprintf("%v", value)
}
}
// strictUnmarshal rejects trailing content and, for numbers, preserves the
// literal so an integer field cannot silently accept 1.5.
func strictUnmarshal(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
// Not decoder.More(): that reports whether another element follows in the
// *current* array or object, so it answers false for a stray "]" or "}" —
// exactly the bytes a caller slicing a value out of a larger document is
// most likely to hand over, which would let `true]` validate as a boolean.
// Reading the next token tolerates only end-of-input.
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
return errors.New("unexpected trailing content")
}
return nil
}
// languageTagPattern accepts the well-formed BCP 47 shapes real clients
// produce: language (with optional extlang), script, region, variants,
// extension singletons and private use — including a purely private-use tag.
// The narrower language[-script][-region] form this started as rejected tags
// Android and iOS emit unprompted — `Locale.toLanguageTag()` appends extension
// subtags for a non-Gregorian calendar or non-Latin numbering system
// (`ar-EG-u-nu-latn`), registered variants like `ca-ES-valencia` are ordinary
// user choices, and the legacy endpoint accepted `zh-cmn` (extlang) and
// `x-private` (private use only), so rejecting them here would turn an
// existing 204 into a 400.
var languageTagPattern = regexp.MustCompile(
`^([a-zA-Z]{2,3}(-[a-zA-Z]{3}){0,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?` +
`(-([0-9a-zA-Z]{5,8}|[0-9][0-9a-zA-Z]{3}))*` +
`(-[0-9a-wy-zA-WY-Z](-[0-9a-zA-Z]{2,8})+)*` +
`(-[xX](-[0-9a-zA-Z]{1,8})+)?` +
`|[xX](-[0-9a-zA-Z]{1,8})+)$`)
// NormalizeLanguageTag returns the canonical BCP 47 form of a tag, or false if
// it is not well-formed.
//
// Normalization is the half that keeps the contract's promise of one stored
// value per language. Without it `en-US`, `en-us` and `EN-us` are three
// distinct rows for one preference, and audio-track matching misses on two of
// them. Underscores are accepted on input because both mobile platforms have a
// locale accessor that produces them (`Locale.identifier` on iOS,
// `Locale.toString()` on Android) and sending one is a mistake worth absorbing
// rather than a value worth rejecting.
//
// The empty string is not a language tag. "No preference" is null, which the
// nullable flag on each language setting already expresses.
func NormalizeLanguageTag(tag string) (string, bool) {
tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")
if !languageTagPattern.MatchString(tag) {
return "", false
}
parts := strings.Split(tag, "-")
// Case is not significant in BCP 47, but the conventional casing is what
// every client library produces: lowercase language, Titlecase script,
// UPPERCASE region, lowercase everything else.
parts[0] = strings.ToLower(parts[0])
// A tag that is entirely private use ("x-whatever") has no script or
// region positions — everything after the leading singleton is private-use
// content and stays lowercase.
inExtension := parts[0] == "x"
for i := 1; i < len(parts); i++ {
part := parts[i]
switch {
case len(part) == 1:
// A singleton opens an extension ("u", "t") or private use ("x").
// Everything after it is extension content, so the two-letter
// region rule must stop applying — "nu" in "ar-EG-u-nu-latn" is an
// extension key, not a region.
inExtension = true
parts[i] = strings.ToLower(part)
case inExtension:
parts[i] = strings.ToLower(part)
case len(part) == 4 && isAlpha(part):
// A script. Not necessarily at index 1: an extlang can precede it
// (`zh-cmn-Hans-CN`). No collision with variants — a four-character
// variant must begin with a digit.
parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
case len(part) == 2 && isAlpha(part):
parts[i] = strings.ToUpper(part)
default:
parts[i] = strings.ToLower(part)
}
}
return strings.Join(parts, "-"), true
}
func isAlpha(value string) bool {
for _, r := range value {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
return false
}
}
return true
}
// CompareValues orders two values of this schema's type: negative when a sorts
// below b, zero when they are equivalent, positive when a sorts above.
//
// This is what makes a ceiling or floor constraint mean anything. Numeric types
// compare numerically; an ordered enum compares by declared member position,
// which is why manifest.schema.json only permits those constraints on a numeric
// type or an enum marked ordered — every other type has no defined direction to
// cap in.
//
// A value that is not a member, or that will not decode, sorts as equivalent so
// an unrecognized value is never silently narrowed. Validation is a separate
// concern and has already rejected it by the time a constraint is applied.
func (v *ValueSchema) CompareValues(a, b json.RawMessage) int {
switch v.Type {
case TypeInteger, TypeNumber:
left, okA := decodeFloat(a)
right, okB := decodeFloat(b)
if !okA || !okB {
return 0
}
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
case TypeEnum:
if !v.Ordered {
return 0
}
left, okA := v.enumIndex(a)
right, okB := v.enumIndex(b)
if !okA || !okB {
return 0
}
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
return 0
}
// enumIndex returns the declared position of raw among this schema's members.
func (v *ValueSchema) enumIndex(raw json.RawMessage) (int, bool) {
var decoded any
if err := strictUnmarshal(bytes.TrimSpace(raw), &decoded); err != nil {
return 0, false
}
for i, member := range v.Values {
if enumMatches(decoded, member.Value) {
return i, true
}
}
return 0, false
}
func decodeFloat(raw json.RawMessage) (float64, bool) {
var number json.Number
if err := strictUnmarshal(bytes.TrimSpace(raw), &number); err != nil {
return 0, false
}
parsed, err := number.Float64()
if err != nil {
return 0, false
}
return parsed, true
}