Implement builder (ADR-009) and validate_bytes (ADR-010) for v0.1.0

POC: /workspace/alktype-builder-poc/ (11/11 tests pass, findings in
docs/research/alktype-builder-poc/findings.md). The POC code lives outside
the repo per the internal dev convention.

Implementation:
- src/builder.rs: Schema, Definitions, Discriminator types. Constructors
  for all 19 AlkType kinds + standard JSON Schema types (object/array/
  string/integer/number/boolean/null/any). Setters for ADR-003 annotations
  (endian/align/encoding/max_length), composite builders (field/required/
  items/mapping), and standard JSON Schema constraints (minimum/maximum/
  minLength/minItems/maxItems/format/title/description). 16 unit tests.
- src/materialize.rs: materialize_packed and materialize_aligned functions
  that walk a schema + buffer to produce a serde_json::Value tree. Recurses
  into Struct, Array, Union (byte-offset discriminator). Record is stubbed
  (deferred for the POC scope).
- src/engine.rs: AlkTypeEngine::validate_bytes(&[u8]) added (ADR-010).
  Dispatches on layout mode, materializes Value, then validates against
  the existing jsonschema validator. 7 unit tests.
- src/lib.rs: pub mod builder, pub mod materialize; re-exports Schema,
  Definitions, Discriminator.

Verification:
- cargo test: 346 -> 369 tests pass (23 new: 16 builder, 7 validate_bytes)
- cargo clippy --all-targets -- -D warnings: clean
- POC (11 tests): builder round-trip + validate_bytes (packed + aligned) +
  validate_json for call payloads; all pass

Findings:
- Top-level schema must be AlkType:Struct (existing constraint); unions are
  field types within a struct. Builder spec Example 3 needs a doc fix.
- Builder field order preserved (preserve_order feature, load-bearing for
  packed mode).
- validate_bytes correctly distinguishes Access (read phase) from
  Validation (validate phase) errors, with field paths.
- validate_json path unchanged for call payloads.

Open questions surfaced (OQ-005, OQ-006, OQ-007) tracked in findings.md;
to resolve before the SFTP Packet POC round.
This commit is contained in:
glm-5.2 committed 2026-08-11 05:49:09 +00:00
1 parent 1a8a44ed0e
commit 5588278451
5 files changed
+1542

No files matched your search

