//! 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { if bytes.len() % 2 != 0 { return None; } let units: Vec = bytes .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); char::decode_utf16(units) .collect::>() .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); } }