//! 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; /// SOAP 1.2 spec name is lowercase `mustUnderstand`. NBFS id 0 /// (capital-M `MustUnderstand` at id 116 is a different token). /// Sending the wrong one triggers a WCF parse fault that /// surfaces as a TCP RST. pub const MUST_UNDERSTAND_ATTR: u32 = 0; } /// 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. /// Serialization shape for the `` SOAP header. /// .NET picks one based on the operation's `[XmlSerializerFormat]` /// attribute (set on `AuthenticateMe`/`Disconnect`/`KeepAlive` and /// nothing else); the Rust port must match. /// /// * `XmlSerializer` — public-property names (`ConnectionId`, /// `MessageNumber`, `MessageAuthenticationCode`, /// `SignatureInitializationVector`) in the /// `http://asb.contracts.data/20111111` namespace (per the /// `[XmlType]` on the class). /// * `DataContract` — private-field names (`connectionIdField`, /// `messageAuthenticationCodeField`, `messageNumberField`, /// `signatureInitializationVectorField`) in the /// `http://schemas.datacontract.org/2004/07/ArchestrAServices /// .ASBContract` namespace (per the `[DataContract]` on the class). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ValidatorWireFormat { XmlSerializer, DataContract, } impl ValidatorWireFormat { /// Pick the wire format based on the SOAP action. Mirrors /// `IAsbDataV2`'s `[XmlSerializerFormat]` attribute distribution /// (`AsbContracts.cs:14-58`): authenticateMeIn, disconnectIn, /// keepAliveIn carry the attribute; everything else uses the /// default DataContract format. pub fn for_action(action: &str) -> Self { if action.ends_with(":authenticateMeIn") || action.ends_with(":disconnectIn") || action.ends_with(":keepAliveIn") { Self::XmlSerializer } else { Self::DataContract } } } #[derive(Debug, Clone, PartialEq)] pub struct SoapEnvelope { pub action: String, /// WS-Addressing `` header value (typically the same /// `net.tcp://...` URL used in the NMF `Via` record). WCF's /// default binding requires `` for service dispatch — /// without it the server resets the connection. Set via /// [`Self::with_to`]. pub to_uri: Option, pub validator: Option, pub body_tokens: Vec, } impl SoapEnvelope { pub fn new(action: impl Into) -> Self { Self { action: action.into(), to_uri: None, validator: None, body_tokens: Vec::new(), } } pub fn with_to(mut self, to_uri: impl Into) -> Self { self.to_uri = Some(to_uri.into()); self } 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. /// /// **Wire shape (WCF binary message format)**: /// /// 1. **Binary header block** prepended to the NBFX envelope. WCF /// uses this to pre-populate the per-session dynamic dictionary /// with strings that appear inside the envelope. Each string gets /// an odd dictionary id (`1, 3, 5, ...` — even ids are reserved /// for the static [MC-NBFS] dictionary). /// /// ```text /// [outer length, multibyte-int31] /// [action length, multibyte-int31] [action UTF-8 bytes] ← dict id 1 /// [to length, multibyte-int31] [to UTF-8 bytes] ← dict id 3 /// ``` /// /// 2. **NBFX envelope** that references the pre-populated strings: /// /// ```xml /// /// /// {dict 1} /// [] /// urn:uuid:… /// {anonymous} /// {dict 3} /// /// {body_tokens} /// /// ``` /// /// The header form was reverse-engineered from the .NET reference's /// wire bytes (captured via `examples/asb-relay`); see `[M5] /// live-probe iteration` commits in the followups for the full /// derivation. pub fn encode_envelope( envelope: &SoapEnvelope, dynamic: &mut DynamicDictionary, ) -> Result, NbfxError> { // ---- Binary header block ---- // // Pre-populate the per-session dynamic dictionary with strings the // NBFX envelope below references. The dynamic dict is *cumulative* // per session — each `intern` returns a 0-based slot whose wire // dict id is `slot * 2 + 1` (odd ids only; even ids are reserved // for `[MC-NBFS]` static). // // The binary message header pre-pops only **new** strings (those // not already in the dict), in order. Strings interned by an // earlier message stay in the dict at their original wire id and // can be referenced by later messages without being re-popped. // // Captured against `MxAsbClient.Probe --via …` through `asb-relay`: // Connect's binary header pre-pops `[action, to]` (ids 1, 3). // AuthenticateMe's binary header only pre-pops the new action // (id 5); the `` element references id 3, still in the dict // from Connect's pre-pop. Earlier `encode_envelope` versions // hardcoded action_dict_id=1 / to_dict_id=3 every message — // wrong for any non-first message in a session, because id 1 / // id 3 then resolved to whatever the *first* message put there. let to_uri = envelope.to_uri.clone().unwrap_or_default(); let mut header_strings: Vec = Vec::with_capacity(2); let action_dict_id: u32 = match dynamic.position_of(&envelope.action) { Some(slot) => slot * 2 + 1, None => { let slot = dynamic.intern(&envelope.action); header_strings.push(envelope.action.clone()); slot * 2 + 1 } }; let to_dict_id: u32 = match dynamic.position_of(&to_uri) { Some(slot) => slot * 2 + 1, None => { let slot = dynamic.intern(&to_uri); header_strings.push(to_uri.clone()); slot * 2 + 1 } }; // ---- NBFX envelope tokens ---- let mut tokens = vec![ NbfxToken::Element { prefix: Some("s".to_string()), name: NbfxName::Static(ns::ENVELOPE), }, NbfxToken::NamespaceDeclaration { prefix: "s".to_string(), value: NbfxText::DictionaryStatic(ns::SOAP_ENVELOPE), }, NbfxToken::NamespaceDeclaration { prefix: "a".to_string(), value: NbfxText::DictionaryStatic(ns::WS_ADDRESSING), }, // NbfxToken::Element { prefix: Some("s".to_string()), name: NbfxName::Static(ns::HEADER), }, // {dict id 1} NbfxToken::Element { prefix: Some("a".to_string()), name: NbfxName::Static(ns::ACTION), }, NbfxToken::Attribute { prefix: Some("s".to_string()), name: NbfxName::Static(ns::MUST_UNDERSTAND_ATTR), value: NbfxText::One, }, NbfxToken::Text(NbfxText::DictionaryStatic(action_dict_id)), NbfxToken::EndElement, // ]; // (when present, comes before // MessageID/ReplyTo per the .NET dump's element order) if let Some(v) = &envelope.validator { let fmt = ValidatorWireFormat::for_action(&envelope.action); encode_validator(&mut tokens, v, dynamic, fmt); } // {16-byte UUID via UniqueIdText} // WCF emits MessageID as a UniqueIdText record (0xAC) carrying the // 16 raw UUID bytes — NOT as Chars text. Verified against .NET // probe wire capture. let message_id_bytes = make_random_uuid_v4_bytes(); tokens.push(NbfxToken::Element { prefix: Some("a".to_string()), name: NbfxName::Static(26), }); tokens.push(NbfxToken::Text(NbfxText::UniqueId(message_id_bytes))); tokens.push(NbfxToken::EndElement); // // {anonymous} tokens.push(NbfxToken::Element { prefix: Some("a".to_string()), name: NbfxName::Static(44), }); tokens.push(NbfxToken::Element { prefix: Some("a".to_string()), name: NbfxName::Static(42), }); tokens.push(NbfxToken::Text(NbfxText::DictionaryStatic(20))); tokens.push(NbfxToken::EndElement); // tokens.push(NbfxToken::EndElement); // // {dict id 3} tokens.push(NbfxToken::Element { prefix: Some("a".to_string()), name: NbfxName::Static(12), }); tokens.push(NbfxToken::Attribute { prefix: Some("s".to_string()), name: NbfxName::Static(ns::MUST_UNDERSTAND_ATTR), value: NbfxText::One, }); tokens.push(NbfxToken::Text(NbfxText::DictionaryStatic(to_dict_id))); tokens.push(NbfxToken::EndElement); // tokens.push(NbfxToken::EndElement); // // // WCF emits xmlns:xsi + xmlns:xsd as raw-string xmlns attributes on // the Body element (verified against .NET probe wire capture). // These declarations are required for `xsi:type` annotations that // appear inside DataContract-serialised body fields (e.g. // ConnectRequest's nested PublicKey). tokens.push(NbfxToken::Element { prefix: Some("s".to_string()), name: NbfxName::Static(ns::BODY), }); tokens.push(NbfxToken::NamespaceDeclaration { prefix: "xsi".to_string(), value: NbfxText::Chars("http://www.w3.org/2001/XMLSchema-instance".to_string()), }); tokens.push(NbfxToken::NamespaceDeclaration { prefix: "xsd".to_string(), value: NbfxText::Chars("http://www.w3.org/2001/XMLSchema".to_string()), }); tokens.extend_from_slice(&envelope.body_tokens); tokens.push(NbfxToken::EndElement); // tokens.push(NbfxToken::EndElement); // // ---- Assemble output: binary header + NBFX envelope ---- let mut nbfx_bytes = Vec::with_capacity(estimate_envelope_size(envelope)); encode_tokens(&tokens, dynamic, &mut nbfx_bytes)?; let header_strs: Vec<&str> = header_strings.iter().map(String::as_str).collect(); let header_bytes = encode_binary_header(&header_strs)?; let mut out = Vec::with_capacity(header_bytes.len() + nbfx_bytes.len()); out.extend_from_slice(&header_bytes); out.extend_from_slice(&nbfx_bytes); Ok(out) } /// Encode the WCF binary message header that prepends the NBFX envelope. /// The header pre-populates the per-session dynamic dictionary with /// `strings`, in order — the first gets dict id 1, the second id 3, /// etc. (odd ids only; evens are reserved for static `[MC-NBFS]`). /// /// Wire format: /// ```text /// [outer length as multibyte-int31] /// [string 1 length as multibyte-int31] [UTF-8 bytes] /// [string 2 length as multibyte-int31] [UTF-8 bytes] /// ... /// ``` /// Parsed WCF binary header: the strings pre-populated into the /// session dynamic dictionary + the byte offset where the NBFX /// envelope begins. struct ParsedBinaryHeader { /// Pre-pop strings in declaration order. Wire ids: index 0 → 1, /// index 1 → 3, index 2 → 5, etc. (odd numbers; even reserved /// for `[MC-NBFS]` static dict). strings: Vec, nbfx_start: usize, } /// Detect + decode the WCF binary header block at the start of a SOAP /// envelope payload. Returns `None` if no header is present (e.g. the /// peer didn't emit one). /// /// Heuristic: read a multibyte-int31 length L from the start. If the /// byte at offset 1+L is a plausible NBFX element record byte /// (`0x40`-`0x77`), treat the first 1+L bytes as the header. Walk the /// inner block as a sequence of length-prefixed UTF-8 strings. fn parse_binary_header_prefix(input: &[u8]) -> Option { use mxaccess_asb_nettcp::nmf::decode_multibyte_int31; let mut cursor = 0usize; let outer_len = decode_multibyte_int31(input, &mut cursor).ok()?; let outer_len = usize::try_from(outer_len).ok()?; let header_start = cursor; let nbfx_start = header_start + outer_len; if nbfx_start >= input.len() { return None; } let first_nbfx = *input.get(nbfx_start)?; if !(0x40..=0x77).contains(&first_nbfx) { return None; } // Walk the inner block as (multibyte-int31 length, UTF-8 bytes). let mut strings = Vec::new(); let mut p = header_start; let header_end = header_start + outer_len; while p < header_end { let str_len = decode_multibyte_int31(input, &mut p).ok()?; let str_len = usize::try_from(str_len).ok()?; let bytes = input.get(p..p + str_len)?; let s = std::str::from_utf8(bytes).ok()?; strings.push(s.to_string()); p += str_len; } Some(ParsedBinaryHeader { strings, nbfx_start, }) } /// Resolve an NBFX text token using static dict + dynamic dict + the /// binary-header pre-pop strings (odd dict ids). fn resolve_with_header( text: &NbfxText, dynamic: &DynamicDictionary, header: Option<&ParsedBinaryHeader>, ) -> Option { if let NbfxText::DictionaryStatic(id) = text { // Even ids hit the static dict; odd ids hit the dynamic // pre-pop. Wire id 2N+1 → header.strings[N]. if id % 2 == 1 { if let Some(h) = header { let idx = (*id as usize - 1) / 2; if let Some(s) = h.strings.get(idx) { return Some(s.clone()); } } } } text.resolve(dynamic) } fn encode_binary_header(strings: &[&str]) -> Result, NbfxError> { use mxaccess_asb_nettcp::nmf::encode_multibyte_int31; let mut inner = Vec::new(); for s in strings { let len = i32::try_from(s.len()).map_err(|_| NbfxError::PayloadTooLarge { len: s.len(), max: i32::MAX as u64, })?; encode_multibyte_int31(&mut inner, len).map_err(|_| NbfxError::IntOverflow)?; inner.extend_from_slice(s.as_bytes()); } let inner_len = i32::try_from(inner.len()).map_err(|_| NbfxError::PayloadTooLarge { len: inner.len(), max: i32::MAX as u64, })?; let mut out = Vec::new(); encode_multibyte_int31(&mut out, inner_len).map_err(|_| NbfxError::IntOverflow)?; out.extend_from_slice(&inner); 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 { // Strip + decode the WCF binary header block (action+to pre-pop) // if present. The header strings get assigned odd dict ids // (1, 3, 5, ...); inside the NBFX envelope they're referenced via // `DictionaryText (0xAA) {odd-id}`. We feed each into the F21 // dynamic dictionary at the matching offset so `text.resolve()` // returns the right string. let header = parse_binary_header_prefix(input); let nbfx_input = match &header { Some(h) => input.get(h.nbfx_start..).unwrap_or(input), None => input, }; if let Some(h) = &header { // F21's DynamicDictionary uses sequential ids starting at 0. // Wire ids for dynamic strings are odd (1, 3, 5, ...). // Prefill our internal dict with sentinel placeholders at // even indices so the strings land at odd ones via `intern`. for (i, s) in h.strings.iter().enumerate() { // Slot 0 → wire id 1, slot 1 → wire id 3, etc. Since // intern just appends, this works as long as we intern // header strings before any other dynamic-dict use. let _ = i; // silence unused-var if we change scheme later dynamic.intern(s); } } let (tokens, _consumed) = decode_tokens(nbfx_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 = resolve_with_header(text, dynamic, header.as_ref()); 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, fmt: ValidatorWireFormat, ) { // // ... per-format children ... // out.push(NbfxToken::Element { prefix: Some("h".to_string()), name: NbfxName::Inline("ConnectionValidator".to_string()), }); match fmt { ValidatorWireFormat::DataContract => { // DataContractSerializer form: private "fooField" names in // the ASBContract namespace, plus an `xmlns:i` declaration // for the `i:nil="true"` attribute that WCF emits on empty // byte[] members. Captured via .NET probe wire dump. out.push(NbfxToken::NamespaceDeclaration { prefix: "i".to_string(), value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance }); out.push(NbfxToken::NamespaceDeclaration { prefix: "h".to_string(), value: NbfxText::Chars(asb_ns::HEADERS.to_string()), }); let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract"; push_dc_field( out, "connectionIdField", dc_ns, &format_uuid(&v.connection_id), ); push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64); push_dc_field( out, "messageNumberField", dc_ns, &v.message_number.to_string(), ); push_dc_field( out, "signatureInitializationVectorField", dc_ns, &v.iv_base64, ); } ValidatorWireFormat::XmlSerializer => { // XmlSerializer form: public property names (PascalCase) in // the data namespace from `[XmlType]` on the class. No // `xmlns:i` declaration here — XmlSerializer doesn't emit // `xsi:nil` for empty byte[] (it uses self-closing // elements instead, but byte[] in the wire format actually // shows a Chars text record holding the base64-encoded // empty content per the .NET probe capture; see // push_xml_byte_array_field). Captured via // `MxAsbClient.Probe --via …` for AuthenticateMe. out.push(NbfxToken::NamespaceDeclaration { prefix: "h".to_string(), value: NbfxText::Chars(asb_ns::HEADERS.to_string()), }); let data_ns = "http://asb.contracts.data/20111111"; push_xml_text_field(out, "ConnectionId", data_ns, &format_uuid(&v.connection_id)); push_xml_text_field( out, "MessageNumber", data_ns, &v.message_number.to_string(), ); push_xml_byte_array_field(out, "MessageAuthenticationCode", data_ns, &v.mac_base64); push_xml_byte_array_field( out, "SignatureInitializationVector", data_ns, &v.iv_base64, ); } } out.push(NbfxToken::EndElement); // } /// Emit `<{name} xmlns="{ns}">{value}` for an XmlSerializer- /// formatted text field. No xmlns:i (no nil semantics). fn push_xml_text_field(out: &mut Vec, name: &str, ns: &str, value: &str) { out.push(NbfxToken::Element { prefix: None, name: NbfxName::Inline(name.to_string()), }); out.push(NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns.to_string()), }); out.push(NbfxToken::Text(NbfxText::Chars(value.to_string()))); out.push(NbfxToken::EndElement); } /// Emit a `byte[]` field in XmlSerializer format. Empty value → /// `<{name} xmlns="{ns}" />` (self-closing, NO xsi:nil — that's the /// DataContract behaviour). Non-empty → `<{name} xmlns="{ns}">b64`. fn push_xml_byte_array_field(out: &mut Vec, name: &str, ns: &str, b64: &str) { out.push(NbfxToken::Element { prefix: None, name: NbfxName::Inline(name.to_string()), }); out.push(NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns.to_string()), }); if !b64.is_empty() { out.push(NbfxToken::Text(NbfxText::Chars(b64.to_string()))); } out.push(NbfxToken::EndElement); } /// Emit a `<{name} xmlns="{dc_ns}">{value}` element. When /// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match /// .NET's WCF DataContractSerializer output for null/empty byte arrays. fn push_dc_field(out: &mut Vec, name: &str, dc_ns: &str, value: &str) { out.push(NbfxToken::Element { prefix: None, name: NbfxName::Inline(name.to_string()), }); out.push(NbfxToken::DefaultNamespace { value: NbfxText::Chars(dc_ns.to_string()), }); if !value.is_empty() { out.push(NbfxToken::Text(NbfxText::Chars(value.to_string()))); } out.push(NbfxToken::EndElement); } /// Random RFC 4122 v4 UUID raw bytes. Used by `encode_envelope` for /// the `` UniqueIdText record (16 raw bytes on the wire). fn make_random_uuid_v4_bytes() -> [u8; 16] { use rand::RngCore; let mut bytes = [0u8; 16]; rand::thread_rng().fill_bytes(&mut bytes); bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 1 (RFC 4122) bytes } #[allow(dead_code)] // kept for callers that need the textual form fn _unused_make_random_uuid_v4() -> String { let bytes = make_random_uuid_v4_bytes(); format!( "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], ) } 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); // Accept both the PascalCase form (legacy) and the // DataContract field-name form (`fooField` — what // .NET WCF emits per the captured `--dump-messages` // output, which is what we now produce on encode). match local.as_str() { "ConnectionId" | "connectionIdField" => { connection_id = parse_uuid(&value).ok(); } "MessageNumber" | "messageNumberField" => { message_number = value.parse::().ok(); } "MessageAuthenticationCode" | "messageAuthenticationCodeField" => { mac_b64 = Some(value); } "SignatureInitializationVector" | "signatureInitializationVectorField" => { 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) } /// Internal helper exposed for the F25 step-6 ConnectRequest body /// builder. Re-exports the same byte-order convention as /// [`format_uuid`]. #[doc(hidden)] pub fn format_uuid_for_test(bytes: &[u8; 16]) -> String { format_uuid(bytes) } /// 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. pub 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"); } } }