[M5] mxaccess-asb-nettcp: F20 [MS-NMF] .NET Message Framing record codec

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<u8>`; 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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:01:24 -04:00
parent 7611d9e215
commit 9dfd1937c2
3 changed files with 686 additions and 2 deletions
+6 -2
View File
@@ -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)
@@ -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};
+676
View File
@@ -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<u8>`; 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<Self> {
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<Self> {
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<Self> {
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<u8>),
/// Length-prefixed payload (the proven ASB request/reply form).
SizedEnvelope(Vec<u8>),
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<u8>) -> 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<Vec<u8>, 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<u8>) -> 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<u8>, 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<i32, NmfError> {
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<u8>, 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<String, NmfError> {
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<u8, NmfError> {
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]);
}
}