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,904 @@
|
||||
//! `ObservedWriteBodyTemplate` — observed-Write body round-trip preserver.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/ObservedWriteBodyTemplate.cs`.
|
||||
//!
|
||||
//! ## What this is for
|
||||
//!
|
||||
//! The template path takes a *captured* Write body (real bytes from
|
||||
//! `captures/0NN-frida-write-*`) and replays it with only the value slot
|
||||
//! replaced. Every other byte — the prefix, the `cmd + version + handle
|
||||
//! projection + wire_kind` header, the trailing suffix (clientToken,
|
||||
//! writeIndex, the `-1 i16` discriminator, any padding) — is preserved
|
||||
//! **verbatim**. This is one of the cornerstones of CLAUDE.md's "preserve
|
||||
//! unknown bytes" rule (project root `CLAUDE.md`): some flows depend on
|
||||
//! byte-for-byte parity with native MXAccess, and the captured suffix
|
||||
//! contains bytes whose meaning is unproven.
|
||||
//!
|
||||
//! ## Layout assumptions (from `ObservedWriteBodyTemplate.cs:9-49`)
|
||||
//!
|
||||
//! Three offset constants from the .NET source:
|
||||
//!
|
||||
//! - `FixedValueOffset = 18` (`.cs:9`) — value slot for scalar types
|
||||
//! (Boolean / Int32 / Float32 / Float64).
|
||||
//! - `VariableValueOffset = 26` (`.cs:10`) — value slot for variable types
|
||||
//! (String / DateTime), preceded by 8 bytes of length headers at offsets
|
||||
//! 18..22 (outer_length) and 22..26 (inner_length).
|
||||
//! - `ArrayValueOffset = 28` (`.cs:11`) — value slot for arrays, preceded by
|
||||
//! 10 bytes (zeros at 18..22, count u16 at 22, element_width u16 at 24,
|
||||
//! zeros at 26..28).
|
||||
//!
|
||||
//! The trailing suffix length is then implied by the captured body size:
|
||||
//!
|
||||
//! - **Fixed:** suffix starts at `FixedValueOffset + valueWidth` and runs
|
||||
//! to `body.Length - sizeof(int)`. The trailing 4 bytes are the writeIndex.
|
||||
//! `(.cs:96-109)`.
|
||||
//! - **Variable:** suffix starts at `VariableValueOffset + valueByteLength`
|
||||
//! where `valueByteLength = body.Slice(22, 4)` (read **unconditionally**).
|
||||
//! Trailing writeIndex still 4 bytes. `(.cs:111-130)`.
|
||||
//! - **Array:** suffix is exactly the **last 18 bytes**, of which the first
|
||||
//! 14 are stored in `_suffixBeforeWriteIndex` and the last 4 are the
|
||||
//! writeIndex. `(.cs:132-144)`.
|
||||
//!
|
||||
//! ## Round-trip preservation
|
||||
//!
|
||||
//! `Encode` (`.cs:51-64`) writes:
|
||||
//!
|
||||
//! 1. The captured prefix (`_prefix`, raw bytes) — preserved verbatim.
|
||||
//! 2. The freshly-encoded value bytes from [`encode_value`].
|
||||
//! 3. The captured suffix (`_suffixBeforeWriteIndex`) — preserved verbatim.
|
||||
//! 4. The fresh `writeIndex` as i32 LE in the trailing 4 bytes.
|
||||
//!
|
||||
//! Then it calls `PatchVariableLengths` and `PatchArrayDescriptor` to keep
|
||||
//! the embedded length fields consistent with the new value bytes
|
||||
//! (`.cs:378-411`).
|
||||
//!
|
||||
//! ## hasDetailStatus audit (Q7 follow-up)
|
||||
//!
|
||||
//! `ObservedWriteBodyTemplate.cs` does not take any `has_*` boolean
|
||||
//! parameter. `CreateVariable` reads `body.Slice(22, 4)` unconditionally
|
||||
//! (`.cs:118`); `CreateArray` reads `body.Slice(22, 2)` unconditionally
|
||||
//! (via the count in DecodeBooleanArray etc., `.cs:198, 221, 251, 281, 337`);
|
||||
//! `Decode*` functions read fixed-offset fields unconditionally per kind.
|
||||
//! No conditional read patterns to mirror. **Audit: clean.**
|
||||
//!
|
||||
//! ## Public-API differences from .NET
|
||||
//!
|
||||
//! - The .NET `Decode` returns `object` and accepts the body to decode at
|
||||
//! call time. The Rust port exposes [`ObservedWriteBodyTemplate::with_value`]
|
||||
//! that returns a fresh body with the value replaced (the single most
|
||||
//! common use case in probes / replay), plus `with_int32`, `with_boolean`,
|
||||
//! etc. helpers.
|
||||
//! - The .NET `Encode` takes a boxed `object` value; the Rust port takes a
|
||||
//! typed [`crate::MxValue`] (no runtime conversion).
|
||||
//! - The Rust port requires the captured kind to match the kind of the
|
||||
//! replacement value — preventing accidentally replacing an Int32 with a
|
||||
//! Float32 (which would corrupt the suffix offsets).
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::{MxValue, MxValueKind};
|
||||
|
||||
/// Value-slot offset for fixed-width scalars (`ObservedWriteBodyTemplate.cs:9`).
|
||||
pub const FIXED_VALUE_OFFSET: usize = 18;
|
||||
|
||||
/// Value-slot offset for variable-length scalars
|
||||
/// (`ObservedWriteBodyTemplate.cs:10`).
|
||||
pub const VARIABLE_VALUE_OFFSET: usize = 26;
|
||||
|
||||
/// Value-slot offset for arrays (`ObservedWriteBodyTemplate.cs:11`).
|
||||
pub const ARRAY_VALUE_OFFSET: usize = 28;
|
||||
|
||||
/// Round-trip preserver for a captured Write body (`0x37`) or
|
||||
/// SecuredWrite2 body (`0x38`).
|
||||
///
|
||||
/// Stores three pieces:
|
||||
///
|
||||
/// - The captured *prefix* up to but not including the value slot.
|
||||
/// - The captured *suffix* starting just after the value slot, up to but
|
||||
/// not including the trailing 4-byte writeIndex.
|
||||
/// - The captured kind (so we can later patch length fields correctly).
|
||||
///
|
||||
/// The prefix and suffix carry every byte unchanged; the value slot is
|
||||
/// rewritten on each [`Self::with_value`] call, and the trailing writeIndex
|
||||
/// is rewritten with whatever the caller passes to encode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObservedWriteBodyTemplate {
|
||||
kind: MxValueKind,
|
||||
/// Captured opcode at body[0]. Either `0x37` (Write) or `0x38`
|
||||
/// (SecuredWrite2) — both share the value-slot layout per the .cs
|
||||
/// constants. Stored separately for [`Self::command`] without re-reading
|
||||
/// `prefix`.
|
||||
command: u8,
|
||||
prefix: Vec<u8>,
|
||||
suffix_before_write_index: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ObservedWriteBodyTemplate {
|
||||
/// Capture a Write body and split it into prefix / suffix around the
|
||||
/// value slot.
|
||||
///
|
||||
/// Mirrors `FromObserved` (`ObservedWriteBodyTemplate.cs:26-49`). The
|
||||
/// caller declares the `kind` because the captured body alone does not
|
||||
/// disambiguate `0x05` String vs DateTime or `0x45` StringArray vs
|
||||
/// DateTimeArray (the encoder collapses them — see
|
||||
/// `crate::write_message` module doc).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `observed_body.len() < 24`
|
||||
/// (`.cs:28-31`).
|
||||
/// - [`CodecError::Decode`] if the kind is unsupported, or if the body
|
||||
/// is too short for its declared kind, or if a variable-length body
|
||||
/// has invalid embedded lengths.
|
||||
pub fn from_observed(kind: MxValueKind, observed_body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:28-31` — minimum length 24 bytes regardless of kind.
|
||||
if observed_body.len() < 24 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 24,
|
||||
actual: observed_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
match kind {
|
||||
// `.cs:35-38` — fixed-width scalars.
|
||||
MxValueKind::Boolean | MxValueKind::Int32 | MxValueKind::Float32 => {
|
||||
Self::create_fixed(kind, observed_body, 4)
|
||||
}
|
||||
MxValueKind::Float64 => Self::create_fixed(kind, observed_body, 8),
|
||||
// `.cs:39-40` — variable-width scalars.
|
||||
MxValueKind::String | MxValueKind::DateTime => {
|
||||
Self::create_variable(kind, observed_body)
|
||||
}
|
||||
// `.cs:41-46` — arrays.
|
||||
MxValueKind::BoolArray
|
||||
| MxValueKind::Int32Array
|
||||
| MxValueKind::Float32Array
|
||||
| MxValueKind::Float64Array
|
||||
| MxValueKind::StringArray
|
||||
| MxValueKind::DateTimeArray => Self::create_array(kind, observed_body),
|
||||
// `.cs:47` — anything else throws.
|
||||
MxValueKind::ElapsedTime | MxValueKind::Unknown => Err(CodecError::Decode {
|
||||
offset: 17,
|
||||
reason: "observed-write template: unsupported value kind",
|
||||
buffer_len: observed_body.len(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Captured opcode at body[0]. Mirrors `_prefix[0]`.
|
||||
pub fn command(&self) -> u8 {
|
||||
self.command
|
||||
}
|
||||
|
||||
/// Captured wire-kind byte at body[17]. Drawn from the captured prefix,
|
||||
/// not from the runtime [`MxValueKind`] (which can disambiguate
|
||||
/// String vs DateTime past the encoder collapse).
|
||||
pub fn wire_kind(&self) -> u8 {
|
||||
// body[17] sits inside the prefix for all three families.
|
||||
self.prefix[17]
|
||||
}
|
||||
|
||||
/// The kind this template was captured against.
|
||||
pub fn kind(&self) -> MxValueKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Borrow the captured prefix bytes (everything before the value slot).
|
||||
pub fn prefix(&self) -> &[u8] {
|
||||
&self.prefix
|
||||
}
|
||||
|
||||
/// Borrow the captured suffix bytes (between the value slot and the
|
||||
/// trailing 4-byte writeIndex).
|
||||
pub fn suffix_before_write_index(&self) -> &[u8] {
|
||||
&self.suffix_before_write_index
|
||||
}
|
||||
|
||||
/// Emit a Write body with the value slot replaced.
|
||||
///
|
||||
/// Mirrors `Encode` (`ObservedWriteBodyTemplate.cs:51-64`):
|
||||
/// 1. Allocate `prefix.len() + value_bytes.len() + suffix.len() + 4`.
|
||||
/// 2. Copy prefix verbatim.
|
||||
/// 3. Copy fresh value bytes.
|
||||
/// 4. Copy suffix verbatim.
|
||||
/// 5. Write i32 LE writeIndex into the trailing 4 bytes.
|
||||
/// 6. Patch embedded length fields (variable / array).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::Decode`] if `value.kind()` doesn't match the
|
||||
/// captured kind. The .NET reference does not check this — it boxes
|
||||
/// any `object` and lets the per-kind encoder throw — but mismatched
|
||||
/// replacement would silently corrupt the suffix offsets, so the Rust
|
||||
/// port enforces it.
|
||||
pub fn with_value(&self, value: &MxValue, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
// Strict kind check (Rust-port tightening; see module doc).
|
||||
// The encoder collapses StringArray and DateTimeArray onto the same
|
||||
// wire kind, so accept that pair as compatible.
|
||||
let val_kind = value.kind();
|
||||
let kinds_match = val_kind == self.kind
|
||||
|| (val_kind == MxValueKind::StringArray && self.kind == MxValueKind::DateTimeArray)
|
||||
|| (val_kind == MxValueKind::DateTimeArray && self.kind == MxValueKind::StringArray)
|
||||
|| (val_kind == MxValueKind::String && self.kind == MxValueKind::DateTime)
|
||||
|| (val_kind == MxValueKind::DateTime && self.kind == MxValueKind::String);
|
||||
if !kinds_match {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 17,
|
||||
reason: "observed-write template: replacement value kind does not match captured kind",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let value_bytes = encode_value_bytes(value, self.kind)?;
|
||||
let body_len =
|
||||
self.prefix.len() + value_bytes.len() + self.suffix_before_write_index.len() + 4;
|
||||
let mut body = vec![0u8; body_len];
|
||||
body[..self.prefix.len()].copy_from_slice(&self.prefix);
|
||||
let value_start = self.prefix.len();
|
||||
body[value_start..value_start + value_bytes.len()].copy_from_slice(&value_bytes);
|
||||
let suffix_start = value_start + value_bytes.len();
|
||||
body[suffix_start..suffix_start + self.suffix_before_write_index.len()]
|
||||
.copy_from_slice(&self.suffix_before_write_index);
|
||||
write_i32_le(&mut body, body_len - 4, write_index);
|
||||
self.patch_variable_lengths(&mut body, value_bytes.len());
|
||||
self.patch_array_descriptor(&mut body, value_bytes.len());
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an i32. Errors if the captured
|
||||
/// kind isn't [`MxValueKind::Int32`].
|
||||
pub fn with_int32(&self, value: i32, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Int32(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with a bool. Errors if the captured
|
||||
/// kind isn't [`MxValueKind::Boolean`].
|
||||
pub fn with_boolean(&self, value: bool, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Boolean(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an f32.
|
||||
pub fn with_float32(&self, value: f32, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Float32(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an f64.
|
||||
pub fn with_float64(&self, value: f64, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Float64(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with a UTF-16LE string.
|
||||
pub fn with_string(&self, value: &str, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::String(value.to_string()), write_index)
|
||||
}
|
||||
|
||||
/// Decode the trailing writeIndex from a body that was emitted by this
|
||||
/// template. Mirrors `DecodeWriteIndex` (`.cs:86-94`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `body` has fewer than 4 bytes.
|
||||
pub fn decode_write_index(body: &[u8]) -> Result<i32, CodecError> {
|
||||
if body.len() < 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
Ok(read_i32_le(body, body.len() - 4))
|
||||
}
|
||||
|
||||
// ---- Private constructors --------------------------------------------
|
||||
|
||||
/// `CreateFixed` (`ObservedWriteBodyTemplate.cs:96-109`).
|
||||
fn create_fixed(
|
||||
kind: MxValueKind,
|
||||
body: &[u8],
|
||||
value_width: usize,
|
||||
) -> Result<Self, CodecError> {
|
||||
let suffix_start = FIXED_VALUE_OFFSET + value_width;
|
||||
// `.cs:99-103` — suffix length must be non-negative.
|
||||
if body.len() < suffix_start + 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: suffix_start + 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_length = body.len() - suffix_start - 4;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
prefix: body[..FIXED_VALUE_OFFSET].to_vec(),
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `CreateVariable` (`ObservedWriteBodyTemplate.cs:111-130`). Reads the
|
||||
/// inner length at offset 22 **unconditionally** — there is no
|
||||
/// "has X" boolean here, mirroring the .NET source.
|
||||
fn create_variable(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:113-116` — minimum length check.
|
||||
if body.len() < VARIABLE_VALUE_OFFSET + 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: VARIABLE_VALUE_OFFSET + 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
// `.cs:118` — value byte length read at offset 22 unconditionally.
|
||||
let value_byte_length = read_i32_le(body, 22);
|
||||
if value_byte_length < 2 {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 22,
|
||||
reason: "observed-write template: variable value_byte_length < 2",
|
||||
buffer_len: body.len(),
|
||||
});
|
||||
}
|
||||
let value_byte_length = value_byte_length as usize;
|
||||
let suffix_start = VARIABLE_VALUE_OFFSET + value_byte_length;
|
||||
if body.len() < suffix_start + 4 {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 22,
|
||||
reason: "observed-write template: variable body too short for declared lengths",
|
||||
buffer_len: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_length = body.len() - suffix_start - 4;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
prefix: body[..VARIABLE_VALUE_OFFSET].to_vec(),
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `CreateArray` (`ObservedWriteBodyTemplate.cs:132-144`). The .NET
|
||||
/// reference takes the **last 18 bytes** as the suffix
|
||||
/// (`suffixStart = body.Length - 18`), of which the first 14 are stored
|
||||
/// in `_suffixBeforeWriteIndex` (`.cs:143`). This loses the array
|
||||
/// payload bytes between offset 28 and `body.Length - 18` — they are
|
||||
/// regenerated by [`encode_value_bytes`] from the supplied value.
|
||||
fn create_array(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:134-137` — body must hold at least 18 trailing bytes plus
|
||||
// the 28-byte prefix.
|
||||
if body.len() < ARRAY_VALUE_OFFSET + 18 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: ARRAY_VALUE_OFFSET + 18,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_start = body.len() - 18;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
// `.cs:142` — prefix is the leading 28 bytes.
|
||||
prefix: body[..ARRAY_VALUE_OFFSET].to_vec(),
|
||||
// `.cs:143` — suffix_before_write_index is exactly 14 bytes
|
||||
// (the trailing 18 minus the 4-byte writeIndex slot).
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + 14].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Length / descriptor patches -------------------------------------
|
||||
|
||||
/// `PatchVariableLengths` (`ObservedWriteBodyTemplate.cs:378-388`).
|
||||
/// Updates the embedded outer/inner length fields at offsets 18 and 22
|
||||
/// for variable-width kinds. No-op for everything else.
|
||||
fn patch_variable_lengths(&self, body: &mut [u8], value_byte_length: usize) {
|
||||
if !matches!(self.kind, MxValueKind::String | MxValueKind::DateTime) {
|
||||
return;
|
||||
}
|
||||
// `.cs:385` — body[18..22] = value_byte_length + 4.
|
||||
write_i32_le(body, 18, value_byte_length as i32 + 4);
|
||||
// `.cs:386` — body[22..26] = value_byte_length.
|
||||
write_i32_le(body, 22, value_byte_length as i32);
|
||||
}
|
||||
|
||||
/// `PatchArrayDescriptor` (`ObservedWriteBodyTemplate.cs:390-411`).
|
||||
/// Updates the array element count u16 at offset 22 for fixed-element
|
||||
/// arrays. No-op for variable-element arrays (StringArray /
|
||||
/// DateTimeArray) and for non-array kinds — matching the early returns
|
||||
/// at `.cs:392-400`.
|
||||
fn patch_array_descriptor(&self, body: &mut [u8], value_byte_length: usize) {
|
||||
let element_size = match self.kind {
|
||||
MxValueKind::BoolArray => 2,
|
||||
MxValueKind::Int32Array | MxValueKind::Float32Array => 4,
|
||||
MxValueKind::Float64Array => 8,
|
||||
// `.cs:397-400` — variable arrays return early; descriptor not patched.
|
||||
_ => return,
|
||||
};
|
||||
let count = value_byte_length / element_size;
|
||||
// `.cs:410` — body[22..24] = checked u16 count.
|
||||
let count_u16: u16 = u16::try_from(count).unwrap_or(u16::MAX);
|
||||
write_u16_le(body, 22, count_u16);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Value-byte encoders --------------------------------------------------
|
||||
|
||||
/// Encode the value bytes that go into the value slot. Mirrors `EncodeValue`
|
||||
/// (`ObservedWriteBodyTemplate.cs:146-164`).
|
||||
///
|
||||
/// `kind` is the captured kind (used to disambiguate the encoder collapse
|
||||
/// for String/DateTime and StringArray/DateTimeArray — though in this
|
||||
/// template path the actual byte layout is identical for both halves of
|
||||
/// each pair, so the distinction is presentational).
|
||||
fn encode_value_bytes(value: &MxValue, _kind: MxValueKind) -> Result<Vec<u8>, CodecError> {
|
||||
Ok(match value {
|
||||
// `.cs:150` / `EncodeBoolean` (`.cs:166-171`). Literal byte
|
||||
// patterns: true -> [0xff,0xff,0xff,0x00], false -> [0x00,0xff,0xff,0x00].
|
||||
MxValue::Boolean(b) => encode_boolean_bytes(*b).to_vec(),
|
||||
// `.cs:151` — i32 LE.
|
||||
MxValue::Int32(v) => v.to_le_bytes().to_vec(),
|
||||
// `.cs:152` — f32 via SingleToInt32Bits + i32 LE
|
||||
// (`.cs:231-236` `EncodeFloat32`).
|
||||
MxValue::Float32(v) => f32::to_bits(*v).to_le_bytes().to_vec(),
|
||||
// `.cs:153` — f64 via DoubleToInt64Bits + i64 LE
|
||||
// (`.cs:261-266` `EncodeFloat64`).
|
||||
MxValue::Float64(v) => f64::to_bits(*v).to_le_bytes().to_vec(),
|
||||
// `.cs:154` — UTF-16LE with 2-byte NUL trailer
|
||||
// (`.cs:291-297` `EncodeUtf16String`).
|
||||
MxValue::String(s) => encode_utf16_with_nul(s),
|
||||
// `.cs:155` — DateTime is formatted with
|
||||
// `"M/d/yyyy h:mm:ss tt"` and encoded as UTF-16LE+NUL. The Rust
|
||||
// port carries the i64 FILETIME ticks; converting that to the
|
||||
// .NET formatted string requires a calendar dependency outside
|
||||
// the codec layer. The carrier expects a pre-formatted string.
|
||||
MxValue::DateTime(_ticks) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: DateTime replacement requires a pre-formatted string; pass MxValue::String instead",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
// `.cs:156` `EncodeBooleanArray` (`.cs:185-194`) — each element
|
||||
// is i16 LE (-1 or 0).
|
||||
MxValue::BoolArray(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 2);
|
||||
for v in arr {
|
||||
let i: i16 = if *v { -1 } else { 0 };
|
||||
bytes.extend_from_slice(&i.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:157` `EncodeInt32Array` (`.cs:208-217`).
|
||||
MxValue::Int32Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 4);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:158` `EncodeFloat32Array` (`.cs:238-247`).
|
||||
MxValue::Float32Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 4);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&f32::to_bits(*v).to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:159` `EncodeFloat64Array` (`.cs:268-277`).
|
||||
MxValue::Float64Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 8);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&f64::to_bits(*v).to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:160` `EncodeVariableArray` (`.cs:317-333`).
|
||||
MxValue::StringArray(arr) => encode_variable_array(arr.iter().map(String::as_str)),
|
||||
// `.cs:161` — DateTimeArray formats each element. Same
|
||||
// limitation as scalar DateTime; require pre-formatted strings.
|
||||
MxValue::DateTimeArray(_arr) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: DateTimeArray replacement requires pre-formatted strings; pass MxValue::StringArray instead",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
// `.cs:162` — InvalidOperationException for unsupported.
|
||||
MxValue::ElapsedTime(_) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: ElapsedTime is not supported on the write side",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Boolean payload bytes — LITERALLY the same 4-byte pattern used by the
|
||||
/// normal-write encoder (`.cs:166-171`,
|
||||
/// matches `crate::write_message` `encode_boolean_value`).
|
||||
const fn encode_boolean_bytes(value: bool) -> [u8; 4] {
|
||||
if value {
|
||||
[0xff, 0xff, 0xff, 0x00]
|
||||
} else {
|
||||
[0x00, 0xff, 0xff, 0x00]
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-16LE encoding with a trailing 2-byte NUL terminator. Mirrors
|
||||
/// `EncodeUtf16String` (`ObservedWriteBodyTemplate.cs:291-297`).
|
||||
fn encode_utf16_with_nul(value: &str) -> Vec<u8> {
|
||||
let utf16: Vec<u16> = value.encode_utf16().collect();
|
||||
let mut bytes = Vec::with_capacity(utf16.len() * 2 + 2);
|
||||
for unit in &utf16 {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
bytes.push(0x00);
|
||||
bytes.push(0x00);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Variable-array payload. Mirrors `EncodeVariableArray`
|
||||
/// (`ObservedWriteBodyTemplate.cs:317-333`).
|
||||
fn encode_variable_array<'a, I>(values: I) -> Vec<u8>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let mut bytes = Vec::new();
|
||||
for value in values {
|
||||
let text_bytes = encode_utf16_with_nul(value);
|
||||
let mut header = [0u8; 13];
|
||||
// header[0..4] = 1 + 4 + 4 + textBytes.Length (`.cs:324`).
|
||||
write_i32_le(&mut header, 0, 1i32 + 4 + 4 + text_bytes.len() as i32);
|
||||
// header[4] = 0x05 (`.cs:325`).
|
||||
header[4] = 0x05;
|
||||
// header[5..9] = textBytes.Length + 4 (`.cs:326`).
|
||||
write_i32_le(&mut header, 5, text_bytes.len() as i32 + 4);
|
||||
// header[9..13] = textBytes.Length (`.cs:327`).
|
||||
write_i32_le(&mut header, 9, text_bytes.len() as i32);
|
||||
bytes.extend_from_slice(&header);
|
||||
bytes.extend_from_slice(&text_bytes);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
// ---- LE primitive helpers -------------------------------------------------
|
||||
|
||||
#[inline]
|
||||
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
||||
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
||||
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[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],
|
||||
])
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::MxReferenceHandle;
|
||||
use crate::write_message::{self, WriteValue};
|
||||
|
||||
fn sample_handle() -> MxReferenceHandle {
|
||||
MxReferenceHandle::from_names(
|
||||
1,
|
||||
42,
|
||||
17,
|
||||
300,
|
||||
"TestChildObject",
|
||||
-1,
|
||||
7,
|
||||
0,
|
||||
"TestInt",
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_int32_body(value: i32, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Int32(value),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_boolean_body(value: bool, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Boolean(value),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_string_body(value: &str, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::String(value.to_string()),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_int32_array_body(values: &[i32], write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Int32Array(values.to_vec()),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---- Constants -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn offset_constants_match_dotnet() {
|
||||
// `ObservedWriteBodyTemplate.cs:9-11`.
|
||||
assert_eq!(FIXED_VALUE_OFFSET, 18);
|
||||
assert_eq!(VARIABLE_VALUE_OFFSET, 26);
|
||||
assert_eq!(ARRAY_VALUE_OFFSET, 28);
|
||||
}
|
||||
|
||||
// ---- Round-trip identity --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fixed_round_trip_preserves_bytes_when_value_unchanged() {
|
||||
// Capture an Int32(123) body, replay with the same value — every
|
||||
// byte should match.
|
||||
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replayed = template.with_int32(123, 7).unwrap();
|
||||
assert_eq!(replayed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_round_trip_preserves_bytes_when_value_unchanged() {
|
||||
let original = observed_string_body("hello", 3, 0x12345678);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
|
||||
let replayed = template.with_string("hello", 3).unwrap();
|
||||
assert_eq!(replayed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_round_trip_preserves_suffix_when_value_unchanged() {
|
||||
let original = observed_int32_array_body(&[1, 2, 3], 1, 0xABCD);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
|
||||
let replayed = template
|
||||
.with_value(&MxValue::Int32Array(vec![1, 2, 3]), 1)
|
||||
.unwrap();
|
||||
// The 14-byte suffix (last 18 minus writeIndex) must match.
|
||||
let original_suffix_start = original.len() - 18;
|
||||
let replayed_suffix_start = replayed.len() - 18;
|
||||
assert_eq!(
|
||||
&original[original_suffix_start..original_suffix_start + 14],
|
||||
&replayed[replayed_suffix_start..replayed_suffix_start + 14]
|
||||
);
|
||||
// Trailing writeIndex field too.
|
||||
assert_eq!(
|
||||
&original[original.len() - 4..],
|
||||
&replayed[replayed.len() - 4..]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Selective replacement -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn replace_int32_only_changes_value_slot() {
|
||||
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replaced = template.with_int32(456, 7).unwrap();
|
||||
assert_eq!(replaced.len(), original.len());
|
||||
// Everything except body[18..22] should match.
|
||||
for (i, (&a, &b)) in original.iter().zip(replaced.iter()).enumerate() {
|
||||
if (FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(a, b, "byte at offset {i} should be preserved");
|
||||
}
|
||||
// The value slot reflects 456.
|
||||
assert_eq!(
|
||||
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&456i32.to_le_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_boolean_uses_literal_4byte_pattern() {
|
||||
// Boolean payload uses the literal 4-byte pattern, not a single byte.
|
||||
let original = observed_boolean_body(true, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Boolean, &original).unwrap();
|
||||
let replaced = template.with_boolean(false, 1).unwrap();
|
||||
assert_eq!(
|
||||
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&[0x00, 0xff, 0xff, 0x00]
|
||||
);
|
||||
// Re-replacing with true gives the original.
|
||||
let back = template.with_boolean(true, 1).unwrap();
|
||||
assert_eq!(
|
||||
&back[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&[0xff, 0xff, 0xff, 0x00]
|
||||
);
|
||||
assert_eq!(back, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_string_with_different_length_grows_body_and_patches_lengths() {
|
||||
// The .cs path supports same-length AND different-length string
|
||||
// replacements: PatchVariableLengths rewrites both length fields
|
||||
// (`.cs:378-388`).
|
||||
let original = observed_string_body("hi", 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
|
||||
let replaced = template.with_string("hello world", 1).unwrap();
|
||||
// New length must reflect the new payload (UTF-16 + NUL = 24 bytes).
|
||||
let utf16_len = "hello world".encode_utf16().count() * 2 + 2;
|
||||
let outer = read_i32_le(&replaced, 18);
|
||||
let inner = read_i32_le(&replaced, 22);
|
||||
assert_eq!(inner, utf16_len as i32);
|
||||
assert_eq!(outer, utf16_len as i32 + 4);
|
||||
// The captured suffix (everything after the value slot) must
|
||||
// still appear verbatim.
|
||||
let suffix_start = VARIABLE_VALUE_OFFSET + utf16_len;
|
||||
let suffix_len = template.suffix_before_write_index().len();
|
||||
assert_eq!(
|
||||
&replaced[suffix_start..suffix_start + suffix_len],
|
||||
template.suffix_before_write_index()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Suffix preservation ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suffix_clienttoken_and_writeindex_preserved_across_with_value_calls() {
|
||||
let original = observed_int32_body(123, 0xAA, 0xDEADBEEF);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
// Replace value but keep writeIndex; the captured clientToken
|
||||
// (in the suffix) must round-trip.
|
||||
let replaced = template.with_int32(999, 0xAA).unwrap();
|
||||
// For Int32 normal write, the suffix layout is:
|
||||
// body[22..24] = -1 i16
|
||||
// body[24..32] = 8-byte filler
|
||||
// body[32..36] = clientToken u32
|
||||
// body[36..40] = writeIndex i32
|
||||
let client_token =
|
||||
u32::from_le_bytes([replaced[32], replaced[33], replaced[34], replaced[35]]);
|
||||
assert_eq!(client_token, 0xDEADBEEF);
|
||||
let write_index =
|
||||
i32::from_le_bytes([replaced[36], replaced[37], replaced[38], replaced[39]]);
|
||||
assert_eq!(write_index, 0xAA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_minus_one_marker_preserved() {
|
||||
// The leading i16 of the normal suffix is -1 (0xFFFF); it lives in
|
||||
// the captured suffix bytes, so any with_value call should preserve it.
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replaced = template.with_int32(0xFEED, 99).unwrap();
|
||||
let leading = i16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
assert_eq!(leading, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_index_decoder() {
|
||||
let original = observed_int32_body(0, 0xABCDEF, 0);
|
||||
let decoded = ObservedWriteBodyTemplate::decode_write_index(&original).unwrap();
|
||||
assert_eq!(decoded, 0xABCDEF);
|
||||
}
|
||||
|
||||
// ---- Array kind: count patch and suffix -----------------------------
|
||||
|
||||
#[test]
|
||||
fn array_descriptor_count_patched_on_replacement() {
|
||||
// Original: 3 Int32 elements -> body[22..24] count = 3.
|
||||
let original = observed_int32_array_body(&[10, 20, 30], 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
|
||||
// Replace with a 5-element array; descriptor count u16 must be 5.
|
||||
let replaced = template
|
||||
.with_value(&MxValue::Int32Array(vec![1, 2, 3, 4, 5]), 1)
|
||||
.unwrap();
|
||||
let count = u16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_string_descriptor_not_patched_on_replacement() {
|
||||
// StringArray / DateTimeArray skip PatchArrayDescriptor (.cs:397-400).
|
||||
let original = write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::StringArray(vec!["a".into(), "b".into()]),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::StringArray, &original).unwrap();
|
||||
let count_before = u16::from_le_bytes([original[22], original[23]]);
|
||||
let replaced = template
|
||||
.with_value(
|
||||
&MxValue::StringArray(vec!["xx".into(), "yy".into(), "zz".into()]),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let count_after = u16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
// Per .NET behaviour, the count u16 is NOT patched for variable arrays.
|
||||
assert_eq!(count_after, count_before);
|
||||
}
|
||||
|
||||
// ---- Error paths -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_short_buffer() {
|
||||
let err =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &[0u8; 23]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_unsupported_kind() {
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::ElapsedTime, &original)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_short_array_body() {
|
||||
// 28 + 18 - 1 = 45 bytes (one byte short).
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &[0u8; 45])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_invalid_variable_lengths() {
|
||||
// Build a 30-byte body where body[22..26] declares a value length of 0.
|
||||
let mut buf = vec![0u8; 30];
|
||||
write_i32_le(&mut buf, 22, 0);
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &buf).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_value_rejects_kind_mismatch() {
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let err = template.with_value(&MxValue::Float32(1.0), 1).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_and_wire_kind_accessors() {
|
||||
let original = observed_int32_body(123, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
// Normal Write opcode is 0x37; Int32 wire kind is 0x02.
|
||||
assert_eq!(template.command(), 0x37);
|
||||
assert_eq!(template.wire_kind(), 0x02);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user