[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:
+5
-1
@@ -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:**
|
||||
|
||||
Generated
+3
@@ -357,6 +357,9 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess-asb-nettcp",
|
||||
"mxaccess-codec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user