Files
mxaccess/rust/crates/mxaccess-codec/src/observed_frame.rs
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:21:00 -04:00

648 lines
26 KiB
Rust

//! `NmxObservedFrame` — tolerant transfer-envelope + inner-message parser.
//!
//! Direct port of `src/MxNativeCodec/NmxObservedFrame.cs`.
//!
//! Where [`crate::NmxTransferEnvelope`] strictly validates the typed fields
//! of the 46-byte transfer header, the *observed* envelope path is a
//! permissive analyser used by probes and replay:
//!
//! - Splits a `TransferData`-shaped or `ProcessDataReceived`-shaped buffer
//! into a 46-byte header plus an inner body.
//! - Surfaces the optional 4-byte length prefix that wraps
//! `ProcessDataReceived` bodies on the wire.
//! - Parses the inner body's leading `cmd + version` bytes plus, for the
//! recognised opcodes `0x1f` and `0x21`, a 16-byte item-correlation GUID.
//! - Walks the body looking for runs of printable UTF-16LE strings and
//! surfaces them with their offsets. Unknown opcodes round-trip cleanly
//! — the parser never rejects them, it just gives them a synthetic
//! `Unknown0xNN` name (`NmxObservedFrame.cs:148`).
//!
//! ## hasDetailStatus audit (Q7 follow-up)
//!
//! `NmxObservedFrame.cs:122-126` reads `itemCorrelationId` **conditionally**:
//!
//! ```csharp
//! if (command is 0x1f or 0x21 && body.Length >= 19)
//! {
//! itemCorrelationId = new Guid(body.Slice(3, 16));
//! }
//! ```
//!
//! That is a `has_*`-style conditional read in the .NET source — it depends
//! on both the opcode and the buffer length. **Audit: the Rust port mirrors
//! the same conditional exactly** (it MUST stay conditional — making it
//! unconditional would either crash on shorter unknown-opcode bodies or
//! attach a meaningless GUID to bodies that have no correlation slot). No
//! other field in this file is read conditionally.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
/// Header length in bytes (`NmxObservedFrame.cs:14`).
pub const HEADER_LENGTH: usize = 46;
/// Inner-length field offset in the transfer header
/// (`NmxObservedFrame.cs:15`).
pub const INNER_LENGTH_OFFSET: usize = 2;
/// Tolerant parse of a `TransferData`-style envelope body. Mirrors
/// [`NmxObservedEnvelope`] returned by `ParseTransferDataBody`
/// (`NmxObservedFrame.cs:17-38`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedEnvelope {
/// Whether the body began with a 4-byte total-length prefix
/// (set on `ProcessDataReceived` payloads).
pub has_length_prefix: bool,
/// The captured 4-byte total-length prefix, or `None` if absent.
pub total_length_prefix: Option<i32>,
/// `inner_length` field at offset 2 of the 46-byte header.
pub declared_inner_length: i32,
/// Actual inner-body length in bytes (`body.len() - 46` after stripping
/// any optional length prefix).
pub actual_inner_length: usize,
/// The captured 46-byte header.
pub header: Vec<u8>,
/// The inner body that follows the header.
pub inner_body: Vec<u8>,
}
impl NmxObservedEnvelope {
/// Parse a `TransferData` body (no leading 4-byte length prefix).
/// Mirrors `ParseTransferDataBody` (`NmxObservedFrame.cs:17-38`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body.len() < 46`.
/// - [`CodecError::InnerLengthMismatch`] if the declared inner length
/// doesn't match the actual inner body length.
pub fn parse_transfer_data_body(body: &[u8]) -> Result<Self, CodecError> {
if body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: body.len(),
});
}
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
let actual_inner_length = body.len() - HEADER_LENGTH;
if declared_inner_length != actual_inner_length as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: actual_inner_length,
});
}
Ok(Self {
has_length_prefix: false,
total_length_prefix: None,
declared_inner_length,
actual_inner_length,
header: body[..HEADER_LENGTH].to_vec(),
inner_body: body[HEADER_LENGTH..].to_vec(),
})
}
/// Parse a `ProcessDataReceived` body — strict form with leading
/// 4-byte total-length prefix. Mirrors `ParseProcessDataReceivedBody`
/// (`NmxObservedFrame.cs:40-69`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body.len() < 50`.
/// - [`CodecError::InnerLengthMismatch`] if either the total-length
/// prefix or the declared inner length doesn't reconcile with the
/// buffer size.
pub fn parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
if body.len() < 4 + HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: 4 + HEADER_LENGTH,
actual: body.len(),
});
}
// `.cs:47` — total length prefix at offset 0.
let total_length_prefix = read_i32_le(body, 0);
if total_length_prefix as usize != body.len() {
return Err(CodecError::InnerLengthMismatch {
declared: total_length_prefix,
actual: body.len(),
});
}
let header_offset = 4;
// `.cs:54-55` — inner length sits at headerOffset + InnerLengthOffset.
let declared_inner_length = read_i32_le(body, header_offset + INNER_LENGTH_OFFSET);
// `.cs:56` — actualInnerLength = declared - sizeof(int).
let actual_inner_length = declared_inner_length - 4;
if actual_inner_length < 0
|| header_offset + HEADER_LENGTH + actual_inner_length as usize != body.len()
{
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: body.len() - header_offset - HEADER_LENGTH,
});
}
let actual_inner_length = actual_inner_length as usize;
Ok(Self {
has_length_prefix: true,
total_length_prefix: Some(total_length_prefix),
declared_inner_length,
actual_inner_length,
header: body[header_offset..header_offset + HEADER_LENGTH].to_vec(),
inner_body: body[header_offset + HEADER_LENGTH
..header_offset + HEADER_LENGTH + actual_inner_length]
.to_vec(),
})
}
/// Flexible `ProcessDataReceived` parse — tries the strict
/// length-prefixed form first; falls back to the `TransferData`-style
/// header-only form. Mirrors `ParseProcessDataReceivedBodyFlexible`
/// (`NmxObservedFrame.cs:71-101`).
pub fn parse_process_data_received_body_flexible(body: &[u8]) -> Result<Self, CodecError> {
// `.cs:73-80` — try the strict path if and only if the leading
// i32 == body length.
if body.len() >= 4 + HEADER_LENGTH {
let total_length_prefix = read_i32_le(body, 0);
if total_length_prefix as usize == body.len() {
return Self::parse_process_data_received_body(body);
}
}
if body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: body.len(),
});
}
// `.cs:87-92` — fall back to header-only inner-length validation.
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
let actual_inner_length = body.len() - HEADER_LENGTH;
if declared_inner_length != actual_inner_length as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: actual_inner_length,
});
}
Ok(Self {
has_length_prefix: false,
total_length_prefix: None,
declared_inner_length,
actual_inner_length,
header: body[..HEADER_LENGTH].to_vec(),
inner_body: body[HEADER_LENGTH..].to_vec(),
})
}
}
/// A printable UTF-16LE string discovered at a specific offset inside the
/// observed body. Mirrors the .NET `NmxObservedString` record
/// (`NmxObservedFrame.cs:104`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedString {
pub offset: usize,
pub value: String,
}
/// Tolerant parse of an inner NMX message body. Mirrors
/// `NmxObservedMessage` (`NmxObservedFrame.cs:106-192`).
///
/// "Tolerant" means: the parser does NOT validate the body shape against
/// any specific opcode — it simply records the leading `cmd`, `version` u16
/// (split into major/minor bytes), and (for `0x1f` / `0x21`) a 16-byte item
/// correlation GUID. Unknown opcodes get a synthetic name (`Unknown0xNN`)
/// per `.cs:148`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedMessage {
pub command: u8,
pub command_name: &'static str,
/// Synthetic name for unknown commands (`Unknown0xNN`). When the command
/// is recognised, this is empty and [`Self::command_name`] is used.
pub synthetic_name: Option<String>,
pub version_major: u8,
pub version_minor: u8,
/// Item-correlation GUID for `AdviseSupervisory` (`0x1f`) and
/// `UnAdvise` (`0x21`) bodies. **Read conditionally** — mirroring
/// `NmxObservedFrame.cs:122-126`. See module-level Q7 audit.
///
/// The GUID is 16 raw bytes from `body[3..19]`. The .NET source uses
/// `new Guid(byte[])` which interprets the first three groups as
/// little-endian (mixed-endian on the wire). The Rust port keeps the
/// raw 16-byte form to avoid pulling in a `Guid`/`uuid` dependency at
/// the codec level — consumers can re-interpret if needed.
pub item_correlation_id: Option<[u8; 16]>,
/// Printable UTF-16LE strings discovered in the body, with their
/// starting byte offsets.
pub strings: Vec<NmxObservedString>,
}
impl NmxObservedMessage {
/// Parse the body. Mirrors `NmxObservedMessage.Parse`
/// (`NmxObservedFrame.cs:114-135`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if the body has fewer than 3 bytes (the
/// minimum needed to read `cmd + version`).
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
// `.cs:116-119` — minimum length 3.
if body.len() < 3 {
return Err(CodecError::ShortRead {
expected: 3,
actual: body.len(),
});
}
let command = body[0];
// `.cs:122-126` — CONDITIONAL read of itemCorrelationId.
// Audit Q7: this stays conditional in the Rust port.
let item_correlation_id = if (command == 0x1f || command == 0x21) && body.len() >= 19 {
let mut guid = [0u8; 16];
guid.copy_from_slice(&body[3..19]);
Some(guid)
} else {
None
};
let (command_name, synthetic_name) = command_name(command);
Ok(Self {
command,
command_name,
synthetic_name,
// `.cs:131` — body[1] is the major byte of the u16 version.
version_major: body[1],
// `.cs:132` — body[2] is the minor byte.
version_minor: body[2],
item_correlation_id,
strings: extract_utf16_strings(body),
})
}
}
/// Map a command byte to its declared name. Mirrors `GetCommandName`
/// (`NmxObservedFrame.cs:137-150`).
///
/// Returns `(known_name, synthetic_name_for_unknown)`. For known commands,
/// the synthetic-name slot is `None`; for unknown commands, the known-name
/// slot is `"Unknown"` and the synthetic slot carries the formatted name.
fn command_name(command: u8) -> (&'static str, Option<String>) {
match command {
0x17 => ("MetadataQuery", None),
0x1f => ("AdviseSupervisory", None),
0x21 => ("UnAdvise", None),
0x32 => ("SubscriptionStatus", None),
0x33 => ("DataUpdate", None),
0x37 => ("Write", None),
0x40 => ("MetadataResponse", None),
// `.cs:148` — synthesised name for everything else.
other => ("Unknown", Some(format!("Unknown0x{other:02X}"))),
}
}
/// Walk the body looking for runs of printable UTF-16LE characters
/// terminated by a 2-byte NUL. Mirrors `ExtractUtf16Strings`
/// (`NmxObservedFrame.cs:152-191`).
///
/// A "string" is at least 3 printable ASCII characters (low byte in
/// `0x20..=0x7e`, high byte zero) followed by a `00 00` terminator. The
/// scanner's appetite is intentionally narrow: arbitrary binary that
/// happens to look like UTF-16 won't trip it.
fn extract_utf16_strings(body: &[u8]) -> Vec<NmxObservedString> {
let mut strings = Vec::new();
let mut offset = 0usize;
// `.cs:156` — outer guard `offset + 8 <= body.length`.
while offset + 8 <= body.len() {
let start = offset;
let mut chars: usize = 0;
// `.cs:160-177` — inner scan loop.
while offset + 1 < body.len() {
let lo = body[offset];
let hi = body[offset + 1];
// `.cs:162-167` — null terminator ends the run.
if lo == 0 && hi == 0 {
break;
}
// `.cs:169-173` — non-printable / non-ASCII byte invalidates
// the candidate run.
if hi != 0 || !(0x20..=0x7e).contains(&lo) {
chars = 0;
break;
}
chars += 1;
offset += 2;
}
// `.cs:179-186` — accept the run if it had at least 3 chars and
// is followed by the 00 00 terminator.
if chars >= 3 && offset + 1 < body.len() && body[offset] == 0 && body[offset + 1] == 0 {
let raw = &body[start..start + chars * 2];
let utf16: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
// The scan accepted only printable ASCII, so the conversion
// can't fail in practice. If it does, we silently drop the run.
if let Ok(value) = String::from_utf16(&utf16) {
strings.push(NmxObservedString {
offset: start,
value,
});
}
offset += 2;
continue;
}
// `.cs:187` — failed match: advance by 1 byte and retry.
offset = start + 1;
}
strings
}
// ---- LE primitive helpers -------------------------------------------------
#[inline]
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
i32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn synthesise_envelope(inner: &[u8]) -> Vec<u8> {
let mut out = vec![0u8; HEADER_LENGTH + inner.len()];
// Pack the header with a recognisable pattern so we can verify
// round-trip preservation.
for (i, b) in out[..HEADER_LENGTH].iter_mut().enumerate() {
*b = 0xA0u8.wrapping_add(i as u8);
}
// Patch the inner-length field at offset 2.
out[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4]
.copy_from_slice(&(inner.len() as i32).to_le_bytes());
out[HEADER_LENGTH..].copy_from_slice(inner);
out
}
fn synthesise_pdr_body(inner: &[u8]) -> Vec<u8> {
// ProcessDataReceived strict layout: 4 (total) + 46 (header) + inner.
// Total-length prefix == body.len(), inner-length field == inner.len() + 4.
let total_len = 4 + HEADER_LENGTH + inner.len();
let mut out = vec![0u8; total_len];
out[..4].copy_from_slice(&(total_len as i32).to_le_bytes());
for (i, b) in out[4..4 + HEADER_LENGTH].iter_mut().enumerate() {
*b = 0xC0u8.wrapping_add(i as u8);
}
// inner length field at offset 4 + 2 = 6, value = inner.len() + 4.
out[6..10].copy_from_slice(&((inner.len() + 4) as i32).to_le_bytes());
out[4 + HEADER_LENGTH..].copy_from_slice(inner);
out
}
#[test]
fn header_constants_match_dotnet() {
// `NmxObservedFrame.cs:14-15`.
assert_eq!(HEADER_LENGTH, 46);
assert_eq!(INNER_LENGTH_OFFSET, 2);
}
// ---- Envelope parsing -----------------------------------------------
#[test]
fn parse_transfer_data_body_round_trip() {
let inner = [0x37u8, 0x01, 0x00, 0xAB, 0xCD];
let body = synthesise_envelope(&inner);
let env = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap();
assert!(!env.has_length_prefix);
assert_eq!(env.total_length_prefix, None);
assert_eq!(env.declared_inner_length, inner.len() as i32);
assert_eq!(env.actual_inner_length, inner.len());
assert_eq!(env.inner_body, inner);
assert_eq!(env.header.len(), HEADER_LENGTH);
// Header preserved verbatim.
assert_eq!(&env.header, &body[..HEADER_LENGTH]);
}
#[test]
fn parse_transfer_data_body_rejects_short_buffer() {
let err = NmxObservedEnvelope::parse_transfer_data_body(&[0u8; 45]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_transfer_data_body_rejects_inner_length_mismatch() {
let mut body = synthesise_envelope(&[0u8; 8]);
// Clobber inner-length field to a wrong value.
body[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4].copy_from_slice(&100i32.to_le_bytes());
let err = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn parse_pdr_body_strict_round_trip() {
let inner = [0x33u8, 0x01, 0x00];
let body = synthesise_pdr_body(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap();
assert!(env.has_length_prefix);
assert_eq!(env.total_length_prefix, Some(body.len() as i32));
assert_eq!(env.actual_inner_length, inner.len());
assert_eq!(env.inner_body, inner);
}
#[test]
fn parse_pdr_body_strict_rejects_bad_total_length() {
let inner = [0u8; 4];
let mut body = synthesise_pdr_body(&inner);
// Corrupt the total-length prefix (compute the corrupt value first
// to avoid borrowing `body` mutably and immutably in the same expr).
let bad_total = body.len() as i32 + 1;
body[0..4].copy_from_slice(&bad_total.to_le_bytes());
let err = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn parse_pdr_flexible_uses_strict_when_possible() {
let inner = [0x32u8, 0x01, 0x00];
let body = synthesise_pdr_body(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
assert!(env.has_length_prefix);
}
#[test]
fn parse_pdr_flexible_falls_back_to_header_only() {
// No leading 4-byte length prefix — flexible parser falls back.
let inner = [0x32u8, 0x01, 0x00];
let body = synthesise_envelope(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
assert!(!env.has_length_prefix);
assert_eq!(env.inner_body, inner);
}
// ---- Inner-message parsing ------------------------------------------
#[test]
fn parse_message_minimum_length_3() {
let err = NmxObservedMessage::parse(&[0x37u8, 0x01]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_recognised_command_yields_known_name() {
let body = [0x37u8, 0x01, 0x00];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command, 0x37);
assert_eq!(msg.command_name, "Write");
assert_eq!(msg.synthetic_name, None);
assert_eq!(msg.version_major, 0x01);
assert_eq!(msg.version_minor, 0x00);
assert_eq!(msg.item_correlation_id, None);
}
#[test]
fn parse_unknown_command_yields_synthetic_name() {
let body = [0xAAu8, 0x01, 0x00];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command, 0xAA);
// Known-name slot is "Unknown" and synthetic_name carries the
// formatted string ("Unknown0xAA").
assert_eq!(msg.command_name, "Unknown");
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xAA"));
}
#[test]
fn advise_supervisory_carries_correlation_id_when_long_enough() {
// 0x1f + version 1 + 16-byte GUID + a couple of stuffer bytes.
let mut body = vec![0x1fu8, 0x01, 0x00];
let guid = [
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
body.extend_from_slice(&guid);
body.extend_from_slice(&[0xDE, 0xAD]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "AdviseSupervisory");
assert_eq!(msg.item_correlation_id, Some(guid));
}
#[test]
fn unadvise_carries_correlation_id_when_long_enough() {
let mut body = vec![0x21u8, 0x01, 0x00];
let guid = [0x42u8; 16];
body.extend_from_slice(&guid);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "UnAdvise");
assert_eq!(msg.item_correlation_id, Some(guid));
}
#[test]
fn correlation_id_only_for_advise_or_unadvise_opcodes() {
// Q7 audit: the conditional read is opcode-gated. Even with 19+
// bytes available, opcodes other than 0x1f / 0x21 do NOT extract
// the GUID slot.
let mut body = vec![0x37u8, 0x01, 0x00];
body.extend_from_slice(&[0xFFu8; 16]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.item_correlation_id, None);
}
#[test]
fn correlation_id_omitted_when_buffer_too_short() {
// Q7 audit: even 0x1f / 0x21 don't get a GUID if the buffer is < 19.
let body = [0x1fu8, 0x01, 0x00, 0x42];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "AdviseSupervisory");
assert_eq!(msg.item_correlation_id, None);
}
// ---- UTF-16 string scanner ------------------------------------------
#[test]
fn extract_strings_finds_simple_run() {
// "Hello" UTF-16LE + 00 00 terminator, embedded in a larger body.
let mut body = vec![0u8; 8];
let utf16 = "Hello".encode_utf16().collect::<Vec<_>>();
for u in &utf16 {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00]);
body.extend_from_slice(&[0u8; 4]);
// Prefix the body with cmd+version so we can call parse().
let mut full = vec![0x17u8, 0x01, 0x00];
full.extend_from_slice(&body);
let msg = NmxObservedMessage::parse(&full).unwrap();
let found: Vec<_> = msg.strings.iter().map(|s| s.value.as_str()).collect();
assert!(
found.contains(&"Hello"),
"did not find 'Hello' in {found:?}"
);
}
#[test]
fn extract_strings_skips_short_runs() {
// "ab\0\0" — only 2 chars, below the 3-char minimum.
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8];
let utf16 = "ab".encode_utf16().collect::<Vec<_>>();
for u in &utf16 {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00, 0u8, 0u8]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert!(msg.strings.is_empty());
}
#[test]
fn extract_strings_ignores_non_printable() {
// A byte sequence that looks UTF-16-ish but contains a control
// character (0x07) — must NOT be reported as a string.
let mut body = vec![0x17u8, 0x01, 0x00];
body.extend_from_slice(&[0x41, 0x00, 0x07, 0x00, 0x42, 0x00, 0x00, 0x00]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert!(msg.strings.is_empty());
}
#[test]
fn extract_strings_reports_offset_relative_to_body() {
// Two trailing strings; verify the second's offset is correct.
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8, 0u8];
let prefix_len = body.len();
for u in "abcdef".encode_utf16() {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.strings.len(), 1);
assert_eq!(msg.strings[0].value, "abcdef");
assert_eq!(msg.strings[0].offset, prefix_len);
}
// ---- Round-trip preservation across malformed bodies ----------------
#[test]
fn malformed_body_does_not_panic() {
// A body of all 0xFF bytes is structurally invalid for any opcode
// but parse() must not panic.
let body = [0xFFu8; 64];
let msg = NmxObservedMessage::parse(&body).unwrap();
// 0xFF is unknown; synthetic name should reflect that.
assert_eq!(msg.command, 0xFF);
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xFF"));
}
#[test]
fn version_bytes_are_split_major_minor() {
// body[1] = major, body[2] = minor, regardless of endianness.
let body = [0x37u8, 0xAB, 0xCD];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.version_major, 0xAB);
assert_eq!(msg.version_minor, 0xCD);
}
}