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

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

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

479 lines
18 KiB
Rust

//! 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).
//!
//! ## History
//!
//! 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.
//!
//! ## What remains here
//!
//! - [`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 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
//! `AlkTypeError::Validation` match arm for both paths.
//!
//! ## Untrusted input
//!
//! 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::BastDoc;
use crate::error::AlkTypeError;
use serde_json::Value;
pub(crate) const DISCRIMINATOR_KEY: &str = "__discriminator";
/// Validate a materialized `value` against the BAST root type.
///
/// 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 cyclic `$ref`, a missing `values`
/// array, etc.).
pub fn validate_value(doc: &BastDoc, value: &Value) -> Result<(), AlkTypeError> {
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).
pub(crate) fn validation_err(path: &str, reason: impl Into<String>) -> AlkTypeError {
let msg = if path.is_empty() {
reason.into()
} else {
format!("{path}: {}", reason.into())
};
AlkTypeError::Validation(jsonschema::ValidationError::custom(msg))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::materialize::materialize_packed;
use crate::read_plan::ReadPlan;
use serde_json::json;
fn doc_from(root: &Value, name: &str) -> BastDoc {
BastDoc::new(root, name).expect("bast doc")
}
fn u32_le(n: u32) -> Vec<u8> {
n.to_le_bytes().to_vec()
}
fn prefixed_str_le(s: &str) -> Vec<u8> {
let mut buf = u32_le(s.len() as u32);
buf.extend_from_slice(s.as_bytes());
buf
}
fn materialize_and_validate(
root: &Value,
doc: &BastDoc,
buffer: &[u8],
) -> Result<(), AlkTypeError> {
let plan = ReadPlan::compile(root, doc.root_name())?;
let value = materialize_packed(&plan, 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]
fn uint32_valid_boundary_passes() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "id", "kind": "uint32" }
] } }
});
let d = doc_from(&root, "S");
assert!(materialize_and_validate(&root, &d, &u32_le(0)).is_ok());
assert!(materialize_and_validate(&root, &d, &u32_le(0xFFFF_FFFF)).is_ok());
}
#[test]
fn int8_boundary_passes() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "v", "kind": "int8" }
] } }
});
let d = doc_from(&root, "S");
assert!(materialize_and_validate(&root, &d, &[127u8]).is_ok());
assert!(materialize_and_validate(&root, &d, &[128u8]).is_ok());
}
#[test]
fn validate_value_rejects_uint32_out_of_range() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "id", "kind": "uint32" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"id": -1})).is_err());
assert!(validate_value(&d, &json!({"id": 4294967296u64})).is_err());
assert!(validate_value(&d, &json!({"id": "x"})).is_err());
}
#[test]
fn validate_value_rejects_int8_out_of_range() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "v", "kind": "int8" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"v": 0})).is_ok());
assert!(validate_value(&d, &json!({"v": 127})).is_ok());
assert!(validate_value(&d, &json!({"v": -128})).is_ok());
assert!(validate_value(&d, &json!({"v": 128})).is_err());
assert!(validate_value(&d, &json!({"v": -129})).is_err());
}
#[test]
fn validate_value_int64_and_uint64() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "a", "kind": "int64" },
{ "name": "b", "kind": "uint64" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"a": 0, "b": 0})).is_ok());
assert!(validate_value(&d, &json!({"a": 9223372036854775807i64, "b": 18446744073709551615u64})).is_ok());
assert!(validate_value(&d, &json!({"a": "x", "b": 0})).is_err());
assert!(validate_value(&d, &json!({"a": 0, "b": -1})).is_err());
}
// ----- maxLength -------------------------------------------------------
#[test]
fn string_max_length_enforced() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "name", "kind": "string", "maxLength": 3 }
] } }
});
let d = doc_from(&root, "S");
assert!(materialize_and_validate(&root, &d, &prefixed_str_le("hi")).is_ok());
let err = materialize_and_validate(&root, &d, &prefixed_str_le("hello")).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
#[test]
fn bytes_max_length_enforced_array_form() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "blob", "kind": "bytes", "maxLength": 2 }
] } }
});
let d = doc_from(&root, "S");
let mut buf = u32_le(2);
buf.extend_from_slice(&[0xAA, 0xBB]);
assert!(materialize_and_validate(&root, &d, &buf).is_ok());
let mut buf = u32_le(3);
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
let err = materialize_and_validate(&root, &d, &buf).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
#[test]
fn bytes_array_entry_out_of_range_rejected() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "blob", "kind": "bytes" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"blob": [65, 256]})).is_err());
assert!(validate_value(&d, &json!({"blob": [65, "x"]})).is_err());
assert!(validate_value(&d, &json!({"blob": [65, 255]})).is_ok());
}
// ----- Enum index bounds (the v0.1.0 dead-constraint fix) -------------
#[test]
fn enum_index_in_bounds_passes() {
let root = json!({
"$defs": {
"S": { "kind": "struct", "fields": [
{ "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } }
] },
"StatusCode": { "kind": "enum", "values": ["Ok", "Error", "Pending"] }
}
});
let d = doc_from(&root, "S");
assert!(materialize_and_validate(&root, &d, &u32_le(0)).is_ok());
assert!(materialize_and_validate(&root, &d, &u32_le(2)).is_ok());
}
#[test]
fn enum_index_out_of_bounds_rejected() {
let root = json!({
"$defs": {
"S": { "kind": "struct", "fields": [
{ "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } }
] },
"StatusCode": { "kind": "enum", "values": ["Ok", "Error"] }
}
});
let d = doc_from(&root, "S");
let err = materialize_and_validate(&root, &d, &u32_le(5)).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
// ----- Union variant dispatch (OQ-008) --------------------------------
#[test]
fn union_byte_disc_max_length_inside_variant_enforced() {
let root = json!({
"$defs": {
"Wrapper": { "kind": "struct", "fields": [
{ "name": "payload", "kind": { "$ref": "#/$defs/Packet" } }
] },
"Packet": {
"kind": "union",
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
"mapping": {
"1": { "$ref": "#/$defs/Ack" },
"2": { "$ref": "#/$defs/Data" }
}
},
"Ack": { "kind": "struct", "fields": [ { "name": "code", "kind": "uint8" } ] },
"Data": { "kind": "struct", "fields": [
{ "name": "blob", "kind": "bytes", "maxLength": 2 }
] }
}
});
let d = doc_from(&root, "Wrapper");
let mut buf = vec![2u8];
buf.extend_from_slice(&u32_le(2));
buf.extend_from_slice(&[0xAA, 0xBB]);
assert!(materialize_and_validate(&root, &d, &buf).is_ok());
let mut buf = vec![2u8];
buf.extend_from_slice(&u32_le(3));
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
let err = materialize_and_validate(&root, &d, &buf).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
let buf = vec![1u8, 7u8];
assert!(materialize_and_validate(&root, &d, &buf).is_ok());
}
#[test]
fn union_field_disc_max_length_inside_variant_enforced() {
let root = json!({
"$defs": {
"Wrapper": { "kind": "struct", "fields": [
{ "name": "payload", "kind": { "$ref": "#/$defs/Event" } }
] },
"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 d = doc_from(&root, "Wrapper");
let mut buf = prefixed_str_le("data");
buf.extend_from_slice(&u32_le(2));
buf.extend_from_slice(&[0xFF, 0xFE]);
let err = materialize_and_validate(&root, &d, &buf).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
#[test]
fn validate_value_union_dispatch_on_value() {
let root = json!({
"$defs": {
"U": {
"kind": "union",
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
"mapping": {
"5": { "kind": "struct", "fields": [ { "name": "id", "kind": "uint32" } ] }
}
}
}
});
let d = doc_from(&root, "U");
assert!(validate_value(&d, &json!({"__discriminator": 5, "id": 42})).is_ok());
assert!(validate_value(&d, &json!({"__discriminator": 5, "id": -1})).is_err());
assert!(validate_value(&d, &json!({"__discriminator": 99, "id": 42})).is_err());
assert!(validate_value(&d, &json!({"id": 42})).is_err());
assert!(validate_value(&d, &json!("not-object")).is_err());
}
// ----- Array / Record --------------------------------------------------
#[test]
fn array_count_mismatch_rejected() {
let root = json!({
"$defs": {
"S": { "kind": "struct", "fields": [
{ "name": "pts", "kind": { "kind": "array", "element": "uint16", "count": 2 } }
] }
}
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"pts": [1, 2]})).is_ok());
assert!(validate_value(&d, &json!({"pts": [1, 2, 3]})).is_err());
assert!(validate_value(&d, &json!({"pts": [1]})).is_err());
}
#[test]
fn array_of_structs_validates() {
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 d = doc_from(&root, "S");
let mut buf = Vec::new();
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&2u16.to_le_bytes());
buf.extend_from_slice(&3u16.to_le_bytes());
buf.extend_from_slice(&4u16.to_le_bytes());
assert!(materialize_and_validate(&root, &d, &buf).is_ok());
}
#[test]
fn record_values_validated() {
let root = json!({
"$defs": {
"S": { "kind": "struct", "fields": [
{ "name": "counts", "kind": { "kind": "record", "values": "uint32" } }
] }
}
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"counts": {"a": 1, "b": 2}})).is_ok());
assert!(validate_value(&d, &json!({"counts": {"a": -1}})).is_err());
assert!(validate_value(&d, &json!({"counts": "not-object"})).is_err());
}
// ----- Struct missing field -------------------------------------------
#[test]
fn validate_value_struct_missing_field_rejected() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "id", "kind": "uint32" },
{ "name": "flag", "kind": "uint8" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"id": 1, "flag": 0})).is_ok());
let err = validate_value(&d, &json!({"id": 1})).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
// ----- Untrusted schema: error, not panic -----------------------------
#[test]
fn short_buffer_rejected_with_access_error() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "id", "kind": "uint32" }
] } }
});
let d = doc_from(&root, "S");
let err = materialize_and_validate(&root, &d, &[0u8; 2]).unwrap_err();
assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}");
}
#[test]
fn float_finiteness_checked() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "f", "kind": "float32" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"f": 3.5})).is_ok());
assert!(validate_value(&d, &json!({"f": 0})).is_ok());
assert!(validate_value(&d, &json!({"f": "x"})).is_err());
}
#[test]
fn bool_validated() {
let root = json!({
"$defs": { "S": { "kind": "struct", "fields": [
{ "name": "flag", "kind": "bool" }
] } }
});
let d = doc_from(&root, "S");
assert!(validate_value(&d, &json!({"flag": true})).is_ok());
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:?}");
}
}