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>
260 lines
12 KiB
Rust
260 lines
12 KiB
Rust
//! Cross-implementation parity: Rust codec vs the .NET reference.
|
|
//!
|
|
//! Closes out the M1 DoD line in `design/60-roadmap.md`:
|
|
//!
|
|
//! > every Frida-captured write/advise/subscribe body that the .NET reference
|
|
//! > encodes today round-trips byte-identical through `mxaccess-codec` — i.e.
|
|
//! > the proven matrix in `work_remain.md` (scalar/array writes, advise/unadvise,
|
|
//! > single-record `0x33` DataUpdate, single-record SubscriptionStatus, the
|
|
//! > 5-byte `00 00 50 80 00` write-complete frame, and the 1-byte completion
|
|
//! > frames `0x00`/`0x41`/`0xEF` preserved verbatim). Cross-validated against
|
|
//! > `src/MxNativeCodec.Tests/` outputs.
|
|
//!
|
|
//! Each fixture in this file is **the canonical hex byte sequence the .NET
|
|
//! `MxNativeCodec.Tests` program asserts against** today. They are copied
|
|
//! verbatim from `src/MxNativeCodec.Tests/Program.cs` lines 4-100. The
|
|
//! reference is the bytes themselves, not the source program — if the .NET
|
|
//! reference's encoder ever changes, regenerate via
|
|
//! `dotnet run --project src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj`
|
|
//! and capture the new hex.
|
|
//!
|
|
//! The parity strategy mirrors the .NET `RunRoundTrip` helper
|
|
//! (`Program.cs:119-156`):
|
|
//! 1. Take the observed bytes.
|
|
//! 2. Hand them to [`ObservedWriteBodyTemplate::from_observed`] to capture the
|
|
//! prefix/suffix slots verbatim.
|
|
//! 3. Call [`ObservedWriteBodyTemplate::with_value`] with the original value.
|
|
//! 4. Assert byte-identical.
|
|
//!
|
|
//! `with_value` preserves the captured prefix (handle, clientToken stash) and
|
|
//! suffix (including FILETIME for timestamped Write2 bodies), so a single
|
|
//! parity check works uniformly for normal and timestamped fixtures.
|
|
|
|
#![allow(
|
|
clippy::unwrap_used,
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic
|
|
)]
|
|
|
|
use mxaccess_codec::{
|
|
MxValue, MxValueKind, NmxTransferEnvelopeTemplate, ObservedWriteBodyTemplate,
|
|
};
|
|
|
|
/// Decode a space-separated hex string into bytes. Mirrors `Convert.FromHexString`
|
|
/// in the .NET test helper.
|
|
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
|
s.split_whitespace()
|
|
.map(|tok| u8::from_str_radix(tok, 16).expect("malformed hex token in fixture"))
|
|
.collect()
|
|
}
|
|
|
|
/// One write-body parity fixture. Mirrors a single `RunRoundTrip(...)` call
|
|
/// in `src/MxNativeCodec.Tests/Program.cs`.
|
|
struct Fixture {
|
|
name: &'static str,
|
|
kind: MxValueKind,
|
|
/// Pre-built `MxValue` matching what the .NET test passes. For DateTime
|
|
/// fixtures, the .NET helper formats the `DateTime` to `M/d/yyyy h:mm:ss tt`
|
|
/// before encoding; we pass the same pre-formatted string via
|
|
/// `MxValue::String` (the encoder collapses DateTime onto the String wire
|
|
/// kind 0x05).
|
|
value: MxValue,
|
|
/// Canonical hex from the .NET test program. Space-separated bytes.
|
|
hex: &'static str,
|
|
}
|
|
|
|
fn write_body_fixtures() -> Vec<Fixture> {
|
|
vec![
|
|
// -------- Scalar Write (0x37) --------
|
|
Fixture {
|
|
name: "int",
|
|
kind: MxValueKind::Int32,
|
|
value: MxValue::Int32(109),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "bool",
|
|
kind: MxValueKind::Boolean,
|
|
value: MxValue::Boolean(true),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9a 00 0a 00 fa 7d 00 00 01 ff ff ff 00 00 00 00 00 00 00 00 e1 d8 b5 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "float",
|
|
kind: MxValueKind::Float32,
|
|
value: MxValue::Float32(1.25),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9c 00 0a 00 a6 ed 00 00 03 00 00 a0 3f ff ff 00 00 00 00 00 00 00 00 64 6f b6 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "double",
|
|
kind: MxValueKind::Float64,
|
|
value: MxValue::Float64(1.125),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9d 00 0a 00 1e 95 00 00 04 00 00 00 00 00 00 f2 3f ff ff 00 00 00 00 00 00 00 00 bf 04 b7 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "string",
|
|
kind: MxValueKind::String,
|
|
value: MxValue::String("AlphaMX".to_string()),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 14 00 00 00 10 00 00 00 41 00 6c 00 70 00 68 00 61 00 4d 00 58 00 00 00 ff ff 00 00 00 00 00 00 00 00 3d a1 b7 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
// .NET formats DateTime via `M/d/yyyy h:mm:ss tt` InvariantCulture.
|
|
// `new DateTime(2026, 4, 25, 2, 30, 0)` → "4/25/2026 2:30:00 AM".
|
|
// Wire kind collapses to 0x05 String per `NmxWriteMessage.cs:107`.
|
|
name: "datetime (M/d/yyyy h:mm:ss tt)",
|
|
kind: MxValueKind::DateTime,
|
|
value: MxValue::String("4/25/2026 2:30:00 AM".to_string()),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9f 00 0a 00 62 49 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 32 00 3a 00 33 00 30 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 ff ff 00 00 00 00 00 00 00 00 58 94 b8 08 01 00 00 00",
|
|
},
|
|
// -------- Timestamped Write (Write2). Suffix `i16` is `0` (not -1)
|
|
// and the 8-byte filler at suffix offsets 2..10 is replaced
|
|
// by the FILETIME — see `design/40-protocol-invariants.md`
|
|
// for the layout. `with_value` preserves the FILETIME from
|
|
// the captured suffix, so the parity check is uniform.
|
|
Fixture {
|
|
name: "write2 int timestamp",
|
|
kind: MxValueKind::Int32,
|
|
value: MxValue::Int32(114),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 72 00 00 00 00 00 00 72 68 3a 83 d4 dc 01 20 2e d8 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "write2 string timestamp",
|
|
kind: MxValueKind::String,
|
|
value: MxValue::String("Write2Alpha".to_string()),
|
|
hex: "37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 1c 00 00 00 18 00 00 00 57 00 72 00 69 00 74 00 65 00 32 00 41 00 6c 00 70 00 68 00 61 00 00 00 00 00 80 68 d5 9c ab d4 dc 01 9f 6e a6 0b 01 00 00 00",
|
|
},
|
|
// -------- Arrays --------
|
|
Fixture {
|
|
name: "int[]",
|
|
kind: MxValueKind::Int32Array,
|
|
value: MxValue::Int32Array(vec![201, 202, 203, 204, 205, 206, 207, 208, 209, 210]),
|
|
hex: "37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 c9 00 00 00 ca 00 00 00 cb 00 00 00 cc 00 00 00 cd 00 00 00 ce 00 00 00 cf 00 00 00 d0 00 00 00 d1 00 00 00 d2 00 00 00 ff ff 00 00 00 00 00 00 00 00 4d c3 c4 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "bool[]",
|
|
kind: MxValueKind::BoolArray,
|
|
value: MxValue::BoolArray(vec![
|
|
true, true, false, false, true, true, false, false, true, true,
|
|
]),
|
|
hex: "37 01 00 05 00 36 d7 02 00 a0 00 0a 00 fa 89 ff ff 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff ff ff 00 00 00 00 00 00 00 00 a8 7f c5 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "float[]",
|
|
kind: MxValueKind::Float32Array,
|
|
value: MxValue::Float32Array(vec![
|
|
1.25, 2.5, 3.75, 4.25, 5.5, 6.75, 7.25, 8.5, 9.75, 10.25,
|
|
]),
|
|
hex: "37 01 00 05 00 36 d7 02 00 a3 00 0a 00 95 f1 ff ff 43 00 00 00 00 0a 00 04 00 00 00 00 00 a0 3f 00 00 20 40 00 00 70 40 00 00 88 40 00 00 b0 40 00 00 d8 40 00 00 e8 40 00 00 08 41 00 00 1c 41 00 00 24 41 ff ff 00 00 00 00 00 00 00 00 0c f4 c5 08 01 00 00 00",
|
|
},
|
|
Fixture {
|
|
name: "string[]",
|
|
kind: MxValueKind::StringArray,
|
|
value: MxValue::StringArray(vec![
|
|
"A01".to_string(),
|
|
"B02".to_string(),
|
|
"C03".to_string(),
|
|
"D04".to_string(),
|
|
"E05".to_string(),
|
|
"F06".to_string(),
|
|
"G07".to_string(),
|
|
"H08".to_string(),
|
|
"I09".to_string(),
|
|
"J10".to_string(),
|
|
]),
|
|
hex: "37 01 00 05 00 36 d7 02 00 a5 00 0a 00 5d 4b ff ff 45 00 00 00 00 0a 00 04 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 41 00 30 00 31 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 42 00 30 00 32 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 43 00 30 00 33 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 44 00 30 00 34 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 45 00 30 00 35 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 46 00 30 00 36 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 47 00 30 00 37 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 48 00 30 00 38 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 49 00 30 00 39 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 4a 00 31 00 30 00 00 00 ff ff 00 00 00 00 00 00 00 00 c3 da c6 08 01 00 00 00",
|
|
},
|
|
// -------- Timestamped arrays --------
|
|
Fixture {
|
|
name: "write2 int[] timestamp",
|
|
kind: MxValueKind::Int32Array,
|
|
value: MxValue::Int32Array(vec![301, 302, 303, 304, 305, 306, 307, 308, 309, 310]),
|
|
hex: "37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 2d 01 00 00 2e 01 00 00 2f 01 00 00 30 01 00 00 31 01 00 00 32 01 00 00 33 01 00 00 34 01 00 00 35 01 00 00 36 01 00 00 00 00 80 93 fc 76 ac d4 dc 01 43 35 ac 0b 01 00 00 00",
|
|
},
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn write_bodies_round_trip_byte_identical_against_dotnet_reference() {
|
|
let mut failures: Vec<String> = Vec::new();
|
|
|
|
for fx in write_body_fixtures() {
|
|
let observed = hex_to_bytes(fx.hex);
|
|
let template = match ObservedWriteBodyTemplate::from_observed(fx.kind, &observed) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
failures.push(format!(
|
|
"fixture {:?} from_observed errored: {e:?}",
|
|
fx.name
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
// .NET helper passes `writeIndex: 1` uniformly (Program.cs:128).
|
|
let regen = match template.with_value(&fx.value, 1) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
failures.push(format!("fixture {:?} with_value errored: {e:?}", fx.name));
|
|
continue;
|
|
}
|
|
};
|
|
if regen != observed {
|
|
// Find first divergent byte for fast triage.
|
|
let len = regen.len().min(observed.len());
|
|
let first = (0..len).find(|i| regen[*i] != observed[*i]);
|
|
failures.push(format!(
|
|
"fixture {:?} diverged: rust_len={} dotnet_len={} first_diff_at={:?}",
|
|
fx.name,
|
|
regen.len(),
|
|
observed.len(),
|
|
first,
|
|
));
|
|
}
|
|
}
|
|
|
|
if !failures.is_empty() {
|
|
panic!(
|
|
"{} fixture(s) failed parity vs .NET reference:\n {}",
|
|
failures.len(),
|
|
failures.join("\n ")
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---- Transfer envelope round-trip ----------------------------------------
|
|
//
|
|
// Mirrors `RunTransferEnvelopeRoundTrip` (Program.cs:174-182) and the canonical
|
|
// fixture at Program.cs:102-104. The envelope template captures the 46-byte
|
|
// header verbatim and re-emits it byte-identical given the same inner body.
|
|
|
|
#[test]
|
|
fn transfer_envelope_round_trips_byte_identical() {
|
|
// Inner body and full transfer body — taken verbatim from
|
|
// src/MxNativeCodec.Tests/Program.cs:102-104.
|
|
let inner_hex = "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00";
|
|
let transfer_hex = "01 00 28 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 \
|
|
37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00";
|
|
|
|
let inner = hex_to_bytes(inner_hex);
|
|
let transfer = hex_to_bytes(transfer_hex);
|
|
|
|
let template = NmxTransferEnvelopeTemplate::from_observed(&transfer)
|
|
.expect("envelope template parse failed");
|
|
|
|
// Decode inner — should match `inner_hex` byte-for-byte.
|
|
let decoded_inner = template
|
|
.decode_inner(&transfer)
|
|
.expect("decode_inner errored");
|
|
assert_eq!(
|
|
decoded_inner,
|
|
inner.as_slice(),
|
|
"decoded inner body diverged from canonical hex"
|
|
);
|
|
|
|
// Re-encode the full 46+inner buffer — should match `transfer_hex`.
|
|
let regen = template.encode(&inner);
|
|
assert_eq!(
|
|
regen, transfer,
|
|
"re-encoded transfer body diverged from canonical hex"
|
|
);
|
|
}
|