Files
alktype/src/bast_meta.rs
T
deepseek-v4-pro 230345a867 Validate BAST documents against the meta-schema at compile time
Resolves review #003 finding M3.

- Add bast_meta::validate_bast_doc and call it from AlkTypeEngine::compile
  before parsing. Malformed annotations (unknown endian/encoding strings,
  non-integer align/maxLength, missing required properties) now surface as
  AlkTypeError::Schema instead of being silently tolerated by the parser.
- Align the meta-schema TypeRef with the parser: allow inline struct/union/
  enum as TypeRefs (the parser and builder already accepted them; the
  meta-schema and spec did not). Update bast-format.md TypeRef table and
  the BastType doc comment to the seven-form vocabulary.

Verification: cargo test --release (410 pass), cargo clippy --all-targets
-- -D warnings (clean), cargo doc --no-deps (clean), cargo build --target
wasm32-unknown-unknown --release (clean).
2026-08-16 08:54:57 +00:00

521 lines
18 KiB
Rust

//! BAST meta-schema — the standard JSON Schema (Draft 2020-12) that
//! validates the *structure* of BAST documents (is a document
//! well-formed?).
//!
//! This is distinct from the BAST-native validator (step 5), which
//! validates *binary data* against a BAST document (are the bytes a
//! valid instance?). See
//! [`docs/architecture/bast-format.md`](../docs/architecture/bast-format.md)
//! for the normative spec.
//!
//! The meta-schema is embedded at compile time via the `serde_json::json!`
//! macro — no I/O, no feature flags, wasm-clean. It is also published at
//! `https://alk.dev/bast/v1/schema` (the `$id`). Consumers and editors can
//! validate BAST documents against it with any standard JSON Schema
//! validator:
//!
//! ```ignore
//! jsonschema::options()
//! .build(&alktype::BAST_META_SCHEMA)?
//! .validate(&bast_doc)?;
//! ```
use serde_json::{json, Value};
use std::sync::LazyLock;
/// The BAST v1 meta-schema, as a `serde_json::Value`.
///
/// A BAST document is valid against this schema iff it is well-formed
/// (correct `$defs` shape, known `kind` strings, required properties
/// present, no additional properties). Value-domain constraints
/// (`maxLength`, enum index bounds, etc.) are enforced by the
/// BAST-native validator, not this meta-schema.
///
/// Built lazily on first access via `serde_json::json!` (the `json!`
/// macro allocates, so it can't be a `const`). The parsed `Value` is
/// then reused for every subsequent call — `&'static` via `LazyLock`.
pub static BAST_META_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://alk.dev/bast/v1/schema",
"title": "Binary Abstract Syntax Tree (BAST) v1",
"description": "Meta-schema for BAST documents. A BAST document describes the binary layout of structured data.",
"type": "object",
"properties": {
"$defs": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/TypeDef" }
}
},
"required": ["$defs"],
"$defs": {
"TypeDef": {
"oneOf": [
{ "$ref": "#/$defs/StructDef" },
{ "$ref": "#/$defs/UnionDef" },
{ "$ref": "#/$defs/EnumDef" }
]
},
"StructDef": {
"type": "object",
"properties": {
"kind": { "const": "struct" },
"endian": { "enum": ["little", "big"] },
"align": { "type": "integer", "minimum": 1 },
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/FieldDef" }
}
},
"required": ["kind", "fields"],
"additionalProperties": false
},
"FieldDef": {
"type": "object",
"properties": {
"name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" },
"kind": { "$ref": "#/$defs/TypeRef" },
"endian": { "enum": ["little", "big"] },
"align": { "type": "integer", "minimum": 1 },
"encoding": { "enum": ["length-prefixed", "offset-indirect"] },
"maxLength": { "type": "integer", "minimum": 0 }
},
"required": ["name", "kind"],
"additionalProperties": false
},
"TypeRef": {
"oneOf": [
{
"description": "Primitive type",
"type": "string",
"enum": [
"int8", "int16", "int32", "int64",
"uint8", "uint16", "uint32", "uint64",
"float32", "float64",
"bool", "string", "bytes", "timestamp"
]
},
{
"description": "Reference to a named $defs entry",
"type": "object",
"properties": {
"$ref": { "type": "string", "pattern": "^#/\\$defs/[a-zA-Z_][a-zA-Z0-9_]*$" }
},
"required": ["$ref"],
"additionalProperties": false
},
{
"description": "Array type (fixed-size only in v1 — count is required)",
"type": "object",
"properties": {
"kind": { "const": "array" },
"element": { "$ref": "#/$defs/TypeRef" },
"count": { "type": "integer", "minimum": 0 }
},
"required": ["kind", "element", "count"],
"additionalProperties": false
},
{
"description": "Record (string-keyed map) type",
"type": "object",
"properties": {
"kind": { "const": "record" },
"values": { "$ref": "#/$defs/TypeRef" }
},
"required": ["kind", "values"],
"additionalProperties": false
},
{
"description": "Inline struct type",
"$ref": "#/$defs/StructDef"
},
{
"description": "Inline union type",
"$ref": "#/$defs/UnionDef"
},
{
"description": "Inline enum type",
"$ref": "#/$defs/EnumDef"
}
]
},
"UnionDef": {
"type": "object",
"properties": {
"kind": { "const": "union" },
"endian": { "enum": ["little", "big"] },
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/FieldDef" }
},
"discriminator": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "const": "byte" },
"offset": { "type": "integer", "minimum": 0 },
"type": { "enum": ["uint8", "uint16", "uint32"] }
},
"required": ["kind", "offset", "type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": { "const": "field" },
"name": { "type": "string" }
},
"required": ["kind", "name"],
"additionalProperties": false
}
]
},
"mapping": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/TypeRef" }
}
},
"required": ["kind", "discriminator", "mapping"],
"additionalProperties": false
},
"EnumDef": {
"type": "object",
"properties": {
"kind": { "const": "enum" },
"values": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
},
"required": ["kind", "values"],
"additionalProperties": false
}
}
})
});
/// Validate a BAST document against the meta-schema.
///
/// Returns `Ok(())` if the document is well-formed, or
/// [`crate::error::AlkTypeError::Schema`] with the first validation error
/// otherwise.
/// This is the structural gate [`crate::AlkTypeEngine::compile`] applies
/// before parsing — it catches malformed annotations (unknown `endian`/
/// `encoding` strings, non-integer `align`/`maxLength`, missing required
/// properties) that the parser would otherwise silently tolerate.
pub fn validate_bast_doc(doc: &Value) -> Result<(), crate::error::AlkTypeError> {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.map_err(|e| {
crate::error::AlkTypeError::Schema(format!("BAST meta-schema failed to build: {e}"))
})?;
validator.validate(doc).map_err(|e| {
crate::error::AlkTypeError::Schema(format!("BAST document is not well-formed: {e}"))
})
}
#[cfg(test)]
mod tests {
use super::BAST_META_SCHEMA;
#[test]
fn meta_schema_has_correct_id_and_draft() {
assert_eq!(
BAST_META_SCHEMA["$schema"],
"https://json-schema.org/draft/2020-12/schema"
);
assert_eq!(
BAST_META_SCHEMA["$id"],
"https://alk.dev/bast/v1/schema"
);
}
#[test]
fn meta_schema_requires_defs() {
let required = BAST_META_SCHEMA["required"].as_array().expect("required is array");
assert!(required.iter().any(|v| v == "$defs"));
}
#[test]
fn meta_schema_covers_struct_union_enum_type_defs() {
let type_def_oneof = BAST_META_SCHEMA["$defs"]["TypeDef"]["oneOf"]
.as_array()
.expect("TypeDef.oneOf is array");
let refs: Vec<&str> = type_def_oneof
.iter()
.map(|v| v["$ref"].as_str().expect("ref string"))
.collect();
assert!(refs.contains(&"#/$defs/StructDef"));
assert!(refs.contains(&"#/$defs/UnionDef"));
assert!(refs.contains(&"#/$defs/EnumDef"));
}
#[test]
fn meta_schema_typeref_enumerates_all_14_primitives() {
let typeref_oneof = BAST_META_SCHEMA["$defs"]["TypeRef"]["oneOf"]
.as_array()
.expect("TypeRef.oneOf is array");
let primitive_arm = typeref_oneof
.iter()
.find(|v| v["description"] == "Primitive type")
.expect("primitive arm present");
let primitives = primitive_arm["enum"]
.as_array()
.expect("primitive enum is array");
let strs: Vec<&str> = primitives.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(strs.len(), 14);
for expected in [
"int8", "int16", "int32", "int64",
"uint8", "uint16", "uint32", "uint64",
"float32", "float64",
"bool", "string", "bytes", "timestamp",
] {
assert!(strs.contains(&expected), "missing primitive {expected}");
}
}
#[test]
fn meta_schema_validates_minimal_bast_document() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Point": {
"kind": "struct",
"fields": [
{ "name": "x", "kind": "uint32" },
{ "name": "y", "kind": "uint32" }
]
}
}
});
assert!(validator.validate(&doc).is_ok(), "valid doc rejected");
}
#[test]
fn meta_schema_rejects_missing_defs() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({ "type": "object" });
assert!(validator.validate(&doc).is_err(), "missing $defs accepted");
}
#[test]
fn meta_schema_rejects_unknown_kind() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Bad": {
"kind": "mystery",
"fields": []
}
}
});
assert!(validator.validate(&doc).is_err(), "unknown kind accepted");
}
#[test]
fn meta_schema_rejects_additional_properties_on_struct() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Bad": {
"kind": "struct",
"fields": [],
"bogus": true
}
}
});
assert!(validator.validate(&doc).is_err(), "additional property accepted");
}
#[test]
fn meta_schema_accepts_union_with_byte_discriminator() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Msg": {
"kind": "union",
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
"mapping": {
"1": { "$ref": "#/$defs/A" },
"2": { "$ref": "#/$defs/B" }
}
},
"A": { "kind": "struct", "fields": [] },
"B": { "kind": "struct", "fields": [] }
}
});
assert!(validator.validate(&doc).is_ok(), "byte-discriminator union rejected");
}
#[test]
fn meta_schema_accepts_enum() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Status": {
"kind": "enum",
"values": ["Ok", "Err"]
}
}
});
assert!(validator.validate(&doc).is_ok(), "enum rejected");
}
#[test]
fn meta_schema_rejects_empty_enum_values() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Bad": { "kind": "enum", "values": [] }
}
});
assert!(validator.validate(&doc).is_err(), "empty enum accepted");
}
#[test]
fn meta_schema_accepts_array_with_count() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Vec3": {
"kind": "struct",
"fields": [
{ "name": "data", "kind": { "kind": "array", "element": "float32", "count": 3 } }
]
}
}
});
assert!(validator.validate(&doc).is_ok(), "array with count rejected");
}
#[test]
fn meta_schema_rejects_array_without_count() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Bad": {
"kind": "struct",
"fields": [
{ "name": "data", "kind": { "kind": "array", "element": "float32" } }
]
}
}
});
assert!(validator.validate(&doc).is_err(), "array without count accepted");
}
#[test]
fn meta_schema_accepts_record() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Map": {
"kind": "struct",
"fields": [
{ "name": "entries", "kind": { "kind": "record", "values": "string" } }
]
}
}
});
assert!(validator.validate(&doc).is_ok(), "record rejected");
}
#[test]
fn meta_schema_accepts_ref_object() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Outer": {
"kind": "struct",
"fields": [
{ "name": "inner", "kind": { "$ref": "#/$defs/Inner" } }
]
},
"Inner": { "kind": "struct", "fields": [] }
}
});
assert!(validator.validate(&doc).is_ok(), "ref object rejected");
}
#[test]
fn meta_schema_rejects_malformed_ref() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"Bad": {
"kind": "struct",
"fields": [
{ "name": "inner", "kind": { "$ref": "Inner" } }
]
}
}
});
assert!(validator.validate(&doc).is_err(), "bare-name ref accepted");
}
#[test]
fn meta_schema_accepts_inline_struct_field() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "inner", "kind": {
"kind": "struct",
"fields": [ { "name": "x", "kind": "uint16" } ]
} }
]
}
}
});
assert!(validator.validate(&doc).is_ok(), "inline struct field rejected");
}
#[test]
fn meta_schema_accepts_inline_struct_in_union_mapping() {
let validator = jsonschema::options()
.build(&BAST_META_SCHEMA)
.expect("meta-schema compiles");
let doc = serde_json::json!({
"$defs": {
"U": {
"kind": "union",
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
"mapping": {
"5": { "kind": "struct", "fields": [ { "name": "x", "kind": "uint8" } ] }
}
}
}
});
assert!(validator.validate(&doc).is_ok(), "inline struct in union mapping rejected");
}
}