diff --git a/design/followups.md b/design/followups.md
index fe6d3e2..b314d9f 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 landed in this commit:
+**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 landed in this commit:
+- 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:**
+- F21 (commit `5f98558`):
- F21: `mxaccess-asb-nettcp::nbfx` ports the `[MC-NBFX]` `.NET Binary XML Format` token codec — the proven subset for ASB. Token model: `Element { prefix, name }` / `EndElement` / `Attribute { prefix, name, value }` / `DefaultNamespace` / `NamespaceDeclaration` / `Text`. Name forms: inline UTF-8, `[MC-NBFS]` static-dictionary id, per-session `DynamicDictionary` id. Text forms: Empty, Zero, One, Bool, Int8/16/32/64, Chars (Chars8/16/32 width variants chosen automatically), and `DictionaryText` static/dynamic refs. The `*WithEndElement` text variants are collapsed automatically: `Text → EndElement` pairs encode as the `+1` record byte (e.g. `EmptyTextWithEndElement = 0xA9`); decoder splits them back out so consumers see the same token stream. 15 unit tests cover the dynamic-dictionary semantics, all element/attribute/xmlns/dict-text record forms, the collapse behavior with explicit byte pinning (`0x87` TrueTextWithEndElement, `0xA9` EmptyTextWithEndElement), Chars width-variant selection (Chars8 / Chars16 / Chars32 by length), unknown-record rejection, and truncated payloads. Records left for follow-up: Decimal, UniqueId, TimeSpan, Float/Double text, DateTime text, Bytes8/16/32, QNameDictionary, the `0x0C-0x25`/`0x26-0x3F` prefix-attribute and `0x44-0x77` prefix-element families.
**Earlier slices:**
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 4cab09e..650505a 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -357,6 +357,9 @@ version = "0.0.0"
dependencies = [
"mxaccess-asb-nettcp",
"mxaccess-codec",
+ "thiserror",
+ "tokio",
+ "tracing",
]
[[package]]
diff --git a/rust/crates/mxaccess-asb/Cargo.toml b/rust/crates/mxaccess-asb/Cargo.toml
index 5ec9c0d..e2b22b8 100644
--- a/rust/crates/mxaccess-asb/Cargo.toml
+++ b/rust/crates/mxaccess-asb/Cargo.toml
@@ -11,6 +11,9 @@ authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+tokio = { workspace = true }
[features]
default = []
diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs
new file mode 100644
index 0000000..3cb6dc9
--- /dev/null
+++ b/rust/crates/mxaccess-asb/src/envelope.rs
@@ -0,0 +1,768 @@
+//! SOAP-1.2-over-`[MC-NBFX]` envelope assembly + parsing for the
+//! `IASBIDataV2` contract.
+//!
+//! The .NET reference defers envelope serialisation to the WCF
+//! `BinaryMessageEncoder`. We hand-roll the equivalent against the F21
+//! NBFX token codec so requests + replies parse byte-identical to the
+//! WCF wire shape.
+//!
+//! The envelope shape is fixed: `s:Envelope` (SOAP 1.2 namespace,
+//! `[MC-NBFS]` id 4) wrapping `s:Header` then `s:Body`. The header
+//! carries a WS-Addressing `Action` plus optionally an
+//! `h:ConnectionValidator` from the ASB headers namespace
+//! (`http://asb.contracts.headers/20111111`). The body wraps the
+//! operation-specific request element from the `urn:msg.data.asb.iom:2`
+//! namespace.
+//!
+//! ## Authoritative source
+//!
+//! `src/MxAsbClient/AsbContracts.cs:11-59` defines the
+//! `[OperationContract]`-decorated `IAsbDataV2` interface; the action
+//! strings used here are copy-paste from that file. `:65-117` defines
+//! the `ConnectionValidator` SOAP header shape that the F23
+//! authenticator's `Sign` populates.
+//!
+//! ## Scope of this iteration (F25 step 1/N)
+//!
+//! Implements:
+//! * `SoapEnvelope` — generic carrier for action + optional connection-
+//! validator + body bytes (NBFX-tokens-as-bytes).
+//! * `encode_envelope` — turns a `SoapEnvelope` into NBFX byte stream.
+//! * `decode_envelope` — pulls action + body back out of a received
+//! NBFX byte stream.
+//! * `IASBIDataV2` action constants for every operation.
+//!
+//! Stubbed for the next F25 iteration:
+//! * Per-operation request/response structs (`ConnectRequest`,
+//! `RegisterItemsRequest`, etc.) — these need contract-specific
+//! element-name strings + the `IAsbCustomSerializableType`
+//! binary-payload fast-path that .NET uses for `Variant`/`AsbStatus`
+//! /`RuntimeValue`/etc.
+//! * `AsbClient` — the network loop: TCP connect + NMF preamble +
+//! sized-envelope read/write + reply correlation + auth handshake.
+
+use mxaccess_asb_nettcp::nbfx::{
+ DynamicDictionary, NbfxError, NbfxName, NbfxText, NbfxToken, decode_tokens, encode_tokens,
+};
+
+/// `IASBIDataV2` operation action strings. Copied verbatim from
+/// `AsbContracts.cs:14-58`. `OperationContract.Action` overrides the
+/// implicit `//` form, so these are
+/// authoritative for what the `` SOAP header must carry.
+pub mod actions {
+ pub const CONNECT: &str = "http://asb.contracts/20111111:connectIn";
+ pub const AUTHENTICATE_ME: &str = "http://asb.contracts/20111111:authenticateMeIn";
+ pub const DISCONNECT: &str = "http://asb.contracts/20111111:disconnectIn";
+ pub const KEEP_ALIVE: &str = "http://asb.contracts/20111111:keepAliveIn";
+ pub const REGISTER_ITEMS: &str = "http://ASB.IDataV2:registerItemsIn";
+ pub const UNREGISTER_ITEMS: &str = "http://ASB.IDataV2:unregisterItemsIn";
+ pub const READ: &str = "http://ASB.IDataV2:readIn";
+ pub const WRITE: &str = "http://ASB.IDataV2:writeIn";
+ pub const PUBLISH_WRITE_COMPLETE: &str = "http://ASB.IDataV2:publishWriteCompleteIn";
+ pub const CREATE_SUBSCRIPTION: &str = "http://ASB.IDataV2:createSubscriptionIn";
+ pub const DELETE_SUBSCRIPTION: &str = "http://ASB.IDataV2:deleteSubscriptionIn";
+ pub const ADD_MONITORED_ITEMS: &str = "http://ASB.IDataV2:addMonitoredItemsIn";
+ pub const DELETE_MONITORED_ITEMS: &str = "http://ASB.IDataV2:deleteMonitoredItemsIn";
+ pub const PUBLISH: &str = "http://ASB.IDataV2:publishIn";
+}
+
+/// `[MC-NBFS]` namespace IDs we reference here. Picked off the curated
+/// subset in `mxaccess-asb-nettcp::nbfs::STATIC_ENTRIES`.
+mod ns {
+ pub const SOAP_ENVELOPE: u32 = 4; // "http://www.w3.org/2003/05/soap-envelope"
+ pub const WS_ADDRESSING: u32 = 6; // "http://www.w3.org/2005/08/addressing"
+ pub const ENVELOPE: u32 = 2;
+ pub const HEADER: u32 = 8;
+ pub const BODY: u32 = 14;
+ pub const ACTION: u32 = 10;
+ pub const MUST_UNDERSTAND_ATTR: u32 = 116; // "MustUnderstand" — capital-M
+}
+
+/// ASB-specific namespace strings (NOT in the static dictionary). The
+/// codec emits these inline; for parity with the .NET reference's
+/// per-session interning, the encoder additionally interns them in the
+/// dynamic dictionary at first use so subsequent references compress.
+///
+/// `MESSAGES` and `IOM` are referenced by the per-operation request /
+/// response builders that the next F25 iteration adds — keep the
+/// strings here so all the ASB namespace literals live in one place.
+#[allow(dead_code)]
+mod asb_ns {
+ pub const HEADERS: &str = "http://asb.contracts.headers/20111111";
+ pub const MESSAGES: &str = "http://asb.contracts.messages/20111111";
+ pub const IOM: &str = "urn:msg.data.asb.iom:2";
+}
+
+/// Connection-validator header shape (`AsbContracts.cs:65-117`,
+/// populated by F23's `AsbAuthenticator::sign`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConnectionValidator {
+ pub connection_id: [u8; 16],
+ pub message_number: u64,
+ pub mac_base64: String,
+ pub iv_base64: String,
+}
+
+impl ConnectionValidator {
+ /// Build from an [`AsbAuthenticator`]-emitted [`SignedValidator`].
+ /// The .NET wire form base64-encodes the MAC + IV bytes; we mirror
+ /// that here so the assembled SOAP body matches `request.ToXml()`
+ /// byte-for-byte.
+ ///
+ /// [`AsbAuthenticator`]: mxaccess_asb_nettcp::auth::AsbAuthenticator
+ /// [`SignedValidator`]: mxaccess_asb_nettcp::auth::SignedValidator
+ pub fn from_signed(validator: &mxaccess_asb_nettcp::auth::SignedValidator) -> Self {
+ Self {
+ connection_id: validator.connection_id,
+ message_number: validator.message_number,
+ mac_base64: base64_encode(&validator.mac),
+ iv_base64: base64_encode(&validator.iv),
+ }
+ }
+}
+
+/// SOAP envelope carrying a single operation. `body_tokens` is the
+/// pre-built NBFX token stream for the operation-specific element
+/// (``, etc.); operation-encoder helpers
+/// (next F25 iteration) produce these.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SoapEnvelope {
+ pub action: String,
+ pub validator: Option,
+ pub body_tokens: Vec,
+}
+
+impl SoapEnvelope {
+ pub fn new(action: impl Into) -> Self {
+ Self {
+ action: action.into(),
+ validator: None,
+ body_tokens: Vec::new(),
+ }
+ }
+
+ pub fn with_validator(mut self, validator: ConnectionValidator) -> Self {
+ self.validator = Some(validator);
+ self
+ }
+
+ pub fn with_body_tokens(mut self, tokens: Vec) -> Self {
+ self.body_tokens = tokens;
+ self
+ }
+}
+
+/// Encode a SOAP envelope to NBFX bytes. Returns the byte buffer + the
+/// dynamic dictionary state at end-of-encode (the F25 client threads
+/// that through subsequent envelopes for compression).
+///
+/// Wire shape:
+/// ```xml
+/// (dict 4 / 2)
+/// (dict 8)
+/// … (dict 10)
+/// [] (asb headers ns)
+///
+/// (dict 14)
+/// {body_tokens}
+///
+///
+/// ```
+pub fn encode_envelope(
+ envelope: &SoapEnvelope,
+ dynamic: &mut DynamicDictionary,
+) -> Result, NbfxError> {
+ let mut tokens = vec![
+ //
+ NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::ENVELOPE),
+ },
+ ];
+ tokens.push(NbfxToken::NamespaceDeclaration {
+ prefix: "s".to_string(),
+ value: NbfxText::DictionaryStatic(ns::SOAP_ENVELOPE),
+ });
+ tokens.push(NbfxToken::NamespaceDeclaration {
+ prefix: "a".to_string(),
+ value: NbfxText::DictionaryStatic(ns::WS_ADDRESSING),
+ });
+
+ //
+ tokens.push(NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::HEADER),
+ });
+
+ // {action}
+ tokens.push(NbfxToken::Element {
+ prefix: Some("a".to_string()),
+ name: NbfxName::Static(ns::ACTION),
+ });
+ tokens.push(NbfxToken::Attribute {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::MUST_UNDERSTAND_ATTR),
+ value: NbfxText::One,
+ });
+ tokens.push(NbfxToken::Text(NbfxText::Chars(envelope.action.clone())));
+ tokens.push(NbfxToken::EndElement); //
+
+ //
+ if let Some(v) = &envelope.validator {
+ encode_validator(&mut tokens, v, dynamic);
+ }
+
+ tokens.push(NbfxToken::EndElement); //
+
+ //
+ tokens.push(NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::BODY),
+ });
+ tokens.extend_from_slice(&envelope.body_tokens);
+ tokens.push(NbfxToken::EndElement); //
+
+ tokens.push(NbfxToken::EndElement); //
+
+ let mut out = Vec::with_capacity(estimate_envelope_size(envelope));
+ encode_tokens(&tokens, dynamic, &mut out)?;
+ Ok(out)
+}
+
+/// Decoded SOAP envelope. The body tokens are returned as the NBFX token
+/// slice between `` and ``; operation-specific decoders
+/// take it from here.
+#[derive(Debug, Clone, PartialEq)]
+pub struct DecodedEnvelope {
+ pub action: Option,
+ pub validator: Option,
+ pub body_tokens: Vec,
+}
+
+/// Decode an NBFX byte stream back into a [`DecodedEnvelope`]. Tolerant
+/// of header ordering — looks for `Action` and `ConnectionValidator`
+/// regardless of where they appear inside ``.
+pub fn decode_envelope(
+ input: &[u8],
+ dynamic: &mut DynamicDictionary,
+) -> Result {
+ let (tokens, _consumed) = decode_tokens(input, dynamic)?;
+ let mut action = None;
+ let mut validator: Option = None;
+ let mut body_tokens = Vec::new();
+
+ let mut idx = 0;
+ while let Some(tok) = tokens.get(idx) {
+ match tok {
+ NbfxToken::Element {
+ name: NbfxName::Static(id),
+ ..
+ } if *id == ns::ACTION => {
+ idx = consume_attributes(&tokens, idx + 1);
+ if let Some(NbfxToken::Text(text)) = tokens.get(idx) {
+ action = text.resolve(dynamic);
+ idx += 1;
+ }
+ idx = skip_until_end(&tokens, idx);
+ }
+ NbfxToken::Element {
+ name: NbfxName::Inline(local),
+ ..
+ } if local.eq_ignore_ascii_case("ConnectionValidator") => {
+ let (decoded, advance) = decode_validator(&tokens, idx + 1, dynamic)?;
+ validator = Some(decoded);
+ idx = advance;
+ }
+ NbfxToken::Element {
+ name: NbfxName::Static(id),
+ ..
+ } if *id == ns::BODY => {
+ let (drained, advance) = drain_body(&tokens, idx + 1);
+ body_tokens = drained;
+ idx = advance;
+ }
+ _ => idx += 1,
+ }
+ }
+
+ Ok(DecodedEnvelope {
+ action,
+ validator,
+ body_tokens,
+ })
+}
+
+// ---- helpers -------------------------------------------------------------
+
+fn encode_validator(
+ out: &mut Vec,
+ v: &ConnectionValidator,
+ _dynamic: &mut DynamicDictionary,
+) {
+ //
+ out.push(NbfxToken::Element {
+ prefix: Some("h".to_string()),
+ name: NbfxName::Inline("ConnectionValidator".to_string()),
+ });
+ out.push(NbfxToken::NamespaceDeclaration {
+ prefix: "h".to_string(),
+ value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
+ });
+
+ // guid-text
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("ConnectionId".to_string()),
+ });
+ out.push(NbfxToken::Text(NbfxText::Chars(format_uuid(
+ &v.connection_id,
+ ))));
+ out.push(NbfxToken::EndElement);
+
+ // n
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("MessageNumber".to_string()),
+ });
+ out.push(NbfxToken::Text(NbfxText::Chars(
+ v.message_number.to_string(),
+ )));
+ out.push(NbfxToken::EndElement);
+
+ // base64
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("MessageAuthenticationCode".to_string()),
+ });
+ out.push(NbfxToken::Text(NbfxText::Chars(v.mac_base64.clone())));
+ out.push(NbfxToken::EndElement);
+
+ // base64
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("SignatureInitializationVector".to_string()),
+ });
+ out.push(NbfxToken::Text(NbfxText::Chars(v.iv_base64.clone())));
+ out.push(NbfxToken::EndElement);
+
+ out.push(NbfxToken::EndElement); //
+}
+
+fn decode_validator(
+ tokens: &[NbfxToken],
+ start: usize,
+ dynamic: &DynamicDictionary,
+) -> Result<(ConnectionValidator, usize), EnvelopeError> {
+ let mut idx = consume_attributes(tokens, start);
+ let mut connection_id: Option<[u8; 16]> = None;
+ let mut message_number: Option = None;
+ let mut mac_b64: Option = None;
+ let mut iv_b64: Option = None;
+
+ while let Some(tok) = tokens.get(idx) {
+ match tok {
+ NbfxToken::EndElement => {
+ idx += 1;
+ break;
+ }
+ NbfxToken::Element {
+ name: NbfxName::Inline(local),
+ ..
+ } => {
+ let local = local.clone();
+ let mut child_idx = consume_attributes(tokens, idx + 1);
+ let mut value = String::new();
+ if let Some(NbfxToken::Text(text)) = tokens.get(child_idx) {
+ if let Some(resolved) = text.resolve(dynamic) {
+ value = resolved;
+ }
+ child_idx += 1;
+ }
+ child_idx = skip_until_end(tokens, child_idx);
+ match local.as_str() {
+ "ConnectionId" => {
+ connection_id = parse_uuid(&value).ok();
+ }
+ "MessageNumber" => {
+ message_number = value.parse::().ok();
+ }
+ "MessageAuthenticationCode" => mac_b64 = Some(value),
+ "SignatureInitializationVector" => iv_b64 = Some(value),
+ _ => {}
+ }
+ idx = child_idx;
+ }
+ _ => idx += 1,
+ }
+ }
+
+ Ok((
+ ConnectionValidator {
+ connection_id: connection_id.ok_or(EnvelopeError::MissingValidatorField {
+ field: "ConnectionId",
+ })?,
+ message_number: message_number.ok_or(EnvelopeError::MissingValidatorField {
+ field: "MessageNumber",
+ })?,
+ mac_base64: mac_b64.unwrap_or_default(),
+ iv_base64: iv_b64.unwrap_or_default(),
+ },
+ idx,
+ ))
+}
+
+fn consume_attributes(tokens: &[NbfxToken], mut idx: usize) -> usize {
+ while let Some(tok) = tokens.get(idx) {
+ match tok {
+ NbfxToken::Attribute { .. }
+ | NbfxToken::DefaultNamespace { .. }
+ | NbfxToken::NamespaceDeclaration { .. } => idx += 1,
+ _ => break,
+ }
+ }
+ idx
+}
+
+fn skip_until_end(tokens: &[NbfxToken], mut idx: usize) -> usize {
+ let mut depth = 1usize;
+ while let Some(tok) = tokens.get(idx) {
+ idx += 1;
+ match tok {
+ NbfxToken::Element { .. } => depth += 1,
+ NbfxToken::EndElement => {
+ depth -= 1;
+ if depth == 0 {
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+ idx
+}
+
+fn drain_body(tokens: &[NbfxToken], start: usize) -> (Vec, usize) {
+ let mut idx = consume_attributes(tokens, start);
+ let mut body = Vec::new();
+ let mut depth = 1usize;
+ while let Some(tok) = tokens.get(idx) {
+ match tok {
+ NbfxToken::Element { .. } => {
+ depth += 1;
+ body.push(tok.clone());
+ }
+ NbfxToken::EndElement => {
+ depth -= 1;
+ if depth == 0 {
+ idx += 1;
+ break;
+ }
+ body.push(tok.clone());
+ }
+ other => body.push(other.clone()),
+ }
+ idx += 1;
+ }
+ (body, idx)
+}
+
+/// Format a 16-byte GUID as the canonical `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`
+/// hex representation that .NET's `Guid.ToString("D")` emits — same form
+/// the WCF XML serialiser writes for `` (`AsbContracts.cs:82`).
+///
+/// .NET stores GUID bytes in mixed-endian: the first 4 bytes are
+/// little-endian, the next 2x2 are little-endian, the last 2+6 are
+/// big-endian. We match that.
+fn format_uuid(bytes: &[u8; 16]) -> String {
+ let d1 = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
+ let d2 = u16::from_le_bytes([bytes[4], bytes[5]]);
+ let d3 = u16::from_le_bytes([bytes[6], bytes[7]]);
+ format!(
+ "{d1:08x}-{d2:04x}-{d3:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
+ bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
+ )
+}
+
+fn parse_uuid(text: &str) -> Result<[u8; 16], &'static str> {
+ let trimmed: String = text.chars().filter(|c| *c != '-').collect();
+ if trimmed.len() != 32 {
+ return Err("uuid must be 32 hex digits");
+ }
+ let mut raw = [0u8; 16];
+ for (i, byte) in raw.iter_mut().enumerate() {
+ let hi = trimmed
+ .as_bytes()
+ .get(i * 2)
+ .and_then(|b| (*b as char).to_digit(16))
+ .ok_or("non-hex digit")?;
+ let lo = trimmed
+ .as_bytes()
+ .get(i * 2 + 1)
+ .and_then(|b| (*b as char).to_digit(16))
+ .ok_or("non-hex digit")?;
+ *byte = ((hi << 4) | lo) as u8;
+ }
+ // Re-encode in .NET's mixed-endian byte order (inverse of `format_uuid`).
+ let d1 = u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]);
+ let d2 = u16::from_be_bytes([raw[4], raw[5]]);
+ let d3 = u16::from_be_bytes([raw[6], raw[7]]);
+ let mut out = [0u8; 16];
+ out[0..4].copy_from_slice(&d1.to_le_bytes());
+ out[4..6].copy_from_slice(&d2.to_le_bytes());
+ out[6..8].copy_from_slice(&d3.to_le_bytes());
+ out[8..16].copy_from_slice(&raw[8..16]);
+ Ok(out)
+}
+
+fn estimate_envelope_size(envelope: &SoapEnvelope) -> usize {
+ // Header overhead ≈ 60 bytes, plus action length, plus body bytes.
+ 60 + envelope.action.len()
+ + envelope
+ .validator
+ .as_ref()
+ .map(|v| v.mac_base64.len() + v.iv_base64.len() + 80)
+ .unwrap_or(0)
+ + envelope.body_tokens.len() * 8
+}
+
+/// Minimal base64 encoder mirroring [`mxaccess_asb_nettcp::auth`]'s
+/// inline implementation. Duplicated rather than re-exported because
+/// this is a thin presentation-layer concern (assembling the SOAP
+/// header), not part of the auth contract.
+fn base64_encode(input: &[u8]) -> String {
+ const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let lookup = |idx: u32| ALPHABET.get((idx & 0x3F) as usize).copied().unwrap_or(b'=');
+ let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
+ for chunk in input.chunks(3) {
+ let b0 = u32::from(chunk.first().copied().unwrap_or(0));
+ let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
+ let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
+ let triple = (b0 << 16) | (b1 << 8) | b2;
+ out.push(lookup(triple >> 18) as char);
+ out.push(lookup(triple >> 12) as char);
+ out.push(if chunk.len() > 1 {
+ lookup(triple >> 6) as char
+ } else {
+ '='
+ });
+ out.push(if chunk.len() > 2 {
+ lookup(triple) as char
+ } else {
+ '='
+ });
+ }
+ out
+}
+
+// ---- error type ----------------------------------------------------------
+
+#[derive(Debug, thiserror::Error)]
+#[non_exhaustive]
+pub enum EnvelopeError {
+ #[error("NBFX codec error: {0}")]
+ Nbfx(#[from] NbfxError),
+ #[error("SOAP envelope is missing ConnectionValidator field {field}")]
+ MissingValidatorField { field: &'static str },
+}
+
+#[cfg(test)]
+#[allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ clippy::indexing_slicing
+)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn round_trip_minimal_envelope() {
+ let envelope = SoapEnvelope::new(actions::REGISTER_ITEMS).with_body_tokens(vec![
+ NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("RegisterItemsRequest".to_string()),
+ },
+ NbfxToken::EndElement,
+ ]);
+
+ let mut dyn_w = DynamicDictionary::new();
+ let bytes = encode_envelope(&envelope, &mut dyn_w).unwrap();
+
+ let mut dyn_r = DynamicDictionary::new();
+ let decoded = decode_envelope(&bytes, &mut dyn_r).unwrap();
+ assert_eq!(decoded.action.as_deref(), Some(actions::REGISTER_ITEMS));
+ assert!(decoded.validator.is_none());
+ assert_eq!(decoded.body_tokens.len(), 2);
+ assert!(matches!(
+ &decoded.body_tokens[0],
+ NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "RegisterItemsRequest"
+ ));
+ }
+
+ #[test]
+ fn round_trip_envelope_with_validator() {
+ let validator = ConnectionValidator {
+ connection_id: [
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+ 0x0f, 0x10,
+ ],
+ message_number: 42,
+ mac_base64: "AAAA".to_string(),
+ iv_base64: "BBBB".to_string(),
+ };
+ let envelope = SoapEnvelope::new(actions::READ).with_validator(validator.clone());
+
+ let mut dyn_w = DynamicDictionary::new();
+ let bytes = encode_envelope(&envelope, &mut dyn_w).unwrap();
+
+ let mut dyn_r = DynamicDictionary::new();
+ let decoded = decode_envelope(&bytes, &mut dyn_r).unwrap();
+ assert_eq!(decoded.action.as_deref(), Some(actions::READ));
+ assert_eq!(decoded.validator, Some(validator));
+ }
+
+ #[test]
+ fn validator_from_signed_validator_base64s_mac_and_iv() {
+ let signed = mxaccess_asb_nettcp::auth::SignedValidator {
+ connection_id: [0xAA; 16],
+ message_number: 7,
+ mac: vec![0x01, 0x02, 0x03],
+ iv: vec![0xFE, 0xED],
+ };
+ let v = ConnectionValidator::from_signed(&signed);
+ assert_eq!(v.connection_id, [0xAA; 16]);
+ assert_eq!(v.message_number, 7);
+ assert_eq!(v.mac_base64, "AQID");
+ assert_eq!(v.iv_base64, "/u0=");
+ }
+
+ #[test]
+ fn format_uuid_matches_dotnet_to_string_d() {
+ // .NET `Guid.ToString("D")` for the bytes
+ // {01,02,03,04, 05,06, 07,08, 09,0a,0b,0c,0d,0e,0f,10}
+ // is "04030201-0605-0807-090a-0b0c0d0e0f10" (note the
+ // mixed-endian byte order in the first three groups).
+ let bytes: [u8; 16] = [
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+ 0x0f, 0x10,
+ ];
+ assert_eq!(format_uuid(&bytes), "04030201-0605-0807-090a-0b0c0d0e0f10");
+ }
+
+ #[test]
+ fn uuid_round_trip_through_text() {
+ let bytes: [u8; 16] = [
+ 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45,
+ 0x67, 0x89,
+ ];
+ let formatted = format_uuid(&bytes);
+ let parsed = parse_uuid(&formatted).unwrap();
+ assert_eq!(parsed, bytes);
+ }
+
+ #[test]
+ fn envelope_carries_action_in_static_dict_prefixed_element() {
+ // The wire form must put the Action element under WS-Addressing
+ // (static-dict id 6), with `s:mustUnderstand="1"` on the Action
+ // element. Round-trip exercises the full path.
+ let env = SoapEnvelope::new(actions::CONNECT);
+ let mut d = DynamicDictionary::new();
+ let bytes = encode_envelope(&env, &mut d).unwrap();
+ // The action string MUST appear in the encoded bytes, since we
+ // emit it as inline UTF-8 (the action strings are not in the
+ // static dictionary).
+ let needle = actions::CONNECT.as_bytes();
+ assert!(
+ bytes.windows(needle.len()).any(|w| w == needle),
+ "action string not found in encoded envelope"
+ );
+ }
+
+ #[test]
+ fn envelope_decode_handles_missing_action() {
+ // Hand-build a minimal envelope without an Action header.
+ let mut tokens = vec![
+ NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::ENVELOPE),
+ },
+ NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::HEADER),
+ },
+ NbfxToken::EndElement, //
+ NbfxToken::Element {
+ prefix: Some("s".to_string()),
+ name: NbfxName::Static(ns::BODY),
+ },
+ NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline("Empty".to_string()),
+ },
+ NbfxToken::EndElement, //
+ NbfxToken::EndElement, //
+ NbfxToken::EndElement, //
+ ];
+ // Sanity: tokens vector is even and balanced.
+ assert!(
+ tokens
+ .iter()
+ .filter(|t| matches!(t, NbfxToken::EndElement))
+ .count()
+ == 4
+ );
+ let mut d_w = DynamicDictionary::new();
+ let mut bytes = Vec::new();
+ encode_tokens(&tokens, &mut d_w, &mut bytes).unwrap();
+ let mut d_r = DynamicDictionary::new();
+ let decoded = decode_envelope(&bytes, &mut d_r).unwrap();
+ assert!(decoded.action.is_none());
+ assert_eq!(decoded.body_tokens.len(), 2); // Element + EndElement
+ let _ = tokens.pop(); // touch to silence unused-mut, body assertion above is the main check
+ }
+
+ #[test]
+ fn validator_round_trip_through_envelope_decode() {
+ let v = ConnectionValidator {
+ connection_id: [
+ 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc,
+ 0xde, 0xf0,
+ ],
+ message_number: 999,
+ mac_base64: "deadbeef".to_string(),
+ iv_base64: "feedface".to_string(),
+ };
+ let env = SoapEnvelope::new(actions::WRITE).with_validator(v.clone());
+ let mut d_w = DynamicDictionary::new();
+ let bytes = encode_envelope(&env, &mut d_w).unwrap();
+ let mut d_r = DynamicDictionary::new();
+ let decoded = decode_envelope(&bytes, &mut d_r).unwrap();
+ assert_eq!(decoded.validator, Some(v));
+ }
+
+ #[test]
+ fn action_constants_cover_full_iasbidata_v2_contract() {
+ // Quick lint: every action string carries the right keyword and
+ // ends with `In` for the request side. This is a guard against
+ // accidental edits drifting from `AsbContracts.cs`.
+ for action in [
+ actions::CONNECT,
+ actions::AUTHENTICATE_ME,
+ actions::DISCONNECT,
+ actions::KEEP_ALIVE,
+ actions::REGISTER_ITEMS,
+ actions::UNREGISTER_ITEMS,
+ actions::READ,
+ actions::WRITE,
+ actions::PUBLISH_WRITE_COMPLETE,
+ actions::CREATE_SUBSCRIPTION,
+ actions::DELETE_SUBSCRIPTION,
+ actions::ADD_MONITORED_ITEMS,
+ actions::DELETE_MONITORED_ITEMS,
+ actions::PUBLISH,
+ ] {
+ assert!(action.ends_with("In"), "{action} should end with `In`");
+ assert!(action.starts_with("http"), "{action} should be a URI");
+ }
+ }
+}
diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs
index cb27e39..d764bff 100644
--- a/rust/crates/mxaccess-asb/src/lib.rs
+++ b/rust/crates/mxaccess-asb/src/lib.rs
@@ -1,5 +1,17 @@
//! `mxaccess-asb` — `IASBIDataV2` client.
//!
-//! M0 stub. Real implementation lands in M5 — see `design/60-roadmap.md`.
+//! M5 work-in-progress (F25). The first slice of F25 — SOAP-1.2-over-NBFX
+//! envelope assembly + action constants for the full `IASBIDataV2`
+//! contract — lives in [`envelope`]. Per-operation request/response
+//! struct codecs and the network-bound `AsbClient` (TCP + NMF preamble +
+//! sized-envelope read/write loop + auth handshake) land in subsequent
+//! F25 iterations.
#![forbid(unsafe_code)]
+
+pub mod envelope;
+
+pub use envelope::{
+ ConnectionValidator, DecodedEnvelope, EnvelopeError, SoapEnvelope, actions, decode_envelope,
+ encode_envelope,
+};