Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
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>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user