Files
mxaccess/rust/crates/mxaccess-asb/src/envelope.rs
T
Joseph Doherty 104efc4e9b [M5] mxaccess-asb: F28 wire-format fixes — AuthenticateMe accepted live
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>
2026-05-05 19:29:48 -04:00

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");
}
}
}