//! `[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 /// [`Self::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]); } }