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, +};