Files
mxaccess/rust/crates/mxaccess-codec/tests/dotnet_codec_parity.rs
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
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>
2026-05-05 06:21:00 -04:00

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