//! `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, suffix_before_write_index: Vec, } 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 { // `.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, 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, 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, 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, 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, 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, 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 { 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 { 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 { // `.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 { // `.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, 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 { let utf16: Vec = 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 where I: IntoIterator, { 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 { 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 { 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 { 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 { 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); } }