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:
glm-5.3-flashandopencode committed 2026-08-31 17:45:46 +00:00
1 parent e461f01c97
commit e4636e6a44
10 files changed
+1568 -429

No files matched your search

+66 -337
View File
@@ -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
View File
@@ -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
View File
@@ -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