e79e289743
Fix all 33 rustdoc warnings across the workspace: - Unresolved intra-doc links: rewrite [`name`] → either backtick text (when not actually a link) or fully-qualified `[Type::method]` / `[crate::module::name]` form. Affected: mxaccess-codec (asb_variant, item_control, metadata_query, observed_write_template, reference_handle, write_message), mxaccess-rpc (pdu), mxaccess-nmx (client), mxaccess-asb-nettcp (nmf), mxaccess-callback (exporter), mxaccess (asb_session, session, lib). - Bracket-text being interpreted as link refs (e.g. `body[17]` → `` `body[17]` ``). - Private-item references in public docs (CALLBACK_BROADCAST_CAPACITY, recover_connection_core, mxvalue_to_writevalue) reduced to backtick-text since they aren't part of the public API. `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` now exits clean. Workspace 759 tests pass; clippy clean. Defers `#![warn(missing_docs)]` lint to a future pass — the cleanup target is the broken-link warnings, which are signal; missing-docs would surface hundreds of low-priority public-item gaps that are out of scope for this F-number. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
905 lines
37 KiB
Rust
905 lines
37 KiB
Rust
//! `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_bytes`.
|
|
//! 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);
|
|
}
|
|
}
|