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

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

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

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

1108 lines
42 KiB
Rust

//! `NmxSubscriptionMessage` — `0x32` SubscriptionStatus / `0x33` DataUpdate
//! callback decoder.
//!
//! Direct port of `src/MxNativeCodec/NmxSubscriptionMessage.cs`. This module
//! decodes the inner-body of the NMX subscription callback delivered through
//! the NMX `TransferData` envelope (callers should call
//! [`NmxTransferEnvelope::parse`](crate::NmxTransferEnvelope::parse) first to
//! peel the 46-byte transfer header, then hand the remaining bytes to
//! [`NmxSubscriptionMessage::parse_inner`]).
//!
//! ## Wire layout summary
//!
//! Both message kinds share the 23-byte preamble
//! (`NmxSubscriptionMessage.cs:52-55`):
//!
//! ```text
//! offset size field
//! 0 1 command (0x32 SubscriptionStatus, 0x33 DataUpdate)
//! 1 2 version u16 LE
//! 3 4 record_count i32 LE
//! 7 16 operation_id GUID (.NET layout)
//! ```
//!
//! `0x32` SubscriptionStatus extends to 39 bytes by appending a 16-byte
//! `item_correlation_id` GUID at offset 23 (`NmxSubscriptionMessage.cs:98-99`).
//! `0x33` DataUpdate has **no** correlation id — its records start at offset 23
//! (`NmxSubscriptionMessage.cs:76-77`).
//!
//! ## Record layout
//!
//! - SubscriptionStatus record: `status i32 + detail_status i32 + quality u16
//! + timestamp_filetime i64 + wire_kind u8 + value` (`hasDetailStatus=true`,
//! `NmxSubscriptionMessage.cs:117-155`).
//! - DataUpdate record: `quality u16 + timestamp_filetime i64 + wire_kind u8
//! + value` (`hasDetailStatus=false`).
//!
//! ## Hard-error: DataUpdate multi-record
//!
//! The .NET reference rejects DataUpdate bodies with `record_count != 1`
//! (`NmxSubscriptionMessage.cs:71-74`). The Rust codec mirrors that hard error
//! via [`CodecError::Decode`] — see `design/70-risks-and-open-questions.md` R13
//! for the soft-error path that the higher-level session layer may add later.
//!
//! ## Encoder/decoder asymmetry: array element width
//!
//! On the wire, the array header is `count u16 LE` at body+4 followed by
//! `element_width` at body+6. The decoder reads `element_width` as **`i32`
//! LE** (`NmxSubscriptionMessage.cs:264-265`); the encoder side (`write_message.rs`,
//! NOT this module) writes `u16/u16`. This asymmetry is real and intentional —
//! the decoder must accept whatever the native NMX service emits.
//!
//! ## Wire-kind table
//!
//! Scalar: `0x01` Boolean, `0x02` Int32, `0x03` Float32, `0x04` Float64,
//! `0x05` String, `0x06` DateTime, `0x07` ElapsedTime
//! (`NmxSubscriptionMessage.cs:165-176`).
//!
//! Array: `0x41` BoolArray, `0x42` Int32Array, `0x43` Float32Array,
//! `0x44` Float64Array, `0x45` StringArray, `0x46` DateTimeArray
//! (`NmxSubscriptionMessage.cs:268-277`). Note the encoder collapses
//! StringArray/DateTimeArray to `0x45`; the decoder keeps `0x46` as
//! DateTimeArray.
// Direct byte indexing — see reference_handle.rs for rationale (every byte
// access is preceded by an explicit length check; matches the .NET source's
// `BinaryPrimitives` calls 1:1 and is far more readable than `.get(n)?`).
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
use crate::{MxValue, MxValueKind};
/// `0x32` — SubscriptionStatus. Per `NmxSubscriptionMessage.cs:36`.
pub const SUBSCRIPTION_STATUS_COMMAND: u8 = 0x32;
/// `0x33` — DataUpdate. Per `NmxSubscriptionMessage.cs:37`.
pub const DATA_UPDATE_COMMAND: u8 = 0x33;
/// 16-byte GUID with the .NET on-the-wire layout used by `new Guid(span)` /
/// `Guid.WriteToSpan` (data1 LE, data2 LE, data3 LE, then 8 raw bytes).
/// Mirrors `NmxSubscriptionMessage.cs:55,98` (the .NET `Guid(ReadOnlySpan<byte>)`
/// constructor consumes exactly 16 bytes in this layout).
///
/// Stored as the raw 16 bytes verbatim — the codec preserves whatever the
/// service emits and surfaces it to consumers as a stable identifier; no
/// interpretation is needed at the codec layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct NmxGuid(pub [u8; 16]);
impl NmxGuid {
/// Encoded GUID size on the wire.
pub const ENCODED_LEN: usize = 16;
/// Construct from a 16-byte slice.
///
/// # Errors
///
/// Returns [`CodecError::ShortRead`] if `bytes.len() != 16`.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CodecError> {
if bytes.len() != Self::ENCODED_LEN {
return Err(CodecError::ShortRead {
expected: Self::ENCODED_LEN,
actual: bytes.len(),
});
}
let mut buf = [0u8; 16];
buf.copy_from_slice(bytes);
Ok(Self(buf))
}
}
/// One record from a [`NmxSubscriptionMessage`]. SubscriptionStatus records
/// carry `status`/`detail_status`; DataUpdate records leave both as `None`.
///
/// Per `NmxSubscriptionMessage.cs:12-26`.
#[derive(Debug, Clone, PartialEq)]
pub struct NmxSubscriptionRecord {
/// Status code — `i32` always present for both DataUpdate and
/// SubscriptionStatus records (`NmxSubscriptionMessage.cs:126-127` reads
/// it unconditionally).
pub status: i32,
/// Detail-status code — present for SubscriptionStatus (`0x32`) only;
/// `None` for DataUpdate (`NmxSubscriptionMessage.cs:129-134`).
pub detail_status: Option<i32>,
/// Quality bitfield (`NmxSubscriptionMessage.cs:136-137`).
pub quality: u16,
/// Windows FILETIME ticks (100ns units since 1601-01-01 UTC). Mirrors the
/// raw `i64` that the .NET reference passes to `DateTime.FromFileTimeUtc`
/// (`NmxSubscriptionMessage.cs:139-150`).
pub timestamp_filetime: i64,
/// Wire-kind tag from the body (`NmxSubscriptionMessage.cs:142`).
pub wire_kind: u8,
/// Decoded value, when the wire kind is recognized and the body is
/// well-formed. Malformed/unknown payloads (negative lengths, bad
/// element widths, unknown wire kinds) yield `None` — mirrors the .NET
/// `NmxCallbackValue.Value = null` paths (e.g. `NmxSubscriptionMessage.cs:182,199`).
pub value: Option<MxValue>,
/// Offset into the inner buffer where this record began.
pub offset: usize,
/// Total bytes this record consumed from the inner buffer.
pub length: usize,
}
/// Parsed `0x32`/`0x33` subscription callback message.
/// Per `NmxSubscriptionMessage.cs:28-34`.
#[derive(Debug, Clone, PartialEq)]
pub struct NmxSubscriptionMessage {
/// `0x32` SubscriptionStatus or `0x33` DataUpdate.
pub command: u8,
/// `version` field (`NmxSubscriptionMessage.cs:53`).
pub version: u16,
/// `record_count` field (`NmxSubscriptionMessage.cs:54`).
pub record_count: i32,
/// `operation_id` GUID (`NmxSubscriptionMessage.cs:55`).
pub operation_id: NmxGuid,
/// `item_correlation_id` GUID — present only on `0x32` SubscriptionStatus
/// (`NmxSubscriptionMessage.cs:98`); always `None` on `0x33` DataUpdate.
pub item_correlation_id: Option<NmxGuid>,
/// Decoded records.
pub records: Vec<NmxSubscriptionRecord>,
}
impl NmxSubscriptionMessage {
/// Length of the shared 23-byte preamble (`NmxSubscriptionMessage.cs:47`).
pub const PREAMBLE_LEN: usize = 23;
/// Length of the SubscriptionStatus header — preamble + 16-byte
/// correlation id (`NmxSubscriptionMessage.cs:93,99`).
pub const SUBSCRIPTION_STATUS_HEADER_LEN: usize = 39;
/// Parse the inner body (post-46-byte-envelope) of an NMX subscription
/// callback. Mirrors `NmxSubscriptionMessage.ParseInner`
/// (`NmxSubscriptionMessage.cs:45-63`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if `inner.len() < 23`.
/// - [`CodecError::UnexpectedOpcode`] if the command byte is neither
/// `0x32` nor `0x33`.
/// - [`CodecError::Decode`] for protocol violations (multi-record
/// DataUpdate, truncated records, etc.).
pub fn parse_inner(inner: &[u8]) -> Result<Self, CodecError> {
if inner.len() < Self::PREAMBLE_LEN {
return Err(CodecError::ShortRead {
expected: Self::PREAMBLE_LEN,
actual: inner.len(),
});
}
let command = inner[0];
let version = read_u16_le(inner, 1);
let record_count = read_i32_le(inner, 3);
let operation_id = NmxGuid::from_bytes(&inner[7..23])?;
match command {
SUBSCRIPTION_STATUS_COMMAND => {
parse_subscription_status(inner, version, record_count, operation_id)
}
DATA_UPDATE_COMMAND => parse_data_update(inner, version, record_count, operation_id),
_ => Err(CodecError::UnexpectedOpcode(command)),
}
}
}
/// `0x33` DataUpdate. Mirrors `NmxSubscriptionMessage.ParseDataUpdate`
/// (`NmxSubscriptionMessage.cs:65-85`).
fn parse_data_update(
inner: &[u8],
version: u16,
record_count: i32,
operation_id: NmxGuid,
) -> Result<NmxSubscriptionMessage, CodecError> {
// .NET hard-throws when `record_count != 1` (`NmxSubscriptionMessage.cs:71-74`).
// Mirror that here — the soft-error path is owned by the higher session
// layer (R13 in `design/70-risks-and-open-questions.md`).
if record_count != 1 {
return Err(CodecError::Decode {
offset: 3,
reason: "DataUpdate multi-record bodies are not yet supported",
buffer_len: inner.len(),
});
}
// Records start immediately after the 23-byte preamble — DataUpdate has
// no correlation id (`NmxSubscriptionMessage.cs:76-77`).
let record = parse_record(inner, NmxSubscriptionMessage::PREAMBLE_LEN, false)?;
Ok(NmxSubscriptionMessage {
command: DATA_UPDATE_COMMAND,
version,
record_count,
operation_id,
item_correlation_id: None,
records: vec![record],
})
}
/// `0x32` SubscriptionStatus. Mirrors
/// `NmxSubscriptionMessage.ParseSubscriptionStatus`
/// (`NmxSubscriptionMessage.cs:87-115`).
fn parse_subscription_status(
inner: &[u8],
version: u16,
record_count: i32,
operation_id: NmxGuid,
) -> Result<NmxSubscriptionMessage, CodecError> {
if inner.len() < NmxSubscriptionMessage::SUBSCRIPTION_STATUS_HEADER_LEN {
return Err(CodecError::ShortRead {
expected: NmxSubscriptionMessage::SUBSCRIPTION_STATUS_HEADER_LEN,
actual: inner.len(),
});
}
let item_correlation_id = NmxGuid::from_bytes(&inner[23..39])?;
let mut offset = NmxSubscriptionMessage::SUBSCRIPTION_STATUS_HEADER_LEN;
// `record_count` is `i32` on the wire; clamp negatives to zero. The .NET
// for-loop `for (int i = 0; i < recordCount; i++)` also yields zero
// iterations for negative counts (`NmxSubscriptionMessage.cs:101`).
let count = if record_count < 0 {
0usize
} else {
record_count as usize
};
let mut records = Vec::with_capacity(count);
for _ in 0..count {
let record = parse_record(inner, offset, true)?;
offset += record.length;
records.push(record);
}
Ok(NmxSubscriptionMessage {
command: SUBSCRIPTION_STATUS_COMMAND,
version,
record_count,
operation_id,
item_correlation_id: Some(item_correlation_id),
records,
})
}
/// Parse a single record. Mirrors `NmxSubscriptionMessage.ParseRecord`
/// (`NmxSubscriptionMessage.cs:117-155`). When `has_detail_status` is true the
/// record begins with `status i32 + detail_status i32`; otherwise neither is
/// present.
fn parse_record(
body: &[u8],
offset: usize,
has_detail_status: bool,
) -> Result<NmxSubscriptionRecord, CodecError> {
// Minimum length is 19 with detail-status (status + detail_status +
// quality + timestamp + wire_kind = 4+4+2+8+1) and 15 without
// (`NmxSubscriptionMessage.cs:119`). We additionally require the
// wire-kind byte itself to be present (`body[offset++]` at line 142).
let minimum_length = if has_detail_status { 19 } else { 15 };
if offset + minimum_length > body.len() {
return Err(CodecError::Decode {
offset,
reason: "subscription record truncated before fixed header",
buffer_len: body.len(),
});
}
let start = offset;
let mut cursor = offset;
// `status: i32` is read unconditionally for both DataUpdate and
// SubscriptionStatus records (`NmxSubscriptionMessage.cs:126-127`).
//
// FOLLOW-UP (M1 wave-1 audit): An earlier port draft conditionally read
// `status` only when `has_detail_status=true`, then required min length 15
// for DataUpdate without consuming the leading 4 bytes — leaving them to
// be misread as `quality`. Verified fixed here; if any other codec agent
// applied the same `hasDetailStatus`-gated conditional read pattern,
// re-audit. Min lengths are 15 (DataUpdate, status+quality+filetime+kind)
// and 19 (SubscriptionStatus, +detail_status). See
// `design/70-risks-and-open-questions.md` "M1 hasDetailStatus audit"
// follow-up entry.
let status = read_i32_le(body, cursor);
cursor += 4;
let detail_status = if has_detail_status {
let d = read_i32_le(body, cursor);
cursor += 4;
Some(d)
} else {
None
};
let quality = read_u16_le(body, cursor);
cursor += 2;
let timestamp_filetime = read_i64_le(body, cursor);
cursor += 8;
let wire_kind = body[cursor];
cursor += 1;
let (value, encoded_len) = decode_value(wire_kind, &body[cursor..]);
cursor += encoded_len;
Ok(NmxSubscriptionRecord {
status,
detail_status,
quality,
timestamp_filetime,
wire_kind,
value,
offset: start,
length: cursor - start,
})
}
/// Decode a value following a wire-kind byte. Returns `(decoded, bytes_consumed)`.
/// Mirrors `NmxSubscriptionMessage.DecodeValue` (`NmxSubscriptionMessage.cs:157-176`)
/// and the per-kind helpers below it.
///
/// On any short / malformed payload returns `(None, 0)` — matching the .NET
/// behaviour where `NmxCallbackValue.Value` is null and `EncodedLength` is 0.
/// (Subsequent records following a malformed value are unrecoverable; the
/// .NET reference exhibits the same property.)
fn decode_value(wire_kind: u8, body: &[u8]) -> (Option<MxValue>, usize) {
if body.is_empty() {
return (None, 0);
}
match wire_kind {
// 0x01 Boolean — single byte, non-zero is true (`NmxSubscriptionMessage.cs:166`).
0x01 if !body.is_empty() => (Some(MxValue::Boolean(body[0] != 0)), 1),
// 0x02 Int32 (`NmxSubscriptionMessage.cs:167`).
0x02 if body.len() >= 4 => (Some(MxValue::Int32(read_i32_le(body, 0))), 4),
// 0x03 Float32 — bit-cast i32->f32 mirrors `Int32BitsToSingle`
// (`NmxSubscriptionMessage.cs:168`).
0x03 if body.len() >= 4 => {
let bits = read_i32_le(body, 0);
(Some(MxValue::Float32(f32::from_bits(bits as u32))), 4)
}
// 0x04 Float64 — bit-cast i64->f64 (`NmxSubscriptionMessage.cs:169`).
0x04 if body.len() >= 8 => {
let bits = read_i64_le(body, 0);
(Some(MxValue::Float64(f64::from_bits(bits as u64))), 8)
}
// 0x05 String (`NmxSubscriptionMessage.cs:170`).
0x05 => decode_string_value(body),
// 0x06 DateTime (`NmxSubscriptionMessage.cs:171`).
0x06 => decode_datetime_value(body),
// 0x07 ElapsedTime (`NmxSubscriptionMessage.cs:172`).
0x07 => decode_elapsed_time_value(body),
// Arrays 0x41..0x46 (`NmxSubscriptionMessage.cs:173`).
0x41..=0x46 => decode_array_value(wire_kind, body),
// Unknown / malformed: matches the `_ => new NmxCallbackValue(...
// EncodedLength=0)` arms in the .NET source.
_ => (None, 0),
}
}
/// Decode a string body. Mirrors `DecodeStringValue`
/// (`NmxSubscriptionMessage.cs:178-210`).
///
/// Layout: `record_length i32 + text_byte_length i32 + utf16le bytes`. The
/// degenerate `record_length == 4` case represents an empty string and
/// consumes exactly 4 bytes (`NmxSubscriptionMessage.cs:186-189`). Otherwise
/// the trailing two-byte UTF-16 NUL is stripped if present
/// (`NmxSubscriptionMessage.cs:203-206`).
fn decode_string_value(body: &[u8]) -> (Option<MxValue>, usize) {
if body.len() < 4 {
return (None, 0);
}
let record_length = read_i32_le(body, 0);
if record_length == 4 {
return (Some(MxValue::String(String::new())), 4);
}
if body.len() < 8 {
return (None, 0);
}
let text_byte_length = read_i32_le(body, 4);
// .NET checks: `recordLength < 8 || textByteLength < 0 ||
// recordLength != textByteLength + 4 || body.Length < 8 + textByteLength`
// (`NmxSubscriptionMessage.cs:197`).
if record_length < 8 || text_byte_length < 0 {
return (None, 0);
}
let text_byte_length_us = text_byte_length as usize;
if record_length as usize != text_byte_length_us + 4 || body.len() < 8 + text_byte_length_us {
return (None, 0);
}
let mut text_bytes = &body[8..8 + text_byte_length_us];
// Strip optional UTF-16LE NUL terminator.
if text_bytes.len() >= 2
&& text_bytes[text_bytes.len() - 2] == 0
&& text_bytes[text_bytes.len() - 1] == 0
{
text_bytes = &text_bytes[..text_bytes.len() - 2];
}
let value = decode_utf16_le_lossy(text_bytes);
(Some(MxValue::String(value)), 8 + text_byte_length_us)
}
/// Decode a DateTime body. Mirrors `DecodeDateTimeValue`
/// (`NmxSubscriptionMessage.cs:212-243`).
///
/// Two shapes exist on the wire:
/// 1. `record_length i32 + filetime i64 (+ trailer)` — used when
/// `body.len() >= 14` (`record_length >= 10`); consumes
/// `4 + record_length` bytes.
/// 2. Bare `filetime i64` — fallback when the framed shape doesn't fit;
/// consumes 8 bytes.
fn decode_datetime_value(body: &[u8]) -> (Option<MxValue>, usize) {
if body.len() >= 14 {
let record_length = read_i32_le(body, 0);
if record_length >= 10 && body.len() >= 4 + record_length as usize {
let file_time = read_i64_le(body, 4);
// The .NET reference returns `Value = null` when the FILETIME
// is out of range (`NmxSubscriptionMessage.cs:229`) but still
// consumes `4 + record_length` bytes. We carry the raw FILETIME
// verbatim — the codec preserves the wire value and lets the
// higher layer judge validity.
return (
Some(MxValue::DateTime(file_time)),
4 + record_length as usize,
);
}
}
if body.len() >= 8 {
let file_time = read_i64_le(body, 0);
return (Some(MxValue::DateTime(file_time)), 8);
}
(None, 0)
}
/// Decode an ElapsedTime body. Mirrors `DecodeElapsedTimeValue`
/// (`NmxSubscriptionMessage.cs:245-254`).
///
/// **Wire is signed i32 milliseconds** (`NmxSubscriptionMessage.cs:252` reads
/// `BinaryPrimitives.ReadInt32LittleEndian`). Negative values are valid and
/// must round-trip — Rust `Duration` is unsigned so we widen to `i64` ms in
/// `MxValue::ElapsedTime` (lib.rs:73-74).
fn decode_elapsed_time_value(body: &[u8]) -> (Option<MxValue>, usize) {
if body.len() < 4 {
return (None, 0);
}
let milliseconds = read_i32_le(body, 0);
(Some(MxValue::ElapsedTime(milliseconds as i64)), 4)
}
/// Decode an array body. Mirrors `DecodeArrayValue`
/// (`NmxSubscriptionMessage.cs:256-278`).
///
/// Header layout (per the **decoder**, see module-level note about asymmetry):
/// `unknown 4 bytes + count u16 LE @+4 + element_width i32 LE @+6 + values`.
/// Total header = 10 bytes (`NmxSubscriptionMessage.cs:258`). The first 4
/// bytes appear to be a record-length / record-kind framing field; the .NET
/// reference does not interpret them and neither do we — they pass through
/// the consumed-byte accounting via the fixed `arrayHeaderLength = 10`.
fn decode_array_value(wire_kind: u8, body: &[u8]) -> (Option<MxValue>, usize) {
const ARRAY_HEADER_LEN: usize = 10;
if body.len() < ARRAY_HEADER_LEN {
return (None, 0);
}
let count = read_u16_le(body, 4) as usize;
// Decoder reads element_width as i32 LE — the encoder writes u16/u16 but
// the wire emitted by NmxSvc puts an i32 here. (`NmxSubscriptionMessage.cs:265`.)
let element_width = read_i32_le(body, 6);
let values = &body[ARRAY_HEADER_LEN..];
match wire_kind {
0x41 => decode_bool_array(count, element_width, values),
0x42 => decode_int32_array(count, element_width, values),
0x43 => decode_float32_array(count, element_width, values),
0x44 => decode_float64_array(count, element_width, values),
0x45 => decode_string_array(count, values),
0x46 => decode_datetime_array(count, element_width, values),
// Unreachable given the guard in `decode_value`, but keep it total.
_ => (None, 0),
}
}
/// Bool array decoder — element width must be `sizeof(short) == 2`, elements
/// are `i16` LE where any non-zero value is true. Per the .NET reference the
/// wire encoding is `-1`/`0` (`NmxSubscriptionMessage.cs:280-294`).
fn decode_bool_array(count: usize, element_width: i32, values: &[u8]) -> (Option<MxValue>, usize) {
if element_width != 2 {
return (None, 0);
}
let needed = count.saturating_mul(2);
if values.len() < needed {
return (None, 0);
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
// Boolean elements are i16 (`-1`/`0` in practice); `!= 0` covers both.
let raw = read_i16_le(values, i * 2);
out.push(raw != 0);
}
(Some(MxValue::BoolArray(out)), 10 + count * 2)
}
/// Int32 array decoder. Per `NmxSubscriptionMessage.cs:296-310`.
fn decode_int32_array(count: usize, element_width: i32, values: &[u8]) -> (Option<MxValue>, usize) {
if element_width != 4 {
return (None, 0);
}
let needed = count.saturating_mul(4);
if values.len() < needed {
return (None, 0);
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
out.push(read_i32_le(values, i * 4));
}
(Some(MxValue::Int32Array(out)), 10 + count * 4)
}
/// Float32 array decoder. Per `NmxSubscriptionMessage.cs:312-326`.
fn decode_float32_array(
count: usize,
element_width: i32,
values: &[u8],
) -> (Option<MxValue>, usize) {
if element_width != 4 {
return (None, 0);
}
let needed = count.saturating_mul(4);
if values.len() < needed {
return (None, 0);
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
let bits = read_i32_le(values, i * 4);
out.push(f32::from_bits(bits as u32));
}
(Some(MxValue::Float32Array(out)), 10 + count * 4)
}
/// Float64 array decoder. Per `NmxSubscriptionMessage.cs:328-342`.
fn decode_float64_array(
count: usize,
element_width: i32,
values: &[u8],
) -> (Option<MxValue>, usize) {
if element_width != 8 {
return (None, 0);
}
let needed = count.saturating_mul(8);
if values.len() < needed {
return (None, 0);
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
let bits = read_i64_le(values, i * 8);
out.push(f64::from_bits(bits as u64));
}
(Some(MxValue::Float64Array(out)), 10 + count * 8)
}
/// DateTime array decoder. Per `NmxSubscriptionMessage.cs:344-359`.
/// Element width is **12** on the wire (FILETIME i64 + 4 bytes of padding /
/// trailer); we read the leading 8 bytes as the FILETIME and skip the rest.
fn decode_datetime_array(
count: usize,
element_width: i32,
values: &[u8],
) -> (Option<MxValue>, usize) {
if element_width != 12 {
return (None, 0);
}
let needed = count.saturating_mul(12);
if values.len() < needed {
return (None, 0);
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
// Only the leading 8 bytes are interpreted; the trailing 4 bytes
// are not consumed by the .NET reference either (it calls
// `BinaryPrimitives.ReadInt64LittleEndian` on the slice's first
// 8 bytes, `NmxSubscriptionMessage.cs:354`).
let file_time = read_i64_le(values, i * 12);
out.push(file_time);
}
(Some(MxValue::DateTimeArray(out)), 10 + count * 12)
}
/// String array decoder. Per `NmxSubscriptionMessage.cs:361-392`.
///
/// Each element is `record_length i32 + element_kind u8 (must be 0x05) +
/// text_record_length i32 + text_byte_length i32 + utf16le bytes`. The
/// element header is 13 bytes. The whole array consumes
/// `10 + sum(13 + text_byte_length)` bytes.
fn decode_string_array(count: usize, values: &[u8]) -> (Option<MxValue>, usize) {
let mut out = Vec::with_capacity(count);
let mut offset = 0usize;
for _ in 0..count {
if offset + 13 > values.len() {
return (None, 0);
}
let record_length = read_i32_le(values, offset);
let element_kind = values[offset + 4];
let text_record_length = read_i32_le(values, offset + 5);
let text_byte_length = read_i32_le(values, offset + 9);
// .NET checks: `recordLength < 9 || elementKind != 0x05 ||
// textRecordLength != textByteLength + sizeof(int) ||
// recordLength != 1 + sizeof(int) + sizeof(int) + textByteLength ||
// offset + 13 + textByteLength > values.Length`
// (`NmxSubscriptionMessage.cs:376`).
if record_length < 9
|| element_kind != 0x05
|| text_byte_length < 0
|| text_record_length != text_byte_length + 4
|| record_length != 1 + 4 + 4 + text_byte_length
{
return (None, 0);
}
let text_byte_length_us = text_byte_length as usize;
if offset + 13 + text_byte_length_us > values.len() {
return (None, 0);
}
let mut text_bytes = &values[offset + 13..offset + 13 + text_byte_length_us];
if text_bytes.len() >= 2
&& text_bytes[text_bytes.len() - 2] == 0
&& text_bytes[text_bytes.len() - 1] == 0
{
text_bytes = &text_bytes[..text_bytes.len() - 2];
}
out.push(decode_utf16_le_lossy(text_bytes));
offset += 13 + text_byte_length_us;
}
(Some(MxValue::StringArray(out)), 10 + offset)
}
/// Map a wire-kind byte to its [`MxValueKind`] without decoding the payload.
/// Mirrors `ToValueKindOrNull` (`NmxSubscriptionMessage.cs:394-413`).
pub fn wire_kind_to_value_kind(wire_kind: u8) -> Option<MxValueKind> {
Some(match wire_kind {
0x01 => MxValueKind::Boolean,
0x02 => MxValueKind::Int32,
0x03 => MxValueKind::Float32,
0x04 => MxValueKind::Float64,
0x05 => MxValueKind::String,
0x06 => MxValueKind::DateTime,
0x07 => MxValueKind::ElapsedTime,
0x41 => MxValueKind::BoolArray,
0x42 => MxValueKind::Int32Array,
0x43 => MxValueKind::Float32Array,
0x44 => MxValueKind::Float64Array,
0x45 => MxValueKind::StringArray,
0x46 => MxValueKind::DateTimeArray,
_ => return None,
})
}
/// Decode UTF-16LE bytes lossily — invalid sequences become U+FFFD. Mirrors
/// `Encoding.Unicode.GetString` (`NmxSubscriptionMessage.cs:208,387`) which
/// is also lossy on malformed surrogates.
fn decode_utf16_le_lossy(bytes: &[u8]) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
}
#[inline]
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
i32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
#[inline]
fn read_i64_le(bytes: &[u8], offset: usize) -> i64 {
i64::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
])
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
/// Sample 16-byte GUID for hand-crafted bodies.
const OPERATION_ID_BYTES: [u8; 16] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10,
];
const CORRELATION_ID_BYTES: [u8; 16] = [
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x20,
];
/// Build a DataUpdate (`0x33`) body with the given record bytes appended.
fn data_update_body(record_count: i32, record: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(23 + record.len());
out.push(DATA_UPDATE_COMMAND);
out.extend_from_slice(&1u16.to_le_bytes()); // version
out.extend_from_slice(&record_count.to_le_bytes());
out.extend_from_slice(&OPERATION_ID_BYTES);
out.extend_from_slice(record);
out
}
/// Build a SubscriptionStatus (`0x32`) body.
fn subscription_status_body(record_count: i32, records: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(39 + records.len());
out.push(SUBSCRIPTION_STATUS_COMMAND);
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&record_count.to_le_bytes());
out.extend_from_slice(&OPERATION_ID_BYTES);
out.extend_from_slice(&CORRELATION_ID_BYTES);
out.extend_from_slice(records);
out
}
/// DataUpdate record: `status(4) + quality(2) + filetime(8) + wire_kind(1)
/// + value` — 15-byte fixed prefix per `NmxSubscriptionMessage.cs:119,126-143`
/// (status is read unconditionally; detail_status is the only field
/// gated on hasDetailStatus).
fn data_record(quality: u16, filetime: i64, wire_kind: u8, value: &[u8]) -> Vec<u8> {
data_record_with_status(0, quality, filetime, wire_kind, value)
}
fn data_record_with_status(
status: i32,
quality: u16,
filetime: i64,
wire_kind: u8,
value: &[u8],
) -> Vec<u8> {
let mut out = Vec::with_capacity(15 + value.len());
out.extend_from_slice(&status.to_le_bytes());
out.extend_from_slice(&quality.to_le_bytes());
out.extend_from_slice(&filetime.to_le_bytes());
out.push(wire_kind);
out.extend_from_slice(value);
out
}
/// SubscriptionStatus record: `status(4) + detail_status(4) + quality(2) +
/// filetime(8) + wire_kind(1) + value`.
fn status_record(
status: i32,
detail_status: i32,
quality: u16,
filetime: i64,
wire_kind: u8,
value: &[u8],
) -> Vec<u8> {
let mut out = Vec::with_capacity(19 + value.len());
out.extend_from_slice(&status.to_le_bytes());
out.extend_from_slice(&detail_status.to_le_bytes());
out.extend_from_slice(&quality.to_le_bytes());
out.extend_from_slice(&filetime.to_le_bytes());
out.push(wire_kind);
out.extend_from_slice(value);
out
}
#[test]
fn data_update_boolean_round_trip() {
let rec = data_record(0x00C0, 132_000_000_000, 0x01, &[0x01]);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.command, DATA_UPDATE_COMMAND);
assert_eq!(msg.record_count, 1);
assert!(msg.item_correlation_id.is_none());
assert_eq!(msg.operation_id.0, OPERATION_ID_BYTES);
assert_eq!(msg.records.len(), 1);
let r = &msg.records[0];
assert_eq!(r.status, 0);
assert_eq!(r.detail_status, None);
assert_eq!(r.quality, 0x00C0);
assert_eq!(r.timestamp_filetime, 132_000_000_000);
assert_eq!(r.wire_kind, 0x01);
assert_eq!(r.value, Some(MxValue::Boolean(true)));
}
#[test]
fn data_update_int32() {
let mut payload = Vec::new();
payload.extend_from_slice(&42i32.to_le_bytes());
let rec = data_record(0x00C0, 0, 0x02, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::Int32(42)));
}
#[test]
fn data_update_float32() {
let mut payload = Vec::new();
payload.extend_from_slice(&1.5f32.to_le_bytes());
let rec = data_record(0x00C0, 0, 0x03, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::Float32(1.5)));
}
#[test]
fn data_update_float64() {
let mut payload = Vec::new();
payload.extend_from_slice(&3.25f64.to_le_bytes());
let rec = data_record(0x00C0, 0, 0x04, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::Float64(3.25)));
}
#[test]
fn data_update_string() {
// "Hi" UTF-16LE = [0x48, 0x00, 0x69, 0x00] then NUL [0x00, 0x00] = 6 bytes.
let utf16 = [0x48, 0x00, 0x69, 0x00, 0x00, 0x00];
let text_byte_length: i32 = utf16.len() as i32;
let record_length: i32 = text_byte_length + 4;
let mut payload = Vec::new();
payload.extend_from_slice(&record_length.to_le_bytes());
payload.extend_from_slice(&text_byte_length.to_le_bytes());
payload.extend_from_slice(&utf16);
let rec = data_record(0x00C0, 0, 0x05, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(
msg.records[0].value,
Some(MxValue::String("Hi".to_string()))
);
}
#[test]
fn data_update_string_empty() {
// record_length == 4 indicates empty string; only 4 bytes consumed.
let mut payload = Vec::new();
payload.extend_from_slice(&4i32.to_le_bytes());
let rec = data_record(0x00C0, 0, 0x05, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::String(String::new())));
}
#[test]
fn data_update_datetime_framed() {
// Framed: record_length(4) + filetime(8) + 2 trailer bytes => 14 byte body.
let file_time: i64 = 132_500_000_000;
let record_length: i32 = 10;
let mut payload = Vec::new();
payload.extend_from_slice(&record_length.to_le_bytes());
payload.extend_from_slice(&file_time.to_le_bytes());
payload.extend_from_slice(&[0x00, 0x00]); // trailer
let rec = data_record(0x00C0, 0, 0x06, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::DateTime(file_time)));
}
#[test]
fn data_update_elapsed_time_negative() {
// Encode -500ms; expect a signed-preserving round-trip.
let mut payload = Vec::new();
payload.extend_from_slice(&(-500i32).to_le_bytes());
let rec = data_record(0x00C0, 0, 0x07, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.records[0].value, Some(MxValue::ElapsedTime(-500)));
}
#[test]
fn subscription_status_int32_round_trip() {
let mut payload = Vec::new();
payload.extend_from_slice(&7i32.to_le_bytes());
let rec = status_record(-1, -2, 0x00C0, 0, 0x02, &payload);
let body = subscription_status_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.command, SUBSCRIPTION_STATUS_COMMAND);
assert_eq!(msg.records.len(), 1);
assert_eq!(msg.item_correlation_id.unwrap().0, CORRELATION_ID_BYTES);
let r = &msg.records[0];
assert_eq!(r.status, -1);
assert_eq!(r.detail_status, Some(-2));
assert_eq!(r.quality, 0x00C0);
assert_eq!(r.value, Some(MxValue::Int32(7)));
}
#[test]
fn data_update_record_count_not_one_hard_errors() {
// recordCount = 2 must hard-error per NmxSubscriptionMessage.cs:71-74.
let body = data_update_body(2, &[]);
let err = NmxSubscriptionMessage::parse_inner(&body).unwrap_err();
match err {
CodecError::Decode { offset, reason, .. } => {
assert_eq!(offset, 3);
assert!(
reason.contains("multi-record"),
"unexpected reason: {reason}"
);
}
other => panic!("expected CodecError::Decode, got {other:?}"),
}
// record_count = 0 also rejected.
let body0 = data_update_body(0, &[]);
assert!(matches!(
NmxSubscriptionMessage::parse_inner(&body0).unwrap_err(),
CodecError::Decode { .. }
));
}
#[test]
fn data_update_has_no_correlation_id() {
// DataUpdate records start at offset 23 — there is no correlation id
// gap. Verify by feeding a body that *would* be malformed if 16 extra
// bytes were consumed before the record.
let rec = data_record(0x00C0, 0, 0x01, &[0x01]);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert!(msg.item_correlation_id.is_none());
// First record begins at offset 23, not 39.
assert_eq!(msg.records[0].offset, 23);
}
#[test]
fn subscription_status_reads_correlation_id() {
let mut payload = Vec::new();
payload.extend_from_slice(&0i32.to_le_bytes());
let rec = status_record(0, 0, 0x00C0, 0, 0x02, &payload);
let body = subscription_status_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(msg.item_correlation_id.unwrap().0, CORRELATION_ID_BYTES);
// First record begins at offset 39 (preamble + correlation id).
assert_eq!(msg.records[0].offset, 39);
}
#[test]
fn boolean_array_minus_one_is_true() {
// Array header: 4 unknown bytes + count u16 + element_width i32 (=2)
// + values (count * 2 bytes).
let count: u16 = 2;
let element_width: i32 = 2;
let mut payload = Vec::new();
payload.extend_from_slice(&[0u8; 4]); // unknown header bytes
payload.extend_from_slice(&count.to_le_bytes());
payload.extend_from_slice(&element_width.to_le_bytes());
// -1 (true) and 0 (false) as i16 LE
payload.extend_from_slice(&(-1i16).to_le_bytes()); // 0xff 0xff
payload.extend_from_slice(&0i16.to_le_bytes()); // 0x00 0x00
let rec = data_record(0x00C0, 0, 0x41, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(
msg.records[0].value,
Some(MxValue::BoolArray(vec![true, false]))
);
}
#[test]
fn boolean_array_byte_pattern_ff_ff_is_true_00_00_is_false() {
// Sanity: `[0xff, 0xff]` as i16 LE = -1 (true); `[0x00, 0x00]` = 0 (false).
let count: u16 = 2;
let element_width: i32 = 2;
let mut payload = Vec::new();
payload.extend_from_slice(&[0u8; 4]);
payload.extend_from_slice(&count.to_le_bytes());
payload.extend_from_slice(&element_width.to_le_bytes());
payload.extend_from_slice(&[0xff, 0xff, 0x00, 0x00]);
let rec = data_record(0x00C0, 0, 0x41, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(
msg.records[0].value,
Some(MxValue::BoolArray(vec![true, false]))
);
}
#[test]
fn datetime_array_decodes_0x46() {
// Element width is 12: 8-byte filetime + 4 bytes of trailing padding.
let count: u16 = 2;
let element_width: i32 = 12;
let mut payload = Vec::new();
payload.extend_from_slice(&[0u8; 4]);
payload.extend_from_slice(&count.to_le_bytes());
payload.extend_from_slice(&element_width.to_le_bytes());
// Two FILETIMEs plus 4 bytes of trailing padding each.
payload.extend_from_slice(&132_000_000_000i64.to_le_bytes());
payload.extend_from_slice(&[0u8; 4]);
payload.extend_from_slice(&132_500_000_000i64.to_le_bytes());
payload.extend_from_slice(&[0u8; 4]);
let rec = data_record(0x00C0, 0, 0x46, &payload);
let body = data_update_body(1, &rec);
let msg = NmxSubscriptionMessage::parse_inner(&body).unwrap();
assert_eq!(
msg.records[0].value,
Some(MxValue::DateTimeArray(vec![
132_000_000_000,
132_500_000_000
]))
);
}
#[test]
fn unknown_command_is_unexpected_opcode() {
let mut body = data_update_body(1, &[]);
body[0] = 0x99;
let err = NmxSubscriptionMessage::parse_inner(&body).unwrap_err();
assert!(matches!(err, CodecError::UnexpectedOpcode(0x99)));
}
#[test]
fn short_inner_is_short_read() {
let err = NmxSubscriptionMessage::parse_inner(&[0u8; 22]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn subscription_status_short_header_is_short_read() {
// 23 bytes is the preamble length, but SubscriptionStatus needs 39.
let mut body = Vec::with_capacity(23);
body.push(SUBSCRIPTION_STATUS_COMMAND);
body.extend_from_slice(&1u16.to_le_bytes());
body.extend_from_slice(&0i32.to_le_bytes());
body.extend_from_slice(&OPERATION_ID_BYTES);
let err = NmxSubscriptionMessage::parse_inner(&body).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn wire_kind_to_value_kind_table() {
assert_eq!(wire_kind_to_value_kind(0x01), Some(MxValueKind::Boolean));
assert_eq!(
wire_kind_to_value_kind(0x07),
Some(MxValueKind::ElapsedTime)
);
assert_eq!(
wire_kind_to_value_kind(0x46),
Some(MxValueKind::DateTimeArray)
);
assert_eq!(wire_kind_to_value_kind(0x99), None);
}
#[test]
fn command_constants_match_dotnet() {
// NmxSubscriptionMessage.cs:36-37
assert_eq!(SUBSCRIPTION_STATUS_COMMAND, 0x32);
assert_eq!(DATA_UPDATE_COMMAND, 0x33);
}
}