[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
@@ -48,8 +48,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let connection_id = [0xAAu8; 16];
let public_key = vec![0xBBu8; 32];
let body = build_connect_request_body(connection_id, &public_key);
let envelope = SoapEnvelope::new(actions::CONNECT).with_body_tokens(body);
let _ = via.clone(); // keep $via in scope for the eprintln above
let envelope = SoapEnvelope::new(actions::CONNECT)
.with_to(&via)
.with_body_tokens(body);
let mut dynamic = DynamicDictionary::new();
let payload = encode_envelope(&envelope, &mut dynamic)?;
eprintln!("envelope NBFX bytes: {}", payload.len());
+1 -1
View File
@@ -43,8 +43,8 @@
//! connects to us, but the URL inside the preamble routes correctly
//! at SMSvcHost).
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};