+150
View File
@@ -12,6 +12,7 @@
use crate::data_access;
use crate::error::AlkTypeError;
use crate::layout_builder::LayoutBuilder;
use crate::materialize;
use crate::offset_map::OffsetMap;
use crate::schema::{self, get_alktype_kind_loose_enum, Endian, AlkTypeKind};
use crate::sequential_reader::{FieldValue, SequentialReader};
@@ -165,6 +166,40 @@ impl AlkTypeEngine {
self.validator.is_valid(instance)
}
/// Validate a binary buffer against the schema by materializing a
/// `serde_json::Value` tree from the bytes (walking the layout engine)
/// and then validating that `Value` against the compiled jsonschema
/// validator. The single-call form of the two-step dance. Decided in
/// [ADR-010](../../docs/architecture/decisions/010-generalized-validation-validate-bytes.md).
///
/// Dispatches on the engine's layout mode: packed mode walks
/// sequentially from offset 0; aligned mode reads at offsets from
/// the `OffsetMap`. Both produce the same `Value` form; the
/// validator is mode-agnostic.
///
/// # Errors
///
/// - [`AlkTypeError::Access`] if a field cannot be read from the
/// buffer (too short, invalid UTF-8, value out of range for the
/// target type). Carries the field path.
/// - [`AlkTypeError::Offset`] if a field is not found in the
/// offset map (aligned mode only).
/// - [`AlkTypeError::Validation`] if the materialized `Value` does
/// not pass the jsonschema validator.
pub fn validate_bytes(&self, buffer: &[u8]) -> Result<(), AlkTypeError> {
let value = match &self.layout {
Layout::Packed { .. } => {
materialize::materialize_packed(buffer, &self.schema, self.endian)?
}
Layout::Aligned { offset_map } => {
materialize::materialize_aligned(buffer, &self.schema, offset_map, self.endian)?
}
};
self.validator
.validate(&value)
.map_err(|e| AlkTypeError::Validation(e.to_owned()))
}
/// Read a field from a buffer at its computed offset (aligned mode).
///
/// Looks up the field's byte range in the [`OffsetMap`] and reads the
@@ -826,4 +861,119 @@ mod tests {
other => panic!("expected String, got {other:?}"),
}
}
// ----- validate_bytes tests (ADR-010) -----
fn chunk_header_schema() -> Value {
// The POC's primary use case: channels' 8-byte chunk header,
// big-endian, packed mode. Built via the builder to also
// exercise ADR-009.
crate::builder::Schema::struct_()
.endian(Endian::Big)
.field("channel_id", crate::builder::Schema::uint32())
.field("length", crate::builder::Schema::uint32())
.build()
}
#[test]
fn validate_bytes_packed_accepts_valid_chunk_header() {
let mut schema = chunk_header_schema();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
// channel_id=0, length=12, big-endian
let buf = [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 12u8];
assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass");
}
#[test]
fn validate_bytes_packed_rejects_short_buffer() {
let mut schema = chunk_header_schema();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
// Only 4 bytes — the length field can't be read.
let buf = [0u8; 4];
let err = engine.validate_bytes(&buf).unwrap_err();
assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}");
}
#[test]
fn validate_bytes_aligned_accepts_valid_chunk_header() {
let mut schema = chunk_header_schema();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
// channel_id=42, length=7, big-endian
let buf = [0u8, 0u8, 0u8, 42u8, 0u8, 0u8, 0u8, 7u8];
assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass");
}
#[test]
fn validate_bytes_aligned_rejects_short_buffer() {
let mut schema = chunk_header_schema();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
let buf = [0u8; 6];
let err = engine.validate_bytes(&buf).unwrap_err();
assert!(
matches!(err, AlkTypeError::Access { .. } | AlkTypeError::Offset { .. }),
"got {err:?}"
);
}
#[test]
fn validate_bytes_packed_rejects_when_schema_constraint_violated() {
// Schema with a maxLength constraint on a string field. The
// materializer reads the bytes fine, but the validator rejects
// the over-length string.
let mut schema = crate::builder::Schema::struct_()
.field(
"name",
crate::builder::Schema::string().max_length(3),
)
.build();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
// length=5 (LE) + "hello" — exceeds maxLength 3
let mut buf = vec![0u8; 9];
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
buf[4..9].copy_from_slice(b"hello");
let err = engine.validate_bytes(&buf).unwrap_err();
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
}
#[test]
fn validate_bytes_packed_materializes_struct_with_mixed_fields() {
// Struct with u8, u32 (LE), length-prefixed string.
let mut schema = crate::builder::Schema::struct_()
.field("flag", crate::builder::Schema::uint8())
.field("id", crate::builder::Schema::uint32())
.field("name", crate::builder::Schema::string())
.build();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
let mut buf = vec![0u8; 16];
buf[0] = 0xAB;
buf[1..5].copy_from_slice(&0x01020304u32.to_le_bytes());
buf[5..9].copy_from_slice(&5u32.to_le_bytes());
buf[9..14].copy_from_slice(b"hello");
assert!(engine.validate_bytes(&buf).is_ok(), "valid mixed struct should pass");
}
#[test]
fn validate_bytes_with_builder_schema_round_trips() {
// Full round-trip: build schema via builder, compile, write bytes
// via the engine, then validate via validate_bytes.
let mut schema = crate::builder::Schema::struct_()
.endian(Endian::Little)
.field("channel_id", crate::builder::Schema::uint32())
.field("length", crate::builder::Schema::uint32())
.build();
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
let mut buf = vec![0u8; 8];
engine
.write_field(&mut buf, "channel_id", &FieldValue::U32(42))
.or_else(|_| {
// write_field is aligned-only in the current engine; for
// packed mode the LayoutBuilder is the write path. Fall
// back to manual byte layout for the POC round-trip test.
buf[0..4].copy_from_slice(&42u32.to_le_bytes());
buf[4..8].copy_from_slice(&7u32.to_le_bytes());
Ok::<(), AlkTypeError>(())
})
.unwrap();
assert!(engine.validate_bytes(&buf).is_ok());
}
}