//! `NmxSecuredWrite2Message` — secured timestamped-write (`0x38`) message //! body codec. //! //! Direct port of `src/MxNativeCodec/NmxSecuredWrite2Message.cs`. //! //! ## Naming and the single-token form //! //! The .NET method name is `WriteSecured2`. It always carries **two** user //! identifiers (`currentUserToken` and `verifierUserToken`) plus a timestamp //! and a client name — there is no separate single-token form on the LMX //! wire. Single-user secured writes pass `currentUserToken == verifierUserToken` //! (per `wwtools/mxaccesscli/docs/api-notes.md:60-72` verification noted in //! `design/40-protocol-invariants.md` after the MAJOR-pass audit). //! //! The R6 entry in `design/70-risks-and-open-questions.md:174` confirms this: //! the production LMX surface accepts `WriteSecured` with two user ids //! unconditionally, and the captured `0x38` shape always has both token slots. //! //! ## Body layout //! //! The secured body inherits the `Write2` (timestamped) prefix shape, then //! appends authentication / verification fields **before** the trailing //! `(-1 i16) + clientToken(u32) + writeIndex(i32)` slot //! (`NmxSecuredWrite2Message.cs:40-69`). //! //! ```text //! offset size field source //! 0..N N = Write2 prefix timestamped Write2 body up to but //! NOT including the 4-byte clientToken //! and 4-byte writeIndex .cs:41-54 //! N 16 currentUserToken .cs:56 //! N+16 4 clientNameLen i32 LE .cs:58 //! N+20 clientNameLen clientNameBytes (UTF-16LE + NUL) .cs:60 //! N+20+L 16 verifierUserToken .cs:62 //! N+36+L 2 -1 i16 LE .cs:64 //! N+38+L 4 clientToken u32 LE .cs:66 //! N+42+L 4 writeIndex i32 LE .cs:68 //! ``` //! //! `prefixLength = timestampedPrefix.Length - sizeof(uint) - sizeof(int)` //! (`.cs:51`) — i.e. the timestamped body **minus** its trailing 8 bytes //! (clientToken + writeIndex). The opcode byte is overwritten from `0x37` //! to `0x38` after the timestamped encoder runs (`.cs:48`). //! //! ## Observed authenticated-user token //! //! The .NET reference exposes a sample observed token at `.cs:12-18`. It is //! mirrored here as [`OBSERVED_AUTHENTICATED_USER_TOKEN`] for tests and //! probes that want to replay the captured `captures/036-frida-secured*` //! body (cited in `design/40-protocol-invariants.md:164`). // Direct byte indexing — see reference_handle.rs for rationale. #![allow(clippy::indexing_slicing)] use crate::MxReferenceHandle; use crate::error::CodecError; use crate::write_message::{self, WriteValue}; /// Secured-write opcode (`NmxSecuredWrite2Message.cs:8`). pub const COMMAND: u8 = 0x38; /// Wire-format version (`NmxSecuredWrite2Message.cs:9`). The .NET `Encode` /// path defers to `NmxWriteMessage.EncodeTimestamped` which writes /// `version = 1` (`NmxWriteMessage.cs:10`); the constant is preserved here /// for parity with the .NET surface. pub const VERSION: u16 = 1; /// Authenticator token length in bytes (`NmxSecuredWrite2Message.cs:10`). pub const AUTHENTICATOR_TOKEN_LENGTH: usize = 16; /// Sample observed authenticated-user token from the live AVEVA stack /// (`NmxSecuredWrite2Message.cs:12-18`, captured in /// `captures/036-frida-secured*`). pub const OBSERVED_AUTHENTICATED_USER_TOKEN: [u8; AUTHENTICATOR_TOKEN_LENGTH] = [ 0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89, 0x0f, ]; /// Resolve the observed token form for a given user id. Mirrors /// `ResolveObservedUserToken` (`NmxSecuredWrite2Message.cs:94-99`): user id /// `0` returns 16 zero bytes; any other id returns the observed authenticated /// token. This helper is for tests / probes; production callers should pass /// real tokens. pub fn resolve_observed_user_token(user_id: i32) -> [u8; AUTHENTICATOR_TOKEN_LENGTH] { if user_id == 0 { [0u8; AUTHENTICATOR_TOKEN_LENGTH] } else { OBSERVED_AUTHENTICATED_USER_TOKEN } } /// Encode a `WriteSecured2` body (`0x38`). /// /// Mirrors `NmxSecuredWrite2Message.Encode` (`NmxSecuredWrite2Message.cs:20-70`). /// Internally builds a timestamped Write2 body via /// [`crate::write_message::encode_timestamped`], strips its trailing 8 bytes /// (clientToken + writeIndex), overwrites the leading opcode byte, and /// appends the secured suffix. /// /// `current_user_token == verifier_user_token` is the single-user secured /// write path and is allowed unconditionally — see module doc. /// /// # Errors /// /// - Returns a [`CodecError::Decode`] if the underlying timestamped Write /// encode fails (e.g. array element count exceeds `u16::MAX`). #[allow(clippy::too_many_arguments)] pub fn encode( handle: &MxReferenceHandle, value: &WriteValue, current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH], verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH], client_name: &str, timestamp_filetime: i64, write_index: i32, client_token: u32, ) -> Result, CodecError> { // 1. Build the timestamped Write2 body. The .NET reference passes // `clientToken: 0` (`NmxSecuredWrite2Message.cs:47`) — those 8 trailing // bytes are about to be stripped, so the value is irrelevant. let timestamped = write_message::encode_timestamped(handle, value, timestamp_filetime, write_index, 0)?; // 2. Strip the trailing clientToken (4) + writeIndex (4) from the // timestamped body — `prefixLength = ts.Length - 4 - 4` // (`NmxSecuredWrite2Message.cs:51`). let prefix_length = timestamped.len() - 4 - 4; // 3. UTF-16LE + NUL terminator for the client name // (`NmxSecuredWrite2Message.cs:50`, // `Encoding.Unicode.GetBytes(clientName + '\0')`). let client_name_bytes = encode_utf16_with_nul(client_name); let client_name_len = client_name_bytes.len(); // 4. Allocate body of the exact final size // (`NmxSecuredWrite2Message.cs:52`). let body_len = prefix_length + AUTHENTICATOR_TOKEN_LENGTH + 4 + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 2 + 4 + 4; let mut body = vec![0u8; body_len]; // 5. Copy stripped timestamped prefix and overwrite opcode // (`NmxSecuredWrite2Message.cs:48, 54`). body[..prefix_length].copy_from_slice(×tamped[..prefix_length]); body[0] = COMMAND; // 6. Append secured suffix in declared order // (`NmxSecuredWrite2Message.cs:55-69`). let mut offset = prefix_length; body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(¤t_user_token); offset += AUTHENTICATOR_TOKEN_LENGTH; write_i32_le(&mut body, offset, client_name_len as i32); offset += 4; body[offset..offset + client_name_len].copy_from_slice(&client_name_bytes); offset += client_name_len; body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(&verifier_user_token); offset += AUTHENTICATOR_TOKEN_LENGTH; write_i16_le(&mut body, offset, -1); offset += 2; write_u32_le(&mut body, offset, client_token); offset += 4; write_i32_le(&mut body, offset, write_index); Ok(body) } /// Decoded secured-write body. #[derive(Debug, Clone, PartialEq)] pub struct DecodedSecuredWrite { /// Inner timestamped Write2 result (handle projection, value, write /// index, client token, timestamp). Note the `client_token` and /// `write_index` of the inner result come from the **secured** suffix — /// the original timestamped body was encoded with `client_token = 0` /// before the trailing 8 bytes were stripped (.cs:47, .cs:51), so the /// decoder reconstructs a synthetic timestamped body with the secured /// suffix's clientToken+writeIndex re-attached for round-trip parity. pub inner: write_message::DecodedWrite, pub current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH], pub verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH], pub client_name: String, } /// Decode a `WriteSecured2` body produced by [`encode`]. /// /// The .NET reference is encode-only (`NmxSecuredWrite2Message.cs:6-105`); the /// Rust port adds a decoder for round-trip tests, mirroring the encoder /// layout exactly. /// /// # Errors /// /// - [`CodecError::ShortRead`] if `body` is too small to carry the secured /// suffix. /// - [`CodecError::UnexpectedOpcode`] if `body[0] != 0x38`. /// - [`CodecError::Decode`] for malformed lengths or invalid client-name UTF-16. /// - Any error returned by [`crate::write_message::decode`] for the inner /// reconstructed timestamped body. pub fn decode(body: &[u8]) -> Result { if body.is_empty() { return Err(CodecError::ShortRead { expected: 1, actual: 0, }); } if body[0] != COMMAND { return Err(CodecError::UnexpectedOpcode(body[0])); } // Trailing slot: 16 (verifier) + 2 (-1 i16) + 4 (clientToken) + 4 (writeIndex) // = 26 bytes after the client-name region. // The minimum body shape is: prefix(>=18) + currentToken(16) + nameLen(4) // + nameBytes(>=2 NUL) + verifierToken(16) + 10-byte tail. if body.len() < 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10 { return Err(CodecError::ShortRead { expected: 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10, actual: body.len(), }); } // Strategy: walk back from the end. The last 26 bytes are the secured // suffix; before that lies a UTF-16LE client_name of length declared in // the i32 LE that precedes it; before THAT lies the 16-byte // currentUserToken; before THAT lies the timestamped Write2 prefix // (without its trailing 8 bytes). We don't know the prefix length up // front, but we can locate the secured suffix by scanning for the // currentUserToken offset using the client-name length field. // // Concretely: the last 26 bytes are // verifier(16) + -1 i16 + clientToken(4) + writeIndex(4) = 26 // Before them is the client_name of length L (variable). Before THAT // is the i32 LE clientNameLen (4). Before THAT is the currentUserToken (16). // // We need to find the offset where currentUserToken starts. We do that // by reading clientNameLen from a position relative to the end: // offset_of_clientNameLen = body.len() - 26 - L - 4 // offset_of_clientNameLen + 4 + L + 16 + 2 + 4 + 4 = body.len() // // Equivalently: the trailing region after the timestamped prefix is // 16 + 4 + L + 16 + 2 + 4 + 4 = 46 + L bytes. // // So prefix_length = body.len() - (46 + L). // // We don't know L without locating clientNameLen first. The .NET // reference does not record where the prefix ends — it derives it on // encode. On decode, we need to use the inner write_message::decode to // figure out the timestamped body's natural length, then derive L. // // Approach: reconstruct a synthetic timestamped body by appending an // 8-byte clientToken+writeIndex tail with zeros to a candidate prefix, // decode it, and use the resulting body length to find the boundary. // // Simpler: walk forward through the timestamped wire shape using the // crate's own write_message::decode after we know the prefix bytes. // But we don't know the prefix length yet. // // The cleanest approach: read the trailing structure. We know the body // tail layout. Count bytes from the end: // [body.len() - 4 .. body.len()] writeIndex i32 // [body.len() - 8 .. body.len() - 4] clientToken u32 // [body.len() - 10 .. body.len() - 8] -1 i16 // [body.len() - 26 .. body.len() - 10] verifierUserToken (16) // [body.len() - 26 - L .. body.len() - 26] clientNameBytes (L) // [body.len() - 30 - L .. body.len() - 26 - L] clientNameLen i32 (4) // [body.len() - 46 - L .. body.len() - 30 - L] currentUserToken (16) // We need L. The clientNameLen i32 lives at offset (body.len() - 30 - L). // We can solve by scanning candidate L values OR by realising that the // timestamped prefix has a deterministic length given the value kind. // // We use the deterministic-length approach: rebuild a candidate // timestamped prefix of length `prefix_length`, then decode by parsing // the wire-kind-driven shape. Since the inner write_message::decode // already implements this, and since the prefix shape is fully // determined by body[1..3] (version) and body[17] (wire_kind), we can // compute the timestamped body length without seeing the secured suffix. // body[0..18] is the common prefix: cmd + version + 14 handle bytes + wire_kind. let wire_kind = body[17]; // For each wire kind, the timestamped body length is fixed (scalar) or // determined by an inner length prefix (variable / array). The // timestamped body length = prefix_length + 8 (the 8 stripped trailing // bytes). prefix_length = body.len() - 46 - L. We don't know L. // // But we DO know the timestamped body length from wire_kind directly: let ts_body_len = compute_timestamped_body_len(body, wire_kind)?; let prefix_length = ts_body_len - 8; // Now layout is known. let suffix_offset = prefix_length; if suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4 > body.len() { return Err(CodecError::ShortRead { expected: suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4, actual: body.len(), }); } let mut current_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH]; current_user_token .copy_from_slice(&body[suffix_offset..suffix_offset + AUTHENTICATOR_TOKEN_LENGTH]); let name_len_offset = suffix_offset + AUTHENTICATOR_TOKEN_LENGTH; let client_name_len = read_i32_le(body, name_len_offset); if client_name_len < 0 { return Err(CodecError::Decode { offset: name_len_offset, reason: "secured-write: negative clientNameLen", buffer_len: body.len(), }); } let client_name_len = client_name_len as usize; let name_offset = name_len_offset + 4; if name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10 > body.len() { return Err(CodecError::ShortRead { expected: name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10, actual: body.len(), }); } let client_name_bytes = &body[name_offset..name_offset + client_name_len]; let client_name = decode_utf16_with_nul(client_name_bytes, name_offset, body.len())?; let verifier_offset = name_offset + client_name_len; let mut verifier_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH]; verifier_user_token .copy_from_slice(&body[verifier_offset..verifier_offset + AUTHENTICATOR_TOKEN_LENGTH]); let tail_offset = verifier_offset + AUTHENTICATOR_TOKEN_LENGTH; let leading = read_i16_le(body, tail_offset); if leading != -1 { return Err(CodecError::Decode { offset: tail_offset, reason: "secured-write: trailing leading i16 is not -1", buffer_len: body.len(), }); } let secured_client_token = read_u32_le(body, tail_offset + 2); let secured_write_index = read_i32_le(body, tail_offset + 6); // Reconstruct the timestamped body so we can call write_message::decode. // The .NET encoder calls EncodeTimestamped with clientToken=0 then // strips, so the original prefix has 0 in the clientToken slot. We need // to substitute the secured suffix's clientToken+writeIndex back so the // inner DecodedWrite reflects what the caller passed to encode(). let mut ts_body = body[..prefix_length].to_vec(); ts_body.extend_from_slice(&secured_client_token.to_le_bytes()); ts_body.extend_from_slice(&secured_write_index.to_le_bytes()); // Restore the inner opcode (was overwritten to 0x38; restore to 0x37 // so write_message::decode accepts it). ts_body[0] = write_message::COMMAND; let inner = write_message::decode(&ts_body)?; Ok(DecodedSecuredWrite { inner, current_user_token, verifier_user_token, client_name, }) } /// Compute the length of the timestamped Write2 body for a given wire kind, /// reading any inner length fields from `body` (which carries a `0x38` body — /// but the prefix bytes are identical to the timestamped `0x37` body). fn compute_timestamped_body_len(body: &[u8], wire_kind: u8) -> Result { // Timestamped body shapes (mirroring write_message.rs): // Boolean (timestamped): 17 + 1 + 1 + 14 + 4 = 37 // [actually: KIND_OFFSET(17) + 1 + 1-byte payload + 18-byte suffix // = 37]. Per write_message.rs:357-364. // Int32: 17 + 1 + 4 + 14 + 4 = 40 // Float32: 40 // Float64: 17 + 1 + 8 + 14 + 4 = 44 // Variable: 44 + utf16_len (read inner_len at offset 22) // Array: 46 + payload_len (count u16 at 22, walk for variable arrays) match wire_kind { 0x01 => Ok(37), 0x02 | 0x03 => Ok(40), 0x04 => Ok(44), 0x05 => { // body[22..26] = inner_length (i32 LE) — UTF-16 byte length // including 2-byte NUL terminator. if body.len() < 26 { return Err(CodecError::ShortRead { expected: 26, actual: body.len(), }); } let inner_len = read_i32_le(body, 22); if inner_len < 0 { return Err(CodecError::Decode { offset: 22, reason: "secured-write: negative variable inner_length", buffer_len: body.len(), }); } Ok(44 + inner_len as usize) } 0x41..=0x44 => { if body.len() < 28 { return Err(CodecError::ShortRead { expected: 28, actual: body.len(), }); } let count = read_u16_le(body, 22) as usize; // `wire_kind` is constrained to 0x41..=0x44 by the outer match // arm; default to 2 for any out-of-table value (shouldn't occur). let element_width = match wire_kind { 0x41 => 2, 0x42 | 0x43 => 4, 0x44 => 8, _ => 2, }; Ok(28 + count * element_width + 18) } 0x45 => { if body.len() < 28 { return Err(CodecError::ShortRead { expected: 28, actual: body.len(), }); } let count = read_u16_le(body, 22) as usize; let mut cursor = 28usize; for _ in 0..count { if cursor + 13 > body.len() { return Err(CodecError::ShortRead { expected: cursor + 13, actual: body.len(), }); } let inner_len = read_i32_le(body, cursor + 9); if inner_len < 0 { return Err(CodecError::Decode { offset: cursor + 9, reason: "secured-write: negative variable-array inner_length", buffer_len: body.len(), }); } cursor += 13 + inner_len as usize; } Ok(cursor + 18) } _ => Err(CodecError::Decode { offset: 17, reason: "secured-write: unknown wire kind", buffer_len: body.len(), }), } } // ---- UTF-16 helpers ------------------------------------------------------- /// UTF-16LE encoding with a trailing 2-byte NUL terminator. /// Mirrors `Encoding.Unicode.GetBytes(clientName + '\0')` /// (`NmxSecuredWrite2Message.cs:50`). 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()); } // Trailing NUL (the `+ '\0'` in the .NET source). bytes.push(0x00); bytes.push(0x00); bytes } fn decode_utf16_with_nul( raw: &[u8], offset: usize, buffer_len: usize, ) -> Result { if raw.len() % 2 != 0 { return Err(CodecError::Decode { offset, reason: "secured-write: client_name byte length is not even", buffer_len, }); } let utf16: Vec = raw .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); // Strip the trailing NUL terminator (the .NET path always emits one). let trimmed: &[u16] = if utf16.last() == Some(&0) { &utf16[..utf16.len() - 1] } else { &utf16 }; String::from_utf16(trimmed).map_err(|_| CodecError::Decode { offset, reason: "secured-write: invalid UTF-16 in client_name", buffer_len, }) } // ---- LE primitive helpers ------------------------------------------------- #[inline] fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) { 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 write_u32_le(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } #[inline] fn read_i16_le(bytes: &[u8], offset: usize) -> i16 { i16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } #[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], ]) } #[inline] fn read_u16_le(bytes: &[u8], offset: usize) -> u16 { u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } #[inline] fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { u32::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::*; fn sample_handle() -> MxReferenceHandle { MxReferenceHandle::from_names( 1, 42, 17, 300, "TestChildObject", -1, 7, 0, "TestInt", false, ) .unwrap() } const TOKEN_A: [u8; 16] = [ 0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89, 0x0f, ]; const TOKEN_B: [u8; 16] = [ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, ]; #[test] fn opcode_and_constants_match_dotnet() { // `NmxSecuredWrite2Message.cs:8-10`. assert_eq!(COMMAND, 0x38); assert_eq!(VERSION, 1); assert_eq!(AUTHENTICATOR_TOKEN_LENGTH, 16); } #[test] fn observed_authenticated_user_token_matches_dotnet() { // `NmxSecuredWrite2Message.cs:12-18`. assert_eq!( OBSERVED_AUTHENTICATED_USER_TOKEN, [ 0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89, 0x0f ] ); } #[test] fn resolve_observed_user_token_matches_dotnet() { // `NmxSecuredWrite2Message.cs:94-99`. assert_eq!(resolve_observed_user_token(0), [0u8; 16]); assert_eq!( resolve_observed_user_token(123), OBSERVED_AUTHENTICATED_USER_TOKEN ); } #[test] fn opcode_byte_is_overwritten_to_0x38() { // `NmxSecuredWrite2Message.cs:48`. let h = sample_handle(); let body = encode( &h, &WriteValue::Int32(123), TOKEN_A, TOKEN_B, "client", 123_456_789_i64, 5, 0xCAFE_BABE, ) .unwrap(); assert_eq!(body[0], COMMAND); } #[test] fn round_trip_int32_two_distinct_user_tokens() { let h = sample_handle(); let body = encode( &h, &WriteValue::Int32(0x1234_5678), TOKEN_A, TOKEN_B, "TestClient", 0x0102_0304_0506_0708_i64, 42, 0xDEAD_BEEF, ) .unwrap(); let decoded = decode(&body).unwrap(); assert_eq!(decoded.current_user_token, TOKEN_A); assert_eq!(decoded.verifier_user_token, TOKEN_B); assert_ne!(decoded.current_user_token, decoded.verifier_user_token); assert_eq!(decoded.client_name, "TestClient"); assert_eq!(decoded.inner.value, WriteValue::Int32(0x1234_5678)); assert_eq!(decoded.inner.write_index, 42); assert_eq!(decoded.inner.client_token, 0xDEAD_BEEF); assert_eq!( decoded.inner.timestamp_filetime, Some(0x0102_0304_0506_0708) ); } #[test] fn round_trip_boolean_single_user_path() { // Per module doc / api-notes.md: single-user secured writes use // currentUserToken == verifierUserToken. let h = sample_handle(); let body = encode( &h, &WriteValue::Boolean(true), TOKEN_A, TOKEN_A, // same token both slots "Solo", 1_700_000_000_000_000_000_i64, 1, 0x1234, ) .unwrap(); let decoded = decode(&body).unwrap(); assert_eq!(decoded.current_user_token, decoded.verifier_user_token); assert_eq!(decoded.current_user_token, TOKEN_A); assert_eq!(decoded.client_name, "Solo"); assert_eq!(decoded.inner.value, WriteValue::Boolean(true)); } #[test] fn round_trip_with_empty_client_name() { // Empty string still emits a 2-byte NUL terminator // (`Encoding.Unicode.GetBytes("" + '\0')`). let h = sample_handle(); let body = encode(&h, &WriteValue::Int32(0), TOKEN_A, TOKEN_B, "", 0, 1, 0).unwrap(); let decoded = decode(&body).unwrap(); assert_eq!(decoded.client_name, ""); assert_eq!(decoded.inner.value, WriteValue::Int32(0)); } #[test] fn round_trip_with_populated_client_name() { let h = sample_handle(); let body = encode( &h, &WriteValue::Int32(42), TOKEN_A, TOKEN_B, "Operator-Console-1", 0, 7, 0xABCD, ) .unwrap(); let decoded = decode(&body).unwrap(); assert_eq!(decoded.client_name, "Operator-Console-1"); } #[test] fn round_trip_string_value() { let h = sample_handle(); let body = encode( &h, &WriteValue::String("hello".to_string()), TOKEN_A, TOKEN_B, "client", 0x1122_3344_5566_7788_i64, 3, 0xFEED, ) .unwrap(); let decoded = decode(&body).unwrap(); assert_eq!(decoded.inner.value, WriteValue::String("hello".to_string())); assert_eq!( decoded.inner.timestamp_filetime, Some(0x1122_3344_5566_7788) ); } #[test] fn wrong_opcode_rejected() { // Take a real body and clobber the opcode. let h = sample_handle(); let mut body = encode(&h, &WriteValue::Int32(1), TOKEN_A, TOKEN_B, "x", 0, 1, 0).unwrap(); body[0] = 0x37; let err = decode(&body).unwrap_err(); assert!(matches!(err, CodecError::UnexpectedOpcode(0x37))); } #[test] fn trailing_fields_land_at_correct_offsets() { // For Int32 the timestamped prefix length is 40 - 8 = 32 bytes. // Verify that: // body[32..48] = currentUserToken // body[48..52] = clientNameLen i32 LE // body[52..52+L] = clientNameBytes // body[..+16] = verifierUserToken // then -1 i16 + clientToken u32 + writeIndex i32 let h = sample_handle(); let client_name = "abc"; // 3 chars * 2 + 2 (NUL) = 8 bytes let body = encode( &h, &WriteValue::Int32(7), TOKEN_A, TOKEN_B, client_name, 0x1111_2222_3333_4444_i64, 5, 0xBEEF_CAFE, ) .unwrap(); // prefix_length for Int32 timestamped = 32. let prefix_length = 32; assert_eq!(&body[prefix_length..prefix_length + 16], &TOKEN_A); let name_len_offset = prefix_length + 16; let name_len = i32::from_le_bytes([ body[name_len_offset], body[name_len_offset + 1], body[name_len_offset + 2], body[name_len_offset + 3], ]); assert_eq!(name_len, 8); let name_offset = name_len_offset + 4; let name_bytes = &body[name_offset..name_offset + 8]; // "abc\0" UTF-16LE LE = 'a' 0 'b' 0 'c' 0 0 0 assert_eq!( name_bytes, &[0x61, 0x00, 0x62, 0x00, 0x63, 0x00, 0x00, 0x00] ); let verifier_offset = name_offset + 8; assert_eq!(&body[verifier_offset..verifier_offset + 16], &TOKEN_B); let tail = verifier_offset + 16; let leading_i16 = i16::from_le_bytes([body[tail], body[tail + 1]]); assert_eq!(leading_i16, -1); let client_token = u32::from_le_bytes([ body[tail + 2], body[tail + 3], body[tail + 4], body[tail + 5], ]); assert_eq!(client_token, 0xBEEF_CAFE); let write_index = i32::from_le_bytes([ body[tail + 6], body[tail + 7], body[tail + 8], body[tail + 9], ]); assert_eq!(write_index, 5); // Total body length: prefix(32) + 16 + 4 + 8 + 16 + 2 + 4 + 4 = 86 assert_eq!(body.len(), 86); } #[test] fn short_buffer_rejected() { let err = decode(&[0x38u8; 4]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } #[test] fn empty_buffer_rejected() { let err = decode(&[]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } }