[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
+78 -2
View File
@@ -167,6 +167,11 @@ pub enum NbfxText {
/// pick `Static` when [`crate::nbfs::lookup_static`] succeeds and
/// fall back to `Dynamic` otherwise.
DictionaryDynamic(u32),
/// Raw bytes (records `0x9E` Bytes8 / `0xA0` Bytes16 / `0xA2`
/// Bytes32 — width chosen automatically by length on encode). Used
/// by `XmlDictionaryWriter.WriteBase64` for the `ASBIData`
/// content of `IAsbCustomSerializableType`-decorated fields.
Bytes(Vec<u8>),
}
impl NbfxText {
@@ -186,6 +191,10 @@ impl NbfxText {
Self::Chars(s) => Some(s.clone()),
Self::DictionaryStatic(id) => nbfs::lookup_static(*id).map(String::from),
Self::DictionaryDynamic(id) => dynamic.lookup(*id).map(String::from),
// Raw bytes have no canonical text representation; .NET's
// `XmlDictionaryReader.ReadElementContentAsBase64` returns
// them as `byte[]`. Consumers should match on the variant.
Self::Bytes(_) => None,
}
}
}
@@ -252,6 +261,9 @@ const REC_CHARS8_TEXT: u8 = 0x98;
const REC_CHARS16_TEXT: u8 = 0x9A;
const REC_CHARS32_TEXT: u8 = 0x9C;
const REC_EMPTY_TEXT: u8 = 0xA8;
const REC_BYTES8_TEXT: u8 = 0x9E;
const REC_BYTES16_TEXT: u8 = 0xA0;
const REC_BYTES32_TEXT: u8 = 0xA2;
const REC_DICTIONARY_TEXT: u8 = 0xAA;
const REC_BOOL_TEXT: u8 = 0xB4;
@@ -429,6 +441,25 @@ fn encode_text(text: &NbfxText, with_end: bool, out: &mut Vec<u8>) -> Result<(),
out.push(REC_DICTIONARY_TEXT + bump);
encode_multibyte_int31_to_nbfx(out, *id)?;
}
NbfxText::Bytes(bytes) => {
let len = bytes.len();
if len <= u8::MAX as usize {
out.push(REC_BYTES8_TEXT + bump);
out.push(len as u8);
} else if len <= u16::MAX as usize {
out.push(REC_BYTES16_TEXT + bump);
out.extend_from_slice(&(len as u16).to_le_bytes());
} else if len <= u32::MAX as usize {
out.push(REC_BYTES32_TEXT + bump);
out.extend_from_slice(&(len as u32).to_le_bytes());
} else {
return Err(NbfxError::PayloadTooLarge {
len,
max: u32::MAX as u64,
});
}
out.extend_from_slice(bytes);
}
}
Ok(())
}
@@ -620,6 +651,21 @@ fn decode_text_body(input: &[u8], cursor: &mut usize, base: u8) -> Result<NbfxTe
*cursor += 1;
NbfxText::Bool(b != 0)
}
REC_BYTES8_TEXT => {
let len = *input.get(*cursor).ok_or(NmfTrunc("bytes8-len"))? as usize;
*cursor += 1;
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes8")?)
}
REC_BYTES16_TEXT => {
let len_bytes = read_le::<2>(input, cursor, "bytes16-len")?;
let len = u16::from_le_bytes(len_bytes) as usize;
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes16")?)
}
REC_BYTES32_TEXT => {
let len_bytes = read_le::<4>(input, cursor, "bytes32-len")?;
let len = u32::from_le_bytes(len_bytes) as usize;
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes32")?)
}
other => return Err(NbfxError::UnknownRecord(other)),
})
}
@@ -657,15 +703,26 @@ fn read_utf8(
len: usize,
stage: &'static str,
) -> Result<String, NbfxError> {
let bytes = input
let raw = read_bytes(input, cursor, len, stage)?;
String::from_utf8(raw).map_err(|_| NbfxError::InvalidUtf8 { stage })
}
fn read_bytes(
input: &[u8],
cursor: &mut usize,
len: usize,
stage: &'static str,
) -> Result<Vec<u8>, NbfxError> {
let slice = input
.get(*cursor..*cursor + len)
.ok_or(NbfxError::Truncated {
need: len,
have: input.len().saturating_sub(*cursor),
stage,
})?;
let out = slice.to_vec();
*cursor += len;
String::from_utf8(bytes.to_vec()).map_err(|_| NbfxError::InvalidUtf8 { stage })
Ok(out)
}
fn decode_string(
@@ -840,6 +897,25 @@ mod tests {
}
}
#[test]
fn bytes_records_round_trip_all_widths() {
for payload in [
vec![],
vec![0xAB; 5],
vec![0xCD; 300], // forces Bytes16
vec![0xEF; 70_000], // forces Bytes32
] {
round_trip(vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("e".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(payload)),
NbfxToken::EndElement,
]);
}
}
#[test]
fn chars32_handled_for_payloads_above_u16_max() {
let big = "x".repeat(70_000);