[M5] mxaccess-asb: WCF binary message header (action+to dict pre-pop)

Adds the binary header block that WCF prepends to SizedEnvelope
payloads. Reverse-engineered from .NET probe wire bytes captured via
asb-relay.

Wire form (per the .NET capture analysis in the previous commit):
```
[outer length, multibyte-int31]
  [string-1 length, multibyte-int31] [UTF-8 bytes]   ← dict id 1 (action)
  [string-2 length, multibyte-int31] [UTF-8 bytes]   ← dict id 3 (to)
[NBFX <s:Envelope>...]
```

Inside the NBFX envelope, `<a:Action>` and `<a:To>` reference the
pre-pop strings via `DictionaryText 0xAA {odd-id}` instead of inlining
their values. The header strings get assigned odd dict ids
(1, 3, 5, ...); even ids stay reserved for the [MC-NBFS] static dict.

Encode side:
* `encode_envelope` now emits header [action, to] before NBFX. `to_uri`
  defaults to empty string when None — caller-supplied `with_to(uri)`
  is the supported path.
* AsbClient's `send_envelope` and `send_envelope_one_way` auto-fill
  `to_uri` from `self.via_uri` when not set.
* New private `encode_binary_header(strings)` helper.

Decode side:
* New `parse_binary_header_prefix(input)` heuristically detects + parses
  the header (look for plausible NBFX element record byte 0x40-0x77 at
  the offset implied by the outer length).
* New `resolve_with_header(text, dynamic, header)` resolves
  `DictionaryText` with odd id by indexing into header.strings; even
  ids fall through to static-dict lookup as before.

Tests pass (72) — round-trip envelope → bytes → envelope recovers
action through the new dict-id resolution path.

Live status: this commit gets us further but the connect SOAP
envelope still TCP-RSTs at SMSvcHost. The remaining delta vs the .NET
capture is structural NBFX optimisation: .NET uses single-letter
prefix-element/attribute records (0x44-0x77 PrefixDictionaryElement
_<a-z>, 0x0C-0x25 PrefixDictionaryAttribute_<a-z>, 0x0B
DictionaryXmlnsAttribute) while our F21 encoder always uses the long
forms (0x43 prefix-string + name-dict-id, etc.). Logically
equivalent but WCF's parser likely strict on which form it accepts.

Next iteration will add short-form encoding to F21 for single-letter
prefixes (s:, a:, h:, i:) which covers every namespace prefix in our
envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 15:40:59 -04:00
parent d4ee5f3a18
commit 2867310817
4 changed files with 265 additions and 90 deletions
+16 -2
View File
@@ -152,7 +152,16 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
return Err(ClientError::AlreadyClosed);
}
let payload = encode_envelope(envelope, &mut self.write_dictionary)?;
// Default the WS-Addressing To header to the same URL we put
// in the NMF Via record. WCF dispatches by To-URL match
// against the registered service URL; an empty / wrong To
// produces an AddressFilterMismatch fault.
let envelope = if envelope.to_uri.is_some() {
envelope.clone()
} else {
envelope.clone().with_to(self.via_uri.clone())
};
let payload = encode_envelope(&envelope, &mut self.write_dictionary)?;
let mut framed = Vec::new();
NmfRecord::SizedEnvelope(payload).encode_into(&mut framed)?;
self.stream.write_all(&framed).await?;
@@ -276,7 +285,12 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
if self.closed {
return Err(ClientError::AlreadyClosed);
}
let payload = encode_envelope(envelope, &mut self.write_dictionary)?;
let envelope = if envelope.to_uri.is_some() {
envelope.clone()
} else {
envelope.clone().with_to(self.via_uri.clone())
};
let payload = encode_envelope(&envelope, &mut self.write_dictionary)?;
let mut framed = Vec::new();
NmfRecord::SizedEnvelope(payload).encode_into(&mut framed)?;
self.stream.write_all(&framed).await?;