[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:
+6
-2
@@ -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};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user