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