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.
479 lines
18 KiB
Rust
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:?}");
|
|
}
|
|
} |