Implement ValidationPlan (ADR-012 §3, plan phase 7)
The compiled value-domain validation form: replaces the interpretive BastDoc walk in validate_bytes with a compile-once-walk-many constraint tree built at engine-compile time. This was the design session + implementation ADR-012 §3 delegated; the shape decisions are recorded in new ADR-012 §3a. - New src/validation_plan.rs: ValidationPlan + ValidNode/ValidField/ ValidVariant (Debug+Clone+PartialEq+Eq+Hash+Send+Sync), compile(&BastDoc) with eager $ref resolution, and a per-buffer walk with deferred error-path rendering (zero happy-path allocation, byte-identical error messages vs the 0.2.0 walker). fingerprint() via DefaultHasher, same as the phase-6 pattern. - Compile-time graph safety: definition-level cycle set + depth cap (128) reject cyclic $ref graphs with AlkTypeError::Schema. The interpretive walker resolved refs lazily with no guard (stack- overflow hazard); diamond (shared) refs still compile. - bast_validation.rs: interpretive walker retired (deleted); validate_value survives as a one-shot wrapper (compile + validate) for callers holding a doc without an engine. - engine: Arc<ValidationPlan> built at compile in BOTH modes; the plan compile runs before the layout build and doubles as the engine's cyclic-ref gate (LayoutBuilder/OffsetMap struct recursion has no cycle guard; a cyclic doc previously overflowed there). validate_bytes walks the plan; new accessor validation_plan(). validate_bytes signature unchanged. - lib.rs: pub mod validation_plan + re-exports (ValidationPlan, ValidNode, ValidField, ValidVariant). Verification: cargo test --release (355 pass, incl. parity suite, fingerprint contract, cycle/depth rejection, Send+Sync + thread-share assertions); clippy --all-targets -D warnings clean; cargo doc zero warnings; wasm32-unknown-unknown release build green. Co-authored-by: opencode <noreply@alk.dev>
This commit is contained in:
1 parent
e461f01c97
commit
e4636e6a44
10 files changed
+1568
-429
No files matched your search
+66
-337
@@ -1,44 +1,34 @@
|
||||
//! BAST-native validator — the `validate_bytes` validation step.
|
||||
//! BAST-native validation — the `validate_bytes` validation step,
|
||||
//! now a thin entry over the compiled [`crate::validation_plan::
|
||||
//! ValidationPlan`] (ADR-012 §3, 0.3.0 phase 7).
|
||||
//!
|
||||
//! A recursive walker over the BAST typed tree ([`crate::bast::BastDoc`]/
|
||||
//! [`crate::bast::BastType`]) that checks the value-domain constraints the
|
||||
//! materializer ([`crate::materialize`]) does NOT check. This replaces the
|
||||
//! v0.1.0 `jsonschema` custom-keyword validators on the bytes path
|
||||
//! (D-BAST-006) with a flat match — no factories, no trait objects, no
|
||||
//! sub-validator pre-computation.
|
||||
//! ## History
|
||||
//!
|
||||
//! ## What the materializer already guarantees
|
||||
//! Through 0.2.0 this module *was* the validator: a recursive walker
|
||||
//! over the BAST typed tree ([`crate::bast::BastDoc`]) that resolved
|
||||
//! `$ref`s lazily and rebuilt path strings per node, on every
|
||||
//! `validate_bytes` call. That interpretive walk is the same class of
|
||||
//! per-buffer cost review #004 measured on the read path (400x), so
|
||||
//! ADR-012 §3 (per review #005 M3) committed the `ValidationPlan`: the
|
||||
//! constraint tree (enum allowed-sets, integer ranges, `maxLength` caps,
|
||||
//! union variant keys, array counts, record value types) is compiled
|
||||
//! once at engine-compile time and walked per buffer with no `BastDoc`
|
||||
//! touch. The interpretive walker's checks moved verbatim into the plan
|
||||
//! (see [`crate::validation_plan`]'s constraint table); the last
|
||||
//! interpretive consumers of this module are gone.
|
||||
//!
|
||||
//! By construction, a materialized [`serde_json::Value`] is structurally
|
||||
//! correct: all declared fields are present, types are correct, bounds
|
||||
//! are checked (via [`crate::data_access`]), UTF-8 is valid, the boolean
|
||||
//! byte is 0 or 1, and the union discriminator is in the mapping. The
|
||||
//! validator only needs to enforce the **value-domain constraints
|
||||
//! expressed in the BAST document** — the ones the materializer can't
|
||||
//! see from the bytes alone.
|
||||
//! ## What remains here
|
||||
//!
|
||||
//! ## Constraint table
|
||||
//!
|
||||
//! See [bast-format.md §Validation Model](../../docs/architecture/bast-format.md#validation-model).
|
||||
//! The arms of the `validate_typeref` walker implement the table:
|
||||
//!
|
||||
//! | Constraint | Validator arm |
|
||||
//! |---|---|
|
||||
//! | Integer range (Int8..Uint64) | `validate_int`/`validate_uint` |
|
||||
//! | Int64/Uint64 (full range) | `validate_int64`/`validate_uint64` |
|
||||
//! | Float finiteness (Float32/64) | `validate_float` |
|
||||
//! | String `maxLength` (byte length) | `check_string` reads [`BastField::max_length`](crate::bast::BastField::max_length) |
|
||||
//! | Bytes `maxLength` (array length) | `check_bytes` |
|
||||
//! | Enum index bounds | `validate_enum` — **fixes the v0.1.0 dead constraint** |
|
||||
//! | Union variant dispatch | `validate_union` reads `__discriminator`, resolves the variant, recurses |
|
||||
//! | Struct fields | `validate_struct` walks `fields`, requires each declared field present, recurses |
|
||||
//! | Array count | `validate_array` checks `arr.len() == count` and recurses per element |
|
||||
//! | Record values | `validate_record` recurses into each value's `values` type |
|
||||
//! | Boolean | `validate_bool` (materializer already rejects non-0/1 bytes) |
|
||||
//! - [`validate_value`] — the public one-shot entry (`compile` +
|
||||
//! `validate`), retained for API compatibility and for callers holding
|
||||
//! a `BastDoc` without an engine. The engine does not use it per
|
||||
//! buffer; it holds a compiled plan (ADR-012 §3).
|
||||
//! - `validation_err` / `DISCRIMINATOR_KEY` — shared helpers, also used
|
||||
//! by the plan walk.
|
||||
//!
|
||||
//! ## Error payload (D-BAST-009)
|
||||
//!
|
||||
//! The bytes path no longer touches `jsonschema` for validation, but the
|
||||
//! The bytes path does not touch `jsonschema` for validation, but the
|
||||
//! error variant retains the `jsonschema::ValidationError<'static>` type
|
||||
//! for uniformity with the `validate_json` path. Errors are constructed
|
||||
//! via [`jsonschema::ValidationError::custom`] so consumers handle one
|
||||
@@ -46,324 +36,40 @@
|
||||
//!
|
||||
//! ## Untrusted input
|
||||
//!
|
||||
//! Every walk path returns [`AlkTypeError::Schema`] on a malformed BAST
|
||||
//! document, never `panic!`/`unreachable!`/`unwrap` (AGENTS.md §3). The
|
||||
//! typed tree is parsed once by [`BastDoc::new`](crate::bast::BastDoc::new);
|
||||
//! lazy `$ref` resolution via [`BastDoc::resolve_typeref`] surfaces a
|
||||
//! `Schema` error for dangling refs.
|
||||
//! Malformed BAST documents surface as [`AlkTypeError::Schema`] from
|
||||
//! [`ValidationPlan::compile`](crate::validation_plan::ValidationPlan::compile)
|
||||
//! — including cyclic `$ref` graphs, which the interpretive walker could
|
||||
//! not reject (it would overflow the stack) and now cannot reach.
|
||||
|
||||
use crate::bast::{
|
||||
BastArray, BastDefKind, BastDoc, BastEnum, BastField, BastRecord, BastStruct, BastType,
|
||||
BastUnion,
|
||||
};
|
||||
use crate::bast::BastDoc;
|
||||
use crate::error::AlkTypeError;
|
||||
use crate::schema::AlkTypeKind;
|
||||
use serde_json::Value;
|
||||
|
||||
const DISCRIMINATOR_KEY: &str = "__discriminator";
|
||||
pub(crate) const DISCRIMINATOR_KEY: &str = "__discriminator";
|
||||
|
||||
/// Validate a materialized `value` against the BAST root type.
|
||||
///
|
||||
/// This is the `validate_bytes` validation step: after
|
||||
/// [`crate::materialize::materialize_packed`]/
|
||||
/// [`crate::materialize::materialize_aligned`] produces a `Value` tree
|
||||
/// from the bytes, this walker enforces the value-domain constraints
|
||||
/// expressed in the BAST document. The materializer already guarantees
|
||||
/// structural correctness; the validator only checks what the bytes
|
||||
/// alone can't tell you (integer ranges, `maxLength`,
|
||||
/// enum index bounds, union variant constraints).
|
||||
/// This is the convenience one-shot form: it compiles a
|
||||
/// [`ValidationPlan`](crate::validation_plan::ValidationPlan) from `doc`
|
||||
/// and validates `value` against it. Use this when you hold a BAST
|
||||
/// document and a materialized `Value` but no engine. In the engine's
|
||||
/// hot path (`AlkTypeEngine::validate_bytes`, ADR-010/ADR-012 §3), the
|
||||
/// plan is compiled once and re-walked per buffer without
|
||||
/// re-touching the document.
|
||||
///
|
||||
/// Returns `Err(AlkTypeError::Validation(...))` on the first violated
|
||||
/// constraint, or `Err(AlkTypeError::Schema(...))` if the BAST document
|
||||
/// is malformed (a dangling `$ref`, a missing `values` array, etc.).
|
||||
/// is malformed (a dangling `$ref`, a cyclic `$ref`, a missing `values`
|
||||
/// array, etc.).
|
||||
pub fn validate_value(doc: &BastDoc<'_>, value: &Value) -> Result<(), AlkTypeError> {
|
||||
let root_def = doc.root_def();
|
||||
match root_def.kind() {
|
||||
BastDefKind::Struct(s) => validate_struct(doc, s, value, ""),
|
||||
BastDefKind::Union(u) => validate_union(doc, u, value, ""),
|
||||
BastDefKind::Enum(e) => validate_enum(value, "", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively validate `value` against `ty`, resolving `$ref`s lazily.
|
||||
///
|
||||
/// `field` is the owning field (for `maxLength`/annotations) — `None` for
|
||||
/// synthetic contexts (array elements, record values, the root). The
|
||||
/// validator reads [`BastField::max_length`] only for `String`/`Bytes`
|
||||
/// arms, so passing a synthetic field (which has `max_length == None`) is
|
||||
/// correct for those contexts.
|
||||
fn validate_typeref(
|
||||
doc: &BastDoc<'_>,
|
||||
ty: &BastType<'_>,
|
||||
field: Option<&BastField<'_>>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let resolved = doc.resolve_typeref(ty)?;
|
||||
match &resolved {
|
||||
BastType::Primitive(AlkTypeKind::Int8) => validate_int(value, path, -128, 127),
|
||||
BastType::Primitive(AlkTypeKind::Int16) => validate_int(value, path, -32768, 32767),
|
||||
BastType::Primitive(AlkTypeKind::Int32) => {
|
||||
validate_int(value, path, -2147483648, 2147483647)
|
||||
}
|
||||
BastType::Primitive(AlkTypeKind::Int64) => validate_int64(value, path),
|
||||
BastType::Primitive(AlkTypeKind::Uint8) => validate_uint(value, path, 255),
|
||||
BastType::Primitive(AlkTypeKind::Uint16) => validate_uint(value, path, 65535),
|
||||
BastType::Primitive(AlkTypeKind::Uint32) => validate_uint(value, path, 4294967295),
|
||||
BastType::Primitive(AlkTypeKind::Uint64) => validate_uint64(value, path),
|
||||
BastType::Primitive(AlkTypeKind::Float32) => validate_float(value, path),
|
||||
BastType::Primitive(AlkTypeKind::Float64) => validate_float(value, path),
|
||||
BastType::Primitive(AlkTypeKind::Boolean) => validate_bool(value, path),
|
||||
BastType::Primitive(AlkTypeKind::String) => {
|
||||
check_string(value, path, field.and_then(|f| f.max_length()))
|
||||
}
|
||||
BastType::Primitive(AlkTypeKind::Bytes) => {
|
||||
check_bytes(value, path, field.and_then(|f| f.max_length()))
|
||||
}
|
||||
BastType::Enum(e) => validate_enum(value, path, e),
|
||||
BastType::Struct(s) => validate_struct(doc, s, value, path),
|
||||
BastType::Union(u) => validate_union(doc, u, value, path),
|
||||
BastType::Array(a) => validate_array(doc, a, value, path),
|
||||
BastType::Record(r) => validate_record(doc, r, value, path),
|
||||
BastType::Ref(_) => Err(AlkTypeError::Schema(format!(
|
||||
"bast_validation: unresolved $ref at {path} (resolve_typeref should have deref'd it)"
|
||||
))),
|
||||
BastType::Primitive(k) => Err(AlkTypeError::Schema(format!(
|
||||
"bast_validation: unsupported primitive kind {k} at {path}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_int(value: &Value, path: &str, min: i64, max: i64) -> Result<(), AlkTypeError> {
|
||||
let n = value
|
||||
.as_i64()
|
||||
.ok_or_else(|| validation_err(path, "expected an integer"))?;
|
||||
if n < min || n > max {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("integer {n} out of range [{min}, {max}]"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_int64(value: &Value, path: &str) -> Result<(), AlkTypeError> {
|
||||
if value.as_i64().is_none() {
|
||||
return Err(validation_err(path, "expected an i64 integer"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_uint(value: &Value, path: &str, max: u64) -> Result<(), AlkTypeError> {
|
||||
let n = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| validation_err(path, "expected a non-negative integer"))?;
|
||||
if n > max {
|
||||
return Err(validation_err(path, format!("integer {n} exceeds {max}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_uint64(value: &Value, path: &str) -> Result<(), AlkTypeError> {
|
||||
if value.as_u64().is_none() {
|
||||
return Err(validation_err(path, "expected a u64 integer"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_float(value: &Value, path: &str) -> Result<(), AlkTypeError> {
|
||||
match value {
|
||||
Value::Number(n) => {
|
||||
let f = n
|
||||
.as_f64()
|
||||
.ok_or_else(|| validation_err(path, "expected a number"))?;
|
||||
if !f.is_finite() {
|
||||
return Err(validation_err(path, "expected a finite number"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(validation_err(path, "expected a number")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bool(value: &Value, path: &str) -> Result<(), AlkTypeError> {
|
||||
match value {
|
||||
Value::Bool(_) => Ok(()),
|
||||
_ => Err(validation_err(path, "expected a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_string(value: &Value, path: &str, max_length: Option<usize>) -> Result<(), AlkTypeError> {
|
||||
let s = value
|
||||
.as_str()
|
||||
.ok_or_else(|| validation_err(path, "expected a string"))?;
|
||||
if let Some(max) = max_length {
|
||||
if s.len() > max {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("string byte length {} exceeds maxLength {max}", s.len()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_bytes(value: &Value, path: &str, max_length: Option<usize>) -> Result<(), AlkTypeError> {
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| validation_err(path, "expected an array of u8 for bytes"))?;
|
||||
if let Some(max) = max_length {
|
||||
if arr.len() > max {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("bytes array length {} exceeds maxLength {max}", arr.len()),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (i, entry) in arr.iter().enumerate() {
|
||||
let n = entry.as_u64().ok_or_else(|| {
|
||||
validation_err(
|
||||
path,
|
||||
format!("bytes array entry {i} is not a non-negative integer"),
|
||||
)
|
||||
})?;
|
||||
if n > 255 {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("bytes array entry {i} = {n} is not a u8 (0..=255)"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enum validation on the bytes path: the materializer emits a numeric
|
||||
/// index (`Value::Number`), and the constraint is that the index is
|
||||
/// within the `values` array bounds (`0..len-1`).
|
||||
///
|
||||
/// This is the **fix for the v0.1.0 dead constraint**: the built-in
|
||||
/// `enum` keyword checked string membership, but the materializer emitted
|
||||
/// a numeric index that never matched — so out-of-bounds enum indices
|
||||
/// silently passed. The BAST-native validator checks the index bounds
|
||||
/// directly.
|
||||
fn validate_enum(value: &Value, path: &str, enum_def: &BastEnum<'_>) -> Result<(), AlkTypeError> {
|
||||
let idx = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| validation_err(path, "expected a non-negative integer enum index"))?;
|
||||
let len = enum_def.values().len() as u64;
|
||||
if idx >= len {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("enum index {idx} out of bounds (values has {len} entries)"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_struct(
|
||||
doc: &BastDoc<'_>,
|
||||
struct_def: &BastStruct<'_>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| validation_err(path, "expected an object"))?;
|
||||
for field in struct_def.fields() {
|
||||
let name = field.name();
|
||||
let field_path = if path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{path}.{name}")
|
||||
};
|
||||
let field_value = obj.get(name).ok_or_else(|| {
|
||||
validation_err(&field_path, format!("missing field {name:?}"))
|
||||
})?;
|
||||
validate_typeref(doc, field.ty(), Some(field), field_value, &field_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Union validation: read `__discriminator`, look up the variant
|
||||
/// [`BastType`] in the union's `mapping`, and recurse into the variant.
|
||||
///
|
||||
/// This recovers OQ-008 per-variant constraint enforcement (e.g.
|
||||
/// `maxLength` on a `bytes` field inside a variant struct) without
|
||||
/// custom keywords — the recursion walks the variant's BAST definition
|
||||
/// and enforces every field constraint it declares.
|
||||
fn validate_union(
|
||||
doc: &BastDoc<'_>,
|
||||
union_def: &BastUnion<'_>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| validation_err(path, "expected an object for union"))?;
|
||||
let disc = obj.get(DISCRIMINATOR_KEY).ok_or_else(|| {
|
||||
validation_err(path, "union instance is missing the '__discriminator' field")
|
||||
})?;
|
||||
let key = match disc {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
"union '__discriminator' must be a string or number",
|
||||
));
|
||||
}
|
||||
};
|
||||
let variant_ty = union_def.variant_for(&key).ok_or_else(|| {
|
||||
validation_err(path, format!("union discriminator value '{key}' not in mapping"))
|
||||
})?;
|
||||
validate_typeref(doc, variant_ty, None, value, path)
|
||||
}
|
||||
|
||||
fn validate_array(
|
||||
doc: &BastDoc<'_>,
|
||||
array_def: &BastArray<'_>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| validation_err(path, "expected an array"))?;
|
||||
let count = array_def.count();
|
||||
if arr.len() != count {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("array length {} does not match declared count {count}", arr.len()),
|
||||
));
|
||||
}
|
||||
let element_ty = array_def.element();
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
let item_path = format!("{path}[{i}]");
|
||||
validate_typeref(doc, element_ty, None, item, &item_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_record(
|
||||
doc: &BastDoc<'_>,
|
||||
record_def: &BastRecord<'_>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| validation_err(path, "expected an object for record"))?;
|
||||
let values_ty = record_def.values();
|
||||
for (k, v) in obj.iter() {
|
||||
let entry_path = format!("{path}[{k}]");
|
||||
validate_typeref(doc, values_ty, None, v, &entry_path)?;
|
||||
}
|
||||
Ok(())
|
||||
let plan = crate::validation_plan::ValidationPlan::compile(doc)?;
|
||||
plan.validate(value)
|
||||
}
|
||||
|
||||
/// Construct a `Validation` error from a path + reason string. The
|
||||
/// payload is a `jsonschema::ValidationError::custom` so the variant
|
||||
/// type stays uniform with the `validate_json` path (D-BAST-009).
|
||||
fn validation_err(path: &str, reason: impl Into<String>) -> AlkTypeError {
|
||||
pub(crate) fn validation_err(path: &str, reason: impl Into<String>) -> AlkTypeError {
|
||||
let msg = if path.is_empty() {
|
||||
reason.into()
|
||||
} else {
|
||||
@@ -375,6 +81,7 @@ fn validation_err(path: &str, reason: impl Into<String>) -> AlkTypeError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::materialize::materialize_packed;
|
||||
use serde_json::json;
|
||||
|
||||
fn doc_from<'a>(root: &'a Value, name: &'a str) -> BastDoc<'a> {
|
||||
@@ -395,10 +102,15 @@ mod tests {
|
||||
doc: &BastDoc<'_>,
|
||||
buffer: &[u8],
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let value = crate::materialize::materialize_packed(doc, buffer)?;
|
||||
let value = materialize_packed(doc, buffer)?;
|
||||
validate_value(doc, &value)
|
||||
}
|
||||
|
||||
// The tests below are the *parity* suite: they drive validation
|
||||
// end-to-end (materialize -> validate_value) through the public API
|
||||
// exactly as the interpretive walker's tests did, so any behavioral
|
||||
// change in the compiled plan shows up here.
|
||||
|
||||
// ----- Integer ranges --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -744,4 +456,21 @@ mod tests {
|
||||
assert!(validate_value(&d, &json!({"flag": false})).is_ok());
|
||||
assert!(validate_value(&d, &json!({"flag": "yes"})).is_err());
|
||||
}
|
||||
|
||||
// ----- Cyclic schema: the new compile-time guard ----------------------
|
||||
|
||||
#[test]
|
||||
fn cyclic_bast_doc_rejected_with_schema_error() {
|
||||
let root = json!({
|
||||
"$defs": {
|
||||
"S": { "kind": "struct", "fields": [
|
||||
{ "name": "me", "kind": { "$ref": "#/$defs/S" } }
|
||||
] }
|
||||
}
|
||||
});
|
||||
let d = doc_from(&root, "S");
|
||||
let err = validate_value(&d, &json!({})).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||||
assert!(err.to_string().contains("cyclic"), "got {err:?}");
|
||||
}
|
||||
}
|
||||
+118
-9
@@ -17,7 +17,6 @@
|
||||
//! [overview.md](../../docs/architecture/overview.md).
|
||||
|
||||
use crate::bast::{BastDefKind, BastDoc, BastStruct, BastType};
|
||||
use crate::bast_validation;
|
||||
use crate::data_access;
|
||||
use crate::error::AlkTypeError;
|
||||
use crate::layout_builder::LayoutBuilder;
|
||||
@@ -26,8 +25,10 @@ use crate::offset_map::OffsetMap;
|
||||
use crate::schema::{AlkTypeKind, Endian, VariableEncoding};
|
||||
use crate::sequential_reader::{FieldValue, SequentialReader};
|
||||
use crate::validation;
|
||||
use crate::validation_plan::ValidationPlan;
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The layout mode selected at engine construction time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -71,7 +72,7 @@ enum Layout {
|
||||
///
|
||||
/// Validation is split (D-BAST-006, D-BAST-007):
|
||||
/// - [`AlkTypeEngine::validate_bytes`] uses the BAST-native validator
|
||||
/// ([`bast_validation`]) — no `jsonschema` involvement, no external
|
||||
/// ([`crate::bast_validation`]) — no `jsonschema` involvement, no external
|
||||
/// JSON Schema required.
|
||||
/// - [`AlkTypeEngine::validate_json`] / [`AlkTypeEngine::is_valid_json`]
|
||||
/// use a standard `jsonschema::Validator` compiled at construction
|
||||
@@ -81,6 +82,7 @@ enum Layout {
|
||||
pub struct AlkTypeEngine {
|
||||
layout: Layout,
|
||||
json_validator: Option<jsonschema::Validator>,
|
||||
validation_plan: Arc<ValidationPlan>,
|
||||
endian: Endian,
|
||||
bast_doc: Value,
|
||||
root_name: String,
|
||||
@@ -139,6 +141,13 @@ impl AlkTypeEngine {
|
||||
}
|
||||
};
|
||||
let endian = struct_node.endian();
|
||||
// The compiled value-domain constraint tree (ADR-012 §3) — built
|
||||
// once here, walked per buffer by `validate_bytes`. It is also the
|
||||
// engine's reference-graph gate: its compile walk rejects cyclic
|
||||
// `$ref` graphs (with a clean `Schema` error) before the layout
|
||||
// builders below, whose struct/union recursion has no cycle guard
|
||||
// and would otherwise recurse unboundedly on such a document.
|
||||
let validation_plan = Arc::new(ValidationPlan::compile(&doc)?);
|
||||
let layout = match mode {
|
||||
LayoutMode::Packed => {
|
||||
let builder = LayoutBuilder::new(bast_doc, root_name)?;
|
||||
@@ -156,6 +165,7 @@ impl AlkTypeEngine {
|
||||
Ok(Self {
|
||||
layout,
|
||||
json_validator,
|
||||
validation_plan,
|
||||
endian,
|
||||
bast_doc: bast_doc.clone(),
|
||||
root_name: root_name.to_string(),
|
||||
@@ -256,17 +266,22 @@ impl AlkTypeEngine {
|
||||
|
||||
/// Validate a binary buffer against the schema by materializing a
|
||||
/// `serde_json::Value` tree from the bytes (walking the layout engine)
|
||||
/// and then validating that `Value` against the BAST-native validator
|
||||
/// — a recursive walker over the BAST type tree that checks the
|
||||
/// value-domain constraints the materializer does not (integer
|
||||
/// ranges, `maxLength`, enum index bounds, union
|
||||
/// variant constraints). Decided in D-BAST-006; see
|
||||
/// and then validating that `Value` against the compiled
|
||||
/// [`ValidationPlan`] — the engine's value-domain constraint tree
|
||||
/// (integer ranges, `maxLength`, enum index bounds, union variant
|
||||
/// constraints), built once at [`AlkTypeEngine::compile`] from the
|
||||
/// BAST document (ADR-010; ADR-012 §3). Decided in D-BAST-006; see
|
||||
/// [bast-format.md §Validation Model](../../docs/architecture/bast-format.md#validation-model).
|
||||
///
|
||||
/// The materialize half is the only per-buffer schema-touching step
|
||||
/// (the bytes must be decoded against the tree); the validation half
|
||||
/// walks the compiled plan — no `$ref` re-resolution, no schema
|
||||
/// re-parse per buffer.
|
||||
///
|
||||
/// Dispatches on the engine's layout mode: packed mode walks
|
||||
/// sequentially from offset 0; aligned mode reads at offsets from
|
||||
/// the `OffsetMap`. Both produce the same `Value` form; the
|
||||
/// BAST-native validator is mode-agnostic.
|
||||
/// validation plan is mode-agnostic.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -288,7 +303,22 @@ impl AlkTypeEngine {
|
||||
materialize::materialize_aligned(&doc, buffer, offset_map)?
|
||||
}
|
||||
};
|
||||
bast_validation::validate_value(&doc, &value)
|
||||
self.validation_plan.validate(&value)
|
||||
}
|
||||
|
||||
/// Access the compiled [`ValidationPlan`] (ADR-012 §3).
|
||||
///
|
||||
/// The plan is the engine's value-domain constraint tree — built once
|
||||
/// at [`AlkTypeEngine::compile`] from the BAST document, shared via
|
||||
/// `Arc`, and walked by [`AlkTypeEngine::validate_bytes`] per buffer
|
||||
/// without touching the BAST document. Exposed for consumers that
|
||||
/// want to validate their *own* materialized `Value` trees
|
||||
/// (e.g. one produced by an external reader) against the same
|
||||
/// constraints, or that want the plan's
|
||||
/// [`fingerprint`](ValidationPlan::fingerprint) for caching or
|
||||
/// schema handshakes.
|
||||
pub fn validation_plan(&self) -> &Arc<ValidationPlan> {
|
||||
&self.validation_plan
|
||||
}
|
||||
|
||||
/// Read a field from a buffer at its computed offset (aligned mode).
|
||||
@@ -1287,6 +1317,85 @@ mod tests {
|
||||
assert!(engine.validate_bytes(&buf).is_ok());
|
||||
}
|
||||
|
||||
// ----- ValidationPlan / validate_bytes (ADR-012 §3, phase 7) ----------
|
||||
|
||||
#[test]
|
||||
fn validation_plan_accessor_returns_compiled_plan() {
|
||||
let doc = uint32_struct_bast();
|
||||
let engine =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile");
|
||||
let plan = engine.validation_plan();
|
||||
assert!(plan.validate(&json!({"id": 42})).is_ok());
|
||||
assert!(plan.validate(&json!({"id": -1})).is_err());
|
||||
// Same schema compiled twice produces equal plans (fingerprint
|
||||
// contract), so the accessor reflects the compile-time build.
|
||||
let engine2 =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile");
|
||||
assert_eq!(plan, engine2.validation_plan());
|
||||
assert_eq!(plan.fingerprint(), engine2.validation_plan().fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_enforces_value_domain_via_plan_in_both_modes() {
|
||||
let doc = json!({
|
||||
"$defs": { "S": { "kind": "struct", "endian": "little", "fields": [
|
||||
{ "name": "status", "kind": { "$ref": "#/$defs/Status" } }
|
||||
] },
|
||||
"Status": { "kind": "enum", "values": ["Ok", "Err"] } }
|
||||
});
|
||||
// Packed: enum index 5 is out of bounds for the 2-value enum.
|
||||
let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile");
|
||||
let err = engine.validate_bytes(&5u32.to_le_bytes()).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
|
||||
// Aligned: same constraint, offset read path (the enum leaf sits
|
||||
// at offset 0 — no alignment padding for a lone u32-width leaf).
|
||||
let engine =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile");
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
|
||||
let err = engine.validate_bytes(&buf).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
|
||||
// Valid index passes in both modes.
|
||||
let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile");
|
||||
assert!(engine.validate_bytes(&1u32.to_le_bytes()).is_ok());
|
||||
let engine =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile");
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(engine.validate_bytes(&buf).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_rejects_cyclic_ref_graph_with_schema_error() {
|
||||
let doc = json!({
|
||||
"$defs": {
|
||||
"A": { "kind": "struct", "fields": [
|
||||
{ "name": "next", "kind": { "$ref": "#/$defs/B" } }
|
||||
] },
|
||||
"B": { "kind": "struct", "fields": [
|
||||
{ "name": "back", "kind": { "$ref": "#/$defs/A" } }
|
||||
] }
|
||||
}
|
||||
});
|
||||
let err = AlkTypeEngine::compile(&doc, "A", LayoutMode::Packed, None).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||||
assert!(err.to_string().contains("cyclic"), "got {err:?}");
|
||||
let err = AlkTypeEngine::compile(&doc, "A", LayoutMode::Aligned, None).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_plan_is_send_sync_shared() {
|
||||
let doc = uint32_struct_bast();
|
||||
let engine =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile");
|
||||
fn assert_send_sync<T: Send + Sync>(_: &T) {}
|
||||
assert_send_sync(engine.validation_plan());
|
||||
let shared:std::sync::Arc<_> = engine.validation_plan().clone();
|
||||
let handle = std::thread::spawn(move || shared.validate(&json!({"id": 1})).is_ok());
|
||||
assert!(handle.join().expect("join"));
|
||||
}
|
||||
|
||||
// ----- validate_json / is_valid_json tests (step 6, D-BAST-007) -----
|
||||
|
||||
fn uint32_struct_bast() -> Value {
|
||||
|
||||
+13
-7
@@ -19,13 +19,17 @@
|
||||
//! offsets, zero-copy for fixed-size types.
|
||||
//! - **TUnion dispatch** ([`tunion`]): Byte-offset and field-name
|
||||
//! discriminator dispatch over `BastUnion`.
|
||||
//! - **Validation** ([`validation`], [`bast_validation`]): two
|
||||
//! validators for two paths. `bast_validation` is the BAST-native
|
||||
//! validator for `validate_bytes` — a recursive walker over the BAST
|
||||
//! type tree (D-BAST-006). `validation` builds a standard
|
||||
//! `jsonschema::Validator` from a consumer-provided JSON Schema for
|
||||
//! `validate_json` / `is_valid_json` (D-BAST-007) — no custom keywords,
|
||||
//! no BAST involvement.
|
||||
//! - **Validation** ([`validation`], [`bast_validation`],
|
||||
//! [`validation_plan`]): two validators for two paths.
|
||||
//! `validation_plan` is the compiled `ValidationPlan` — a
|
||||
//! compile-once-walk-many constraint tree over the BAST document's
|
||||
//! value-domain constraints, walked by `validate_bytes` per buffer
|
||||
//! without re-touching the document (ADR-012 §3). `bast_validation`
|
||||
//! hosts the one-shot `validate_value` wrapper over the plan.
|
||||
//! `validation` builds a standard `jsonschema::Validator` from a
|
||||
//! consumer-provided JSON Schema for `validate_json` /
|
||||
//! `is_valid_json` (D-BAST-007) — no custom keywords, no BAST
|
||||
//! involvement.
|
||||
//! - **Builder** ([`builder`]): Fluent Rust API for constructing BAST
|
||||
//! documents (binary layout) and standard JSON Schemas (JSON
|
||||
//! validation) at runtime, producing `serde_json::Value` (ADR-009,
|
||||
@@ -52,6 +56,7 @@ pub mod schema;
|
||||
pub mod sequential_reader;
|
||||
pub mod tunion;
|
||||
pub mod validation;
|
||||
pub mod validation_plan;
|
||||
|
||||
pub use bast_meta::BAST_META_SCHEMA;
|
||||
pub use bast::{
|
||||
@@ -67,3 +72,4 @@ pub use schema::{Endian, AlkTypeKind, VariableEncoding};
|
||||
pub use sequential_reader::{FieldValue, SequentialReader};
|
||||
pub use tunion::UnionDispatch;
|
||||
pub use validation::build_validator;
|
||||
pub use validation_plan::{ValidField, ValidNode, ValidVariant, ValidationPlan};
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user