Files
silo-server/internal/settingscontract/canonical.go
T
Quick 6535203791 ci: gate Go lint on the lines a branch changes
AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go
job ran only gofmt and vet. A change failing the documented Go lint gate
passed all three jobs.

Running the linter as-is is not an option: the tree has ~296 findings today,
which is why this half of `make lint` was never enforced. Blocking every PR
on a cleanup nobody has scheduled gets the gate deleted again, so CI runs
with --new-from-merge-base and only the lines a branch touches have to be
clean. The count can then only fall.

golangci-lint is built from source at a pinned version rather than
downloaded. A released binary refuses to run against a Go newer than the one
it was built with, and go.mod here tracks Go closely enough that the current
release already fails that way on 1.26.4.

.golangci.yml declared version 2 while still using v1's issues.exclude-rules
key. Current golangci-lint ignores it, so the "allow repeated strings and
unchecked cleanup errors in tests" exclusions silently did not apply — 16
findings in test files that the config says to skip. Moved to
linters.exclusions, which `golangci-lint config verify` accepts.

The four lines this surfaced in scantrigger are fixed rather than excluded:
its repeated status codes and messages are now named constants, so one
condition cannot end up worded two ways.

Also drops the workflow token to contents:read and stops persisting
credentials in the three checkouts, neither of which any job needs.

Reported by CodeRabbit and Codex review on #479.
2026-07-27 00:32:15 +00:00

