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.
1133 lines
40 KiB
Rust
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>();
|
|
}
|
|
} |