//! `ValidationPlan` — the compiled validation form over a BAST document's //! value-domain constraints (ADR-012 §3, 0.3.0 phase 7). //! //! This is the value-domain analog of the read-side compiled forms: where //! [`crate::sequential_reader`] walks a byte-position plan and //! [`crate::offset_map::OffsetMap`] walks precomputed offsets, this //! module walks a **constraint tree** computed once at compile time. The //! per-buffer interpretive cost — re-resolving `$ref`s (deep-cloning //! inline types per field per buffer via //! [`crate::bast::BastDoc::resolve_typeref`]) and rebuilding field-path //! strings on every node — is paid once at [`ValidationPlan::compile`] //! instead of per buffer. //! //! ## What the plan checks //! //! Exactly the constraint set the interpretive //! [`crate::bast_validation`] walker enforced — nothing more, nothing //! less (behavioral parity): //! //! | Constraint | Plan node | //! |-------------------------------|----------------------| //! | Integer range (Int8..Int32) | [`ValidNode::Int`] | //! | Int64 full range | [`ValidNode::I64`] | //! | Integer range (Uint8..Uint32) | [`ValidNode::Uint`] | //! | Uint64 full range | [`ValidNode::U64`] | //! | Float finiteness | [`ValidNode::Float`] | //! | Boolean | [`ValidNode::Bool`] | //! | String `maxLength` (bytes) | [`ValidNode::Str`] | //! | Bytes `maxLength` (u8 array) | [`ValidNode::Bytes`] | //! | Enum index bounds | [`ValidNode::Enum`] | //! | Union variant dispatch | [`ValidNode::Union`] | //! | Struct fields | [`ValidNode::Struct`]| //! | Array count | [`ValidNode::Array`] | //! | Record values | [`ValidNode::Record`]| //! //! Fields with no declared `maxLength` compile to `max_len: None`, which //! the walk accepts without a length check — parity with the interpretive //! walk. `maxLength` is baked into the leaf node from the *owning field* //! at compile time; it never crosses a `$ref` (a `$ref` always lands on a //! struct/union/enum `$defs` entry, never a primitive, so the interpretive //! walk could never consult it through one either). //! //! The union's declared `fields` (shared fields for field-name //! discriminators) are validated only as part of the *variant* walk: the //! materialized `__discriminator` dispatch selects a variant and the //! whole object is validated against the variant node, mirroring //! [`crate::bast_validation`]'s union arm, which recursed into the //! variant alone. //! //! ## Error rendering //! //! Paths are assembled from a segment stack only when a constraint //! fails, so the happy path performs **zero allocations and zero path //! formatting**. Error messages are byte-identical in format to the //! interpretive walker's (`"a.b[0].c: reason"`, same wording per //! constraint), so consumers matching on //! payloads see no behavioral change. Record keys render as //! `{path}[{key}]` and array elements as `{path}[{i}]`, matching the //! interpretive walker. //! //! ## Untrusted input //! //! Compile is the trust boundary: a malformed or cyclically-referencing //! BAST document surfaces as [`AlkTypeError::Schema`] at `compile` time //! (a depth cap plus a definition-level cycle set protect the stack — //! the interpretive walker had neither), never a panic (AGENTS.md §3). //! The walk itself works over compiled nodes and materialized `Value`s //! only — both sides are already-parsed data. use crate::bast::{BastDefKind, BastDoc, BastStruct, BastType, BastUnion}; use crate::bast_validation::{validation_err, DISCRIMINATOR_KEY}; use crate::error::AlkTypeError; use crate::schema::AlkTypeKind; use serde_json::Value; use std::collections::BTreeSet; use std::fmt::Write as _; /// Maximum recursion depth for the compile walk. Bounds deeply-nested /// (or cyclically-referencing) adversarial schemas; the interpretive /// walker had no guard and could overflow the stack on such input. const MAX_COMPILE_DEPTH: usize = 128; /// The compiled value-domain constraint tree (ADR-012 §3). /// /// Built once via [`ValidationPlan::compile`]; walked per buffer by /// [`ValidationPlan::validate`]. Immutable, owned, `Send + Sync` (the /// engine shares it via `Arc`), with a [`PartialEq`]/[`Hash`]-stable /// structural form and a [`Self::fingerprint`] for downstream plan-cache /// and handshake uses. /// /// The fingerprint contract (ADR-012 §1, §3): two validation plans with /// equal hashes accept/reject identical `(bytes)` inputs identically. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ValidationPlan { root: ValidNode, } impl ValidationPlan { /// Compile the value-domain constraints of a BAST document into a /// plan. /// /// Walks the typed tree once, eagerly resolving every `$ref` (the /// interpretive walk resolved them lazily per buffer). The root may /// be any of the three `$defs` composite kinds — struct, union, or /// enum — matching [`crate::bast_validation`]'s entry conditions. /// /// # Errors /// /// Returns [`AlkTypeError::Schema`] if the document is malformed /// (dangling `$ref`, unsupported node) or if the reference graph /// nests deeper than the compile depth cap (128) or contains a /// reference cycle (a compile-time rejection of self-recursive /// schemas). pub fn compile(doc: &BastDoc) -> Result { let root_def = doc.root_def(); let mut seen = BTreeSet::new(); let root = match root_def.kind() { BastDefKind::Struct(s) => compile_struct(doc, s, "", 0, &mut seen)?, BastDefKind::Union(u) => compile_union(doc, u, "", 0, &mut seen)?, BastDefKind::Enum(e) => compile_enum_node(e)?, }; Ok(ValidationPlan { root }) } /// Validate a materialized `Value` tree against this plan. /// /// This is the per-buffer walk that replaces the interpretive /// `BastDoc` recursion: dispatch on the compiled node (no `$ref` /// resolution, no schema re-parse), with a segment stack for error /// paths (rendered only on failure). /// /// # Errors /// /// Returns [`AlkTypeError::Validation`] with the same message formats /// as the interpretive walk on the first violated constraint. pub fn validate(&self, value: &Value) -> Result<(), AlkTypeError> { let mut path: Vec> = Vec::new(); validate_node(&self.root, value, &mut path) } /// A stable 64-bit fingerprint of this plan's accept/reject behavior. /// /// Two plans with the same fingerprint accept and reject identical /// `(bytes)` inputs identically (the fingerprint contract). Stable /// within a crate version; see ADR-012 §1. pub fn fingerprint(&self) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::hash::DefaultHasher::new(); self.hash(&mut h); h.finish() } /// The compiled root constraint node (diagnostic access). pub fn root(&self) -> &ValidNode { &self.root } } /// A compiled constraint node — one arm per value-domain check. /// /// Public for diagnostics: consumers can inspect the compiled /// constraints via [`ValidationPlan::root`]. Construction is only /// possible through [`ValidationPlan::compile`]. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ValidNode { /// Signed integer in `[min, max]`. Compiled from `Int8`/`Int16`/ /// `Int32`. Int { min: i64, max: i64, }, /// `Int64` — any `i64` value. I64, /// Unsigned integer in `[0, max]`. Compiled from `Uint8`/`Uint16`/ /// `Uint32`. Uint { max: u64, }, /// `Uint64` — any `u64` value. U64, /// `Float32`/`Float64` — finiteness check. Float, /// `Boolean`. Bool, /// `String` with an optional byte-length cap. Str { max_len: Option, }, /// `Bytes` — array of u8 with an optional length cap. Bytes { max_len: Option, }, /// `Enum` — index in `[0, count)`. Enum { count: u64, }, /// `Struct` — every declared field must be present and valid. Struct { fields: Vec, }, /// `Union` — the materialized `__discriminator` selects a variant; /// the whole union instance (including `__discriminator`) is then /// validated against the variant node. Union { variants: Vec, }, /// `Array` — length must equal the declared count; each element /// validated against the element node. Array { count: usize, element: Box, }, /// `Record` — each value validated against the values node. Record { values: Box, }, } /// One declared struct field with its compiled constraint node. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ValidField { name: String, node: ValidNode, } impl ValidField { /// The field's declared name. pub fn name(&self) -> &str { &self.name } /// The field's compiled constraint node (`maxLength` already baked /// in, where applicable). pub fn node(&self) -> &ValidNode { &self.node } } /// One union mapping entry with its compiled variant node. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ValidVariant { key: String, node: ValidNode, } impl ValidVariant { /// The discriminator value this variant answers to (the mapping key). pub fn key(&self) -> &str { &self.key } /// The variant's compiled constraint node. pub fn node(&self) -> &ValidNode { &self.node } } fn compile_enum_node(e: &crate::bast::BastEnum) -> Result { let count = u64::try_from(e.values().len()).map_err(|_| { AlkTypeError::Schema("bast_validation: enum values len overflows u64".to_string()) })?; Ok(ValidNode::Enum { count }) } fn depth_err(path: &str) -> AlkTypeError { AlkTypeError::Schema(format!( "validation_plan: compile depth exceeded {MAX_COMPILE_DEPTH} at {path} \ (cyclic $ref or adversarially deep nesting)" )) } fn cycle_err(name: &str, path: &str) -> AlkTypeError { AlkTypeError::Schema(format!( "validation_plan: cyclic $ref through {name:?} at {path}" )) } /// Compile a possibly-`$ref` type node to its concrete constraint node, /// eagerly. `max_len` is the owning field's `maxLength` (baked into /// `String`/`Bytes` leaves; never crosses a `$ref`, whose target is /// always a struct/union/enum `$defs` entry). fn compile_typeref( doc: &BastDoc, ty: &BastType, max_len: Option, path: &str, depth: usize, seen: &mut BTreeSet, ) -> Result { if depth > MAX_COMPILE_DEPTH { return Err(depth_err(path)); } match ty { BastType::Ref(r) => { let name = r.name(); if !seen.insert(name.to_string()) { return Err(cycle_err(name, path)); } let def = doc.resolve_ref(r)?; let node = match def.kind() { BastDefKind::Struct(s) => compile_struct(doc, s, path, depth + 1, seen)?, BastDefKind::Union(u) => compile_union(doc, u, path, depth + 1, seen)?, BastDefKind::Enum(e) => compile_enum_node(e)?, }; seen.remove(name); Ok(node) } other => compile_resolved(doc, other, max_len, path, depth, seen), } } /// Compile a dereferenced type node (the `Ref` case never reaches here). fn compile_resolved( doc: &BastDoc, ty: &BastType, max_len: Option, path: &str, depth: usize, seen: &mut BTreeSet, ) -> Result { match ty { BastType::Primitive(AlkTypeKind::Int8) => { Ok(ValidNode::Int { min: i8::MIN as i64, max: i8::MAX as i64 }) } BastType::Primitive(AlkTypeKind::Int16) => { Ok(ValidNode::Int { min: i16::MIN as i64, max: i16::MAX as i64 }) } BastType::Primitive(AlkTypeKind::Int32) => { Ok(ValidNode::Int { min: i32::MIN as i64, max: i32::MAX as i64 }) } BastType::Primitive(AlkTypeKind::Int64) => Ok(ValidNode::I64), BastType::Primitive(AlkTypeKind::Uint8) => Ok(ValidNode::Uint { max: u8::MAX as u64 }), BastType::Primitive(AlkTypeKind::Uint16) => Ok(ValidNode::Uint { max: u16::MAX as u64 }), BastType::Primitive(AlkTypeKind::Uint32) => Ok(ValidNode::Uint { max: u32::MAX as u64 }), BastType::Primitive(AlkTypeKind::Uint64) => Ok(ValidNode::U64), BastType::Primitive(AlkTypeKind::Float32 | AlkTypeKind::Float64) => Ok(ValidNode::Float), BastType::Primitive(AlkTypeKind::Boolean) => Ok(ValidNode::Bool), BastType::Primitive(AlkTypeKind::String) => Ok(ValidNode::Str { max_len }), BastType::Primitive(AlkTypeKind::Bytes) => Ok(ValidNode::Bytes { max_len }), BastType::Primitive(other) => Err(AlkTypeError::Schema(format!( "bast_validation: unsupported primitive kind {other} at {path}" ))), BastType::Enum(e) => compile_enum_node(e), BastType::Struct(s) => compile_struct(doc, s, path, depth + 1, seen), BastType::Union(u) => compile_union(doc, u, path, depth + 1, seen), BastType::Array(a) => { if depth > MAX_COMPILE_DEPTH { return Err(depth_err(path)); } let element = compile_typeref(doc, a.element(), None, path, depth + 1, seen)?; Ok(ValidNode::Array { count: a.count(), element: Box::new(element) }) } BastType::Record(r) => { if depth > MAX_COMPILE_DEPTH { return Err(depth_err(path)); } let values = compile_typeref(doc, r.values(), None, path, depth + 1, seen)?; Ok(ValidNode::Record { values: Box::new(values) }) } BastType::Ref(_) => Err(AlkTypeError::Schema(format!( "bast_validation: unresolved $ref at {path} \ (should have been deref'd by compile_typeref)" ))), } } fn compile_struct( doc: &BastDoc, s: &BastStruct, path: &str, depth: usize, seen: &mut BTreeSet, ) -> Result { if depth > MAX_COMPILE_DEPTH { return Err(depth_err(path)); } let mut fields = Vec::with_capacity(s.fields().len()); for field in s.fields() { let field_path = if path.is_empty() { field.name().to_string() } else { format!("{path}.{}", field.name()) }; let node = compile_typeref( doc, field.ty(), field.max_length(), &field_path, depth + 1, seen, )?; fields.push(ValidField { name: field.name().to_string(), node, }); } Ok(ValidNode::Struct { fields }) } fn compile_union( doc: &BastDoc, u: &BastUnion, path: &str, depth: usize, seen: &mut BTreeSet, ) -> Result { if depth > MAX_COMPILE_DEPTH { return Err(depth_err(path)); } let mut variants = Vec::with_capacity(u.mapping().len()); for (key, ty) in u.mapping() { let variant_path = format!("{path}.mapping[{key}]"); let node = compile_typeref(doc, ty, None, &variant_path, depth + 1, seen)?; variants.push(ValidVariant { key: (*key).to_string(), node, }); } Ok(ValidNode::Union { variants }) } /// One already-rendered path segment in the error-path stack. enum PathSeg<'a> { Field(&'a str), Index(usize), Key(&'a str), } impl PathSeg<'_> { fn render(&self, out: &mut String) { match self { PathSeg::Field(name) => { if !out.is_empty() { out.push('.'); } out.push_str(name); } PathSeg::Index(i) => { let _ = write!(out, "[{i}]"); } PathSeg::Key(k) => { let _ = write!(out, "[{k}]"); } } } } fn render_path(path: &[PathSeg<'_>]) -> String { let mut out = String::new(); for seg in path { seg.render(&mut out); } out } fn err_at(path: &[PathSeg<'_>], reason: impl std::fmt::Display) -> AlkTypeError { let rendered = render_path(path); validation_err(&rendered, reason.to_string()) } fn validate_node<'a>( node: &'a ValidNode, value: &'a Value, path: &mut Vec>, ) -> Result<(), AlkTypeError> { match node { ValidNode::Int { min, max } => { let n = value .as_i64() .ok_or_else(|| err_at(path, "expected an integer"))?; if n < *min || n > *max { return Err(err_at( path, format_args!("integer {n} out of range [{min}, {max}]"), )); } Ok(()) } ValidNode::I64 => { if value.as_i64().is_none() { return Err(err_at(path, "expected an i64 integer")); } Ok(()) } ValidNode::Uint { max } => { let n = value .as_u64() .ok_or_else(|| err_at(path, "expected a non-negative integer"))?; if n > *max { return Err(err_at(path, format_args!("integer {n} exceeds {max}"))); } Ok(()) } ValidNode::U64 => { if value.as_u64().is_none() { return Err(err_at(path, "expected a u64 integer")); } Ok(()) } ValidNode::Float => match value { Value::Number(n) => { let f = n .as_f64() .ok_or_else(|| err_at(path, "expected a number"))?; if !f.is_finite() { return Err(err_at(path, "expected a finite number")); } Ok(()) } _ => Err(err_at(path, "expected a number")), }, ValidNode::Bool => match value { Value::Bool(_) => Ok(()), _ => Err(err_at(path, "expected a boolean")), }, ValidNode::Str { max_len } => { let s = value .as_str() .ok_or_else(|| err_at(path, "expected a string"))?; if let Some(max) = max_len { if s.len() > *max { return Err(err_at( path, format_args!("string byte length {} exceeds maxLength {max}", s.len()), )); } } Ok(()) } ValidNode::Bytes { max_len } => { let arr = value .as_array() .ok_or_else(|| err_at(path, "expected an array of u8 for bytes"))?; if let Some(max) = max_len { if arr.len() > *max { return Err(err_at( path, format_args!("bytes array length {} exceeds maxLength {max}", arr.len()), )); } } for (i, entry) in arr.iter().enumerate() { let n = entry.as_u64().ok_or_else(|| { err_at( path, format_args!("bytes array entry {i} is not a non-negative integer"), ) })?; if n > 255 { return Err(err_at( path, format_args!("bytes array entry {i} = {n} is not a u8 (0..=255)"), )); } } Ok(()) } ValidNode::Enum { count } => { let idx = value .as_u64() .ok_or_else(|| err_at(path, "expected a non-negative integer enum index"))?; if idx >= *count { return Err(err_at( path, format_args!( "enum index {idx} out of bounds (values has {count} entries)" ), )); } Ok(()) } ValidNode::Struct { fields } => { let obj = value .as_object() .ok_or_else(|| err_at(path, "expected an object"))?; for field in fields { path.push(PathSeg::Field(field.name())); let field_value = obj .get(field.name()) .ok_or_else(|| err_at(path, format_args!("missing field {:?}", field.name())))?; let res = validate_node(field.node(), field_value, path); path.pop(); res?; } Ok(()) } ValidNode::Union { variants } => { let obj = value .as_object() .ok_or_else(|| err_at(path, "expected an object for union"))?; let disc = obj .get(DISCRIMINATOR_KEY) .ok_or_else(|| { err_at(path, "union instance is missing the '__discriminator' field") })?; let key = match disc { Value::String(s) => s.as_str(), Value::Number(n) => { return validate_union_numeric(variants, n, value, path); } _ => { return Err(err_at( path, "union '__discriminator' must be a string or number", )); } }; let variant = variants .iter() .find(|v| v.key() == key) .ok_or_else(|| { err_at( path, format_args!( "union discriminator value '{key}' not in mapping" ), ) })?; validate_node(variant.node(), value, path) } ValidNode::Array { count, element } => { let arr = value .as_array() .ok_or_else(|| err_at(path, "expected an array"))?; if arr.len() != *count { return Err(err_at( path, format_args!( "array length {} does not match declared count {count}", arr.len() ), )); } for (i, item) in arr.iter().enumerate() { path.push(PathSeg::Index(i)); let res = validate_node(element, item, path); path.pop(); res?; } Ok(()) } ValidNode::Record { values } => { let obj = value .as_object() .ok_or_else(|| err_at(path, "expected an object for record"))?; for (k, v) in obj.iter() { path.push(PathSeg::Key(k)); let res = validate_node(values, v, path); path.pop(); res?; } Ok(()) } } } /// Validate a numeric (`Value::Number`) discriminator. The interpretive /// walker stringified the number with `n.to_string()` and looked the key /// up in the mapping; this does the equivalent without allocating by /// first matching the small integer forms directly, then falling back to /// the string form (values are always stringified integers for byte /// discriminators, so a direct integer match covers every real case). fn validate_union_numeric<'p>( variants: &'p [ValidVariant], n: &serde_json::Number, whole: &'p Value, path: &mut Vec>, ) -> Result<(), AlkTypeError> { let variant = if let Some(i) = n.as_u64() { variants.iter().find(|v| v.key() == i.to_string()) } else if let Some(i) = n.as_i64() { variants.iter().find(|v| v.key() == i.to_string()) } else { variants.iter().find(|v| v.key() == n.to_string()) }; match variant { Some(v) => validate_node(v.node(), whole, path), None => { let key = n.to_string(); Err(err_at( path, format_args!("union discriminator value '{key}' not in mapping"), )) } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn doc_from(root: &Value, name: &str) -> BastDoc { BastDoc::new(root, name).expect("bast doc") } fn plan_for(root: &Value, name: &str) -> ValidationPlan { let d = doc_from(root, name); ValidationPlan::compile(&d).expect("compile") } // ----- Parity with the interpretive walker ---------------------------- #[test] fn parity_uint32_range() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint32" } ] } } }); let plan = plan_for(&root, "S"); assert!( plan.validate(&json!({"id": -1})).is_err(), "negative must fail" ); assert!( plan.validate(&json!({"id": 4294967296u64})).is_err(), "above u32 must fail" ); assert!(plan.validate(&json!({"id": 0})).is_ok()); assert!(plan.validate(&json!({"id": 4294967295u64})).is_ok()); assert!(plan.validate(&json!({"id": "x"})).is_err()); } #[test] fn parity_int8_range() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "v", "kind": "int8" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"v": 0})).is_ok()); assert!(plan.validate(&json!({"v": 127})).is_ok()); assert!(plan.validate(&json!({"v": -128})).is_ok()); assert!(plan.validate(&json!({"v": 128})).is_err()); assert!(plan.validate(&json!({"v": -129})).is_err()); } #[test] fn parity_int64_uint64() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "a", "kind": "int64" }, { "name": "b", "kind": "uint64" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"a": 0, "b": 0})).is_ok()); assert!(plan .validate(&json!({ "a": 9223372036854775807i64, "b": 18446744073709551615u64 })) .is_ok()); assert!(plan.validate(&json!({"a": "x", "b": 0})).is_err()); assert!(plan.validate(&json!({"a": 0, "b": -1})).is_err()); } #[test] fn parity_string_max_length() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "name", "kind": "string", "maxLength": 3 } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"name": "hi"})).is_ok()); let err = plan.validate(&json!({"name": "hello"})).unwrap_err(); assert!( matches!(err, AlkTypeError::Validation(_)), "got {err:?}" ); } #[test] fn parity_bytes_max_length_and_u8_entries() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "blob", "kind": "bytes", "maxLength": 2 } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"blob": [0xAA, 0xBB]})).is_ok()); assert!(plan.validate(&json!({"blob": [1, 2, 3]})).is_err()); let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "blob", "kind": "bytes" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"blob": [65, 256]})).is_err()); assert!(plan.validate(&json!({"blob": [65, "x"]})).is_err()); assert!(plan.validate(&json!({"blob": [65, 255]})).is_ok()); } #[test] fn parity_enum_index_bounds() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } } ] }, "StatusCode": { "kind": "enum", "values": ["Ok", "Error", "Pending"] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"status": 0})).is_ok()); assert!(plan.validate(&json!({"status": 2})).is_ok()); assert!(plan.validate(&json!({"status": 3})).is_err()); assert!(plan.validate(&json!({"status": "Ok"})).is_err()); } #[test] fn parity_union_dispatch_byte_disc() { let root = json!({ "$defs": { "U": { "kind": "union", "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" }, "mapping": { "5": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint32" } ] } } } } }); let plan = plan_for(&root, "U"); assert!(plan .validate(&json!({"__discriminator": 5, "id": 42})) .is_ok()); assert!(plan .validate(&json!({"__discriminator": 5, "id": -1})) .is_err()); assert!(plan .validate(&json!({"__discriminator": 99, "id": 42})) .is_err()); assert!(plan.validate(&json!({"id": 42})).is_err()); assert!(plan.validate(&json!("not-object")).is_err()); } #[test] fn parity_union_dispatch_field_disc() { let root = json!({ "$defs": { "Event": { "kind": "union", "discriminator": { "kind": "field", "name": "type" }, "fields": [ { "name": "type", "kind": "string" } ], "mapping": { "data": { "$ref": "#/$defs/Data" } } }, "Data": { "kind": "struct", "fields": [ { "name": "blob", "kind": "bytes", "maxLength": 1 } ] } } }); let plan = plan_for(&root, "Event"); let ok = json!({ "__discriminator": "data", "type": "data", "blob": [1] }); assert!(plan.validate(&ok).is_ok()); let bad = json!({ "__discriminator": "data", "type": "data", "blob": [1, 2] }); assert!(plan.validate(&bad).is_err()); } #[test] fn parity_array_count_and_struct_elements() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "pts", "kind": { "kind": "array", "element": "uint16", "count": 2 } } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"pts": [1, 2]})).is_ok()); assert!(plan.validate(&json!({"pts": [1, 2, 3]})).is_err()); assert!(plan.validate(&json!({"pts": [1]})).is_err()); assert!(plan.validate(&json!({"pts": "x"})).is_err()); let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "points", "kind": { "kind": "array", "element": { "$ref": "#/$defs/Point" }, "count": 2 } } ] }, "Point": { "kind": "struct", "fields": [ { "name": "x", "kind": "uint16" }, { "name": "y", "kind": "uint16" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"pts": [{"x": 0, "y": 0}]})).is_err()); let good = json!({"points": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}); assert!(plan.validate(&good).is_ok()); let bad = json!({"points": [{"x": 1, "y": 2}, {"x": 3}]}); assert!(plan.validate(&bad).is_err()); } #[test] fn parity_record_values() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "counts", "kind": { "kind": "record", "values": "uint32" } } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"counts": {"a": 1, "b": 2}})).is_ok()); assert!(plan.validate(&json!({"counts": {"a": -1}})).is_err()); assert!(plan.validate(&json!({"counts": "not-object"})).is_err()); } #[test] fn parity_struct_missing_field() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint32" }, { "name": "flag", "kind": "uint8" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"id": 1, "flag": 0})).is_ok()); assert!(plan.validate(&json!({"id": 1})).is_err()); } #[test] fn parity_float_and_bool() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "f", "kind": "float32" }, { "name": "flag", "kind": "bool" } ] } } }); let plan = plan_for(&root, "S"); assert!(plan.validate(&json!({"f": 3.5, "flag": true})).is_ok()); assert!(plan.validate(&json!({"f": 0, "flag": false})).is_ok()); assert!(plan.validate(&json!({"f": "x", "flag": false})).is_err()); assert!(plan.validate(&json!({"f": 0.0, "flag": "yes"})).is_err()); } // ----- Error message format parity (paths) ---------------------------- #[test] fn error_paths_match_interpretive_format() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "inner", "kind": { "kind": "struct", "fields": [ { "name": "blob", "kind": "bytes", "maxLength": 1 } ] } } ] } } }); let plan = plan_for(&root, "S"); let err = plan .validate(&json!({"inner": {"blob": [1, 2]}})) .unwrap_err(); let msg = err.to_string(); assert!( msg.contains("inner.blob"), "expected nested path in {msg}" ); assert!( msg.contains("maxLength"), "expected maxLength wording in {msg}" ); // Array element path rendering: `pts[0]`. let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "pts", "kind": { "kind": "array", "element": "uint16", "count": 1 } } ] } } }); let plan = plan_for(&root, "S"); let err = plan.validate(&json!({"pts": [65536]})).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("pts[0]"), "expected array index in {msg}"); // Record key rendering: `counts[a]`. let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "counts", "kind": { "kind": "record", "values": "uint32" } } ] } } }); let plan = plan_for(&root, "S"); let err = plan.validate(&json!({"counts": {"a": -1}})).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("counts[a]"), "expected record key in {msg}"); } // ----- Fingerprint contract ------------------------------------------- #[test] fn fingerprint_stable_across_compiles_and_sensitive_to_changes() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint32" } ] } } }); let p1 = plan_for(&root, "S"); let p2 = plan_for(&root, "S"); assert_eq!(p1, p2); assert_eq!(p1.fingerprint(), p2.fingerprint()); let changed = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint8" } ] } } }); let p3 = plan_for(&changed, "S"); assert_ne!(p1.fingerprint(), p3.fingerprint()); } // ----- Untrusted schema: cycles, depth, malformed --------------------- #[test] fn cyclic_schema_rejected_at_compile() { let root = json!({ "$defs": { "A": { "kind": "struct", "fields": [ { "name": "next", "kind": { "$ref": "#/$defs/B" } } ] }, "B": { "kind": "struct", "fields": [ { "name": "back", "kind": { "$ref": "#/$defs/A" } } ] } } }); let d = doc_from(&root, "A"); let err = ValidationPlan::compile(&d).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } #[test] fn deep_nesting_rejected_not_stack_overflow() { // Build a 200-deep inline struct nesting. Meta-schema would reject // this, but the plan compiler must survive it on its own (the // engine's meta-schema validation runs before compile in // `AlkTypeEngine::compile`, but `ValidationPlan::compile` is // public and must be safe standalone). let mut fields = json!([{ "name": "v", "kind": "uint8" }]); for _ in 0..200 { fields = json!([{ "name": "n", "kind": { "kind": "struct", "fields": fields } }]); } let root = json!({ "$defs": { "S": { "kind": "struct", "fields": fields } } }); let d = doc_from(&root, "S"); let err = ValidationPlan::compile(&d).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); assert!( err.to_string().contains("depth exceeded"), "expected depth error, got {err:?}" ); } #[test] fn self_referential_ref_chain_rejected() { let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "me", "kind": { "$ref": "#/$defs/S" } } ] } } }); let d = doc_from(&root, "S"); let err = ValidationPlan::compile(&d).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); assert!( err.to_string().contains("cyclic"), "expected cycle error, got {err:?}" ); } #[test] fn shared_refs_compile_without_false_cycle() { // Diamond references: two fields referencing the same def is not // a cycle. The `seen` set is path-scoped, so this must compile. let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [ { "name": "a", "kind": { "$ref": "#/$defs/Point" } }, { "name": "b", "kind": { "$ref": "#/$defs/Point" } } ] }, "Point": { "kind": "struct", "fields": [ { "name": "x", "kind": "uint16" } ] } } }); let d = doc_from(&root, "S"); let plan = ValidationPlan::compile(&d).expect("diamond refs compile"); assert!(plan .validate(&json!({"a": {"x": 1}, "b": {"x": 2}})) .is_ok()); } #[test] fn enum_root_compiles() { let root = json!({ "$defs": { "Status": { "kind": "enum", "values": ["On", "Off"] } } }); let plan = plan_for(&root, "Status"); assert!(plan.validate(&json!(1)).is_ok()); assert!(plan.validate(&json!(2)).is_err()); assert!(plan.validate(&json!("On")).is_err()); } #[test] fn send_sync_bounds_hold() { fn assert_send_sync() {} assert_send_sync::(); assert_send_sync::(); } }