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.
366 lines
10 KiB
Go
366 lines
10 KiB
Go
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
|
||
}
|