Initial project state: .NET reference, design, Rust port (M0+M1), evidence
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>
This commit is contained in:
Joseph Doherty
2026-05-05 06:21:00 -04:00
parent 43733699b0
commit fe2a6db786
3849 changed files with 352975 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
//! `NmxTransferEnvelope` — 46-byte NMX wire envelope.
//!
//! Direct port of `src/MxNativeCodec/NmxTransferEnvelope.cs`. The Rust port
//! adds `reserved6_10: [u8; 4]` preservation per CLAUDE.md unknown-bytes rule
//! — the .NET reference reads only Version/InnerLength/ProtocolMarker/MessageKind
//! and discards bytes 6..10 (`NmxTransferEnvelope.cs:39-75`); the Rust codec
//! round-trips them.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
/// Encoded layout per `NmxTransferEnvelope.cs:23-37`:
///
/// ```text
/// offset size field
/// 0 2 version u16 LE = 1
/// 2 4 inner_length i32 LE = body.len() - 46
/// 6 4 reserved6_10 [u8; 4] preserved verbatim by Rust port
/// 10 4 message_kind i32 LE 1=Metadata, 2=ItemControl, 3=Write
/// 14 4 source_galaxy_id i32 LE
/// 18 4 source_platform_id i32 LE
/// 22 4 local_engine_id i32 LE
/// 26 4 target_galaxy_id i32 LE
/// 30 4 target_platform_id i32 LE
/// 34 4 target_engine_id i32 LE
/// 38 4 protocol_marker i32 LE = 0x0201 (bytes: 01 02 00 00)
/// 42 4 timeout_ms i32 LE default 30000
/// 46+ body...
/// ```
pub const ENVELOPE_HEADER_LEN: usize = 46;
const VERSION: u16 = 1;
const PROTOCOL_MARKER: i32 = 0x0201;
const DEFAULT_TIMEOUT_MS: i32 = 30000;
const INNER_LENGTH_OFFSET: usize = 2;
const RESERVED_OFFSET: usize = 6;
const MESSAGE_KIND_OFFSET: usize = 10;
const SOURCE_GALAXY_OFFSET: usize = 14;
const SOURCE_PLATFORM_OFFSET: usize = 18;
const LOCAL_ENGINE_OFFSET: usize = 22;
const TARGET_GALAXY_OFFSET: usize = 26;
const TARGET_PLATFORM_OFFSET: usize = 30;
const TARGET_ENGINE_OFFSET: usize = 34;
const PROTOCOL_MARKER_OFFSET: usize = 38;
const TIMEOUT_OFFSET: usize = 42;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[repr(u8)]
pub enum NmxTransferMessageKind {
#[default]
Unknown = 0,
Metadata = 1,
ItemControl = 2,
Write = 3,
}
impl NmxTransferMessageKind {
fn from_i32(value: i32) -> Self {
match value {
1 => Self::Metadata,
2 => Self::ItemControl,
3 => Self::Write,
_ => Self::Unknown,
}
}
fn to_i32(self) -> i32 {
match self {
Self::Unknown => 0,
Self::Metadata => 1,
Self::ItemControl => 2,
Self::Write => 3,
}
}
}
/// 46-byte envelope. `reserved6_10` is preserved verbatim — the .NET reference
/// discards these bytes on parse and writes 0 on encode. The Rust port carries
/// them through so captured envelopes with non-zero values at offset 6..10
/// round-trip byte-identical.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NmxTransferEnvelope {
pub message_kind: NmxTransferMessageKind,
pub source_galaxy_id: i32,
pub source_platform_id: i32,
pub local_engine_id: i32,
pub target_galaxy_id: i32,
pub target_platform_id: i32,
pub target_engine_id: i32,
pub timeout_ms: i32,
/// Bytes 6..10 of the envelope. The .NET reference does not retain these;
/// the Rust port preserves them per CLAUDE.md unknown-bytes rule.
/// Defaults to `[0; 4]` for newly-constructed envelopes.
pub reserved6_10: [u8; 4],
}
impl Default for NmxTransferEnvelope {
fn default() -> Self {
Self {
message_kind: NmxTransferMessageKind::default(),
source_galaxy_id: 1,
source_platform_id: 1,
local_engine_id: 0,
target_galaxy_id: 0,
target_platform_id: 0,
target_engine_id: 0,
timeout_ms: DEFAULT_TIMEOUT_MS,
reserved6_10: [0; 4],
}
}
}
impl NmxTransferEnvelope {
/// Header length in bytes.
pub const HEADER_LEN: usize = ENVELOPE_HEADER_LEN;
/// Parse a transfer body — the 46-byte header followed by the inner body.
/// Returns the parsed envelope and the inner body length (the inner bytes
/// themselves are accessed by the caller via `&transfer_body[46..]`).
///
/// Mirrors `NmxTransferEnvelope.Parse` (`NmxTransferEnvelope.cs:39-75`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `transfer_body.len() < 46`.
/// - [`CodecError::UnsupportedVersion`] if version != 1.
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
/// does not match `transfer_body.len() - 46`.
/// - [`CodecError::UnsupportedProtocolMarker`] if the marker != 0x0201.
pub fn parse(transfer_body: &[u8]) -> Result<Self, CodecError> {
if transfer_body.len() < Self::HEADER_LEN {
return Err(CodecError::ShortRead {
expected: Self::HEADER_LEN,
actual: transfer_body.len(),
});
}
let version = read_u16_le(transfer_body, 0);
if version != VERSION {
return Err(CodecError::UnsupportedVersion {
expected: VERSION,
actual: version,
});
}
let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET);
let actual_inner = transfer_body.len() - Self::HEADER_LEN;
if inner_length != actual_inner as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: inner_length,
actual: actual_inner,
});
}
let protocol_marker = read_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET);
if protocol_marker != PROTOCOL_MARKER {
return Err(CodecError::UnsupportedProtocolMarker(protocol_marker));
}
let mut reserved6_10 = [0u8; 4];
reserved6_10.copy_from_slice(&transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4]);
Ok(Self {
message_kind: NmxTransferMessageKind::from_i32(read_i32_le(
transfer_body,
MESSAGE_KIND_OFFSET,
)),
source_galaxy_id: read_i32_le(transfer_body, SOURCE_GALAXY_OFFSET),
source_platform_id: read_i32_le(transfer_body, SOURCE_PLATFORM_OFFSET),
local_engine_id: read_i32_le(transfer_body, LOCAL_ENGINE_OFFSET),
target_galaxy_id: read_i32_le(transfer_body, TARGET_GALAXY_OFFSET),
target_platform_id: read_i32_le(transfer_body, TARGET_PLATFORM_OFFSET),
target_engine_id: read_i32_le(transfer_body, TARGET_ENGINE_OFFSET),
timeout_ms: read_i32_le(transfer_body, TIMEOUT_OFFSET),
reserved6_10,
})
}
/// Encode the envelope header into the front of `transfer_body`. The
/// caller is responsible for providing a buffer of length
/// `46 + inner_body.len()` and copying the inner body into the tail
/// before transmission.
///
/// Mirrors `NmxTransferEnvelope.Encode` (`NmxTransferEnvelope.cs:77-103`)
/// but additionally writes `reserved6_10` (the .NET version always writes 0).
///
/// # Errors
///
/// Returns [`CodecError::InnerLengthMismatch`] if `transfer_body.len() < 46`.
pub fn write_to(self, transfer_body: &mut [u8]) -> Result<(), CodecError> {
if transfer_body.len() < Self::HEADER_LEN {
return Err(CodecError::InnerLengthMismatch {
declared: 0,
actual: transfer_body.len(),
});
}
let inner_len = transfer_body.len() - Self::HEADER_LEN;
write_u16_le(transfer_body, 0, VERSION);
write_i32_le(transfer_body, INNER_LENGTH_OFFSET, inner_len as i32);
transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4].copy_from_slice(&self.reserved6_10);
write_i32_le(
transfer_body,
MESSAGE_KIND_OFFSET,
self.message_kind.to_i32(),
);
write_i32_le(transfer_body, SOURCE_GALAXY_OFFSET, self.source_galaxy_id);
write_i32_le(
transfer_body,
SOURCE_PLATFORM_OFFSET,
self.source_platform_id,
);
write_i32_le(transfer_body, LOCAL_ENGINE_OFFSET, self.local_engine_id);
write_i32_le(transfer_body, TARGET_GALAXY_OFFSET, self.target_galaxy_id);
write_i32_le(
transfer_body,
TARGET_PLATFORM_OFFSET,
self.target_platform_id,
);
write_i32_le(transfer_body, TARGET_ENGINE_OFFSET, self.target_engine_id);
write_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET, PROTOCOL_MARKER);
write_i32_le(transfer_body, TIMEOUT_OFFSET, self.timeout_ms);
Ok(())
}
/// Convenience encoder that allocates a buffer of `46 + inner_body.len()`,
/// writes the header, and copies `inner_body` into the tail. Mirrors the
/// shape of `NmxTransferEnvelope.Encode`.
pub fn encode_with_inner(self, inner_body: &[u8]) -> Vec<u8> {
let mut out = vec![0u8; Self::HEADER_LEN + inner_body.len()];
// write_to validates and never errors when the buffer is large enough,
// so this branch is unreachable in practice. We propagate the bug as
// an empty buffer rather than panicking.
if self.write_to(&mut out).is_err() {
return Vec::new();
}
out[Self::HEADER_LEN..].copy_from_slice(inner_body);
out
}
}
#[inline]
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::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 write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
let le = value.to_le_bytes();
bytes[offset..offset + 2].copy_from_slice(&le);
}
#[inline]
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
let le = value.to_le_bytes();
bytes[offset..offset + 4].copy_from_slice(&le);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn sample_envelope() -> NmxTransferEnvelope {
NmxTransferEnvelope {
message_kind: NmxTransferMessageKind::Write,
source_galaxy_id: 1,
source_platform_id: 1,
local_engine_id: 5,
target_galaxy_id: 1,
target_platform_id: 2,
target_engine_id: 17,
timeout_ms: 30000,
reserved6_10: [0; 4],
}
}
#[test]
fn round_trip_default_envelope() {
let env = sample_envelope();
let inner = [0xab, 0xcd, 0xef];
let encoded = env.encode_with_inner(&inner);
assert_eq!(encoded.len(), 46 + 3);
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
assert_eq!(env, parsed);
assert_eq!(&encoded[46..], &inner);
}
#[test]
fn protocol_marker_bytes_are_le() {
let env = sample_envelope();
let encoded = env.encode_with_inner(&[]);
// 0x0201 LE = 01 02 00 00
assert_eq!(encoded[38], 0x01);
assert_eq!(encoded[39], 0x02);
assert_eq!(encoded[40], 0x00);
assert_eq!(encoded[41], 0x00);
}
#[test]
fn version_bytes_are_le_one() {
let env = sample_envelope();
let encoded = env.encode_with_inner(&[]);
assert_eq!(encoded[0], 0x01);
assert_eq!(encoded[1], 0x00);
}
#[test]
fn reserved_bytes_round_trip() {
// Construct an envelope with non-zero reserved bytes (as if parsed
// from a captured frame). Encode and re-parse — they must survive.
let env = NmxTransferEnvelope {
reserved6_10: [0xde, 0xad, 0xbe, 0xef],
..sample_envelope()
};
let encoded = env.encode_with_inner(&[]);
assert_eq!(&encoded[6..10], &[0xde, 0xad, 0xbe, 0xef]);
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
assert_eq!(parsed.reserved6_10, [0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn parse_rejects_wrong_version() {
let mut encoded = sample_envelope().encode_with_inner(&[]);
encoded[0] = 0x02; // version = 2
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
}
#[test]
fn parse_rejects_inner_length_mismatch() {
let mut encoded = sample_envelope().encode_with_inner(&[0; 4]);
// Corrupt the inner_length field to claim 100 inner bytes when
// there are only 4.
write_i32_le(&mut encoded, INNER_LENGTH_OFFSET, 100);
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn parse_rejects_wrong_protocol_marker() {
let mut encoded = sample_envelope().encode_with_inner(&[]);
write_i32_le(&mut encoded, PROTOCOL_MARKER_OFFSET, 0x0102);
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
assert!(matches!(err, CodecError::UnsupportedProtocolMarker(0x0102)));
}
#[test]
fn parse_rejects_short_buffer() {
let err = NmxTransferEnvelope::parse(&[0u8; 45]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn message_kind_round_trips() {
for kind in [
NmxTransferMessageKind::Metadata,
NmxTransferMessageKind::ItemControl,
NmxTransferMessageKind::Write,
] {
let env = NmxTransferEnvelope {
message_kind: kind,
..sample_envelope()
};
let encoded = env.encode_with_inner(&[]);
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
assert_eq!(parsed.message_kind, kind);
}
}
#[test]
fn header_length_constant() {
assert_eq!(NmxTransferEnvelope::HEADER_LEN, 46);
assert_eq!(ENVELOPE_HEADER_LEN, 46);
}
}
@@ -0,0 +1,408 @@
//! `NmxTransferEnvelopeTemplate` — buffer-preserving alternative to
//! [`crate::NmxTransferEnvelope`].
//!
//! Direct port of `src/MxNativeCodec/NmxTransferEnvelopeTemplate.cs`.
//!
//! Where [`crate::NmxTransferEnvelope`] decodes the 46-byte header into typed
//! fields and re-encodes them, the template path **takes a captured 46-byte
//! header verbatim and only patches the field(s) the caller asks to patch**.
//! Every other byte in the header — including any reserved/unknown bytes the
//! typed codec ignores — is preserved bit-for-bit.
//!
//! This is the path used for high-fidelity replay against the AVEVA stack
//! when the captured envelope contains bytes whose meaning is unproven and
//! the round-trip must remain byte-identical to the capture.
//!
//! # Differences from the .NET reference
//!
//! - Setters return a new value (`with_inner_length`, `with_message_kind`)
//! rather than mutating in place. The underlying header buffer is owned
//! by the template, so `with_*` methods clone the buffer before patching.
//! The .NET reference does not expose setters — it re-encodes only the
//! `inner_length` field on every `Encode` call. The Rust port adds
//! targeted setters for forward use cases without breaking the
//! "patch only what the caller patches" contract.
//! - `decode_inner` returns a borrow (`&[u8]`) rather than a `ReadOnlyMemory<byte>`.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::NmxTransferMessageKind;
use crate::error::CodecError;
/// Header length in bytes (`NmxTransferEnvelopeTemplate.cs:7`).
pub const HEADER_LENGTH: usize = 46;
/// Offset of the `inner_length` i32 LE field
/// (`NmxTransferEnvelopeTemplate.cs:8`).
pub const INNER_LENGTH_OFFSET: usize = 2;
/// Offset of the `message_kind` i32 LE field. Mirrors the constant of the
/// same name in [`crate::envelope`].
const MESSAGE_KIND_OFFSET: usize = 10;
/// Round-trip preserver for an observed 46-byte transfer envelope.
///
/// Internally stores the captured 46-byte header verbatim. Setters
/// (`with_inner_length`, `with_message_kind`) return a clone with only the
/// targeted bytes patched. [`Self::encode`] writes the header followed by the
/// supplied inner body, patching `inner_length` to match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxTransferEnvelopeTemplate {
/// The captured 46-byte header, byte-for-byte (`NmxTransferEnvelopeTemplate.cs:10`).
header: [u8; HEADER_LENGTH],
}
impl NmxTransferEnvelopeTemplate {
/// Header length in bytes (matches `NmxTransferEnvelopeTemplate.HeaderLength`).
pub const HEADER_LEN: usize = HEADER_LENGTH;
/// Construct a template from an observed `TransferData` body.
///
/// The body must be at least 46 bytes long, and the `inner_length` field
/// at offset 2 must declare exactly `body.len() - 46` bytes. Mirrors
/// `FromObserved` (`NmxTransferEnvelopeTemplate.cs:17-31`).
///
/// Only the leading 46 bytes are retained — the inner body is dropped.
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `observed_transfer_body.len() < 46`
/// (`NmxTransferEnvelopeTemplate.cs:19-22`).
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
/// field does not match the actual inner length
/// (`NmxTransferEnvelopeTemplate.cs:24-28`).
pub fn from_observed(observed_transfer_body: &[u8]) -> Result<Self, CodecError> {
if observed_transfer_body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: observed_transfer_body.len(),
});
}
let inner_length = read_i32_le(observed_transfer_body, INNER_LENGTH_OFFSET);
let actual_inner = observed_transfer_body.len() - HEADER_LENGTH;
if inner_length != actual_inner as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: inner_length,
actual: actual_inner,
});
}
let mut header = [0u8; HEADER_LENGTH];
header.copy_from_slice(&observed_transfer_body[..HEADER_LENGTH]);
Ok(Self { header })
}
/// Borrow the captured 46-byte header. Useful for round-trip identity
/// asserts and for callers that need to inspect reserved/unknown bytes
/// without going through the typed [`crate::NmxTransferEnvelope`] codec.
pub fn header(&self) -> &[u8; HEADER_LENGTH] {
&self.header
}
/// Return a new template with `inner_length` (offset 2, i32 LE) patched
/// to `inner_length`. Every other byte is preserved.
///
/// Note: [`Self::encode`] also patches `inner_length` to match the supplied
/// inner body. This setter exists for callers that need to manipulate the
/// template separately from encoding.
#[must_use]
pub fn with_inner_length(mut self, inner_length: i32) -> Self {
write_i32_le(&mut self.header, INNER_LENGTH_OFFSET, inner_length);
self
}
/// Return a new template with `message_kind` (offset 10, i32 LE) patched
/// to `kind`. Every other byte is preserved.
///
/// `NmxTransferMessageKind::Unknown` encodes as 0 — same as the typed
/// codec ([`crate::envelope`]).
#[must_use]
pub fn with_message_kind(mut self, kind: NmxTransferMessageKind) -> Self {
let value: i32 = match kind {
NmxTransferMessageKind::Unknown => 0,
NmxTransferMessageKind::Metadata => 1,
NmxTransferMessageKind::ItemControl => 2,
NmxTransferMessageKind::Write => 3,
};
write_i32_le(&mut self.header, MESSAGE_KIND_OFFSET, value);
self
}
/// Encode the captured header followed by `inner_put_request_body`,
/// patching the `inner_length` field at offset 2 to match the supplied
/// inner body length.
///
/// Mirrors `Encode` (`NmxTransferEnvelopeTemplate.cs:33-40`). Allocates a
/// fresh `Vec<u8>` of length `46 + inner_put_request_body.len()`.
pub fn encode(&self, inner_put_request_body: &[u8]) -> Vec<u8> {
let inner_len = inner_put_request_body.len();
let mut body = vec![0u8; HEADER_LENGTH + inner_len];
body[..HEADER_LENGTH].copy_from_slice(&self.header);
// Patch the inner_length field — `NmxTransferEnvelopeTemplate.cs:37`.
// `inner_len as i32` matches the .NET `int` cast.
write_i32_le(&mut body, INNER_LENGTH_OFFSET, inner_len as i32);
body[HEADER_LENGTH..].copy_from_slice(inner_put_request_body);
body
}
/// Strip the 46-byte header off `transfer_body` and return a borrow of
/// the inner bytes.
///
/// Mirrors `DecodeInner` (`NmxTransferEnvelopeTemplate.cs:42-56`). Validates
/// that the declared `inner_length` matches the actual inner body length.
/// Does **not** verify that the captured 46-byte prefix matches the
/// template's stored header — by design, the template path is a
/// permissive round-trip; if the caller wants strict validation they
/// should use [`crate::NmxTransferEnvelope::parse`].
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `transfer_body.len() < 46`
/// (`NmxTransferEnvelopeTemplate.cs:44-47`).
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
/// does not match the actual inner length
/// (`NmxTransferEnvelopeTemplate.cs:49-53`).
pub fn decode_inner<'a>(&self, transfer_body: &'a [u8]) -> Result<&'a [u8], CodecError> {
if transfer_body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: transfer_body.len(),
});
}
let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET);
let actual_inner = transfer_body.len() - HEADER_LENGTH;
if inner_length != actual_inner as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: inner_length,
actual: actual_inner,
});
}
Ok(&transfer_body[HEADER_LENGTH..])
}
}
#[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 write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
let le = value.to_le_bytes();
bytes[offset..offset + 4].copy_from_slice(&le);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
/// Build a synthetic 46-byte header with each byte tagged so we can
/// verify which bytes the round-trip preserves vs. patches.
/// Offsets 2..6 are written explicitly so `inner_length` validates;
/// every other byte is `0xA0 + offset & 0xff` so reserved-byte
/// preservation is observable.
fn synthetic_header_with_inner_len(inner_len: i32) -> [u8; HEADER_LENGTH] {
let mut header = [0u8; HEADER_LENGTH];
for (i, b) in header.iter_mut().enumerate() {
*b = 0xA0u8.wrapping_add(i as u8);
}
write_i32_le(&mut header, INNER_LENGTH_OFFSET, inner_len);
header
}
#[test]
fn header_length_constant() {
assert_eq!(HEADER_LENGTH, 46);
assert_eq!(NmxTransferEnvelopeTemplate::HEADER_LEN, 46);
}
#[test]
fn round_trip_zero_inner() {
// No inner body — header preserved exactly.
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let encoded = template.encode(&[]);
assert_eq!(encoded.len(), HEADER_LENGTH);
assert_eq!(&encoded[..], &header[..]);
}
#[test]
fn from_observed_rejects_short_buffer() {
let err = NmxTransferEnvelopeTemplate::from_observed(&[0u8; 45]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn from_observed_rejects_inner_length_mismatch() {
let mut buf = [0u8; HEADER_LENGTH + 8];
// Claim 100 inner bytes when only 8 follow.
write_i32_le(&mut buf, INNER_LENGTH_OFFSET, 100);
let err = NmxTransferEnvelopeTemplate::from_observed(&buf).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn encode_patches_inner_length() {
// Template has inner_length = 0 baked in. Encoding with an 8-byte
// inner body should patch inner_length to 8.
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let inner = [0xDEu8, 0xAD, 0xBE, 0xEF, 0x12, 0x34, 0x56, 0x78];
let encoded = template.encode(&inner);
assert_eq!(encoded.len(), HEADER_LENGTH + 8);
assert_eq!(read_i32_le(&encoded, INNER_LENGTH_OFFSET), 8);
// Inner body must follow.
assert_eq!(&encoded[HEADER_LENGTH..], &inner);
}
#[test]
fn encode_preserves_every_byte_outside_inner_length_field() {
// Build a template from a header packed with non-trivial bytes.
// After encoding with arbitrary inner data, every header byte
// outside offset 2..6 must match the original header.
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let inner = vec![0u8; 32];
let encoded = template.encode(&inner);
for i in 0..HEADER_LENGTH {
if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) {
continue;
}
assert_eq!(
encoded[i], header[i],
"byte at offset {i} must be preserved verbatim"
);
}
}
#[test]
fn with_inner_length_patches_only_inner_length_field() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let patched = template.with_inner_length(0x12345678);
let patched_header = patched.header();
// Inner length field reflects the patch.
assert_eq!(read_i32_le(patched_header, INNER_LENGTH_OFFSET), 0x12345678);
// Every other byte unchanged.
for i in 0..HEADER_LENGTH {
if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) {
continue;
}
assert_eq!(patched_header[i], header[i]);
}
}
#[test]
fn with_message_kind_patches_only_message_kind_field() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let patched = template.with_message_kind(NmxTransferMessageKind::Write);
let patched_header = patched.header();
// Message-kind field reflects the patch (Write = 3).
assert_eq!(read_i32_le(patched_header, MESSAGE_KIND_OFFSET), 3);
// Every other byte unchanged.
for i in 0..HEADER_LENGTH {
if (MESSAGE_KIND_OFFSET..MESSAGE_KIND_OFFSET + 4).contains(&i) {
continue;
}
assert_eq!(patched_header[i], header[i]);
}
}
#[test]
fn with_message_kind_round_trips_all_variants() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
for (kind, expected) in [
(NmxTransferMessageKind::Unknown, 0),
(NmxTransferMessageKind::Metadata, 1),
(NmxTransferMessageKind::ItemControl, 2),
(NmxTransferMessageKind::Write, 3),
] {
let patched = template.clone().with_message_kind(kind);
assert_eq!(
read_i32_le(patched.header(), MESSAGE_KIND_OFFSET),
expected,
"kind {kind:?} must encode as {expected}"
);
}
}
#[test]
fn decode_inner_returns_inner_body() {
// Build the full 50-byte buffer first; `from_observed` validates that
// the buffer length matches the declared inner_length, so we must
// pass header+inner together.
let header = synthetic_header_with_inner_len(4);
let mut full = vec![0u8; HEADER_LENGTH + 4];
full[..HEADER_LENGTH].copy_from_slice(&header);
full[HEADER_LENGTH..].copy_from_slice(&[0x11, 0x22, 0x33, 0x44]);
let template = NmxTransferEnvelopeTemplate::from_observed(&full).unwrap();
let inner = template.decode_inner(&full).unwrap();
assert_eq!(inner, &[0x11, 0x22, 0x33, 0x44]);
}
#[test]
fn decode_inner_rejects_short_buffer() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
let err = template.decode_inner(&[0u8; 45]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn decode_inner_rejects_inner_length_mismatch() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
// Build a 46+8 byte body but with inner_length declared as 0.
let mut full = vec![0u8; HEADER_LENGTH + 8];
full[..HEADER_LENGTH].copy_from_slice(&header);
// header has inner_length = 0; actual inner is 8 → mismatch.
let err = template.decode_inner(&full).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn header_accessor_returns_captured_bytes() {
let header = synthetic_header_with_inner_len(0);
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
assert_eq!(template.header(), &header);
}
#[test]
fn captured_observed_body_is_preserved_byte_for_byte() {
// Simulates a captured envelope where bytes outside the four typed
// fields carry non-zero "reserved" data. The template path must
// round-trip every byte. The typed `NmxTransferEnvelope` codec
// would normally strip / synthesise these bytes; the template
// sidesteps that.
let mut captured = [0u8; HEADER_LENGTH + 12];
// Pack the header with a recognisable pattern.
for (i, b) in captured[..HEADER_LENGTH].iter_mut().enumerate() {
*b = 0xA5u8.wrapping_add(i as u8);
}
// Set inner_length = 12 so from_observed accepts it.
write_i32_le(&mut captured, INNER_LENGTH_OFFSET, 12);
// Inner body bytes.
for (i, b) in captured[HEADER_LENGTH..].iter_mut().enumerate() {
*b = 0xC0u8.wrapping_add(i as u8);
}
let template = NmxTransferEnvelopeTemplate::from_observed(&captured).unwrap();
let encoded = template.encode(&captured[HEADER_LENGTH..]);
assert_eq!(
encoded, captured,
"round-trip via template must be byte-identical"
);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Codec-level errors. Used by [`MxReferenceHandle`](crate::MxReferenceHandle),
//! [`NmxTransferEnvelope`](crate::NmxTransferEnvelope), and the M1+ message
//! codecs.
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CodecError {
/// A buffer was shorter than required to decode the type.
#[error("short read: expected {expected} bytes, got {actual}")]
ShortRead { expected: usize, actual: usize },
/// `name.trim().is_empty()`. Mirrors `ArgumentException.ThrowIfNullOrWhiteSpace`
/// in `MxReferenceHandle.cs:49`.
#[error("name must not be empty or whitespace-only")]
InvalidName,
/// The `inner_length` field declared in an envelope did not match the
/// actual body size.
#[error("inner length {declared} does not match body size {actual}")]
InnerLengthMismatch { declared: i32, actual: usize },
#[error("unsupported version {actual} (expected {expected})")]
UnsupportedVersion { expected: u16, actual: u16 },
#[error("unsupported protocol marker {0:#010x}")]
UnsupportedProtocolMarker(i32),
#[error("unexpected opcode {0:#04x}")]
UnexpectedOpcode(u8),
/// Decoder failure with a position and human-readable reason.
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
Decode {
offset: usize,
reason: &'static str,
buffer_len: usize,
},
}
@@ -0,0 +1,550 @@
//! `NmxItemControlMessage` — NMX item-control body (advise / unadvise).
//!
//! Direct port of `src/MxNativeCodec/NmxItemControlMessage.cs`. The body
//! carries an advise-supervisory or unadvise command together with a 16-byte
//! item correlation GUID and a 14-byte projection of an [`MxReferenceHandle`]
//! (handle bytes 6..20 — `object_id` through `attribute_index`).
//!
//! ## Wire layout
//!
//! Per `NmxItemControlMessage.cs:24-36, 63-81, 121-142`:
//!
//! ```text
//! offset size field notes
//! 0 1 command (u8) 0x1f AdviseSupervisory, 0x21 UnAdvise
//! 1 2 version (u16 LE) must be 1
//! 3 16 item_correlation_id (GUID) .NET layout (mixed-endian)
//! 19 2 advise extra (u16 LE) ONLY when command == AdviseSupervisory
//! [+2 if advise]
//! 19/21 2 object_id (u16 LE)
//! +2 2 object_signature (u16 LE)
//! +4 2 primitive_id (i16 LE)
//! +6 2 attribute_id (i16 LE)
//! +8 2 property_id (i16 LE)
//! +10 2 attribute_signature (u16 LE)
//! +12 2 attribute_index (i16 LE)
//! +14 4 tail (u32 LE) default 3 per cs:88
//! ```
//!
//! Total: 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
//!
//! ## Opcode invariant
//!
//! `Advise` and `AdviseSupervisory` share opcode `0x1f` in the .NET enum
//! (`NmxItemControlMessage.cs:7-8`). The parser explicitly rejects anything
//! that is not `AdviseSupervisory` or `UnAdvise` (`cs:46-49`); there is no
//! 37-byte plain-Advise wire shape. The Rust port mirrors this: the public
//! command type only exposes `AdviseSupervisory` and `UnAdvise`.
// Direct byte indexing — see reference_handle.rs for rationale. Every read or
// write is preceded by an explicit length check that mirrors the .NET source's
// `ReadOnlySpan` slicing, so the resulting code reads as a 1:1 mirror of
// `BinaryPrimitives` calls.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
/// NMX item-control command opcode.
///
/// In the .NET reference this is a `byte` enum where `Advise` and
/// `AdviseSupervisory` are aliases for `0x1f` (`NmxItemControlMessage.cs:5-10`).
/// Only `AdviseSupervisory` and `UnAdvise` are accepted on the wire
/// (`cs:46-49`), so the Rust enum collapses the alias and exposes just those
/// two variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum NmxItemControlCommand {
/// `0x1f`. Per `NmxItemControlMessage.cs:8`.
AdviseSupervisory = 0x1f,
/// `0x21`. Per `NmxItemControlMessage.cs:9`.
UnAdvise = 0x21,
}
impl NmxItemControlCommand {
/// Map a wire byte to a command, mirroring the parser check at
/// `NmxItemControlMessage.cs:45-49`.
fn from_u8(value: u8) -> Result<Self, CodecError> {
match value {
0x1f => Ok(Self::AdviseSupervisory),
0x21 => Ok(Self::UnAdvise),
other => Err(CodecError::UnexpectedOpcode(other)),
}
}
fn to_u8(self) -> u8 {
self as u8
}
}
/// Wire-format constants from `NmxItemControlMessage.cs:24-28`.
const VERSION: u16 = 1;
const HEADER_LENGTH: usize = 3; // cmd(1) + version u16(2)
const GUID_LENGTH: usize = 16; // cs:26
const ADVISE_EXTRA_LENGTH: usize = 2; // cs:27
const PAYLOAD_LENGTH: usize = 18; // cs:28 — 7×u16 + u32 tail = 18 bytes
/// Default tail value used by `FromReferenceHandle` (`NmxItemControlMessage.cs:88`).
pub const DEFAULT_TAIL: u32 = 3;
/// Decoded NMX item-control body. The fields after `item_correlation_id`
/// project bytes 6..20 of an [`MxReferenceHandle`] — see
/// `NmxItemControlMessage.cs:71-81, 134-141`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NmxItemControlMessage {
pub command: NmxItemControlCommand,
/// 16-byte GUID. Stored as raw bytes in .NET-`Guid` layout (the layout
/// produced by `Guid.TryWriteBytes` and consumed by `new Guid(span)` —
/// mixed-endian: little-endian `Data1`/`Data2`/`Data3`, big-endian
/// `Data4`/`Data4Tail`). The Rust port stays at the byte level so the
/// .NET-shape round-trips exactly. See `NmxItemControlMessage.cs:64, 127`.
pub item_correlation_id: [u8; GUID_LENGTH],
pub object_id: u16,
pub object_signature: u16,
pub primitive_id: i16,
pub attribute_id: i16,
pub property_id: i16,
pub attribute_signature: u16,
pub attribute_index: i16,
/// Trailing u32. Default `3` per `NmxItemControlMessage.cs:88`.
pub tail: u32,
}
impl NmxItemControlMessage {
/// Encoded length for a given command. Matches
/// `NmxItemControlMessage.GetEncodedLength` (`cs:30-36`):
/// 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
#[must_use]
pub fn encoded_length(command: NmxItemControlCommand) -> usize {
HEADER_LENGTH
+ GUID_LENGTH
+ match command {
NmxItemControlCommand::AdviseSupervisory => ADVISE_EXTRA_LENGTH,
NmxItemControlCommand::UnAdvise => 0,
}
+ PAYLOAD_LENGTH
}
/// Construct a message from the bytes 6..20 of a reference handle.
/// Mirrors `NmxItemControlMessage.FromReferenceHandle` (`cs:84-101`).
///
/// `tail` defaults to [`DEFAULT_TAIL`] (`3`) per `cs:88`.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn from_reference_handle_fields(
command: NmxItemControlCommand,
item_correlation_id: [u8; GUID_LENGTH],
object_id: u16,
object_signature: u16,
primitive_id: i16,
attribute_id: i16,
property_id: i16,
attribute_signature: u16,
attribute_index: i16,
tail: u32,
) -> Self {
Self {
command,
item_correlation_id,
object_id,
object_signature,
primitive_id,
attribute_id,
property_id,
attribute_signature,
attribute_index,
tail,
}
}
/// Return a copy with `command = UnAdvise`. Mirrors `ToUnAdvise`
/// (`NmxItemControlMessage.cs:145-148`).
#[must_use]
pub fn to_un_advise(self) -> Self {
Self {
command: NmxItemControlCommand::UnAdvise,
..self
}
}
/// Return a copy with `command = AdviseSupervisory`. Mirrors
/// `ToAdviseSupervisory` (`NmxItemControlMessage.cs:150-153`).
#[must_use]
pub fn to_advise_supervisory(self) -> Self {
Self {
command: NmxItemControlCommand::AdviseSupervisory,
..self
}
}
/// Parse an item-control body. Mirrors `NmxItemControlMessage.Parse`
/// (`cs:38-82`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if the buffer is shorter than the
/// minimum 37-byte UnAdvise body (`cs:40-43`).
/// - [`CodecError::UnexpectedOpcode`] if the leading command byte is
/// neither `0x1f` (AdviseSupervisory) nor `0x21` (UnAdvise) (`cs:45-49`).
/// This is also what blocks the alias plain-`Advise` from being
/// accepted on the wire.
/// - [`CodecError::UnsupportedVersion`] if the version word is not 1
/// (`cs:51-55`).
/// - [`CodecError::Decode`] if the buffer length does not match the
/// per-command expected length (`cs:57-61`).
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
// Minimum length is the UnAdvise body (no advise-extra). Mirrors cs:40.
let min_len = HEADER_LENGTH + GUID_LENGTH + PAYLOAD_LENGTH;
if body.len() < min_len {
return Err(CodecError::ShortRead {
expected: min_len,
actual: body.len(),
});
}
let command = NmxItemControlCommand::from_u8(body[0])?;
let version = read_u16_le(body, 1);
if version != VERSION {
return Err(CodecError::UnsupportedVersion {
expected: VERSION,
actual: version,
});
}
let expected_length = Self::encoded_length(command);
if body.len() != expected_length {
return Err(CodecError::Decode {
offset: 0,
reason: "unexpected item-control body length",
buffer_len: body.len(),
});
}
let mut offset = HEADER_LENGTH;
let mut item_correlation_id = [0u8; GUID_LENGTH];
item_correlation_id.copy_from_slice(&body[offset..offset + GUID_LENGTH]);
offset += GUID_LENGTH;
if command == NmxItemControlCommand::AdviseSupervisory {
// Skip the 2-byte advise-extra word (cs:66-69). The .NET parser
// does not retain it; the Rust port mirrors that drop on parse
// and writes zeros on encode.
offset += ADVISE_EXTRA_LENGTH;
}
Ok(Self {
command,
item_correlation_id,
object_id: read_u16_le(body, offset),
object_signature: read_u16_le(body, offset + 2),
primitive_id: read_i16_le(body, offset + 4),
attribute_id: read_i16_le(body, offset + 6),
property_id: read_i16_le(body, offset + 8),
attribute_signature: read_u16_le(body, offset + 10),
attribute_index: read_i16_le(body, offset + 12),
tail: read_u32_le(body, offset + 14),
})
}
/// Encode to a freshly allocated `Vec<u8>`. Mirrors
/// `NmxItemControlMessage.Encode` (`cs:121-143`).
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut body = vec![0u8; Self::encoded_length(self.command)];
body[0] = self.command.to_u8();
write_u16_le(&mut body, 1, VERSION);
let mut offset = HEADER_LENGTH;
body[offset..offset + GUID_LENGTH].copy_from_slice(&self.item_correlation_id);
offset += GUID_LENGTH;
if self.command == NmxItemControlCommand::AdviseSupervisory {
// Two zero bytes per cs:129-132 — the .NET source advances the
// offset over already-zeroed buffer space.
offset += ADVISE_EXTRA_LENGTH;
}
write_u16_le(&mut body, offset, self.object_id);
write_u16_le(&mut body, offset + 2, self.object_signature);
write_i16_le(&mut body, offset + 4, self.primitive_id);
write_i16_le(&mut body, offset + 6, self.attribute_id);
write_i16_le(&mut body, offset + 8, self.property_id);
write_u16_le(&mut body, offset + 10, self.attribute_signature);
write_i16_le(&mut body, offset + 12, self.attribute_index);
write_u32_le(&mut body, offset + 14, self.tail);
body
}
}
#[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_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
#[inline]
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
let le = value.to_le_bytes();
bytes[offset..offset + 2].copy_from_slice(&le);
}
#[inline]
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
let le = value.to_le_bytes();
bytes[offset..offset + 2].copy_from_slice(&le);
}
#[inline]
fn write_u32_le(bytes: &mut [u8], offset: usize, value: u32) {
let le = value.to_le_bytes();
bytes[offset..offset + 4].copy_from_slice(&le);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn sample(command: NmxItemControlCommand) -> NmxItemControlMessage {
NmxItemControlMessage {
command,
item_correlation_id: [0x11; GUID_LENGTH],
object_id: 0x1234,
object_signature: 0xABCD,
primitive_id: -1,
attribute_id: 7,
property_id: 0,
attribute_signature: 0xBEEF,
attribute_index: -1,
tail: DEFAULT_TAIL,
}
}
#[test]
fn encoded_length_matches_dotnet() {
// cs:30-36: AdviseSupervisory = 3+16+2+18 = 39, UnAdvise = 3+16+18 = 37.
assert_eq!(
NmxItemControlMessage::encoded_length(NmxItemControlCommand::AdviseSupervisory),
39
);
assert_eq!(
NmxItemControlMessage::encoded_length(NmxItemControlCommand::UnAdvise),
37
);
}
#[test]
fn round_trip_advise_supervisory() {
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let encoded = msg.encode();
assert_eq!(encoded.len(), 39);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn round_trip_un_advise() {
let msg = sample(NmxItemControlCommand::UnAdvise);
let encoded = msg.encode();
assert_eq!(encoded.len(), 37);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn parse_rejects_plain_advise_alias() {
// The .NET enum aliases `Advise = 0x1f = AdviseSupervisory`, so on
// the wire the leading byte 0x1f always means AdviseSupervisory.
// There is *no* 37-byte plain-Advise shape; a 37-byte body that
// starts with 0x1f must be rejected because the per-command length
// check (cs:57-61) demands 39 bytes for 0x1f.
let mut bogus = vec![0u8; 37];
bogus[0] = 0x1f;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(
matches!(err, CodecError::Decode { .. }),
"expected length-mismatch decode error, got {err:?}"
);
}
#[test]
fn parse_rejects_unknown_command_opcode() {
// Leading byte that is neither 0x1f nor 0x21 — cs:46-49.
let mut bogus = vec![0u8; 37];
bogus[0] = 0x42;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(matches!(err, CodecError::UnexpectedOpcode(0x42)));
}
#[test]
fn parse_rejects_wrong_length_buffer_advise() {
// 39 bytes is right for AdviseSupervisory; 38 is wrong.
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let mut encoded = msg.encode();
encoded.pop();
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
// 38 bytes still passes the 37-byte minimum, so we hit the
// per-command length mismatch (cs:57-61) — Decode.
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn parse_rejects_wrong_length_buffer_un_advise() {
// 36 bytes is below the 37-byte UnAdvise minimum (cs:40).
let err = NmxItemControlMessage::parse(&[0u8; 36]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_rejects_oversized_un_advise() {
// 38 bytes with leading 0x21 — UnAdvise demands exactly 37.
let mut bogus = vec![0u8; 38];
bogus[0] = 0x21;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn parse_rejects_wrong_version() {
let msg = sample(NmxItemControlCommand::UnAdvise);
let mut encoded = msg.encode();
write_u16_le(&mut encoded, 1, 2);
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
}
#[test]
fn guid_round_trips_byte_identical() {
// Use a distinctive byte pattern so a re-shuffle would be obvious.
let guid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let msg = NmxItemControlMessage {
item_correlation_id: guid,
..sample(NmxItemControlCommand::AdviseSupervisory)
};
let encoded = msg.encode();
// GUID lives at offset 3..19 (after cmd+version) per cs:63-65.
assert_eq!(&encoded[3..19], &guid);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(decoded.item_correlation_id, guid);
}
#[test]
fn guid_round_trips_known_pattern() {
// The brief calls out `[0x11; 16]` as a sanity vector.
let guid = [0x11u8; 16];
let msg = NmxItemControlMessage {
item_correlation_id: guid,
..sample(NmxItemControlCommand::UnAdvise)
};
let encoded = msg.encode();
assert_eq!(&encoded[3..19], &guid);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(decoded.item_correlation_id, [0x11; 16]);
}
#[test]
fn default_tail_is_three() {
// cs:88 — `uint tail = 3`.
assert_eq!(DEFAULT_TAIL, 3);
let msg = NmxItemControlMessage::from_reference_handle_fields(
NmxItemControlCommand::AdviseSupervisory,
[0x11; 16],
1,
2,
3,
4,
5,
6,
7,
DEFAULT_TAIL,
);
let encoded = msg.encode();
// Tail u32 lives at the last 4 bytes of the body.
let n = encoded.len();
assert_eq!(&encoded[n - 4..], &3u32.to_le_bytes());
}
#[test]
fn advise_supervisory_extra_bytes_are_zero_on_encode() {
// cs:129-132: the 2-byte advise-extra word at offset 19..21 is
// skipped (left as zero in the freshly-allocated buffer).
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let encoded = msg.encode();
assert_eq!(&encoded[19..21], &[0x00, 0x00]);
}
#[test]
fn handle_projection_offsets_advise_supervisory() {
// For AdviseSupervisory the handle projection starts at offset 21
// (3 + 16 + 2). Verify object_id 0x1234 lands as 34 12 there.
let msg = NmxItemControlMessage {
object_id: 0x1234,
..sample(NmxItemControlCommand::AdviseSupervisory)
};
let encoded = msg.encode();
assert_eq!(encoded[21], 0x34);
assert_eq!(encoded[22], 0x12);
}
#[test]
fn handle_projection_offsets_un_advise() {
// For UnAdvise the handle projection starts at offset 19 (3 + 16).
let msg = NmxItemControlMessage {
object_id: 0x1234,
..sample(NmxItemControlCommand::UnAdvise)
};
let encoded = msg.encode();
assert_eq!(encoded[19], 0x34);
assert_eq!(encoded[20], 0x12);
}
#[test]
fn version_word_is_one_le() {
let encoded = sample(NmxItemControlCommand::UnAdvise).encode();
assert_eq!(encoded[1], 0x01);
assert_eq!(encoded[2], 0x00);
}
#[test]
fn command_byte_round_trips() {
let advise = sample(NmxItemControlCommand::AdviseSupervisory).encode();
let unadvise = sample(NmxItemControlCommand::UnAdvise).encode();
assert_eq!(advise[0], 0x1f);
assert_eq!(unadvise[0], 0x21);
}
#[test]
fn to_un_advise_and_back() {
// Mirrors cs:145-153 — `with` updates only the command.
let advise = sample(NmxItemControlCommand::AdviseSupervisory);
let unadvise = advise.to_un_advise();
assert_eq!(unadvise.command, NmxItemControlCommand::UnAdvise);
// All other fields preserved.
assert_eq!(advise.item_correlation_id, unadvise.item_correlation_id);
assert_eq!(advise.object_id, unadvise.object_id);
assert_eq!(advise.tail, unadvise.tail);
let again = unadvise.to_advise_supervisory();
assert_eq!(again.command, NmxItemControlCommand::AdviseSupervisory);
assert_eq!(again, advise);
}
}
+92
View File
@@ -0,0 +1,92 @@
//! `mxaccess-codec` — pure protocol codec for the AVEVA / Wonderware MXAccess
//! wire format. No I/O.
//!
//! M1 codec parity in progress. Implemented:
//! - Foundational types: `MxReferenceHandle` (CRC-16/IBM), `NmxTransferEnvelope`
//! (with `reserved6_10` preservation), `MxStatus` + `MxStatusCategory` +
//! `MxStatusSource` + `detail_text`, `MxValue` + `MxValueKind` + `MxDataType`.
//! - Message-body codecs: `NmxItemControlMessage` (advise/supervisory/unadvise),
//! `write_message` module (scalar + array, normal + timestamped Write),
//! `subscription_message` (DataUpdate `0x33` + SubscriptionStatus `0x32`),
//! `NmxReferenceRegistrationMessage` + Result, `NmxOperationStatusMessage`
//! (incl. the proven `00 00 50 80 00` 5-byte completion frame and the
//! `0x00`/`0x41`/`0xEF` 1-byte completion frames preserved verbatim),
//! `NmxMetadataQueryMessage` (observed pre-advise template),
//! `NmxTransferEnvelopeTemplate` (round-trip preserver).
//!
//! Remaining (wave 2): `NmxSecuredWrite2Message` (`0x38`),
//! `ObservedWriteBodyTemplate`. ASB Variant + AsbStatus + RuntimeValue land
//! in M5.
//!
//! Every wire shape here is grounded in `src/MxNativeCodec/*.cs` (the .NET
//! reference) and `captures/0NN-frida-*` (Frida ground truth).
#![forbid(unsafe_code)]
pub mod envelope;
pub mod envelope_template;
pub mod error;
pub mod item_control;
pub mod metadata_query;
pub mod observed_frame;
pub mod observed_write_template;
pub mod operation_status;
pub mod reference_handle;
pub mod reference_registration;
pub mod secured_write;
pub mod status;
pub mod subscription_message;
pub mod value;
pub mod write_message;
pub use envelope::{ENVELOPE_HEADER_LEN, NmxTransferEnvelope, NmxTransferMessageKind};
pub use envelope_template::NmxTransferEnvelopeTemplate;
pub use error::CodecError;
pub use item_control::{NmxItemControlCommand, NmxItemControlMessage};
pub use metadata_query::NmxMetadataQueryMessage;
pub use observed_frame::{NmxObservedEnvelope, NmxObservedMessage, NmxObservedString};
pub use observed_write_template::ObservedWriteBodyTemplate;
pub use operation_status::{NmxOperationStatusFormat, NmxOperationStatusMessage};
pub use reference_handle::{MxReferenceHandle, compute_name_signature, update_crc16_ibm};
pub use reference_registration::{
NmxReferenceRegistrationMessage, NmxReferenceRegistrationResultMessage,
};
pub use secured_write::DecodedSecuredWrite;
pub use status::{MxStatus, MxStatusCategory, MxStatusSource, detail_text};
pub use subscription_message::{NmxSubscriptionMessage, NmxSubscriptionRecord};
pub use value::{MxDataType, MxValue, MxValueKind};
// `NmxWriteMessage` and `NmxSecuredWrite2Message` are not single struct types
// in the Rust port — encoding/decoding live as functions in the
// `write_message` and `secured_write` modules. Keep stubs as short type
// aliases so existing references compile; consumers should call the module
// functions directly.
#[derive(Debug, Clone)]
pub struct NmxWriteMessage;
#[derive(Debug, Clone)]
pub struct NmxSecuredWrite2Message;
// ---- ASB types (M5 follow-up) --------------------------------------------
#[derive(Debug, Clone)]
pub struct AsbVariant;
#[derive(Debug, Clone, Copy, Default)]
pub struct AsbStatus;
#[derive(Debug, Clone)]
pub struct RuntimeValue;
// ---- Convenience prelude -------------------------------------------------
pub mod prelude {
pub use super::{
CodecError, MxDataType, MxReferenceHandle, MxStatus, MxStatusCategory, MxStatusSource,
MxValue, MxValueKind, NmxItemControlCommand, NmxItemControlMessage,
NmxOperationStatusMessage, NmxReferenceRegistrationMessage,
NmxReferenceRegistrationResultMessage, NmxSubscriptionMessage, NmxTransferEnvelope,
NmxTransferEnvelopeTemplate, NmxTransferMessageKind,
};
}
@@ -0,0 +1,192 @@
//! `NmxMetadataQueryMessage` — observed pre-advise metadata-query body.
//!
//! Direct port of `src/MxNativeCodec/NmxMetadataQueryMessage.cs`. The .NET
//! reference exposes a single static helper, [`encode_observed_pre_advise`],
//! which returns a fixed observed body with a 16-byte item-correlation GUID
//! patched in at offset `0x8a`.
//!
//! The body is a captured constant — both segments of the hex literal in
//! `NmxMetadataQueryMessage.cs:10-11` are reproduced byte-for-byte below.
//! It encodes two metadata queries against `$DevPlatform.GR.TimeOfLastDeploy`
//! and `$DevPlatform.GR.TimeOfLastConfigChange`. The Rust port preserves
//! every byte; the only mutation is the GUID at offset `0x8a`.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
/// Offset of the 16-byte item-correlation GUID inside the observed body
/// (`NmxMetadataQueryMessage.cs:5`).
pub const PRE_ADVISE_CORRELATION_OFFSET: usize = 0x8a;
/// Length of the first hex segment in bytes — `NmxMetadataQueryMessage.cs:10`.
const SEGMENT_1_LEN: usize = 160;
/// Length of the second hex segment in bytes — `NmxMetadataQueryMessage.cs:11`.
const SEGMENT_2_LEN: usize = 154;
/// Length of the observed body in bytes (160 + 154 = 314).
pub const PRE_ADVISE_BODY_LEN: usize = SEGMENT_1_LEN + SEGMENT_2_LEN;
/// First hex segment from `NmxMetadataQueryMessage.cs:10`. Decoded byte-for-byte
/// from `Convert.FromHexString(...)` of the literal in the .NET source.
const SEGMENT_1: [u8; SEGMENT_1_LEN] = [
0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x65, 0x00, 0x71, 0x00, 0x0a, 0x00,
0x00, 0x00, 0x00, 0x00, 0x08, 0x6a, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x81, 0x44, 0x00, 0x65,
0x00, 0x76, 0x00, 0x50, 0x00, 0x6c, 0x00, 0x61, 0x00, 0x74, 0x00, 0x66, 0x00, 0x6f, 0x00, 0x72,
0x00, 0x6d, 0x00, 0x2e, 0x00, 0x47, 0x00, 0x52, 0x00, 0x2e, 0x00, 0x54, 0x00, 0x69, 0x00, 0x6d,
0x00, 0x65, 0x00, 0x4f, 0x00, 0x66, 0x00, 0x4c, 0x00, 0x61, 0x00, 0x73, 0x00, 0x74, 0x00, 0x44,
0x00, 0x65, 0x00, 0x70, 0x00, 0x6c, 0x00, 0x6f, 0x00, 0x79, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0xd0, 0xfc, 0x40, 0x09, 0x1f, 0x01, 0x00, 0xc0, 0xca, 0x9c, 0xcd, 0x32, 0x65,
0xb0, 0x46, 0xa5, 0x85, 0xa5, 0x83, 0xb2, 0xe7, 0x7a, 0x5d, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
];
/// Second hex segment from `NmxMetadataQueryMessage.cs:11`. Decoded
/// byte-for-byte from `Convert.FromHexString(...)` of the literal in the
/// .NET source.
const SEGMENT_2: [u8; SEGMENT_2_LEN] = [
0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x65, 0x00, 0x71, 0x00, 0x0a, 0x00,
0x00, 0x00, 0x00, 0x00, 0x08, 0x76, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x00, 0x81, 0x44, 0x00, 0x65,
0x00, 0x76, 0x00, 0x50, 0x00, 0x6c, 0x00, 0x61, 0x00, 0x74, 0x00, 0x66, 0x00, 0x6f, 0x00, 0x72,
0x00, 0x6d, 0x00, 0x2e, 0x00, 0x47, 0x00, 0x52, 0x00, 0x2e, 0x00, 0x54, 0x00, 0x69, 0x00, 0x6d,
0x00, 0x65, 0x00, 0x4f, 0x00, 0x66, 0x00, 0x4c, 0x00, 0x61, 0x00, 0x73, 0x00, 0x74, 0x00, 0x43,
0x00, 0x6f, 0x00, 0x6e, 0x00, 0x66, 0x00, 0x69, 0x00, 0x67, 0x00, 0x43, 0x00, 0x68, 0x00, 0x61,
0x00, 0x6e, 0x00, 0x67, 0x00, 0x65, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x50,
0x03, 0x41, 0x09, 0x20, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00,
];
/// Concatenation of `SEGMENT_1 || SEGMENT_2`. Equivalent to the result of
/// `Convert.FromHexString` on the joined hex literal at
/// `NmxMetadataQueryMessage.cs:10-11`.
const OBSERVED_PRE_ADVISE_BODY: [u8; PRE_ADVISE_BODY_LEN] = {
let mut out = [0u8; PRE_ADVISE_BODY_LEN];
let mut i = 0;
while i < SEGMENT_1_LEN {
out[i] = SEGMENT_1[i];
i += 1;
}
let mut j = 0;
while j < SEGMENT_2_LEN {
out[SEGMENT_1_LEN + j] = SEGMENT_2[j];
j += 1;
}
out
};
/// Stateless helpers around the observed metadata-query body.
///
/// Mirrors the static class `NmxMetadataQueryMessage`
/// (`NmxMetadataQueryMessage.cs:3-15`).
pub struct NmxMetadataQueryMessage;
impl NmxMetadataQueryMessage {
/// Encode the observed pre-advise body, patching the supplied 16-byte
/// GUID into offset `0x8a` (`NmxMetadataQueryMessage.cs:7-14`).
///
/// `item_correlation_id` is the raw 16-byte little-endian Guid layout —
/// the same byte order .NET's `Guid.TryWriteBytes` emits. Callers
/// constructing a Guid from Rust types are responsible for using the
/// same wire layout (e.g. `windows::core::GUID::to_u128_le().to_le_bytes()`
/// or equivalent).
pub fn encode_observed_pre_advise(item_correlation_id: [u8; 16]) -> Vec<u8> {
let mut body = OBSERVED_PRE_ADVISE_BODY.to_vec();
body[PRE_ADVISE_CORRELATION_OFFSET..PRE_ADVISE_CORRELATION_OFFSET + 16]
.copy_from_slice(&item_correlation_id);
body
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn body_length_is_314() {
// 160 + 154 = 314 bytes — derived from the two hex segments at
// `NmxMetadataQueryMessage.cs:10-11`.
assert_eq!(SEGMENT_1_LEN, 160);
assert_eq!(SEGMENT_2_LEN, 154);
assert_eq!(PRE_ADVISE_BODY_LEN, 314);
assert_eq!(OBSERVED_PRE_ADVISE_BODY.len(), 314);
}
// Compile-time bounds checks: clippy denies `assert!(<const expr>)` at
// runtime, so anchor these as `const _: () = assert!(...)` instead. They
// still fail the build if the constants drift — at compile time, before
// the test runner even spins up.
const _: () = assert!(PRE_ADVISE_CORRELATION_OFFSET + 16 <= PRE_ADVISE_BODY_LEN);
const _: () = assert!(PRE_ADVISE_CORRELATION_OFFSET + 16 <= SEGMENT_1_LEN);
#[test]
fn correlation_offset_is_0x8a() {
assert_eq!(PRE_ADVISE_CORRELATION_OFFSET, 0x8a);
// 0x8a (138) + 16 = 154, which is inside the first 160-byte segment.
// Anchor checks are above as `const _: () = assert!(...)`.
}
#[test]
fn observed_guid_in_template_matches_dotnet_capture() {
// The captured GUID at offset 0x8a in the literal body
// (`NmxMetadataQueryMessage.cs:10` — after the `0xc0` byte at offset 138).
let expected = [
0xc0, 0xca, 0x9c, 0xcd, 0x32, 0x65, 0xb0, 0x46, 0xa5, 0x85, 0xa5, 0x83, 0xb2, 0xe7,
0x7a, 0x5d,
];
assert_eq!(
&OBSERVED_PRE_ADVISE_BODY[0x8a..0x8a + 16],
&expected,
"the embedded GUID must match the .NET literal byte-for-byte"
);
}
#[test]
fn guid_is_patched_at_0x8a() {
let guid = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff, 0x00,
];
let body = NmxMetadataQueryMessage::encode_observed_pre_advise(guid);
assert_eq!(body.len(), PRE_ADVISE_BODY_LEN);
assert_eq!(&body[0x8a..0x8a + 16], &guid);
}
#[test]
fn bytes_outside_correlation_window_are_unchanged() {
// Encode with an all-zero GUID and an all-0xff GUID, compare every
// byte outside the patch window — they must be identical.
let body_a = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
let body_b = NmxMetadataQueryMessage::encode_observed_pre_advise([0xffu8; 16]);
for i in 0..PRE_ADVISE_BODY_LEN {
if (PRE_ADVISE_CORRELATION_OFFSET..PRE_ADVISE_CORRELATION_OFFSET + 16).contains(&i) {
continue;
}
assert_eq!(body_a[i], body_b[i], "byte {i} should be unchanged");
}
}
#[test]
fn encoded_body_matches_observed_template_at_known_offsets() {
// Spot-check anchor bytes from the .NET hex string. Offsets 0..10
// are the `17 01 00 01 01 00 01 00 00 00` header
// (`NmxMetadataQueryMessage.cs:10`); offset 160 starts the second
// segment with the same 10-byte preamble (`NmxMetadataQueryMessage.cs:11`).
let body = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
let preamble = [0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
assert_eq!(&body[0..10], &preamble);
assert_eq!(&body[SEGMENT_1_LEN..SEGMENT_1_LEN + 10], &preamble);
}
#[test]
fn fresh_call_does_not_mutate_template() {
// Each call must return an independent buffer — patching the result
// of one call must not affect a subsequent call.
let mut a = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
a[0] = 0x99;
let b = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
assert_eq!(b[0], 0x17, "second call must not see mutation of first");
}
}
@@ -0,0 +1,647 @@
//! `NmxObservedFrame` — tolerant transfer-envelope + inner-message parser.
//!
//! Direct port of `src/MxNativeCodec/NmxObservedFrame.cs`.
//!
//! Where [`crate::NmxTransferEnvelope`] strictly validates the typed fields
//! of the 46-byte transfer header, the *observed* envelope path is a
//! permissive analyser used by probes and replay:
//!
//! - Splits a `TransferData`-shaped or `ProcessDataReceived`-shaped buffer
//! into a 46-byte header plus an inner body.
//! - Surfaces the optional 4-byte length prefix that wraps
//! `ProcessDataReceived` bodies on the wire.
//! - Parses the inner body's leading `cmd + version` bytes plus, for the
//! recognised opcodes `0x1f` and `0x21`, a 16-byte item-correlation GUID.
//! - Walks the body looking for runs of printable UTF-16LE strings and
//! surfaces them with their offsets. Unknown opcodes round-trip cleanly
//! — the parser never rejects them, it just gives them a synthetic
//! `Unknown0xNN` name (`NmxObservedFrame.cs:148`).
//!
//! ## hasDetailStatus audit (Q7 follow-up)
//!
//! `NmxObservedFrame.cs:122-126` reads `itemCorrelationId` **conditionally**:
//!
//! ```csharp
//! if (command is 0x1f or 0x21 && body.Length >= 19)
//! {
//! itemCorrelationId = new Guid(body.Slice(3, 16));
//! }
//! ```
//!
//! That is a `has_*`-style conditional read in the .NET source — it depends
//! on both the opcode and the buffer length. **Audit: the Rust port mirrors
//! the same conditional exactly** (it MUST stay conditional — making it
//! unconditional would either crash on shorter unknown-opcode bodies or
//! attach a meaningless GUID to bodies that have no correlation slot). No
//! other field in this file is read conditionally.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
/// Header length in bytes (`NmxObservedFrame.cs:14`).
pub const HEADER_LENGTH: usize = 46;
/// Inner-length field offset in the transfer header
/// (`NmxObservedFrame.cs:15`).
pub const INNER_LENGTH_OFFSET: usize = 2;
/// Tolerant parse of a `TransferData`-style envelope body. Mirrors
/// [`NmxObservedEnvelope`] returned by `ParseTransferDataBody`
/// (`NmxObservedFrame.cs:17-38`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedEnvelope {
/// Whether the body began with a 4-byte total-length prefix
/// (set on `ProcessDataReceived` payloads).
pub has_length_prefix: bool,
/// The captured 4-byte total-length prefix, or `None` if absent.
pub total_length_prefix: Option<i32>,
/// `inner_length` field at offset 2 of the 46-byte header.
pub declared_inner_length: i32,
/// Actual inner-body length in bytes (`body.len() - 46` after stripping
/// any optional length prefix).
pub actual_inner_length: usize,
/// The captured 46-byte header.
pub header: Vec<u8>,
/// The inner body that follows the header.
pub inner_body: Vec<u8>,
}
impl NmxObservedEnvelope {
/// Parse a `TransferData` body (no leading 4-byte length prefix).
/// Mirrors `ParseTransferDataBody` (`NmxObservedFrame.cs:17-38`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body.len() < 46`.
/// - [`CodecError::InnerLengthMismatch`] if the declared inner length
/// doesn't match the actual inner body length.
pub fn parse_transfer_data_body(body: &[u8]) -> Result<Self, CodecError> {
if body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: body.len(),
});
}
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
let actual_inner_length = body.len() - HEADER_LENGTH;
if declared_inner_length != actual_inner_length as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: actual_inner_length,
});
}
Ok(Self {
has_length_prefix: false,
total_length_prefix: None,
declared_inner_length,
actual_inner_length,
header: body[..HEADER_LENGTH].to_vec(),
inner_body: body[HEADER_LENGTH..].to_vec(),
})
}
/// Parse a `ProcessDataReceived` body — strict form with leading
/// 4-byte total-length prefix. Mirrors `ParseProcessDataReceivedBody`
/// (`NmxObservedFrame.cs:40-69`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body.len() < 50`.
/// - [`CodecError::InnerLengthMismatch`] if either the total-length
/// prefix or the declared inner length doesn't reconcile with the
/// buffer size.
pub fn parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
if body.len() < 4 + HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: 4 + HEADER_LENGTH,
actual: body.len(),
});
}
// `.cs:47` — total length prefix at offset 0.
let total_length_prefix = read_i32_le(body, 0);
if total_length_prefix as usize != body.len() {
return Err(CodecError::InnerLengthMismatch {
declared: total_length_prefix,
actual: body.len(),
});
}
let header_offset = 4;
// `.cs:54-55` — inner length sits at headerOffset + InnerLengthOffset.
let declared_inner_length = read_i32_le(body, header_offset + INNER_LENGTH_OFFSET);
// `.cs:56` — actualInnerLength = declared - sizeof(int).
let actual_inner_length = declared_inner_length - 4;
if actual_inner_length < 0
|| header_offset + HEADER_LENGTH + actual_inner_length as usize != body.len()
{
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: body.len() - header_offset - HEADER_LENGTH,
});
}
let actual_inner_length = actual_inner_length as usize;
Ok(Self {
has_length_prefix: true,
total_length_prefix: Some(total_length_prefix),
declared_inner_length,
actual_inner_length,
header: body[header_offset..header_offset + HEADER_LENGTH].to_vec(),
inner_body: body[header_offset + HEADER_LENGTH
..header_offset + HEADER_LENGTH + actual_inner_length]
.to_vec(),
})
}
/// Flexible `ProcessDataReceived` parse — tries the strict
/// length-prefixed form first; falls back to the `TransferData`-style
/// header-only form. Mirrors `ParseProcessDataReceivedBodyFlexible`
/// (`NmxObservedFrame.cs:71-101`).
pub fn parse_process_data_received_body_flexible(body: &[u8]) -> Result<Self, CodecError> {
// `.cs:73-80` — try the strict path if and only if the leading
// i32 == body length.
if body.len() >= 4 + HEADER_LENGTH {
let total_length_prefix = read_i32_le(body, 0);
if total_length_prefix as usize == body.len() {
return Self::parse_process_data_received_body(body);
}
}
if body.len() < HEADER_LENGTH {
return Err(CodecError::ShortRead {
expected: HEADER_LENGTH,
actual: body.len(),
});
}
// `.cs:87-92` — fall back to header-only inner-length validation.
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
let actual_inner_length = body.len() - HEADER_LENGTH;
if declared_inner_length != actual_inner_length as i32 {
return Err(CodecError::InnerLengthMismatch {
declared: declared_inner_length,
actual: actual_inner_length,
});
}
Ok(Self {
has_length_prefix: false,
total_length_prefix: None,
declared_inner_length,
actual_inner_length,
header: body[..HEADER_LENGTH].to_vec(),
inner_body: body[HEADER_LENGTH..].to_vec(),
})
}
}
/// A printable UTF-16LE string discovered at a specific offset inside the
/// observed body. Mirrors the .NET `NmxObservedString` record
/// (`NmxObservedFrame.cs:104`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedString {
pub offset: usize,
pub value: String,
}
/// Tolerant parse of an inner NMX message body. Mirrors
/// `NmxObservedMessage` (`NmxObservedFrame.cs:106-192`).
///
/// "Tolerant" means: the parser does NOT validate the body shape against
/// any specific opcode — it simply records the leading `cmd`, `version` u16
/// (split into major/minor bytes), and (for `0x1f` / `0x21`) a 16-byte item
/// correlation GUID. Unknown opcodes get a synthetic name (`Unknown0xNN`)
/// per `.cs:148`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NmxObservedMessage {
pub command: u8,
pub command_name: &'static str,
/// Synthetic name for unknown commands (`Unknown0xNN`). When the command
/// is recognised, this is empty and [`Self::command_name`] is used.
pub synthetic_name: Option<String>,
pub version_major: u8,
pub version_minor: u8,
/// Item-correlation GUID for `AdviseSupervisory` (`0x1f`) and
/// `UnAdvise` (`0x21`) bodies. **Read conditionally** — mirroring
/// `NmxObservedFrame.cs:122-126`. See module-level Q7 audit.
///
/// The GUID is 16 raw bytes from `body[3..19]`. The .NET source uses
/// `new Guid(byte[])` which interprets the first three groups as
/// little-endian (mixed-endian on the wire). The Rust port keeps the
/// raw 16-byte form to avoid pulling in a `Guid`/`uuid` dependency at
/// the codec level — consumers can re-interpret if needed.
pub item_correlation_id: Option<[u8; 16]>,
/// Printable UTF-16LE strings discovered in the body, with their
/// starting byte offsets.
pub strings: Vec<NmxObservedString>,
}
impl NmxObservedMessage {
/// Parse the body. Mirrors `NmxObservedMessage.Parse`
/// (`NmxObservedFrame.cs:114-135`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if the body has fewer than 3 bytes (the
/// minimum needed to read `cmd + version`).
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
// `.cs:116-119` — minimum length 3.
if body.len() < 3 {
return Err(CodecError::ShortRead {
expected: 3,
actual: body.len(),
});
}
let command = body[0];
// `.cs:122-126` — CONDITIONAL read of itemCorrelationId.
// Audit Q7: this stays conditional in the Rust port.
let item_correlation_id = if (command == 0x1f || command == 0x21) && body.len() >= 19 {
let mut guid = [0u8; 16];
guid.copy_from_slice(&body[3..19]);
Some(guid)
} else {
None
};
let (command_name, synthetic_name) = command_name(command);
Ok(Self {
command,
command_name,
synthetic_name,
// `.cs:131` — body[1] is the major byte of the u16 version.
version_major: body[1],
// `.cs:132` — body[2] is the minor byte.
version_minor: body[2],
item_correlation_id,
strings: extract_utf16_strings(body),
})
}
}
/// Map a command byte to its declared name. Mirrors `GetCommandName`
/// (`NmxObservedFrame.cs:137-150`).
///
/// Returns `(known_name, synthetic_name_for_unknown)`. For known commands,
/// the synthetic-name slot is `None`; for unknown commands, the known-name
/// slot is `"Unknown"` and the synthetic slot carries the formatted name.
fn command_name(command: u8) -> (&'static str, Option<String>) {
match command {
0x17 => ("MetadataQuery", None),
0x1f => ("AdviseSupervisory", None),
0x21 => ("UnAdvise", None),
0x32 => ("SubscriptionStatus", None),
0x33 => ("DataUpdate", None),
0x37 => ("Write", None),
0x40 => ("MetadataResponse", None),
// `.cs:148` — synthesised name for everything else.
other => ("Unknown", Some(format!("Unknown0x{other:02X}"))),
}
}
/// Walk the body looking for runs of printable UTF-16LE characters
/// terminated by a 2-byte NUL. Mirrors `ExtractUtf16Strings`
/// (`NmxObservedFrame.cs:152-191`).
///
/// A "string" is at least 3 printable ASCII characters (low byte in
/// `0x20..=0x7e`, high byte zero) followed by a `00 00` terminator. The
/// scanner's appetite is intentionally narrow: arbitrary binary that
/// happens to look like UTF-16 won't trip it.
fn extract_utf16_strings(body: &[u8]) -> Vec<NmxObservedString> {
let mut strings = Vec::new();
let mut offset = 0usize;
// `.cs:156` — outer guard `offset + 8 <= body.length`.
while offset + 8 <= body.len() {
let start = offset;
let mut chars: usize = 0;
// `.cs:160-177` — inner scan loop.
while offset + 1 < body.len() {
let lo = body[offset];
let hi = body[offset + 1];
// `.cs:162-167` — null terminator ends the run.
if lo == 0 && hi == 0 {
break;
}
// `.cs:169-173` — non-printable / non-ASCII byte invalidates
// the candidate run.
if hi != 0 || !(0x20..=0x7e).contains(&lo) {
chars = 0;
break;
}
chars += 1;
offset += 2;
}
// `.cs:179-186` — accept the run if it had at least 3 chars and
// is followed by the 00 00 terminator.
if chars >= 3 && offset + 1 < body.len() && body[offset] == 0 && body[offset + 1] == 0 {
let raw = &body[start..start + chars * 2];
let utf16: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
// The scan accepted only printable ASCII, so the conversion
// can't fail in practice. If it does, we silently drop the run.
if let Ok(value) = String::from_utf16(&utf16) {
strings.push(NmxObservedString {
offset: start,
value,
});
}
offset += 2;
continue;
}
// `.cs:187` — failed match: advance by 1 byte and retry.
offset = start + 1;
}
strings
}
// ---- LE primitive helpers -------------------------------------------------
#[inline]
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
i32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn synthesise_envelope(inner: &[u8]) -> Vec<u8> {
let mut out = vec![0u8; HEADER_LENGTH + inner.len()];
// Pack the header with a recognisable pattern so we can verify
// round-trip preservation.
for (i, b) in out[..HEADER_LENGTH].iter_mut().enumerate() {
*b = 0xA0u8.wrapping_add(i as u8);
}
// Patch the inner-length field at offset 2.
out[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4]
.copy_from_slice(&(inner.len() as i32).to_le_bytes());
out[HEADER_LENGTH..].copy_from_slice(inner);
out
}
fn synthesise_pdr_body(inner: &[u8]) -> Vec<u8> {
// ProcessDataReceived strict layout: 4 (total) + 46 (header) + inner.
// Total-length prefix == body.len(), inner-length field == inner.len() + 4.
let total_len = 4 + HEADER_LENGTH + inner.len();
let mut out = vec![0u8; total_len];
out[..4].copy_from_slice(&(total_len as i32).to_le_bytes());
for (i, b) in out[4..4 + HEADER_LENGTH].iter_mut().enumerate() {
*b = 0xC0u8.wrapping_add(i as u8);
}
// inner length field at offset 4 + 2 = 6, value = inner.len() + 4.
out[6..10].copy_from_slice(&((inner.len() + 4) as i32).to_le_bytes());
out[4 + HEADER_LENGTH..].copy_from_slice(inner);
out
}
#[test]
fn header_constants_match_dotnet() {
// `NmxObservedFrame.cs:14-15`.
assert_eq!(HEADER_LENGTH, 46);
assert_eq!(INNER_LENGTH_OFFSET, 2);
}
// ---- Envelope parsing -----------------------------------------------
#[test]
fn parse_transfer_data_body_round_trip() {
let inner = [0x37u8, 0x01, 0x00, 0xAB, 0xCD];
let body = synthesise_envelope(&inner);
let env = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap();
assert!(!env.has_length_prefix);
assert_eq!(env.total_length_prefix, None);
assert_eq!(env.declared_inner_length, inner.len() as i32);
assert_eq!(env.actual_inner_length, inner.len());
assert_eq!(env.inner_body, inner);
assert_eq!(env.header.len(), HEADER_LENGTH);
// Header preserved verbatim.
assert_eq!(&env.header, &body[..HEADER_LENGTH]);
}
#[test]
fn parse_transfer_data_body_rejects_short_buffer() {
let err = NmxObservedEnvelope::parse_transfer_data_body(&[0u8; 45]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_transfer_data_body_rejects_inner_length_mismatch() {
let mut body = synthesise_envelope(&[0u8; 8]);
// Clobber inner-length field to a wrong value.
body[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4].copy_from_slice(&100i32.to_le_bytes());
let err = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn parse_pdr_body_strict_round_trip() {
let inner = [0x33u8, 0x01, 0x00];
let body = synthesise_pdr_body(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap();
assert!(env.has_length_prefix);
assert_eq!(env.total_length_prefix, Some(body.len() as i32));
assert_eq!(env.actual_inner_length, inner.len());
assert_eq!(env.inner_body, inner);
}
#[test]
fn parse_pdr_body_strict_rejects_bad_total_length() {
let inner = [0u8; 4];
let mut body = synthesise_pdr_body(&inner);
// Corrupt the total-length prefix (compute the corrupt value first
// to avoid borrowing `body` mutably and immutably in the same expr).
let bad_total = body.len() as i32 + 1;
body[0..4].copy_from_slice(&bad_total.to_le_bytes());
let err = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap_err();
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
}
#[test]
fn parse_pdr_flexible_uses_strict_when_possible() {
let inner = [0x32u8, 0x01, 0x00];
let body = synthesise_pdr_body(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
assert!(env.has_length_prefix);
}
#[test]
fn parse_pdr_flexible_falls_back_to_header_only() {
// No leading 4-byte length prefix — flexible parser falls back.
let inner = [0x32u8, 0x01, 0x00];
let body = synthesise_envelope(&inner);
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
assert!(!env.has_length_prefix);
assert_eq!(env.inner_body, inner);
}
// ---- Inner-message parsing ------------------------------------------
#[test]
fn parse_message_minimum_length_3() {
let err = NmxObservedMessage::parse(&[0x37u8, 0x01]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_recognised_command_yields_known_name() {
let body = [0x37u8, 0x01, 0x00];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command, 0x37);
assert_eq!(msg.command_name, "Write");
assert_eq!(msg.synthetic_name, None);
assert_eq!(msg.version_major, 0x01);
assert_eq!(msg.version_minor, 0x00);
assert_eq!(msg.item_correlation_id, None);
}
#[test]
fn parse_unknown_command_yields_synthetic_name() {
let body = [0xAAu8, 0x01, 0x00];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command, 0xAA);
// Known-name slot is "Unknown" and synthetic_name carries the
// formatted string ("Unknown0xAA").
assert_eq!(msg.command_name, "Unknown");
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xAA"));
}
#[test]
fn advise_supervisory_carries_correlation_id_when_long_enough() {
// 0x1f + version 1 + 16-byte GUID + a couple of stuffer bytes.
let mut body = vec![0x1fu8, 0x01, 0x00];
let guid = [
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
body.extend_from_slice(&guid);
body.extend_from_slice(&[0xDE, 0xAD]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "AdviseSupervisory");
assert_eq!(msg.item_correlation_id, Some(guid));
}
#[test]
fn unadvise_carries_correlation_id_when_long_enough() {
let mut body = vec![0x21u8, 0x01, 0x00];
let guid = [0x42u8; 16];
body.extend_from_slice(&guid);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "UnAdvise");
assert_eq!(msg.item_correlation_id, Some(guid));
}
#[test]
fn correlation_id_only_for_advise_or_unadvise_opcodes() {
// Q7 audit: the conditional read is opcode-gated. Even with 19+
// bytes available, opcodes other than 0x1f / 0x21 do NOT extract
// the GUID slot.
let mut body = vec![0x37u8, 0x01, 0x00];
body.extend_from_slice(&[0xFFu8; 16]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.item_correlation_id, None);
}
#[test]
fn correlation_id_omitted_when_buffer_too_short() {
// Q7 audit: even 0x1f / 0x21 don't get a GUID if the buffer is < 19.
let body = [0x1fu8, 0x01, 0x00, 0x42];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.command_name, "AdviseSupervisory");
assert_eq!(msg.item_correlation_id, None);
}
// ---- UTF-16 string scanner ------------------------------------------
#[test]
fn extract_strings_finds_simple_run() {
// "Hello" UTF-16LE + 00 00 terminator, embedded in a larger body.
let mut body = vec![0u8; 8];
let utf16 = "Hello".encode_utf16().collect::<Vec<_>>();
for u in &utf16 {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00]);
body.extend_from_slice(&[0u8; 4]);
// Prefix the body with cmd+version so we can call parse().
let mut full = vec![0x17u8, 0x01, 0x00];
full.extend_from_slice(&body);
let msg = NmxObservedMessage::parse(&full).unwrap();
let found: Vec<_> = msg.strings.iter().map(|s| s.value.as_str()).collect();
assert!(
found.contains(&"Hello"),
"did not find 'Hello' in {found:?}"
);
}
#[test]
fn extract_strings_skips_short_runs() {
// "ab\0\0" — only 2 chars, below the 3-char minimum.
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8];
let utf16 = "ab".encode_utf16().collect::<Vec<_>>();
for u in &utf16 {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00, 0u8, 0u8]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert!(msg.strings.is_empty());
}
#[test]
fn extract_strings_ignores_non_printable() {
// A byte sequence that looks UTF-16-ish but contains a control
// character (0x07) — must NOT be reported as a string.
let mut body = vec![0x17u8, 0x01, 0x00];
body.extend_from_slice(&[0x41, 0x00, 0x07, 0x00, 0x42, 0x00, 0x00, 0x00]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert!(msg.strings.is_empty());
}
#[test]
fn extract_strings_reports_offset_relative_to_body() {
// Two trailing strings; verify the second's offset is correct.
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8, 0u8];
let prefix_len = body.len();
for u in "abcdef".encode_utf16() {
body.extend_from_slice(&u.to_le_bytes());
}
body.extend_from_slice(&[0x00, 0x00]);
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.strings.len(), 1);
assert_eq!(msg.strings[0].value, "abcdef");
assert_eq!(msg.strings[0].offset, prefix_len);
}
// ---- Round-trip preservation across malformed bodies ----------------
#[test]
fn malformed_body_does_not_panic() {
// A body of all 0xFF bytes is structurally invalid for any opcode
// but parse() must not panic.
let body = [0xFFu8; 64];
let msg = NmxObservedMessage::parse(&body).unwrap();
// 0xFF is unknown; synthetic name should reflect that.
assert_eq!(msg.command, 0xFF);
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xFF"));
}
#[test]
fn version_bytes_are_split_major_minor() {
// body[1] = major, body[2] = minor, regardless of endianness.
let body = [0x37u8, 0xAB, 0xCD];
let msg = NmxObservedMessage::parse(&body).unwrap();
assert_eq!(msg.version_major, 0xAB);
assert_eq!(msg.version_minor, 0xCD);
}
}
@@ -0,0 +1,904 @@
//! `ObservedWriteBodyTemplate` — observed-Write body round-trip preserver.
//!
//! Direct port of `src/MxNativeCodec/ObservedWriteBodyTemplate.cs`.
//!
//! ## What this is for
//!
//! The template path takes a *captured* Write body (real bytes from
//! `captures/0NN-frida-write-*`) and replays it with only the value slot
//! replaced. Every other byte — the prefix, the `cmd + version + handle
//! projection + wire_kind` header, the trailing suffix (clientToken,
//! writeIndex, the `-1 i16` discriminator, any padding) — is preserved
//! **verbatim**. This is one of the cornerstones of CLAUDE.md's "preserve
//! unknown bytes" rule (project root `CLAUDE.md`): some flows depend on
//! byte-for-byte parity with native MXAccess, and the captured suffix
//! contains bytes whose meaning is unproven.
//!
//! ## Layout assumptions (from `ObservedWriteBodyTemplate.cs:9-49`)
//!
//! Three offset constants from the .NET source:
//!
//! - `FixedValueOffset = 18` (`.cs:9`) — value slot for scalar types
//! (Boolean / Int32 / Float32 / Float64).
//! - `VariableValueOffset = 26` (`.cs:10`) — value slot for variable types
//! (String / DateTime), preceded by 8 bytes of length headers at offsets
//! 18..22 (outer_length) and 22..26 (inner_length).
//! - `ArrayValueOffset = 28` (`.cs:11`) — value slot for arrays, preceded by
//! 10 bytes (zeros at 18..22, count u16 at 22, element_width u16 at 24,
//! zeros at 26..28).
//!
//! The trailing suffix length is then implied by the captured body size:
//!
//! - **Fixed:** suffix starts at `FixedValueOffset + valueWidth` and runs
//! to `body.Length - sizeof(int)`. The trailing 4 bytes are the writeIndex.
//! `(.cs:96-109)`.
//! - **Variable:** suffix starts at `VariableValueOffset + valueByteLength`
//! where `valueByteLength = body.Slice(22, 4)` (read **unconditionally**).
//! Trailing writeIndex still 4 bytes. `(.cs:111-130)`.
//! - **Array:** suffix is exactly the **last 18 bytes**, of which the first
//! 14 are stored in `_suffixBeforeWriteIndex` and the last 4 are the
//! writeIndex. `(.cs:132-144)`.
//!
//! ## Round-trip preservation
//!
//! `Encode` (`.cs:51-64`) writes:
//!
//! 1. The captured prefix (`_prefix`, raw bytes) — preserved verbatim.
//! 2. The freshly-encoded value bytes from [`encode_value`].
//! 3. The captured suffix (`_suffixBeforeWriteIndex`) — preserved verbatim.
//! 4. The fresh `writeIndex` as i32 LE in the trailing 4 bytes.
//!
//! Then it calls `PatchVariableLengths` and `PatchArrayDescriptor` to keep
//! the embedded length fields consistent with the new value bytes
//! (`.cs:378-411`).
//!
//! ## hasDetailStatus audit (Q7 follow-up)
//!
//! `ObservedWriteBodyTemplate.cs` does not take any `has_*` boolean
//! parameter. `CreateVariable` reads `body.Slice(22, 4)` unconditionally
//! (`.cs:118`); `CreateArray` reads `body.Slice(22, 2)` unconditionally
//! (via the count in DecodeBooleanArray etc., `.cs:198, 221, 251, 281, 337`);
//! `Decode*` functions read fixed-offset fields unconditionally per kind.
//! No conditional read patterns to mirror. **Audit: clean.**
//!
//! ## Public-API differences from .NET
//!
//! - The .NET `Decode` returns `object` and accepts the body to decode at
//! call time. The Rust port exposes [`ObservedWriteBodyTemplate::with_value`]
//! that returns a fresh body with the value replaced (the single most
//! common use case in probes / replay), plus `with_int32`, `with_boolean`,
//! etc. helpers.
//! - The .NET `Encode` takes a boxed `object` value; the Rust port takes a
//! typed [`crate::MxValue`] (no runtime conversion).
//! - The Rust port requires the captured kind to match the kind of the
//! replacement value — preventing accidentally replacing an Int32 with a
//! Float32 (which would corrupt the suffix offsets).
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
use crate::{MxValue, MxValueKind};
/// Value-slot offset for fixed-width scalars (`ObservedWriteBodyTemplate.cs:9`).
pub const FIXED_VALUE_OFFSET: usize = 18;
/// Value-slot offset for variable-length scalars
/// (`ObservedWriteBodyTemplate.cs:10`).
pub const VARIABLE_VALUE_OFFSET: usize = 26;
/// Value-slot offset for arrays (`ObservedWriteBodyTemplate.cs:11`).
pub const ARRAY_VALUE_OFFSET: usize = 28;
/// Round-trip preserver for a captured Write body (`0x37`) or
/// SecuredWrite2 body (`0x38`).
///
/// Stores three pieces:
///
/// - The captured *prefix* up to but not including the value slot.
/// - The captured *suffix* starting just after the value slot, up to but
/// not including the trailing 4-byte writeIndex.
/// - The captured kind (so we can later patch length fields correctly).
///
/// The prefix and suffix carry every byte unchanged; the value slot is
/// rewritten on each [`Self::with_value`] call, and the trailing writeIndex
/// is rewritten with whatever the caller passes to encode.
#[derive(Debug, Clone)]
pub struct ObservedWriteBodyTemplate {
kind: MxValueKind,
/// Captured opcode at body[0]. Either `0x37` (Write) or `0x38`
/// (SecuredWrite2) — both share the value-slot layout per the .cs
/// constants. Stored separately for [`Self::command`] without re-reading
/// `prefix`.
command: u8,
prefix: Vec<u8>,
suffix_before_write_index: Vec<u8>,
}
impl ObservedWriteBodyTemplate {
/// Capture a Write body and split it into prefix / suffix around the
/// value slot.
///
/// Mirrors `FromObserved` (`ObservedWriteBodyTemplate.cs:26-49`). The
/// caller declares the `kind` because the captured body alone does not
/// disambiguate `0x05` String vs DateTime or `0x45` StringArray vs
/// DateTimeArray (the encoder collapses them — see
/// `crate::write_message` module doc).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `observed_body.len() < 24`
/// (`.cs:28-31`).
/// - [`CodecError::Decode`] if the kind is unsupported, or if the body
/// is too short for its declared kind, or if a variable-length body
/// has invalid embedded lengths.
pub fn from_observed(kind: MxValueKind, observed_body: &[u8]) -> Result<Self, CodecError> {
// `.cs:28-31` — minimum length 24 bytes regardless of kind.
if observed_body.len() < 24 {
return Err(CodecError::ShortRead {
expected: 24,
actual: observed_body.len(),
});
}
match kind {
// `.cs:35-38` — fixed-width scalars.
MxValueKind::Boolean | MxValueKind::Int32 | MxValueKind::Float32 => {
Self::create_fixed(kind, observed_body, 4)
}
MxValueKind::Float64 => Self::create_fixed(kind, observed_body, 8),
// `.cs:39-40` — variable-width scalars.
MxValueKind::String | MxValueKind::DateTime => {
Self::create_variable(kind, observed_body)
}
// `.cs:41-46` — arrays.
MxValueKind::BoolArray
| MxValueKind::Int32Array
| MxValueKind::Float32Array
| MxValueKind::Float64Array
| MxValueKind::StringArray
| MxValueKind::DateTimeArray => Self::create_array(kind, observed_body),
// `.cs:47` — anything else throws.
MxValueKind::ElapsedTime | MxValueKind::Unknown => Err(CodecError::Decode {
offset: 17,
reason: "observed-write template: unsupported value kind",
buffer_len: observed_body.len(),
}),
}
}
/// Captured opcode at body[0]. Mirrors `_prefix[0]`.
pub fn command(&self) -> u8 {
self.command
}
/// Captured wire-kind byte at body[17]. Drawn from the captured prefix,
/// not from the runtime [`MxValueKind`] (which can disambiguate
/// String vs DateTime past the encoder collapse).
pub fn wire_kind(&self) -> u8 {
// body[17] sits inside the prefix for all three families.
self.prefix[17]
}
/// The kind this template was captured against.
pub fn kind(&self) -> MxValueKind {
self.kind
}
/// Borrow the captured prefix bytes (everything before the value slot).
pub fn prefix(&self) -> &[u8] {
&self.prefix
}
/// Borrow the captured suffix bytes (between the value slot and the
/// trailing 4-byte writeIndex).
pub fn suffix_before_write_index(&self) -> &[u8] {
&self.suffix_before_write_index
}
/// Emit a Write body with the value slot replaced.
///
/// Mirrors `Encode` (`ObservedWriteBodyTemplate.cs:51-64`):
/// 1. Allocate `prefix.len() + value_bytes.len() + suffix.len() + 4`.
/// 2. Copy prefix verbatim.
/// 3. Copy fresh value bytes.
/// 4. Copy suffix verbatim.
/// 5. Write i32 LE writeIndex into the trailing 4 bytes.
/// 6. Patch embedded length fields (variable / array).
///
/// # Errors
///
/// - [`CodecError::Decode`] if `value.kind()` doesn't match the
/// captured kind. The .NET reference does not check this — it boxes
/// any `object` and lets the per-kind encoder throw — but mismatched
/// replacement would silently corrupt the suffix offsets, so the Rust
/// port enforces it.
pub fn with_value(&self, value: &MxValue, write_index: i32) -> Result<Vec<u8>, CodecError> {
// Strict kind check (Rust-port tightening; see module doc).
// The encoder collapses StringArray and DateTimeArray onto the same
// wire kind, so accept that pair as compatible.
let val_kind = value.kind();
let kinds_match = val_kind == self.kind
|| (val_kind == MxValueKind::StringArray && self.kind == MxValueKind::DateTimeArray)
|| (val_kind == MxValueKind::DateTimeArray && self.kind == MxValueKind::StringArray)
|| (val_kind == MxValueKind::String && self.kind == MxValueKind::DateTime)
|| (val_kind == MxValueKind::DateTime && self.kind == MxValueKind::String);
if !kinds_match {
return Err(CodecError::Decode {
offset: 17,
reason: "observed-write template: replacement value kind does not match captured kind",
buffer_len: 0,
});
}
let value_bytes = encode_value_bytes(value, self.kind)?;
let body_len =
self.prefix.len() + value_bytes.len() + self.suffix_before_write_index.len() + 4;
let mut body = vec![0u8; body_len];
body[..self.prefix.len()].copy_from_slice(&self.prefix);
let value_start = self.prefix.len();
body[value_start..value_start + value_bytes.len()].copy_from_slice(&value_bytes);
let suffix_start = value_start + value_bytes.len();
body[suffix_start..suffix_start + self.suffix_before_write_index.len()]
.copy_from_slice(&self.suffix_before_write_index);
write_i32_le(&mut body, body_len - 4, write_index);
self.patch_variable_lengths(&mut body, value_bytes.len());
self.patch_array_descriptor(&mut body, value_bytes.len());
Ok(body)
}
/// Convenience: replace the value with an i32. Errors if the captured
/// kind isn't [`MxValueKind::Int32`].
pub fn with_int32(&self, value: i32, write_index: i32) -> Result<Vec<u8>, CodecError> {
self.with_value(&MxValue::Int32(value), write_index)
}
/// Convenience: replace the value with a bool. Errors if the captured
/// kind isn't [`MxValueKind::Boolean`].
pub fn with_boolean(&self, value: bool, write_index: i32) -> Result<Vec<u8>, CodecError> {
self.with_value(&MxValue::Boolean(value), write_index)
}
/// Convenience: replace the value with an f32.
pub fn with_float32(&self, value: f32, write_index: i32) -> Result<Vec<u8>, CodecError> {
self.with_value(&MxValue::Float32(value), write_index)
}
/// Convenience: replace the value with an f64.
pub fn with_float64(&self, value: f64, write_index: i32) -> Result<Vec<u8>, CodecError> {
self.with_value(&MxValue::Float64(value), write_index)
}
/// Convenience: replace the value with a UTF-16LE string.
pub fn with_string(&self, value: &str, write_index: i32) -> Result<Vec<u8>, CodecError> {
self.with_value(&MxValue::String(value.to_string()), write_index)
}
/// Decode the trailing writeIndex from a body that was emitted by this
/// template. Mirrors `DecodeWriteIndex` (`.cs:86-94`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body` has fewer than 4 bytes.
pub fn decode_write_index(body: &[u8]) -> Result<i32, CodecError> {
if body.len() < 4 {
return Err(CodecError::ShortRead {
expected: 4,
actual: body.len(),
});
}
Ok(read_i32_le(body, body.len() - 4))
}
// ---- Private constructors --------------------------------------------
/// `CreateFixed` (`ObservedWriteBodyTemplate.cs:96-109`).
fn create_fixed(
kind: MxValueKind,
body: &[u8],
value_width: usize,
) -> Result<Self, CodecError> {
let suffix_start = FIXED_VALUE_OFFSET + value_width;
// `.cs:99-103` — suffix length must be non-negative.
if body.len() < suffix_start + 4 {
return Err(CodecError::ShortRead {
expected: suffix_start + 4,
actual: body.len(),
});
}
let suffix_length = body.len() - suffix_start - 4;
Ok(Self {
kind,
command: body[0],
prefix: body[..FIXED_VALUE_OFFSET].to_vec(),
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
})
}
/// `CreateVariable` (`ObservedWriteBodyTemplate.cs:111-130`). Reads the
/// inner length at offset 22 **unconditionally** — there is no
/// "has X" boolean here, mirroring the .NET source.
fn create_variable(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
// `.cs:113-116` — minimum length check.
if body.len() < VARIABLE_VALUE_OFFSET + 4 {
return Err(CodecError::ShortRead {
expected: VARIABLE_VALUE_OFFSET + 4,
actual: body.len(),
});
}
// `.cs:118` — value byte length read at offset 22 unconditionally.
let value_byte_length = read_i32_le(body, 22);
if value_byte_length < 2 {
return Err(CodecError::Decode {
offset: 22,
reason: "observed-write template: variable value_byte_length < 2",
buffer_len: body.len(),
});
}
let value_byte_length = value_byte_length as usize;
let suffix_start = VARIABLE_VALUE_OFFSET + value_byte_length;
if body.len() < suffix_start + 4 {
return Err(CodecError::Decode {
offset: 22,
reason: "observed-write template: variable body too short for declared lengths",
buffer_len: body.len(),
});
}
let suffix_length = body.len() - suffix_start - 4;
Ok(Self {
kind,
command: body[0],
prefix: body[..VARIABLE_VALUE_OFFSET].to_vec(),
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
})
}
/// `CreateArray` (`ObservedWriteBodyTemplate.cs:132-144`). The .NET
/// reference takes the **last 18 bytes** as the suffix
/// (`suffixStart = body.Length - 18`), of which the first 14 are stored
/// in `_suffixBeforeWriteIndex` (`.cs:143`). This loses the array
/// payload bytes between offset 28 and `body.Length - 18` — they are
/// regenerated by [`encode_value_bytes`] from the supplied value.
fn create_array(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
// `.cs:134-137` — body must hold at least 18 trailing bytes plus
// the 28-byte prefix.
if body.len() < ARRAY_VALUE_OFFSET + 18 {
return Err(CodecError::ShortRead {
expected: ARRAY_VALUE_OFFSET + 18,
actual: body.len(),
});
}
let suffix_start = body.len() - 18;
Ok(Self {
kind,
command: body[0],
// `.cs:142` — prefix is the leading 28 bytes.
prefix: body[..ARRAY_VALUE_OFFSET].to_vec(),
// `.cs:143` — suffix_before_write_index is exactly 14 bytes
// (the trailing 18 minus the 4-byte writeIndex slot).
suffix_before_write_index: body[suffix_start..suffix_start + 14].to_vec(),
})
}
// ---- Length / descriptor patches -------------------------------------
/// `PatchVariableLengths` (`ObservedWriteBodyTemplate.cs:378-388`).
/// Updates the embedded outer/inner length fields at offsets 18 and 22
/// for variable-width kinds. No-op for everything else.
fn patch_variable_lengths(&self, body: &mut [u8], value_byte_length: usize) {
if !matches!(self.kind, MxValueKind::String | MxValueKind::DateTime) {
return;
}
// `.cs:385` — body[18..22] = value_byte_length + 4.
write_i32_le(body, 18, value_byte_length as i32 + 4);
// `.cs:386` — body[22..26] = value_byte_length.
write_i32_le(body, 22, value_byte_length as i32);
}
/// `PatchArrayDescriptor` (`ObservedWriteBodyTemplate.cs:390-411`).
/// Updates the array element count u16 at offset 22 for fixed-element
/// arrays. No-op for variable-element arrays (StringArray /
/// DateTimeArray) and for non-array kinds — matching the early returns
/// at `.cs:392-400`.
fn patch_array_descriptor(&self, body: &mut [u8], value_byte_length: usize) {
let element_size = match self.kind {
MxValueKind::BoolArray => 2,
MxValueKind::Int32Array | MxValueKind::Float32Array => 4,
MxValueKind::Float64Array => 8,
// `.cs:397-400` — variable arrays return early; descriptor not patched.
_ => return,
};
let count = value_byte_length / element_size;
// `.cs:410` — body[22..24] = checked u16 count.
let count_u16: u16 = u16::try_from(count).unwrap_or(u16::MAX);
write_u16_le(body, 22, count_u16);
}
}
// ---- Value-byte encoders --------------------------------------------------
/// Encode the value bytes that go into the value slot. Mirrors `EncodeValue`
/// (`ObservedWriteBodyTemplate.cs:146-164`).
///
/// `kind` is the captured kind (used to disambiguate the encoder collapse
/// for String/DateTime and StringArray/DateTimeArray — though in this
/// template path the actual byte layout is identical for both halves of
/// each pair, so the distinction is presentational).
fn encode_value_bytes(value: &MxValue, _kind: MxValueKind) -> Result<Vec<u8>, CodecError> {
Ok(match value {
// `.cs:150` / `EncodeBoolean` (`.cs:166-171`). Literal byte
// patterns: true -> [0xff,0xff,0xff,0x00], false -> [0x00,0xff,0xff,0x00].
MxValue::Boolean(b) => encode_boolean_bytes(*b).to_vec(),
// `.cs:151` — i32 LE.
MxValue::Int32(v) => v.to_le_bytes().to_vec(),
// `.cs:152` — f32 via SingleToInt32Bits + i32 LE
// (`.cs:231-236` `EncodeFloat32`).
MxValue::Float32(v) => f32::to_bits(*v).to_le_bytes().to_vec(),
// `.cs:153` — f64 via DoubleToInt64Bits + i64 LE
// (`.cs:261-266` `EncodeFloat64`).
MxValue::Float64(v) => f64::to_bits(*v).to_le_bytes().to_vec(),
// `.cs:154` — UTF-16LE with 2-byte NUL trailer
// (`.cs:291-297` `EncodeUtf16String`).
MxValue::String(s) => encode_utf16_with_nul(s),
// `.cs:155` — DateTime is formatted with
// `"M/d/yyyy h:mm:ss tt"` and encoded as UTF-16LE+NUL. The Rust
// port carries the i64 FILETIME ticks; converting that to the
// .NET formatted string requires a calendar dependency outside
// the codec layer. The carrier expects a pre-formatted string.
MxValue::DateTime(_ticks) => {
return Err(CodecError::Decode {
offset: 0,
reason: "observed-write template: DateTime replacement requires a pre-formatted string; pass MxValue::String instead",
buffer_len: 0,
});
}
// `.cs:156` `EncodeBooleanArray` (`.cs:185-194`) — each element
// is i16 LE (-1 or 0).
MxValue::BoolArray(arr) => {
let mut bytes = Vec::with_capacity(arr.len() * 2);
for v in arr {
let i: i16 = if *v { -1 } else { 0 };
bytes.extend_from_slice(&i.to_le_bytes());
}
bytes
}
// `.cs:157` `EncodeInt32Array` (`.cs:208-217`).
MxValue::Int32Array(arr) => {
let mut bytes = Vec::with_capacity(arr.len() * 4);
for v in arr {
bytes.extend_from_slice(&v.to_le_bytes());
}
bytes
}
// `.cs:158` `EncodeFloat32Array` (`.cs:238-247`).
MxValue::Float32Array(arr) => {
let mut bytes = Vec::with_capacity(arr.len() * 4);
for v in arr {
bytes.extend_from_slice(&f32::to_bits(*v).to_le_bytes());
}
bytes
}
// `.cs:159` `EncodeFloat64Array` (`.cs:268-277`).
MxValue::Float64Array(arr) => {
let mut bytes = Vec::with_capacity(arr.len() * 8);
for v in arr {
bytes.extend_from_slice(&f64::to_bits(*v).to_le_bytes());
}
bytes
}
// `.cs:160` `EncodeVariableArray` (`.cs:317-333`).
MxValue::StringArray(arr) => encode_variable_array(arr.iter().map(String::as_str)),
// `.cs:161` — DateTimeArray formats each element. Same
// limitation as scalar DateTime; require pre-formatted strings.
MxValue::DateTimeArray(_arr) => {
return Err(CodecError::Decode {
offset: 0,
reason: "observed-write template: DateTimeArray replacement requires pre-formatted strings; pass MxValue::StringArray instead",
buffer_len: 0,
});
}
// `.cs:162` — InvalidOperationException for unsupported.
MxValue::ElapsedTime(_) => {
return Err(CodecError::Decode {
offset: 0,
reason: "observed-write template: ElapsedTime is not supported on the write side",
buffer_len: 0,
});
}
})
}
/// Boolean payload bytes — LITERALLY the same 4-byte pattern used by the
/// normal-write encoder (`.cs:166-171`,
/// matches `crate::write_message` `encode_boolean_value`).
const fn encode_boolean_bytes(value: bool) -> [u8; 4] {
if value {
[0xff, 0xff, 0xff, 0x00]
} else {
[0x00, 0xff, 0xff, 0x00]
}
}
/// UTF-16LE encoding with a trailing 2-byte NUL terminator. Mirrors
/// `EncodeUtf16String` (`ObservedWriteBodyTemplate.cs:291-297`).
fn encode_utf16_with_nul(value: &str) -> Vec<u8> {
let utf16: Vec<u16> = value.encode_utf16().collect();
let mut bytes = Vec::with_capacity(utf16.len() * 2 + 2);
for unit in &utf16 {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes.push(0x00);
bytes.push(0x00);
bytes
}
/// Variable-array payload. Mirrors `EncodeVariableArray`
/// (`ObservedWriteBodyTemplate.cs:317-333`).
fn encode_variable_array<'a, I>(values: I) -> Vec<u8>
where
I: IntoIterator<Item = &'a str>,
{
let mut bytes = Vec::new();
for value in values {
let text_bytes = encode_utf16_with_nul(value);
let mut header = [0u8; 13];
// header[0..4] = 1 + 4 + 4 + textBytes.Length (`.cs:324`).
write_i32_le(&mut header, 0, 1i32 + 4 + 4 + text_bytes.len() as i32);
// header[4] = 0x05 (`.cs:325`).
header[4] = 0x05;
// header[5..9] = textBytes.Length + 4 (`.cs:326`).
write_i32_le(&mut header, 5, text_bytes.len() as i32 + 4);
// header[9..13] = textBytes.Length (`.cs:327`).
write_i32_le(&mut header, 9, text_bytes.len() as i32);
bytes.extend_from_slice(&header);
bytes.extend_from_slice(&text_bytes);
}
bytes
}
// ---- LE primitive helpers -------------------------------------------------
#[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_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
#[inline]
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
i32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
use crate::MxReferenceHandle;
use crate::write_message::{self, WriteValue};
fn sample_handle() -> MxReferenceHandle {
MxReferenceHandle::from_names(
1,
42,
17,
300,
"TestChildObject",
-1,
7,
0,
"TestInt",
false,
)
.unwrap()
}
fn observed_int32_body(value: i32, write_index: i32, client_token: u32) -> Vec<u8> {
write_message::encode(
&sample_handle(),
&WriteValue::Int32(value),
write_index,
client_token,
)
.unwrap()
}
fn observed_boolean_body(value: bool, write_index: i32, client_token: u32) -> Vec<u8> {
write_message::encode(
&sample_handle(),
&WriteValue::Boolean(value),
write_index,
client_token,
)
.unwrap()
}
fn observed_string_body(value: &str, write_index: i32, client_token: u32) -> Vec<u8> {
write_message::encode(
&sample_handle(),
&WriteValue::String(value.to_string()),
write_index,
client_token,
)
.unwrap()
}
fn observed_int32_array_body(values: &[i32], write_index: i32, client_token: u32) -> Vec<u8> {
write_message::encode(
&sample_handle(),
&WriteValue::Int32Array(values.to_vec()),
write_index,
client_token,
)
.unwrap()
}
// ---- Constants -------------------------------------------------------
#[test]
fn offset_constants_match_dotnet() {
// `ObservedWriteBodyTemplate.cs:9-11`.
assert_eq!(FIXED_VALUE_OFFSET, 18);
assert_eq!(VARIABLE_VALUE_OFFSET, 26);
assert_eq!(ARRAY_VALUE_OFFSET, 28);
}
// ---- Round-trip identity --------------------------------------------
#[test]
fn fixed_round_trip_preserves_bytes_when_value_unchanged() {
// Capture an Int32(123) body, replay with the same value — every
// byte should match.
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
let replayed = template.with_int32(123, 7).unwrap();
assert_eq!(replayed, original);
}
#[test]
fn variable_round_trip_preserves_bytes_when_value_unchanged() {
let original = observed_string_body("hello", 3, 0x12345678);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
let replayed = template.with_string("hello", 3).unwrap();
assert_eq!(replayed, original);
}
#[test]
fn array_round_trip_preserves_suffix_when_value_unchanged() {
let original = observed_int32_array_body(&[1, 2, 3], 1, 0xABCD);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
let replayed = template
.with_value(&MxValue::Int32Array(vec![1, 2, 3]), 1)
.unwrap();
// The 14-byte suffix (last 18 minus writeIndex) must match.
let original_suffix_start = original.len() - 18;
let replayed_suffix_start = replayed.len() - 18;
assert_eq!(
&original[original_suffix_start..original_suffix_start + 14],
&replayed[replayed_suffix_start..replayed_suffix_start + 14]
);
// Trailing writeIndex field too.
assert_eq!(
&original[original.len() - 4..],
&replayed[replayed.len() - 4..]
);
}
// ---- Selective replacement -------------------------------------------
#[test]
fn replace_int32_only_changes_value_slot() {
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
let replaced = template.with_int32(456, 7).unwrap();
assert_eq!(replaced.len(), original.len());
// Everything except body[18..22] should match.
for (i, (&a, &b)) in original.iter().zip(replaced.iter()).enumerate() {
if (FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4).contains(&i) {
continue;
}
assert_eq!(a, b, "byte at offset {i} should be preserved");
}
// The value slot reflects 456.
assert_eq!(
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
&456i32.to_le_bytes()
);
}
#[test]
fn replace_boolean_uses_literal_4byte_pattern() {
// Boolean payload uses the literal 4-byte pattern, not a single byte.
let original = observed_boolean_body(true, 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Boolean, &original).unwrap();
let replaced = template.with_boolean(false, 1).unwrap();
assert_eq!(
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
&[0x00, 0xff, 0xff, 0x00]
);
// Re-replacing with true gives the original.
let back = template.with_boolean(true, 1).unwrap();
assert_eq!(
&back[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
&[0xff, 0xff, 0xff, 0x00]
);
assert_eq!(back, original);
}
#[test]
fn replace_string_with_different_length_grows_body_and_patches_lengths() {
// The .cs path supports same-length AND different-length string
// replacements: PatchVariableLengths rewrites both length fields
// (`.cs:378-388`).
let original = observed_string_body("hi", 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
let replaced = template.with_string("hello world", 1).unwrap();
// New length must reflect the new payload (UTF-16 + NUL = 24 bytes).
let utf16_len = "hello world".encode_utf16().count() * 2 + 2;
let outer = read_i32_le(&replaced, 18);
let inner = read_i32_le(&replaced, 22);
assert_eq!(inner, utf16_len as i32);
assert_eq!(outer, utf16_len as i32 + 4);
// The captured suffix (everything after the value slot) must
// still appear verbatim.
let suffix_start = VARIABLE_VALUE_OFFSET + utf16_len;
let suffix_len = template.suffix_before_write_index().len();
assert_eq!(
&replaced[suffix_start..suffix_start + suffix_len],
template.suffix_before_write_index()
);
}
// ---- Suffix preservation ---------------------------------------------
#[test]
fn suffix_clienttoken_and_writeindex_preserved_across_with_value_calls() {
let original = observed_int32_body(123, 0xAA, 0xDEADBEEF);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
// Replace value but keep writeIndex; the captured clientToken
// (in the suffix) must round-trip.
let replaced = template.with_int32(999, 0xAA).unwrap();
// For Int32 normal write, the suffix layout is:
// body[22..24] = -1 i16
// body[24..32] = 8-byte filler
// body[32..36] = clientToken u32
// body[36..40] = writeIndex i32
let client_token =
u32::from_le_bytes([replaced[32], replaced[33], replaced[34], replaced[35]]);
assert_eq!(client_token, 0xDEADBEEF);
let write_index =
i32::from_le_bytes([replaced[36], replaced[37], replaced[38], replaced[39]]);
assert_eq!(write_index, 0xAA);
}
#[test]
fn suffix_minus_one_marker_preserved() {
// The leading i16 of the normal suffix is -1 (0xFFFF); it lives in
// the captured suffix bytes, so any with_value call should preserve it.
let original = observed_int32_body(0, 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
let replaced = template.with_int32(0xFEED, 99).unwrap();
let leading = i16::from_le_bytes([replaced[22], replaced[23]]);
assert_eq!(leading, -1);
}
#[test]
fn write_index_decoder() {
let original = observed_int32_body(0, 0xABCDEF, 0);
let decoded = ObservedWriteBodyTemplate::decode_write_index(&original).unwrap();
assert_eq!(decoded, 0xABCDEF);
}
// ---- Array kind: count patch and suffix -----------------------------
#[test]
fn array_descriptor_count_patched_on_replacement() {
// Original: 3 Int32 elements -> body[22..24] count = 3.
let original = observed_int32_array_body(&[10, 20, 30], 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
// Replace with a 5-element array; descriptor count u16 must be 5.
let replaced = template
.with_value(&MxValue::Int32Array(vec![1, 2, 3, 4, 5]), 1)
.unwrap();
let count = u16::from_le_bytes([replaced[22], replaced[23]]);
assert_eq!(count, 5);
}
#[test]
fn array_string_descriptor_not_patched_on_replacement() {
// StringArray / DateTimeArray skip PatchArrayDescriptor (.cs:397-400).
let original = write_message::encode(
&sample_handle(),
&WriteValue::StringArray(vec!["a".into(), "b".into()]),
1,
0,
)
.unwrap();
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::StringArray, &original).unwrap();
let count_before = u16::from_le_bytes([original[22], original[23]]);
let replaced = template
.with_value(
&MxValue::StringArray(vec!["xx".into(), "yy".into(), "zz".into()]),
1,
)
.unwrap();
let count_after = u16::from_le_bytes([replaced[22], replaced[23]]);
// Per .NET behaviour, the count u16 is NOT patched for variable arrays.
assert_eq!(count_after, count_before);
}
// ---- Error paths -----------------------------------------------------
#[test]
fn from_observed_rejects_short_buffer() {
let err =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &[0u8; 23]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn from_observed_rejects_unsupported_kind() {
let original = observed_int32_body(0, 1, 0);
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::ElapsedTime, &original)
.unwrap_err();
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn from_observed_rejects_short_array_body() {
// 28 + 18 - 1 = 45 bytes (one byte short).
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &[0u8; 45])
.unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn from_observed_rejects_invalid_variable_lengths() {
// Build a 30-byte body where body[22..26] declares a value length of 0.
let mut buf = vec![0u8; 30];
write_i32_le(&mut buf, 22, 0);
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &buf).unwrap_err();
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn with_value_rejects_kind_mismatch() {
let original = observed_int32_body(0, 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
let err = template.with_value(&MxValue::Float32(1.0), 1).unwrap_err();
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn command_and_wire_kind_accessors() {
let original = observed_int32_body(123, 1, 0);
let template =
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
// Normal Write opcode is 0x37; Int32 wire kind is 0x02.
assert_eq!(template.command(), 0x37);
assert_eq!(template.wire_kind(), 0x02);
}
}
@@ -0,0 +1,284 @@
//! `NmxOperationStatusMessage` — completion / status-word frames.
//!
//! Direct port of `src/MxNativeCodec/NmxOperationStatusMessage.cs`.
//!
//! Two on-the-wire shapes are recognised by the inner-body parser:
//!
//! 1. **5-byte status-word frame** — `00 00 SS SS CC` where `SS SS` is a u16
//! LE status code and `CC` is a completion code. The single proven mapping
//! is `00 00 50 80 00` → [`MxStatus::WRITE_COMPLETE_OK`]
//! (`NmxOperationStatusMessage.cs:48-62`,
//! `design/40-protocol-invariants.md:346`).
//! 2. **1-byte completion-only frame** — a single byte `CC`. Three values are
//! observed in the wild (`0x00`, `0x41`, `0xEF`) but the byte→status
//! mapping is unproven; they are preserved verbatim per
//! `design/70-risks-and-open-questions.md` R3/R4 and
//! `NmxOperationStatusMessage.cs:36-46,69-76`.
//!
//! The .NET reference also exposes `TryParseProcessDataReceivedBody`, which
//! peels an outer `NmxObservedEnvelope` before delegating to the inner-body
//! parser. The outer envelope codec has not yet been ported to Rust; only
//! [`NmxOperationStatusMessage::try_parse_inner`] is provided here. When
//! `NmxObservedEnvelope` lands, add `try_parse_process_data_received_body` as
//! a thin wrapper.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
use crate::status::{MxStatus, MxStatusCategory, MxStatusSource};
/// Which of the two recognised inner-frame shapes was decoded
/// (`NmxOperationStatusMessage.cs:3-7`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NmxOperationStatusFormat {
/// Single-byte completion frame (`NmxOperationStatusMessage.cs:5,36-46`).
CompletionOnly,
/// 5-byte `00 00 SS SS CC` status-word frame
/// (`NmxOperationStatusMessage.cs:6,48-62`).
StatusWord,
}
/// Decoded operation-status frame
/// (`NmxOperationStatusMessage.cs:9-15` — record fields).
///
/// The four payload fields preserve the raw on-wire bytes; [`Self::status`]
/// carries the promoted [`MxStatus`] when a known mapping exists, or an
/// unpromoted placeholder otherwise (see `CompletionOnly` and the fallback
/// branch of `StatusWord`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NmxOperationStatusMessage {
/// Which inner frame shape was observed.
pub format: NmxOperationStatusFormat,
/// First byte of the 5-byte frame (`NmxOperationStatusMessage.cs:54`).
/// `0` for `CompletionOnly` frames.
pub command: u8,
/// `inner[2..4]` u16 LE for `StatusWord` (`NmxOperationStatusMessage.cs:50`).
/// `0` for `CompletionOnly` frames.
pub status_code: u16,
/// Completion byte. `inner[0]` for `CompletionOnly`
/// (`NmxOperationStatusMessage.cs:38`); `inner[4]` for `StatusWord`
/// (`NmxOperationStatusMessage.cs:51`).
pub completion_code: u8,
/// Promoted status. The only proven promotion is
/// `status_code == 0x8050 && completion_code == 0x00 → WRITE_COMPLETE_OK`
/// (`NmxOperationStatusMessage.cs:57`,
/// `design/40-protocol-invariants.md:346`). Every other shape is wrapped
/// in an `Unknown`/`Unknown` placeholder with the raw byte preserved in
/// `detail`.
pub status: MxStatus,
}
impl NmxOperationStatusMessage {
/// `true` for the proven `00 00 50 80 00` frame
/// (`NmxOperationStatusMessage.cs:16-18`).
pub fn is_mx_access_write_complete(&self) -> bool {
self.format == NmxOperationStatusFormat::StatusWord
&& self.status_code == 0x8050
&& self.completion_code == 0x00
}
/// Parse an inner body — either 1 byte (`CompletionOnly`) or 5 bytes
/// (`StatusWord` with leading `00 00`).
///
/// Mirrors `NmxOperationStatusMessage.TryParseInner`
/// (`NmxOperationStatusMessage.cs:34-67`).
///
/// # Errors
///
/// Returns [`CodecError::ShortRead`] when the buffer length matches no
/// recognised shape. The .NET reference returns `false` and a `null!`
/// out-param; the Rust port surfaces the failure as a typed error so
/// callers can distinguish "not an operation-status frame" from
/// "successfully parsed". Match on the error to mirror the bool API.
pub fn try_parse_inner(inner: &[u8]) -> Result<Self, CodecError> {
if inner.len() == 1 {
// CompletionOnly — `NmxOperationStatusMessage.cs:36-46`.
let completion_code = inner[0];
return Ok(Self {
format: NmxOperationStatusFormat::CompletionOnly,
command: 0,
status_code: 0,
completion_code,
status: create_unpromoted_completion_status(completion_code),
});
}
if inner.len() == 5 && inner[0] == 0x00 && inner[1] == 0x00 {
// StatusWord — `NmxOperationStatusMessage.cs:48-62`.
let status_code = u16::from(inner[2]) | (u16::from(inner[3]) << 8);
let completion_code = inner[4];
// Only the (0x8050, 0x00) shape is promoted to a typed status.
// Every other (status_code, completion_code) pair is preserved as
// an Unknown/Unknown placeholder with the raw byte in `detail`,
// mirroring `NmxOperationStatusMessage.cs:57-61`.
//
// The .NET fallback packs `detail` as:
// completion_code == 0x00 ? (short)status_code : completion_code
// We replicate the same selection here, including the
// `unchecked((short)statusCode)` reinterpretation (i.e. the u16's
// bit pattern as i16).
let status = if status_code == 0x8050 && completion_code == 0x00 {
MxStatus::WRITE_COMPLETE_OK
} else {
let detail = if completion_code == 0x00 {
// Reinterpret the u16 status_code as i16 (two's complement).
status_code as i16
} else {
i16::from(completion_code)
};
MxStatus {
success: 0,
category: MxStatusCategory::Unknown,
detected_by: MxStatusSource::Unknown,
detail,
}
};
return Ok(Self {
format: NmxOperationStatusFormat::StatusWord,
command: inner[0],
status_code,
completion_code,
status,
});
}
Err(CodecError::ShortRead {
// 1 or 5 are the two valid lengths; report the smaller for the
// diagnostic. Callers that need the strict bool API should pattern
// match on `Err(_) => false`.
expected: 1,
actual: inner.len(),
})
}
}
/// Build the unpromoted placeholder status used by `CompletionOnly` frames
/// (`NmxOperationStatusMessage.cs:69-76`).
fn create_unpromoted_completion_status(completion_code: u8) -> MxStatus {
MxStatus {
success: 0,
category: MxStatusCategory::Unknown,
detected_by: MxStatusSource::Unknown,
detail: i16::from(completion_code),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn write_complete_ok_frame() {
// The proven 5-byte mapping (`design/40-protocol-invariants.md:346`).
let frame = [0x00, 0x00, 0x50, 0x80, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
assert_eq!(msg.command, 0x00);
assert_eq!(msg.status_code, 0x8050);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status, MxStatus::WRITE_COMPLETE_OK);
assert!(msg.is_mx_access_write_complete());
}
#[test]
fn status_word_unknown_with_completion_zero_packs_status_code_as_i16() {
// status_code 0x8051, completion 0x00 — not the proven mapping; falls
// through to the unpromoted branch with detail = (i16)0x8051 = -32687.
// Mirrors `NmxOperationStatusMessage.cs:61` (`unchecked((short)statusCode)`).
let frame = [0x00, 0x00, 0x51, 0x80, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
assert_eq!(msg.status_code, 0x8051);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
assert_eq!(msg.status.detected_by, MxStatusSource::Unknown);
assert_eq!(msg.status.detail, 0x8051u16 as i16);
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn status_word_unknown_with_nonzero_completion_packs_completion_in_detail() {
// completion_code != 0 — detail = completion_code as i16
// (`NmxOperationStatusMessage.cs:61`).
let frame = [0x00, 0x00, 0x50, 0x80, 0x42];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.completion_code, 0x42);
assert_eq!(msg.status.detail, 0x42);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn completion_only_zero_byte() {
// 1-byte 0x00 — preserved verbatim per design R3/R4.
let frame = [0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.command, 0);
assert_eq!(msg.status_code, 0);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status.detail, 0x00);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
// `CompletionOnly` is never promoted to WriteCompleteOk.
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn completion_only_0x41_byte() {
// 1-byte 0x41 — observed in the wild, mapping unproven
// (`design/70-risks-and-open-questions.md` R4).
let frame = [0x41];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.completion_code, 0x41);
assert_eq!(msg.status.detail, 0x41);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
}
#[test]
fn completion_only_0xef_byte() {
// 1-byte 0xEF — observed in the wild, mapping unproven
// (`design/70-risks-and-open-questions.md` R4).
let frame = [0xEF];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.completion_code, 0xEF);
// 0xEF as i16 is 0x00EF (zero-extended), not -17.
assert_eq!(msg.status.detail, 0xEF);
}
#[test]
fn rejects_unknown_length() {
// 0 / 2 / 3 / 4 / 6 bytes — all non-recognised shapes.
for len in [0_usize, 2, 3, 4, 6, 16] {
let buf = vec![0u8; len];
assert!(
NmxOperationStatusMessage::try_parse_inner(&buf).is_err(),
"length {len} should be rejected"
);
}
}
#[test]
fn rejects_5_byte_frame_without_leading_zeros() {
// 5 bytes with non-zero leading bytes — not a StatusWord frame
// (`NmxOperationStatusMessage.cs:48` requires `inner[0] == 0 && inner[1] == 0`).
let frame = [0x01, 0x00, 0x50, 0x80, 0x00];
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
let frame = [0x00, 0x01, 0x50, 0x80, 0x00];
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
}
#[test]
fn status_code_is_little_endian() {
// `inner[2..4]` is read as u16 LE — `inner[2] | (inner[3] << 8)`.
// 0xAA at [2], 0xBB at [3] → 0xBBAA.
let frame = [0x00, 0x00, 0xAA, 0xBB, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.status_code, 0xBBAA);
}
}
@@ -0,0 +1,422 @@
//! `MxReferenceHandle` — 20-byte reference handle.
//!
//! Direct port of `src/MxNativeCodec/MxReferenceHandle.cs`. CRC-16/IBM
//! (poly `0xa001`, initial `0`) computed over lowercase UTF-16LE name bytes
//! (low byte then high byte per char), per `MxReferenceHandle.cs:51,47-59`.
// Direct byte indexing is the right pattern for fixed-layout codec code:
// every byte access is preceded by an explicit length check, and the resulting
// code reads as a 1:1 mirror of the .NET source's `BinaryPrimitives` calls.
// `.get(n)?` would obscure the byte map.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
const CRC16_IBM_POLYNOMIAL: u16 = 0xa001;
/// 20-byte reference handle. Encoded layout matches the .NET reference
/// (`MxReferenceHandle.cs:88-106`):
///
/// ```text
/// offset size field
/// 0 1 galaxy_id
/// 1 1 reserved (always 0; not exposed publicly)
/// 2 2 platform_id u16 LE
/// 4 2 engine_id u16 LE
/// 6 2 object_id u16 LE
/// 8 2 object_signature u16 LE (CRC-16/IBM of object tag name)
/// 10 2 primitive_id i16 LE
/// 12 2 attribute_id i16 LE
/// 14 2 property_id i16 LE
/// 16 2 attribute_signature u16 LE (CRC-16/IBM of attribute name)
/// 18 2 attribute_index i16 LE (-1 array, 0 scalar)
/// ```
///
/// `object_signature` and `attribute_signature` are derived values. The Rust
/// port keeps them private — the only constructor that produces a handle from
/// names is [`from_names`]; the only mutators that update one signature are
/// [`with_object_tag_name`] and [`with_attribute_name`], which both
/// recompute. This is a deliberate tightening over the .NET reference (which
/// is a record with public init-only signature fields).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct MxReferenceHandle {
pub galaxy_id: u8,
pub platform_id: u16,
pub engine_id: u16,
pub object_id: u16,
object_signature: u16,
pub primitive_id: i16,
pub attribute_id: i16,
pub property_id: i16,
attribute_signature: u16,
pub attribute_index: i16,
}
impl MxReferenceHandle {
pub const ENCODED_LEN: usize = 20;
/// Construct a handle by computing the object/attribute signatures from
/// their respective names. Mirrors `MxReferenceHandle.Create`.
///
/// # Errors
///
/// Returns [`CodecError::InvalidName`] if either name is empty or
/// whitespace-only — matching the .NET `ArgumentException.ThrowIfNullOrWhiteSpace`
/// contract at `MxReferenceHandle.cs:49`.
#[allow(clippy::too_many_arguments)]
pub fn from_names(
galaxy_id: u8,
platform_id: u16,
engine_id: u16,
object_id: u16,
object_tag_name: &str,
primitive_id: i16,
attribute_id: i16,
property_id: i16,
attribute_name: &str,
is_array: bool,
) -> Result<Self, CodecError> {
Ok(Self {
galaxy_id,
platform_id,
engine_id,
object_id,
object_signature: compute_name_signature(object_tag_name)?,
primitive_id,
attribute_id,
property_id,
attribute_signature: compute_name_signature(attribute_name)?,
attribute_index: if is_array { -1 } else { 0 },
})
}
pub fn object_signature(self) -> u16 {
self.object_signature
}
pub fn attribute_signature(self) -> u16 {
self.attribute_signature
}
/// Returns a new handle with the object signature recomputed from
/// `object_tag_name`. Every other field is preserved.
pub fn with_object_tag_name(self, object_tag_name: &str) -> Result<Self, CodecError> {
Ok(Self {
object_signature: compute_name_signature(object_tag_name)?,
..self
})
}
/// Returns a new handle with the attribute signature recomputed from
/// `attribute_name`. Every other field is preserved.
pub fn with_attribute_name(self, attribute_name: &str) -> Result<Self, CodecError> {
Ok(Self {
attribute_signature: compute_name_signature(attribute_name)?,
..self
})
}
/// Parse a 20-byte encoded handle. Mirrors `MxReferenceHandle.Parse`
/// (`MxReferenceHandle.cs:61-79`); byte 1 is read but discarded.
///
/// # Errors
///
/// Returns [`CodecError::ShortRead`] if `bytes` is not exactly 20 bytes.
pub fn parse(bytes: &[u8]) -> Result<Self, CodecError> {
if bytes.len() != Self::ENCODED_LEN {
return Err(CodecError::ShortRead {
expected: Self::ENCODED_LEN,
actual: bytes.len(),
});
}
Ok(Self {
galaxy_id: bytes[0],
// byte 1 reserved (discarded, mirrors .NET Parse)
platform_id: read_u16_le(bytes, 2),
engine_id: read_u16_le(bytes, 4),
object_id: read_u16_le(bytes, 6),
object_signature: read_u16_le(bytes, 8),
primitive_id: read_i16_le(bytes, 10),
attribute_id: read_i16_le(bytes, 12),
property_id: read_i16_le(bytes, 14),
attribute_signature: read_u16_le(bytes, 16),
attribute_index: read_i16_le(bytes, 18),
})
}
/// Encode into a freshly-allocated 20-byte buffer.
pub fn encode(self) -> [u8; Self::ENCODED_LEN] {
let mut bytes = [0u8; Self::ENCODED_LEN];
self.write_to(&mut bytes);
bytes
}
/// Encode into the provided destination. Mirrors `MxReferenceHandle.WriteTo`
/// (`MxReferenceHandle.cs:88-106`); byte 1 is always written as 0.
///
/// # Panics
///
/// Panics if `destination.len() < 20`. Use a 20-byte slice or call
/// [`encode`] for a fresh buffer.
pub fn write_to(self, destination: &mut [u8]) {
assert!(
destination.len() >= Self::ENCODED_LEN,
"destination must be at least {} bytes",
Self::ENCODED_LEN
);
destination[0] = self.galaxy_id;
destination[1] = 0;
write_u16_le(destination, 2, self.platform_id);
write_u16_le(destination, 4, self.engine_id);
write_u16_le(destination, 6, self.object_id);
write_u16_le(destination, 8, self.object_signature);
write_i16_le(destination, 10, self.primitive_id);
write_i16_le(destination, 12, self.attribute_id);
write_i16_le(destination, 14, self.property_id);
write_u16_le(destination, 16, self.attribute_signature);
write_i16_le(destination, 18, self.attribute_index);
}
}
/// CRC-16/IBM signature of a name. Lowercases the name, then for each `char`
/// runs the low byte then high byte of the UTF-16LE representation through
/// [`update_crc16_ibm`].
///
/// Mirrors `MxReferenceHandle.ComputeNameSignature` (`MxReferenceHandle.cs:47-59`).
///
/// **Unicode caveat**: This uses Rust's [`str::to_lowercase`], which performs
/// the Unicode Default_Lowercase mapping. This is intended to match
/// `String.ToLowerInvariant()` in .NET. Edge cases involving locale-tailored
/// mappings (e.g. Turkish dotless-i) may diverge — see
/// `design/10-raw-layer.md` L37 for the path forward via `icu_casemap`.
///
/// # Errors
///
/// Returns [`CodecError::InvalidName`] if `name` is empty or whitespace-only.
pub fn compute_name_signature(name: &str) -> Result<u16, CodecError> {
if name.trim().is_empty() {
return Err(CodecError::InvalidName);
}
let lower = name.to_lowercase();
let mut crc: u16 = 0;
for ch in lower.chars() {
// UTF-16LE: low byte then high byte of each `char`'s UTF-16 code units.
// Surrogate-pair chars (>= U+10000) emit two u16 code units; we feed
// each as low-then-high. This mirrors the .NET enumeration which
// iterates over UTF-16 code units (the `char` in C# is a u16).
let mut buf = [0u16; 2];
let utf16 = ch.encode_utf16(&mut buf);
for unit in utf16 {
crc = update_crc16_ibm(crc, *unit as u8);
crc = update_crc16_ibm(crc, (*unit >> 8) as u8);
}
}
Ok(crc)
}
/// One iteration of the CRC-16/IBM update loop (poly `0xa001`, right-shifted
/// variant). Mirrors `UpdateCrc16Ibm` (`MxReferenceHandle.cs:108-119`).
pub const fn update_crc16_ibm(mut crc: u16, value: u8) -> u16 {
crc ^= value as u16;
let mut bit = 0u8;
while bit < 8 {
crc = if (crc & 1) != 0 {
(crc >> 1) ^ CRC16_IBM_POLYNOMIAL
} else {
crc >> 1
};
bit += 1;
}
crc
}
#[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 write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
let le = value.to_le_bytes();
bytes[offset] = le[0];
bytes[offset + 1] = le[1];
}
#[inline]
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
let le = value.to_le_bytes();
bytes[offset] = le[0];
bytes[offset + 1] = le[1];
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
/// CRC vectors hand-traced from `MxReferenceHandle.cs` against the
/// .NET `ToLowerInvariant` + per-char low/high UTF-16LE feed.
///
/// Single ASCII char "a" (0x61):
/// low byte = 0x61 → after one iter: crc = ?
/// high byte = 0x00 → after another iter
///
/// Easier sanity: empty string check; matches the .NET behaviour of
/// throwing on whitespace-only input.
/// **Cross-implementation parity**: the values on the right are the exact
/// CRC-16/IBM outputs of `MxNativeCodec.MxReferenceHandle.ComputeNameSignature`
/// in the .NET reference, captured via `tools/Compute-Crc.ps1`. If the
/// Rust port ever diverges, these tests catch it. Regenerate with
/// `pwsh -NoProfile -File tools\Compute-Crc.ps1` after adding new vectors.
#[test]
fn dotnet_reference_parity_vectors() {
let cases = [
("TestObject", 0x0B25),
("TestInt", 0xDA3E),
("$Object", 0x22A4),
("a", 0x9029),
("TestChildObject", 0xD736),
// Case-insensitivity: all three of these collapse to the same CRC
// because `to_lowercase` matches `String.ToLowerInvariant`.
("testobject", 0x0B25),
("TESTOBJECT", 0x0B25),
];
for (name, expected) in cases {
assert_eq!(
compute_name_signature(name).unwrap(),
expected,
"CRC for {name:?} diverged from .NET reference"
);
}
}
#[test]
fn empty_name_rejected() {
assert!(compute_name_signature("").is_err());
assert!(compute_name_signature(" ").is_err());
}
#[test]
fn lowercasing_is_invariant() {
// Same name in different cases produces the same signature.
let a = compute_name_signature("TestObject").unwrap();
let b = compute_name_signature("testobject").unwrap();
let c = compute_name_signature("TESTOBJECT").unwrap();
assert_eq!(a, b);
assert_eq!(a, c);
}
#[test]
fn distinct_names_distinct_signatures() {
// Different names should hash to different values for any reasonable
// hash. (CRC-16 collisions exist, but these short distinct strings
// shouldn't collide.)
let a = compute_name_signature("TestObject").unwrap();
let b = compute_name_signature("TestInt").unwrap();
let c = compute_name_signature("$Object").unwrap();
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(b, c);
}
#[test]
fn crc_init_is_zero() {
// CRC of a single null byte under poly 0xa001 with init 0:
// crc = 0 XOR 0 = 0; eight right-shifts on 0 stay 0.
// So CRC of [0u8] under update_crc16_ibm is 0.
assert_eq!(update_crc16_ibm(0, 0), 0);
}
#[test]
fn round_trip_zero_handle() {
let handle = MxReferenceHandle::default();
let encoded = handle.encode();
let decoded = MxReferenceHandle::parse(&encoded).unwrap();
assert_eq!(handle, decoded);
assert_eq!(encoded, [0u8; 20]);
}
#[test]
fn round_trip_populated_handle() {
let handle = MxReferenceHandle::from_names(
1, // galaxy_id
42, // platform_id
17, // engine_id
300, // object_id
"TestChildObject", // object_tag_name
-1, // primitive_id
7, // attribute_id
0, // property_id
"TestInt", // attribute_name
false, // is_array
)
.unwrap();
let encoded = handle.encode();
let decoded = MxReferenceHandle::parse(&encoded).unwrap();
assert_eq!(handle, decoded);
assert_eq!(decoded.galaxy_id, 1);
assert_eq!(decoded.platform_id, 42);
assert_eq!(decoded.engine_id, 17);
assert_eq!(decoded.object_id, 300);
assert_eq!(decoded.primitive_id, -1);
assert_eq!(decoded.attribute_id, 7);
assert_eq!(decoded.property_id, 0);
assert_eq!(decoded.attribute_index, 0);
assert_eq!(decoded.object_signature(), handle.object_signature());
assert_eq!(decoded.attribute_signature(), handle.attribute_signature());
}
#[test]
fn array_flag_is_minus_one() {
let handle = MxReferenceHandle::from_names(1, 1, 1, 1, "X", 0, 0, 0, "Y", true).unwrap();
assert_eq!(handle.attribute_index, -1);
}
#[test]
fn byte_1_always_zero_on_encode() {
let handle = MxReferenceHandle {
galaxy_id: 0xff,
..MxReferenceHandle::default()
};
let encoded = handle.encode();
assert_eq!(encoded[0], 0xff);
assert_eq!(encoded[1], 0x00);
}
#[test]
fn parse_rejects_short_buffer() {
assert!(MxReferenceHandle::parse(&[0u8; 19]).is_err());
assert!(MxReferenceHandle::parse(&[0u8; 21]).is_err());
}
#[test]
fn with_attribute_name_recomputes_signature() {
let h1 = MxReferenceHandle::from_names(1, 1, 1, 1, "Obj", 0, 0, 0, "AttrA", false).unwrap();
let h2 = h1.with_attribute_name("AttrB").unwrap();
assert_ne!(h1.attribute_signature(), h2.attribute_signature());
// Object signature unchanged.
assert_eq!(h1.object_signature(), h2.object_signature());
// Other fields preserved.
assert_eq!(h1.galaxy_id, h2.galaxy_id);
assert_eq!(h1.platform_id, h2.platform_id);
}
#[test]
fn endianness_is_little() {
// Verify that platform_id 0x1234 ends up as bytes [0x34, 0x12] at
// offset 2..4.
let h = MxReferenceHandle {
platform_id: 0x1234,
..MxReferenceHandle::default()
};
let encoded = h.encode();
assert_eq!(encoded[2], 0x34);
assert_eq!(encoded[3], 0x12);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,820 @@
//! `NmxSecuredWrite2Message` — secured timestamped-write (`0x38`) message
//! body codec.
//!
//! Direct port of `src/MxNativeCodec/NmxSecuredWrite2Message.cs`.
//!
//! ## Naming and the single-token form
//!
//! The .NET method name is `WriteSecured2`. It always carries **two** user
//! identifiers (`currentUserToken` and `verifierUserToken`) plus a timestamp
//! and a client name — there is no separate single-token form on the LMX
//! wire. Single-user secured writes pass `currentUserToken == verifierUserToken`
//! (per `wwtools/mxaccesscli/docs/api-notes.md:60-72` verification noted in
//! `design/40-protocol-invariants.md` after the MAJOR-pass audit).
//!
//! The R6 entry in `design/70-risks-and-open-questions.md:174` confirms this:
//! the production LMX surface accepts `WriteSecured` with two user ids
//! unconditionally, and the captured `0x38` shape always has both token slots.
//!
//! ## Body layout
//!
//! The secured body inherits the `Write2` (timestamped) prefix shape, then
//! appends authentication / verification fields **before** the trailing
//! `(-1 i16) + clientToken(u32) + writeIndex(i32)` slot
//! (`NmxSecuredWrite2Message.cs:40-69`).
//!
//! ```text
//! offset size field source
//! 0..N N = Write2 prefix timestamped Write2 body up to but
//! NOT including the 4-byte clientToken
//! and 4-byte writeIndex .cs:41-54
//! N 16 currentUserToken .cs:56
//! N+16 4 clientNameLen i32 LE .cs:58
//! N+20 clientNameLen clientNameBytes (UTF-16LE + NUL) .cs:60
//! N+20+L 16 verifierUserToken .cs:62
//! N+36+L 2 -1 i16 LE .cs:64
//! N+38+L 4 clientToken u32 LE .cs:66
//! N+42+L 4 writeIndex i32 LE .cs:68
//! ```
//!
//! `prefixLength = timestampedPrefix.Length - sizeof(uint) - sizeof(int)`
//! (`.cs:51`) — i.e. the timestamped body **minus** its trailing 8 bytes
//! (clientToken + writeIndex). The opcode byte is overwritten from `0x37`
//! to `0x38` after the timestamped encoder runs (`.cs:48`).
//!
//! ## Observed authenticated-user token
//!
//! The .NET reference exposes a sample observed token at `.cs:12-18`. It is
//! mirrored here as [`OBSERVED_AUTHENTICATED_USER_TOKEN`] for tests and
//! probes that want to replay the captured `captures/036-frida-secured*`
//! body (cited in `design/40-protocol-invariants.md:164`).
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::MxReferenceHandle;
use crate::error::CodecError;
use crate::write_message::{self, WriteValue};
/// Secured-write opcode (`NmxSecuredWrite2Message.cs:8`).
pub const COMMAND: u8 = 0x38;
/// Wire-format version (`NmxSecuredWrite2Message.cs:9`). The .NET `Encode`
/// path defers to `NmxWriteMessage.EncodeTimestamped` which writes
/// `version = 1` (`NmxWriteMessage.cs:10`); the constant is preserved here
/// for parity with the .NET surface.
pub const VERSION: u16 = 1;
/// Authenticator token length in bytes (`NmxSecuredWrite2Message.cs:10`).
pub const AUTHENTICATOR_TOKEN_LENGTH: usize = 16;
/// Sample observed authenticated-user token from the live AVEVA stack
/// (`NmxSecuredWrite2Message.cs:12-18`, captured in
/// `captures/036-frida-secured*`).
pub const OBSERVED_AUTHENTICATED_USER_TOKEN: [u8; AUTHENTICATOR_TOKEN_LENGTH] = [
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89, 0x0f,
];
/// Resolve the observed token form for a given user id. Mirrors
/// `ResolveObservedUserToken` (`NmxSecuredWrite2Message.cs:94-99`): user id
/// `0` returns 16 zero bytes; any other id returns the observed authenticated
/// token. This helper is for tests / probes; production callers should pass
/// real tokens.
pub fn resolve_observed_user_token(user_id: i32) -> [u8; AUTHENTICATOR_TOKEN_LENGTH] {
if user_id == 0 {
[0u8; AUTHENTICATOR_TOKEN_LENGTH]
} else {
OBSERVED_AUTHENTICATED_USER_TOKEN
}
}
/// Encode a `WriteSecured2` body (`0x38`).
///
/// Mirrors `NmxSecuredWrite2Message.Encode` (`NmxSecuredWrite2Message.cs:20-70`).
/// Internally builds a timestamped Write2 body via
/// [`crate::write_message::encode_timestamped`], strips its trailing 8 bytes
/// (clientToken + writeIndex), overwrites the leading opcode byte, and
/// appends the secured suffix.
///
/// `current_user_token == verifier_user_token` is the single-user secured
/// write path and is allowed unconditionally — see module doc.
///
/// # Errors
///
/// - Returns a [`CodecError::Decode`] if the underlying timestamped Write
/// encode fails (e.g. array element count exceeds `u16::MAX`).
#[allow(clippy::too_many_arguments)]
pub fn encode(
handle: &MxReferenceHandle,
value: &WriteValue,
current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
client_name: &str,
timestamp_filetime: i64,
write_index: i32,
client_token: u32,
) -> Result<Vec<u8>, CodecError> {
// 1. Build the timestamped Write2 body. The .NET reference passes
// `clientToken: 0` (`NmxSecuredWrite2Message.cs:47`) — those 8 trailing
// bytes are about to be stripped, so the value is irrelevant.
let timestamped =
write_message::encode_timestamped(handle, value, timestamp_filetime, write_index, 0)?;
// 2. Strip the trailing clientToken (4) + writeIndex (4) from the
// timestamped body — `prefixLength = ts.Length - 4 - 4`
// (`NmxSecuredWrite2Message.cs:51`).
let prefix_length = timestamped.len() - 4 - 4;
// 3. UTF-16LE + NUL terminator for the client name
// (`NmxSecuredWrite2Message.cs:50`,
// `Encoding.Unicode.GetBytes(clientName + '\0')`).
let client_name_bytes = encode_utf16_with_nul(client_name);
let client_name_len = client_name_bytes.len();
// 4. Allocate body of the exact final size
// (`NmxSecuredWrite2Message.cs:52`).
let body_len = prefix_length
+ AUTHENTICATOR_TOKEN_LENGTH
+ 4
+ client_name_len
+ AUTHENTICATOR_TOKEN_LENGTH
+ 2
+ 4
+ 4;
let mut body = vec![0u8; body_len];
// 5. Copy stripped timestamped prefix and overwrite opcode
// (`NmxSecuredWrite2Message.cs:48, 54`).
body[..prefix_length].copy_from_slice(&timestamped[..prefix_length]);
body[0] = COMMAND;
// 6. Append secured suffix in declared order
// (`NmxSecuredWrite2Message.cs:55-69`).
let mut offset = prefix_length;
body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(&current_user_token);
offset += AUTHENTICATOR_TOKEN_LENGTH;
write_i32_le(&mut body, offset, client_name_len as i32);
offset += 4;
body[offset..offset + client_name_len].copy_from_slice(&client_name_bytes);
offset += client_name_len;
body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(&verifier_user_token);
offset += AUTHENTICATOR_TOKEN_LENGTH;
write_i16_le(&mut body, offset, -1);
offset += 2;
write_u32_le(&mut body, offset, client_token);
offset += 4;
write_i32_le(&mut body, offset, write_index);
Ok(body)
}
/// Decoded secured-write body.
#[derive(Debug, Clone, PartialEq)]
pub struct DecodedSecuredWrite {
/// Inner timestamped Write2 result (handle projection, value, write
/// index, client token, timestamp). Note the `client_token` and
/// `write_index` of the inner result come from the **secured** suffix —
/// the original timestamped body was encoded with `client_token = 0`
/// before the trailing 8 bytes were stripped (.cs:47, .cs:51), so the
/// decoder reconstructs a synthetic timestamped body with the secured
/// suffix's clientToken+writeIndex re-attached for round-trip parity.
pub inner: write_message::DecodedWrite,
pub current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
pub verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
pub client_name: String,
}
/// Decode a `WriteSecured2` body produced by [`encode`].
///
/// The .NET reference is encode-only (`NmxSecuredWrite2Message.cs:6-105`); the
/// Rust port adds a decoder for round-trip tests, mirroring the encoder
/// layout exactly.
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `body` is too small to carry the secured
/// suffix.
/// - [`CodecError::UnexpectedOpcode`] if `body[0] != 0x38`.
/// - [`CodecError::Decode`] for malformed lengths or invalid client-name UTF-16.
/// - Any error returned by [`crate::write_message::decode`] for the inner
/// reconstructed timestamped body.
pub fn decode(body: &[u8]) -> Result<DecodedSecuredWrite, CodecError> {
if body.is_empty() {
return Err(CodecError::ShortRead {
expected: 1,
actual: 0,
});
}
if body[0] != COMMAND {
return Err(CodecError::UnexpectedOpcode(body[0]));
}
// Trailing slot: 16 (verifier) + 2 (-1 i16) + 4 (clientToken) + 4 (writeIndex)
// = 26 bytes after the client-name region.
// The minimum body shape is: prefix(>=18) + currentToken(16) + nameLen(4)
// + nameBytes(>=2 NUL) + verifierToken(16) + 10-byte tail.
if body.len() < 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10 {
return Err(CodecError::ShortRead {
expected: 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10,
actual: body.len(),
});
}
// Strategy: walk back from the end. The last 26 bytes are the secured
// suffix; before that lies a UTF-16LE client_name of length declared in
// the i32 LE that precedes it; before THAT lies the 16-byte
// currentUserToken; before THAT lies the timestamped Write2 prefix
// (without its trailing 8 bytes). We don't know the prefix length up
// front, but we can locate the secured suffix by scanning for the
// currentUserToken offset using the client-name length field.
//
// Concretely: the last 26 bytes are
// verifier(16) + -1 i16 + clientToken(4) + writeIndex(4) = 26
// Before them is the client_name of length L (variable). Before THAT
// is the i32 LE clientNameLen (4). Before THAT is the currentUserToken (16).
//
// We need to find the offset where currentUserToken starts. We do that
// by reading clientNameLen from a position relative to the end:
// offset_of_clientNameLen = body.len() - 26 - L - 4
// offset_of_clientNameLen + 4 + L + 16 + 2 + 4 + 4 = body.len()
//
// Equivalently: the trailing region after the timestamped prefix is
// 16 + 4 + L + 16 + 2 + 4 + 4 = 46 + L bytes.
//
// So prefix_length = body.len() - (46 + L).
//
// We don't know L without locating clientNameLen first. The .NET
// reference does not record where the prefix ends — it derives it on
// encode. On decode, we need to use the inner write_message::decode to
// figure out the timestamped body's natural length, then derive L.
//
// Approach: reconstruct a synthetic timestamped body by appending an
// 8-byte clientToken+writeIndex tail with zeros to a candidate prefix,
// decode it, and use the resulting body length to find the boundary.
//
// Simpler: walk forward through the timestamped wire shape using the
// crate's own write_message::decode after we know the prefix bytes.
// But we don't know the prefix length yet.
//
// The cleanest approach: read the trailing structure. We know the body
// tail layout. Count bytes from the end:
// [body.len() - 4 .. body.len()] writeIndex i32
// [body.len() - 8 .. body.len() - 4] clientToken u32
// [body.len() - 10 .. body.len() - 8] -1 i16
// [body.len() - 26 .. body.len() - 10] verifierUserToken (16)
// [body.len() - 26 - L .. body.len() - 26] clientNameBytes (L)
// [body.len() - 30 - L .. body.len() - 26 - L] clientNameLen i32 (4)
// [body.len() - 46 - L .. body.len() - 30 - L] currentUserToken (16)
// We need L. The clientNameLen i32 lives at offset (body.len() - 30 - L).
// We can solve by scanning candidate L values OR by realising that the
// timestamped prefix has a deterministic length given the value kind.
//
// We use the deterministic-length approach: rebuild a candidate
// timestamped prefix of length `prefix_length`, then decode by parsing
// the wire-kind-driven shape. Since the inner write_message::decode
// already implements this, and since the prefix shape is fully
// determined by body[1..3] (version) and body[17] (wire_kind), we can
// compute the timestamped body length without seeing the secured suffix.
// body[0..18] is the common prefix: cmd + version + 14 handle bytes + wire_kind.
let wire_kind = body[17];
// For each wire kind, the timestamped body length is fixed (scalar) or
// determined by an inner length prefix (variable / array). The
// timestamped body length = prefix_length + 8 (the 8 stripped trailing
// bytes). prefix_length = body.len() - 46 - L. We don't know L.
//
// But we DO know the timestamped body length from wire_kind directly:
let ts_body_len = compute_timestamped_body_len(body, wire_kind)?;
let prefix_length = ts_body_len - 8;
// Now layout is known.
let suffix_offset = prefix_length;
if suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4 > body.len() {
return Err(CodecError::ShortRead {
expected: suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4,
actual: body.len(),
});
}
let mut current_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH];
current_user_token
.copy_from_slice(&body[suffix_offset..suffix_offset + AUTHENTICATOR_TOKEN_LENGTH]);
let name_len_offset = suffix_offset + AUTHENTICATOR_TOKEN_LENGTH;
let client_name_len = read_i32_le(body, name_len_offset);
if client_name_len < 0 {
return Err(CodecError::Decode {
offset: name_len_offset,
reason: "secured-write: negative clientNameLen",
buffer_len: body.len(),
});
}
let client_name_len = client_name_len as usize;
let name_offset = name_len_offset + 4;
if name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10 > body.len() {
return Err(CodecError::ShortRead {
expected: name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10,
actual: body.len(),
});
}
let client_name_bytes = &body[name_offset..name_offset + client_name_len];
let client_name = decode_utf16_with_nul(client_name_bytes, name_offset, body.len())?;
let verifier_offset = name_offset + client_name_len;
let mut verifier_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH];
verifier_user_token
.copy_from_slice(&body[verifier_offset..verifier_offset + AUTHENTICATOR_TOKEN_LENGTH]);
let tail_offset = verifier_offset + AUTHENTICATOR_TOKEN_LENGTH;
let leading = read_i16_le(body, tail_offset);
if leading != -1 {
return Err(CodecError::Decode {
offset: tail_offset,
reason: "secured-write: trailing leading i16 is not -1",
buffer_len: body.len(),
});
}
let secured_client_token = read_u32_le(body, tail_offset + 2);
let secured_write_index = read_i32_le(body, tail_offset + 6);
// Reconstruct the timestamped body so we can call write_message::decode.
// The .NET encoder calls EncodeTimestamped with clientToken=0 then
// strips, so the original prefix has 0 in the clientToken slot. We need
// to substitute the secured suffix's clientToken+writeIndex back so the
// inner DecodedWrite reflects what the caller passed to encode().
let mut ts_body = body[..prefix_length].to_vec();
ts_body.extend_from_slice(&secured_client_token.to_le_bytes());
ts_body.extend_from_slice(&secured_write_index.to_le_bytes());
// Restore the inner opcode (was overwritten to 0x38; restore to 0x37
// so write_message::decode accepts it).
ts_body[0] = write_message::COMMAND;
let inner = write_message::decode(&ts_body)?;
Ok(DecodedSecuredWrite {
inner,
current_user_token,
verifier_user_token,
client_name,
})
}
/// Compute the length of the timestamped Write2 body for a given wire kind,
/// reading any inner length fields from `body` (which carries a `0x38` body —
/// but the prefix bytes are identical to the timestamped `0x37` body).
fn compute_timestamped_body_len(body: &[u8], wire_kind: u8) -> Result<usize, CodecError> {
// Timestamped body shapes (mirroring write_message.rs):
// Boolean (timestamped): 17 + 1 + 1 + 14 + 4 = 37
// [actually: KIND_OFFSET(17) + 1 + 1-byte payload + 18-byte suffix
// = 37]. Per write_message.rs:357-364.
// Int32: 17 + 1 + 4 + 14 + 4 = 40
// Float32: 40
// Float64: 17 + 1 + 8 + 14 + 4 = 44
// Variable: 44 + utf16_len (read inner_len at offset 22)
// Array: 46 + payload_len (count u16 at 22, walk for variable arrays)
match wire_kind {
0x01 => Ok(37),
0x02 | 0x03 => Ok(40),
0x04 => Ok(44),
0x05 => {
// body[22..26] = inner_length (i32 LE) — UTF-16 byte length
// including 2-byte NUL terminator.
if body.len() < 26 {
return Err(CodecError::ShortRead {
expected: 26,
actual: body.len(),
});
}
let inner_len = read_i32_le(body, 22);
if inner_len < 0 {
return Err(CodecError::Decode {
offset: 22,
reason: "secured-write: negative variable inner_length",
buffer_len: body.len(),
});
}
Ok(44 + inner_len as usize)
}
0x41..=0x44 => {
if body.len() < 28 {
return Err(CodecError::ShortRead {
expected: 28,
actual: body.len(),
});
}
let count = read_u16_le(body, 22) as usize;
// `wire_kind` is constrained to 0x41..=0x44 by the outer match
// arm; default to 2 for any out-of-table value (shouldn't occur).
let element_width = match wire_kind {
0x41 => 2,
0x42 | 0x43 => 4,
0x44 => 8,
_ => 2,
};
Ok(28 + count * element_width + 18)
}
0x45 => {
if body.len() < 28 {
return Err(CodecError::ShortRead {
expected: 28,
actual: body.len(),
});
}
let count = read_u16_le(body, 22) as usize;
let mut cursor = 28usize;
for _ in 0..count {
if cursor + 13 > body.len() {
return Err(CodecError::ShortRead {
expected: cursor + 13,
actual: body.len(),
});
}
let inner_len = read_i32_le(body, cursor + 9);
if inner_len < 0 {
return Err(CodecError::Decode {
offset: cursor + 9,
reason: "secured-write: negative variable-array inner_length",
buffer_len: body.len(),
});
}
cursor += 13 + inner_len as usize;
}
Ok(cursor + 18)
}
_ => Err(CodecError::Decode {
offset: 17,
reason: "secured-write: unknown wire kind",
buffer_len: body.len(),
}),
}
}
// ---- UTF-16 helpers -------------------------------------------------------
/// UTF-16LE encoding with a trailing 2-byte NUL terminator.
/// Mirrors `Encoding.Unicode.GetBytes(clientName + '\0')`
/// (`NmxSecuredWrite2Message.cs:50`).
fn encode_utf16_with_nul(value: &str) -> Vec<u8> {
let utf16: Vec<u16> = value.encode_utf16().collect();
let mut bytes = Vec::with_capacity(utf16.len() * 2 + 2);
for unit in &utf16 {
bytes.extend_from_slice(&unit.to_le_bytes());
}
// Trailing NUL (the `+ '\0'` in the .NET source).
bytes.push(0x00);
bytes.push(0x00);
bytes
}
fn decode_utf16_with_nul(
raw: &[u8],
offset: usize,
buffer_len: usize,
) -> Result<String, CodecError> {
if raw.len() % 2 != 0 {
return Err(CodecError::Decode {
offset,
reason: "secured-write: client_name byte length is not even",
buffer_len,
});
}
let utf16: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
// Strip the trailing NUL terminator (the .NET path always emits one).
let trimmed: &[u16] = if utf16.last() == Some(&0) {
&utf16[..utf16.len() - 1]
} else {
&utf16
};
String::from_utf16(trimmed).map_err(|_| CodecError::Decode {
offset,
reason: "secured-write: invalid UTF-16 in client_name",
buffer_len,
})
}
// ---- LE primitive helpers -------------------------------------------------
#[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_u32_le(bytes: &mut [u8], offset: usize, value: u32) {
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
#[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_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn sample_handle() -> MxReferenceHandle {
MxReferenceHandle::from_names(
1,
42,
17,
300,
"TestChildObject",
-1,
7,
0,
"TestInt",
false,
)
.unwrap()
}
const TOKEN_A: [u8; 16] = [
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89,
0x0f,
];
const TOKEN_B: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
0x00,
];
#[test]
fn opcode_and_constants_match_dotnet() {
// `NmxSecuredWrite2Message.cs:8-10`.
assert_eq!(COMMAND, 0x38);
assert_eq!(VERSION, 1);
assert_eq!(AUTHENTICATOR_TOKEN_LENGTH, 16);
}
#[test]
fn observed_authenticated_user_token_matches_dotnet() {
// `NmxSecuredWrite2Message.cs:12-18`.
assert_eq!(
OBSERVED_AUTHENTICATED_USER_TOKEN,
[
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c,
0x89, 0x0f
]
);
}
#[test]
fn resolve_observed_user_token_matches_dotnet() {
// `NmxSecuredWrite2Message.cs:94-99`.
assert_eq!(resolve_observed_user_token(0), [0u8; 16]);
assert_eq!(
resolve_observed_user_token(123),
OBSERVED_AUTHENTICATED_USER_TOKEN
);
}
#[test]
fn opcode_byte_is_overwritten_to_0x38() {
// `NmxSecuredWrite2Message.cs:48`.
let h = sample_handle();
let body = encode(
&h,
&WriteValue::Int32(123),
TOKEN_A,
TOKEN_B,
"client",
123_456_789_i64,
5,
0xCAFE_BABE,
)
.unwrap();
assert_eq!(body[0], COMMAND);
}
#[test]
fn round_trip_int32_two_distinct_user_tokens() {
let h = sample_handle();
let body = encode(
&h,
&WriteValue::Int32(0x1234_5678),
TOKEN_A,
TOKEN_B,
"TestClient",
0x0102_0304_0506_0708_i64,
42,
0xDEAD_BEEF,
)
.unwrap();
let decoded = decode(&body).unwrap();
assert_eq!(decoded.current_user_token, TOKEN_A);
assert_eq!(decoded.verifier_user_token, TOKEN_B);
assert_ne!(decoded.current_user_token, decoded.verifier_user_token);
assert_eq!(decoded.client_name, "TestClient");
assert_eq!(decoded.inner.value, WriteValue::Int32(0x1234_5678));
assert_eq!(decoded.inner.write_index, 42);
assert_eq!(decoded.inner.client_token, 0xDEAD_BEEF);
assert_eq!(
decoded.inner.timestamp_filetime,
Some(0x0102_0304_0506_0708)
);
}
#[test]
fn round_trip_boolean_single_user_path() {
// Per module doc / api-notes.md: single-user secured writes use
// currentUserToken == verifierUserToken.
let h = sample_handle();
let body = encode(
&h,
&WriteValue::Boolean(true),
TOKEN_A,
TOKEN_A, // same token both slots
"Solo",
1_700_000_000_000_000_000_i64,
1,
0x1234,
)
.unwrap();
let decoded = decode(&body).unwrap();
assert_eq!(decoded.current_user_token, decoded.verifier_user_token);
assert_eq!(decoded.current_user_token, TOKEN_A);
assert_eq!(decoded.client_name, "Solo");
assert_eq!(decoded.inner.value, WriteValue::Boolean(true));
}
#[test]
fn round_trip_with_empty_client_name() {
// Empty string still emits a 2-byte NUL terminator
// (`Encoding.Unicode.GetBytes("" + '\0')`).
let h = sample_handle();
let body = encode(&h, &WriteValue::Int32(0), TOKEN_A, TOKEN_B, "", 0, 1, 0).unwrap();
let decoded = decode(&body).unwrap();
assert_eq!(decoded.client_name, "");
assert_eq!(decoded.inner.value, WriteValue::Int32(0));
}
#[test]
fn round_trip_with_populated_client_name() {
let h = sample_handle();
let body = encode(
&h,
&WriteValue::Int32(42),
TOKEN_A,
TOKEN_B,
"Operator-Console-1",
0,
7,
0xABCD,
)
.unwrap();
let decoded = decode(&body).unwrap();
assert_eq!(decoded.client_name, "Operator-Console-1");
}
#[test]
fn round_trip_string_value() {
let h = sample_handle();
let body = encode(
&h,
&WriteValue::String("hello".to_string()),
TOKEN_A,
TOKEN_B,
"client",
0x1122_3344_5566_7788_i64,
3,
0xFEED,
)
.unwrap();
let decoded = decode(&body).unwrap();
assert_eq!(decoded.inner.value, WriteValue::String("hello".to_string()));
assert_eq!(
decoded.inner.timestamp_filetime,
Some(0x1122_3344_5566_7788)
);
}
#[test]
fn wrong_opcode_rejected() {
// Take a real body and clobber the opcode.
let h = sample_handle();
let mut body = encode(&h, &WriteValue::Int32(1), TOKEN_A, TOKEN_B, "x", 0, 1, 0).unwrap();
body[0] = 0x37;
let err = decode(&body).unwrap_err();
assert!(matches!(err, CodecError::UnexpectedOpcode(0x37)));
}
#[test]
fn trailing_fields_land_at_correct_offsets() {
// For Int32 the timestamped prefix length is 40 - 8 = 32 bytes.
// Verify that:
// body[32..48] = currentUserToken
// body[48..52] = clientNameLen i32 LE
// body[52..52+L] = clientNameBytes
// body[..+16] = verifierUserToken
// then -1 i16 + clientToken u32 + writeIndex i32
let h = sample_handle();
let client_name = "abc"; // 3 chars * 2 + 2 (NUL) = 8 bytes
let body = encode(
&h,
&WriteValue::Int32(7),
TOKEN_A,
TOKEN_B,
client_name,
0x1111_2222_3333_4444_i64,
5,
0xBEEF_CAFE,
)
.unwrap();
// prefix_length for Int32 timestamped = 32.
let prefix_length = 32;
assert_eq!(&body[prefix_length..prefix_length + 16], &TOKEN_A);
let name_len_offset = prefix_length + 16;
let name_len = i32::from_le_bytes([
body[name_len_offset],
body[name_len_offset + 1],
body[name_len_offset + 2],
body[name_len_offset + 3],
]);
assert_eq!(name_len, 8);
let name_offset = name_len_offset + 4;
let name_bytes = &body[name_offset..name_offset + 8];
// "abc\0" UTF-16LE LE = 'a' 0 'b' 0 'c' 0 0 0
assert_eq!(
name_bytes,
&[0x61, 0x00, 0x62, 0x00, 0x63, 0x00, 0x00, 0x00]
);
let verifier_offset = name_offset + 8;
assert_eq!(&body[verifier_offset..verifier_offset + 16], &TOKEN_B);
let tail = verifier_offset + 16;
let leading_i16 = i16::from_le_bytes([body[tail], body[tail + 1]]);
assert_eq!(leading_i16, -1);
let client_token = u32::from_le_bytes([
body[tail + 2],
body[tail + 3],
body[tail + 4],
body[tail + 5],
]);
assert_eq!(client_token, 0xBEEF_CAFE);
let write_index = i32::from_le_bytes([
body[tail + 6],
body[tail + 7],
body[tail + 8],
body[tail + 9],
]);
assert_eq!(write_index, 5);
// Total body length: prefix(32) + 16 + 4 + 8 + 16 + 2 + 4 + 4 = 86
assert_eq!(body.len(), 86);
}
#[test]
fn short_buffer_rejected() {
let err = decode(&[0x38u8; 4]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn empty_buffer_rejected() {
let err = decode(&[]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
}
+314
View File
@@ -0,0 +1,314 @@
//! `MxStatus` — 4-tuple `(Success, Category, DetectedBy, Detail)` per
//! `src/MxNativeCodec/MxStatus.cs:28-65`.
//!
//! `Success=-1` is the documented OK sentinel. Detail is a signed 16-bit
//! lookup code; canonical text for known codes is in [`detail_text`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[repr(i16)]
pub enum MxStatusCategory {
#[default]
Unknown = -1,
Ok = 0,
Pending = 1,
Warning = 2,
CommunicationError = 3,
ConfigurationError = 4,
OperationalError = 5,
SecurityError = 6,
SoftwareError = 7,
OtherError = 8,
}
impl MxStatusCategory {
pub fn from_i16(value: i16) -> Self {
match value {
0 => Self::Ok,
1 => Self::Pending,
2 => Self::Warning,
3 => Self::CommunicationError,
4 => Self::ConfigurationError,
5 => Self::OperationalError,
6 => Self::SecurityError,
7 => Self::SoftwareError,
8 => Self::OtherError,
_ => Self::Unknown,
}
}
pub fn to_i16(self) -> i16 {
self as i16
}
}
/// Seven values per `MxStatus.cs:17-26`. The `DetectedBy` field is essential
/// for diagnostics — it identifies which layer detected the fault.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[repr(i16)]
pub enum MxStatusSource {
#[default]
Unknown = -1,
RequestingLmx = 0,
RespondingLmx = 1,
RequestingNmx = 2,
RespondingNmx = 3,
RequestingAutomationObject = 4,
RespondingAutomationObject = 5,
}
impl MxStatusSource {
pub fn from_i16(value: i16) -> Self {
match value {
0 => Self::RequestingLmx,
1 => Self::RespondingLmx,
2 => Self::RequestingNmx,
3 => Self::RespondingNmx,
4 => Self::RequestingAutomationObject,
5 => Self::RespondingAutomationObject,
_ => Self::Unknown,
}
}
pub fn to_i16(self) -> i16 {
self as i16
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct MxStatus {
pub success: i16,
pub category: MxStatusCategory,
pub detected_by: MxStatusSource,
pub detail: i16,
}
impl MxStatus {
/// `(success=-1, Ok, RequestingLmx, detail=0)` — `MxStatus.DataChangeOk`
/// from `MxStatus.cs:36-40`.
pub const DATA_CHANGE_OK: Self = Self {
success: -1,
category: MxStatusCategory::Ok,
detected_by: MxStatusSource::RequestingLmx,
detail: 0,
};
/// `(success=-1, Ok, RespondingAutomationObject, detail=0)` —
/// `MxStatus.WriteCompleteOk` from `MxStatus.cs:42-46`.
pub const WRITE_COMPLETE_OK: Self = Self {
success: -1,
category: MxStatusCategory::Ok,
detected_by: MxStatusSource::RespondingAutomationObject,
detail: 0,
};
/// `(success=-1, Pending, RequestingLmx, detail=0)` —
/// `MxStatus.SuspendPending` from `MxStatus.cs:48-52`.
pub const SUSPEND_PENDING: Self = Self {
success: -1,
category: MxStatusCategory::Pending,
detected_by: MxStatusSource::RequestingLmx,
detail: 0,
};
/// `(success=-1, Ok, RequestingLmx, detail=0)` — `MxStatus.ActivateOk`
/// from `MxStatus.cs:54-58`.
pub const ACTIVATE_OK: Self = Self {
success: -1,
category: MxStatusCategory::Ok,
detected_by: MxStatusSource::RequestingLmx,
detail: 0,
};
/// `(success=0, ConfigurationError, RequestingLmx, detail=6)` —
/// `MxStatus.InvalidReferenceConfiguration` from `MxStatus.cs:60-64`.
pub const INVALID_REFERENCE_CONFIGURATION: Self = Self {
success: 0,
category: MxStatusCategory::ConfigurationError,
detected_by: MxStatusSource::RequestingLmx,
detail: 6,
};
/// Look up the canonical text for `self.detail`, mirroring
/// `MxStatus.DetailText` (`MxStatus.cs:34`). Returns `None` for unknown
/// detail codes.
pub fn detail_text(&self) -> Option<&'static str> {
detail_text(self.detail)
}
pub fn is_ok(&self) -> bool {
self.category == MxStatusCategory::Ok
}
}
/// Canonical detail-code text per `MxStatusDetails.KnownDetails`
/// (`MxStatus.cs:69-120`). Returns `None` for unknown codes.
pub fn detail_text(detail: i16) -> Option<&'static str> {
match detail {
16 => Some("Request timed out"),
17 => Some("Platform communication error"),
18 => Some("Invalid platform ID"),
19 => Some("Invalid engine ID"),
20 => Some("Engine communication error"),
21 => Some("Invalid reference"),
22 => Some("No Galaxy Repository"),
23 => Some("Invalid object ID"),
24 => Some("Object signature mismatch"),
25 => Some("Invalid primitive ID"),
26 => Some("Invalid attribute ID"),
27 => Some("Invalid property ID"),
28 => Some("Index out of range"),
29 => Some("Data out of range"),
30 => Some("Incorrect data type"),
31 => Some("Attribute not readable"),
32 => Some("Attribute not writeable"),
33 => Some("Write access denied"),
34 => Some("Unknown error"),
35 => Some("detected by"),
36 => Some("Wrong data type"),
37 => Some("Wrong number of dimensions"),
38 => Some("Invalid index"),
39 => Some("Index out of order"),
40 => Some("Dimension does not exist"),
41 => Some("Conversion not supported"),
42 => Some("Unable to convert string"),
43 => Some("Overflow"),
44 => Some("Attribute signature mismatch"),
45 => Some("Resolving local portion of reference"),
46 => Some("Resolving global portion of reference"),
47 => Some("Nmx version mismatch"),
48 => Some("Nmx command not valid"),
49 => Some("Lmx version mismatch"),
50 => Some("Lmx command not valid"),
51 => Some(
"However, the object could not be put On Scan - Permission to modify \"Operate\" attributes is required",
),
52 => Some(
"Unable to resolve reference for 'set' request because Galaxy Repository is busy performing a 'Deploy/Undeploy' operation",
),
53 => Some("Too many outstanding pending requests to engine"),
54 => Some("Object Initializing"),
55 => Some("Engine Initializing"),
56 => Some("Secured Write"),
57 => Some("Verified Write"),
58 => Some("No Alarm Ack Privilege"),
59 => Some("Alarm Acked Already"),
60 => Some("User did not have the necessary permissions to write"),
61 => Some("Verifier did not have the necessary permissions to verify"),
541 => Some("Conversion to intended data type is not supported"),
542 => Some("Unable to convert the input string to intended data type"),
8017 => Some(
"Object must be offscan to modify attributes that have an MxSecurityConfigure security classification",
),
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn category_round_trip() {
for cat in [
MxStatusCategory::Unknown,
MxStatusCategory::Ok,
MxStatusCategory::Pending,
MxStatusCategory::Warning,
MxStatusCategory::CommunicationError,
MxStatusCategory::ConfigurationError,
MxStatusCategory::OperationalError,
MxStatusCategory::SecurityError,
MxStatusCategory::SoftwareError,
MxStatusCategory::OtherError,
] {
assert_eq!(MxStatusCategory::from_i16(cat.to_i16()), cat);
}
}
#[test]
fn source_round_trip() {
for src in [
MxStatusSource::Unknown,
MxStatusSource::RequestingLmx,
MxStatusSource::RespondingLmx,
MxStatusSource::RequestingNmx,
MxStatusSource::RespondingNmx,
MxStatusSource::RequestingAutomationObject,
MxStatusSource::RespondingAutomationObject,
] {
assert_eq!(MxStatusSource::from_i16(src.to_i16()), src);
}
}
#[test]
fn unknown_codes_map_to_unknown_variants() {
assert_eq!(MxStatusCategory::from_i16(99), MxStatusCategory::Unknown);
assert_eq!(MxStatusCategory::from_i16(-99), MxStatusCategory::Unknown);
assert_eq!(MxStatusSource::from_i16(99), MxStatusSource::Unknown);
assert_eq!(MxStatusSource::from_i16(-2), MxStatusSource::Unknown);
}
#[test]
fn canonical_sentinels_match_dotnet() {
// `MxStatus.cs:36-58` defines five canonical sentinels.
assert_eq!(MxStatus::DATA_CHANGE_OK.success, -1);
assert_eq!(MxStatus::DATA_CHANGE_OK.category, MxStatusCategory::Ok);
assert_eq!(
MxStatus::DATA_CHANGE_OK.detected_by,
MxStatusSource::RequestingLmx
);
assert_eq!(MxStatus::DATA_CHANGE_OK.detail, 0);
assert_eq!(
MxStatus::WRITE_COMPLETE_OK.detected_by,
MxStatusSource::RespondingAutomationObject
);
assert_eq!(
MxStatus::INVALID_REFERENCE_CONFIGURATION.success,
0,
"InvalidReferenceConfiguration uses success=0, not -1"
);
assert_eq!(MxStatus::INVALID_REFERENCE_CONFIGURATION.detail, 6);
}
#[test]
fn detail_text_known_codes() {
assert_eq!(detail_text(16), Some("Request timed out"));
assert_eq!(detail_text(21), Some("Invalid reference"));
assert_eq!(detail_text(33), Some("Write access denied"));
assert_eq!(detail_text(57), Some("Verified Write"));
assert_eq!(
detail_text(541),
Some("Conversion to intended data type is not supported")
);
assert_eq!(
detail_text(8017),
Some(
"Object must be offscan to modify attributes that have an MxSecurityConfigure security classification"
)
);
}
#[test]
fn detail_text_unknown_codes() {
assert_eq!(detail_text(0), None);
assert_eq!(detail_text(15), None);
assert_eq!(detail_text(62), None);
assert_eq!(detail_text(540), None);
assert_eq!(detail_text(8018), None);
assert_eq!(detail_text(-1), None);
}
#[test]
fn is_ok_categorisation() {
assert!(MxStatus::DATA_CHANGE_OK.is_ok());
assert!(MxStatus::WRITE_COMPLETE_OK.is_ok());
assert!(MxStatus::ACTIVATE_OK.is_ok());
assert!(!MxStatus::SUSPEND_PENDING.is_ok());
assert!(!MxStatus::INVALID_REFERENCE_CONFIGURATION.is_ok());
}
}
File diff suppressed because it is too large Load Diff
+471
View File
@@ -0,0 +1,471 @@
//! Value model — `MxValueKind`, `MxDataType`, and `MxValue`.
//!
//! Ports `src/MxNativeCodec/MxValueKind.cs`, `src/MxNativeCodec/MxDataType.cs`,
//! and the wire-kind/value mapping that `NmxSubscriptionMessage.cs` and
//! `NmxWriteMessage.cs` use. `MxValueKind` carries the on-the-wire numeric
//! tags observed in NMX subscription / write bodies; `MxDataType` is the
//! attribute-model side of the .NET enum and is independent of the wire
//! kind; `MxValue` is the runtime carrier used by codecs.
//!
//! The wire-kind values are not encoded as an enum in the .NET reference
//! (`MxValueKind.cs:3-18` uses default integer ordering) — the `0x01..0x07`
//! and `0x41..0x46` byte tags come from the encoder/decoder switches in
//! `NmxWriteMessage.cs:94-110` and `NmxSubscriptionMessage.cs:164-176`.
#![allow(clippy::indexing_slicing)]
/// On-the-wire value kind tag.
///
/// Per `NmxWriteMessage.cs:94-110` (`GetWireKind`) and
/// `NmxSubscriptionMessage.cs:164-176` (`DecodeValue`). The byte values are
/// the actual wire tags written into / read out of NMX message bodies; they
/// are NOT the `int` ordinals of the C# `MxValueKind` enum
/// (`MxValueKind.cs:3-18`).
///
/// Encoder asymmetry: both `StringArray` and `DateTimeArray` are written as
/// `0x45` on the wire (`NmxWriteMessage.cs:107`), but the decoder
/// distinguishes `0x46` for `DateTimeArray`
/// (`NmxSubscriptionMessage.cs:173,275`). [`MxValue::kind`] reflects the
/// encoder behaviour — see its docs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[repr(u8)]
pub enum MxValueKind {
/// Sentinel for unrecognised wire tags. Not on the wire; used by
/// [`MxValueKind::from_u8`] to surface "unknown kind" without panicking.
#[default]
Unknown = 0x00,
/// `MxValueKind.cs:5` / wire `0x01` (`NmxWriteMessage.cs:98`,
/// `NmxSubscriptionMessage.cs:166`).
Boolean = 0x01,
/// `MxValueKind.cs:6` / wire `0x02` (`NmxWriteMessage.cs:99`,
/// `NmxSubscriptionMessage.cs:167`).
Int32 = 0x02,
/// `MxValueKind.cs:7` / wire `0x03` (`NmxWriteMessage.cs:100`,
/// `NmxSubscriptionMessage.cs:168`).
Float32 = 0x03,
/// `MxValueKind.cs:8` / wire `0x04` (`NmxWriteMessage.cs:101`,
/// `NmxSubscriptionMessage.cs:169`).
Float64 = 0x04,
/// `MxValueKind.cs:9` / wire `0x05` (`NmxWriteMessage.cs:102`,
/// `NmxSubscriptionMessage.cs:170`). Encoder collapses `String` and
/// `DateTime` to the same tag (`NmxWriteMessage.cs:102`).
String = 0x05,
/// `MxValueKind.cs:10` / wire `0x06` on the decode path
/// (`NmxSubscriptionMessage.cs:171`). Encoder collapses to `0x05`
/// (`NmxWriteMessage.cs:102`).
DateTime = 0x06,
/// `MxValueKind.cs:11` / wire `0x07` (`NmxSubscriptionMessage.cs:172,253`).
/// Decoder reads a signed `i32` milliseconds value.
ElapsedTime = 0x07,
/// `MxValueKind.cs:12` / wire `0x41` (`NmxWriteMessage.cs:103`,
/// `NmxSubscriptionMessage.cs:173,270`).
BoolArray = 0x41,
/// `MxValueKind.cs:13` / wire `0x42` (`NmxWriteMessage.cs:104`,
/// `NmxSubscriptionMessage.cs:173,271`).
Int32Array = 0x42,
/// `MxValueKind.cs:14` / wire `0x43` (`NmxWriteMessage.cs:105`,
/// `NmxSubscriptionMessage.cs:173,272`).
Float32Array = 0x43,
/// `MxValueKind.cs:15` / wire `0x44` (`NmxWriteMessage.cs:106`,
/// `NmxSubscriptionMessage.cs:173,273`).
Float64Array = 0x44,
/// `MxValueKind.cs:16` / wire `0x45` (`NmxWriteMessage.cs:107`,
/// `NmxSubscriptionMessage.cs:173,274`). Encoder collapses
/// `StringArray` and `DateTimeArray` to this tag
/// (`NmxWriteMessage.cs:107`).
StringArray = 0x45,
/// `MxValueKind.cs:17` / wire `0x46` on the decode path
/// (`NmxSubscriptionMessage.cs:173,275`). Encoder collapses to `0x45`
/// (`NmxWriteMessage.cs:107`).
DateTimeArray = 0x46,
// ElapsedTimeArray (0x47) is not enumerated by `MxValueKind.cs` and is
// not handled by either the encoder or decoder. Known gap, parity with
// .NET reference.
}
impl MxValueKind {
/// Decode a wire byte into a kind. Returns [`MxValueKind::Unknown`] for
/// any tag not in `0x01..=0x07` or `0x41..=0x46` — mirrors the fall-
/// through arms of `NmxSubscriptionMessage.DecodeValue`
/// (`NmxSubscriptionMessage.cs:174`) and `ToValueKindOrNull` in the
/// .NET reference.
pub fn from_u8(value: u8) -> Self {
match value {
0x01 => Self::Boolean,
0x02 => Self::Int32,
0x03 => Self::Float32,
0x04 => Self::Float64,
0x05 => Self::String,
0x06 => Self::DateTime,
0x07 => Self::ElapsedTime,
0x41 => Self::BoolArray,
0x42 => Self::Int32Array,
0x43 => Self::Float32Array,
0x44 => Self::Float64Array,
0x45 => Self::StringArray,
0x46 => Self::DateTimeArray,
_ => Self::Unknown,
}
}
/// Encode the kind to its wire byte. `Unknown` returns `0x00`; do not
/// emit `Unknown` to the wire — the .NET encoder
/// (`NmxWriteMessage.cs:108`) throws `ArgumentOutOfRangeException` for
/// any kind not in its match.
pub fn to_u8(self) -> u8 {
self as u8
}
}
/// Attribute-model data type — port of `MxDataType.cs:3-24`.
///
/// This is the model-side attribute classification, distinct from the wire
/// `MxValueKind`. The numeric values are the explicit `short` discriminants
/// from `MxDataType.cs:3` (`enum MxDataType : short`). Used by the runtime
/// model and by `RegisterMxReferences` results, not by NMX value bodies.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
#[repr(i16)]
pub enum MxDataType {
/// `MxDataType.cs:5` — sentinel for "no type known".
#[default]
Unknown = -1,
/// `MxDataType.cs:6`.
NoData = 0,
/// `MxDataType.cs:7`.
Boolean = 1,
/// `MxDataType.cs:8`.
Integer = 2,
/// `MxDataType.cs:9`.
Float = 3,
/// `MxDataType.cs:10`.
Double = 4,
/// `MxDataType.cs:11`.
String = 5,
/// `MxDataType.cs:12`.
Time = 6,
/// `MxDataType.cs:13`.
ElapsedTime = 7,
/// `MxDataType.cs:14`.
ReferenceType = 8,
/// `MxDataType.cs:15`.
StatusType = 9,
/// `MxDataType.cs:16`.
Enum = 10,
/// `MxDataType.cs:17`.
SecurityClassificationEnum = 11,
/// `MxDataType.cs:18`.
DataQualityType = 12,
/// `MxDataType.cs:19`.
QualifiedEnum = 13,
/// `MxDataType.cs:20`.
QualifiedStruct = 14,
/// `MxDataType.cs:21`.
InternationalizedString = 15,
/// `MxDataType.cs:22`.
BigString = 16,
/// `MxDataType.cs:23` — terminator sentinel from the .NET enum.
End = 17,
}
impl MxDataType {
/// Decode the model-side type id. Out-of-range values map to
/// [`MxDataType::Unknown`]. Mirrors the `Unknown = -1` sentinel from
/// `MxDataType.cs:5`.
pub fn from_i16(value: i16) -> Self {
match value {
0 => Self::NoData,
1 => Self::Boolean,
2 => Self::Integer,
3 => Self::Float,
4 => Self::Double,
5 => Self::String,
6 => Self::Time,
7 => Self::ElapsedTime,
8 => Self::ReferenceType,
9 => Self::StatusType,
10 => Self::Enum,
11 => Self::SecurityClassificationEnum,
12 => Self::DataQualityType,
13 => Self::QualifiedEnum,
14 => Self::QualifiedStruct,
15 => Self::InternationalizedString,
16 => Self::BigString,
17 => Self::End,
_ => Self::Unknown,
}
}
pub fn to_i16(self) -> i16 {
self as i16
}
}
/// Runtime carrier for a decoded MXAccess value.
///
/// Variant set tracks `NmxSubscriptionMessage.DecodeValue`
/// (`NmxSubscriptionMessage.cs:164-176`): the seven scalar wire kinds
/// (`0x01..=0x07`) plus the six array wire kinds (`0x41..=0x46`,
/// minus `ElapsedTimeArray` which has no .NET support).
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum MxValue {
/// Scalar boolean, wire `0x01` (`NmxSubscriptionMessage.cs:166`).
Boolean(bool),
/// Scalar `i32`, wire `0x02` (`NmxSubscriptionMessage.cs:167`).
Int32(i32),
/// Scalar `f32`, wire `0x03` (`NmxSubscriptionMessage.cs:168`).
Float32(f32),
/// Scalar `f64`, wire `0x04` (`NmxSubscriptionMessage.cs:169`).
Float64(f64),
/// Scalar UTF-16LE string, wire `0x05`
/// (`NmxSubscriptionMessage.cs:170,178-210`).
String(String),
/// Windows `FILETIME` ticks (100ns since 1601-01-01 UTC), wire `0x06`
/// (`NmxSubscriptionMessage.cs:171,212-243`). Carries the raw `i64`
/// rather than a `DateTime` to preserve byte-for-byte parity even when
/// the value falls outside `chrono`/`time` clamp ranges.
DateTime(i64),
/// Signed milliseconds, wire `0x07`
/// (`NmxSubscriptionMessage.cs:172,245-254`). Wire is `i32`; widened to
/// `i64` here to allow the model to be unambiguous about sign and to
/// avoid forcing the `std::time::Duration` (unsigned) shape.
ElapsedTime(i64),
/// Boolean array, wire `0x41`
/// (`NmxSubscriptionMessage.cs:173,280-294`). On the wire each element
/// is an `i16` per `elementWidth==sizeof(short)` check.
BoolArray(Vec<bool>),
/// `i32` array, wire `0x42`
/// (`NmxSubscriptionMessage.cs:173,296-310`).
Int32Array(Vec<i32>),
/// `f32` array, wire `0x43`
/// (`NmxSubscriptionMessage.cs:173,312-326`).
Float32Array(Vec<f32>),
/// `f64` array, wire `0x44`
/// (`NmxSubscriptionMessage.cs:173,328-342`).
Float64Array(Vec<f64>),
/// String array, wire `0x45` on encode AND decode
/// (`NmxWriteMessage.cs:107`, `NmxSubscriptionMessage.cs:173,274`).
StringArray(Vec<String>),
/// `DateTime` array; decoded from wire `0x46`
/// (`NmxSubscriptionMessage.cs:173,275`), but encoded as `0x45`
/// (`NmxWriteMessage.cs:107`). Elements are raw `FILETIME` ticks.
DateTimeArray(Vec<i64>),
}
impl MxValue {
/// Wire kind for this value. Mirrors `NmxWriteMessage.GetWireKind`
/// (`NmxWriteMessage.cs:94-110`) — i.e. the *encoder* behaviour.
///
/// **Encoder collapse:** both [`MxValue::StringArray`] and
/// [`MxValue::DateTimeArray`] return [`MxValueKind::StringArray`]
/// (`0x45`) here, matching `NmxWriteMessage.cs:107`. The decoder is
/// asymmetric (`0x46` round-trips back into `DateTimeArray`); see
/// [`MxValueKind`] docs.
pub fn kind(&self) -> MxValueKind {
match self {
Self::Boolean(_) => MxValueKind::Boolean,
Self::Int32(_) => MxValueKind::Int32,
Self::Float32(_) => MxValueKind::Float32,
Self::Float64(_) => MxValueKind::Float64,
Self::String(_) => MxValueKind::String,
// Per NmxWriteMessage.cs:102 the encoder collapses DateTime to
// the String wire tag (0x05). Returning `DateTime` here keeps
// the model side honest; encoders should re-map via
// `MxValueKind::to_u8` semantics or call out to a future
// `to_wire_byte` helper.
Self::DateTime(_) => MxValueKind::DateTime,
Self::ElapsedTime(_) => MxValueKind::ElapsedTime,
Self::BoolArray(_) => MxValueKind::BoolArray,
Self::Int32Array(_) => MxValueKind::Int32Array,
Self::Float32Array(_) => MxValueKind::Float32Array,
Self::Float64Array(_) => MxValueKind::Float64Array,
// Encoder collapse: NmxWriteMessage.cs:107 maps both
// StringArray and DateTimeArray to wire 0x45. Matches the
// .NET encoder; the decoder asymmetrically uses 0x46 for
// DateTimeArray (NmxSubscriptionMessage.cs:275).
Self::StringArray(_) => MxValueKind::StringArray,
Self::DateTimeArray(_) => MxValueKind::StringArray,
}
}
/// Model-side data type hint. This is best-effort: the wire never
/// carries `MxDataType` — it's the model classification used by
/// register results and the public API. Arrays return their scalar
/// element's `MxDataType` (the .NET reference does not have an
/// "array of X" `MxDataType` discriminant).
pub fn data_type(&self) -> MxDataType {
match self {
Self::Boolean(_) | Self::BoolArray(_) => MxDataType::Boolean,
Self::Int32(_) | Self::Int32Array(_) => MxDataType::Integer,
Self::Float32(_) | Self::Float32Array(_) => MxDataType::Float,
Self::Float64(_) | Self::Float64Array(_) => MxDataType::Double,
Self::String(_) | Self::StringArray(_) => MxDataType::String,
Self::DateTime(_) | Self::DateTimeArray(_) => MxDataType::Time,
Self::ElapsedTime(_) => MxDataType::ElapsedTime,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const ALL_KINDS: &[MxValueKind] = &[
MxValueKind::Unknown,
MxValueKind::Boolean,
MxValueKind::Int32,
MxValueKind::Float32,
MxValueKind::Float64,
MxValueKind::String,
MxValueKind::DateTime,
MxValueKind::ElapsedTime,
MxValueKind::BoolArray,
MxValueKind::Int32Array,
MxValueKind::Float32Array,
MxValueKind::Float64Array,
MxValueKind::StringArray,
MxValueKind::DateTimeArray,
];
#[test]
fn value_kind_round_trip() {
for &kind in ALL_KINDS {
assert_eq!(MxValueKind::from_u8(kind.to_u8()), kind, "{kind:?}");
}
}
#[test]
fn value_kind_unknown_byte_maps_to_unknown() {
assert_eq!(MxValueKind::from_u8(0xff), MxValueKind::Unknown);
// Tags between scalar (0x07) and array (0x41) ranges are not
// assigned in the .NET reference.
assert_eq!(MxValueKind::from_u8(0x08), MxValueKind::Unknown);
assert_eq!(MxValueKind::from_u8(0x40), MxValueKind::Unknown);
// 0x47 (would-be ElapsedTimeArray) is a documented gap — must
// currently surface as Unknown.
assert_eq!(MxValueKind::from_u8(0x47), MxValueKind::Unknown);
}
#[test]
fn value_kind_to_u8_matches_wire_tags() {
// Spot-check the wire bytes against the .cs sources.
assert_eq!(MxValueKind::Boolean.to_u8(), 0x01);
assert_eq!(MxValueKind::Int32.to_u8(), 0x02);
assert_eq!(MxValueKind::Float32.to_u8(), 0x03);
assert_eq!(MxValueKind::Float64.to_u8(), 0x04);
assert_eq!(MxValueKind::String.to_u8(), 0x05);
assert_eq!(MxValueKind::DateTime.to_u8(), 0x06);
assert_eq!(MxValueKind::ElapsedTime.to_u8(), 0x07);
assert_eq!(MxValueKind::BoolArray.to_u8(), 0x41);
assert_eq!(MxValueKind::Int32Array.to_u8(), 0x42);
assert_eq!(MxValueKind::Float32Array.to_u8(), 0x43);
assert_eq!(MxValueKind::Float64Array.to_u8(), 0x44);
assert_eq!(MxValueKind::StringArray.to_u8(), 0x45);
assert_eq!(MxValueKind::DateTimeArray.to_u8(), 0x46);
}
#[test]
fn data_type_round_trip() {
let all = [
MxDataType::Unknown,
MxDataType::NoData,
MxDataType::Boolean,
MxDataType::Integer,
MxDataType::Float,
MxDataType::Double,
MxDataType::String,
MxDataType::Time,
MxDataType::ElapsedTime,
MxDataType::ReferenceType,
MxDataType::StatusType,
MxDataType::Enum,
MxDataType::SecurityClassificationEnum,
MxDataType::DataQualityType,
MxDataType::QualifiedEnum,
MxDataType::QualifiedStruct,
MxDataType::InternationalizedString,
MxDataType::BigString,
MxDataType::End,
];
for dt in all {
assert_eq!(MxDataType::from_i16(dt.to_i16()), dt, "{dt:?}");
}
}
#[test]
fn data_type_out_of_range_maps_to_unknown() {
assert_eq!(MxDataType::from_i16(-2), MxDataType::Unknown);
assert_eq!(MxDataType::from_i16(18), MxDataType::Unknown);
assert_eq!(MxDataType::from_i16(i16::MAX), MxDataType::Unknown);
assert_eq!(MxDataType::from_i16(i16::MIN), MxDataType::Unknown);
}
#[test]
fn value_kind_for_each_variant() {
assert_eq!(MxValue::Boolean(true).kind(), MxValueKind::Boolean);
assert_eq!(MxValue::Int32(0).kind(), MxValueKind::Int32);
assert_eq!(MxValue::Float32(0.0).kind(), MxValueKind::Float32);
assert_eq!(MxValue::Float64(0.0).kind(), MxValueKind::Float64);
assert_eq!(MxValue::String(String::new()).kind(), MxValueKind::String);
assert_eq!(MxValue::DateTime(0).kind(), MxValueKind::DateTime);
assert_eq!(MxValue::ElapsedTime(0).kind(), MxValueKind::ElapsedTime);
assert_eq!(MxValue::BoolArray(vec![]).kind(), MxValueKind::BoolArray);
assert_eq!(MxValue::Int32Array(vec![]).kind(), MxValueKind::Int32Array);
assert_eq!(
MxValue::Float32Array(vec![]).kind(),
MxValueKind::Float32Array
);
assert_eq!(
MxValue::Float64Array(vec![]).kind(),
MxValueKind::Float64Array
);
}
/// Both `StringArray` and `DateTimeArray` collapse to the same wire
/// kind on the encode path. This mirrors `NmxWriteMessage.cs:107`:
///
/// ```text
/// MxValueKind.StringArray or MxValueKind.DateTimeArray => 0x45,
/// ```
#[test]
fn string_and_datetime_arrays_collapse_to_string_array_wire_kind() {
let s = MxValue::StringArray(vec!["a".to_string()]);
let d = MxValue::DateTimeArray(vec![0_i64]);
assert_eq!(s.kind(), MxValueKind::StringArray);
assert_eq!(d.kind(), MxValueKind::StringArray);
assert_eq!(s.kind(), d.kind());
assert_eq!(s.kind().to_u8(), 0x45);
assert_eq!(d.kind().to_u8(), 0x45);
}
#[test]
fn data_type_for_each_value() {
assert_eq!(MxValue::Boolean(false).data_type(), MxDataType::Boolean);
assert_eq!(MxValue::Int32(0).data_type(), MxDataType::Integer);
assert_eq!(MxValue::Float32(0.0).data_type(), MxDataType::Float);
assert_eq!(MxValue::Float64(0.0).data_type(), MxDataType::Double);
assert_eq!(
MxValue::String(String::new()).data_type(),
MxDataType::String
);
assert_eq!(MxValue::DateTime(0).data_type(), MxDataType::Time);
assert_eq!(MxValue::ElapsedTime(0).data_type(), MxDataType::ElapsedTime);
assert_eq!(MxValue::BoolArray(vec![]).data_type(), MxDataType::Boolean);
assert_eq!(MxValue::DateTimeArray(vec![]).data_type(), MxDataType::Time);
}
#[test]
fn defaults_match_dotnet_sentinels() {
// MxValueKind::Unknown is the from_u8 fall-through and the Default
// (matches `ToValueKindOrNull` semantics in
// `NmxSubscriptionMessage.cs:174`).
assert_eq!(MxValueKind::default(), MxValueKind::Unknown);
// MxDataType::Unknown == -1 per `MxDataType.cs:5`.
assert_eq!(MxDataType::default(), MxDataType::Unknown);
assert_eq!(MxDataType::default().to_i16(), -1);
}
}
File diff suppressed because it is too large Load Diff