// Package schemaguard validates published example documents against the JSON // Schemas this repository publishes. // // It exists because of a defect class that hit three repositories in one week: // a contract whose examples contradict its prose gets implemented as its // examples. flex-auth shipped a caring fixture asserting an authority the // contract's own ownership section denied, and a decision record naming // flex-auth.decision-record.v1 while omitting three of that contract's // provenance fields. approval-engine suggested the check after the same class // caught its own two published claim examples. // // This is a deliberately small subset of JSON Schema — only the keywords the // schemas here actually use. The property that makes it trustworthy is that an // unrecognised keyword is a FAILURE, not a skip: a validator that silently // approves what it does not understand is worse than no validator, because it // invites reliance it cannot support. package schemaguard import ( "encoding/json" "fmt" "os" "path" "path/filepath" "regexp" "sort" "strings" ) // known lists every keyword this validator implements. Anything else in a // schema is reported rather than ignored. var known = map[string]bool{ "$schema": true, "$id": true, "title": true, "description": true, "$defs": true, "$ref": true, "type": true, "required": true, "properties": true, "additionalProperties": true, "const": true, "enum": true, "pattern": true, "items": true, "minLength": true, "format": true, "if": true, "then": true, "allOf": true, "uniqueItems": true, "minimum": true, "maximum": true, "not": true, "minItems": true, } // Schema is a parsed JSON Schema document. // // Sibling schemas are resolved by file name from the directory the schema was // loaded from. These schemas reference each other by absolute $id URL, but the // URLs are identifiers rather than fetchable locations, so resolving them over // the network would be both wrong and untestable. type Schema struct { root map[string]any dir string sibling map[string]map[string]any } // Load reads a schema from disk. func Load(path string) (*Schema, error) { root, err := loadDoc(path) if err != nil { return nil, err } return &Schema{root: root, dir: filepath.Dir(path), sibling: map[string]map[string]any{}}, nil } func loadDoc(path string) (map[string]any, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var root map[string]any if err := json.Unmarshal(raw, &root); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } return root, nil } // Validate checks doc against the schema, returning every problem found rather // than stopping at the first. func (s *Schema) Validate(doc any) []string { return s.check("", s.root, doc) } // ValidateFile reads a JSON document and validates it. func (s *Schema) ValidateFile(path string) ([]string, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var doc any if err := json.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } return s.Validate(doc), nil } func at(path string) string { if path == "" { return "(root)" } return path } func (s *Schema) resolve(doc0 map[string]any, node map[string]any) (map[string]any, map[string]any, []string) { ref, ok := node["$ref"].(string) if !ok { return doc0, node, nil } doc := doc0 frag := ref if !strings.HasPrefix(ref, "#") { loc, rest, _ := strings.Cut(ref, "#") name := path.Base(loc) other, cached := s.sibling[name] if !cached { loaded, err := loadDoc(filepath.Join(s.dir, name)) if err != nil { return nil, nil, []string{fmt.Sprintf("$ref %q: sibling schema %s not readable in %s: %v", ref, name, s.dir, err)} } s.sibling[name] = loaded other = loaded } doc = other if rest == "" { return other, other, nil } frag = "#" + rest } const prefix = "#/$defs/" if !strings.HasPrefix(frag, prefix) { return nil, nil, []string{fmt.Sprintf("unsupported $ref fragment %q — schemaguard resolves only %s*", frag, prefix)} } defs, _ := doc["$defs"].(map[string]any) target, ok := defs[strings.TrimPrefix(frag, prefix)].(map[string]any) if !ok { return nil, nil, []string{fmt.Sprintf("$ref %q does not resolve", ref)} } return doc, target, nil } func (s *Schema) check(path string, node map[string]any, doc any) []string { return s.checkIn(s.root, path, node, doc) } func (s *Schema) checkIn(doc0 map[string]any, path string, node map[string]any, doc any) []string { doc0, node, probs := s.resolve(doc0, node) if probs != nil { return probs } for k := range node { if !known[k] { probs = append(probs, fmt.Sprintf("%s: schema uses keyword %q, which schemaguard does not implement — extend it rather than trusting this pass", at(path), k)) } } if want, ok := node["const"]; ok && !equal(want, doc) { probs = append(probs, fmt.Sprintf("%s: must be %v, got %v", at(path), want, doc)) } if raw, ok := node["enum"].([]any); ok { hit := false for _, v := range raw { if equal(v, doc) { hit = true break } } if !hit { probs = append(probs, fmt.Sprintf("%s: %v is not one of %v", at(path), doc, raw)) } } if t, ok := node["type"]; ok { if err := checkType(t, doc); err != "" { probs = append(probs, fmt.Sprintf("%s: %s", at(path), err)) return probs // further checks would be noise } } if pat, ok := node["pattern"].(string); ok { if str, isStr := doc.(string); isStr { re, err := regexp.Compile(pat) switch { case err != nil: probs = append(probs, fmt.Sprintf("%s: schema pattern %q does not compile: %v", at(path), pat, err)) case !re.MatchString(str): probs = append(probs, fmt.Sprintf("%s: %q does not match %s", at(path), str, pat)) } } } if min, ok := node["minimum"].(float64); ok { if n, isNum := doc.(float64); isNum && n < min { probs = append(probs, fmt.Sprintf("%s: %v is below minimum %v", at(path), n, min)) } } if max, ok := node["maximum"].(float64); ok { if n, isNum := doc.(float64); isNum && n > max { probs = append(probs, fmt.Sprintf("%s: %v is above maximum %v", at(path), n, max)) } } if not, ok := node["not"].(map[string]any); ok { if len(s.checkIn(doc0, path, not, doc)) == 0 { probs = append(probs, fmt.Sprintf("%s: matches a schema it must not match", at(path))) } } if min, ok := node["minLength"].(float64); ok { if str, isStr := doc.(string); isStr && len(str) < int(min) { probs = append(probs, fmt.Sprintf("%s: shorter than minLength %d", at(path), int(min))) } } if arr, ok := doc.([]any); ok { if unique, has := node["uniqueItems"].(bool); has && unique { seen := map[string]bool{} for _, v := range arr { key := fmt.Sprintf("%v", v) if seen[key] { probs = append(probs, fmt.Sprintf("%s: duplicate item %v but uniqueItems is set", at(path), v)) } seen[key] = true } } if min, has := node["minItems"].(float64); has && len(arr) < int(min) { probs = append(probs, fmt.Sprintf("%s: %d items, below minItems %d", at(path), len(arr), int(min))) } if items, has := node["items"].(map[string]any); has { for i, v := range arr { probs = append(probs, s.checkIn(doc0, fmt.Sprintf("%s[%d]", path, i), items, v)...) } } } obj, isObj := doc.(map[string]any) if !isObj { return probs } if req, ok := node["required"].([]any); ok { for _, r := range req { name, _ := r.(string) if _, present := obj[name]; !present { probs = append(probs, fmt.Sprintf("%s: missing required field %q", at(path), name)) } } } props, _ := node["properties"].(map[string]any) if allow, ok := node["additionalProperties"].(bool); ok && !allow { var extra []string for k := range obj { if _, declared := props[k]; !declared { extra = append(extra, k) } } sort.Strings(extra) for _, k := range extra { probs = append(probs, fmt.Sprintf("%s: undeclared field %q (additionalProperties is false)", at(path), k)) } } for name, sub := range props { v, present := obj[name] if !present { continue } subSchema, ok := sub.(map[string]any) if !ok { continue } probs = append(probs, s.checkIn(doc0, join(path, name), subSchema, v)...) } // if/then: apply then only when if validates cleanly. if cond, ok := node["if"].(map[string]any); ok { if then, hasThen := node["then"].(map[string]any); hasThen { if len(s.checkIn(doc0, path, cond, doc)) == 0 { probs = append(probs, s.checkIn(doc0, path, then, doc)...) } } } if all, ok := node["allOf"].([]any); ok { for _, sub := range all { if subSchema, isMap := sub.(map[string]any); isMap { probs = append(probs, s.checkIn(doc0, path, subSchema, doc)...) } } } return probs } func join(path, name string) string { if path == "" { return name } return path + "." + name } func checkType(t any, doc any) string { var want []string switch v := t.(type) { case string: want = []string{v} case []any: for _, x := range v { if s, ok := x.(string); ok { want = append(want, s) } } default: return fmt.Sprintf("schema type %v is neither a string nor a list", t) } for _, w := range want { if matchesType(w, doc) { return "" } } return fmt.Sprintf("expected type %s, got %T", strings.Join(want, "|"), doc) } func matchesType(want string, doc any) bool { switch want { case "object": _, ok := doc.(map[string]any) return ok case "array": _, ok := doc.([]any) return ok case "string": _, ok := doc.(string) return ok case "boolean": _, ok := doc.(bool) return ok case "number": _, ok := doc.(float64) return ok case "integer": f, ok := doc.(float64) return ok && f == float64(int64(f)) case "null": return doc == nil } return false } func equal(a, b any) bool { return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) }