fe2a6db786
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>
391 lines
14 KiB
Rust
391 lines
14 KiB
Rust
//! `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);
|
|
}
|
|
}
|