From 9dfd1937c2c88dd96727d6d9fc56f32403f1d95b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 11:01:24 -0400 Subject: [PATCH] [M5] mxaccess-asb-nettcp: F20 [MS-NMF] .NET Message Framing record codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 13 record types from `[MS-NMF]` §2.2 (Version, Mode, Via, KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault, UpgradeRequest/Response, PreambleAck, PreambleEnd) over a `net.tcp` channel. Includes the `Multibyte Int31` length codec (LEB128-style 7-bit groups over a 31-bit unsigned range, max 5 bytes; rejects negative input and overflow), plus an `encode_preamble` helper that emits the canonical ASB connect record sequence (`Version 1.0 → Duplex → Via $uri → BinaryWithDictionary → PreambleEnd`). Pure codec — no I/O. Encoders write into a `Vec`; decoders parse from a `&[u8]` slice and return the consumed-byte count alongside the record. Higher-level connect/request/response orchestration stays in the M5 ASB client (`mxaccess-asb`, F25). 24 new unit tests cover round-trip for every record type, multibyte-int31 boundary cases (0, 1, 127, 128, 16383, 16384, 200, i32::MAX), preamble emission against the canonical ASB sequence, byte-layout pinning for Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding bytes plus truncated sized-envelope frames. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 8 +- rust/crates/mxaccess-asb-nettcp/src/lib.rs | 4 + rust/crates/mxaccess-asb-nettcp/src/nmf.rs | 676 +++++++++++++++++++++ 3 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 rust/crates/mxaccess-asb-nettcp/src/nmf.rs diff --git a/design/followups.md b/design/followups.md index 048a5b7..0896e71 100644 --- a/design/followups.md +++ b/design/followups.md @@ -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 landed in commit `ed17c07`; F24 landed in this commit: +**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 landed in this commit: +- F20: `mxaccess-asb-nettcp::nmf` ports the `[MS-NMF]` `.NET Message Framing` record codec — Version, Mode, Via, KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault, UpgradeRequest/Response, PreambleAck, PreambleEnd. `Multibyte Int31` (LEB128 over 31-bit unsigned) implementation with overflow + negative-length rejection. `encode_preamble` helper emits the canonical ASB connect sequence (`Version 1.0 → Duplex → Via $uri → BinaryWithDictionary → PreambleEnd`). 24 unit tests cover record round-trip for every record type, multi-byte length boundary cases (0/1/127/128/16383/16384/200/i32::MAX), preamble emission, byte-layout pinning for Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding bytes plus truncated sized-envelope frames. + +**Earlier slices:** +- F24 (commit `7611d9e`): - F24: `mxaccess-codec::asb_variant` ports `Variant` + `AsbStatus` + `RuntimeValue` from `AsbContracts.cs:1109-1241,741-791` plus `MxAsbDataClient::DecodeVariant` + `AsbVariantFactory` from `cs:713-825,1310-1429`. Wire layout per `docs/ASB-Variant-Wire-Format.md`. `AsbVariant` is the raw 10-byte-header + payload form; `DecodedVariant` is the typed view; `from_*` factories mirror .NET's `From*`. 25 unit tests cover all proven scalar/array types' round-trip, byte layout (2/4/4/payload), `Unsupported` fallback for type ids outside the proven matrix, `AsbStatus` round-trip, `RuntimeValue` round-trip, malformed `string[]` partial-decode preservation, and short-frame rejection. **Earlier slices:** @@ -54,7 +58,7 @@ move to `## Resolved` with a date + commit hash. - F19: workspace deps added (`hmac`, `md-5`, `sha1`, `sha2`, `aes`, `cbc`, `pbkdf2`, `flate2`, `rand`, `num-bigint`, `num-traits`, `num-integer`, `quick-xml`, `tokio-util`, `zeroize`) + crate `Cargo.toml` propagation. - F23: `mxaccess-asb-nettcp::auth` ports `AsbSystemAuthenticator` (167 LoC .NET → ~480 LoC Rust + tests). 13 tests cover decimal-prime parsing, .NET `BigInteger` byte-order round-trip (sign-byte append/strip + zero), base64 against RFC 4648 §10 vectors, public-key range, private-key sizing, peer-to-peer DH shared-secret agreement, signed-validator message-number monotonicity, AES-CBC PKCS7 padding, unknown hash algorithm fallback (no MAC unless `force_hmac=true`), Apollo `:V2` lifetime-suffix dispatch, PBKDF2-SHA1 self-consistency snapshot. -F20, F21, F22, F25, F26 remain open for parallel agent fan-out. F27 (constant-time DH) is filed as a separate follow-up below. +F21, F22, F25, F26 remain open for parallel agent fan-out. F27 (constant-time DH) is filed as a separate follow-up below. ### F27 — Constant-time DH `mod_exp` (swap `num-bigint` → `crypto-bigint::BoxedUint`) **Severity:** P2 (security regression vs the long-term Rust target — but at parity with the .NET reference today, so not a release-blocker) diff --git a/rust/crates/mxaccess-asb-nettcp/src/lib.rs b/rust/crates/mxaccess-asb-nettcp/src/lib.rs index a8c4697..924fb67 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/lib.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/lib.rs @@ -20,3 +20,7 @@ #![forbid(unsafe_code)] pub mod auth; +pub mod nmf; + +pub use auth::AuthError; +pub use nmf::{NmfEncoding, NmfError, NmfMode, NmfRecord, NmfRecordType}; diff --git a/rust/crates/mxaccess-asb-nettcp/src/nmf.rs b/rust/crates/mxaccess-asb-nettcp/src/nmf.rs new file mode 100644 index 0000000..6551509 --- /dev/null +++ b/rust/crates/mxaccess-asb-nettcp/src/nmf.rs @@ -0,0 +1,676 @@ +//! `[MS-NMF]` `.NET Message Framing` record codec. +//! +//! Implements the record types `[MS-NMF]` §2.2 enumerates over a +//! `net.tcp` channel: +//! +//! | Byte | Record | Body | +//! |------|-------------------------|-----------------------------------------------------| +//! | 0x00 | `VersionRecord` | major (`u8`), minor (`u8`) | +//! | 0x01 | `ModeRecord` | mode (`u8` — Singleton/Duplex/Simplex/...) | +//! | 0x02 | `ViaRecord` | `Multibyte Int31` length + UTF-8 URI | +//! | 0x03 | `KnownEncodingRecord` | encoding (`u8`) | +//! | 0x04 | `ExtensibleEncoding` | length-prefixed encoding name | +//! | 0x05 | `UnsizedEnvelopeRecord` | unbounded payload, terminated by `EndRecord` | +//! | 0x06 | `SizedEnvelopeRecord` | `Multibyte Int31` length + payload bytes | +//! | 0x07 | `EndRecord` | (no body) | +//! | 0x08 | `FaultRecord` | `Multibyte Int31` length + UTF-8 fault string | +//! | 0x09 | `UpgradeRequestRecord` | length + UTF-8 upgrade name (e.g. SSL/TLS) | +//! | 0x0A | `UpgradeResponseRecord` | (no body) | +//! | 0x0B | `PreambleAckRecord` | (no body) | +//! | 0x0C | `PreambleEndRecord` | (no body) | +//! +//! Length fields are encoded as `Multibyte Int31` (`[MS-NMF]` §2.2.2.1): +//! 7-bit groups, MSB signals continuation, max 5 bytes (LEB128 unsigned +//! over `i32`). +//! +//! No I/O. Encoders write into a `Vec`; decoders parse from a `&[u8]` +//! slice and return the consumed-byte count alongside the record. Higher- +//! level `connect`/`request`/`response` flows stay in the M5 ASB client +//! (`mxaccess-asb`) — this module is a pure codec. +//! +//! Source for the on-the-wire shape: WCF wraps the framing inside its +//! `BinaryMessageEncodingBindingElement` (selected by default for the +//! `NetTcpBinding(SecurityMode.None)` at +//! `src/MxAsbClient/MxAsbDataClient.cs:660-685`); the framing itself is +//! the `[MS-NMF]` spec, not a project-specific extension. Captured wire +//! traces under `analysis/proxy/mxasbclient-*` confirm the proven record +//! sequence (Version → Mode → Via → KnownEncoding → PreambleEnd → +//! PreambleAck → SizedEnvelope* → End). + +use crate::AuthError; // re-imported into the same crate from auth.rs + +use thiserror::Error; + +/// Record type bytes per `[MS-NMF]` §2.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum NmfRecordType { + Version = 0x00, + Mode = 0x01, + Via = 0x02, + KnownEncoding = 0x03, + ExtensibleEncoding = 0x04, + UnsizedEnvelope = 0x05, + SizedEnvelope = 0x06, + End = 0x07, + Fault = 0x08, + UpgradeRequest = 0x09, + UpgradeResponse = 0x0A, + PreambleAck = 0x0B, + PreambleEnd = 0x0C, +} + +impl NmfRecordType { + pub fn from_u8(b: u8) -> Option { + match b { + 0x00 => Some(Self::Version), + 0x01 => Some(Self::Mode), + 0x02 => Some(Self::Via), + 0x03 => Some(Self::KnownEncoding), + 0x04 => Some(Self::ExtensibleEncoding), + 0x05 => Some(Self::UnsizedEnvelope), + 0x06 => Some(Self::SizedEnvelope), + 0x07 => Some(Self::End), + 0x08 => Some(Self::Fault), + 0x09 => Some(Self::UpgradeRequest), + 0x0A => Some(Self::UpgradeResponse), + 0x0B => Some(Self::PreambleAck), + 0x0C => Some(Self::PreambleEnd), + _ => None, + } + } +} + +/// `ModeRecord` body byte (`[MS-NMF]` §2.2.3.2). The values match the WCF +/// `MessageEncodingMode` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum NmfMode { + Singleton = 0x01, + Duplex = 0x02, + Simplex = 0x03, + SingletonSized = 0x04, +} + +impl NmfMode { + pub fn from_u8(b: u8) -> Option { + match b { + 0x01 => Some(Self::Singleton), + 0x02 => Some(Self::Duplex), + 0x03 => Some(Self::Simplex), + 0x04 => Some(Self::SingletonSized), + _ => None, + } + } +} + +/// `KnownEncodingRecord` body byte (`[MS-NMF]` §2.2.3.4). ASB uses +/// `BinaryWithDictionary` (`0x08`) — the WCF `BinaryMessageEncoder` +/// referencing `[MC-NBFX]` + `[MC-NBFS]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum NmfEncoding { + Utf8SoapText = 0x00, + Utf16SoapText = 0x01, + Utf16LeSoapText = 0x02, + Binary = 0x03, + BinaryWithMtom = 0x04, + Mtom = 0x07, + BinaryWithDictionary = 0x08, +} + +impl NmfEncoding { + pub fn from_u8(b: u8) -> Option { + match b { + 0x00 => Some(Self::Utf8SoapText), + 0x01 => Some(Self::Utf16SoapText), + 0x02 => Some(Self::Utf16LeSoapText), + 0x03 => Some(Self::Binary), + 0x04 => Some(Self::BinaryWithMtom), + 0x07 => Some(Self::Mtom), + 0x08 => Some(Self::BinaryWithDictionary), + _ => None, + } + } +} + +/// Decoded NMF record body. Encoders accept this type; decoders return it +/// alongside the consumed byte count. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NmfRecord { + Version { + major: u8, + minor: u8, + }, + Mode(NmfMode), + /// Via URI bytes — UTF-8. The .NET reference uses `Encoding.UTF8` for + /// the via string (`net.tcp://...`). + Via(String), + KnownEncoding(NmfEncoding), + /// Length-prefixed UTF-8 encoding name for non-`KnownEncoding` cases + /// (`[MS-NMF]` §2.2.3.5). Currently unused by ASB but round-tripped. + ExtensibleEncoding(String), + /// Unbounded payload that streams between this record and the next + /// `EndRecord`. Caller is responsible for chunking. + UnsizedEnvelope(Vec), + /// Length-prefixed payload (the proven ASB request/reply form). + SizedEnvelope(Vec), + End, + Fault(String), + UpgradeRequest(String), + UpgradeResponse, + PreambleAck, + PreambleEnd, +} + +impl NmfRecord { + /// Encode to wire bytes; appends to `out`. + pub fn encode_into(&self, out: &mut Vec) -> Result<(), NmfError> { + match self { + Self::Version { major, minor } => { + out.push(NmfRecordType::Version as u8); + out.push(*major); + out.push(*minor); + } + Self::Mode(mode) => { + out.push(NmfRecordType::Mode as u8); + out.push(*mode as u8); + } + Self::Via(uri) => { + out.push(NmfRecordType::Via as u8); + encode_string(out, uri.as_bytes())?; + } + Self::KnownEncoding(enc) => { + out.push(NmfRecordType::KnownEncoding as u8); + out.push(*enc as u8); + } + Self::ExtensibleEncoding(name) => { + out.push(NmfRecordType::ExtensibleEncoding as u8); + encode_string(out, name.as_bytes())?; + } + Self::UnsizedEnvelope(payload) => { + // The unsized form is a streaming body. The .NET reference + // never produces this directly — it's set up by the + // negotiated mode. We emit the type byte; payload bytes + // are written by the caller because they may be chunked. + out.push(NmfRecordType::UnsizedEnvelope as u8); + out.extend_from_slice(payload); + } + Self::SizedEnvelope(payload) => { + out.push(NmfRecordType::SizedEnvelope as u8); + let payload_len = i32::try_from(payload.len()) + .map_err(|_| NmfError::PayloadTooLarge { len: payload.len() })?; + encode_multibyte_int31(out, payload_len)?; + out.extend_from_slice(payload); + } + Self::End => out.push(NmfRecordType::End as u8), + Self::Fault(message) => { + out.push(NmfRecordType::Fault as u8); + encode_string(out, message.as_bytes())?; + } + Self::UpgradeRequest(name) => { + out.push(NmfRecordType::UpgradeRequest as u8); + encode_string(out, name.as_bytes())?; + } + Self::UpgradeResponse => out.push(NmfRecordType::UpgradeResponse as u8), + Self::PreambleAck => out.push(NmfRecordType::PreambleAck as u8), + Self::PreambleEnd => out.push(NmfRecordType::PreambleEnd as u8), + } + Ok(()) + } + + /// Encode to a fresh buffer. Convenience wrapper around + /// [`encode_into`]. + pub fn encode(&self) -> Result, NmfError> { + let mut out = Vec::new(); + self.encode_into(&mut out)?; + Ok(out) + } + + /// Decode a single record. Returns `(record, bytes_consumed)`. + pub fn decode(input: &[u8]) -> Result<(Self, usize), NmfError> { + let kind_byte = *input.first().ok_or(NmfError::Truncated { + need: 1, + have: 0, + stage: "record-type", + })?; + let kind = + NmfRecordType::from_u8(kind_byte).ok_or(NmfError::UnknownRecordType(kind_byte))?; + + let mut cursor = 1usize; + let record = match kind { + NmfRecordType::Version => { + let major = read_byte(input, &mut cursor, "version-major")?; + let minor = read_byte(input, &mut cursor, "version-minor")?; + Self::Version { major, minor } + } + NmfRecordType::Mode => { + let m = read_byte(input, &mut cursor, "mode-byte")?; + Self::Mode(NmfMode::from_u8(m).ok_or(NmfError::UnknownMode(m))?) + } + NmfRecordType::Via => Self::Via(decode_string(input, &mut cursor, "via")?), + NmfRecordType::KnownEncoding => { + let e = read_byte(input, &mut cursor, "encoding-byte")?; + Self::KnownEncoding(NmfEncoding::from_u8(e).ok_or(NmfError::UnknownEncoding(e))?) + } + NmfRecordType::ExtensibleEncoding => { + Self::ExtensibleEncoding(decode_string(input, &mut cursor, "extensible-encoding")?) + } + NmfRecordType::UnsizedEnvelope => { + // Unsized envelope is a streaming body; the codec returns + // the remaining bytes verbatim and the caller is + // responsible for splitting at the next `End` record. + let tail = input.get(cursor..).unwrap_or(&[]); + cursor += tail.len(); + Self::UnsizedEnvelope(tail.to_vec()) + } + NmfRecordType::SizedEnvelope => { + let len = decode_multibyte_int31(input, &mut cursor)?; + let len = usize::try_from(len).map_err(|_| NmfError::NegativeLength(len))?; + let payload = input.get(cursor..cursor + len).ok_or(NmfError::Truncated { + need: len, + have: input.len().saturating_sub(cursor), + stage: "sized-envelope-payload", + })?; + cursor += len; + Self::SizedEnvelope(payload.to_vec()) + } + NmfRecordType::End => Self::End, + NmfRecordType::Fault => Self::Fault(decode_string(input, &mut cursor, "fault")?), + NmfRecordType::UpgradeRequest => { + Self::UpgradeRequest(decode_string(input, &mut cursor, "upgrade-request")?) + } + NmfRecordType::UpgradeResponse => Self::UpgradeResponse, + NmfRecordType::PreambleAck => Self::PreambleAck, + NmfRecordType::PreambleEnd => Self::PreambleEnd, + }; + + Ok((record, cursor)) + } +} + +/// Convenience: the canonical preamble sequence for an ASB `net.tcp` +/// connect (`Version 1.0` → `Duplex` → `Via $uri` → +/// `KnownEncoding(BinaryWithDictionary)` → `PreambleEnd`). +/// +/// Mirrors the records WCF emits when `NetTcpBinding(SecurityMode.None)` +/// brings up a duplex channel — verified against +/// `analysis/proxy/mxasbclient-register-message.txt` capture preamble. +pub fn encode_preamble(via_uri: &str, out: &mut Vec) -> Result<(), NmfError> { + NmfRecord::Version { major: 1, minor: 0 }.encode_into(out)?; + NmfRecord::Mode(NmfMode::Duplex).encode_into(out)?; + NmfRecord::Via(via_uri.to_string()).encode_into(out)?; + NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary).encode_into(out)?; + NmfRecord::PreambleEnd.encode_into(out)?; + Ok(()) +} + +// ---- multibyte int31 ----------------------------------------------------- + +/// Encode a non-negative `i32` as `[MS-NMF]` §2.2.2.1 `Multibyte Int31`. +/// 7-bit little-endian groups; MSB signals continuation; max 5 bytes. +/// Negative values are rejected. +pub fn encode_multibyte_int31(out: &mut Vec, value: i32) -> Result<(), NmfError> { + if value < 0 { + return Err(NmfError::NegativeLength(value)); + } + let mut v = value as u32; + loop { + let byte = (v & 0x7F) as u8; + v >>= 7; + if v == 0 { + out.push(byte); + return Ok(()); + } + out.push(byte | 0x80); + } +} + +/// Decode a `Multibyte Int31`. Reads at most 5 bytes; returns the parsed +/// value and advances `cursor`. +pub fn decode_multibyte_int31(input: &[u8], cursor: &mut usize) -> Result { + let mut value: u32 = 0; + for shift in (0u32..).step_by(7).take(5) { + let byte = input.get(*cursor).copied().ok_or(NmfError::Truncated { + need: 1, + have: 0, + stage: "multibyte-int31", + })?; + *cursor += 1; + value |= ((byte & 0x7F) as u32).wrapping_shl(shift); + if byte & 0x80 == 0 { + return i32::try_from(value).map_err(|_| NmfError::IntOverflow); + } + } + Err(NmfError::IntOverflow) +} + +// ---- string helpers ------------------------------------------------------ + +fn encode_string(out: &mut Vec, bytes: &[u8]) -> Result<(), NmfError> { + let len = + i32::try_from(bytes.len()).map_err(|_| NmfError::PayloadTooLarge { len: bytes.len() })?; + encode_multibyte_int31(out, len)?; + out.extend_from_slice(bytes); + Ok(()) +} + +fn decode_string( + input: &[u8], + cursor: &mut usize, + stage: &'static str, +) -> Result { + let len_i = decode_multibyte_int31(input, cursor)?; + let len = usize::try_from(len_i).map_err(|_| NmfError::NegativeLength(len_i))?; + let bytes = input + .get(*cursor..*cursor + len) + .ok_or(NmfError::Truncated { + need: len, + have: input.len().saturating_sub(*cursor), + stage, + })?; + *cursor += len; + String::from_utf8(bytes.to_vec()).map_err(|_| NmfError::InvalidUtf8 { stage }) +} + +fn read_byte(input: &[u8], cursor: &mut usize, stage: &'static str) -> Result { + let byte = input.get(*cursor).copied().ok_or(NmfError::Truncated { + need: 1, + have: 0, + stage, + })?; + *cursor += 1; + Ok(byte) +} + +// ---- error type ---------------------------------------------------------- + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum NmfError { + #[error("truncated frame at {stage}: need {need} bytes, have {have}")] + Truncated { + need: usize, + have: usize, + stage: &'static str, + }, + #[error("unknown NMF record type 0x{0:02x}")] + UnknownRecordType(u8), + #[error("unknown NMF mode 0x{0:02x}")] + UnknownMode(u8), + #[error("unknown NMF encoding 0x{0:02x}")] + UnknownEncoding(u8), + #[error("payload too large: {len} bytes (max {})", i32::MAX)] + PayloadTooLarge { len: usize }, + #[error("multibyte int31 overflowed 31-bit unsigned range")] + IntOverflow, + #[error("negative length {0} in NMF frame")] + NegativeLength(i32), + #[error("invalid UTF-8 in NMF {stage} payload")] + InvalidUtf8 { stage: &'static str }, +} + +// `AuthError` is unrelated; this re-import exists only so consumers of the +// crate can use a single `use mxaccess_asb_nettcp::*;` statement and pull +// both auth + framing types in one go without a path collision. +#[allow(dead_code)] +const _AUTH_ERROR_IS_REACHABLE: fn(&AuthError) = |_| {}; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::*; + + fn round_trip(record: NmfRecord) { + let bytes = record.encode().unwrap(); + let (decoded, consumed) = NmfRecord::decode(&bytes).unwrap(); + assert_eq!(consumed, bytes.len(), "decode consumed != encoded len"); + assert_eq!(decoded, record); + } + + #[test] + fn version_round_trip() { + round_trip(NmfRecord::Version { major: 1, minor: 0 }); + round_trip(NmfRecord::Version { major: 0, minor: 0 }); + } + + #[test] + fn mode_round_trip_all_modes() { + for m in [ + NmfMode::Singleton, + NmfMode::Duplex, + NmfMode::Simplex, + NmfMode::SingletonSized, + ] { + round_trip(NmfRecord::Mode(m)); + } + } + + #[test] + fn via_round_trip_with_ascii_uri() { + round_trip(NmfRecord::Via( + "net.tcp://localhost:5074/ASBService".to_string(), + )); + } + + #[test] + fn via_round_trip_with_unicode_uri() { + // `net.tcp://` URIs are ASCII in practice; this is a defensive + // round-trip to catch any UTF-8 corruption in the codec path. + round_trip(NmfRecord::Via("net.tcp://hôst.example/ásb".to_string())); + } + + #[test] + fn known_encoding_round_trip() { + for e in [ + NmfEncoding::Utf8SoapText, + NmfEncoding::Utf16SoapText, + NmfEncoding::Utf16LeSoapText, + NmfEncoding::Binary, + NmfEncoding::BinaryWithMtom, + NmfEncoding::Mtom, + NmfEncoding::BinaryWithDictionary, + ] { + round_trip(NmfRecord::KnownEncoding(e)); + } + } + + #[test] + fn extensible_encoding_round_trip() { + round_trip(NmfRecord::ExtensibleEncoding( + "application/octet-stream".to_string(), + )); + } + + #[test] + fn sized_envelope_round_trip_small() { + round_trip(NmfRecord::SizedEnvelope(vec![])); + round_trip(NmfRecord::SizedEnvelope((0u8..=255).collect())); + } + + #[test] + fn sized_envelope_round_trip_large_uses_multibyte_length() { + // 200-byte payload: length needs 2 multibyte-int31 bytes (200 = + // 0xC8, encoded as 0xC8 0x01). + let payload = vec![0xAB; 200]; + let bytes = NmfRecord::SizedEnvelope(payload.clone()).encode().unwrap(); + // type (1) + length-bytes (2) + payload (200) + assert_eq!(bytes.len(), 1 + 2 + 200); + assert_eq!(bytes[0], NmfRecordType::SizedEnvelope as u8); + assert_eq!(bytes[1], 0xC8); + assert_eq!(bytes[2], 0x01); + let (decoded, consumed) = NmfRecord::decode(&bytes).unwrap(); + assert_eq!(consumed, bytes.len()); + assert!(matches!(decoded, NmfRecord::SizedEnvelope(p) if p == payload)); + } + + #[test] + fn end_record_is_one_byte() { + let bytes = NmfRecord::End.encode().unwrap(); + assert_eq!(bytes, vec![0x07]); + round_trip(NmfRecord::End); + } + + #[test] + fn fault_record_round_trip() { + round_trip(NmfRecord::Fault("invalid request".to_string())); + } + + #[test] + fn preamble_ack_and_end_round_trip() { + round_trip(NmfRecord::PreambleAck); + round_trip(NmfRecord::PreambleEnd); + round_trip(NmfRecord::UpgradeResponse); + } + + #[test] + fn upgrade_request_round_trip() { + round_trip(NmfRecord::UpgradeRequest("application/ssl-tls".to_string())); + } + + #[test] + fn unsized_envelope_round_trip_streams_payload_to_eof() { + // The unsized form returns whatever bytes follow the type byte — + // chunking is the caller's responsibility. Round-trip with an + // explicit payload to catch byte-loss in the codec. + let record = NmfRecord::UnsizedEnvelope(vec![0xDE, 0xAD, 0xBE, 0xEF]); + let bytes = record.encode().unwrap(); + // Type byte + 4 payload bytes + assert_eq!(bytes.len(), 5); + let (decoded, _) = NmfRecord::decode(&bytes).unwrap(); + assert_eq!(decoded, record); + } + + #[test] + fn multibyte_int31_round_trip_known_vectors() { + // [MS-NMF] §2.2.2.1 examples + LEB128 reference vectors. + for (value, expected) in [ + (0i32, vec![0x00u8]), + (1, vec![0x01]), + (127, vec![0x7F]), + (128, vec![0x80, 0x01]), + (16_383, vec![0xFF, 0x7F]), + (16_384, vec![0x80, 0x80, 0x01]), + (200, vec![0xC8, 0x01]), + (i32::MAX, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07]), + ] { + let mut out = Vec::new(); + encode_multibyte_int31(&mut out, value).unwrap(); + assert_eq!(out, expected, "encoding {value}"); + let mut cursor = 0; + let decoded = decode_multibyte_int31(&out, &mut cursor).unwrap(); + assert_eq!(decoded, value); + assert_eq!(cursor, expected.len()); + } + } + + #[test] + fn multibyte_int31_rejects_negative() { + let mut out = Vec::new(); + let err = encode_multibyte_int31(&mut out, -1).unwrap_err(); + assert!(matches!(err, NmfError::NegativeLength(-1))); + } + + #[test] + fn multibyte_int31_rejects_overflow() { + // 6 continuation bytes — beyond the 5-byte spec maximum. + let bytes = vec![0x80, 0x80, 0x80, 0x80, 0x80, 0x80]; + let mut cursor = 0; + let err = decode_multibyte_int31(&bytes, &mut cursor).unwrap_err(); + assert!(matches!(err, NmfError::IntOverflow)); + } + + #[test] + fn decode_rejects_unknown_record_type() { + let bytes = vec![0xFFu8]; + let err = NmfRecord::decode(&bytes).unwrap_err(); + assert!(matches!(err, NmfError::UnknownRecordType(0xFF))); + } + + #[test] + fn decode_rejects_unknown_mode() { + let bytes = vec![NmfRecordType::Mode as u8, 0xEE]; + let err = NmfRecord::decode(&bytes).unwrap_err(); + assert!(matches!(err, NmfError::UnknownMode(0xEE))); + } + + #[test] + fn decode_rejects_unknown_encoding() { + let bytes = vec![NmfRecordType::KnownEncoding as u8, 0x42]; + let err = NmfRecord::decode(&bytes).unwrap_err(); + assert!(matches!(err, NmfError::UnknownEncoding(0x42))); + } + + #[test] + fn decode_rejects_truncated_sized_envelope() { + // Type + length(=10) but only 5 payload bytes. + let mut bytes = vec![NmfRecordType::SizedEnvelope as u8, 0x0A]; + bytes.extend_from_slice(&[0xAA; 5]); + let err = NmfRecord::decode(&bytes).unwrap_err(); + assert!(matches!( + err, + NmfError::Truncated { + stage: "sized-envelope-payload", + .. + } + )); + } + + #[test] + fn preamble_emits_canonical_record_sequence() { + let mut out = Vec::new(); + encode_preamble("net.tcp://localhost:5074/ASBService", &mut out).unwrap(); + // Decode back and verify the sequence. + let mut cursor = 0; + let mut records = Vec::new(); + while cursor < out.len() { + let (record, consumed) = NmfRecord::decode(&out[cursor..]).unwrap(); + cursor += consumed; + records.push(record); + } + assert_eq!(cursor, out.len()); + assert_eq!(records.len(), 5); + assert!(matches!( + records[0], + NmfRecord::Version { major: 1, minor: 0 } + )); + assert!(matches!(records[1], NmfRecord::Mode(NmfMode::Duplex))); + match &records[2] { + NmfRecord::Via(uri) => assert_eq!(uri, "net.tcp://localhost:5074/ASBService"), + other => panic!("expected Via, got {other:?}"), + } + assert!(matches!( + records[3], + NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary) + )); + assert!(matches!(records[4], NmfRecord::PreambleEnd)); + } + + #[test] + fn version_record_byte_layout() { + // [MS-NMF] §2.2.3.1: 0x00 major minor. + let bytes = NmfRecord::Version { major: 1, minor: 0 }.encode().unwrap(); + assert_eq!(bytes, vec![0x00, 0x01, 0x00]); + } + + #[test] + fn mode_record_byte_layout() { + // [MS-NMF] §2.2.3.2: 0x01 mode-byte. Duplex = 0x02. + let bytes = NmfRecord::Mode(NmfMode::Duplex).encode().unwrap(); + assert_eq!(bytes, vec![0x01, 0x02]); + } + + #[test] + fn known_encoding_record_byte_layout() { + // [MS-NMF] §2.2.3.4: 0x03 enc-byte. BinaryWithDictionary = 0x08. + let bytes = NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary) + .encode() + .unwrap(); + assert_eq!(bytes, vec![0x03, 0x08]); + } +}