fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
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>
1191 lines
46 KiB
Rust
1191 lines
46 KiB
Rust
//! NMX reference-registration request (`0x10`) and result (`0x11`) messages.
|
|
//!
|
|
//! Direct port of:
|
|
//! - `src/MxNativeCodec/NmxReferenceRegistrationMessage.cs` (request, opcode `0x10`)
|
|
//! - `src/MxNativeCodec/NmxReferenceRegistrationResultMessage.cs` (result, opcode `0x11`)
|
|
//!
|
|
//! Both types live in the same module because they are tightly coupled: the
|
|
//! result is the server's response to the request, sharing the `(ItemHandle,
|
|
//! ItemCorrelationId, ItemDefinition, ItemContext)` quadruple as a correlation
|
|
//! key.
|
|
//!
|
|
//! ## Unknown-bytes preservation (request)
|
|
//!
|
|
//! Per CLAUDE.md, the Rust port must round-trip non-zero bytes in protocol
|
|
//! gaps that the .NET reference zero-initialises but does not validate. The
|
|
//! request has two such gaps:
|
|
//!
|
|
//! - bytes 25-26 (2 bytes) — `reserved_25_27`
|
|
//! - bytes 31-54 (24 bytes) — `reserved_31_55`
|
|
//!
|
|
//! The .NET `Encode` allocates `new byte[...]` (`NmxReferenceRegistrationMessage.cs:71-78`)
|
|
//! which zeros those regions, but `Parse` does **not** assert they are zero —
|
|
//! it skips over them entirely. Captured frames could carry non-zero values
|
|
//! and the Rust port preserves them on round-trip.
|
|
//!
|
|
//! ## Validated-zero regions
|
|
//!
|
|
//! The .NET `Parse` for both messages **does** assert several regions are
|
|
//! all-zero and rejects non-zero bytes:
|
|
//!
|
|
//! - request: `itemStringReserved` (8 bytes between the two strings) —
|
|
//! `NmxReferenceRegistrationMessage.cs:42-47`
|
|
//! - request: tail bytes 0..18 (19 of the 20 tail bytes) —
|
|
//! `NmxReferenceRegistrationMessage.cs:54-57`
|
|
//! - result: 6-byte gap between `mxDataType` and `itemContext` —
|
|
//! `NmxReferenceRegistrationResultMessage.cs:52-55`
|
|
//! - result: full 16-byte tail —
|
|
//! `NmxReferenceRegistrationResultMessage.cs:64-67`
|
|
//!
|
|
//! These are reproduced as hard rejections in the Rust port.
|
|
|
|
// Direct byte indexing — see reference_handle.rs for rationale.
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
use crate::error::CodecError;
|
|
|
|
// ============================================================================
|
|
// Request message — opcode 0x10
|
|
// ============================================================================
|
|
|
|
/// Command byte at offset 0 (`NmxReferenceRegistrationMessage.cs:13`).
|
|
const REQUEST_COMMAND: u8 = 0x10;
|
|
|
|
/// Version u16 LE at offset 1 (`NmxReferenceRegistrationMessage.cs:14`).
|
|
const REQUEST_VERSION: u16 = 1;
|
|
|
|
/// Fixed prefix length (`NmxReferenceRegistrationMessage.cs:15`).
|
|
const REQUEST_HEADER_LEN: usize = 55;
|
|
|
|
/// 8-byte zero-asserted region between the two registered strings
|
|
/// (`NmxReferenceRegistrationMessage.cs:16,42-47`).
|
|
const ITEM_STRING_RESERVED_LEN: usize = 8;
|
|
|
|
/// 20-byte tail; bytes 0..19 must be zero, byte 19 is the `subscribe_flag`
|
|
/// (`NmxReferenceRegistrationMessage.cs:17,54-56,92`).
|
|
const REQUEST_TAIL_LEN: usize = 20;
|
|
|
|
/// High byte for the 0x81 string length marker
|
|
/// (`NmxReferenceRegistrationMessage.cs:108-110, 131`). Used by tests for
|
|
/// direct byte assertions; the encode/parse paths use [`TAGGED_STRING_MARKER_WORD`]
|
|
/// and [`TAGGED_STRING_MARKER_MASK`] instead.
|
|
#[allow(dead_code)]
|
|
const TAGGED_STRING_MARKER_BYTE: u8 = 0x81;
|
|
|
|
/// Mask for extracting the byte length from a tagged length word
|
|
/// (`NmxReferenceRegistrationMessage.cs:107`).
|
|
const TAGGED_STRING_LENGTH_MASK: i32 = 0x00FF_FFFF;
|
|
|
|
/// Value of the upper byte (`(rawLength >> 24) & 0xFF`) for a tagged length
|
|
/// (`NmxReferenceRegistrationMessage.cs:108-110`).
|
|
const TAGGED_STRING_MARKER_WORD: i32 = 0x8100_0000_u32 as i32;
|
|
|
|
/// `0xFF00_0000` — used to isolate the marker byte when validating tagged
|
|
/// strings (`NmxReferenceRegistrationMessage.cs:108`).
|
|
const TAGGED_STRING_MARKER_MASK: i32 = 0xFF00_0000_u32 as i32;
|
|
|
|
/// 16-byte GUID alias. The .NET reference uses `System.Guid`; the Rust port
|
|
/// keeps the raw 16 bytes since this codec crate has no `uuid` dependency
|
|
/// and the GUID is opaque from the wire's perspective.
|
|
pub type Guid16 = [u8; 16];
|
|
|
|
/// NMX reference-registration request message (opcode `0x10`).
|
|
///
|
|
/// Mirrors `NmxReferenceRegistrationMessage` in the .NET reference. The
|
|
/// request asks the server to register a tag (`item_definition`) under a
|
|
/// `(item_handle, item_correlation_id)` pair within a given `item_context`,
|
|
/// optionally subscribing for updates.
|
|
///
|
|
/// # Layout
|
|
///
|
|
/// ```text
|
|
/// offset size field
|
|
/// 0 1 command u8 = 0x10
|
|
/// 1 2 version u16 LE = 1
|
|
/// 3 4 item_handle i32 LE
|
|
/// 7 16 item_correlation_id GUID
|
|
/// 23 2 i16 LE = -1 (constant marker)
|
|
/// 25 2 reserved_25_27 [u8; 2] (preserved verbatim)
|
|
/// 27 4 i32 LE = 1 (constant)
|
|
/// 31 24 reserved_31_55 [u8; 24] (preserved verbatim)
|
|
/// 55 4 item_definition tagged length (high byte 0x81 + 24-bit byte length)
|
|
/// 59+ N item_definition UTF-16LE + null terminator
|
|
/// ... 8 item_string_reserved (must be all zero on parse)
|
|
/// ... 4 item_context untagged length (i32 LE byte length)
|
|
/// ... M item_context UTF-16LE + null terminator
|
|
/// ... 20 tail: 19 zero bytes + 1 subscribe_flag byte
|
|
/// ```
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
|
pub struct NmxReferenceRegistrationMessage {
|
|
/// Item handle assigned by the client; echoed back in the result.
|
|
/// Offset 3, i32 LE (`NmxReferenceRegistrationMessage.cs:37, 82`).
|
|
pub item_handle: i32,
|
|
|
|
/// Correlation GUID; echoed back in the result.
|
|
/// Offset 7, 16 bytes (`NmxReferenceRegistrationMessage.cs:38, 83`).
|
|
pub item_correlation_id: Guid16,
|
|
|
|
/// Tag definition (e.g. `"$Object.SomeAttr"`). Encoded as a tagged string
|
|
/// — the i32 length word has high byte `0x81` and the low 24 bits hold
|
|
/// the UTF-16LE byte count including the null terminator
|
|
/// (`NmxReferenceRegistrationMessage.cs:40, 89`).
|
|
pub item_definition: String,
|
|
|
|
/// Context (galaxy/platform identifier). Encoded as an untagged string —
|
|
/// the length word is a plain i32 byte count
|
|
/// (`NmxReferenceRegistrationMessage.cs:48, 91`).
|
|
pub item_context: String,
|
|
|
|
/// Subscribe flag at byte 19 of the 20-byte tail
|
|
/// (`NmxReferenceRegistrationMessage.cs:64, 92`).
|
|
pub subscribe: bool,
|
|
|
|
/// Bytes 25..27 of the prefix. The .NET reference zero-initialises these
|
|
/// (`NmxReferenceRegistrationMessage.cs:71-78`) but `Parse` does not
|
|
/// validate them, so the Rust port preserves them per CLAUDE.md.
|
|
pub reserved_25_27: [u8; 2],
|
|
|
|
/// Bytes 31..55 of the prefix. Same preservation rationale as
|
|
/// `reserved_25_27`.
|
|
pub reserved_31_55: [u8; 24],
|
|
}
|
|
|
|
impl NmxReferenceRegistrationMessage {
|
|
/// Opcode of the request (`0x10`).
|
|
pub const COMMAND: u8 = REQUEST_COMMAND;
|
|
|
|
/// Append `.property(buffer)` to a tag definition unless it is already
|
|
/// present (case-insensitive). Mirrors
|
|
/// `NmxReferenceRegistrationMessage.ToBufferedItemDefinition`
|
|
/// (`NmxReferenceRegistrationMessage.cs:96-102`).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`CodecError::InvalidName`] if `item_definition` is empty or
|
|
/// whitespace-only — matches `ArgumentException.ThrowIfNullOrWhiteSpace`
|
|
/// in the .NET reference.
|
|
pub fn to_buffered_item_definition(item_definition: &str) -> Result<String, CodecError> {
|
|
if item_definition.trim().is_empty() {
|
|
return Err(CodecError::InvalidName);
|
|
}
|
|
const SUFFIX: &str = ".property(buffer)";
|
|
// Use `as_bytes` to avoid panicking on non-UTF-8-char-boundary slices.
|
|
// `SUFFIX` is pure ASCII, so byte-level case-insensitive comparison is
|
|
// equivalent to `String.EndsWith(..., OrdinalIgnoreCase)`.
|
|
let bytes = item_definition.as_bytes();
|
|
let suffix = SUFFIX.as_bytes();
|
|
let already_buffered = bytes.len() >= suffix.len()
|
|
&& bytes[bytes.len() - suffix.len()..].eq_ignore_ascii_case(suffix);
|
|
if already_buffered {
|
|
Ok(item_definition.to_string())
|
|
} else {
|
|
Ok(format!("{item_definition}{SUFFIX}"))
|
|
}
|
|
}
|
|
|
|
/// Parse a reference-registration request body.
|
|
///
|
|
/// Mirrors `NmxReferenceRegistrationMessage.Parse`
|
|
/// (`NmxReferenceRegistrationMessage.cs:19-65`).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - [`CodecError::ShortRead`] if `body.len() < 83` (header + reserved + tail).
|
|
/// - [`CodecError::UnexpectedOpcode`] if byte 0 != `0x10`.
|
|
/// - [`CodecError::UnsupportedVersion`] if bytes 1..3 != `1u16`.
|
|
/// - [`CodecError::Decode`] for any malformed string, missing tagged
|
|
/// marker, non-zero `item_string_reserved`, mismatched tail length, or
|
|
/// non-zero tail bytes 0..19.
|
|
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
|
|
let min_len = REQUEST_HEADER_LEN + ITEM_STRING_RESERVED_LEN + REQUEST_TAIL_LEN;
|
|
if body.len() < min_len {
|
|
return Err(CodecError::ShortRead {
|
|
expected: min_len,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
|
|
// Offset 0 — command (`NmxReferenceRegistrationMessage.cs:26-29`).
|
|
if body[0] != REQUEST_COMMAND {
|
|
return Err(CodecError::UnexpectedOpcode(body[0]));
|
|
}
|
|
|
|
// Offset 1 — version (`NmxReferenceRegistrationMessage.cs:31-35`).
|
|
let version = read_u16_le(body, 1);
|
|
if version != REQUEST_VERSION {
|
|
return Err(CodecError::UnsupportedVersion {
|
|
expected: REQUEST_VERSION,
|
|
actual: version,
|
|
});
|
|
}
|
|
|
|
// Offset 3 — item handle (`NmxReferenceRegistrationMessage.cs:37`).
|
|
let item_handle = read_i32_le(body, 3);
|
|
|
|
// Offset 7 — correlation GUID (`NmxReferenceRegistrationMessage.cs:38`).
|
|
let mut item_correlation_id = [0u8; 16];
|
|
item_correlation_id.copy_from_slice(&body[7..23]);
|
|
|
|
// Offset 23 — i16 LE = -1. The .NET parser does not validate this,
|
|
// it only writes -1 on encode (`NmxReferenceRegistrationMessage.cs:85`).
|
|
// Mirror parse behaviour: ignore. Encoding always emits -1.
|
|
let _ = read_i16_le(body, 23);
|
|
|
|
// Offset 25..27 — preserved reserved bytes.
|
|
let mut reserved_25_27 = [0u8; 2];
|
|
reserved_25_27.copy_from_slice(&body[25..27]);
|
|
|
|
// Offset 27 — i32 LE = 1. Constant; `Parse` does not validate it
|
|
// (`NmxReferenceRegistrationMessage.cs:86`). Mirror.
|
|
let _ = read_i32_le(body, 27);
|
|
|
|
// Offset 31..55 — preserved reserved bytes.
|
|
let mut reserved_31_55 = [0u8; 24];
|
|
reserved_31_55.copy_from_slice(&body[31..55]);
|
|
|
|
// Offset 55 — item definition (tagged string).
|
|
let mut offset = REQUEST_HEADER_LEN;
|
|
let item_definition = read_registered_string(body, &mut offset, true)?;
|
|
|
|
// Item-string reserved 8 bytes (must all be zero per
|
|
// `NmxReferenceRegistrationMessage.cs:42-46`).
|
|
if offset + ITEM_STRING_RESERVED_LEN > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: offset + ITEM_STRING_RESERVED_LEN,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
if body[offset..offset + ITEM_STRING_RESERVED_LEN]
|
|
.iter()
|
|
.any(|&b| b != 0)
|
|
{
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "non-zero reference-registration item string reserved bytes",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
offset += ITEM_STRING_RESERVED_LEN;
|
|
|
|
// Item context — untagged string.
|
|
let item_context = read_registered_string(body, &mut offset, false)?;
|
|
|
|
// Tail (`NmxReferenceRegistrationMessage.cs:49-57, 64`).
|
|
let remaining = body.len() - offset;
|
|
if remaining != REQUEST_TAIL_LEN {
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "unexpected reference-registration tail length",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
if body[offset..offset + REQUEST_TAIL_LEN - 1]
|
|
.iter()
|
|
.any(|&b| b != 0)
|
|
{
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "non-zero reference-registration tail bytes",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
let subscribe = body[offset + REQUEST_TAIL_LEN - 1] != 0;
|
|
|
|
Ok(Self {
|
|
item_handle,
|
|
item_correlation_id,
|
|
item_definition,
|
|
item_context,
|
|
subscribe,
|
|
reserved_25_27,
|
|
reserved_31_55,
|
|
})
|
|
}
|
|
|
|
/// Encode the request body. Mirrors `NmxReferenceRegistrationMessage.Encode`
|
|
/// (`NmxReferenceRegistrationMessage.cs:67-94`), but additionally writes
|
|
/// `reserved_25_27` and `reserved_31_55` verbatim instead of leaving them
|
|
/// at zero.
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let item_definition_bytes = encode_null_terminated_utf16(&self.item_definition);
|
|
let item_context_bytes = encode_null_terminated_utf16(&self.item_context);
|
|
|
|
let total_len = REQUEST_HEADER_LEN
|
|
+ 4 // tagged length word
|
|
+ item_definition_bytes.len()
|
|
+ ITEM_STRING_RESERVED_LEN
|
|
+ 4 // untagged length word
|
|
+ item_context_bytes.len()
|
|
+ REQUEST_TAIL_LEN;
|
|
|
|
let mut body = vec![0u8; total_len];
|
|
|
|
// Offset 0 — command.
|
|
body[0] = REQUEST_COMMAND;
|
|
// Offset 1 — version (`NmxReferenceRegistrationMessage.cs:81`).
|
|
write_u16_le(&mut body, 1, REQUEST_VERSION);
|
|
// Offset 3 — item handle (`NmxReferenceRegistrationMessage.cs:82`).
|
|
write_i32_le(&mut body, 3, self.item_handle);
|
|
// Offset 7 — correlation GUID (`NmxReferenceRegistrationMessage.cs:83`).
|
|
body[7..23].copy_from_slice(&self.item_correlation_id);
|
|
// Offset 23 — i16 LE = -1 marker (`NmxReferenceRegistrationMessage.cs:85`).
|
|
write_i16_le(&mut body, 23, -1);
|
|
// Offset 25..27 — preserved reserved bytes (Rust-port-only behaviour).
|
|
body[25..27].copy_from_slice(&self.reserved_25_27);
|
|
// Offset 27 — i32 LE = 1 (`NmxReferenceRegistrationMessage.cs:86`).
|
|
write_i32_le(&mut body, 27, 1);
|
|
// Offset 31..55 — preserved reserved bytes.
|
|
body[31..55].copy_from_slice(&self.reserved_31_55);
|
|
|
|
let mut offset = REQUEST_HEADER_LEN;
|
|
write_registered_string(&mut body, &mut offset, &item_definition_bytes, true);
|
|
// 8-byte item-string reserved is left zero (already initialised).
|
|
offset += ITEM_STRING_RESERVED_LEN;
|
|
write_registered_string(&mut body, &mut offset, &item_context_bytes, false);
|
|
// 20-byte tail: bytes 0..19 already zero; byte 19 is subscribe flag
|
|
// (`NmxReferenceRegistrationMessage.cs:92`).
|
|
body[offset + REQUEST_TAIL_LEN - 1] = u8::from(self.subscribe);
|
|
|
|
body
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Result message — opcode 0x11
|
|
// ============================================================================
|
|
|
|
/// Command byte at offset 0 (`NmxReferenceRegistrationResultMessage.cs:17`).
|
|
const RESULT_COMMAND: u8 = 0x11;
|
|
|
|
/// Version u16 LE at offset 1 (`NmxReferenceRegistrationResultMessage.cs:18`).
|
|
const RESULT_VERSION: u16 = 1;
|
|
|
|
/// Fixed prefix length (`NmxReferenceRegistrationResultMessage.cs:19`).
|
|
const RESULT_HEADER_LEN: usize = 45;
|
|
|
|
/// Spans the i32 mxDataType + 6 zero-asserted bytes between the two strings
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:20, 49-57`).
|
|
const RESULT_BETWEEN_STRINGS_LEN: usize = 10;
|
|
|
|
/// 16-byte tail; must be all zero on parse
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:21, 64-67`).
|
|
const RESULT_TAIL_LEN: usize = 16;
|
|
|
|
/// Offset of the i32 LE block-length field (validates body.Length - 41)
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:41-45`).
|
|
const RESULT_BLOCK_LENGTH_OFFSET: usize = 41;
|
|
|
|
/// NMX reference-registration result message (opcode `0x11`).
|
|
///
|
|
/// Mirrors `NmxReferenceRegistrationResultMessage` in the .NET reference. The
|
|
/// .NET type is parse-only (no `Encode` method); the Rust port additionally
|
|
/// supplies an [`encode`](Self::encode) so round-trip tests can exercise
|
|
/// every code path. Encoding always emits the validated regions as all-zero
|
|
/// to match what the server actually produces.
|
|
///
|
|
/// # Layout
|
|
///
|
|
/// ```text
|
|
/// offset size field
|
|
/// 0 1 command u8 = 0x11
|
|
/// 1 2 version u16 LE = 1
|
|
/// 3 4 item_handle i32 LE
|
|
/// 7 16 item_correlation_id GUID
|
|
/// 23 8 first_timestamp_filetime i64 LE (Windows FILETIME ticks)
|
|
/// 31 8 second_timestamp_filetime i64 LE (Windows FILETIME ticks)
|
|
/// 39 1 status_category u8
|
|
/// 40 1 status_detail u8
|
|
/// 41 4 block_length i32 LE = body.len() - 41 (validated)
|
|
/// 45 4 item_definition tagged length (0x81 marker + 24-bit byte length)
|
|
/// 49+ N item_definition UTF-16LE + null terminator
|
|
/// ... 4 mx_data_type i32 LE
|
|
/// ... 6 zero-asserted reserved bytes
|
|
/// ... 4 item_context untagged length (i32 LE byte length)
|
|
/// ... M item_context UTF-16LE + null terminator
|
|
/// ... 16 tail: all zero
|
|
/// ```
|
|
///
|
|
/// `first_timestamp_filetime` / `second_timestamp_filetime` are raw Windows
|
|
/// FILETIME tick counts (100-ns intervals since 1601-01-01 UTC). The .NET
|
|
/// reference converts them via `DateTime.FromFileTimeUtc`
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:72-73`); the Rust codec keeps
|
|
/// them as `i64` ticks and leaves time-zone / `chrono`/`time` conversion to
|
|
/// higher layers.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
|
pub struct NmxReferenceRegistrationResultMessage {
|
|
/// Echo of the request's `item_handle`.
|
|
pub item_handle: i32,
|
|
/// Echo of the request's `item_correlation_id`.
|
|
pub item_correlation_id: Guid16,
|
|
/// Windows FILETIME ticks (100-ns since 1601-01-01 UTC).
|
|
/// `NmxReferenceRegistrationResultMessage.cs:72`.
|
|
pub first_timestamp_filetime: i64,
|
|
/// Windows FILETIME ticks (100-ns since 1601-01-01 UTC).
|
|
/// `NmxReferenceRegistrationResultMessage.cs:73`.
|
|
pub second_timestamp_filetime: i64,
|
|
/// `MxStatus` category byte at offset 39
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:74`).
|
|
pub status_category: u8,
|
|
/// `MxStatus` detail byte at offset 40
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:75`).
|
|
pub status_detail: u8,
|
|
/// Echo of the request's `item_definition`.
|
|
pub item_definition: String,
|
|
/// MxDataType i32 LE that follows `item_definition`
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:49-50, 77`).
|
|
pub mx_data_type: i32,
|
|
/// Echo of the request's `item_context`.
|
|
pub item_context: String,
|
|
}
|
|
|
|
impl NmxReferenceRegistrationResultMessage {
|
|
/// Opcode of the result (`0x11`).
|
|
pub const COMMAND: u8 = RESULT_COMMAND;
|
|
|
|
/// Parse a reference-registration result body.
|
|
///
|
|
/// Mirrors `NmxReferenceRegistrationResultMessage.Parse`
|
|
/// (`NmxReferenceRegistrationResultMessage.cs:23-79`).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - [`CodecError::ShortRead`] if `body.len() < 61` (header + tail).
|
|
/// - [`CodecError::UnexpectedOpcode`] if byte 0 != `0x11`.
|
|
/// - [`CodecError::UnsupportedVersion`] if bytes 1..3 != `1u16`.
|
|
/// - [`CodecError::Decode`] for malformed strings, mismatched
|
|
/// `block_length`, non-zero between-strings reserved, mismatched tail
|
|
/// length, or non-zero tail bytes.
|
|
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
|
|
let min_len = RESULT_HEADER_LEN + RESULT_TAIL_LEN;
|
|
if body.len() < min_len {
|
|
return Err(CodecError::ShortRead {
|
|
expected: min_len,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
|
|
// Offset 0 — command (`NmxReferenceRegistrationResultMessage.cs:30-33`).
|
|
if body[0] != RESULT_COMMAND {
|
|
return Err(CodecError::UnexpectedOpcode(body[0]));
|
|
}
|
|
|
|
// Offset 1 — version (`NmxReferenceRegistrationResultMessage.cs:35-39`).
|
|
let version = read_u16_le(body, 1);
|
|
if version != RESULT_VERSION {
|
|
return Err(CodecError::UnsupportedVersion {
|
|
expected: RESULT_VERSION,
|
|
actual: version,
|
|
});
|
|
}
|
|
|
|
// Offset 41 — block length validates body length
|
|
// (`NmxReferenceRegistrationResultMessage.cs:41-45`).
|
|
let block_length = read_i32_le(body, RESULT_BLOCK_LENGTH_OFFSET);
|
|
let expected_block_length = (body.len() - RESULT_BLOCK_LENGTH_OFFSET) as i32;
|
|
if block_length != expected_block_length {
|
|
return Err(CodecError::Decode {
|
|
offset: RESULT_BLOCK_LENGTH_OFFSET,
|
|
reason: "reference-registration result block length mismatch",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
|
|
// Offset 45 — item definition (tagged string).
|
|
let mut offset = RESULT_HEADER_LEN;
|
|
let item_definition = read_registered_string(body, &mut offset, true)?;
|
|
|
|
// Followed by mxDataType i32 LE
|
|
// (`NmxReferenceRegistrationResultMessage.cs:49-50`).
|
|
if offset + 4 > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: offset + 4,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let mx_data_type = read_i32_le(body, offset);
|
|
offset += 4;
|
|
|
|
// 6 zero-asserted bytes (`BetweenStringsLength - sizeof(int)` =
|
|
// 10 - 4 = 6) — `NmxReferenceRegistrationResultMessage.cs:52-57`.
|
|
let between_zero_len = RESULT_BETWEEN_STRINGS_LEN - 4;
|
|
if offset + between_zero_len > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: offset + between_zero_len,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
if body[offset..offset + between_zero_len]
|
|
.iter()
|
|
.any(|&b| b != 0)
|
|
{
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "non-zero reference-registration result reserved bytes",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
offset += between_zero_len;
|
|
|
|
// Item context — untagged string.
|
|
let item_context = read_registered_string(body, &mut offset, false)?;
|
|
|
|
// Tail (`NmxReferenceRegistrationResultMessage.cs:59-67`).
|
|
let remaining = body.len() - offset;
|
|
if remaining != RESULT_TAIL_LEN {
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "unexpected reference-registration result tail length",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
if body[offset..offset + RESULT_TAIL_LEN]
|
|
.iter()
|
|
.any(|&b| b != 0)
|
|
{
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "non-zero reference-registration result tail bytes",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
|
|
// Header values read at the end to mirror the .NET ordering
|
|
// (`NmxReferenceRegistrationResultMessage.cs:69-78`).
|
|
let item_handle = read_i32_le(body, 3);
|
|
let mut item_correlation_id = [0u8; 16];
|
|
item_correlation_id.copy_from_slice(&body[7..23]);
|
|
let first_timestamp_filetime = read_i64_le(body, 23);
|
|
let second_timestamp_filetime = read_i64_le(body, 31);
|
|
let status_category = body[39];
|
|
let status_detail = body[40];
|
|
|
|
Ok(Self {
|
|
item_handle,
|
|
item_correlation_id,
|
|
first_timestamp_filetime,
|
|
second_timestamp_filetime,
|
|
status_category,
|
|
status_detail,
|
|
item_definition,
|
|
mx_data_type,
|
|
item_context,
|
|
})
|
|
}
|
|
|
|
/// Encode the result body. The .NET reference does not provide an
|
|
/// `Encode` (the result is server-emitted); the Rust port supplies one
|
|
/// for round-trip testing and for synthetic-server use cases. The
|
|
/// validated zero regions (between-strings reserved + 16-byte tail) are
|
|
/// emitted as zero, matching what `Parse` accepts.
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let item_definition_bytes = encode_null_terminated_utf16(&self.item_definition);
|
|
let item_context_bytes = encode_null_terminated_utf16(&self.item_context);
|
|
|
|
let total_len = RESULT_HEADER_LEN
|
|
+ 4 // tagged length word
|
|
+ item_definition_bytes.len()
|
|
+ RESULT_BETWEEN_STRINGS_LEN
|
|
+ 4 // untagged length word
|
|
+ item_context_bytes.len()
|
|
+ RESULT_TAIL_LEN;
|
|
|
|
let mut body = vec![0u8; total_len];
|
|
|
|
body[0] = RESULT_COMMAND;
|
|
write_u16_le(&mut body, 1, RESULT_VERSION);
|
|
write_i32_le(&mut body, 3, self.item_handle);
|
|
body[7..23].copy_from_slice(&self.item_correlation_id);
|
|
write_i64_le(&mut body, 23, self.first_timestamp_filetime);
|
|
write_i64_le(&mut body, 31, self.second_timestamp_filetime);
|
|
body[39] = self.status_category;
|
|
body[40] = self.status_detail;
|
|
|
|
// Block length covers everything from offset 41 onward
|
|
// (`NmxReferenceRegistrationResultMessage.cs:41-44`).
|
|
let block_length = (total_len - RESULT_BLOCK_LENGTH_OFFSET) as i32;
|
|
write_i32_le(&mut body, RESULT_BLOCK_LENGTH_OFFSET, block_length);
|
|
|
|
let mut offset = RESULT_HEADER_LEN;
|
|
write_registered_string(&mut body, &mut offset, &item_definition_bytes, true);
|
|
// mxDataType i32 LE.
|
|
write_i32_le(&mut body, offset, self.mx_data_type);
|
|
offset += 4;
|
|
// 6 zero bytes (already initialised).
|
|
offset += RESULT_BETWEEN_STRINGS_LEN - 4;
|
|
write_registered_string(&mut body, &mut offset, &item_context_bytes, false);
|
|
// 16-byte zero tail (already initialised).
|
|
let _ = offset;
|
|
|
|
body
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared codec helpers — tagged / untagged registered strings
|
|
// ============================================================================
|
|
|
|
/// Read a registered string. `tagged_length=true` requires the high byte of
|
|
/// the i32 length to be `0x81` and masks it off.
|
|
///
|
|
/// Mirrors `ReadRegisteredString` (`NmxReferenceRegistrationMessage.cs:104-127`
|
|
/// and the identical helper in `NmxReferenceRegistrationResultMessage.cs:96-119`).
|
|
fn read_registered_string(
|
|
body: &[u8],
|
|
offset: &mut usize,
|
|
tagged_length: bool,
|
|
) -> Result<String, CodecError> {
|
|
if *offset + 4 > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: *offset + 4,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let raw_length = read_i32_le(body, *offset);
|
|
let byte_length = if tagged_length {
|
|
if (raw_length & TAGGED_STRING_MARKER_MASK) != TAGGED_STRING_MARKER_WORD {
|
|
return Err(CodecError::Decode {
|
|
offset: *offset,
|
|
reason: "missing 0x81 tagged-string marker",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
raw_length & TAGGED_STRING_LENGTH_MASK
|
|
} else {
|
|
raw_length
|
|
};
|
|
|
|
*offset += 4;
|
|
|
|
// `NmxReferenceRegistrationMessage.cs:114-117`: byte length must be a
|
|
// positive even number that fits within the remaining buffer.
|
|
if byte_length < 2 || byte_length % 2 != 0 {
|
|
return Err(CodecError::Decode {
|
|
offset: *offset - 4,
|
|
reason: "invalid registered-string byte length",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
let byte_length = byte_length as usize;
|
|
if *offset + byte_length > body.len() {
|
|
return Err(CodecError::Decode {
|
|
offset: *offset - 4,
|
|
reason: "registered-string byte length exceeds buffer",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
|
|
let payload = &body[*offset..*offset + byte_length];
|
|
// Last two bytes must form the UTF-16LE null terminator
|
|
// (`NmxReferenceRegistrationMessage.cs:120-122`).
|
|
if payload[byte_length - 2] != 0 || payload[byte_length - 1] != 0 {
|
|
return Err(CodecError::Decode {
|
|
offset: *offset,
|
|
reason: "registered string is not null-terminated",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
|
|
let value = decode_utf16_le(&payload[..byte_length - 2]).ok_or(CodecError::Decode {
|
|
offset: *offset,
|
|
reason: "registered string is not valid UTF-16LE",
|
|
buffer_len: body.len(),
|
|
})?;
|
|
*offset += byte_length;
|
|
Ok(value)
|
|
}
|
|
|
|
/// Write a registered string. `tagged_length=true` ORs the high byte `0x81`
|
|
/// into the length word.
|
|
///
|
|
/// Mirrors `WriteRegisteredString` (`NmxReferenceRegistrationMessage.cs:129-136`).
|
|
fn write_registered_string(body: &mut [u8], offset: &mut usize, value: &[u8], tagged_length: bool) {
|
|
let raw_length = if tagged_length {
|
|
// value.Length | 0x81000000
|
|
(value.len() as i32) | TAGGED_STRING_MARKER_WORD
|
|
} else {
|
|
value.len() as i32
|
|
};
|
|
write_i32_le(body, *offset, raw_length);
|
|
*offset += 4;
|
|
body[*offset..*offset + value.len()].copy_from_slice(value);
|
|
*offset += value.len();
|
|
}
|
|
|
|
/// Encode a string as null-terminated UTF-16LE.
|
|
///
|
|
/// Mirrors `EncodeNullTerminatedUtf16` (`NmxReferenceRegistrationMessage.cs:138-141`):
|
|
/// `Encoding.Unicode.GetBytes(value + '\0')`.
|
|
fn encode_null_terminated_utf16(value: &str) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity((value.len() + 1) * 2);
|
|
for ch in value.encode_utf16() {
|
|
out.extend_from_slice(&ch.to_le_bytes());
|
|
}
|
|
out.extend_from_slice(&[0u8, 0u8]);
|
|
out
|
|
}
|
|
|
|
/// Decode a UTF-16LE byte slice. Returns `None` if `bytes.len()` is odd or if
|
|
/// the bytes contain unpaired surrogates. Empty input returns `Some(String::new())`.
|
|
fn decode_utf16_le(bytes: &[u8]) -> Option<String> {
|
|
if bytes.len() % 2 != 0 {
|
|
return None;
|
|
}
|
|
let units: Vec<u16> = bytes
|
|
.chunks_exact(2)
|
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
|
.collect();
|
|
char::decode_utf16(units)
|
|
.collect::<Result<String, _>>()
|
|
.ok()
|
|
}
|
|
|
|
// ============================================================================
|
|
// LE helpers
|
|
// ============================================================================
|
|
|
|
#[inline]
|
|
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
|
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
|
}
|
|
|
|
#[inline]
|
|
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
|
|
i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
|
}
|
|
|
|
#[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],
|
|
])
|
|
}
|
|
|
|
#[inline]
|
|
fn read_i64_le(bytes: &[u8], offset: usize) -> i64 {
|
|
i64::from_le_bytes([
|
|
bytes[offset],
|
|
bytes[offset + 1],
|
|
bytes[offset + 2],
|
|
bytes[offset + 3],
|
|
bytes[offset + 4],
|
|
bytes[offset + 5],
|
|
bytes[offset + 6],
|
|
bytes[offset + 7],
|
|
])
|
|
}
|
|
|
|
#[inline]
|
|
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
|
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
|
|
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
|
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn write_i64_le(bytes: &mut [u8], offset: usize, value: i64) {
|
|
bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_request() -> NmxReferenceRegistrationMessage {
|
|
NmxReferenceRegistrationMessage {
|
|
item_handle: 0x12345678,
|
|
item_correlation_id: [
|
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
|
0x0f, 0x10,
|
|
],
|
|
item_definition: "TestObject.SomeAttr".to_string(),
|
|
item_context: "Galaxy.Platform".to_string(),
|
|
subscribe: true,
|
|
reserved_25_27: [0u8; 2],
|
|
reserved_31_55: [0u8; 24],
|
|
}
|
|
}
|
|
|
|
fn sample_result() -> NmxReferenceRegistrationResultMessage {
|
|
NmxReferenceRegistrationResultMessage {
|
|
item_handle: 0x77665544,
|
|
item_correlation_id: [
|
|
0x10, 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03,
|
|
0x02, 0x01,
|
|
],
|
|
first_timestamp_filetime: 132_500_000_000_000_000,
|
|
second_timestamp_filetime: 132_600_000_000_000_000,
|
|
status_category: 0x40,
|
|
status_detail: 0x02,
|
|
item_definition: "TestObject.Attr".to_string(),
|
|
mx_data_type: 7,
|
|
item_context: "GalaxyA".to_string(),
|
|
}
|
|
}
|
|
|
|
// ---------- Request round-trip tests ----------
|
|
|
|
#[test]
|
|
fn request_round_trip_zero_reserved() {
|
|
let req = sample_request();
|
|
let encoded = req.encode();
|
|
let parsed = NmxReferenceRegistrationMessage::parse(&encoded).unwrap();
|
|
assert_eq!(req, parsed);
|
|
// Verify reserved regions encoded as zero (control for the next test).
|
|
assert_eq!(&encoded[25..27], &[0u8; 2]);
|
|
assert_eq!(&encoded[31..55], &[0u8; 24]);
|
|
}
|
|
|
|
#[test]
|
|
fn request_round_trip_nonzero_reserved_preserved() {
|
|
// Non-zero reserved bytes must survive a parse/encode cycle (CLAUDE.md
|
|
// unknown-bytes preservation rule).
|
|
let req = NmxReferenceRegistrationMessage {
|
|
reserved_25_27: [0xde, 0xad],
|
|
reserved_31_55: [
|
|
0xfe, 0xed, 0xfa, 0xce, 0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, 0x01, 0x23,
|
|
0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xff, 0xee, 0xdd, 0xcc,
|
|
],
|
|
..sample_request()
|
|
};
|
|
let encoded = req.encode();
|
|
// Bytes are present on the wire.
|
|
assert_eq!(&encoded[25..27], &[0xde, 0xad]);
|
|
assert_eq!(
|
|
&encoded[31..55],
|
|
&[
|
|
0xfe, 0xed, 0xfa, 0xce, 0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, 0x01, 0x23,
|
|
0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xff, 0xee, 0xdd, 0xcc,
|
|
]
|
|
);
|
|
|
|
let parsed = NmxReferenceRegistrationMessage::parse(&encoded).unwrap();
|
|
assert_eq!(req, parsed);
|
|
assert_eq!(parsed.reserved_25_27, [0xde, 0xad]);
|
|
assert_eq!(parsed.reserved_31_55[0], 0xfe);
|
|
assert_eq!(parsed.reserved_31_55[23], 0xcc);
|
|
|
|
// And re-encoding once more yields a byte-identical buffer.
|
|
let re_encoded = parsed.encode();
|
|
assert_eq!(encoded, re_encoded);
|
|
}
|
|
|
|
#[test]
|
|
fn request_tagged_string_marker_byte() {
|
|
let req = sample_request();
|
|
let encoded = req.encode();
|
|
// The high byte of the tagged length at offset 55 must be 0x81.
|
|
assert_eq!(encoded[55 + 3], TAGGED_STRING_MARKER_BYTE);
|
|
}
|
|
|
|
#[test]
|
|
fn request_untagged_string_round_trip() {
|
|
// Use a multi-codepoint context including a non-ASCII char to exercise
|
|
// the UTF-16LE encoder.
|
|
let req = NmxReferenceRegistrationMessage {
|
|
item_definition: "Obj.Attr".to_string(),
|
|
item_context: "Δgalaxy".to_string(),
|
|
..sample_request()
|
|
};
|
|
let encoded = req.encode();
|
|
let parsed = NmxReferenceRegistrationMessage::parse(&encoded).unwrap();
|
|
assert_eq!(parsed.item_context, "Δgalaxy");
|
|
assert_eq!(parsed.item_definition, "Obj.Attr");
|
|
}
|
|
|
|
#[test]
|
|
fn request_subscribe_flag_round_trip() {
|
|
for flag in [false, true] {
|
|
let req = NmxReferenceRegistrationMessage {
|
|
subscribe: flag,
|
|
..sample_request()
|
|
};
|
|
let encoded = req.encode();
|
|
// Last byte is the subscribe flag.
|
|
assert_eq!(*encoded.last().unwrap(), u8::from(flag));
|
|
// Bytes in the rest of the tail are zero.
|
|
let tail_start = encoded.len() - REQUEST_TAIL_LEN;
|
|
assert!(
|
|
encoded[tail_start..tail_start + REQUEST_TAIL_LEN - 1]
|
|
.iter()
|
|
.all(|&b| b == 0)
|
|
);
|
|
let parsed = NmxReferenceRegistrationMessage::parse(&encoded).unwrap();
|
|
assert_eq!(parsed.subscribe, flag);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_wrong_opcode() {
|
|
let mut encoded = sample_request().encode();
|
|
encoded[0] = 0x11; // Result opcode, not request.
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::UnexpectedOpcode(0x11)));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_wrong_version() {
|
|
let mut encoded = sample_request().encode();
|
|
write_u16_le(&mut encoded, 1, 2);
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_missing_tagged_marker() {
|
|
// Strip the 0x81 marker from the tagged length word at offset 55.
|
|
let mut encoded = sample_request().encode();
|
|
// Zero out the high byte (offset 55+3).
|
|
encoded[55 + 3] = 0x00;
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_wrong_length_prefix() {
|
|
// Set the tagged byte length to one larger than what the buffer
|
|
// actually carries (preserves the 0x81 marker).
|
|
let mut encoded = sample_request().encode();
|
|
let raw = read_i32_le(&encoded, 55);
|
|
let bumped = ((raw & TAGGED_STRING_LENGTH_MASK) + 0x100) | TAGGED_STRING_MARKER_WORD;
|
|
write_i32_le(&mut encoded, 55, bumped);
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_nonzero_item_string_reserved() {
|
|
// Encode a request, find the item-string reserved 8 bytes, plant a
|
|
// non-zero byte there, and verify Parse rejects.
|
|
let req = sample_request();
|
|
let mut encoded = req.encode();
|
|
// The item-definition tagged string starts at offset 55: 4-byte
|
|
// length, then `item_definition_bytes`. The reserved 8 bytes follow.
|
|
let item_def_bytes = encode_null_terminated_utf16(&req.item_definition);
|
|
let reserved_offset = REQUEST_HEADER_LEN + 4 + item_def_bytes.len();
|
|
encoded[reserved_offset + 3] = 0xab;
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
let reason = match err {
|
|
CodecError::Decode { reason, .. } => reason,
|
|
other => {
|
|
assert!(matches!(other, CodecError::Decode { .. }));
|
|
return;
|
|
}
|
|
};
|
|
assert!(reason.contains("item string reserved"));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_nonzero_tail_filler() {
|
|
// Plant a non-zero byte in the 19-byte zero region of the tail.
|
|
let mut encoded = sample_request().encode();
|
|
let tail_start = encoded.len() - REQUEST_TAIL_LEN;
|
|
encoded[tail_start] = 0x01;
|
|
let err = NmxReferenceRegistrationMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_rejects_short_buffer() {
|
|
let err = NmxReferenceRegistrationMessage::parse(&[0u8; 50]).unwrap_err();
|
|
assert!(matches!(err, CodecError::ShortRead { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_to_buffered_item_definition_appends() {
|
|
let buffered =
|
|
NmxReferenceRegistrationMessage::to_buffered_item_definition("Obj.Attr").unwrap();
|
|
assert_eq!(buffered, "Obj.Attr.property(buffer)");
|
|
}
|
|
|
|
#[test]
|
|
fn request_to_buffered_item_definition_idempotent_case_insensitive() {
|
|
let already = "Obj.Attr.PROPERTY(buffer)";
|
|
let buffered =
|
|
NmxReferenceRegistrationMessage::to_buffered_item_definition(already).unwrap();
|
|
assert_eq!(buffered, already);
|
|
}
|
|
|
|
#[test]
|
|
fn request_to_buffered_item_definition_rejects_blank() {
|
|
assert!(matches!(
|
|
NmxReferenceRegistrationMessage::to_buffered_item_definition(""),
|
|
Err(CodecError::InvalidName)
|
|
));
|
|
assert!(matches!(
|
|
NmxReferenceRegistrationMessage::to_buffered_item_definition(" "),
|
|
Err(CodecError::InvalidName)
|
|
));
|
|
}
|
|
|
|
// ---------- Result round-trip tests ----------
|
|
|
|
#[test]
|
|
fn result_round_trip() {
|
|
let res = sample_result();
|
|
let encoded = res.encode();
|
|
let parsed = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap();
|
|
assert_eq!(res, parsed);
|
|
}
|
|
|
|
#[test]
|
|
fn result_block_length_matches_body_len_minus_41() {
|
|
let res = sample_result();
|
|
let encoded = res.encode();
|
|
let block_length = read_i32_le(&encoded, RESULT_BLOCK_LENGTH_OFFSET);
|
|
assert_eq!(block_length as usize, encoded.len() - 41);
|
|
}
|
|
|
|
#[test]
|
|
fn result_tagged_string_marker_byte() {
|
|
let res = sample_result();
|
|
let encoded = res.encode();
|
|
// The tagged length word is at offset 45; high byte should be 0x81.
|
|
assert_eq!(encoded[45 + 3], TAGGED_STRING_MARKER_BYTE);
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_wrong_opcode() {
|
|
let mut encoded = sample_result().encode();
|
|
encoded[0] = 0x10;
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::UnexpectedOpcode(0x10)));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_wrong_version() {
|
|
let mut encoded = sample_result().encode();
|
|
write_u16_le(&mut encoded, 1, 9);
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_wrong_block_length() {
|
|
let mut encoded = sample_result().encode();
|
|
// Corrupt the block length to claim a different value.
|
|
let actual = read_i32_le(&encoded, RESULT_BLOCK_LENGTH_OFFSET);
|
|
write_i32_le(&mut encoded, RESULT_BLOCK_LENGTH_OFFSET, actual + 4);
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
let reason = match err {
|
|
CodecError::Decode { reason, .. } => reason,
|
|
other => {
|
|
assert!(matches!(other, CodecError::Decode { .. }));
|
|
return;
|
|
}
|
|
};
|
|
assert!(reason.contains("block length"));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_nonzero_between_strings() {
|
|
// Plant a non-zero byte in the 6-byte zero region between mxDataType
|
|
// and item_context.
|
|
let res = sample_result();
|
|
let mut encoded = res.encode();
|
|
let item_def_bytes = encode_null_terminated_utf16(&res.item_definition);
|
|
// After tagged length (4) + item def bytes + mxDataType (4), we are
|
|
// at the 6-byte zero region.
|
|
let between_offset = RESULT_HEADER_LEN + 4 + item_def_bytes.len() + 4;
|
|
encoded[between_offset + 2] = 0x77;
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_nonzero_tail() {
|
|
let mut encoded = sample_result().encode();
|
|
let tail_start = encoded.len() - RESULT_TAIL_LEN;
|
|
encoded[tail_start + 5] = 0x99;
|
|
// Recompute block length so the prior validation step still passes.
|
|
let new_block_length = (encoded.len() - RESULT_BLOCK_LENGTH_OFFSET) as i32;
|
|
write_i32_le(&mut encoded, RESULT_BLOCK_LENGTH_OFFSET, new_block_length);
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_short_buffer() {
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&[0u8; 60]).unwrap_err();
|
|
assert!(matches!(err, CodecError::ShortRead { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn result_rejects_wrong_length_prefix() {
|
|
// Bump the tagged length on the item_definition string but keep the
|
|
// 0x81 marker. Block length must stay valid for this test to bypass
|
|
// the earlier check, then the inner string-length validation should
|
|
// fire.
|
|
let mut encoded = sample_result().encode();
|
|
let raw = read_i32_le(&encoded, RESULT_HEADER_LEN);
|
|
let bumped = ((raw & TAGGED_STRING_LENGTH_MASK) + 0x100) | TAGGED_STRING_MARKER_WORD;
|
|
write_i32_le(&mut encoded, RESULT_HEADER_LEN, bumped);
|
|
let err = NmxReferenceRegistrationResultMessage::parse(&encoded).unwrap_err();
|
|
assert!(matches!(err, CodecError::Decode { .. }));
|
|
}
|
|
|
|
// ---------- Cross-cutting checks ----------
|
|
|
|
#[test]
|
|
fn request_total_length_matches_dotnet_formula() {
|
|
// Per `NmxReferenceRegistrationMessage.cs:71-78`, total length =
|
|
// 55 + 4 + item_def_bytes + 8 + 4 + item_ctx_bytes + 20.
|
|
let req = sample_request();
|
|
let item_def_bytes = encode_null_terminated_utf16(&req.item_definition);
|
|
let item_ctx_bytes = encode_null_terminated_utf16(&req.item_context);
|
|
let expected = REQUEST_HEADER_LEN
|
|
+ 4
|
|
+ item_def_bytes.len()
|
|
+ ITEM_STRING_RESERVED_LEN
|
|
+ 4
|
|
+ item_ctx_bytes.len()
|
|
+ REQUEST_TAIL_LEN;
|
|
assert_eq!(req.encode().len(), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn request_byte_offsets_match_dotnet() {
|
|
// Verify the constant writes at offsets 0, 1, 3, 7, 23, 27 match what
|
|
// the .NET reference emits (`NmxReferenceRegistrationMessage.cs:80-87`).
|
|
let req = sample_request();
|
|
let encoded = req.encode();
|
|
assert_eq!(encoded[0], REQUEST_COMMAND);
|
|
assert_eq!(read_u16_le(&encoded, 1), REQUEST_VERSION);
|
|
assert_eq!(read_i32_le(&encoded, 3), req.item_handle);
|
|
assert_eq!(&encoded[7..23], &req.item_correlation_id);
|
|
assert_eq!(read_i16_le(&encoded, 23), -1);
|
|
assert_eq!(read_i32_le(&encoded, 27), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn result_byte_offsets_match_dotnet() {
|
|
// Verify that the result offsets match what the .NET Parse reads
|
|
// (`NmxReferenceRegistrationResultMessage.cs:69-78`).
|
|
let res = sample_result();
|
|
let encoded = res.encode();
|
|
assert_eq!(encoded[0], RESULT_COMMAND);
|
|
assert_eq!(read_u16_le(&encoded, 1), RESULT_VERSION);
|
|
assert_eq!(read_i32_le(&encoded, 3), res.item_handle);
|
|
assert_eq!(&encoded[7..23], &res.item_correlation_id);
|
|
assert_eq!(read_i64_le(&encoded, 23), res.first_timestamp_filetime);
|
|
assert_eq!(read_i64_le(&encoded, 31), res.second_timestamp_filetime);
|
|
assert_eq!(encoded[39], res.status_category);
|
|
assert_eq!(encoded[40], res.status_detail);
|
|
}
|
|
}
|