366 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package settingscontract
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
)
// canonicalOnce guards the four derived representations below. All of them are
// pure functions of files embedded at compile time, so they are computed once
// and handed out as copies rather than rebuilt per request: a conditional GET
// of the manifest would otherwise pay a full parse and re-serialize just to
// answer 304.
var (
canonicalOnce sync.Once
canonicalManifest []byte
canonicalPublic []byte
canonicalETag string
canonicalPublicTag string
canonicalErr error
)
func computeCanonical() {
raw, err := RawBytes()
if err != nil {
canonicalErr = err
return
}
canonicalManifest, canonicalErr = canonicalize(raw)
if canonicalErr != nil {
return
}
canonicalPublic, canonicalErr = buildPublic(raw)
if canonicalErr != nil {
return
}
canonicalETag, canonicalErr = digestWithSchemas(canonicalManifest)
if canonicalErr != nil {
return
}
canonicalPublicTag, canonicalErr = digestWithSchemas(canonicalPublic)
}
// CanonicalBytes returns the RFC 8785 (JCS) canonicalization of the manifest:
// UTF-8, object keys sorted by code point, no insignificant whitespace.
//
// Two servers built from the same manifest produce identical bytes, which is
// what makes the ETag comparable across deployments and generated client code
// reproducible.
func CanonicalBytes() ([]byte, error) {
canonicalOnce.Do(computeCanonical)
if canonicalErr != nil {
return nil, canonicalErr
}
return append([]byte(nil), canonicalManifest...), nil
}
// ETag returns the entity tag for the contract, formatted as a strong HTTP
// entity tag.
//
// The digest covers the value schemas under schemas/ as well as the manifest.
// Those files decide which object values the server accepts, so a change to one
// changes the contract even though manifest.json is byte-identical. Folding
// them in means the tag is a validator for the contract rather than for the
// served bytes alone: a client whose conditional GET misses re-reads a body
// that may not have changed, which is the cheap direction to be wrong in. The
// alternative — 304 forever while the server silently validates against a
// different schema — is the drift this contract exists to prevent.
func ETag() (string, error) {
canonicalOnce.Do(computeCanonical)
if canonicalErr != nil {
return "", canonicalErr
}
return canonicalETag, nil
}
// PublicBytes returns the canonicalized manifest with maintainer-only fields
// removed. This is what GET /api/v1/settings/manifest serves.
//
// Only "notes" is stripped today. Internal storage bindings, when they exist,
// are stripped here too: the public manifest must never name a table or column.
func PublicBytes() ([]byte, error) {
canonicalOnce.Do(computeCanonical)
if canonicalErr != nil {
return nil, canonicalErr
}
return append([]byte(nil), canonicalPublic...), nil
}
// PublicETag returns the entity tag for the public manifest projection. It
// differs from ETag because stripping notes changes the bytes clients see.
func PublicETag() (string, error) {
canonicalOnce.Do(computeCanonical)
if canonicalErr != nil {
return "", canonicalErr
}
return canonicalPublicTag, nil
}
// buildPublic strips maintainer-only fields and canonicalizes in one pass. The
// decoded document is handed straight to the canonical writer, which already
// understands map[string]any / []any / json.Number, rather than being
// re-marshaled and re-parsed.
func buildPublic(raw []byte) ([]byte, error) {
doc, err := decodeJSON(raw)
if err != nil {
return nil, err
}
root, _ := doc.(map[string]any)
definitions, _ := root["definitions"].([]any)
for _, entry := range definitions {
if def, ok := entry.(map[string]any); ok {
delete(def, "notes")
}
}
return canonicalizeValue(doc)
}
// digestWithSchemas hashes a canonical manifest projection together with every
// value schema, keyed by filename so a rename is a change too.
func digestWithSchemas(manifest []byte) (string, error) {
schemas, err := SchemaBytes()
if err != nil {
return "", err
}
names := make([]string, 0, len(schemas))
for name := range schemas {
names = append(names, name)
}
sort.Strings(names)
digest := sha256.New()
digest.Write(manifest)
for _, name := range names {
canonical, err := canonicalize(schemas[name])
if err != nil {
return "", fmt.Errorf("canonicalizing value schema %s: %w", name, err)
}
// Length-prefixed so no combination of names and bodies can collide
// with a different combination.
// digest is a hash.Hash; its Write never returns an error.
_, _ = fmt.Fprintf(digest, "\n%d:%s\n%d:", len(name), name, len(canonical))
digest.Write(canonical)
}
sum := digest.Sum(nil)
return `"` + hex.EncodeToString(sum) + `"`, nil
}
func decodeJSON(raw []byte) (any, error) {
// Both checks run before the decode that would paper over what they catch.
// This is the shared entry point for the manifest, its public projection and
// every value schema, so all three get the same guarantees.
if err := rejectLoneSurrogates(raw); err != nil {
return nil, fmt.Errorf("canonicalization refused a lone surrogate: %w", err)
}
if err := rejectDuplicateKeys(raw); err != nil {
return nil, fmt.Errorf("canonicalization refused a duplicate key: %w", err)
}
var doc any
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&doc); err != nil {
return nil, fmt.Errorf("parsing JSON for canonicalization: %w", err)
}
return doc, nil
}
func canonicalize(raw []byte) ([]byte, error) {
doc, err := decodeJSON(raw)
if err != nil {
return nil, err
}
return canonicalizeValue(doc)
}
func canonicalizeValue(doc any) ([]byte, error) {
var out bytes.Buffer
if err := writeCanonical(&out, doc); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func writeCanonical(out *bytes.Buffer, value any) error {
switch typed := value.(type) {
case nil:
out.WriteString("null")
case bool:
if typed {
out.WriteString("true")
} else {
out.WriteString("false")
}
case json.Number:
serialized, err := canonicalNumber(typed)
if err != nil {
return err
}
out.WriteString(serialized)
case string:
writeCanonicalString(out, typed)
case []any:
out.WriteByte('[')
for i, item := range typed {
if i > 0 {
out.WriteByte(',')
}
if err := writeCanonical(out, item); err != nil {
return err
}
}
out.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
// JCS sorts by UTF-16 code unit. For the ASCII key space this contract
// uses, byte order and code-unit order agree.
sort.Strings(keys)
out.WriteByte('{')
for i, key := range keys {
if i > 0 {
out.WriteByte(',')
}
writeCanonicalString(out, key)
out.WriteByte(':')
if err := writeCanonical(out, typed[key]); err != nil {
return err
}
}
out.WriteByte('}')
default:
return fmt.Errorf("unexpected JSON value of type %T", value)
}
return nil
}
// writeCanonicalString escapes a string the way ECMAScript's JSON.stringify
// does, which is what JCS requires.
//
// encoding/json is deliberately not used here: json.Marshal HTML-escapes <, >
// and &, which JCS emits literally. Nothing in the manifest triggers that
// today, so the difference would first appear as an ETag that silently
// disagrees with every conforming client the moment a label contains an
// ampersand.
func writeCanonicalString(out *bytes.Buffer, value string) {
out.WriteByte('"')
for _, r := range value {
switch r {
case '"':
out.WriteString(`\"`)
case '\\':
out.WriteString(`\\`)
case '\b':
out.WriteString(`\b`)
case '\f':
out.WriteString(`\f`)
case '\n':
out.WriteString(`\n`)
case '\r':
out.WriteString(`\r`)
case '\t':
out.WriteString(`\t`)
default:
if r < 0x20 {
fmt.Fprintf(out, `\u%04x`, r)
continue
}
out.WriteRune(r)
}
}
out.WriteByte('"')
}
// canonicalNumber renders a number the way ECMAScript's Number::toString does,
// which is what JCS requires: 3.0 becomes "3", 0.05 stays "0.05".
func canonicalNumber(number json.Number) (string, error) {
parsed, err := number.Float64()
if err != nil {
return "", fmt.Errorf("parsing number %s: %w", number, err)
}
if math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return "", fmt.Errorf("NaN and infinities cannot be canonicalized: %s", number)
}
return formatECMAScript(parsed)
}
// formatECMAScript implements ECMA-262 Number::toString for radix 10.
//
// strconv is used only for the shortest round-tripping digits and the decimal
// exponent; the layout is reassembled here because Go's own formats differ from
// ECMAScript in three ways that all silently change the digest: 'g' switches to
// exponential at 1e-5 where ECMAScript switches at 1e-7, Go zero-pads the
// exponent ("1e-07" vs "1e-7"), and Go prints negative zero as "-0" where
// ECMAScript and JCS print "0".
func formatECMAScript(value float64) (string, error) {
if value == 0 {
return "0", nil
}
sign := ""
if value < 0 {
sign = "-"
value = -value
}
// "d.dddde±dd" with the fewest digits that round-trip.
scientific := strconv.FormatFloat(value, 'e', -1, 64)
separator := strings.IndexByte(scientific, 'e')
if separator < 0 {
return "", fmt.Errorf("unexpected float formatting for %v", value)
}
exponent, err := strconv.Atoi(scientific[separator+1:])
if err != nil {
return "", fmt.Errorf("parsing exponent of %v: %w", value, err)
}
digits := strings.Replace(scientific[:separator], ".", "", 1)
// ECMA-262 names these k (digit count) and n (position of the decimal
// point relative to the digits), where the value is digits × 10^(n-k).
k := len(digits)
n := exponent + 1
switch {
case k <= n && n <= 21:
return sign + digits + strings.Repeat("0", n-k), nil
case 0 < n && n <= 21:
return sign + digits[:n] + "." + digits[n:], nil
case -6 < n && n <= 0:
return sign + "0." + strings.Repeat("0", -n) + digits, nil
}
suffix := "e+" + strconv.Itoa(n-1)
if n-1 < 0 {
suffix = "e-" + strconv.Itoa(1-n)
}
if k == 1 {
return sign + digits + suffix, nil
}
return sign + digits[:1] + "." + digits[1:] + suffix, nil
}