Files
mxaccess/rust/crates/mxaccess-codec/src/reference_registration.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

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);
}
}