1use std::collections::{BTreeMap, BTreeSet};
59
60use async_trait::async_trait;
61use boundgraph_core::{CheckerId, Condensate, CondensatePayload, Digest, WorkOrder};
62use serde_json::Value;
63
64use crate::checker::{Checker, Judgement};
65
66pub const SUPPORTED_KEYWORDS: &[&str] = &[
71 "type",
72 "enum",
73 "const",
74 "properties",
75 "required",
76 "additionalProperties",
77 "minProperties",
78 "maxProperties",
79 "items",
80 "minItems",
81 "maxItems",
82 "uniqueItems",
83 "minimum",
84 "maximum",
85 "exclusiveMinimum",
86 "exclusiveMaximum",
87 "multipleOf",
88 "minLength",
89 "maxLength",
90 "allOf",
91 "anyOf",
92 "oneOf",
93 "not",
94];
95
96const ANNOTATIONS: &[&str] = &[
101 "$schema",
102 "$id",
103 "$comment",
104 "title",
105 "description",
106 "default",
107 "examples",
108 "deprecated",
109 "readOnly",
110 "writeOnly",
111];
112
113pub const MAX_DEPTH: usize = 64;
118
119pub const DEFAULT_BUDGET: usize = 100_000;
124
125#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
131#[non_exhaustive]
132pub enum SchemaError {
133 #[error("the schema is not valid JSON: {0}")]
135 NotJson(String),
136
137 #[error("{path}: `{keyword}` is not supported by this validator, so the schema is refused")]
139 UnsupportedKeyword {
140 path: String,
142 keyword: String,
144 },
145
146 #[error("{path}: `{keyword}` must be {expected}")]
148 MalformedKeyword {
149 path: String,
151 keyword: String,
153 expected: String,
155 },
156
157 #[error("{path}: a schema must be an object or a boolean, found {found}")]
159 NotASchema {
160 path: String,
162 found: String,
164 },
165
166 #[error("{path}: the schema nests deeper than {MAX_DEPTH} levels")]
168 TooDeep {
169 path: String,
171 },
172
173 #[error("the schema could not be canonically encoded: {0}")]
175 Encode(String),
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Violation {
185 pub path: String,
188 pub constraint: &'static str,
190 pub detail: String,
192}
193
194impl Violation {
195 pub fn render(&self) -> String {
197 let at = if self.path.is_empty() {
198 "(root)".to_string()
199 } else {
200 self.path.clone()
201 };
202 format!("{at}: {} ({})", self.detail, self.constraint)
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Validation {
209 Valid,
211 Invalid(Vec<Violation>),
213 Exhausted {
219 budget: usize,
221 },
222}
223
224impl Validation {
225 pub fn is_valid(&self) -> bool {
230 matches!(self, Self::Valid)
231 }
232
233 pub fn is_decided(&self) -> bool {
238 matches!(self, Self::Valid | Self::Invalid(_))
239 }
240}
241
242#[derive(Debug, Clone, PartialEq)]
244pub struct Schema {
245 root: Node,
246 source: Value,
247 digest: Digest,
248}
249
250#[derive(Debug, Clone, PartialEq, Default)]
252struct Node {
253 boolean: Option<bool>,
255 types: Option<BTreeSet<Type>>,
256 enumeration: Option<Vec<Value>>,
257 constant: Option<Value>,
258 properties: BTreeMap<String, Node>,
259 required: Vec<String>,
260 additional: Option<Additional>,
261 min_properties: Option<usize>,
262 max_properties: Option<usize>,
263 items: Option<Box<Node>>,
264 min_items: Option<usize>,
265 max_items: Option<usize>,
266 unique_items: bool,
267 minimum: Option<f64>,
268 maximum: Option<f64>,
269 exclusive_minimum: Option<f64>,
270 exclusive_maximum: Option<f64>,
271 multiple_of: Option<f64>,
272 min_length: Option<usize>,
273 max_length: Option<usize>,
274 all_of: Vec<Node>,
275 any_of: Vec<Node>,
276 one_of: Vec<Node>,
277 not: Option<Box<Node>>,
278}
279
280#[derive(Debug, Clone, PartialEq)]
282enum Additional {
283 Forbidden,
285 Schema(Box<Node>),
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
292enum Type {
293 Null,
294 Boolean,
295 Object,
296 Array,
297 Number,
298 Integer,
299 String,
300}
301
302impl Type {
303 fn parse(name: &str) -> Option<Self> {
304 Some(match name {
305 "null" => Self::Null,
306 "boolean" => Self::Boolean,
307 "object" => Self::Object,
308 "array" => Self::Array,
309 "number" => Self::Number,
310 "integer" => Self::Integer,
311 "string" => Self::String,
312 _ => return None,
313 })
314 }
315
316 fn name(self) -> &'static str {
317 match self {
318 Self::Null => "null",
319 Self::Boolean => "boolean",
320 Self::Object => "object",
321 Self::Array => "array",
322 Self::Number => "number",
323 Self::Integer => "integer",
324 Self::String => "string",
325 }
326 }
327
328 fn matches(self, value: &Value) -> bool {
334 match self {
335 Self::Null => value.is_null(),
336 Self::Boolean => value.is_boolean(),
337 Self::Object => value.is_object(),
338 Self::Array => value.is_array(),
339 Self::Number => value.is_number(),
340 Self::Integer => value.as_f64().is_some_and(|n| n.fract() == 0.0),
341 Self::String => value.is_string(),
342 }
343 }
344}
345
346fn type_of(value: &Value) -> &'static str {
348 match value {
349 Value::Null => "null",
350 Value::Bool(_) => "boolean",
351 Value::Number(_) => "number",
352 Value::String(_) => "string",
353 Value::Array(_) => "array",
354 Value::Object(_) => "object",
355 }
356}
357
358impl Schema {
359 pub fn parse(text: &str) -> Result<Self, SchemaError> {
366 let value: Value =
367 serde_json::from_str(text).map_err(|e| SchemaError::NotJson(e.to_string()))?;
368 Self::compile(&value)
369 }
370
371 pub fn compile(value: &Value) -> Result<Self, SchemaError> {
379 let root = compile_node(value, "", 0)?;
380 let bytes = boundgraph_core::to_canonical_cbor(value)
381 .map_err(|e| SchemaError::Encode(e.to_string()))?;
382 Ok(Self {
383 root,
384 source: value.clone(),
385 digest: Digest::of(&bytes),
386 })
387 }
388
389 pub const fn digest(&self) -> Digest {
396 self.digest
397 }
398
399 pub const fn source(&self) -> &Value {
401 &self.source
402 }
403
404 pub fn validate(&self, value: &Value) -> Validation {
406 self.validate_within(value, DEFAULT_BUDGET)
407 }
408
409 pub fn validate_within(&self, value: &Value, budget: usize) -> Validation {
413 let mut ctx = Ctx {
414 remaining: budget,
415 violations: Vec::new(),
416 gave_up: false,
417 };
418
419 check(&self.root, value, "", &mut ctx);
420
421 if ctx.gave_up {
422 Validation::Exhausted { budget }
423 } else if ctx.violations.is_empty() {
424 Validation::Valid
425 } else {
426 Validation::Invalid(ctx.violations)
427 }
428 }
429}
430
431struct Ctx {
438 remaining: usize,
439 violations: Vec<Violation>,
440 gave_up: bool,
441}
442
443impl Ctx {
444 fn step(&mut self) -> bool {
446 if self.gave_up {
447 return false;
448 }
449 match self.remaining.checked_sub(1) {
450 Some(left) => {
451 self.remaining = left;
452 true
453 }
454 None => {
455 self.gave_up = true;
456 false
457 }
458 }
459 }
460
461 fn fail(&mut self, path: &str, constraint: &'static str, detail: impl Into<String>) {
462 self.violations.push(Violation {
463 path: path.to_string(),
464 constraint,
465 detail: detail.into(),
466 });
467 }
468}
469
470fn compile_node(value: &Value, path: &str, depth: usize) -> Result<Node, SchemaError> {
472 if depth > MAX_DEPTH {
473 return Err(SchemaError::TooDeep {
474 path: path.to_string(),
475 });
476 }
477
478 let object = match value {
479 Value::Bool(accept) => {
480 return Ok(Node {
481 boolean: Some(*accept),
482 ..Node::default()
483 });
484 }
485 Value::Object(map) => map,
486 other => {
487 return Err(SchemaError::NotASchema {
488 path: path.to_string(),
489 found: type_of(other).to_string(),
490 });
491 }
492 };
493
494 let malformed = |keyword: &str, expected: &str| SchemaError::MalformedKeyword {
495 path: path.to_string(),
496 keyword: keyword.to_string(),
497 expected: expected.to_string(),
498 };
499
500 let mut node = Node::default();
501
502 for (keyword, arg) in object {
503 if ANNOTATIONS.contains(&keyword.as_str()) {
504 continue;
505 }
506 if !SUPPORTED_KEYWORDS.contains(&keyword.as_str()) {
507 return Err(SchemaError::UnsupportedKeyword {
508 path: path.to_string(),
509 keyword: keyword.clone(),
510 });
511 }
512
513 let at = |segment: &str| format!("{path}/{keyword}{segment}");
514
515 match keyword.as_str() {
516 "type" => {
517 let names = match arg {
518 Value::String(one) => vec![one.clone()],
519 Value::Array(many) => many
520 .iter()
521 .map(|v| v.as_str().map(str::to_string))
522 .collect::<Option<Vec<_>>>()
523 .ok_or_else(|| malformed("type", "a type name or an array of them"))?,
524 _ => return Err(malformed("type", "a type name or an array of them")),
525 };
526 let mut types = BTreeSet::new();
527 for name in names {
528 types.insert(Type::parse(&name).ok_or_else(|| {
529 malformed(
530 "type",
531 "one of null, boolean, object, array, number, integer, string",
532 )
533 })?);
534 }
535 node.types = Some(types);
536 }
537 "enum" => {
538 let values = arg
539 .as_array()
540 .ok_or_else(|| malformed("enum", "an array of values"))?;
541 node.enumeration = Some(values.clone());
542 }
543 "const" => node.constant = Some(arg.clone()),
544 "properties" => {
545 let map = arg
546 .as_object()
547 .ok_or_else(|| malformed("properties", "an object of schemas"))?;
548 for (name, sub) in map {
549 node.properties.insert(
550 name.clone(),
551 compile_node(sub, &at(&format!("/{name}")), depth + 1)?,
552 );
553 }
554 }
555 "required" => {
556 let names = arg
557 .as_array()
558 .and_then(|a| {
559 a.iter()
560 .map(|v| v.as_str().map(str::to_string))
561 .collect::<Option<Vec<_>>>()
562 })
563 .ok_or_else(|| malformed("required", "an array of property names"))?;
564 node.required = names;
565 }
566 "additionalProperties" => {
567 node.additional = Some(match arg {
568 Value::Bool(false) => Additional::Forbidden,
569 other => Additional::Schema(Box::new(compile_node(other, &at(""), depth + 1)?)),
570 });
571 }
572 "items" => node.items = Some(Box::new(compile_node(arg, &at(""), depth + 1)?)),
573 "not" => node.not = Some(Box::new(compile_node(arg, &at(""), depth + 1)?)),
574 "allOf" | "anyOf" | "oneOf" => {
575 let branches = arg
576 .as_array()
577 .ok_or_else(|| malformed(keyword, "an array of schemas"))?;
578 let mut compiled = Vec::with_capacity(branches.len());
579 for (index, branch) in branches.iter().enumerate() {
580 compiled.push(compile_node(branch, &at(&format!("/{index}")), depth + 1)?);
581 }
582 match keyword.as_str() {
583 "allOf" => node.all_of = compiled,
584 "anyOf" => node.any_of = compiled,
585 _ => node.one_of = compiled,
586 }
587 }
588 "minProperties" | "maxProperties" | "minItems" | "maxItems" | "minLength"
589 | "maxLength" => {
590 let n = arg
591 .as_u64()
592 .ok_or_else(|| malformed(keyword, "a non-negative integer"))?
593 as usize;
594 match keyword.as_str() {
595 "minProperties" => node.min_properties = Some(n),
596 "maxProperties" => node.max_properties = Some(n),
597 "minItems" => node.min_items = Some(n),
598 "maxItems" => node.max_items = Some(n),
599 "minLength" => node.min_length = Some(n),
600 _ => node.max_length = Some(n),
601 }
602 }
603 "uniqueItems" => {
604 node.unique_items = arg
605 .as_bool()
606 .ok_or_else(|| malformed("uniqueItems", "a boolean"))?;
607 }
608 "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" | "multipleOf" => {
609 let n = arg.as_f64().ok_or_else(|| malformed(keyword, "a number"))?;
610 match keyword.as_str() {
611 "minimum" => node.minimum = Some(n),
612 "maximum" => node.maximum = Some(n),
613 "exclusiveMinimum" => node.exclusive_minimum = Some(n),
614 "exclusiveMaximum" => node.exclusive_maximum = Some(n),
615 _ => {
616 if n <= 0.0 {
617 return Err(malformed("multipleOf", "a number greater than zero"));
618 }
619 node.multiple_of = Some(n);
620 }
621 }
622 }
623 _ => unreachable!("keyword {keyword} is in SUPPORTED_KEYWORDS but not handled"),
624 }
625 }
626
627 Ok(node)
628}
629
630fn pointer(path: &str, segment: &str) -> String {
632 format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
633}
634
635fn check(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) {
640 if !ctx.step() {
643 return;
644 }
645
646 if let Some(accept) = node.boolean {
647 if !accept {
648 ctx.fail(path, "false", "this schema accepts nothing");
649 }
650 return;
651 }
652
653 if let Some(types) = &node.types
654 && !types.iter().any(|t| t.matches(value))
655 {
656 let names: Vec<&str> = types.iter().map(|t| t.name()).collect();
657 ctx.fail(
658 path,
659 "type",
660 format!("expected {}, found {}", names.join(" or "), type_of(value)),
661 );
662 return;
665 }
666
667 if let Some(allowed) = &node.enumeration
668 && !allowed.contains(value)
669 {
670 ctx.fail(
671 path,
672 "enum",
673 format!(
674 "`{value}` is not one of the {} permitted values",
675 allowed.len()
676 ),
677 );
678 }
679
680 if let Some(expected) = &node.constant
681 && value != expected
682 {
683 ctx.fail(
684 path,
685 "const",
686 format!("expected `{expected}`, found `{value}`"),
687 );
688 }
689
690 match value {
691 Value::Object(map) => check_object(node, map, path, ctx),
692 Value::Array(items) => check_array(node, items, path, ctx),
693 Value::String(text) => check_string(node, text, path, ctx),
694 Value::Number(_) => {
695 if let Some(n) = value.as_f64() {
696 check_number(node, n, path, ctx);
697 }
698 }
699 _ => {}
700 }
701
702 check_combinators(node, value, path, ctx);
703}
704
705fn check_object(node: &Node, map: &serde_json::Map<String, Value>, path: &str, ctx: &mut Ctx) {
706 for name in &node.required {
707 if !ctx.step() {
708 return;
709 }
710 if !map.contains_key(name) {
711 ctx.fail(
712 path,
713 "required",
714 format!("missing required property `{name}`"),
715 );
716 }
717 }
718
719 if let Some(min) = node.min_properties
720 && map.len() < min
721 {
722 ctx.fail(
723 path,
724 "minProperties",
725 format!("expected at least {min} properties, found {}", map.len()),
726 );
727 }
728 if let Some(max) = node.max_properties
729 && map.len() > max
730 {
731 ctx.fail(
732 path,
733 "maxProperties",
734 format!("expected at most {max} properties, found {}", map.len()),
735 );
736 }
737
738 for (name, sub) in map {
739 if ctx.gave_up {
740 return;
741 }
742 let at = pointer(path, name);
743 if let Some(schema) = node.properties.get(name) {
744 check(schema, sub, &at, ctx);
745 } else {
746 match node.additional.as_ref() {
747 Some(Additional::Forbidden) => ctx.fail(
748 path,
749 "additionalProperties",
750 format!("property `{name}` is not permitted here"),
751 ),
752 Some(Additional::Schema(schema)) => check(schema, sub, &at, ctx),
753 None => {}
754 }
755 }
756 }
757}
758
759fn check_array(node: &Node, items: &[Value], path: &str, ctx: &mut Ctx) {
760 if let Some(min) = node.min_items
761 && items.len() < min
762 {
763 ctx.fail(
764 path,
765 "minItems",
766 format!("expected at least {min} items, found {}", items.len()),
767 );
768 }
769 if let Some(max) = node.max_items
770 && items.len() > max
771 {
772 ctx.fail(
773 path,
774 "maxItems",
775 format!("expected at most {max} items, found {}", items.len()),
776 );
777 }
778
779 if node.unique_items {
780 for (index, item) in items.iter().enumerate() {
781 if !ctx.step() {
782 return;
783 }
784 if let Some(first) = items[..index].iter().position(|other| other == item) {
785 ctx.fail(
786 &format!("{path}/{index}"),
787 "uniqueItems",
788 format!("duplicates the item at index {first}"),
789 );
790 }
791 }
792 }
793
794 if let Some(schema) = &node.items {
795 for (index, item) in items.iter().enumerate() {
796 if ctx.gave_up {
797 return;
798 }
799 check(schema, item, &format!("{path}/{index}"), ctx);
800 }
801 }
802}
803
804fn check_string(node: &Node, text: &str, path: &str, ctx: &mut Ctx) {
805 let length = text.chars().count();
809
810 if let Some(min) = node.min_length
811 && length < min
812 {
813 ctx.fail(
814 path,
815 "minLength",
816 format!("expected at least {min} characters, found {length}"),
817 );
818 }
819 if let Some(max) = node.max_length
820 && length > max
821 {
822 ctx.fail(
823 path,
824 "maxLength",
825 format!("expected at most {max} characters, found {length}"),
826 );
827 }
828}
829
830fn check_number(node: &Node, n: f64, path: &str, ctx: &mut Ctx) {
831 if let Some(min) = node.minimum
832 && n < min
833 {
834 ctx.fail(path, "minimum", format!("{n} is below the minimum {min}"));
835 }
836 if let Some(max) = node.maximum
837 && n > max
838 {
839 ctx.fail(path, "maximum", format!("{n} is above the maximum {max}"));
840 }
841 if let Some(min) = node.exclusive_minimum
842 && n <= min
843 {
844 ctx.fail(
845 path,
846 "exclusiveMinimum",
847 format!("{n} is not above the exclusive minimum {min}"),
848 );
849 }
850 if let Some(max) = node.exclusive_maximum
851 && n >= max
852 {
853 ctx.fail(
854 path,
855 "exclusiveMaximum",
856 format!("{n} is not below the exclusive maximum {max}"),
857 );
858 }
859 if let Some(step) = node.multiple_of {
860 let quotient = n / step;
861 let off = (quotient - quotient.round()).abs();
865 if off > 1e-9 {
866 ctx.fail(
867 path,
868 "multipleOf",
869 format!("{n} is not a multiple of {step}"),
870 );
871 }
872 }
873}
874
875fn check_combinators(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) {
876 for branch in &node.all_of {
877 if ctx.gave_up {
878 return;
879 }
880 check(branch, value, path, ctx);
881 }
882
883 if !node.any_of.is_empty() {
884 let mut matched = false;
885 for branch in &node.any_of {
886 match matches_quietly(branch, value, path, ctx) {
887 None => return,
888 Some(true) => {
889 matched = true;
890 break;
891 }
892 Some(false) => {}
893 }
894 }
895 if !matched {
896 ctx.fail(
897 path,
898 "anyOf",
899 format!("matched none of the {} alternatives", node.any_of.len()),
900 );
901 }
902 }
903
904 if !node.one_of.is_empty() {
905 let mut matches = 0usize;
906 for branch in &node.one_of {
907 match matches_quietly(branch, value, path, ctx) {
908 None => return,
909 Some(true) => matches += 1,
910 Some(false) => {}
911 }
912 }
913 if matches != 1 {
914 ctx.fail(
915 path,
916 "oneOf",
917 format!(
918 "matched {matches} of the {} alternatives, expected exactly one",
919 node.one_of.len()
920 ),
921 );
922 }
923 }
924
925 if let Some(inner) = &node.not {
926 match matches_quietly(inner, value, path, ctx) {
927 None => (),
928 Some(true) => ctx.fail(path, "not", "matched a schema it must not match"),
929 Some(false) => {}
930 }
931 }
932}
933
934fn matches_quietly(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) -> Option<bool> {
940 let mut probe = Ctx {
941 remaining: ctx.remaining,
942 violations: Vec::new(),
943 gave_up: false,
944 };
945 check(node, value, path, &mut probe);
946
947 ctx.remaining = probe.remaining;
950
951 if probe.gave_up {
952 ctx.gave_up = probe.gave_up;
953 return None;
954 }
955 Some(probe.violations.is_empty())
956}
957
958#[derive(Debug, Clone)]
982pub struct SchemaChecker {
983 schema: Schema,
984 id: CheckerId,
985 budget: usize,
986}
987
988impl SchemaChecker {
989 pub fn new(schema: Schema) -> Self {
991 Self {
992 schema,
993 id: CheckerId::new("json-schema", 1),
994 budget: DEFAULT_BUDGET,
995 }
996 }
997
998 #[must_use]
1000 pub fn within(mut self, budget: usize) -> Self {
1001 self.budget = budget;
1002 self
1003 }
1004
1005 #[must_use]
1008 pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
1009 self.id = CheckerId::new(name, version);
1010 self
1011 }
1012
1013 pub const fn schema(&self) -> &Schema {
1015 &self.schema
1016 }
1017}
1018
1019#[async_trait]
1020impl Checker for SchemaChecker {
1021 fn id(&self) -> CheckerId {
1022 self.id.clone()
1023 }
1024
1025 async fn judge(&self, _order: &WorkOrder, condensate: &Condensate) -> Judgement {
1029 let schema_digest = self.schema.digest();
1030
1031 let payload = match &condensate.payload {
1032 CondensatePayload::Inline(value) => value,
1033 CondensatePayload::Stored(digest) => {
1037 return Judgement::inconclusive(format!(
1038 "the condensate payload is stored at {digest} rather than inline, \
1039 and this checker cannot fetch it"
1040 ))
1041 .covering(schema_digest);
1042 }
1043 };
1044
1045 let json: Value = match payload.deserialized() {
1047 Ok(json) => json,
1048 Err(e) => {
1049 return Judgement::inconclusive(format!(
1050 "the condensate payload is not representable as JSON: {e}"
1051 ))
1052 .covering(schema_digest);
1053 }
1054 };
1055
1056 let judgement = match self.schema.validate_within(&json, self.budget) {
1057 Validation::Valid => Judgement::pass(),
1058 Validation::Invalid(violations) => {
1059 let listed: Vec<String> = violations.iter().map(Violation::render).collect();
1060 Judgement::fail(format!(
1061 "{} schema violation{}: {}",
1062 listed.len(),
1063 if listed.len() == 1 { "" } else { "s" },
1064 listed.join("; ")
1065 ))
1066 }
1067 Validation::Exhausted { budget } => Judgement::inconclusive(format!(
1068 "validation did not finish within {budget} comparisons"
1069 )),
1070 };
1071
1072 judgement.covering(schema_digest)
1073 }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use crate::sign::LocalKey;
1080 use boundgraph_core::{Digest, GrantSet, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
1081 use serde_json::json;
1082
1083 fn schema(value: Value) -> Schema {
1084 Schema::compile(&value).expect("test schema should compile")
1085 }
1086
1087 fn order() -> WorkOrder {
1088 WorkOrder::new(
1089 Intent::new("report.emit", &"summarise the findings").unwrap(),
1090 vec![],
1091 GrantSet::EMPTY,
1092 ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
1093 )
1094 }
1095
1096 fn condensate(payload: &Value) -> Condensate {
1097 Condensate::new(
1098 SchemaId::new("boundgraph.json.v1", 1),
1099 CondensatePayload::inline(payload).unwrap(),
1100 Digest::of(b"the raw response"),
1101 400,
1102 false,
1103 SlicerId::new("json", 1),
1104 )
1105 }
1106
1107 fn violations(schema: &Schema, value: &Value) -> Vec<Violation> {
1108 match schema.validate(value) {
1109 Validation::Invalid(v) => v,
1110 other => panic!("expected violations, got {other:?}"),
1111 }
1112 }
1113
1114 #[test]
1117 fn a_matching_payload_is_valid() {
1118 let s = schema(json!({
1119 "type": "object",
1120 "properties": { "name": { "type": "string" }, "count": { "type": "integer" } },
1121 "required": ["name"],
1122 }));
1123
1124 assert!(
1125 s.validate(&json!({ "name": "auth.rs", "count": 3 }))
1126 .is_valid()
1127 );
1128 }
1129
1130 #[test]
1132 fn a_violation_names_the_path_and_the_constraint() {
1133 let s = schema(json!({
1134 "type": "object",
1135 "properties": {
1136 "items": { "type": "array", "items": { "type": "string" } },
1137 },
1138 }));
1139
1140 let found = violations(&s, &json!({ "items": ["ok", "also ok", 42] }));
1141
1142 assert_eq!(found.len(), 1);
1143 assert_eq!(found[0].path, "/items/2");
1144 assert_eq!(found[0].constraint, "type");
1145 assert!(found[0].detail.contains("expected string"));
1146 assert!(found[0].detail.contains("found number"));
1147 }
1148
1149 #[test]
1150 fn a_missing_required_property_names_it() {
1151 let s = schema(json!({ "type": "object", "required": ["email"] }));
1152 let found = violations(&s, &json!({ "name": "alex" }));
1153
1154 assert_eq!(found[0].constraint, "required");
1155 assert!(found[0].detail.contains("email"));
1156 }
1157
1158 #[test]
1159 fn a_property_name_with_a_slash_is_escaped_in_the_pointer() {
1160 let s = schema(json!({
1161 "type": "object",
1162 "properties": { "a/b": { "type": "string" } },
1163 }));
1164
1165 let found = violations(&s, &json!({ "a/b": 1 }));
1166 assert_eq!(found[0].path, "/a~1b");
1167 }
1168
1169 #[test]
1170 fn the_root_renders_as_root_rather_than_an_empty_path() {
1171 let s = schema(json!({ "type": "string" }));
1172 let found = violations(&s, &json!(1));
1173
1174 assert_eq!(found[0].path, "");
1175 assert!(found[0].render().starts_with("(root)"));
1176 }
1177
1178 #[test]
1179 fn every_supported_keyword_is_enforced() {
1180 let cases: &[(Value, Value, &str)] = &[
1181 (json!({ "type": "string" }), json!(1), "type"),
1182 (json!({ "enum": ["a", "b"] }), json!("c"), "enum"),
1183 (json!({ "const": 7 }), json!(8), "const"),
1184 (
1185 json!({ "type": "object", "required": ["x"] }),
1186 json!({}),
1187 "required",
1188 ),
1189 (
1190 json!({ "type": "object", "properties": { "x": true }, "additionalProperties": false }),
1191 json!({ "y": 1 }),
1192 "additionalProperties",
1193 ),
1194 (
1195 json!({ "type": "object", "minProperties": 2 }),
1196 json!({ "x": 1 }),
1197 "minProperties",
1198 ),
1199 (
1200 json!({ "type": "object", "maxProperties": 1 }),
1201 json!({ "x": 1, "y": 2 }),
1202 "maxProperties",
1203 ),
1204 (
1205 json!({ "type": "array", "minItems": 2 }),
1206 json!([1]),
1207 "minItems",
1208 ),
1209 (
1210 json!({ "type": "array", "maxItems": 1 }),
1211 json!([1, 2]),
1212 "maxItems",
1213 ),
1214 (
1215 json!({ "type": "array", "uniqueItems": true }),
1216 json!([1, 1]),
1217 "uniqueItems",
1218 ),
1219 (json!({ "minimum": 5 }), json!(4), "minimum"),
1220 (json!({ "maximum": 5 }), json!(6), "maximum"),
1221 (
1222 json!({ "exclusiveMinimum": 5 }),
1223 json!(5),
1224 "exclusiveMinimum",
1225 ),
1226 (
1227 json!({ "exclusiveMaximum": 5 }),
1228 json!(5),
1229 "exclusiveMaximum",
1230 ),
1231 (json!({ "multipleOf": 3 }), json!(7), "multipleOf"),
1232 (json!({ "minLength": 3 }), json!("ab"), "minLength"),
1233 (json!({ "maxLength": 1 }), json!("ab"), "maxLength"),
1234 (
1235 json!({ "allOf": [{ "type": "string" }, { "minLength": 5 }] }),
1236 json!("abc"),
1237 "minLength",
1238 ),
1239 (
1240 json!({ "anyOf": [{ "type": "string" }, { "type": "number" }] }),
1241 json!(true),
1242 "anyOf",
1243 ),
1244 (
1245 json!({ "oneOf": [{ "type": "number" }, { "type": "integer" }] }),
1246 json!(2),
1247 "oneOf",
1248 ),
1249 (json!({ "not": { "type": "string" } }), json!("x"), "not"),
1250 ];
1251
1252 for (schema_value, instance, constraint) in cases {
1253 let found = violations(&schema(schema_value.clone()), instance);
1254 assert!(
1255 found.iter().any(|v| v.constraint == *constraint),
1256 "{schema_value} over {instance} should report `{constraint}`, got {found:?}"
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn integer_accepts_a_whole_number_written_as_a_float() {
1263 let s = schema(json!({ "type": "integer" }));
1264 assert!(s.validate(&json!(2.0)).is_valid());
1265 assert!(!s.validate(&json!(2.5)).is_valid());
1266 }
1267
1268 #[test]
1269 fn string_lengths_count_characters_not_bytes() {
1270 let s = schema(json!({ "type": "string", "maxLength": 3 }));
1271 assert!(!s.validate(&json!("héllo")).is_valid());
1272 assert!(s.validate(&json!("héo")).is_valid());
1273 }
1274
1275 #[test]
1276 fn multiple_of_tolerates_ordinary_decimal_arithmetic() {
1277 let s = schema(json!({ "multipleOf": 0.1 }));
1278 assert!(s.validate(&json!(0.3)).is_valid());
1279 assert!(!s.validate(&json!(0.35)).is_valid());
1280 }
1281
1282 #[test]
1283 fn a_boolean_schema_accepts_or_refuses_everything() {
1284 assert!(
1285 schema(json!(true))
1286 .validate(&json!({ "anything": 1 }))
1287 .is_valid()
1288 );
1289 assert!(!schema(json!(false)).validate(&json!(null)).is_valid());
1290 }
1291
1292 #[test]
1295 fn a_type_mismatch_does_not_cascade() {
1296 let s = schema(json!({
1297 "type": "object",
1298 "required": ["a", "b", "c"],
1299 "minProperties": 3,
1300 }));
1301
1302 let found = violations(&s, &json!("not an object at all"));
1303 assert_eq!(found.len(), 1);
1304 assert_eq!(found[0].constraint, "type");
1305 }
1306
1307 #[test]
1308 fn every_violation_is_reported_not_just_the_first() {
1309 let s = schema(json!({
1310 "type": "object",
1311 "properties": { "a": { "type": "string" }, "b": { "type": "string" } },
1312 "required": ["c"],
1313 }));
1314
1315 let found = violations(&s, &json!({ "a": 1, "b": 2 }));
1316 assert_eq!(found.len(), 3, "two type errors and one missing property");
1317 }
1318
1319 #[test]
1322 fn a_failed_any_of_branch_is_not_reported_separately() {
1323 let s = schema(json!({ "anyOf": [{ "type": "string" }, { "type": "number" }] }));
1324 let found = violations(&s, &json!(true));
1325
1326 assert_eq!(found.len(), 1);
1327 assert_eq!(found[0].constraint, "anyOf");
1328 }
1329
1330 #[test]
1335 fn an_unsupported_keyword_is_refused_rather_than_ignored() {
1336 for keyword in [
1337 "$ref",
1338 "pattern",
1339 "patternProperties",
1340 "format",
1341 "if",
1342 "contains",
1343 "propertyNames",
1344 "prefixItems",
1345 ] {
1346 let err = Schema::compile(&json!({ keyword: "whatever" })).unwrap_err();
1347 let SchemaError::UnsupportedKeyword { keyword: named, .. } = err else {
1348 panic!("`{keyword}` should be refused, got {err:?}");
1349 };
1350 assert_eq!(named, keyword);
1351 }
1352 }
1353
1354 #[test]
1355 fn an_unsupported_keyword_is_found_however_deeply_it_is_nested() {
1356 let err = Schema::compile(&json!({
1357 "type": "object",
1358 "properties": { "user": { "properties": { "email": { "format": "email" } } } },
1359 }))
1360 .unwrap_err();
1361
1362 let SchemaError::UnsupportedKeyword { path, keyword } = err else {
1363 panic!("expected UnsupportedKeyword, got {err:?}");
1364 };
1365 assert_eq!(keyword, "format");
1366 assert!(path.contains("user"), "the path should locate it: {path}");
1367 }
1368
1369 #[test]
1370 fn annotations_are_skipped_rather_than_refused() {
1371 let s = Schema::compile(&json!({
1372 "$schema": "https://json-schema.org/draft/2020-12/schema",
1373 "title": "A finding",
1374 "description": "what the step found",
1375 "type": "object",
1376 }));
1377 assert!(s.is_ok(), "{s:?}");
1378 }
1379
1380 #[test]
1381 fn a_malformed_keyword_says_what_it_should_have_been() {
1382 let err = Schema::compile(&json!({ "required": "email" })).unwrap_err();
1383 let SchemaError::MalformedKeyword {
1384 keyword, expected, ..
1385 } = err
1386 else {
1387 panic!("expected MalformedKeyword, got {err:?}");
1388 };
1389 assert_eq!(keyword, "required");
1390 assert!(expected.contains("array"));
1391 }
1392
1393 #[test]
1394 fn an_unknown_type_name_is_refused() {
1395 assert!(Schema::compile(&json!({ "type": "struct" })).is_err());
1396 }
1397
1398 #[test]
1399 fn a_multiple_of_zero_is_refused_rather_than_dividing_by_it() {
1400 assert!(Schema::compile(&json!({ "multipleOf": 0 })).is_err());
1401 }
1402
1403 #[test]
1404 fn something_that_is_not_a_schema_at_all_is_refused() {
1405 let err = Schema::compile(&json!(["a", "list"])).unwrap_err();
1406 assert!(matches!(err, SchemaError::NotASchema { .. }));
1407 }
1408
1409 #[test]
1410 fn a_schema_nested_past_the_depth_limit_is_refused() {
1411 let mut value = json!({ "type": "string" });
1412 for _ in 0..(MAX_DEPTH + 2) {
1413 value = json!({ "type": "object", "properties": { "next": value } });
1414 }
1415
1416 assert!(matches!(
1417 Schema::compile(&value),
1418 Err(SchemaError::TooDeep { .. })
1419 ));
1420 }
1421
1422 #[test]
1423 fn schema_text_that_is_not_json_is_a_typed_error() {
1424 assert!(matches!(
1425 Schema::parse("{not json"),
1426 Err(SchemaError::NotJson(_))
1427 ));
1428 }
1429
1430 #[test]
1434 fn the_same_schema_always_has_the_same_digest() {
1435 let a = schema(json!({ "type": "object", "required": ["x"] }));
1436 let b = Schema::parse(r#"{"required": ["x"], "type": "object"}"#).unwrap();
1437
1438 assert_eq!(a.digest(), b.digest(), "key order is not part of a schema");
1439 }
1440
1441 #[test]
1442 fn a_different_constraint_is_a_different_digest() {
1443 let a = schema(json!({ "type": "object", "required": ["x"] }));
1444 let b = schema(json!({ "type": "object", "required": ["y"] }));
1445
1446 assert_ne!(a.digest(), b.digest());
1447 }
1448
1449 #[test]
1453 fn running_out_of_budget_is_neither_a_pass_nor_a_failure() {
1454 let s = schema(json!({ "type": "array", "items": { "type": "integer" } }));
1455 let big = Value::Array((0..10_000).map(|n| json!(n)).collect());
1456
1457 let outcome = s.validate_within(&big, 50);
1458
1459 assert_eq!(outcome, Validation::Exhausted { budget: 50 });
1460 assert!(!outcome.is_valid());
1461 }
1462
1463 #[test]
1467 fn a_deeply_nested_instance_is_walked_only_as_deep_as_the_schema() {
1468 let s = schema(json!({
1469 "type": "object",
1470 "additionalProperties": { "type": "object" },
1471 }));
1472
1473 let mut instance = json!({});
1474 for _ in 0..1_000 {
1475 instance = json!({ "next": instance });
1476 }
1477
1478 let outcome = s.validate_within(&instance, 16);
1480 assert!(outcome.is_valid(), "{outcome:?}");
1481 }
1482
1483 #[test]
1484 fn nested_alternation_over_a_large_instance_gives_up_rather_than_grinding() {
1485 let branch = json!({
1486 "oneOf": [
1487 { "type": "object", "additionalProperties": { "type": "integer" } },
1488 { "type": "object", "additionalProperties": { "type": "number" } },
1489 { "type": "object", "additionalProperties": { "type": "string" } },
1490 ],
1491 });
1492 let s = schema(json!({ "type": "array", "items": branch }));
1493
1494 let big = Value::Array((0..2_000).map(|n| json!({ "k": n })).collect());
1495 assert!(matches!(
1496 s.validate_within(&big, 200),
1497 Validation::Exhausted { .. }
1498 ));
1499 }
1500
1501 #[tokio::test]
1504 async fn a_matching_payload_passes_and_the_schema_digest_is_in_the_evidence() {
1505 let s = schema(json!({
1506 "type": "object",
1507 "properties": { "severity": { "enum": ["low", "high"] } },
1508 "required": ["severity"],
1509 }));
1510 let digest = s.digest();
1511
1512 let evidence = SchemaChecker::new(s)
1513 .check(
1514 &order(),
1515 &condensate(&json!({ "severity": "high" })),
1516 &LocalKey::from_seed("k", [1u8; 32]),
1517 )
1518 .await
1519 .unwrap();
1520
1521 assert!(evidence.permits_commit());
1522 assert!(
1523 evidence.covers(&digest),
1524 "the evidence must name the schema it was reached against"
1525 );
1526 }
1527
1528 #[tokio::test]
1529 async fn a_violating_payload_fails_with_the_path_in_the_reason() {
1530 let s = schema(json!({
1531 "type": "object",
1532 "properties": { "count": { "type": "integer" } },
1533 }));
1534
1535 let evidence = SchemaChecker::new(s)
1536 .check(
1537 &order(),
1538 &condensate(&json!({ "count": "three" })),
1539 &LocalKey::from_seed("k", [1u8; 32]),
1540 )
1541 .await
1542 .unwrap();
1543
1544 assert!(!evidence.permits_commit());
1545 let reason = evidence.verdict.reason().unwrap();
1546 assert!(reason.contains("/count"), "{reason}");
1547 assert!(reason.contains("type"), "{reason}");
1548 }
1549
1550 #[tokio::test]
1551 async fn a_stored_payload_is_inconclusive_rather_than_a_pass() {
1552 let s = schema(json!({ "type": "object" }));
1553 let mut k = condensate(&json!({}));
1554 k.payload = CondensatePayload::Stored(Digest::of(b"elsewhere"));
1555
1556 let evidence = SchemaChecker::new(s)
1557 .check(&order(), &k, &LocalKey::from_seed("k", [1u8; 32]))
1558 .await
1559 .unwrap();
1560
1561 assert!(!evidence.permits_commit());
1562 assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1563 }
1564
1565 #[tokio::test]
1566 async fn an_exhausted_budget_is_inconclusive_rather_than_a_pass() {
1567 let s = schema(json!({ "type": "array", "items": { "type": "integer" } }));
1568 let big = Value::Array((0..5_000).map(|n| json!(n)).collect());
1569
1570 let evidence = SchemaChecker::new(s)
1571 .within(10)
1572 .check(
1573 &order(),
1574 &condensate(&big),
1575 &LocalKey::from_seed("k", [1u8; 32]),
1576 )
1577 .await
1578 .unwrap();
1579
1580 assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1581 }
1582
1583 #[tokio::test]
1586 async fn a_pass_means_the_payload_deserialises_downstream() {
1587 #[derive(serde::Deserialize)]
1588 struct Finding {
1589 #[allow(dead_code)]
1590 severity: String,
1591 #[allow(dead_code)]
1592 files: Vec<String>,
1593 }
1594
1595 let s = schema(json!({
1596 "type": "object",
1597 "properties": {
1598 "severity": { "type": "string" },
1599 "files": { "type": "array", "items": { "type": "string" } },
1600 },
1601 "required": ["severity", "files"],
1602 }));
1603
1604 let payload = json!({ "severity": "high", "files": ["src/auth.rs"] });
1605 let k = condensate(&payload);
1606
1607 let evidence = SchemaChecker::new(s)
1608 .check(&order(), &k, &LocalKey::from_seed("k", [1u8; 32]))
1609 .await
1610 .unwrap();
1611 assert!(evidence.permits_commit());
1612
1613 let CondensatePayload::Inline(value) = &k.payload else {
1614 unreachable!()
1615 };
1616 let parsed: Result<Finding, _> = value.deserialized();
1617 assert!(parsed.is_ok(), "a pass must mean this cannot fail");
1618 }
1619
1620 #[tokio::test]
1621 async fn the_checker_needs_no_authority_at_all() {
1622 assert_eq!(
1623 SchemaChecker::new(schema(json!(true))).grants(),
1624 GrantSet::EMPTY
1625 );
1626 }
1627
1628 #[tokio::test]
1629 async fn the_same_payload_always_gives_the_same_evidence() {
1630 let checker = SchemaChecker::new(schema(json!({ "type": "object" })));
1631 let (order, k, key) = (
1632 order(),
1633 condensate(&json!({})),
1634 LocalKey::from_seed("k", [1u8; 32]),
1635 );
1636
1637 let first = checker.check(&order, &k, &key).await.unwrap();
1638 for _ in 0..8 {
1639 assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
1640 }
1641 }
1642}