Files
mxaccess/rust/crates/mxaccess-codec/src/secured_write.rs
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:21:00 -04:00

821 lines
30 KiB
Rust

//! `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<Vec<u8>, 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(&timestamped[..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(&current_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<DecodedSecuredWrite, CodecError> {
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<usize, CodecError> {
// 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<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());
}
// 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<String, CodecError> {
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<u16> = 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 { .. }));
}
}