[M5] mxaccess-asb: F25 step 1 — SOAP envelope codec

First slice of F25. Provides the building blocks the per-operation
request/response codecs and the network loop will compose:

* `actions` module — IASBIDataV2 action strings (all 14 operations,
  verbatim from `AsbContracts.cs:14-58`).
* `ConnectionValidator` — SOAP header struct mirroring
  `AsbContracts.cs:65-117`. `from_signed(&SignedValidator)` converts
  F23's MAC + IV to base64 for the wire, matching .NET's
  `BinaryWriter`-via-`XmlSerializer` shape.
* `SoapEnvelope` + `encode_envelope` — assembles the NBFX token
  stream: `s:Envelope` → `s:Header` → `a:Action s:mustUnderstand="1"`
  → optional `h:ConnectionValidator` → `s:Body` → caller-supplied
  body tokens. Uses static-dictionary IDs for the SOAP/WS-Addressing
  tokens via F22's `lookup_static`.
* `decode_envelope` — pulls action + validator + body tokens back
  out of received bytes. Tolerant of header ordering.
* Mixed-endian GUID format/parse (`format_uuid` / `parse_uuid`) that
  mirrors .NET's `Guid.ToString("D")` byte order so connection-id
  round-trip matches the wire exactly.

9 new unit tests cover:
* Round-trip with and without validator.
* `from_signed` base64 encoding of MAC + IV.
* `format_uuid` produces the correct .NET-mixed-endian hex string.
* GUID round-trip through string formatter.
* Action string presence in the encoded byte stream.
* Decoder tolerance of envelopes without an Action header.
* Validator round-trip through full encode → decode.
* Lint-style guard that all 14 action constants are URIs ending `In`.

Stubbed for next F25 iteration: per-operation request/response
struct codecs (`ConnectRequest`, `RegisterItemsRequest`, etc.) +
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:16:22 -04:00
parent 5f985588f7
commit 25dbd8d3bd
5 changed files with 792 additions and 2 deletions
+5 -1
View File
@@ -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 `<s:Header>`). 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:**
+3
View File
@@ -357,6 +357,9 @@ version = "0.0.0"
dependencies = [
"mxaccess-asb-nettcp",
"mxaccess-codec",
"thiserror",
"tokio",
"tracing",
]
[[package]]
+3
View File
@@ -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 = []
+768
View File
@@ -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 `<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;
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
/// (`<RegisterItemsRequest .../>`, etc.); operation-encoder helpers
/// (next F25 iteration) produce these.
#[derive(Debug, Clone, PartialEq)]
pub struct SoapEnvelope {
pub action: String,
pub validator: Option<ConnectionValidator>,
pub body_tokens: Vec<NbfxToken>,
}
impl SoapEnvelope {
pub fn new(action: impl Into<String>) -> 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<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 (the F25 client threads
/// that through subsequent envelopes for compression).
///
/// Wire shape:
/// ```xml
/// <s:Envelope> (dict 4 / 2)
/// <s:Header> (dict 8)
/// <a:Action s:mustUnderstand="1">…</a:Action> (dict 10)
/// [<h:ConnectionValidator …/>] (asb headers ns)
/// </s:Header>
/// <s:Body> (dict 14)
/// {body_tokens}
/// </s:Body>
/// </s:Envelope>
/// ```
pub fn encode_envelope(
envelope: &SoapEnvelope,
dynamic: &mut DynamicDictionary,
) -> Result<Vec<u8>, NbfxError> {
let mut tokens = vec![
// <s:Envelope xmlns:s="…soap-envelope" xmlns:a="…addressing">
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),
});
// <s:Header>
tokens.push(NbfxToken::Element {
prefix: Some("s".to_string()),
name: NbfxName::Static(ns::HEADER),
});
// <a:Action s:mustUnderstand="1">{action}</a: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); // </a:Action>
// <h:ConnectionValidator …/>
if let Some(v) = &envelope.validator {
encode_validator(&mut tokens, v, dynamic);
}
tokens.push(NbfxToken::EndElement); // </s:Header>
// <s:Body>
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); // </s:Body>
tokens.push(NbfxToken::EndElement); // </s:Envelope>
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 `<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> {
let (tokens, _consumed) = decode_tokens(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 = 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<NbfxToken>,
v: &ConnectionValidator,
_dynamic: &mut DynamicDictionary,
) {
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111">
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()),
});
// <ConnectionId>guid-text</ConnectionId>
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);
// <MessageNumber>n</MessageNumber>
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);
// <MessageAuthenticationCode>base64</MessageAuthenticationCode>
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);
// <SignatureInitializationVector>base64</SignatureInitializationVector>
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); // </h:ConnectionValidator>
}
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);
match local.as_str() {
"ConnectionId" => {
connection_id = parse_uuid(&value).ok();
}
"MessageNumber" => {
message_number = value.parse::<u64>().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<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)
}
/// 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.
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");
}
}
}
+13 -1
View File
@@ -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,
};