343 lines
9.7 KiB
Go
343 lines
9.7 KiB
Go
|
|
// 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)
|
||
|
|
}
|