Add BAST-native validator for validate_bytes (step 5)
Replace the jsonschema custom-keyword validator on the bytes path with a recursive walker over the BAST typed tree. The materializer already guarantees structural correctness; the validator enforces only the value-domain constraints expressed in the BAST document. - New `src/bast_validation.rs`: `validate_value` dispatches on `BastType`, resolving `$ref`s lazily via `BastDoc::resolve_typeref`. Constraint arms: integer ranges (Int8..Uint64), float finiteness, string/bytes `maxLength` (from `BastField::max_length`), RFC 3339 timestamp shape, enum index bounds, union variant dispatch (recurses into the variant, recovering OQ-008 per-variant constraints), struct field presence, array count, record values. - `engine::validate_bytes` now calls `bast_validation::validate_value` instead of `self.validator.validate`. The `validator` field is still built and used by `validate_json`/`is_valid_json` (step 6 reworks those). - Enum index bounds check (`idx < values.len()`) fixes the v0.1.0 dead constraint: the built-in `enum` keyword checked string membership, but the materializer emits a numeric index that never matched. - Errors constructed via `jsonschema::ValidationError::custom` so `AlkTypeError::Validation` keeps its payload type uniform with the `validate_json` path (D-BAST-009). - `lib.rs`: add `pub mod bast_validation;` (engine-internal, not re-exported in the `pub use` block) and update the module doc. Verification: - cargo test --release: 446 pass (369 lib + 77 integration) - cargo clippy --all-targets -- -D warnings: clean - cargo build --target wasm32-unknown-unknown --release: clean - cargo doc --no-deps: clean
This commit is contained in:
1 parent
04573e1d86
commit
f853dafaf1
3 files changed
+853
-14
No files matched your search
@@ -0,0 +1,832 @@
|
||||
//! BAST-native validator — the `validate_bytes` validation step.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## What the materializer already guarantees
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## 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` |
|
||||
//! | RFC 3339 timestamp shape | `validate_timestamp` (non-strict, matching v0.1.0) |
|
||||
//! | 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) |
|
||||
//!
|
||||
//! ## Error payload (D-BAST-009)
|
||||
//!
|
||||
//! The bytes path no longer touches `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
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use crate::bast::{
|
||||
BastArray, BastDefKind, BastDoc, BastEnum, BastField, BastRecord, BastStruct, BastType,
|
||||
BastUnion,
|
||||
};
|
||||
use crate::error::AlkTypeError;
|
||||
use crate::schema::AlkTypeKind;
|
||||
use serde_json::Value;
|
||||
|
||||
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`, timestamp shape,
|
||||
/// enum index bounds, union variant constraints).
|
||||
///
|
||||
/// 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.).
|
||||
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::Primitive(AlkTypeKind::Timestamp) => validate_timestamp(value, path),
|
||||
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> {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(max) = max_length {
|
||||
if s.len() > max {
|
||||
return Err(validation_err(
|
||||
path,
|
||||
format!("bytes length {} exceeds maxLength {max}", s.len()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
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(())
|
||||
}
|
||||
_ => Err(validation_err(
|
||||
path,
|
||||
"expected a string or array of u8 for bytes",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_timestamp(value: &Value, path: &str) -> Result<(), AlkTypeError> {
|
||||
match value.as_str() {
|
||||
Some(s) if is_rfc3339_timestamp(s) => Ok(()),
|
||||
_ => Err(validation_err(path, "expected an RFC 3339 timestamp string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let msg = if path.is_empty() {
|
||||
reason.into()
|
||||
} else {
|
||||
format!("{path}: {}", reason.into())
|
||||
};
|
||||
AlkTypeError::Validation(jsonschema::ValidationError::custom(msg))
|
||||
}
|
||||
|
||||
/// Non-strict RFC 3339 / ISO 8601 datetime check — same shape as the
|
||||
/// v0.1.0 `TimestampValidator` so behavior is identical. Structural
|
||||
/// sanity, not strict validation (Feb 31 passes; seconds range isn't
|
||||
/// checked; leap seconds aren't handled). Strict RFC 3339 validation is
|
||||
/// the consumer's responsibility if they need it (ADR-003, review #002
|
||||
/// N1).
|
||||
fn is_rfc3339_timestamp(s: &str) -> bool {
|
||||
let parts: Vec<&str> = s.splitn(2, 'T').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let date_parts: Vec<&str> = parts[0].split('-').collect();
|
||||
if date_parts.len() != 3 {
|
||||
return false;
|
||||
}
|
||||
let time_part = parts[1];
|
||||
let time_clean = if let Some(pos) = time_part.find(['Z', '+']) {
|
||||
&time_part[..pos]
|
||||
} else if let Some(pos) = time_part.rfind('-') {
|
||||
if pos >= 8 {
|
||||
&time_part[..pos]
|
||||
} else {
|
||||
time_part
|
||||
}
|
||||
} else {
|
||||
time_part
|
||||
};
|
||||
let time_parts: Vec<&str> = time_clean.split(':').collect();
|
||||
if time_parts.len() < 2 || time_parts.len() > 3 {
|
||||
return false;
|
||||
}
|
||||
date_parts[0].parse::<u16>().is_ok_and(|y| y > 0)
|
||||
&& date_parts[1]
|
||||
.parse::<u8>()
|
||||
.is_ok_and(|m| (1..=12).contains(&m))
|
||||
&& date_parts[2]
|
||||
.parse::<u8>()
|
||||
.is_ok_and(|d| (1..=31).contains(&d))
|
||||
&& time_parts[0].parse::<u8>().is_ok_and(|h| h <= 23)
|
||||
&& time_parts[1].parse::<u8>().is_ok_and(|m| m <= 59)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema::Endian;
|
||||
use serde_json::json;
|
||||
|
||||
fn doc_from<'a>(root: &'a Value, name: &'a str) -> BastDoc<'a> {
|
||||
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(
|
||||
doc: &BastDoc<'_>,
|
||||
buffer: &[u8],
|
||||
) -> Result<(), AlkTypeError> {
|
||||
let value = crate::materialize::materialize_packed(doc, buffer, Endian::Little)?;
|
||||
validate_value(doc, &value)
|
||||
}
|
||||
|
||||
// ----- 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(&d, &u32_le(0)).is_ok());
|
||||
assert!(materialize_and_validate(&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(&d, &[127u8]).is_ok());
|
||||
assert!(materialize_and_validate(&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(&d, &prefixed_str_le("hi")).is_ok());
|
||||
let err = materialize_and_validate(&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(&d, &buf).is_ok());
|
||||
let mut buf = u32_le(3);
|
||||
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
let err = materialize_and_validate(&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());
|
||||
}
|
||||
|
||||
// ----- Timestamp -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn timestamp_valid_and_invalid() {
|
||||
let root = json!({
|
||||
"$defs": { "S": { "kind": "struct", "fields": [
|
||||
{ "name": "ts", "kind": "timestamp" }
|
||||
] } }
|
||||
});
|
||||
let d = doc_from(&root, "S");
|
||||
assert!(materialize_and_validate(&d, &prefixed_str_le("2026-07-20T15:30:00Z")).is_ok());
|
||||
assert!(materialize_and_validate(&d, &prefixed_str_le("2026-07-20T15:30:00")).is_ok());
|
||||
assert!(materialize_and_validate(&d, &prefixed_str_le("2026-07-20T15:30:00+02:00")).is_ok());
|
||||
let err = materialize_and_validate(&d, &prefixed_str_le("not-a-date")).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
// ----- 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(&d, &u32_le(0)).is_ok());
|
||||
assert!(materialize_and_validate(&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(&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(&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(&d, &buf).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
|
||||
|
||||
let buf = vec![1u8, 7u8];
|
||||
assert!(materialize_and_validate(&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(&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(&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(&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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user