Files
alktype/src/validation_plan.rs
T
glm-5.3-flash c583762352 Make BastDoc owned: drop Bast* lifetimes (ADR-012 §2a, plan phase 3)
Every Bast* type drops <'a>: &'a str -> String, &'a Value -> Value
(deferred decision 1: plain String/Value — the tree is built once;
Arc<str> name-sharing needs a bench justification that doesn't exist).
BastDoc::new(&Value, &str) still takes references in and clones into
owned storage; the doc gains Clone. resolve_ref/resolve_typeref/
resolve_typeref_as_def return owned types.

- Engine ownership flip: AlkTypeEngine holds the owned BastDoc
  (replacing bast_doc: Value + root_name: String; root_name()
  delegates to the doc), killing its three per-call BastDoc::new
  re-parses (aligned validate_bytes, read_field, write_field — the
  review #004 M1 pattern removed by construction; phase 5 retires the
  lookup_leaf_field walk itself). New public accessor root_name()
  (additive). Engine Send + Sync with the owned doc, asserted in the
  existing thread-share test.
- Bonus cleanup: materialize_typeref_packed's dead _field param
  dropped (phase 2 left it dangling). Under ownership, keeping it
  would force a deep Value clone per array element / record value /
  union variant via dummy_field_for. The param, dummy_field_for, and
  ty_source are gone; no behavior change (the arg was already
  ignored). BastField::synthetic keeps an owned-signature
  #[allow(dead_code)] definition (no remaining callers today).
- Consumers adapted: OffsetMap::compute(&BastDoc),
  materialize_aligned(&BastDoc, ...) (no lifetime), BuildCtx/
  ComputeCtx hold &'d BastDoc, tunion/discriminator name borrows,
  lib.rs module doc. LayoutBuilder's doc_value re-parse cache is
  unchanged pending phase 4.

Verification: 465 tests pass with zero test-logic changes (bast.rs
suites exercise every parser path through the public API); clippy
-D warnings clean; cargo doc zero warnings; wasm32 release build
green.
2026-09-02 08:31:58 +00:00

1133 lines
40 KiB
Rust

//! `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<Self, AlkTypeError> {
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<PathSeg<'_>> = 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<usize>,
},
/// `Bytes` — array of u8 with an optional length cap.
Bytes {
max_len: Option<usize>,
},
/// `Enum` — index in `[0, count)`.
Enum {
count: u64,
},
/// `Struct` — every declared field must be present and valid.
Struct {
fields: Vec<ValidField>,
},
/// `Union` — the materialized `__discriminator` selects a variant;
/// the whole union instance (including `__discriminator`) is then
/// validated against the variant node.
Union {
variants: Vec<ValidVariant>,
},
/// `Array` — length must equal the declared count; each element
/// validated against the element node.
Array {
count: usize,
element: Box<ValidNode>,
},
/// `Record` — each value validated against the values node.
Record {
values: Box<ValidNode>,
},
}
/// 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<ValidNode, AlkTypeError> {
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<usize>,
path: &str,
depth: usize,
seen: &mut BTreeSet<String>,
) -> Result<ValidNode, AlkTypeError> {
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<usize>,
path: &str,
depth: usize,
seen: &mut BTreeSet<String>,
) -> Result<ValidNode, AlkTypeError> {
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<String>,
) -> Result<ValidNode, AlkTypeError> {
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<String>,
) -> Result<ValidNode, AlkTypeError> {
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<PathSeg<'a>>,
) -> 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<PathSeg<'p>>,
) -> 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<T: Send + Sync>() {}
assert_send_sync::<ValidationPlan>();
assert_send_sync::<ValidNode>();
}
}