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>
409 lines
17 KiB
Rust
409 lines
17 KiB
Rust
//! `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"
|
|
);
|
|
}
|
|
}
|