feat(typedef/tunion): implement TUnion discriminator dispatch for byte-offset and field-name

This commit is contained in:
glm-5.2 committed 2026-07-21 10:14:50 +00:00
1 parent 1c6a705987
commit 4b8e5c163a
2 files changed
+656 -4

No files matched your search

+641 -2
View File
@@ -2,6 +2,645 @@
//!
//! TUnion supports two discriminator kinds: byte-offset (protocol
//! dispatch, e.g., SFTP type bytes) and field-name (typedef.ts string
//! pattern).
//! pattern). This module reads the discriminator value from a byte
//! buffer, looks up the variant schema in the union's `mapping`, and
//! reports the offset where the variant struct begins.
//!
//! All reads go through [`crate::data_access`] so bounds checks and
//! endianness handling are uniform with the rest of the engine.
// TODO: implement
use crate::data_access::{read_enum, read_string, read_u16, read_u32, read_u8};
use crate::error::TypedefError;
use crate::schema::{get_typedef_kind, parse_discriminator, DiscriminatorKind, Endian};
use serde_json::Value;
const U32_SIZE: usize = 4;
const STRING_PREFIX_SIZE: usize = 4;
const DISCRIMINATOR_PATH: &str = "__discriminator";
/// The result of reading a TUnion discriminator.
#[derive(Debug, Clone)]
pub struct UnionDispatch {
/// The mapping key (stringified discriminator value for byte-offset,
/// string value for field-name).
pub key: String,
/// The byte offset where the variant struct starts.
pub variant_offset: usize,
/// The size of the discriminator in bytes.
pub discriminator_size: usize,
}
/// Read the discriminator value from a byte-offset TUnion.
///
/// The discriminator is a fixed-size integer at a known byte offset.
/// Returns the mapping key (as a string) and the variant struct offset.
///
/// This is the SFTP `Packet` enum pattern — byte 0 is the type byte,
/// bytes 1..N are the variant struct. The call protocol's event type
/// dispatch uses the same pattern.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
/// or malformed, or if the discriminator `type` is not one of
/// `TypeDef:Uint8` / `TypeDef:Uint16` / `TypeDef:Uint32`.
/// - [`TypedefError::Access`] if the buffer is too short to contain the
/// discriminator, or if the read value is not present in the union's
/// `mapping`.
pub fn read_byte_discriminator(
buffer: &[u8],
union_schema: &Value,
endian: Endian,
) -> Result<UnionDispatch, TypedefError> {
let disc = parse_discriminator(union_schema)?;
let (offset, disc_type) = match disc {
DiscriminatorKind::Byte { offset, disc_type } => (offset, disc_type),
DiscriminatorKind::Field { .. } => {
return Err(TypedefError::Schema(
"read_byte_discriminator requires a byte-offset discriminator".to_string(),
));
}
};
let (disc_value, discriminator_size) = match disc_type.as_str() {
"TypeDef:Uint8" => (u32::from(read_u8(buffer, offset, DISCRIMINATOR_PATH)?), 1),
"TypeDef:Uint16" => (
u32::from(read_u16(buffer, offset, DISCRIMINATOR_PATH, endian)?),
2,
),
"TypeDef:Uint32" => (read_u32(buffer, offset, DISCRIMINATOR_PATH, endian)?, 4),
other => {
return Err(TypedefError::Schema(format!(
"unsupported byte discriminator type: {other}"
)));
}
};
let key = disc_value.to_string();
verify_mapping_key(union_schema, &key, DISCRIMINATOR_PATH, &key)?;
let variant_offset = offset
.checked_add(discriminator_size)
.ok_or_else(|| TypedefError::Access {
field_path: DISCRIMINATOR_PATH.to_string(),
reason: format!(
"offset {offset} + discriminator_size {discriminator_size} overflows usize"
),
})?;
Ok(UnionDispatch {
key,
variant_offset,
discriminator_size,
})
}
/// Read the discriminator value from a field-name TUnion.
///
/// The discriminator is a named field within the struct — its offset
/// is computed like any other field. The consumer provides the
/// discriminator field's offset (from the OffsetMap or LayoutBuilder).
///
/// This is the typedef.ts `TUnion` pattern — the discriminator is a
/// field like any other, and the mapping keys are string values.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
/// or malformed, the discriminator field is not declared in
/// `properties`, the field has no `TypeDef:*` kind, or the field's
/// kind is not one of `TypeDef:String` / `TypeDef:Uint8` /
/// `TypeDef:Enum`.
/// - [`TypedefError::Access`] if the buffer is too short to contain the
/// discriminator field, or if the read value is not present in the
/// union's `mapping`.
pub fn read_field_discriminator(
buffer: &[u8],
union_schema: &Value,
disc_field_offset: usize,
endian: Endian,
) -> Result<UnionDispatch, TypedefError> {
let disc = parse_discriminator(union_schema)?;
let name = match disc {
DiscriminatorKind::Field { name } => name,
DiscriminatorKind::Byte { .. } => {
return Err(TypedefError::Schema(
"read_field_discriminator requires a field-name discriminator".to_string(),
));
}
};
let field_schema = union_schema
.get("properties")
.and_then(Value::as_object)
.and_then(|props| props.get(&name))
.ok_or_else(|| {
TypedefError::Schema(format!(
"discriminator field '{name}' not found in union properties"
))
})?;
let kind = get_typedef_kind(field_schema).ok_or_else(|| {
TypedefError::Schema(format!(
"discriminator field '{name}' has no TypeDef:* kind"
))
})?;
let (key, discriminator_field_size) = match kind {
"TypeDef:String" => {
let s = read_string(buffer, disc_field_offset, &name, endian)?;
let size = STRING_PREFIX_SIZE
.checked_add(s.len())
.ok_or_else(|| TypedefError::Access {
field_path: name.clone(),
reason: format!(
"string prefix {STRING_PREFIX_SIZE} + data length {} overflows usize",
s.len()
),
})?;
(s.to_string(), size)
}
"TypeDef:Uint8" => {
let v = read_u8(buffer, disc_field_offset, &name)?;
(v.to_string(), 1)
}
"TypeDef:Enum" => {
let v = read_enum(buffer, disc_field_offset, &name, endian)?;
(v.to_string(), U32_SIZE)
}
other => {
return Err(TypedefError::Schema(format!(
"unsupported discriminator field type: {other}"
)));
}
};
verify_mapping_key(union_schema, &key, &name, key.as_str())?;
let variant_offset = disc_field_offset
.checked_add(discriminator_field_size)
.ok_or_else(|| TypedefError::Access {
field_path: name.clone(),
reason: format!(
"disc_field_offset {disc_field_offset} + discriminator_field_size {discriminator_field_size} overflows usize"
),
})?;
Ok(UnionDispatch {
key,
variant_offset,
discriminator_size: discriminator_field_size,
})
}
/// Look up a variant schema from the union's mapping.
///
/// Returns the variant schema. Inline schemas are returned directly.
/// `$ref` pointers of the form `"#/$defs/<name>"` are resolved against
/// the `union_schema`'s own `$defs` block (when the union schema is the
/// schema root). For nested unions whose `$defs` live on an ancestor,
/// the caller (typically `TypedefEngine::compile`) is expected to
/// resolve refs before reaching this function, or to inline the
/// variant schemas into the mapping at load time.
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the union has no `mapping` object, the
/// `key` is not present, a `$ref` is malformed, or a `$ref` cannot be
/// resolved against the union schema's own `$defs`.
pub fn resolve_variant<'a>(
union_schema: &'a Value,
key: &str,
) -> Result<&'a Value, TypedefError> {
let mapping = union_schema
.get("mapping")
.and_then(Value::as_object)
.ok_or_else(|| TypedefError::Schema("union is missing 'mapping' object".to_string()))?;
let variant = mapping
.get(key)
.ok_or_else(|| TypedefError::Schema(format!("unknown mapping key: {key}")))?;
let ref_str = match variant.get("$ref").and_then(Value::as_str) {
Some(r) => r,
None => return Ok(variant),
};
let pointer = ref_str
.strip_prefix('#')
.ok_or_else(|| TypedefError::Schema(format!("unsupported $ref form: {ref_str}")))?;
let resolved = resolve_json_pointer(union_schema, pointer).ok_or_else(|| {
TypedefError::Schema(format!(
"cannot resolve $ref {ref_str} against union schema; ensure refs are inlined or the union schema contains $defs"
))
})?;
Ok(resolved)
}
/// Get the discriminator size in bytes for a byte-offset discriminator.
///
/// Returns 1 for `TypeDef:Uint8`, 2 for `TypeDef:Uint16`, and 4 for
/// `TypeDef:Uint32`. Field-name discriminators have no fixed size and
/// produce a [`TypedefError::Schema`].
///
/// # Errors
///
/// - [`TypedefError::Schema`] if the discriminator annotation is
/// missing/malformed, the discriminator `type` is unsupported, or the
/// discriminator is a field-name discriminator.
pub fn discriminator_size(union_schema: &Value) -> Result<usize, TypedefError> {
let disc = parse_discriminator(union_schema)?;
match disc {
DiscriminatorKind::Byte { disc_type, .. } => match disc_type.as_str() {
"TypeDef:Uint8" => Ok(1),
"TypeDef:Uint16" => Ok(2),
"TypeDef:Uint32" => Ok(4),
other => Err(TypedefError::Schema(format!(
"unsupported byte discriminator type: {other}"
))),
},
DiscriminatorKind::Field { .. } => Err(TypedefError::Schema(
"field-name discriminator has no fixed size".to_string(),
)),
}
}
fn verify_mapping_key(
union_schema: &Value,
key: &str,
field_path: &str,
raw_value: &str,
) -> Result<(), TypedefError> {
let in_mapping = union_schema
.get("mapping")
.and_then(Value::as_object)
.map(|m| m.contains_key(key))
.unwrap_or(false);
if in_mapping {
Ok(())
} else {
Err(TypedefError::Access {
field_path: field_path.to_string(),
reason: format!("unknown discriminator value: {raw_value}"),
})
}
}
fn resolve_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> {
if pointer.is_empty() {
return Some(root);
}
let trimmed = pointer.strip_prefix('/')?;
let mut current = root;
for unescaped in trimmed.split('/') {
let segment = unescape_json_pointer_token(unescaped)?;
current = current.get(&segment)?;
}
Some(current)
}
fn unescape_json_pointer_token(token: &str) -> Option<String> {
let mut out = String::with_capacity(token.len());
let mut chars = token.chars();
while let Some(c) = chars.next() {
match c {
'~' => match chars.next() {
Some('0') => out.push('~'),
Some('1') => out.push('/'),
_ => return None,
},
other => out.push(other),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const LE: Endian = Endian::Little;
const BE: Endian = Endian::Big;
fn byte_union_schema(offset: usize, disc_type: &str) -> Value {
json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte", "offset": offset, "type": disc_type},
"mapping": {
"5": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}},
"6": {"TypeDef:Struct": true, "properties": {"len": {"TypeDef:Uint16": true}}}
}
})
}
fn field_union_schema(field_name: &str, field_kind: &str) -> Value {
let field_schema = match field_kind {
"TypeDef:Enum" => json!({
"TypeDef:Enum": true,
"enum": ["read", "write"]
}),
_ => json!({field_kind: true}),
};
let (key_a, key_b) = match field_kind {
"TypeDef:String" => ("read", "write"),
_ => ("0", "1"),
};
json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": field_name},
"properties": {
field_name: field_schema
},
"mapping": {
key_a: {"TypeDef:Struct": true, "properties": {"n": {"TypeDef:Uint32": true}}},
key_b: {"TypeDef:Struct": true, "properties": {"m": {"TypeDef:Uint16": true}}}
}
})
}
#[test]
fn read_byte_discriminator_uint8_default_offset() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [5u8, 0xAA, 0xBB, 0xCC];
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 1);
assert_eq!(d.discriminator_size, 1);
}
#[test]
fn read_byte_discriminator_uint8_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [6u8];
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 1);
}
#[test]
fn read_byte_discriminator_uint16_little_endian() {
let schema = byte_union_schema(2, "TypeDef:Uint16");
let mut buf = vec![0u8; 4];
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 2);
}
#[test]
fn read_byte_discriminator_uint16_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint16");
let buf = [0x00, 0x06, 0xAA, 0xBB];
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 2);
}
#[test]
fn read_byte_discriminator_uint32_little_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
assert_eq!(d.key, "5");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 4);
}
#[test]
fn read_byte_discriminator_uint32_big_endian() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&6u32.to_be_bytes());
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
assert_eq!(d.key, "6");
assert_eq!(d.variant_offset, 4);
}
#[test]
fn read_byte_discriminator_unknown_value_is_access_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [99u8];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, DISCRIMINATOR_PATH);
assert!(reason.contains("99"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_byte_discriminator_buffer_too_short_is_access_error() {
let schema = byte_union_schema(4, "TypeDef:Uint32");
let buf = [0u8; 2];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
assert!(matches!(err, TypedefError::Access { .. }));
}
#[test]
fn read_byte_discriminator_field_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:String");
let buf = [0u8; 16];
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_string() {
let schema = field_union_schema("type", "TypeDef:String");
let mut buf = vec![0u8; 32];
let value = "read";
let len_bytes = (value.len() as u32).to_le_bytes();
buf[0..4].copy_from_slice(&len_bytes);
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "read");
assert_eq!(d.variant_offset, 4 + value.len());
assert_eq!(d.discriminator_size, 4 + value.len());
}
#[test]
fn read_field_discriminator_uint8() {
let schema = field_union_schema("type", "TypeDef:Uint8");
let mut buf = vec![0u8; 8];
buf[0] = 0;
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "0");
assert_eq!(d.variant_offset, 1);
assert_eq!(d.discriminator_size, 1);
}
#[test]
fn read_field_discriminator_enum() {
let schema = field_union_schema("type", "TypeDef:Enum");
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
assert_eq!(d.key, "0");
assert_eq!(d.variant_offset, 4);
assert_eq!(d.discriminator_size, 4);
}
#[test]
fn read_field_discriminator_string_big_endian() {
let schema = field_union_schema("type", "TypeDef:String");
let mut buf = vec![0u8; 32];
let value = "write";
let len_bytes = (value.len() as u32).to_be_bytes();
buf[0..4].copy_from_slice(&len_bytes);
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
let d = read_field_discriminator(&buf, &schema, 0, BE).expect("read");
assert_eq!(d.key, "write");
assert_eq!(d.variant_offset, 4 + value.len());
}
#[test]
fn read_field_discriminator_unknown_value_is_access_error() {
let schema = field_union_schema("type", "TypeDef:Uint8");
let mut buf = vec![0u8; 8];
buf[0] = 99;
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
match err {
TypedefError::Access { field_path, reason } => {
assert_eq!(field_path, "type");
assert!(reason.contains("99"), "reason: {reason}");
}
other => panic!("expected Access, got {other:?}"),
}
}
#[test]
fn read_field_discriminator_field_not_found_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "missing"},
"properties": {"other": {"TypeDef:Uint8": true}},
"mapping": {"5": {"TypeDef:Struct": true}}
});
let buf = [0u8; 4];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_no_typedef_kind_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "field", "name": "type"},
"properties": {"type": {"type": "string"}},
"mapping": {"read": {"TypeDef:Struct": true}}
});
let buf = [0u8; 4];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_unsupported_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:Float32");
let buf = [0u8; 8];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn read_field_discriminator_byte_kind_is_schema_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let buf = [5u8];
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_inline_schema() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let variant = resolve_variant(&schema, "5").expect("resolve");
assert_eq!(
variant.get("TypeDef:Struct").and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn resolve_variant_ref_against_own_defs() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte"},
"mapping": {
"5": {"$ref": "#/$defs/Read"}
},
"$defs": {
"Read": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
}
});
let variant = resolve_variant(&schema, "5").expect("resolve");
assert_eq!(
variant.get("TypeDef:Struct").and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn resolve_variant_unknown_key_is_schema_error() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
let err = resolve_variant(&schema, "999").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_missing_mapping_is_schema_error() {
let schema = json!({"TypeDef:Union": true, "discriminator": {"kind": "byte"}});
let err = resolve_variant(&schema, "5").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn resolve_variant_unresolvable_ref_is_schema_error() {
let schema = json!({
"TypeDef:Union": true,
"discriminator": {"kind": "byte"},
"mapping": {
"5": {"$ref": "#/$defs/Read"}
}
});
let err = resolve_variant(&schema, "5").unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn discriminator_size_uint8() {
let schema = byte_union_schema(0, "TypeDef:Uint8");
assert_eq!(discriminator_size(&schema).unwrap(), 1);
}
#[test]
fn discriminator_size_uint16() {
let schema = byte_union_schema(0, "TypeDef:Uint16");
assert_eq!(discriminator_size(&schema).unwrap(), 2);
}
#[test]
fn discriminator_size_uint32() {
let schema = byte_union_schema(0, "TypeDef:Uint32");
assert_eq!(discriminator_size(&schema).unwrap(), 4);
}
#[test]
fn discriminator_size_field_kind_is_schema_error() {
let schema = field_union_schema("type", "TypeDef:String");
let err = discriminator_size(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
#[test]
fn discriminator_size_missing_discriminator_is_schema_error() {
let schema = json!({"TypeDef:Union": true});
let err = discriminator_size(&schema).unwrap_err();
assert!(matches!(err, TypedefError::Schema(_)));
}
}
+15 -2
View File
@@ -1,7 +1,7 @@
---
id: typedef/tunion
name: Implement TUnion discriminator dispatch for byte-offset and field-name discriminators
status: pending
status: completed
depends_on: [typedef/schema-types, typedef/error-type, typedef/data-access]
scope: narrow
risk: medium
@@ -142,4 +142,17 @@ are full JSON Pointer paths).
## Summary
> To be filled on completion
Implemented TUnion discriminator dispatch in `crates/alknet-typedef/src/tunion.rs`
with the `UnionDispatch` result type and four public functions: `read_byte_discriminator`,
`read_field_discriminator`, `resolve_variant`, and `discriminator_size`. Byte-offset
discriminators support `TypeDef:Uint8`/`Uint16`/`Uint32` via the endian-aware
`data_access` readers, stringifying the integer value as the mapping key and returning
`offset + discriminator_size` as the variant start. Field-name discriminators support
`TypeDef:String`/`Uint8`/`Enum` fields, reading the value at the caller-provided offset
and computing the variant start as `disc_field_offset + discriminator_field_size`
(`4 + str.len()` for strings, 1 for u8, 4 for enum). `resolve_variant` returns inline
schemas directly and resolves `"#/$defs/<name>"` refs against the union schema's own
`$defs` block, returning `TypedefError::Schema` for unresolvable refs (the engine
layer is expected to inline or pass the root for nested unions). All 28 unit tests
pass, covering both discriminator kinds, all supported sub-types, both endiannesses,
buffer-too-short, unknown mapping keys, and ref resolution.