diff --git a/design/followups.md b/design/followups.md index b314d9f..8c9d56f 100644 --- a/design/followups.md +++ b/design/followups.md @@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash. **Resolves when:** F19-F26 are all closed and the four DoD bullets above pass. -**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 landed in this commit: +**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 landed in this commit: +- F25 step 2: per-operation request-body builders + `IAsbCustomSerializableType` binary fast-path. F21 NBFX gains `Bytes8/16/32` text records (used by `XmlDictionaryWriter.WriteBase64` for the `` content). New `mxaccess-asb::contracts::ItemIdentity` ports the binary `WriteToStream` shape from `AsbContracts.cs:594-611`: u16 kind + u16 reference_type + `AsbBinary.WriteUnicodeString` Name + ContextName + u64 Id + u8 IdSpecified. Plus `encode_item_identity_array` / `decode_item_identity_array` mirroring `WriteArrayToStream` (4-byte int32 count + items). New `mxaccess-asb::operations` builds the SOAP body NBFX token streams: `build_register_items_request_body(items, require_id, register_only)` and `build_unregister_items_request_body(items)`. The `` element is wrapped with raw NBFX `Bytes` records (the binary form of WCF's `WriteBase64`). 14 new tests cover ItemIdentity round-trip (default, with id, unicode), ItemIdentity array round-trip, AsbBinary unicode-string null/empty/value semantics, byte-layout pinning (21-byte minimum for default ItemIdentity, le-int32 array count), and the full RegisterItems → SoapEnvelope → encode → decode → recover-ItemIdentity-array round-trip through the entire stack. + +**Earlier slices:** +- F25 step 1 (commit `25dbd8d`): - F25 step 1: `mxaccess-asb::envelope` — SOAP-1.2-over-NBFX envelope assembly + parsing for the `IASBIDataV2` contract. Provides `actions::*` constants for all 14 operations (verbatim from `AsbContracts.cs:14-58`), a `ConnectionValidator` header struct that converts F23's `SignedValidator` (`mac` + `iv` get base64-encoded for the wire), `SoapEnvelope` builder, `encode_envelope` (NBFX-token assembly: `s:Envelope` → `s:Header` → `a:Action s:mustUnderstand="1"` → optional `h:ConnectionValidator` → `s:Body` → `body_tokens`), and `decode_envelope` (tolerant of header ordering — looks for Action and ConnectionValidator anywhere inside ``). Includes a `format_uuid`/`parse_uuid` pair that mirrors .NET's `Guid.ToString("D")` mixed-endian byte order so connection-id round-trip matches the wire. 9 unit tests cover round-trip with/without validator, validator-from-SignedValidator base64 encoding, .NET-mixed-endian GUID format, action-string presence in encoded bytes, missing-Action tolerance, and full validator round-trip through encode→decode. **Stubbed for next F25 iteration:** per-operation request/response struct codecs (`ConnectRequest`, `RegisterItemsRequest`, etc. with the `IAsbCustomSerializableType` binary fast-path that .NET uses for `Variant`/`AsbStatus`/`RuntimeValue`), and `AsbClient` (TCP + NMF preamble + sized-envelope read/write loop + auth handshake). **Earlier slices:** diff --git a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs index 59fc773..d65df90 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs @@ -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), } 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) -> 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 { + 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 { - 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, 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); diff --git a/rust/crates/mxaccess-asb/src/contracts.rs b/rust/crates/mxaccess-asb/src/contracts.rs new file mode 100644 index 0000000..bb73b50 --- /dev/null +++ b/rust/crates/mxaccess-asb/src/contracts.rs @@ -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 `` 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, + pub context_name: Option, + 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) -> 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) { + 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 { + 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 { + 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, 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, 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, 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 { + let bytes = read_array::<2>(input, cursor)?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result { + let bytes = read_array::<4>(input, cursor)?; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result { + let bytes = read_array::<4>(input, cursor)?; + Ok(i32::from_le_bytes(bytes)) +} + +fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result { + let bytes = read_array::<8>(input, cursor)?; + Ok(u64::from_le_bytes(bytes)) +} + +fn read_u8(input: &[u8], cursor: &mut usize) -> Result { + let byte = *input.get(*cursor).ok_or(CodecError::ShortRead { + expected: 1, + actual: 0, + })?; + *cursor += 1; + Ok(byte) +} + +fn read_array(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::::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]); + } +} diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs index d764bff..2e8766d 100644 --- a/rust/crates/mxaccess-asb/src/lib.rs +++ b/rust/crates/mxaccess-asb/src/lib.rs @@ -9,9 +9,16 @@ #![forbid(unsafe_code)] +pub mod contracts; pub mod envelope; +pub mod operations; +pub use contracts::{ + ItemIdentity, ItemIdentityType, ItemReferenceType, decode_item_identity_array, + encode_item_identity_array, +}; pub use envelope::{ ConnectionValidator, DecodedEnvelope, EnvelopeError, SoapEnvelope, actions, decode_envelope, encode_envelope, }; +pub use operations::{build_register_items_request_body, build_unregister_items_request_body}; diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs new file mode 100644 index 0000000..e4ce584 --- /dev/null +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -0,0 +1,324 @@ +//! Per-operation request / response NBFX-token builders for +//! `IASBIDataV2`. +//! +//! Each `IAsbCustomSerializableType`-decorated field in a request +//! contract is serialised by WCF's `AsbDataCustomSerializer` +//! (`AsbContracts.cs:1561-1599`) as: +//! +//! ```xml +//! +//! {base64-binary} +//! +//! ``` +//! +//! The `` element body is the binary `WriteToStream` / +//! `WriteArrayToStream` output, written via `WriteBase64`. In the NBFX +//! wire form we get from the WCF binary encoder, `WriteBase64` emits a +//! `Bytes8/16/32Text` record (raw binary, NOT base64 text — base64 is +//! the XML-text representation of the same bytes). +//! +//! ## Scope this iteration (F25 step 2) +//! +//! Implements: +//! * [`build_register_items_request_body`] — `RegisterItems` request +//! contract per `AsbContracts.cs:119-143`. +//! * [`build_unregister_items_request_body`] — `UnregisterItems` +//! request per `cs:145-159`. +//! +//! Stubbed for next F25 iteration: +//! * `Read`, `Write`, `PublishWriteComplete`, `CreateSubscription`, +//! `AddMonitoredItems`, `DeleteMonitoredItems`, `Publish`. Each +//! follows the same NBFX-token pattern; the per-operation cost is +//! small once the `RegisterItems` reference is set. +//! * Response decoders. Same pattern in reverse: the reply envelope's +//! body tokens carry a per-operation outer element wrapping +//! `` Bytes records, each decoded via the corresponding +//! `InitializeArrayFromStream` shape. + +use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken}; + +use crate::contracts::{ItemIdentity, encode_item_identity_array}; + +/// Build the NBFX token stream for the body of a `RegisterItemsIn` +/// SOAP envelope. The caller wraps it via [`crate::SoapEnvelope`] + +/// [`crate::encode_envelope`]. +/// +/// Wire shape (from `AsbContracts.cs:119-143`): +/// ```xml +/// +/// +/// {int32 count + each ItemIdentity binary} +/// +/// true|false +/// true|false +/// +/// ``` +/// +/// NOTE: WCF emits the wrapper element's `xmlns` declaration as a +/// default-namespace attribute (``). NBFX represents this as a +/// `DefaultNamespace`-attribute token immediately after the element +/// open. +pub fn build_register_items_request_body( + items: &[ItemIdentity], + require_id: bool, + register_only: bool, +) -> Vec { + let payload = encode_item_identity_array(items); + asbidata_request_body( + "RegisterItemsRequest", + &[ + BodyField::asbidata("Items", payload), + BodyField::boolean("RequireId", require_id), + BodyField::boolean("RegisterOnly", register_only), + ], + ) +} + +/// Build the NBFX token stream for `UnregisterItemsIn`. Mirror of +/// `AsbContracts.cs:145-159`: +/// ```xml +/// +/// {int32 count + each ItemIdentity binary} +/// +/// ``` +pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec { + let payload = encode_item_identity_array(items); + asbidata_request_body( + "UnregisterItemsRequest", + &[BodyField::asbidata("Items", payload)], + ) +} + +// ---- internal helpers ---------------------------------------------------- + +const IOM_NS: &str = "urn:msg.data.asb.iom:2"; + +#[derive(Debug, Clone)] +enum BodyField { + /// Plain element with text body. + BoolElement { name: &'static str, value: bool }, + /// Element wrapping `` with base64-binary content (NBFX + /// represents that as `Bytes` text records). + AsbiDataElement { + name: &'static str, + payload: Vec, + }, +} + +impl BodyField { + fn boolean(name: &'static str, value: bool) -> Self { + Self::BoolElement { name, value } + } + + fn asbidata(name: &'static str, payload: Vec) -> Self { + Self::AsbiDataElement { name, payload } + } +} + +/// Emit `<{outer} xmlns="urn:msg.data.asb.iom:2"> ... ` with +/// each [`BodyField`] in order. +fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec { + let mut tokens = vec![ + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline(outer.to_string()), + }, + NbfxToken::DefaultNamespace { + value: NbfxText::Chars(IOM_NS.to_string()), + }, + ]; + for field in fields { + match field { + BodyField::BoolElement { name, value } => { + tokens.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline((*name).to_string()), + }); + tokens.push(NbfxToken::Text(NbfxText::Bool(*value))); + tokens.push(NbfxToken::EndElement); + } + BodyField::AsbiDataElement { name, payload } => { + tokens.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline((*name).to_string()), + }); + tokens.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("ASBIData".to_string()), + }); + tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone()))); + tokens.push(NbfxToken::EndElement); // + tokens.push(NbfxToken::EndElement); // + } + } + } + tokens.push(NbfxToken::EndElement); // + tokens +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::*; + use crate::contracts::decode_item_identity_array; + use mxaccess_asb_nettcp::nbfx::DynamicDictionary; + + #[test] + fn register_items_body_round_trips_items_via_asbidata() { + let items = vec![ + ItemIdentity::absolute_by_name("Tag.A"), + ItemIdentity::absolute_by_name("Tag.B"), + ]; + let body = build_register_items_request_body(&items, true, false); + + // The body should open with + assert!(matches!( + &body[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "RegisterItemsRequest" + )); + assert!(matches!( + &body[1], + NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns) } if ns == IOM_NS + )); + + // Find the {Bytes} token sequence and pull + // the Bytes payload back out — it must round-trip the + // ItemIdentity array exactly. + let mut bytes_payload: Option> = None; + for window in body.windows(3) { + if matches!( + &window[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData" + ) { + if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] { + if matches!(window[2], NbfxToken::EndElement) { + bytes_payload = Some(b.clone()); + break; + } + } + } + } + let payload = bytes_payload.expect("ASBIData Bytes record not found in body"); + let decoded = decode_item_identity_array(&payload).unwrap(); + assert_eq!(decoded, items); + } + + #[test] + fn register_items_request_round_trips_through_envelope() { + // End-to-end: build_register_items_request_body → SoapEnvelope + // → encode_envelope → decode_envelope → re-extract body tokens + // → re-extract ItemIdentity array. + let items = vec![ItemIdentity::absolute_by_name("Tag.X")]; + let body = build_register_items_request_body(&items, true, true); + + let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body); + + let mut dyn_w = DynamicDictionary::new(); + let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap(); + + let mut dyn_r = DynamicDictionary::new(); + let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap(); + assert_eq!( + decoded.action.as_deref(), + Some(crate::actions::REGISTER_ITEMS) + ); + + let mut bytes_payload: Option> = None; + for window in decoded.body_tokens.windows(3) { + if matches!( + &window[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData" + ) { + if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] { + bytes_payload = Some(b.clone()); + break; + } + } + } + let payload = bytes_payload.expect("ASBIData payload missing from decoded envelope"); + let recovered = decode_item_identity_array(&payload).unwrap(); + assert_eq!(recovered, items); + } + + #[test] + fn register_items_body_carries_require_id_and_register_only_booleans() { + let body = build_register_items_request_body(&[], true, false); + // After the {} sub-tree, the + // body should carry true followed by + // false. Because `Bytes(empty)` + // still emits a Bytes8 record + 1 EndElement + 1 EndElement, + // walk the tokens by name to be robust. + let mut saw_require_id_true = false; + let mut saw_register_only_false = false; + let mut idx = 0; + while idx < body.len() { + if let NbfxToken::Element { + name: NbfxName::Inline(local), + .. + } = &body[idx] + { + if local == "RequireId" + && matches!( + body.get(idx + 1), + Some(NbfxToken::Text(NbfxText::Bool(true))) + ) + { + saw_require_id_true = true; + } + if local == "RegisterOnly" + && matches!( + body.get(idx + 1), + Some(NbfxToken::Text(NbfxText::Bool(false))) + ) + { + saw_register_only_false = true; + } + } + idx += 1; + } + assert!(saw_require_id_true, "RequireId true not found"); + assert!(saw_register_only_false, "RegisterOnly false not found"); + } + + #[test] + fn unregister_items_body_uses_correct_outer_element_name() { + let body = build_unregister_items_request_body(&[ItemIdentity::absolute_by_name("X")]); + assert!(matches!( + &body[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "UnregisterItemsRequest" + )); + // Should NOT have RequireId / RegisterOnly fields — the + // unregister contract has only the Items array. + for tok in &body { + if let NbfxToken::Element { + name: NbfxName::Inline(local), + .. + } = tok + { + assert!(local != "RequireId"); + assert!(local != "RegisterOnly"); + } + } + } + + #[test] + fn empty_items_array_still_produces_valid_envelope() { + let body = build_register_items_request_body(&[], false, false); + let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body); + let mut dyn_w = DynamicDictionary::new(); + let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap(); + // Round-trip — at minimum, the action must come back. + let mut dyn_r = DynamicDictionary::new(); + let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap(); + assert_eq!( + decoded.action.as_deref(), + Some(crate::actions::REGISTER_ITEMS) + ); + } +}