e79e289743
Fix all 33 rustdoc warnings across the workspace: - Unresolved intra-doc links: rewrite [`name`] → either backtick text (when not actually a link) or fully-qualified `[Type::method]` / `[crate::module::name]` form. Affected: mxaccess-codec (asb_variant, item_control, metadata_query, observed_write_template, reference_handle, write_message), mxaccess-rpc (pdu), mxaccess-nmx (client), mxaccess-asb-nettcp (nmf), mxaccess-callback (exporter), mxaccess (asb_session, session, lib). - Bracket-text being interpreted as link refs (e.g. `body[17]` → `` `body[17]` ``). - Private-item references in public docs (CALLBACK_BROADCAST_CAPACITY, recover_connection_core, mxvalue_to_writevalue) reduced to backtick-text since they aren't part of the public API. `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` now exits clean. Workspace 759 tests pass; clippy clean. Defers `#![warn(missing_docs)]` lint to a future pass — the cleanup target is the broken-link warnings, which are signal; missing-docs would surface hundreds of low-priority public-item gaps that are out of scope for this F-number. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
677 lines
24 KiB
Rust
677 lines
24 KiB
Rust
//! `[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
|
|
/// [`Self::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]);
|
|
}
|
|
}
|