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