104efc4e9b
Three wire-level bugs surfaced by side-by-side relay capture against
the .NET probe routed via the new --via flag:
1. **Dynamic-dictionary id drift**. Our `encode_envelope` hardcoded
action_dict_id=1 / to_dict_id=3, which is correct for the FIRST
message in a session but wrong for every subsequent one. The
per-session dynamic dict accumulates across messages: Connect's
binary header pre-pops [action,to] at ids 1,3; AuthenticateMe must
reference the new action at id 5 (continuing the odd sequence) and
the To URL at id 3 (still in the dict from Connect). Fix uses
`DynamicDictionary::position_of` + `intern` to compute the right
wire id, only pre-popping strings that are NEW to the session.
Captured against .NET probe via asb-relay: AuthenticateMe binary
header has only one string (action) at offset 0x260 (`06 de 08 2f
2e ...`), and `<a:Action>` value `ab 05` references the new id 5.
2. **ConnectionValidator wire format depends on operation**. .NET's
`IAsbDataV2` declares `[XmlSerializerFormat]` on AuthenticateMe,
Disconnect, KeepAlive (one-way ops) — those use XmlSerializer for
the ENTIRE message including the [MessageHeader] ConnectionValid-
ator. Other ops use the default DataContractSerializer. The wire
shapes differ:
XmlSerializer: `<ConnectionId xmlns="http://asb.contracts.data/
20111111">guid</ConnectionId>` (PascalCase property name in
data namespace)
DataContract: `<connectionIdField xmlns="http://schemas.data
contract.org/2004/07/ArchestrAServices.ASBContract">guid</…>`
(private "fooField" name in datacontract namespace)
New `ValidatorWireFormat::for_action` picks the right shape per
action; `encode_validator` now branches on it. New helpers
`push_xml_text_field` / `push_xml_byte_array_field` for the
XmlSerializer form. The DataContract form is preserved verbatim
for Register/Read/Write/etc.
3. **Decoder missing 0x0A** (`ShortDictionaryXmlnsAttribute`). The
server's RegisterItemsResponse uses `0x0A {dict-id}` to set the
default namespace from the static dict; our decoder bailed out
with `UnknownRecord(10)`. New decode arm produces a
`DefaultNamespace` token with `DictionaryStatic` value.
**.NET probe gains a `--via` flag** (`AsbConnectionOptions.Via` →
`ChannelFactory.CreateChannel(addr, viaUri)`) so the probe can be
routed through asb-relay for byte-level capture without triggering
an `AddressFilterMismatch` fault. CoreWCF / .NET 10 dropped
`ClientViaBehavior`; the `CreateChannel(addr, via)` overload is the
modern equivalent.
Live status (this commit): Connect handshake works, AuthenticateMe
no longer faults (canonical XML + crypto + wire-format all match
.NET now), RegisterItemsResponse comes back from the server (a real
response, not a dispatcher fault). One remaining issue: our response
decoder hits `MissingField { field: "Status" }` — the server's
RegisterItemsResponse uses a slightly different element naming or
encoding than `collect_asbidata_payloads` expects. Next iteration
hunts that.
Workspace: 710 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1203 lines
46 KiB
Rust
1203 lines
46 KiB
Rust
//! 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 `<service-namespace>/<contract>/<method>` form, so these are
|
|
/// authoritative for what the `<a:Action>` 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
|
|
/// (`<RegisterItemsRequest .../>`, etc.); operation-encoder helpers
|
|
/// (next F25 iteration) produce these.
|
|
/// Serialization shape for the `<ConnectionValidator>` 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 `<a:To>` header value (typically the same
|
|
/// `net.tcp://...` URL used in the NMF `Via` record). WCF's
|
|
/// default binding requires `<a:To>` for service dispatch —
|
|
/// without it the server resets the connection. Set via
|
|
/// [`Self::with_to`].
|
|
pub to_uri: Option<String>,
|
|
pub validator: Option<ConnectionValidator>,
|
|
pub body_tokens: Vec<NbfxToken>,
|
|
}
|
|
|
|
impl SoapEnvelope {
|
|
pub fn new(action: impl Into<String>) -> Self {
|
|
Self {
|
|
action: action.into(),
|
|
to_uri: None,
|
|
validator: None,
|
|
body_tokens: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_to(mut self, to_uri: impl Into<String>) -> 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<NbfxToken>) -> 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
|
|
/// <s:Envelope xmlns:s="…" xmlns:a="…">
|
|
/// <s:Header>
|
|
/// <a:Action s:mustUnderstand="1">{dict 1}</a:Action>
|
|
/// [<h:ConnectionValidator …/>]
|
|
/// <a:MessageID>urn:uuid:…</a:MessageID>
|
|
/// <a:ReplyTo><a:Address>{anonymous}</a:Address></a:ReplyTo>
|
|
/// <a:To s:mustUnderstand="1">{dict 3}</a:To>
|
|
/// </s:Header>
|
|
/// <s:Body>{body_tokens}</s:Body>
|
|
/// </s:Envelope>
|
|
/// ```
|
|
///
|
|
/// 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<Vec<u8>, 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 `<a:To>` 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<String> = 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),
|
|
},
|
|
// <s:Header>
|
|
NbfxToken::Element {
|
|
prefix: Some("s".to_string()),
|
|
name: NbfxName::Static(ns::HEADER),
|
|
},
|
|
// <a:Action s:mustUnderstand="1">{dict id 1}</a:Action>
|
|
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, // </a:Action>
|
|
];
|
|
|
|
// <h:ConnectionValidator …/> (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);
|
|
}
|
|
|
|
// <a:MessageID>{16-byte UUID via UniqueIdText}</a:MessageID>
|
|
// 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); // </a:MessageID>
|
|
|
|
// <a:ReplyTo><a:Address>{anonymous}</a:Address></a:ReplyTo>
|
|
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); // </a:Address>
|
|
tokens.push(NbfxToken::EndElement); // </a:ReplyTo>
|
|
|
|
// <a:To s:mustUnderstand="1">{dict id 3}</a:To>
|
|
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); // </a:To>
|
|
|
|
tokens.push(NbfxToken::EndElement); // </s:Header>
|
|
|
|
// <s:Body xmlns:xsi="...XMLSchema-instance" xmlns:xsd="...XMLSchema">
|
|
// 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); // </s:Body>
|
|
|
|
tokens.push(NbfxToken::EndElement); // </s:Envelope>
|
|
|
|
// ---- 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<String>,
|
|
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<ParsedBinaryHeader> {
|
|
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<String> {
|
|
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<Vec<u8>, 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 `<s:Body>` and `</s:Body>`; operation-specific decoders
|
|
/// take it from here.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct DecodedEnvelope {
|
|
pub action: Option<String>,
|
|
pub validator: Option<ConnectionValidator>,
|
|
pub body_tokens: Vec<NbfxToken>,
|
|
}
|
|
|
|
/// Decode an NBFX byte stream back into a [`DecodedEnvelope`]. Tolerant
|
|
/// of header ordering — looks for `Action` and `ConnectionValidator`
|
|
/// regardless of where they appear inside `<s:Header>`.
|
|
pub fn decode_envelope(
|
|
input: &[u8],
|
|
dynamic: &mut DynamicDictionary,
|
|
) -> Result<DecodedEnvelope, EnvelopeError> {
|
|
// 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<ConnectionValidator> = 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<NbfxToken>,
|
|
v: &ConnectionValidator,
|
|
_dynamic: &mut DynamicDictionary,
|
|
fmt: ValidatorWireFormat,
|
|
) {
|
|
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111"
|
|
// ... shape depends on fmt ...>
|
|
// ... per-format children ...
|
|
// </h:ConnectionValidator>
|
|
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); // </h:ConnectionValidator>
|
|
}
|
|
|
|
/// Emit `<{name} xmlns="{ns}">{value}</{name}>` for an XmlSerializer-
|
|
/// formatted text field. No xmlns:i (no nil semantics).
|
|
fn push_xml_text_field(out: &mut Vec<NbfxToken>, 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<NbfxToken>, 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}</{name}>` 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<NbfxToken>, 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 `<a:MessageID>` 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<u64> = None;
|
|
let mut mac_b64: Option<String> = None;
|
|
let mut iv_b64: Option<String> = 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::<u64>().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<NbfxToken>, 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 `<ConnectionId>` (`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, // </s:Header>
|
|
NbfxToken::Element {
|
|
prefix: Some("s".to_string()),
|
|
name: NbfxName::Static(ns::BODY),
|
|
},
|
|
NbfxToken::Element {
|
|
prefix: None,
|
|
name: NbfxName::Inline("Empty".to_string()),
|
|
},
|
|
NbfxToken::EndElement, // </Empty>
|
|
NbfxToken::EndElement, // </s:Body>
|
|
NbfxToken::EndElement, // </s:Envelope>
|
|
];
|
|
// 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");
|
|
}
|
|
}
|
|
}
|