[M5] mxaccess-asb: F25 step 2 — per-operation request body codecs

Adds the IAsbCustomSerializableType binary fast-path + per-operation
request-body NBFX-token builders. RegisterItems and UnregisterItems
now compose end-to-end through SoapEnvelope + encode_envelope to a
byte stream that round-trips back to the original ItemIdentity array.

Three pieces:

1. F21 NBFX gains `Bytes8/16/32` text records (records 0x9E/0xA0/0xA2
   plus +1 WithEndElement variants). WCF's `XmlDictionaryWriter.
   WriteBase64` emits these in binary form — not actual base64 text —
   so they're required for the `<ASBIData>` content.

2. `mxaccess-asb::contracts::ItemIdentity` ports `AsbContracts.cs:533-633`:
   * Wire layout: u16 kind + u16 reference_type +
     AsbBinary.WriteUnicodeString(Name) + AsbBinary.WriteUnicodeString
     (ContextName) + u64 Id + u8 IdSpecified.
   * `AsbBinary.WriteUnicodeString` per cs:1622-1633: u32 byte-length
     + UTF-16LE bytes; null/empty collapse to a 4-byte zero header.
   * `encode_item_identity_array` / `decode_item_identity_array`
     mirror `WriteArrayToStream` — 4-byte int32 count + each
     element's `WriteToStream` output. Per `AsbDataCustomSerializer`
     at cs:1583-1591.
   * `absolute_by_name(...)` convenience constructor matching
     `MxAsbDataClient.CreateAbsoluteItem` at cs:172-194.

3. `mxaccess-asb::operations` builds SOAP body NBFX token streams:
   * `build_register_items_request_body(items, require_id, register_only)`
     — RegisterItems contract per cs:119-143.
   * `build_unregister_items_request_body(items)` — UnregisterItems
     per cs:145-159.
   * Internal `BodyField` helper assembles the wire shape:
     `<RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
        <Items><ASBIData>{Bytes(payload)}</ASBIData></Items>
        <RequireId>true|false</RequireId>
        <RegisterOnly>true|false</RegisterOnly>
      </RegisterItemsRequest>`

15 new tests cover:
* ItemIdentity round-trip (default, with id, unicode name).
* AsbBinary unicode-string null/empty/value semantics.
* Byte-layout pinning (21 bytes for default ItemIdentity, le-int32
  array count).
* ItemIdentity array round-trip.
* `<ASBIData>` Bytes record round-trip across NBFX widths
  (Bytes8/16/32 selected by length).
* RegisterItems body → SoapEnvelope → encode → decode → recover the
  ItemIdentity array end-to-end.
* RequireId / RegisterOnly Bool wire form.
* UnregisterItems body uses correct outer element name and omits
  the RegisterItems-only fields.

Stubbed for next F25 iteration: per-operation Read / Write /
PublishWriteComplete / CreateSubscription / AddMonitoredItems /
DeleteMonitoredItems / Publish builders, response decoders, and the
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:24:19 -04:00
parent 25dbd8d3bd
commit a2b8989cbf
5 changed files with 784 additions and 3 deletions
+370
View File
@@ -0,0 +1,370 @@
//! `IAsbCustomSerializableType` binary codecs.
//!
//! Ports the binary fast-path WCF uses for `Variant` /
//! `IAsbCustomSerializableType`-decorated structs. Each type writes a
//! `BinaryWriter`-style payload (LE primitives + `AsbBinary` UTF-16 LE
//! length-prefixed strings); the WCF `AsbDataCustomSerializer`
//! (`AsbContracts.cs:1507-1612`) then base64-encodes that payload and
//! wraps it inside an `<ASBIData>` element under the field's outer XML
//! tag.
//!
//! ## Scope
//!
//! Implements:
//! * [`ItemIdentity`] — used by RegisterItems / UnregisterItems / Read
//! / AddMonitoredItems / DeleteMonitoredItems request bodies.
//!
//! Stubbed for follow-up F25 iterations:
//! * `ItemStatus`, `ItemRegistration`, `WriteValue`, `RuntimeValue`
//! payloads, `ItemWriteComplete`, `MonitoredItemSettings`,
//! `MonitoredItem`. The pattern is identical — pure binary
//! round-trip — so the per-type cost is small once the
//! [`ItemIdentity`] reference establishes it.
use mxaccess_codec::CodecError;
/// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout:
///
/// | Offset | Size | Field | Notes |
/// |-------:|-----:|---------------|--------------------------------------|
/// | 0 | 2 | `Type` | u16 `ItemIdentityType` enum |
/// | 2 | 2 | `ReferenceType` | u16 `ItemReferenceType` enum |
/// | 4 | n | `Name` | `AsbBinary.WriteUnicodeString` |
/// | | m | `ContextName` | `AsbBinary.WriteUnicodeString` |
/// | | 8 | `Id` | u64 |
/// | | 1 | `IdSpecified` | bool (`BinaryWriter.Write(bool)`) |
///
/// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`:
/// * Null/empty → 4-byte `0u32` length, no payload
/// * Non-empty → 4-byte byte-length + UTF-16LE bytes
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ItemIdentity {
pub kind: u16,
pub reference_type: u16,
pub name: Option<String>,
pub context_name: Option<String>,
pub id: u64,
pub id_specified: bool,
}
/// `ItemIdentityType` enum (`AsbContracts.cs:1295-1300`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum ItemIdentityType {
Name = 0,
Id = 1,
NameAndId = 2,
}
/// `ItemReferenceType` enum (`AsbContracts.cs:1302-1308`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum ItemReferenceType {
None = 0,
Absolute = 1,
Hierarchical = 2,
Relative = 3,
}
impl ItemIdentity {
/// Convenience constructor for an absolute name reference. The
/// `MxAsbDataClient.CreateAbsoluteItem` path
/// (`MxAsbDataClient.cs:172-194`) sets `Type =
/// ItemIdentityType.Name`, `ReferenceType =
/// ItemReferenceType.Absolute`, and supplies the tag name. Most
/// register-time callers use this shape.
pub fn absolute_by_name(name: impl Into<String>) -> Self {
Self {
kind: ItemIdentityType::Name as u16,
reference_type: ItemReferenceType::Absolute as u16,
name: Some(name.into()),
context_name: None,
id: 0,
id_specified: false,
}
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.kind.to_le_bytes());
out.extend_from_slice(&self.reference_type.to_le_bytes());
write_unicode_string(out, self.name.as_deref());
write_unicode_string(out, self.context_name.as_deref());
out.extend_from_slice(&self.id.to_le_bytes());
out.push(if self.id_specified { 1 } else { 0 });
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let mut cursor = 0usize;
let kind = read_u16_le(input, &mut cursor)?;
let reference_type = read_u16_le(input, &mut cursor)?;
let name = read_unicode_string(input, &mut cursor)?;
let context_name = read_unicode_string(input, &mut cursor)?;
let id = read_u64_le(input, &mut cursor)?;
let id_specified = read_u8(input, &mut cursor)? != 0;
Ok((
Self {
kind,
reference_type,
name,
context_name,
id,
id_specified,
},
cursor,
))
}
}
/// Encode an array of `IAsbCustomSerializableType` items per
/// `AsbDataCustomSerializer.WriteObjectContent` array branch
/// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which
/// emits a 4-byte count followed by each element's `WriteToStream`).
pub fn encode_item_identity_array(items: &[ItemIdentity]) -> Vec<u8> {
let mut out = Vec::new();
let count = i32::try_from(items.len()).unwrap_or(i32::MAX);
out.extend_from_slice(&count.to_le_bytes());
for item in items {
item.encode_into(&mut out);
}
out
}
/// Decode an array of `ItemIdentity`s from the WCF custom-serializer
/// binary form (4-byte count + items). Mirrors
/// `ItemIdentity.InitializeArrayFromStream` (`cs:614-623`).
pub fn decode_item_identity_array(input: &[u8]) -> Result<Vec<ItemIdentity>, CodecError> {
let mut cursor = 0usize;
let count = read_i32_le(input, &mut cursor)?;
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative item-identity array count",
buffer_len: input.len(),
});
}
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (item, consumed) = ItemIdentity::decode(tail)?;
cursor += consumed;
out.push(item);
}
Ok(out)
}
// ---- AsbBinary helpers ---------------------------------------------------
/// Mirror `AsbBinary.WriteUnicodeString` at `cs:1622-1633`. Null/empty
/// strings emit a 4-byte `0u32` length and no payload bytes.
fn write_unicode_string(out: &mut Vec<u8>, value: Option<&str>) {
let s = value.unwrap_or("");
if s.is_empty() {
out.extend_from_slice(&0u32.to_le_bytes());
return;
}
let mut utf16 = Vec::with_capacity(s.len() * 2);
for unit in s.encode_utf16() {
utf16.extend_from_slice(&unit.to_le_bytes());
}
let len = u32::try_from(utf16.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&utf16);
}
/// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0
/// returns `None` (matches `string.Empty` in .NET — both forms collapse
/// to a Rust `None` here so callers can distinguish unset from empty by
/// asserting on the original string).
fn read_unicode_string(input: &[u8], cursor: &mut usize) -> Result<Option<String>, CodecError> {
let len = read_u32_le(input, cursor)? as usize;
if len == 0 {
return Ok(None);
}
if len % 2 != 0 {
return Err(CodecError::Decode {
offset: *cursor,
reason: "unicode string length is odd",
buffer_len: input.len(),
});
}
let bytes = input
.get(*cursor..*cursor + len)
.ok_or(CodecError::ShortRead {
expected: len,
actual: input.len().saturating_sub(*cursor),
})?;
let mut units = Vec::with_capacity(len / 2);
for chunk in bytes.chunks_exact(2) {
let mut buf = [0u8; 2];
buf.copy_from_slice(chunk);
units.push(u16::from_le_bytes(buf));
}
let s = String::from_utf16(&units).map_err(|_| CodecError::Decode {
offset: *cursor,
reason: "invalid UTF-16 in unicode string",
buffer_len: input.len(),
})?;
*cursor += len;
Ok(Some(s))
}
fn read_u16_le(input: &[u8], cursor: &mut usize) -> Result<u16, CodecError> {
let bytes = read_array::<2>(input, cursor)?;
Ok(u16::from_le_bytes(bytes))
}
fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result<u32, CodecError> {
let bytes = read_array::<4>(input, cursor)?;
Ok(u32::from_le_bytes(bytes))
}
fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result<i32, CodecError> {
let bytes = read_array::<4>(input, cursor)?;
Ok(i32::from_le_bytes(bytes))
}
fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result<u64, CodecError> {
let bytes = read_array::<8>(input, cursor)?;
Ok(u64::from_le_bytes(bytes))
}
fn read_u8(input: &[u8], cursor: &mut usize) -> Result<u8, CodecError> {
let byte = *input.get(*cursor).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
*cursor += 1;
Ok(byte)
}
fn read_array<const N: usize>(input: &[u8], cursor: &mut usize) -> Result<[u8; N], CodecError> {
let slice = input
.get(*cursor..*cursor + N)
.ok_or(CodecError::ShortRead {
expected: N,
actual: input.len().saturating_sub(*cursor),
})?;
let mut out = [0u8; N];
out.copy_from_slice(slice);
*cursor += N;
Ok(out)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn round_trip(item: ItemIdentity) {
let bytes = item.encode();
let (decoded, consumed) = ItemIdentity::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, item);
}
#[test]
fn item_identity_round_trip_default() {
round_trip(ItemIdentity::default());
}
#[test]
fn item_identity_round_trip_absolute_by_name() {
round_trip(ItemIdentity::absolute_by_name("TestChildObject.TestInt"));
}
#[test]
fn item_identity_round_trip_with_id() {
round_trip(ItemIdentity {
kind: ItemIdentityType::NameAndId as u16,
reference_type: ItemReferenceType::Absolute as u16,
name: Some("TestChildObject.TestInt".to_string()),
context_name: Some("TestObject".to_string()),
id: 0x1234_5678_9abc_def0,
id_specified: true,
});
}
#[test]
fn item_identity_round_trip_unicode_name() {
round_trip(ItemIdentity::absolute_by_name("TéstObj.Φοο"));
}
#[test]
fn item_identity_byte_layout_minimum_19_bytes() {
// Empty Name + empty ContextName + Id=0 + IdSpecified=false:
// 2 (kind) + 2 (refType) + 4 (name len=0) + 4 (ctx len=0)
// + 8 (id) + 1 (idSpecified) = 21 bytes.
let item = ItemIdentity::default();
let bytes = item.encode();
assert_eq!(bytes.len(), 21);
}
#[test]
fn unicode_string_round_trip_handles_null_empty_and_value() {
// Null
let mut buf = Vec::new();
write_unicode_string(&mut buf, None);
let mut c = 0;
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
// Empty
let mut buf = Vec::new();
write_unicode_string(&mut buf, Some(""));
let mut c = 0;
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
// ASCII
let mut buf = Vec::new();
write_unicode_string(&mut buf, Some("hi"));
let mut c = 0;
assert_eq!(
read_unicode_string(&buf, &mut c).unwrap(),
Some("hi".to_string())
);
}
#[test]
fn item_identity_array_round_trip() {
let items = vec![
ItemIdentity::absolute_by_name("Tag.A"),
ItemIdentity::absolute_by_name("Tag.B"),
ItemIdentity::absolute_by_name("Tag.C"),
];
let bytes = encode_item_identity_array(&items);
let decoded = decode_item_identity_array(&bytes).unwrap();
assert_eq!(decoded, items);
}
#[test]
fn item_identity_array_empty() {
let bytes = encode_item_identity_array(&[]);
// 4 bytes (count = 0)
assert_eq!(bytes.len(), 4);
assert_eq!(
decode_item_identity_array(&bytes).unwrap(),
Vec::<ItemIdentity>::new()
);
}
#[test]
fn item_identity_array_count_is_le_int32() {
let items = vec![ItemIdentity::default(); 7];
let bytes = encode_item_identity_array(&items);
// First 4 bytes = 7 little-endian.
assert_eq!(&bytes[0..4], &[0x07, 0x00, 0x00, 0x00]);
}
}