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>
285 lines
12 KiB
Rust
285 lines
12 KiB
Rust
//! `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);
|
|
}
|
|
}
|