Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Cargo build artifacts
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Test fixture symlinks/junctions (per design/60-roadmap.md, copy is the
|
||||
# portable default; live-only fixture trees should not be committed)
|
||||
/tests/fixtures/
|
||||
Generated
+117
@@ -0,0 +1,117 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess-codec",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-asb"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess-asb-nettcp",
|
||||
"mxaccess-codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-asb-nettcp"
|
||||
version = "0.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-callback"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess-codec",
|
||||
"mxaccess-rpc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-codec"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-compat"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-galaxy"
|
||||
version = "0.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-nmx"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"mxaccess-callback",
|
||||
"mxaccess-codec",
|
||||
"mxaccess-rpc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mxaccess-rpc"
|
||||
version = "0.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
@@ -0,0 +1,52 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [
|
||||
"crates/mxaccess-codec",
|
||||
"crates/mxaccess-galaxy",
|
||||
"crates/mxaccess-rpc",
|
||||
"crates/mxaccess-callback",
|
||||
"crates/mxaccess-nmx",
|
||||
"crates/mxaccess-asb-nettcp",
|
||||
"crates/mxaccess-asb",
|
||||
"crates/mxaccess",
|
||||
"crates/mxaccess-compat",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/<org>/mxaccess"
|
||||
rust-version = "1.85"
|
||||
authors = ["Joseph Doherty <dohejw01@gmail.com>"]
|
||||
|
||||
# Workspace-level dependency pins. Crates opt in via `dep = { workspace = true }`.
|
||||
# M0 stubs use minimal deps; the full pinned set per design/30-crate-topology.md
|
||||
# will be uncommented as M1+ implementation lands.
|
||||
[workspace.dependencies]
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
byteorder = "1"
|
||||
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "warn" # warn during M0 stubs; will tighten to deny post-M1
|
||||
unreachable = "deny"
|
||||
indexing_slicing = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
@@ -0,0 +1,51 @@
|
||||
# mxaccess (Rust port)
|
||||
|
||||
Native Rust replacement for AVEVA / Wonderware MXAccess. See `../design/` for
|
||||
the architectural specification, `../src/` for the .NET reference (the
|
||||
executable spec), and `../CLAUDE.md` for project-wide rules.
|
||||
|
||||
## Status
|
||||
|
||||
**M0** — Workspace skeleton. Stub types compile; nothing is implemented yet.
|
||||
See `../design/60-roadmap.md` for the M0–M6 milestone plan.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
rust/
|
||||
Cargo.toml workspace root
|
||||
rust-toolchain.toml 1.85 stable
|
||||
crates/
|
||||
mxaccess-codec/ pure protocol codec, no I/O
|
||||
mxaccess-galaxy/ Galaxy SQL resolver (tiberius)
|
||||
mxaccess-rpc/ DCE/RPC + NTLMv2 + OXID + OBJREF
|
||||
mxaccess-callback/ INmxSvcCallback RPC server
|
||||
mxaccess-nmx/ INmxService2 client
|
||||
mxaccess-asb-nettcp/ net.tcp framing (MC-NMF + MC-NBFX/NBFS)
|
||||
mxaccess-asb/ IASBIDataV2 client
|
||||
mxaccess/ async session + Transport trait + public API
|
||||
mxaccess-compat/ LMXProxyServer-shaped facade
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
cargo build --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
## Live probes
|
||||
|
||||
```powershell
|
||||
. ..\tools\Setup-LiveProbeEnv.ps1
|
||||
cargo test -p mxaccess --features live -- --ignored
|
||||
```
|
||||
|
||||
The setup script fetches credentials from Infisical via
|
||||
`wwtools/secrets/Get-Secret.ps1`. Never inline plaintext credentials.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `../LICENSE`.
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "mxaccess-asb-nettcp"
|
||||
description = "net.tcp framing layer: MC-NMF (.NET Message Framing) + MC-NBFX/NBFS (.NET Binary XML / dictionary string table) — the default binary message encoder for NetTcpBinding. Workspace-internal."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
//! `mxaccess-asb-nettcp` — `[MS-NMF]` framing + `[MC-NBFX]/[MC-NBFS]` binary
|
||||
//! message encoding (the default `NetTcpBinding` encoder, **not** SOAP/XML).
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M5 — see `design/60-roadmap.md`.
|
||||
//! The .NET reference at `src/MxAsbClient/MxAsbDataClient.cs:660-685` uses
|
||||
//! `new NetTcpBinding(SecurityMode.None)` with no encoder override, which
|
||||
//! selects `BinaryMessageEncodingBindingElement` by default.
|
||||
//!
|
||||
//! Implements two layers:
|
||||
//! 1. `[MS-NMF]` framing (preamble, preamble-ack, sized-envelope, end, fault)
|
||||
//! plus the reliable-session ack handling on the underlying `net.tcp` channel.
|
||||
//! 2. `[MC-NBFX]` binary XML + `[MC-NBFS]` static dictionary that holds the
|
||||
//! SOAP/WS-Addressing/`IASBIDataV2`-action strings.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "mxaccess-asb"
|
||||
description = "IASBIDataV2 client — the alternate ASB data plane for the AVEVA System Platform."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||
mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `dpapi` provides a Windows DPAPI-backed default impl of the `SecretProvider`
|
||||
# trait. With `dpapi=off`, callers must inject a `SecretProvider` explicitly;
|
||||
# otherwise `Session::builder()` fails at construction.
|
||||
dpapi = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,5 @@
|
||||
//! `mxaccess-asb` — `IASBIDataV2` client.
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M5 — see `design/60-roadmap.md`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "mxaccess-callback"
|
||||
description = "TCP listener + RPC server for INmxSvcCallback and IRemUnknown (the callback exporter)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,12 @@
|
||||
//! `mxaccess-callback` — `INmxSvcCallback` RPC server (the callback exporter).
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`.
|
||||
//!
|
||||
//! Opnums (verified against `src/MxNativeClient/NmxSvcCallbackMessages.cs:11-12`):
|
||||
//! - `3` `DataReceived(bufferSize: i32, dataBuffer: sbyte[bufferSize]) -> hresult`
|
||||
//! - `4` `StatusReceived(bufferSize: i32, statusBuffer: sbyte[bufferSize]) -> hresult`
|
||||
//!
|
||||
//! Plus the `IRemUnknown::RemQueryInterface` handler that completes the
|
||||
//! server-side handshake against our exported OBJREF (DoD condition for M2).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "mxaccess-codec"
|
||||
description = "Pure encoder/decoder for NMX wire types (envelope, write/advise/subscribe bodies, reference handles) and ASB Variant. No I/O. Compiles on every Rust target."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
serde = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,390 @@
|
||||
//! `NmxTransferEnvelope` — 46-byte NMX wire envelope.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxTransferEnvelope.cs`. The Rust port
|
||||
//! adds `reserved6_10: [u8; 4]` preservation per CLAUDE.md unknown-bytes rule
|
||||
//! — the .NET reference reads only Version/InnerLength/ProtocolMarker/MessageKind
|
||||
//! and discards bytes 6..10 (`NmxTransferEnvelope.cs:39-75`); the Rust codec
|
||||
//! round-trips them.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
|
||||
/// Encoded layout per `NmxTransferEnvelope.cs:23-37`:
|
||||
///
|
||||
/// ```text
|
||||
/// offset size field
|
||||
/// 0 2 version u16 LE = 1
|
||||
/// 2 4 inner_length i32 LE = body.len() - 46
|
||||
/// 6 4 reserved6_10 [u8; 4] preserved verbatim by Rust port
|
||||
/// 10 4 message_kind i32 LE 1=Metadata, 2=ItemControl, 3=Write
|
||||
/// 14 4 source_galaxy_id i32 LE
|
||||
/// 18 4 source_platform_id i32 LE
|
||||
/// 22 4 local_engine_id i32 LE
|
||||
/// 26 4 target_galaxy_id i32 LE
|
||||
/// 30 4 target_platform_id i32 LE
|
||||
/// 34 4 target_engine_id i32 LE
|
||||
/// 38 4 protocol_marker i32 LE = 0x0201 (bytes: 01 02 00 00)
|
||||
/// 42 4 timeout_ms i32 LE default 30000
|
||||
/// 46+ body...
|
||||
/// ```
|
||||
pub const ENVELOPE_HEADER_LEN: usize = 46;
|
||||
|
||||
const VERSION: u16 = 1;
|
||||
const PROTOCOL_MARKER: i32 = 0x0201;
|
||||
const DEFAULT_TIMEOUT_MS: i32 = 30000;
|
||||
|
||||
const INNER_LENGTH_OFFSET: usize = 2;
|
||||
const RESERVED_OFFSET: usize = 6;
|
||||
const MESSAGE_KIND_OFFSET: usize = 10;
|
||||
const SOURCE_GALAXY_OFFSET: usize = 14;
|
||||
const SOURCE_PLATFORM_OFFSET: usize = 18;
|
||||
const LOCAL_ENGINE_OFFSET: usize = 22;
|
||||
const TARGET_GALAXY_OFFSET: usize = 26;
|
||||
const TARGET_PLATFORM_OFFSET: usize = 30;
|
||||
const TARGET_ENGINE_OFFSET: usize = 34;
|
||||
const PROTOCOL_MARKER_OFFSET: usize = 38;
|
||||
const TIMEOUT_OFFSET: usize = 42;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[non_exhaustive]
|
||||
#[repr(u8)]
|
||||
pub enum NmxTransferMessageKind {
|
||||
#[default]
|
||||
Unknown = 0,
|
||||
Metadata = 1,
|
||||
ItemControl = 2,
|
||||
Write = 3,
|
||||
}
|
||||
|
||||
impl NmxTransferMessageKind {
|
||||
fn from_i32(value: i32) -> Self {
|
||||
match value {
|
||||
1 => Self::Metadata,
|
||||
2 => Self::ItemControl,
|
||||
3 => Self::Write,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i32(self) -> i32 {
|
||||
match self {
|
||||
Self::Unknown => 0,
|
||||
Self::Metadata => 1,
|
||||
Self::ItemControl => 2,
|
||||
Self::Write => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 46-byte envelope. `reserved6_10` is preserved verbatim — the .NET reference
|
||||
/// discards these bytes on parse and writes 0 on encode. The Rust port carries
|
||||
/// them through so captured envelopes with non-zero values at offset 6..10
|
||||
/// round-trip byte-identical.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct NmxTransferEnvelope {
|
||||
pub message_kind: NmxTransferMessageKind,
|
||||
pub source_galaxy_id: i32,
|
||||
pub source_platform_id: i32,
|
||||
pub local_engine_id: i32,
|
||||
pub target_galaxy_id: i32,
|
||||
pub target_platform_id: i32,
|
||||
pub target_engine_id: i32,
|
||||
pub timeout_ms: i32,
|
||||
/// Bytes 6..10 of the envelope. The .NET reference does not retain these;
|
||||
/// the Rust port preserves them per CLAUDE.md unknown-bytes rule.
|
||||
/// Defaults to `[0; 4]` for newly-constructed envelopes.
|
||||
pub reserved6_10: [u8; 4],
|
||||
}
|
||||
|
||||
impl Default for NmxTransferEnvelope {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
message_kind: NmxTransferMessageKind::default(),
|
||||
source_galaxy_id: 1,
|
||||
source_platform_id: 1,
|
||||
local_engine_id: 0,
|
||||
target_galaxy_id: 0,
|
||||
target_platform_id: 0,
|
||||
target_engine_id: 0,
|
||||
timeout_ms: DEFAULT_TIMEOUT_MS,
|
||||
reserved6_10: [0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NmxTransferEnvelope {
|
||||
/// Header length in bytes.
|
||||
pub const HEADER_LEN: usize = ENVELOPE_HEADER_LEN;
|
||||
|
||||
/// Parse a transfer body — the 46-byte header followed by the inner body.
|
||||
/// Returns the parsed envelope and the inner body length (the inner bytes
|
||||
/// themselves are accessed by the caller via `&transfer_body[46..]`).
|
||||
///
|
||||
/// Mirrors `NmxTransferEnvelope.Parse` (`NmxTransferEnvelope.cs:39-75`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `transfer_body.len() < 46`.
|
||||
/// - [`CodecError::UnsupportedVersion`] if version != 1.
|
||||
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
|
||||
/// does not match `transfer_body.len() - 46`.
|
||||
/// - [`CodecError::UnsupportedProtocolMarker`] if the marker != 0x0201.
|
||||
pub fn parse(transfer_body: &[u8]) -> Result<Self, CodecError> {
|
||||
if transfer_body.len() < Self::HEADER_LEN {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: Self::HEADER_LEN,
|
||||
actual: transfer_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let version = read_u16_le(transfer_body, 0);
|
||||
if version != VERSION {
|
||||
return Err(CodecError::UnsupportedVersion {
|
||||
expected: VERSION,
|
||||
actual: version,
|
||||
});
|
||||
}
|
||||
|
||||
let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET);
|
||||
let actual_inner = transfer_body.len() - Self::HEADER_LEN;
|
||||
if inner_length != actual_inner as i32 {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: inner_length,
|
||||
actual: actual_inner,
|
||||
});
|
||||
}
|
||||
|
||||
let protocol_marker = read_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET);
|
||||
if protocol_marker != PROTOCOL_MARKER {
|
||||
return Err(CodecError::UnsupportedProtocolMarker(protocol_marker));
|
||||
}
|
||||
|
||||
let mut reserved6_10 = [0u8; 4];
|
||||
reserved6_10.copy_from_slice(&transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4]);
|
||||
|
||||
Ok(Self {
|
||||
message_kind: NmxTransferMessageKind::from_i32(read_i32_le(
|
||||
transfer_body,
|
||||
MESSAGE_KIND_OFFSET,
|
||||
)),
|
||||
source_galaxy_id: read_i32_le(transfer_body, SOURCE_GALAXY_OFFSET),
|
||||
source_platform_id: read_i32_le(transfer_body, SOURCE_PLATFORM_OFFSET),
|
||||
local_engine_id: read_i32_le(transfer_body, LOCAL_ENGINE_OFFSET),
|
||||
target_galaxy_id: read_i32_le(transfer_body, TARGET_GALAXY_OFFSET),
|
||||
target_platform_id: read_i32_le(transfer_body, TARGET_PLATFORM_OFFSET),
|
||||
target_engine_id: read_i32_le(transfer_body, TARGET_ENGINE_OFFSET),
|
||||
timeout_ms: read_i32_le(transfer_body, TIMEOUT_OFFSET),
|
||||
reserved6_10,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode the envelope header into the front of `transfer_body`. The
|
||||
/// caller is responsible for providing a buffer of length
|
||||
/// `46 + inner_body.len()` and copying the inner body into the tail
|
||||
/// before transmission.
|
||||
///
|
||||
/// Mirrors `NmxTransferEnvelope.Encode` (`NmxTransferEnvelope.cs:77-103`)
|
||||
/// but additionally writes `reserved6_10` (the .NET version always writes 0).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CodecError::InnerLengthMismatch`] if `transfer_body.len() < 46`.
|
||||
pub fn write_to(self, transfer_body: &mut [u8]) -> Result<(), CodecError> {
|
||||
if transfer_body.len() < Self::HEADER_LEN {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: 0,
|
||||
actual: transfer_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let inner_len = transfer_body.len() - Self::HEADER_LEN;
|
||||
write_u16_le(transfer_body, 0, VERSION);
|
||||
write_i32_le(transfer_body, INNER_LENGTH_OFFSET, inner_len as i32);
|
||||
transfer_body[RESERVED_OFFSET..RESERVED_OFFSET + 4].copy_from_slice(&self.reserved6_10);
|
||||
write_i32_le(
|
||||
transfer_body,
|
||||
MESSAGE_KIND_OFFSET,
|
||||
self.message_kind.to_i32(),
|
||||
);
|
||||
write_i32_le(transfer_body, SOURCE_GALAXY_OFFSET, self.source_galaxy_id);
|
||||
write_i32_le(
|
||||
transfer_body,
|
||||
SOURCE_PLATFORM_OFFSET,
|
||||
self.source_platform_id,
|
||||
);
|
||||
write_i32_le(transfer_body, LOCAL_ENGINE_OFFSET, self.local_engine_id);
|
||||
write_i32_le(transfer_body, TARGET_GALAXY_OFFSET, self.target_galaxy_id);
|
||||
write_i32_le(
|
||||
transfer_body,
|
||||
TARGET_PLATFORM_OFFSET,
|
||||
self.target_platform_id,
|
||||
);
|
||||
write_i32_le(transfer_body, TARGET_ENGINE_OFFSET, self.target_engine_id);
|
||||
write_i32_le(transfer_body, PROTOCOL_MARKER_OFFSET, PROTOCOL_MARKER);
|
||||
write_i32_le(transfer_body, TIMEOUT_OFFSET, self.timeout_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convenience encoder that allocates a buffer of `46 + inner_body.len()`,
|
||||
/// writes the header, and copies `inner_body` into the tail. Mirrors the
|
||||
/// shape of `NmxTransferEnvelope.Encode`.
|
||||
pub fn encode_with_inner(self, inner_body: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; Self::HEADER_LEN + inner_body.len()];
|
||||
// write_to validates and never errors when the buffer is large enough,
|
||||
// so this branch is unreachable in practice. We propagate the bug as
|
||||
// an empty buffer rather than panicking.
|
||||
if self.write_to(&mut out).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
out[Self::HEADER_LEN..].copy_from_slice(inner_body);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
||||
u16::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 write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 2].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 4].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_envelope() -> NmxTransferEnvelope {
|
||||
NmxTransferEnvelope {
|
||||
message_kind: NmxTransferMessageKind::Write,
|
||||
source_galaxy_id: 1,
|
||||
source_platform_id: 1,
|
||||
local_engine_id: 5,
|
||||
target_galaxy_id: 1,
|
||||
target_platform_id: 2,
|
||||
target_engine_id: 17,
|
||||
timeout_ms: 30000,
|
||||
reserved6_10: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_default_envelope() {
|
||||
let env = sample_envelope();
|
||||
let inner = [0xab, 0xcd, 0xef];
|
||||
let encoded = env.encode_with_inner(&inner);
|
||||
assert_eq!(encoded.len(), 46 + 3);
|
||||
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
|
||||
assert_eq!(env, parsed);
|
||||
assert_eq!(&encoded[46..], &inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_marker_bytes_are_le() {
|
||||
let env = sample_envelope();
|
||||
let encoded = env.encode_with_inner(&[]);
|
||||
// 0x0201 LE = 01 02 00 00
|
||||
assert_eq!(encoded[38], 0x01);
|
||||
assert_eq!(encoded[39], 0x02);
|
||||
assert_eq!(encoded[40], 0x00);
|
||||
assert_eq!(encoded[41], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_bytes_are_le_one() {
|
||||
let env = sample_envelope();
|
||||
let encoded = env.encode_with_inner(&[]);
|
||||
assert_eq!(encoded[0], 0x01);
|
||||
assert_eq!(encoded[1], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_bytes_round_trip() {
|
||||
// Construct an envelope with non-zero reserved bytes (as if parsed
|
||||
// from a captured frame). Encode and re-parse — they must survive.
|
||||
let env = NmxTransferEnvelope {
|
||||
reserved6_10: [0xde, 0xad, 0xbe, 0xef],
|
||||
..sample_envelope()
|
||||
};
|
||||
let encoded = env.encode_with_inner(&[]);
|
||||
assert_eq!(&encoded[6..10], &[0xde, 0xad, 0xbe, 0xef]);
|
||||
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
|
||||
assert_eq!(parsed.reserved6_10, [0xde, 0xad, 0xbe, 0xef]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_version() {
|
||||
let mut encoded = sample_envelope().encode_with_inner(&[]);
|
||||
encoded[0] = 0x02; // version = 2
|
||||
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
|
||||
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_inner_length_mismatch() {
|
||||
let mut encoded = sample_envelope().encode_with_inner(&[0; 4]);
|
||||
// Corrupt the inner_length field to claim 100 inner bytes when
|
||||
// there are only 4.
|
||||
write_i32_le(&mut encoded, INNER_LENGTH_OFFSET, 100);
|
||||
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
|
||||
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_protocol_marker() {
|
||||
let mut encoded = sample_envelope().encode_with_inner(&[]);
|
||||
write_i32_le(&mut encoded, PROTOCOL_MARKER_OFFSET, 0x0102);
|
||||
let err = NmxTransferEnvelope::parse(&encoded).unwrap_err();
|
||||
assert!(matches!(err, CodecError::UnsupportedProtocolMarker(0x0102)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_buffer() {
|
||||
let err = NmxTransferEnvelope::parse(&[0u8; 45]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_kind_round_trips() {
|
||||
for kind in [
|
||||
NmxTransferMessageKind::Metadata,
|
||||
NmxTransferMessageKind::ItemControl,
|
||||
NmxTransferMessageKind::Write,
|
||||
] {
|
||||
let env = NmxTransferEnvelope {
|
||||
message_kind: kind,
|
||||
..sample_envelope()
|
||||
};
|
||||
let encoded = env.encode_with_inner(&[]);
|
||||
let parsed = NmxTransferEnvelope::parse(&encoded).unwrap();
|
||||
assert_eq!(parsed.message_kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_length_constant() {
|
||||
assert_eq!(NmxTransferEnvelope::HEADER_LEN, 46);
|
||||
assert_eq!(ENVELOPE_HEADER_LEN, 46);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! `NmxTransferEnvelopeTemplate` — buffer-preserving alternative to
|
||||
//! [`crate::NmxTransferEnvelope`].
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxTransferEnvelopeTemplate.cs`.
|
||||
//!
|
||||
//! Where [`crate::NmxTransferEnvelope`] decodes the 46-byte header into typed
|
||||
//! fields and re-encodes them, the template path **takes a captured 46-byte
|
||||
//! header verbatim and only patches the field(s) the caller asks to patch**.
|
||||
//! Every other byte in the header — including any reserved/unknown bytes the
|
||||
//! typed codec ignores — is preserved bit-for-bit.
|
||||
//!
|
||||
//! This is the path used for high-fidelity replay against the AVEVA stack
|
||||
//! when the captured envelope contains bytes whose meaning is unproven and
|
||||
//! the round-trip must remain byte-identical to the capture.
|
||||
//!
|
||||
//! # Differences from the .NET reference
|
||||
//!
|
||||
//! - Setters return a new value (`with_inner_length`, `with_message_kind`)
|
||||
//! rather than mutating in place. The underlying header buffer is owned
|
||||
//! by the template, so `with_*` methods clone the buffer before patching.
|
||||
//! The .NET reference does not expose setters — it re-encodes only the
|
||||
//! `inner_length` field on every `Encode` call. The Rust port adds
|
||||
//! targeted setters for forward use cases without breaking the
|
||||
//! "patch only what the caller patches" contract.
|
||||
//! - `decode_inner` returns a borrow (`&[u8]`) rather than a `ReadOnlyMemory<byte>`.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::NmxTransferMessageKind;
|
||||
use crate::error::CodecError;
|
||||
|
||||
/// Header length in bytes (`NmxTransferEnvelopeTemplate.cs:7`).
|
||||
pub const HEADER_LENGTH: usize = 46;
|
||||
|
||||
/// Offset of the `inner_length` i32 LE field
|
||||
/// (`NmxTransferEnvelopeTemplate.cs:8`).
|
||||
pub const INNER_LENGTH_OFFSET: usize = 2;
|
||||
|
||||
/// Offset of the `message_kind` i32 LE field. Mirrors the constant of the
|
||||
/// same name in [`crate::envelope`].
|
||||
const MESSAGE_KIND_OFFSET: usize = 10;
|
||||
|
||||
/// Round-trip preserver for an observed 46-byte transfer envelope.
|
||||
///
|
||||
/// Internally stores the captured 46-byte header verbatim. Setters
|
||||
/// (`with_inner_length`, `with_message_kind`) return a clone with only the
|
||||
/// targeted bytes patched. [`Self::encode`] writes the header followed by the
|
||||
/// supplied inner body, patching `inner_length` to match.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NmxTransferEnvelopeTemplate {
|
||||
/// The captured 46-byte header, byte-for-byte (`NmxTransferEnvelopeTemplate.cs:10`).
|
||||
header: [u8; HEADER_LENGTH],
|
||||
}
|
||||
|
||||
impl NmxTransferEnvelopeTemplate {
|
||||
/// Header length in bytes (matches `NmxTransferEnvelopeTemplate.HeaderLength`).
|
||||
pub const HEADER_LEN: usize = HEADER_LENGTH;
|
||||
|
||||
/// Construct a template from an observed `TransferData` body.
|
||||
///
|
||||
/// The body must be at least 46 bytes long, and the `inner_length` field
|
||||
/// at offset 2 must declare exactly `body.len() - 46` bytes. Mirrors
|
||||
/// `FromObserved` (`NmxTransferEnvelopeTemplate.cs:17-31`).
|
||||
///
|
||||
/// Only the leading 46 bytes are retained — the inner body is dropped.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `observed_transfer_body.len() < 46`
|
||||
/// (`NmxTransferEnvelopeTemplate.cs:19-22`).
|
||||
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
|
||||
/// field does not match the actual inner length
|
||||
/// (`NmxTransferEnvelopeTemplate.cs:24-28`).
|
||||
pub fn from_observed(observed_transfer_body: &[u8]) -> Result<Self, CodecError> {
|
||||
if observed_transfer_body.len() < HEADER_LENGTH {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: HEADER_LENGTH,
|
||||
actual: observed_transfer_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let inner_length = read_i32_le(observed_transfer_body, INNER_LENGTH_OFFSET);
|
||||
let actual_inner = observed_transfer_body.len() - HEADER_LENGTH;
|
||||
if inner_length != actual_inner as i32 {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: inner_length,
|
||||
actual: actual_inner,
|
||||
});
|
||||
}
|
||||
|
||||
let mut header = [0u8; HEADER_LENGTH];
|
||||
header.copy_from_slice(&observed_transfer_body[..HEADER_LENGTH]);
|
||||
Ok(Self { header })
|
||||
}
|
||||
|
||||
/// Borrow the captured 46-byte header. Useful for round-trip identity
|
||||
/// asserts and for callers that need to inspect reserved/unknown bytes
|
||||
/// without going through the typed [`crate::NmxTransferEnvelope`] codec.
|
||||
pub fn header(&self) -> &[u8; HEADER_LENGTH] {
|
||||
&self.header
|
||||
}
|
||||
|
||||
/// Return a new template with `inner_length` (offset 2, i32 LE) patched
|
||||
/// to `inner_length`. Every other byte is preserved.
|
||||
///
|
||||
/// Note: [`Self::encode`] also patches `inner_length` to match the supplied
|
||||
/// inner body. This setter exists for callers that need to manipulate the
|
||||
/// template separately from encoding.
|
||||
#[must_use]
|
||||
pub fn with_inner_length(mut self, inner_length: i32) -> Self {
|
||||
write_i32_le(&mut self.header, INNER_LENGTH_OFFSET, inner_length);
|
||||
self
|
||||
}
|
||||
|
||||
/// Return a new template with `message_kind` (offset 10, i32 LE) patched
|
||||
/// to `kind`. Every other byte is preserved.
|
||||
///
|
||||
/// `NmxTransferMessageKind::Unknown` encodes as 0 — same as the typed
|
||||
/// codec ([`crate::envelope`]).
|
||||
#[must_use]
|
||||
pub fn with_message_kind(mut self, kind: NmxTransferMessageKind) -> Self {
|
||||
let value: i32 = match kind {
|
||||
NmxTransferMessageKind::Unknown => 0,
|
||||
NmxTransferMessageKind::Metadata => 1,
|
||||
NmxTransferMessageKind::ItemControl => 2,
|
||||
NmxTransferMessageKind::Write => 3,
|
||||
};
|
||||
write_i32_le(&mut self.header, MESSAGE_KIND_OFFSET, value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Encode the captured header followed by `inner_put_request_body`,
|
||||
/// patching the `inner_length` field at offset 2 to match the supplied
|
||||
/// inner body length.
|
||||
///
|
||||
/// Mirrors `Encode` (`NmxTransferEnvelopeTemplate.cs:33-40`). Allocates a
|
||||
/// fresh `Vec<u8>` of length `46 + inner_put_request_body.len()`.
|
||||
pub fn encode(&self, inner_put_request_body: &[u8]) -> Vec<u8> {
|
||||
let inner_len = inner_put_request_body.len();
|
||||
let mut body = vec![0u8; HEADER_LENGTH + inner_len];
|
||||
body[..HEADER_LENGTH].copy_from_slice(&self.header);
|
||||
// Patch the inner_length field — `NmxTransferEnvelopeTemplate.cs:37`.
|
||||
// `inner_len as i32` matches the .NET `int` cast.
|
||||
write_i32_le(&mut body, INNER_LENGTH_OFFSET, inner_len as i32);
|
||||
body[HEADER_LENGTH..].copy_from_slice(inner_put_request_body);
|
||||
body
|
||||
}
|
||||
|
||||
/// Strip the 46-byte header off `transfer_body` and return a borrow of
|
||||
/// the inner bytes.
|
||||
///
|
||||
/// Mirrors `DecodeInner` (`NmxTransferEnvelopeTemplate.cs:42-56`). Validates
|
||||
/// that the declared `inner_length` matches the actual inner body length.
|
||||
/// Does **not** verify that the captured 46-byte prefix matches the
|
||||
/// template's stored header — by design, the template path is a
|
||||
/// permissive round-trip; if the caller wants strict validation they
|
||||
/// should use [`crate::NmxTransferEnvelope::parse`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `transfer_body.len() < 46`
|
||||
/// (`NmxTransferEnvelopeTemplate.cs:44-47`).
|
||||
/// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length`
|
||||
/// does not match the actual inner length
|
||||
/// (`NmxTransferEnvelopeTemplate.cs:49-53`).
|
||||
pub fn decode_inner<'a>(&self, transfer_body: &'a [u8]) -> Result<&'a [u8], CodecError> {
|
||||
if transfer_body.len() < HEADER_LENGTH {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: HEADER_LENGTH,
|
||||
actual: transfer_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET);
|
||||
let actual_inner = transfer_body.len() - HEADER_LENGTH;
|
||||
if inner_length != actual_inner as i32 {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: inner_length,
|
||||
actual: actual_inner,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(&transfer_body[HEADER_LENGTH..])
|
||||
}
|
||||
}
|
||||
|
||||
#[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 write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 4].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic 46-byte header with each byte tagged so we can
|
||||
/// verify which bytes the round-trip preserves vs. patches.
|
||||
/// Offsets 2..6 are written explicitly so `inner_length` validates;
|
||||
/// every other byte is `0xA0 + offset & 0xff` so reserved-byte
|
||||
/// preservation is observable.
|
||||
fn synthetic_header_with_inner_len(inner_len: i32) -> [u8; HEADER_LENGTH] {
|
||||
let mut header = [0u8; HEADER_LENGTH];
|
||||
for (i, b) in header.iter_mut().enumerate() {
|
||||
*b = 0xA0u8.wrapping_add(i as u8);
|
||||
}
|
||||
write_i32_le(&mut header, INNER_LENGTH_OFFSET, inner_len);
|
||||
header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_length_constant() {
|
||||
assert_eq!(HEADER_LENGTH, 46);
|
||||
assert_eq!(NmxTransferEnvelopeTemplate::HEADER_LEN, 46);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_zero_inner() {
|
||||
// No inner body — header preserved exactly.
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let encoded = template.encode(&[]);
|
||||
assert_eq!(encoded.len(), HEADER_LENGTH);
|
||||
assert_eq!(&encoded[..], &header[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_short_buffer() {
|
||||
let err = NmxTransferEnvelopeTemplate::from_observed(&[0u8; 45]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_inner_length_mismatch() {
|
||||
let mut buf = [0u8; HEADER_LENGTH + 8];
|
||||
// Claim 100 inner bytes when only 8 follow.
|
||||
write_i32_le(&mut buf, INNER_LENGTH_OFFSET, 100);
|
||||
let err = NmxTransferEnvelopeTemplate::from_observed(&buf).unwrap_err();
|
||||
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_patches_inner_length() {
|
||||
// Template has inner_length = 0 baked in. Encoding with an 8-byte
|
||||
// inner body should patch inner_length to 8.
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let inner = [0xDEu8, 0xAD, 0xBE, 0xEF, 0x12, 0x34, 0x56, 0x78];
|
||||
let encoded = template.encode(&inner);
|
||||
assert_eq!(encoded.len(), HEADER_LENGTH + 8);
|
||||
assert_eq!(read_i32_le(&encoded, INNER_LENGTH_OFFSET), 8);
|
||||
// Inner body must follow.
|
||||
assert_eq!(&encoded[HEADER_LENGTH..], &inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_preserves_every_byte_outside_inner_length_field() {
|
||||
// Build a template from a header packed with non-trivial bytes.
|
||||
// After encoding with arbitrary inner data, every header byte
|
||||
// outside offset 2..6 must match the original header.
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let inner = vec![0u8; 32];
|
||||
let encoded = template.encode(&inner);
|
||||
for i in 0..HEADER_LENGTH {
|
||||
if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
encoded[i], header[i],
|
||||
"byte at offset {i} must be preserved verbatim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_inner_length_patches_only_inner_length_field() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let patched = template.with_inner_length(0x12345678);
|
||||
let patched_header = patched.header();
|
||||
// Inner length field reflects the patch.
|
||||
assert_eq!(read_i32_le(patched_header, INNER_LENGTH_OFFSET), 0x12345678);
|
||||
// Every other byte unchanged.
|
||||
for i in 0..HEADER_LENGTH {
|
||||
if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(patched_header[i], header[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_message_kind_patches_only_message_kind_field() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let patched = template.with_message_kind(NmxTransferMessageKind::Write);
|
||||
let patched_header = patched.header();
|
||||
// Message-kind field reflects the patch (Write = 3).
|
||||
assert_eq!(read_i32_le(patched_header, MESSAGE_KIND_OFFSET), 3);
|
||||
// Every other byte unchanged.
|
||||
for i in 0..HEADER_LENGTH {
|
||||
if (MESSAGE_KIND_OFFSET..MESSAGE_KIND_OFFSET + 4).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(patched_header[i], header[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_message_kind_round_trips_all_variants() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
for (kind, expected) in [
|
||||
(NmxTransferMessageKind::Unknown, 0),
|
||||
(NmxTransferMessageKind::Metadata, 1),
|
||||
(NmxTransferMessageKind::ItemControl, 2),
|
||||
(NmxTransferMessageKind::Write, 3),
|
||||
] {
|
||||
let patched = template.clone().with_message_kind(kind);
|
||||
assert_eq!(
|
||||
read_i32_le(patched.header(), MESSAGE_KIND_OFFSET),
|
||||
expected,
|
||||
"kind {kind:?} must encode as {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_inner_returns_inner_body() {
|
||||
// Build the full 50-byte buffer first; `from_observed` validates that
|
||||
// the buffer length matches the declared inner_length, so we must
|
||||
// pass header+inner together.
|
||||
let header = synthetic_header_with_inner_len(4);
|
||||
let mut full = vec![0u8; HEADER_LENGTH + 4];
|
||||
full[..HEADER_LENGTH].copy_from_slice(&header);
|
||||
full[HEADER_LENGTH..].copy_from_slice(&[0x11, 0x22, 0x33, 0x44]);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&full).unwrap();
|
||||
let inner = template.decode_inner(&full).unwrap();
|
||||
assert_eq!(inner, &[0x11, 0x22, 0x33, 0x44]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_inner_rejects_short_buffer() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
let err = template.decode_inner(&[0u8; 45]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_inner_rejects_inner_length_mismatch() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
// Build a 46+8 byte body but with inner_length declared as 0.
|
||||
let mut full = vec![0u8; HEADER_LENGTH + 8];
|
||||
full[..HEADER_LENGTH].copy_from_slice(&header);
|
||||
// header has inner_length = 0; actual inner is 8 → mismatch.
|
||||
let err = template.decode_inner(&full).unwrap_err();
|
||||
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_accessor_returns_captured_bytes() {
|
||||
let header = synthetic_header_with_inner_len(0);
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap();
|
||||
assert_eq!(template.header(), &header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_observed_body_is_preserved_byte_for_byte() {
|
||||
// Simulates a captured envelope where bytes outside the four typed
|
||||
// fields carry non-zero "reserved" data. The template path must
|
||||
// round-trip every byte. The typed `NmxTransferEnvelope` codec
|
||||
// would normally strip / synthesise these bytes; the template
|
||||
// sidesteps that.
|
||||
let mut captured = [0u8; HEADER_LENGTH + 12];
|
||||
// Pack the header with a recognisable pattern.
|
||||
for (i, b) in captured[..HEADER_LENGTH].iter_mut().enumerate() {
|
||||
*b = 0xA5u8.wrapping_add(i as u8);
|
||||
}
|
||||
// Set inner_length = 12 so from_observed accepts it.
|
||||
write_i32_le(&mut captured, INNER_LENGTH_OFFSET, 12);
|
||||
// Inner body bytes.
|
||||
for (i, b) in captured[HEADER_LENGTH..].iter_mut().enumerate() {
|
||||
*b = 0xC0u8.wrapping_add(i as u8);
|
||||
}
|
||||
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&captured).unwrap();
|
||||
let encoded = template.encode(&captured[HEADER_LENGTH..]);
|
||||
assert_eq!(
|
||||
encoded, captured,
|
||||
"round-trip via template must be byte-identical"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Codec-level errors. Used by [`MxReferenceHandle`](crate::MxReferenceHandle),
|
||||
//! [`NmxTransferEnvelope`](crate::NmxTransferEnvelope), and the M1+ message
|
||||
//! codecs.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum CodecError {
|
||||
/// A buffer was shorter than required to decode the type.
|
||||
#[error("short read: expected {expected} bytes, got {actual}")]
|
||||
ShortRead { expected: usize, actual: usize },
|
||||
|
||||
/// `name.trim().is_empty()`. Mirrors `ArgumentException.ThrowIfNullOrWhiteSpace`
|
||||
/// in `MxReferenceHandle.cs:49`.
|
||||
#[error("name must not be empty or whitespace-only")]
|
||||
InvalidName,
|
||||
|
||||
/// The `inner_length` field declared in an envelope did not match the
|
||||
/// actual body size.
|
||||
#[error("inner length {declared} does not match body size {actual}")]
|
||||
InnerLengthMismatch { declared: i32, actual: usize },
|
||||
|
||||
#[error("unsupported version {actual} (expected {expected})")]
|
||||
UnsupportedVersion { expected: u16, actual: u16 },
|
||||
|
||||
#[error("unsupported protocol marker {0:#010x}")]
|
||||
UnsupportedProtocolMarker(i32),
|
||||
|
||||
#[error("unexpected opcode {0:#04x}")]
|
||||
UnexpectedOpcode(u8),
|
||||
|
||||
/// Decoder failure with a position and human-readable reason.
|
||||
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
|
||||
Decode {
|
||||
offset: usize,
|
||||
reason: &'static str,
|
||||
buffer_len: usize,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! `NmxItemControlMessage` — NMX item-control body (advise / unadvise).
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxItemControlMessage.cs`. The body
|
||||
//! carries an advise-supervisory or unadvise command together with a 16-byte
|
||||
//! item correlation GUID and a 14-byte projection of an [`MxReferenceHandle`]
|
||||
//! (handle bytes 6..20 — `object_id` through `attribute_index`).
|
||||
//!
|
||||
//! ## Wire layout
|
||||
//!
|
||||
//! Per `NmxItemControlMessage.cs:24-36, 63-81, 121-142`:
|
||||
//!
|
||||
//! ```text
|
||||
//! offset size field notes
|
||||
//! 0 1 command (u8) 0x1f AdviseSupervisory, 0x21 UnAdvise
|
||||
//! 1 2 version (u16 LE) must be 1
|
||||
//! 3 16 item_correlation_id (GUID) .NET layout (mixed-endian)
|
||||
//! 19 2 advise extra (u16 LE) ONLY when command == AdviseSupervisory
|
||||
//! [+2 if advise]
|
||||
//! 19/21 2 object_id (u16 LE)
|
||||
//! +2 2 object_signature (u16 LE)
|
||||
//! +4 2 primitive_id (i16 LE)
|
||||
//! +6 2 attribute_id (i16 LE)
|
||||
//! +8 2 property_id (i16 LE)
|
||||
//! +10 2 attribute_signature (u16 LE)
|
||||
//! +12 2 attribute_index (i16 LE)
|
||||
//! +14 4 tail (u32 LE) default 3 per cs:88
|
||||
//! ```
|
||||
//!
|
||||
//! Total: 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
|
||||
//!
|
||||
//! ## Opcode invariant
|
||||
//!
|
||||
//! `Advise` and `AdviseSupervisory` share opcode `0x1f` in the .NET enum
|
||||
//! (`NmxItemControlMessage.cs:7-8`). The parser explicitly rejects anything
|
||||
//! that is not `AdviseSupervisory` or `UnAdvise` (`cs:46-49`); there is no
|
||||
//! 37-byte plain-Advise wire shape. The Rust port mirrors this: the public
|
||||
//! command type only exposes `AdviseSupervisory` and `UnAdvise`.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale. Every read or
|
||||
// write is preceded by an explicit length check that mirrors the .NET source's
|
||||
// `ReadOnlySpan` slicing, so the resulting code reads as a 1:1 mirror of
|
||||
// `BinaryPrimitives` calls.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
|
||||
/// NMX item-control command opcode.
|
||||
///
|
||||
/// In the .NET reference this is a `byte` enum where `Advise` and
|
||||
/// `AdviseSupervisory` are aliases for `0x1f` (`NmxItemControlMessage.cs:5-10`).
|
||||
/// Only `AdviseSupervisory` and `UnAdvise` are accepted on the wire
|
||||
/// (`cs:46-49`), so the Rust enum collapses the alias and exposes just those
|
||||
/// two variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum NmxItemControlCommand {
|
||||
/// `0x1f`. Per `NmxItemControlMessage.cs:8`.
|
||||
AdviseSupervisory = 0x1f,
|
||||
/// `0x21`. Per `NmxItemControlMessage.cs:9`.
|
||||
UnAdvise = 0x21,
|
||||
}
|
||||
|
||||
impl NmxItemControlCommand {
|
||||
/// Map a wire byte to a command, mirroring the parser check at
|
||||
/// `NmxItemControlMessage.cs:45-49`.
|
||||
fn from_u8(value: u8) -> Result<Self, CodecError> {
|
||||
match value {
|
||||
0x1f => Ok(Self::AdviseSupervisory),
|
||||
0x21 => Ok(Self::UnAdvise),
|
||||
other => Err(CodecError::UnexpectedOpcode(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-format constants from `NmxItemControlMessage.cs:24-28`.
|
||||
const VERSION: u16 = 1;
|
||||
const HEADER_LENGTH: usize = 3; // cmd(1) + version u16(2)
|
||||
const GUID_LENGTH: usize = 16; // cs:26
|
||||
const ADVISE_EXTRA_LENGTH: usize = 2; // cs:27
|
||||
const PAYLOAD_LENGTH: usize = 18; // cs:28 — 7×u16 + u32 tail = 18 bytes
|
||||
|
||||
/// Default tail value used by `FromReferenceHandle` (`NmxItemControlMessage.cs:88`).
|
||||
pub const DEFAULT_TAIL: u32 = 3;
|
||||
|
||||
/// Decoded NMX item-control body. The fields after `item_correlation_id`
|
||||
/// project bytes 6..20 of an [`MxReferenceHandle`] — see
|
||||
/// `NmxItemControlMessage.cs:71-81, 134-141`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct NmxItemControlMessage {
|
||||
pub command: NmxItemControlCommand,
|
||||
/// 16-byte GUID. Stored as raw bytes in .NET-`Guid` layout (the layout
|
||||
/// produced by `Guid.TryWriteBytes` and consumed by `new Guid(span)` —
|
||||
/// mixed-endian: little-endian `Data1`/`Data2`/`Data3`, big-endian
|
||||
/// `Data4`/`Data4Tail`). The Rust port stays at the byte level so the
|
||||
/// .NET-shape round-trips exactly. See `NmxItemControlMessage.cs:64, 127`.
|
||||
pub item_correlation_id: [u8; GUID_LENGTH],
|
||||
pub object_id: u16,
|
||||
pub object_signature: u16,
|
||||
pub primitive_id: i16,
|
||||
pub attribute_id: i16,
|
||||
pub property_id: i16,
|
||||
pub attribute_signature: u16,
|
||||
pub attribute_index: i16,
|
||||
/// Trailing u32. Default `3` per `NmxItemControlMessage.cs:88`.
|
||||
pub tail: u32,
|
||||
}
|
||||
|
||||
impl NmxItemControlMessage {
|
||||
/// Encoded length for a given command. Matches
|
||||
/// `NmxItemControlMessage.GetEncodedLength` (`cs:30-36`):
|
||||
/// 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
|
||||
#[must_use]
|
||||
pub fn encoded_length(command: NmxItemControlCommand) -> usize {
|
||||
HEADER_LENGTH
|
||||
+ GUID_LENGTH
|
||||
+ match command {
|
||||
NmxItemControlCommand::AdviseSupervisory => ADVISE_EXTRA_LENGTH,
|
||||
NmxItemControlCommand::UnAdvise => 0,
|
||||
}
|
||||
+ PAYLOAD_LENGTH
|
||||
}
|
||||
|
||||
/// Construct a message from the bytes 6..20 of a reference handle.
|
||||
/// Mirrors `NmxItemControlMessage.FromReferenceHandle` (`cs:84-101`).
|
||||
///
|
||||
/// `tail` defaults to [`DEFAULT_TAIL`] (`3`) per `cs:88`.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_reference_handle_fields(
|
||||
command: NmxItemControlCommand,
|
||||
item_correlation_id: [u8; GUID_LENGTH],
|
||||
object_id: u16,
|
||||
object_signature: u16,
|
||||
primitive_id: i16,
|
||||
attribute_id: i16,
|
||||
property_id: i16,
|
||||
attribute_signature: u16,
|
||||
attribute_index: i16,
|
||||
tail: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
item_correlation_id,
|
||||
object_id,
|
||||
object_signature,
|
||||
primitive_id,
|
||||
attribute_id,
|
||||
property_id,
|
||||
attribute_signature,
|
||||
attribute_index,
|
||||
tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a copy with `command = UnAdvise`. Mirrors `ToUnAdvise`
|
||||
/// (`NmxItemControlMessage.cs:145-148`).
|
||||
#[must_use]
|
||||
pub fn to_un_advise(self) -> Self {
|
||||
Self {
|
||||
command: NmxItemControlCommand::UnAdvise,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a copy with `command = AdviseSupervisory`. Mirrors
|
||||
/// `ToAdviseSupervisory` (`NmxItemControlMessage.cs:150-153`).
|
||||
#[must_use]
|
||||
pub fn to_advise_supervisory(self) -> Self {
|
||||
Self {
|
||||
command: NmxItemControlCommand::AdviseSupervisory,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an item-control body. Mirrors `NmxItemControlMessage.Parse`
|
||||
/// (`cs:38-82`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if the buffer is shorter than the
|
||||
/// minimum 37-byte UnAdvise body (`cs:40-43`).
|
||||
/// - [`CodecError::UnexpectedOpcode`] if the leading command byte is
|
||||
/// neither `0x1f` (AdviseSupervisory) nor `0x21` (UnAdvise) (`cs:45-49`).
|
||||
/// This is also what blocks the alias plain-`Advise` from being
|
||||
/// accepted on the wire.
|
||||
/// - [`CodecError::UnsupportedVersion`] if the version word is not 1
|
||||
/// (`cs:51-55`).
|
||||
/// - [`CodecError::Decode`] if the buffer length does not match the
|
||||
/// per-command expected length (`cs:57-61`).
|
||||
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
|
||||
// Minimum length is the UnAdvise body (no advise-extra). Mirrors cs:40.
|
||||
let min_len = HEADER_LENGTH + GUID_LENGTH + PAYLOAD_LENGTH;
|
||||
if body.len() < min_len {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: min_len,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let command = NmxItemControlCommand::from_u8(body[0])?;
|
||||
|
||||
let version = read_u16_le(body, 1);
|
||||
if version != VERSION {
|
||||
return Err(CodecError::UnsupportedVersion {
|
||||
expected: VERSION,
|
||||
actual: version,
|
||||
});
|
||||
}
|
||||
|
||||
let expected_length = Self::encoded_length(command);
|
||||
if body.len() != expected_length {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "unexpected item-control body length",
|
||||
buffer_len: body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut offset = HEADER_LENGTH;
|
||||
let mut item_correlation_id = [0u8; GUID_LENGTH];
|
||||
item_correlation_id.copy_from_slice(&body[offset..offset + GUID_LENGTH]);
|
||||
offset += GUID_LENGTH;
|
||||
|
||||
if command == NmxItemControlCommand::AdviseSupervisory {
|
||||
// Skip the 2-byte advise-extra word (cs:66-69). The .NET parser
|
||||
// does not retain it; the Rust port mirrors that drop on parse
|
||||
// and writes zeros on encode.
|
||||
offset += ADVISE_EXTRA_LENGTH;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
item_correlation_id,
|
||||
object_id: read_u16_le(body, offset),
|
||||
object_signature: read_u16_le(body, offset + 2),
|
||||
primitive_id: read_i16_le(body, offset + 4),
|
||||
attribute_id: read_i16_le(body, offset + 6),
|
||||
property_id: read_i16_le(body, offset + 8),
|
||||
attribute_signature: read_u16_le(body, offset + 10),
|
||||
attribute_index: read_i16_le(body, offset + 12),
|
||||
tail: read_u32_le(body, offset + 14),
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode to a freshly allocated `Vec<u8>`. Mirrors
|
||||
/// `NmxItemControlMessage.Encode` (`cs:121-143`).
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut body = vec![0u8; Self::encoded_length(self.command)];
|
||||
body[0] = self.command.to_u8();
|
||||
write_u16_le(&mut body, 1, VERSION);
|
||||
|
||||
let mut offset = HEADER_LENGTH;
|
||||
body[offset..offset + GUID_LENGTH].copy_from_slice(&self.item_correlation_id);
|
||||
offset += GUID_LENGTH;
|
||||
|
||||
if self.command == NmxItemControlCommand::AdviseSupervisory {
|
||||
// Two zero bytes per cs:129-132 — the .NET source advances the
|
||||
// offset over already-zeroed buffer space.
|
||||
offset += ADVISE_EXTRA_LENGTH;
|
||||
}
|
||||
|
||||
write_u16_le(&mut body, offset, self.object_id);
|
||||
write_u16_le(&mut body, offset + 2, self.object_signature);
|
||||
write_i16_le(&mut body, offset + 4, self.primitive_id);
|
||||
write_i16_le(&mut body, offset + 6, self.attribute_id);
|
||||
write_i16_le(&mut body, offset + 8, self.property_id);
|
||||
write_u16_le(&mut body, offset + 10, self.attribute_signature);
|
||||
write_i16_le(&mut body, offset + 12, self.attribute_index);
|
||||
write_u32_le(&mut body, offset + 14, self.tail);
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
||||
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
|
||||
i16::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],
|
||||
])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 2].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 2].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_u32_le(bytes: &mut [u8], offset: usize, value: u32) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset..offset + 4].copy_from_slice(&le);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(command: NmxItemControlCommand) -> NmxItemControlMessage {
|
||||
NmxItemControlMessage {
|
||||
command,
|
||||
item_correlation_id: [0x11; GUID_LENGTH],
|
||||
object_id: 0x1234,
|
||||
object_signature: 0xABCD,
|
||||
primitive_id: -1,
|
||||
attribute_id: 7,
|
||||
property_id: 0,
|
||||
attribute_signature: 0xBEEF,
|
||||
attribute_index: -1,
|
||||
tail: DEFAULT_TAIL,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_length_matches_dotnet() {
|
||||
// cs:30-36: AdviseSupervisory = 3+16+2+18 = 39, UnAdvise = 3+16+18 = 37.
|
||||
assert_eq!(
|
||||
NmxItemControlMessage::encoded_length(NmxItemControlCommand::AdviseSupervisory),
|
||||
39
|
||||
);
|
||||
assert_eq!(
|
||||
NmxItemControlMessage::encoded_length(NmxItemControlCommand::UnAdvise),
|
||||
37
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_advise_supervisory() {
|
||||
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(encoded.len(), 39);
|
||||
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_un_advise() {
|
||||
let msg = sample(NmxItemControlCommand::UnAdvise);
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(encoded.len(), 37);
|
||||
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_plain_advise_alias() {
|
||||
// The .NET enum aliases `Advise = 0x1f = AdviseSupervisory`, so on
|
||||
// the wire the leading byte 0x1f always means AdviseSupervisory.
|
||||
// There is *no* 37-byte plain-Advise shape; a 37-byte body that
|
||||
// starts with 0x1f must be rejected because the per-command length
|
||||
// check (cs:57-61) demands 39 bytes for 0x1f.
|
||||
let mut bogus = vec![0u8; 37];
|
||||
bogus[0] = 0x1f;
|
||||
write_u16_le(&mut bogus, 1, VERSION);
|
||||
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CodecError::Decode { .. }),
|
||||
"expected length-mismatch decode error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unknown_command_opcode() {
|
||||
// Leading byte that is neither 0x1f nor 0x21 — cs:46-49.
|
||||
let mut bogus = vec![0u8; 37];
|
||||
bogus[0] = 0x42;
|
||||
write_u16_le(&mut bogus, 1, VERSION);
|
||||
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
|
||||
assert!(matches!(err, CodecError::UnexpectedOpcode(0x42)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_length_buffer_advise() {
|
||||
// 39 bytes is right for AdviseSupervisory; 38 is wrong.
|
||||
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
|
||||
let mut encoded = msg.encode();
|
||||
encoded.pop();
|
||||
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
|
||||
// 38 bytes still passes the 37-byte minimum, so we hit the
|
||||
// per-command length mismatch (cs:57-61) — Decode.
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_length_buffer_un_advise() {
|
||||
// 36 bytes is below the 37-byte UnAdvise minimum (cs:40).
|
||||
let err = NmxItemControlMessage::parse(&[0u8; 36]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_oversized_un_advise() {
|
||||
// 38 bytes with leading 0x21 — UnAdvise demands exactly 37.
|
||||
let mut bogus = vec![0u8; 38];
|
||||
bogus[0] = 0x21;
|
||||
write_u16_le(&mut bogus, 1, VERSION);
|
||||
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_version() {
|
||||
let msg = sample(NmxItemControlCommand::UnAdvise);
|
||||
let mut encoded = msg.encode();
|
||||
write_u16_le(&mut encoded, 1, 2);
|
||||
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
|
||||
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_round_trips_byte_identical() {
|
||||
// Use a distinctive byte pattern so a re-shuffle would be obvious.
|
||||
let guid: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||||
0xee, 0xff,
|
||||
];
|
||||
let msg = NmxItemControlMessage {
|
||||
item_correlation_id: guid,
|
||||
..sample(NmxItemControlCommand::AdviseSupervisory)
|
||||
};
|
||||
let encoded = msg.encode();
|
||||
// GUID lives at offset 3..19 (after cmd+version) per cs:63-65.
|
||||
assert_eq!(&encoded[3..19], &guid);
|
||||
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
|
||||
assert_eq!(decoded.item_correlation_id, guid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_round_trips_known_pattern() {
|
||||
// The brief calls out `[0x11; 16]` as a sanity vector.
|
||||
let guid = [0x11u8; 16];
|
||||
let msg = NmxItemControlMessage {
|
||||
item_correlation_id: guid,
|
||||
..sample(NmxItemControlCommand::UnAdvise)
|
||||
};
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(&encoded[3..19], &guid);
|
||||
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
|
||||
assert_eq!(decoded.item_correlation_id, [0x11; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tail_is_three() {
|
||||
// cs:88 — `uint tail = 3`.
|
||||
assert_eq!(DEFAULT_TAIL, 3);
|
||||
let msg = NmxItemControlMessage::from_reference_handle_fields(
|
||||
NmxItemControlCommand::AdviseSupervisory,
|
||||
[0x11; 16],
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
DEFAULT_TAIL,
|
||||
);
|
||||
let encoded = msg.encode();
|
||||
// Tail u32 lives at the last 4 bytes of the body.
|
||||
let n = encoded.len();
|
||||
assert_eq!(&encoded[n - 4..], &3u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advise_supervisory_extra_bytes_are_zero_on_encode() {
|
||||
// cs:129-132: the 2-byte advise-extra word at offset 19..21 is
|
||||
// skipped (left as zero in the freshly-allocated buffer).
|
||||
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(&encoded[19..21], &[0x00, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_projection_offsets_advise_supervisory() {
|
||||
// For AdviseSupervisory the handle projection starts at offset 21
|
||||
// (3 + 16 + 2). Verify object_id 0x1234 lands as 34 12 there.
|
||||
let msg = NmxItemControlMessage {
|
||||
object_id: 0x1234,
|
||||
..sample(NmxItemControlCommand::AdviseSupervisory)
|
||||
};
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(encoded[21], 0x34);
|
||||
assert_eq!(encoded[22], 0x12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_projection_offsets_un_advise() {
|
||||
// For UnAdvise the handle projection starts at offset 19 (3 + 16).
|
||||
let msg = NmxItemControlMessage {
|
||||
object_id: 0x1234,
|
||||
..sample(NmxItemControlCommand::UnAdvise)
|
||||
};
|
||||
let encoded = msg.encode();
|
||||
assert_eq!(encoded[19], 0x34);
|
||||
assert_eq!(encoded[20], 0x12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_word_is_one_le() {
|
||||
let encoded = sample(NmxItemControlCommand::UnAdvise).encode();
|
||||
assert_eq!(encoded[1], 0x01);
|
||||
assert_eq!(encoded[2], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_byte_round_trips() {
|
||||
let advise = sample(NmxItemControlCommand::AdviseSupervisory).encode();
|
||||
let unadvise = sample(NmxItemControlCommand::UnAdvise).encode();
|
||||
assert_eq!(advise[0], 0x1f);
|
||||
assert_eq!(unadvise[0], 0x21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_un_advise_and_back() {
|
||||
// Mirrors cs:145-153 — `with` updates only the command.
|
||||
let advise = sample(NmxItemControlCommand::AdviseSupervisory);
|
||||
let unadvise = advise.to_un_advise();
|
||||
assert_eq!(unadvise.command, NmxItemControlCommand::UnAdvise);
|
||||
// All other fields preserved.
|
||||
assert_eq!(advise.item_correlation_id, unadvise.item_correlation_id);
|
||||
assert_eq!(advise.object_id, unadvise.object_id);
|
||||
assert_eq!(advise.tail, unadvise.tail);
|
||||
let again = unadvise.to_advise_supervisory();
|
||||
assert_eq!(again.command, NmxItemControlCommand::AdviseSupervisory);
|
||||
assert_eq!(again, advise);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! `mxaccess-codec` — pure protocol codec for the AVEVA / Wonderware MXAccess
|
||||
//! wire format. No I/O.
|
||||
//!
|
||||
//! M1 codec parity in progress. Implemented:
|
||||
//! - Foundational types: `MxReferenceHandle` (CRC-16/IBM), `NmxTransferEnvelope`
|
||||
//! (with `reserved6_10` preservation), `MxStatus` + `MxStatusCategory` +
|
||||
//! `MxStatusSource` + `detail_text`, `MxValue` + `MxValueKind` + `MxDataType`.
|
||||
//! - Message-body codecs: `NmxItemControlMessage` (advise/supervisory/unadvise),
|
||||
//! `write_message` module (scalar + array, normal + timestamped Write),
|
||||
//! `subscription_message` (DataUpdate `0x33` + SubscriptionStatus `0x32`),
|
||||
//! `NmxReferenceRegistrationMessage` + Result, `NmxOperationStatusMessage`
|
||||
//! (incl. the proven `00 00 50 80 00` 5-byte completion frame and the
|
||||
//! `0x00`/`0x41`/`0xEF` 1-byte completion frames preserved verbatim),
|
||||
//! `NmxMetadataQueryMessage` (observed pre-advise template),
|
||||
//! `NmxTransferEnvelopeTemplate` (round-trip preserver).
|
||||
//!
|
||||
//! Remaining (wave 2): `NmxSecuredWrite2Message` (`0x38`),
|
||||
//! `ObservedWriteBodyTemplate`. ASB Variant + AsbStatus + RuntimeValue land
|
||||
//! in M5.
|
||||
//!
|
||||
//! Every wire shape here is grounded in `src/MxNativeCodec/*.cs` (the .NET
|
||||
//! reference) and `captures/0NN-frida-*` (Frida ground truth).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod envelope;
|
||||
pub mod envelope_template;
|
||||
pub mod error;
|
||||
pub mod item_control;
|
||||
pub mod metadata_query;
|
||||
pub mod observed_frame;
|
||||
pub mod observed_write_template;
|
||||
pub mod operation_status;
|
||||
pub mod reference_handle;
|
||||
pub mod reference_registration;
|
||||
pub mod secured_write;
|
||||
pub mod status;
|
||||
pub mod subscription_message;
|
||||
pub mod value;
|
||||
pub mod write_message;
|
||||
|
||||
pub use envelope::{ENVELOPE_HEADER_LEN, NmxTransferEnvelope, NmxTransferMessageKind};
|
||||
pub use envelope_template::NmxTransferEnvelopeTemplate;
|
||||
pub use error::CodecError;
|
||||
pub use item_control::{NmxItemControlCommand, NmxItemControlMessage};
|
||||
pub use metadata_query::NmxMetadataQueryMessage;
|
||||
pub use observed_frame::{NmxObservedEnvelope, NmxObservedMessage, NmxObservedString};
|
||||
pub use observed_write_template::ObservedWriteBodyTemplate;
|
||||
pub use operation_status::{NmxOperationStatusFormat, NmxOperationStatusMessage};
|
||||
pub use reference_handle::{MxReferenceHandle, compute_name_signature, update_crc16_ibm};
|
||||
pub use reference_registration::{
|
||||
NmxReferenceRegistrationMessage, NmxReferenceRegistrationResultMessage,
|
||||
};
|
||||
pub use secured_write::DecodedSecuredWrite;
|
||||
pub use status::{MxStatus, MxStatusCategory, MxStatusSource, detail_text};
|
||||
pub use subscription_message::{NmxSubscriptionMessage, NmxSubscriptionRecord};
|
||||
pub use value::{MxDataType, MxValue, MxValueKind};
|
||||
|
||||
// `NmxWriteMessage` and `NmxSecuredWrite2Message` are not single struct types
|
||||
// in the Rust port — encoding/decoding live as functions in the
|
||||
// `write_message` and `secured_write` modules. Keep stubs as short type
|
||||
// aliases so existing references compile; consumers should call the module
|
||||
// functions directly.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NmxWriteMessage;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NmxSecuredWrite2Message;
|
||||
|
||||
// ---- ASB types (M5 follow-up) --------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AsbVariant;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AsbStatus;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeValue;
|
||||
|
||||
// ---- Convenience prelude -------------------------------------------------
|
||||
|
||||
pub mod prelude {
|
||||
pub use super::{
|
||||
CodecError, MxDataType, MxReferenceHandle, MxStatus, MxStatusCategory, MxStatusSource,
|
||||
MxValue, MxValueKind, NmxItemControlCommand, NmxItemControlMessage,
|
||||
NmxOperationStatusMessage, NmxReferenceRegistrationMessage,
|
||||
NmxReferenceRegistrationResultMessage, NmxSubscriptionMessage, NmxTransferEnvelope,
|
||||
NmxTransferEnvelopeTemplate, NmxTransferMessageKind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `NmxMetadataQueryMessage` — observed pre-advise metadata-query body.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxMetadataQueryMessage.cs`. The .NET
|
||||
//! reference exposes a single static helper, [`encode_observed_pre_advise`],
|
||||
//! which returns a fixed observed body with a 16-byte item-correlation GUID
|
||||
//! patched in at offset `0x8a`.
|
||||
//!
|
||||
//! The body is a captured constant — both segments of the hex literal in
|
||||
//! `NmxMetadataQueryMessage.cs:10-11` are reproduced byte-for-byte below.
|
||||
//! It encodes two metadata queries against `$DevPlatform.GR.TimeOfLastDeploy`
|
||||
//! and `$DevPlatform.GR.TimeOfLastConfigChange`. The Rust port preserves
|
||||
//! every byte; the only mutation is the GUID at offset `0x8a`.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
/// Offset of the 16-byte item-correlation GUID inside the observed body
|
||||
/// (`NmxMetadataQueryMessage.cs:5`).
|
||||
pub const PRE_ADVISE_CORRELATION_OFFSET: usize = 0x8a;
|
||||
|
||||
/// Length of the first hex segment in bytes — `NmxMetadataQueryMessage.cs:10`.
|
||||
const SEGMENT_1_LEN: usize = 160;
|
||||
|
||||
/// Length of the second hex segment in bytes — `NmxMetadataQueryMessage.cs:11`.
|
||||
const SEGMENT_2_LEN: usize = 154;
|
||||
|
||||
/// Length of the observed body in bytes (160 + 154 = 314).
|
||||
pub const PRE_ADVISE_BODY_LEN: usize = SEGMENT_1_LEN + SEGMENT_2_LEN;
|
||||
|
||||
/// First hex segment from `NmxMetadataQueryMessage.cs:10`. Decoded byte-for-byte
|
||||
/// from `Convert.FromHexString(...)` of the literal in the .NET source.
|
||||
const SEGMENT_1: [u8; SEGMENT_1_LEN] = [
|
||||
0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x65, 0x00, 0x71, 0x00, 0x0a, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x08, 0x6a, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x81, 0x44, 0x00, 0x65,
|
||||
0x00, 0x76, 0x00, 0x50, 0x00, 0x6c, 0x00, 0x61, 0x00, 0x74, 0x00, 0x66, 0x00, 0x6f, 0x00, 0x72,
|
||||
0x00, 0x6d, 0x00, 0x2e, 0x00, 0x47, 0x00, 0x52, 0x00, 0x2e, 0x00, 0x54, 0x00, 0x69, 0x00, 0x6d,
|
||||
0x00, 0x65, 0x00, 0x4f, 0x00, 0x66, 0x00, 0x4c, 0x00, 0x61, 0x00, 0x73, 0x00, 0x74, 0x00, 0x44,
|
||||
0x00, 0x65, 0x00, 0x70, 0x00, 0x6c, 0x00, 0x6f, 0x00, 0x79, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x01, 0xd0, 0xfc, 0x40, 0x09, 0x1f, 0x01, 0x00, 0xc0, 0xca, 0x9c, 0xcd, 0x32, 0x65,
|
||||
0xb0, 0x46, 0xa5, 0x85, 0xa5, 0x83, 0xb2, 0xe7, 0x7a, 0x5d, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// Second hex segment from `NmxMetadataQueryMessage.cs:11`. Decoded
|
||||
/// byte-for-byte from `Convert.FromHexString(...)` of the literal in the
|
||||
/// .NET source.
|
||||
const SEGMENT_2: [u8; SEGMENT_2_LEN] = [
|
||||
0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x65, 0x00, 0x71, 0x00, 0x0a, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x08, 0x76, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x00, 0x81, 0x44, 0x00, 0x65,
|
||||
0x00, 0x76, 0x00, 0x50, 0x00, 0x6c, 0x00, 0x61, 0x00, 0x74, 0x00, 0x66, 0x00, 0x6f, 0x00, 0x72,
|
||||
0x00, 0x6d, 0x00, 0x2e, 0x00, 0x47, 0x00, 0x52, 0x00, 0x2e, 0x00, 0x54, 0x00, 0x69, 0x00, 0x6d,
|
||||
0x00, 0x65, 0x00, 0x4f, 0x00, 0x66, 0x00, 0x4c, 0x00, 0x61, 0x00, 0x73, 0x00, 0x74, 0x00, 0x43,
|
||||
0x00, 0x6f, 0x00, 0x6e, 0x00, 0x66, 0x00, 0x69, 0x00, 0x67, 0x00, 0x43, 0x00, 0x68, 0x00, 0x61,
|
||||
0x00, 0x6e, 0x00, 0x67, 0x00, 0x65, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x50,
|
||||
0x03, 0x41, 0x09, 0x20, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// Concatenation of `SEGMENT_1 || SEGMENT_2`. Equivalent to the result of
|
||||
/// `Convert.FromHexString` on the joined hex literal at
|
||||
/// `NmxMetadataQueryMessage.cs:10-11`.
|
||||
const OBSERVED_PRE_ADVISE_BODY: [u8; PRE_ADVISE_BODY_LEN] = {
|
||||
let mut out = [0u8; PRE_ADVISE_BODY_LEN];
|
||||
let mut i = 0;
|
||||
while i < SEGMENT_1_LEN {
|
||||
out[i] = SEGMENT_1[i];
|
||||
i += 1;
|
||||
}
|
||||
let mut j = 0;
|
||||
while j < SEGMENT_2_LEN {
|
||||
out[SEGMENT_1_LEN + j] = SEGMENT_2[j];
|
||||
j += 1;
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
/// Stateless helpers around the observed metadata-query body.
|
||||
///
|
||||
/// Mirrors the static class `NmxMetadataQueryMessage`
|
||||
/// (`NmxMetadataQueryMessage.cs:3-15`).
|
||||
pub struct NmxMetadataQueryMessage;
|
||||
|
||||
impl NmxMetadataQueryMessage {
|
||||
/// Encode the observed pre-advise body, patching the supplied 16-byte
|
||||
/// GUID into offset `0x8a` (`NmxMetadataQueryMessage.cs:7-14`).
|
||||
///
|
||||
/// `item_correlation_id` is the raw 16-byte little-endian Guid layout —
|
||||
/// the same byte order .NET's `Guid.TryWriteBytes` emits. Callers
|
||||
/// constructing a Guid from Rust types are responsible for using the
|
||||
/// same wire layout (e.g. `windows::core::GUID::to_u128_le().to_le_bytes()`
|
||||
/// or equivalent).
|
||||
pub fn encode_observed_pre_advise(item_correlation_id: [u8; 16]) -> Vec<u8> {
|
||||
let mut body = OBSERVED_PRE_ADVISE_BODY.to_vec();
|
||||
body[PRE_ADVISE_CORRELATION_OFFSET..PRE_ADVISE_CORRELATION_OFFSET + 16]
|
||||
.copy_from_slice(&item_correlation_id);
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_length_is_314() {
|
||||
// 160 + 154 = 314 bytes — derived from the two hex segments at
|
||||
// `NmxMetadataQueryMessage.cs:10-11`.
|
||||
assert_eq!(SEGMENT_1_LEN, 160);
|
||||
assert_eq!(SEGMENT_2_LEN, 154);
|
||||
assert_eq!(PRE_ADVISE_BODY_LEN, 314);
|
||||
assert_eq!(OBSERVED_PRE_ADVISE_BODY.len(), 314);
|
||||
}
|
||||
|
||||
// Compile-time bounds checks: clippy denies `assert!(<const expr>)` at
|
||||
// runtime, so anchor these as `const _: () = assert!(...)` instead. They
|
||||
// still fail the build if the constants drift — at compile time, before
|
||||
// the test runner even spins up.
|
||||
const _: () = assert!(PRE_ADVISE_CORRELATION_OFFSET + 16 <= PRE_ADVISE_BODY_LEN);
|
||||
const _: () = assert!(PRE_ADVISE_CORRELATION_OFFSET + 16 <= SEGMENT_1_LEN);
|
||||
|
||||
#[test]
|
||||
fn correlation_offset_is_0x8a() {
|
||||
assert_eq!(PRE_ADVISE_CORRELATION_OFFSET, 0x8a);
|
||||
// 0x8a (138) + 16 = 154, which is inside the first 160-byte segment.
|
||||
// Anchor checks are above as `const _: () = assert!(...)`.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_guid_in_template_matches_dotnet_capture() {
|
||||
// The captured GUID at offset 0x8a in the literal body
|
||||
// (`NmxMetadataQueryMessage.cs:10` — after the `0xc0` byte at offset 138).
|
||||
let expected = [
|
||||
0xc0, 0xca, 0x9c, 0xcd, 0x32, 0x65, 0xb0, 0x46, 0xa5, 0x85, 0xa5, 0x83, 0xb2, 0xe7,
|
||||
0x7a, 0x5d,
|
||||
];
|
||||
assert_eq!(
|
||||
&OBSERVED_PRE_ADVISE_BODY[0x8a..0x8a + 16],
|
||||
&expected,
|
||||
"the embedded GUID must match the .NET literal byte-for-byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_is_patched_at_0x8a() {
|
||||
let guid = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
|
||||
0xff, 0x00,
|
||||
];
|
||||
let body = NmxMetadataQueryMessage::encode_observed_pre_advise(guid);
|
||||
assert_eq!(body.len(), PRE_ADVISE_BODY_LEN);
|
||||
assert_eq!(&body[0x8a..0x8a + 16], &guid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_outside_correlation_window_are_unchanged() {
|
||||
// Encode with an all-zero GUID and an all-0xff GUID, compare every
|
||||
// byte outside the patch window — they must be identical.
|
||||
let body_a = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
|
||||
let body_b = NmxMetadataQueryMessage::encode_observed_pre_advise([0xffu8; 16]);
|
||||
for i in 0..PRE_ADVISE_BODY_LEN {
|
||||
if (PRE_ADVISE_CORRELATION_OFFSET..PRE_ADVISE_CORRELATION_OFFSET + 16).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(body_a[i], body_b[i], "byte {i} should be unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_body_matches_observed_template_at_known_offsets() {
|
||||
// Spot-check anchor bytes from the .NET hex string. Offsets 0..10
|
||||
// are the `17 01 00 01 01 00 01 00 00 00` header
|
||||
// (`NmxMetadataQueryMessage.cs:10`); offset 160 starts the second
|
||||
// segment with the same 10-byte preamble (`NmxMetadataQueryMessage.cs:11`).
|
||||
let body = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
|
||||
let preamble = [0x17, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
|
||||
assert_eq!(&body[0..10], &preamble);
|
||||
assert_eq!(&body[SEGMENT_1_LEN..SEGMENT_1_LEN + 10], &preamble);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_call_does_not_mutate_template() {
|
||||
// Each call must return an independent buffer — patching the result
|
||||
// of one call must not affect a subsequent call.
|
||||
let mut a = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
|
||||
a[0] = 0x99;
|
||||
let b = NmxMetadataQueryMessage::encode_observed_pre_advise([0u8; 16]);
|
||||
assert_eq!(b[0], 0x17, "second call must not see mutation of first");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! `NmxObservedFrame` — tolerant transfer-envelope + inner-message parser.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxObservedFrame.cs`.
|
||||
//!
|
||||
//! Where [`crate::NmxTransferEnvelope`] strictly validates the typed fields
|
||||
//! of the 46-byte transfer header, the *observed* envelope path is a
|
||||
//! permissive analyser used by probes and replay:
|
||||
//!
|
||||
//! - Splits a `TransferData`-shaped or `ProcessDataReceived`-shaped buffer
|
||||
//! into a 46-byte header plus an inner body.
|
||||
//! - Surfaces the optional 4-byte length prefix that wraps
|
||||
//! `ProcessDataReceived` bodies on the wire.
|
||||
//! - Parses the inner body's leading `cmd + version` bytes plus, for the
|
||||
//! recognised opcodes `0x1f` and `0x21`, a 16-byte item-correlation GUID.
|
||||
//! - Walks the body looking for runs of printable UTF-16LE strings and
|
||||
//! surfaces them with their offsets. Unknown opcodes round-trip cleanly
|
||||
//! — the parser never rejects them, it just gives them a synthetic
|
||||
//! `Unknown0xNN` name (`NmxObservedFrame.cs:148`).
|
||||
//!
|
||||
//! ## hasDetailStatus audit (Q7 follow-up)
|
||||
//!
|
||||
//! `NmxObservedFrame.cs:122-126` reads `itemCorrelationId` **conditionally**:
|
||||
//!
|
||||
//! ```csharp
|
||||
//! if (command is 0x1f or 0x21 && body.Length >= 19)
|
||||
//! {
|
||||
//! itemCorrelationId = new Guid(body.Slice(3, 16));
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! That is a `has_*`-style conditional read in the .NET source — it depends
|
||||
//! on both the opcode and the buffer length. **Audit: the Rust port mirrors
|
||||
//! the same conditional exactly** (it MUST stay conditional — making it
|
||||
//! unconditional would either crash on shorter unknown-opcode bodies or
|
||||
//! attach a meaningless GUID to bodies that have no correlation slot). No
|
||||
//! other field in this file is read conditionally.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
|
||||
/// Header length in bytes (`NmxObservedFrame.cs:14`).
|
||||
pub const HEADER_LENGTH: usize = 46;
|
||||
|
||||
/// Inner-length field offset in the transfer header
|
||||
/// (`NmxObservedFrame.cs:15`).
|
||||
pub const INNER_LENGTH_OFFSET: usize = 2;
|
||||
|
||||
/// Tolerant parse of a `TransferData`-style envelope body. Mirrors
|
||||
/// [`NmxObservedEnvelope`] returned by `ParseTransferDataBody`
|
||||
/// (`NmxObservedFrame.cs:17-38`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NmxObservedEnvelope {
|
||||
/// Whether the body began with a 4-byte total-length prefix
|
||||
/// (set on `ProcessDataReceived` payloads).
|
||||
pub has_length_prefix: bool,
|
||||
/// The captured 4-byte total-length prefix, or `None` if absent.
|
||||
pub total_length_prefix: Option<i32>,
|
||||
/// `inner_length` field at offset 2 of the 46-byte header.
|
||||
pub declared_inner_length: i32,
|
||||
/// Actual inner-body length in bytes (`body.len() - 46` after stripping
|
||||
/// any optional length prefix).
|
||||
pub actual_inner_length: usize,
|
||||
/// The captured 46-byte header.
|
||||
pub header: Vec<u8>,
|
||||
/// The inner body that follows the header.
|
||||
pub inner_body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl NmxObservedEnvelope {
|
||||
/// Parse a `TransferData` body (no leading 4-byte length prefix).
|
||||
/// Mirrors `ParseTransferDataBody` (`NmxObservedFrame.cs:17-38`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `body.len() < 46`.
|
||||
/// - [`CodecError::InnerLengthMismatch`] if the declared inner length
|
||||
/// doesn't match the actual inner body length.
|
||||
pub fn parse_transfer_data_body(body: &[u8]) -> Result<Self, CodecError> {
|
||||
if body.len() < HEADER_LENGTH {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: HEADER_LENGTH,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
|
||||
let actual_inner_length = body.len() - HEADER_LENGTH;
|
||||
if declared_inner_length != actual_inner_length as i32 {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: declared_inner_length,
|
||||
actual: actual_inner_length,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
has_length_prefix: false,
|
||||
total_length_prefix: None,
|
||||
declared_inner_length,
|
||||
actual_inner_length,
|
||||
header: body[..HEADER_LENGTH].to_vec(),
|
||||
inner_body: body[HEADER_LENGTH..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `ProcessDataReceived` body — strict form with leading
|
||||
/// 4-byte total-length prefix. Mirrors `ParseProcessDataReceivedBody`
|
||||
/// (`NmxObservedFrame.cs:40-69`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `body.len() < 50`.
|
||||
/// - [`CodecError::InnerLengthMismatch`] if either the total-length
|
||||
/// prefix or the declared inner length doesn't reconcile with the
|
||||
/// buffer size.
|
||||
pub fn parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
|
||||
if body.len() < 4 + HEADER_LENGTH {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 4 + HEADER_LENGTH,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
// `.cs:47` — total length prefix at offset 0.
|
||||
let total_length_prefix = read_i32_le(body, 0);
|
||||
if total_length_prefix as usize != body.len() {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: total_length_prefix,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let header_offset = 4;
|
||||
// `.cs:54-55` — inner length sits at headerOffset + InnerLengthOffset.
|
||||
let declared_inner_length = read_i32_le(body, header_offset + INNER_LENGTH_OFFSET);
|
||||
// `.cs:56` — actualInnerLength = declared - sizeof(int).
|
||||
let actual_inner_length = declared_inner_length - 4;
|
||||
if actual_inner_length < 0
|
||||
|| header_offset + HEADER_LENGTH + actual_inner_length as usize != body.len()
|
||||
{
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: declared_inner_length,
|
||||
actual: body.len() - header_offset - HEADER_LENGTH,
|
||||
});
|
||||
}
|
||||
let actual_inner_length = actual_inner_length as usize;
|
||||
Ok(Self {
|
||||
has_length_prefix: true,
|
||||
total_length_prefix: Some(total_length_prefix),
|
||||
declared_inner_length,
|
||||
actual_inner_length,
|
||||
header: body[header_offset..header_offset + HEADER_LENGTH].to_vec(),
|
||||
inner_body: body[header_offset + HEADER_LENGTH
|
||||
..header_offset + HEADER_LENGTH + actual_inner_length]
|
||||
.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Flexible `ProcessDataReceived` parse — tries the strict
|
||||
/// length-prefixed form first; falls back to the `TransferData`-style
|
||||
/// header-only form. Mirrors `ParseProcessDataReceivedBodyFlexible`
|
||||
/// (`NmxObservedFrame.cs:71-101`).
|
||||
pub fn parse_process_data_received_body_flexible(body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:73-80` — try the strict path if and only if the leading
|
||||
// i32 == body length.
|
||||
if body.len() >= 4 + HEADER_LENGTH {
|
||||
let total_length_prefix = read_i32_le(body, 0);
|
||||
if total_length_prefix as usize == body.len() {
|
||||
return Self::parse_process_data_received_body(body);
|
||||
}
|
||||
}
|
||||
if body.len() < HEADER_LENGTH {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: HEADER_LENGTH,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
// `.cs:87-92` — fall back to header-only inner-length validation.
|
||||
let declared_inner_length = read_i32_le(body, INNER_LENGTH_OFFSET);
|
||||
let actual_inner_length = body.len() - HEADER_LENGTH;
|
||||
if declared_inner_length != actual_inner_length as i32 {
|
||||
return Err(CodecError::InnerLengthMismatch {
|
||||
declared: declared_inner_length,
|
||||
actual: actual_inner_length,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
has_length_prefix: false,
|
||||
total_length_prefix: None,
|
||||
declared_inner_length,
|
||||
actual_inner_length,
|
||||
header: body[..HEADER_LENGTH].to_vec(),
|
||||
inner_body: body[HEADER_LENGTH..].to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A printable UTF-16LE string discovered at a specific offset inside the
|
||||
/// observed body. Mirrors the .NET `NmxObservedString` record
|
||||
/// (`NmxObservedFrame.cs:104`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NmxObservedString {
|
||||
pub offset: usize,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Tolerant parse of an inner NMX message body. Mirrors
|
||||
/// `NmxObservedMessage` (`NmxObservedFrame.cs:106-192`).
|
||||
///
|
||||
/// "Tolerant" means: the parser does NOT validate the body shape against
|
||||
/// any specific opcode — it simply records the leading `cmd`, `version` u16
|
||||
/// (split into major/minor bytes), and (for `0x1f` / `0x21`) a 16-byte item
|
||||
/// correlation GUID. Unknown opcodes get a synthetic name (`Unknown0xNN`)
|
||||
/// per `.cs:148`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NmxObservedMessage {
|
||||
pub command: u8,
|
||||
pub command_name: &'static str,
|
||||
/// Synthetic name for unknown commands (`Unknown0xNN`). When the command
|
||||
/// is recognised, this is empty and [`Self::command_name`] is used.
|
||||
pub synthetic_name: Option<String>,
|
||||
pub version_major: u8,
|
||||
pub version_minor: u8,
|
||||
/// Item-correlation GUID for `AdviseSupervisory` (`0x1f`) and
|
||||
/// `UnAdvise` (`0x21`) bodies. **Read conditionally** — mirroring
|
||||
/// `NmxObservedFrame.cs:122-126`. See module-level Q7 audit.
|
||||
///
|
||||
/// The GUID is 16 raw bytes from `body[3..19]`. The .NET source uses
|
||||
/// `new Guid(byte[])` which interprets the first three groups as
|
||||
/// little-endian (mixed-endian on the wire). The Rust port keeps the
|
||||
/// raw 16-byte form to avoid pulling in a `Guid`/`uuid` dependency at
|
||||
/// the codec level — consumers can re-interpret if needed.
|
||||
pub item_correlation_id: Option<[u8; 16]>,
|
||||
/// Printable UTF-16LE strings discovered in the body, with their
|
||||
/// starting byte offsets.
|
||||
pub strings: Vec<NmxObservedString>,
|
||||
}
|
||||
|
||||
impl NmxObservedMessage {
|
||||
/// Parse the body. Mirrors `NmxObservedMessage.Parse`
|
||||
/// (`NmxObservedFrame.cs:114-135`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if the body has fewer than 3 bytes (the
|
||||
/// minimum needed to read `cmd + version`).
|
||||
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:116-119` — minimum length 3.
|
||||
if body.len() < 3 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 3,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let command = body[0];
|
||||
|
||||
// `.cs:122-126` — CONDITIONAL read of itemCorrelationId.
|
||||
// Audit Q7: this stays conditional in the Rust port.
|
||||
let item_correlation_id = if (command == 0x1f || command == 0x21) && body.len() >= 19 {
|
||||
let mut guid = [0u8; 16];
|
||||
guid.copy_from_slice(&body[3..19]);
|
||||
Some(guid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (command_name, synthetic_name) = command_name(command);
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
command_name,
|
||||
synthetic_name,
|
||||
// `.cs:131` — body[1] is the major byte of the u16 version.
|
||||
version_major: body[1],
|
||||
// `.cs:132` — body[2] is the minor byte.
|
||||
version_minor: body[2],
|
||||
item_correlation_id,
|
||||
strings: extract_utf16_strings(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a command byte to its declared name. Mirrors `GetCommandName`
|
||||
/// (`NmxObservedFrame.cs:137-150`).
|
||||
///
|
||||
/// Returns `(known_name, synthetic_name_for_unknown)`. For known commands,
|
||||
/// the synthetic-name slot is `None`; for unknown commands, the known-name
|
||||
/// slot is `"Unknown"` and the synthetic slot carries the formatted name.
|
||||
fn command_name(command: u8) -> (&'static str, Option<String>) {
|
||||
match command {
|
||||
0x17 => ("MetadataQuery", None),
|
||||
0x1f => ("AdviseSupervisory", None),
|
||||
0x21 => ("UnAdvise", None),
|
||||
0x32 => ("SubscriptionStatus", None),
|
||||
0x33 => ("DataUpdate", None),
|
||||
0x37 => ("Write", None),
|
||||
0x40 => ("MetadataResponse", None),
|
||||
// `.cs:148` — synthesised name for everything else.
|
||||
other => ("Unknown", Some(format!("Unknown0x{other:02X}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the body looking for runs of printable UTF-16LE characters
|
||||
/// terminated by a 2-byte NUL. Mirrors `ExtractUtf16Strings`
|
||||
/// (`NmxObservedFrame.cs:152-191`).
|
||||
///
|
||||
/// A "string" is at least 3 printable ASCII characters (low byte in
|
||||
/// `0x20..=0x7e`, high byte zero) followed by a `00 00` terminator. The
|
||||
/// scanner's appetite is intentionally narrow: arbitrary binary that
|
||||
/// happens to look like UTF-16 won't trip it.
|
||||
fn extract_utf16_strings(body: &[u8]) -> Vec<NmxObservedString> {
|
||||
let mut strings = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
// `.cs:156` — outer guard `offset + 8 <= body.length`.
|
||||
while offset + 8 <= body.len() {
|
||||
let start = offset;
|
||||
let mut chars: usize = 0;
|
||||
// `.cs:160-177` — inner scan loop.
|
||||
while offset + 1 < body.len() {
|
||||
let lo = body[offset];
|
||||
let hi = body[offset + 1];
|
||||
// `.cs:162-167` — null terminator ends the run.
|
||||
if lo == 0 && hi == 0 {
|
||||
break;
|
||||
}
|
||||
// `.cs:169-173` — non-printable / non-ASCII byte invalidates
|
||||
// the candidate run.
|
||||
if hi != 0 || !(0x20..=0x7e).contains(&lo) {
|
||||
chars = 0;
|
||||
break;
|
||||
}
|
||||
chars += 1;
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
// `.cs:179-186` — accept the run if it had at least 3 chars and
|
||||
// is followed by the 00 00 terminator.
|
||||
if chars >= 3 && offset + 1 < body.len() && body[offset] == 0 && body[offset + 1] == 0 {
|
||||
let raw = &body[start..start + chars * 2];
|
||||
let utf16: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
// The scan accepted only printable ASCII, so the conversion
|
||||
// can't fail in practice. If it does, we silently drop the run.
|
||||
if let Ok(value) = String::from_utf16(&utf16) {
|
||||
strings.push(NmxObservedString {
|
||||
offset: start,
|
||||
value,
|
||||
});
|
||||
}
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// `.cs:187` — failed match: advance by 1 byte and retry.
|
||||
offset = start + 1;
|
||||
}
|
||||
strings
|
||||
}
|
||||
|
||||
// ---- LE primitive helpers -------------------------------------------------
|
||||
|
||||
#[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::*;
|
||||
|
||||
fn synthesise_envelope(inner: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; HEADER_LENGTH + inner.len()];
|
||||
// Pack the header with a recognisable pattern so we can verify
|
||||
// round-trip preservation.
|
||||
for (i, b) in out[..HEADER_LENGTH].iter_mut().enumerate() {
|
||||
*b = 0xA0u8.wrapping_add(i as u8);
|
||||
}
|
||||
// Patch the inner-length field at offset 2.
|
||||
out[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4]
|
||||
.copy_from_slice(&(inner.len() as i32).to_le_bytes());
|
||||
out[HEADER_LENGTH..].copy_from_slice(inner);
|
||||
out
|
||||
}
|
||||
|
||||
fn synthesise_pdr_body(inner: &[u8]) -> Vec<u8> {
|
||||
// ProcessDataReceived strict layout: 4 (total) + 46 (header) + inner.
|
||||
// Total-length prefix == body.len(), inner-length field == inner.len() + 4.
|
||||
let total_len = 4 + HEADER_LENGTH + inner.len();
|
||||
let mut out = vec![0u8; total_len];
|
||||
out[..4].copy_from_slice(&(total_len as i32).to_le_bytes());
|
||||
for (i, b) in out[4..4 + HEADER_LENGTH].iter_mut().enumerate() {
|
||||
*b = 0xC0u8.wrapping_add(i as u8);
|
||||
}
|
||||
// inner length field at offset 4 + 2 = 6, value = inner.len() + 4.
|
||||
out[6..10].copy_from_slice(&((inner.len() + 4) as i32).to_le_bytes());
|
||||
out[4 + HEADER_LENGTH..].copy_from_slice(inner);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_constants_match_dotnet() {
|
||||
// `NmxObservedFrame.cs:14-15`.
|
||||
assert_eq!(HEADER_LENGTH, 46);
|
||||
assert_eq!(INNER_LENGTH_OFFSET, 2);
|
||||
}
|
||||
|
||||
// ---- Envelope parsing -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_transfer_data_body_round_trip() {
|
||||
let inner = [0x37u8, 0x01, 0x00, 0xAB, 0xCD];
|
||||
let body = synthesise_envelope(&inner);
|
||||
let env = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap();
|
||||
assert!(!env.has_length_prefix);
|
||||
assert_eq!(env.total_length_prefix, None);
|
||||
assert_eq!(env.declared_inner_length, inner.len() as i32);
|
||||
assert_eq!(env.actual_inner_length, inner.len());
|
||||
assert_eq!(env.inner_body, inner);
|
||||
assert_eq!(env.header.len(), HEADER_LENGTH);
|
||||
// Header preserved verbatim.
|
||||
assert_eq!(&env.header, &body[..HEADER_LENGTH]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_transfer_data_body_rejects_short_buffer() {
|
||||
let err = NmxObservedEnvelope::parse_transfer_data_body(&[0u8; 45]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_transfer_data_body_rejects_inner_length_mismatch() {
|
||||
let mut body = synthesise_envelope(&[0u8; 8]);
|
||||
// Clobber inner-length field to a wrong value.
|
||||
body[INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4].copy_from_slice(&100i32.to_le_bytes());
|
||||
let err = NmxObservedEnvelope::parse_transfer_data_body(&body).unwrap_err();
|
||||
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pdr_body_strict_round_trip() {
|
||||
let inner = [0x33u8, 0x01, 0x00];
|
||||
let body = synthesise_pdr_body(&inner);
|
||||
let env = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap();
|
||||
assert!(env.has_length_prefix);
|
||||
assert_eq!(env.total_length_prefix, Some(body.len() as i32));
|
||||
assert_eq!(env.actual_inner_length, inner.len());
|
||||
assert_eq!(env.inner_body, inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pdr_body_strict_rejects_bad_total_length() {
|
||||
let inner = [0u8; 4];
|
||||
let mut body = synthesise_pdr_body(&inner);
|
||||
// Corrupt the total-length prefix (compute the corrupt value first
|
||||
// to avoid borrowing `body` mutably and immutably in the same expr).
|
||||
let bad_total = body.len() as i32 + 1;
|
||||
body[0..4].copy_from_slice(&bad_total.to_le_bytes());
|
||||
let err = NmxObservedEnvelope::parse_process_data_received_body(&body).unwrap_err();
|
||||
assert!(matches!(err, CodecError::InnerLengthMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pdr_flexible_uses_strict_when_possible() {
|
||||
let inner = [0x32u8, 0x01, 0x00];
|
||||
let body = synthesise_pdr_body(&inner);
|
||||
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
|
||||
assert!(env.has_length_prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pdr_flexible_falls_back_to_header_only() {
|
||||
// No leading 4-byte length prefix — flexible parser falls back.
|
||||
let inner = [0x32u8, 0x01, 0x00];
|
||||
let body = synthesise_envelope(&inner);
|
||||
let env = NmxObservedEnvelope::parse_process_data_received_body_flexible(&body).unwrap();
|
||||
assert!(!env.has_length_prefix);
|
||||
assert_eq!(env.inner_body, inner);
|
||||
}
|
||||
|
||||
// ---- Inner-message parsing ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_message_minimum_length_3() {
|
||||
let err = NmxObservedMessage::parse(&[0x37u8, 0x01]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recognised_command_yields_known_name() {
|
||||
let body = [0x37u8, 0x01, 0x00];
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.command, 0x37);
|
||||
assert_eq!(msg.command_name, "Write");
|
||||
assert_eq!(msg.synthetic_name, None);
|
||||
assert_eq!(msg.version_major, 0x01);
|
||||
assert_eq!(msg.version_minor, 0x00);
|
||||
assert_eq!(msg.item_correlation_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_command_yields_synthetic_name() {
|
||||
let body = [0xAAu8, 0x01, 0x00];
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.command, 0xAA);
|
||||
// Known-name slot is "Unknown" and synthetic_name carries the
|
||||
// formatted string ("Unknown0xAA").
|
||||
assert_eq!(msg.command_name, "Unknown");
|
||||
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advise_supervisory_carries_correlation_id_when_long_enough() {
|
||||
// 0x1f + version 1 + 16-byte GUID + a couple of stuffer bytes.
|
||||
let mut body = vec![0x1fu8, 0x01, 0x00];
|
||||
let guid = [
|
||||
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
body.extend_from_slice(&guid);
|
||||
body.extend_from_slice(&[0xDE, 0xAD]);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.command_name, "AdviseSupervisory");
|
||||
assert_eq!(msg.item_correlation_id, Some(guid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unadvise_carries_correlation_id_when_long_enough() {
|
||||
let mut body = vec![0x21u8, 0x01, 0x00];
|
||||
let guid = [0x42u8; 16];
|
||||
body.extend_from_slice(&guid);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.command_name, "UnAdvise");
|
||||
assert_eq!(msg.item_correlation_id, Some(guid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlation_id_only_for_advise_or_unadvise_opcodes() {
|
||||
// Q7 audit: the conditional read is opcode-gated. Even with 19+
|
||||
// bytes available, opcodes other than 0x1f / 0x21 do NOT extract
|
||||
// the GUID slot.
|
||||
let mut body = vec![0x37u8, 0x01, 0x00];
|
||||
body.extend_from_slice(&[0xFFu8; 16]);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.item_correlation_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlation_id_omitted_when_buffer_too_short() {
|
||||
// Q7 audit: even 0x1f / 0x21 don't get a GUID if the buffer is < 19.
|
||||
let body = [0x1fu8, 0x01, 0x00, 0x42];
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.command_name, "AdviseSupervisory");
|
||||
assert_eq!(msg.item_correlation_id, None);
|
||||
}
|
||||
|
||||
// ---- UTF-16 string scanner ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_strings_finds_simple_run() {
|
||||
// "Hello" UTF-16LE + 00 00 terminator, embedded in a larger body.
|
||||
let mut body = vec![0u8; 8];
|
||||
let utf16 = "Hello".encode_utf16().collect::<Vec<_>>();
|
||||
for u in &utf16 {
|
||||
body.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
body.extend_from_slice(&[0x00, 0x00]);
|
||||
body.extend_from_slice(&[0u8; 4]);
|
||||
// Prefix the body with cmd+version so we can call parse().
|
||||
let mut full = vec![0x17u8, 0x01, 0x00];
|
||||
full.extend_from_slice(&body);
|
||||
let msg = NmxObservedMessage::parse(&full).unwrap();
|
||||
let found: Vec<_> = msg.strings.iter().map(|s| s.value.as_str()).collect();
|
||||
assert!(
|
||||
found.contains(&"Hello"),
|
||||
"did not find 'Hello' in {found:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_strings_skips_short_runs() {
|
||||
// "ab\0\0" — only 2 chars, below the 3-char minimum.
|
||||
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8];
|
||||
let utf16 = "ab".encode_utf16().collect::<Vec<_>>();
|
||||
for u in &utf16 {
|
||||
body.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
body.extend_from_slice(&[0x00, 0x00, 0u8, 0u8]);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert!(msg.strings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_strings_ignores_non_printable() {
|
||||
// A byte sequence that looks UTF-16-ish but contains a control
|
||||
// character (0x07) — must NOT be reported as a string.
|
||||
let mut body = vec![0x17u8, 0x01, 0x00];
|
||||
body.extend_from_slice(&[0x41, 0x00, 0x07, 0x00, 0x42, 0x00, 0x00, 0x00]);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert!(msg.strings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_strings_reports_offset_relative_to_body() {
|
||||
// Two trailing strings; verify the second's offset is correct.
|
||||
let mut body = vec![0x17u8, 0x01, 0x00, 0u8, 0u8, 0u8];
|
||||
let prefix_len = body.len();
|
||||
for u in "abcdef".encode_utf16() {
|
||||
body.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
body.extend_from_slice(&[0x00, 0x00]);
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.strings.len(), 1);
|
||||
assert_eq!(msg.strings[0].value, "abcdef");
|
||||
assert_eq!(msg.strings[0].offset, prefix_len);
|
||||
}
|
||||
|
||||
// ---- Round-trip preservation across malformed bodies ----------------
|
||||
|
||||
#[test]
|
||||
fn malformed_body_does_not_panic() {
|
||||
// A body of all 0xFF bytes is structurally invalid for any opcode
|
||||
// but parse() must not panic.
|
||||
let body = [0xFFu8; 64];
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
// 0xFF is unknown; synthetic name should reflect that.
|
||||
assert_eq!(msg.command, 0xFF);
|
||||
assert_eq!(msg.synthetic_name.as_deref(), Some("Unknown0xFF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_bytes_are_split_major_minor() {
|
||||
// body[1] = major, body[2] = minor, regardless of endianness.
|
||||
let body = [0x37u8, 0xAB, 0xCD];
|
||||
let msg = NmxObservedMessage::parse(&body).unwrap();
|
||||
assert_eq!(msg.version_major, 0xAB);
|
||||
assert_eq!(msg.version_minor, 0xCD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
//! `ObservedWriteBodyTemplate` — observed-Write body round-trip preserver.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/ObservedWriteBodyTemplate.cs`.
|
||||
//!
|
||||
//! ## What this is for
|
||||
//!
|
||||
//! The template path takes a *captured* Write body (real bytes from
|
||||
//! `captures/0NN-frida-write-*`) and replays it with only the value slot
|
||||
//! replaced. Every other byte — the prefix, the `cmd + version + handle
|
||||
//! projection + wire_kind` header, the trailing suffix (clientToken,
|
||||
//! writeIndex, the `-1 i16` discriminator, any padding) — is preserved
|
||||
//! **verbatim**. This is one of the cornerstones of CLAUDE.md's "preserve
|
||||
//! unknown bytes" rule (project root `CLAUDE.md`): some flows depend on
|
||||
//! byte-for-byte parity with native MXAccess, and the captured suffix
|
||||
//! contains bytes whose meaning is unproven.
|
||||
//!
|
||||
//! ## Layout assumptions (from `ObservedWriteBodyTemplate.cs:9-49`)
|
||||
//!
|
||||
//! Three offset constants from the .NET source:
|
||||
//!
|
||||
//! - `FixedValueOffset = 18` (`.cs:9`) — value slot for scalar types
|
||||
//! (Boolean / Int32 / Float32 / Float64).
|
||||
//! - `VariableValueOffset = 26` (`.cs:10`) — value slot for variable types
|
||||
//! (String / DateTime), preceded by 8 bytes of length headers at offsets
|
||||
//! 18..22 (outer_length) and 22..26 (inner_length).
|
||||
//! - `ArrayValueOffset = 28` (`.cs:11`) — value slot for arrays, preceded by
|
||||
//! 10 bytes (zeros at 18..22, count u16 at 22, element_width u16 at 24,
|
||||
//! zeros at 26..28).
|
||||
//!
|
||||
//! The trailing suffix length is then implied by the captured body size:
|
||||
//!
|
||||
//! - **Fixed:** suffix starts at `FixedValueOffset + valueWidth` and runs
|
||||
//! to `body.Length - sizeof(int)`. The trailing 4 bytes are the writeIndex.
|
||||
//! `(.cs:96-109)`.
|
||||
//! - **Variable:** suffix starts at `VariableValueOffset + valueByteLength`
|
||||
//! where `valueByteLength = body.Slice(22, 4)` (read **unconditionally**).
|
||||
//! Trailing writeIndex still 4 bytes. `(.cs:111-130)`.
|
||||
//! - **Array:** suffix is exactly the **last 18 bytes**, of which the first
|
||||
//! 14 are stored in `_suffixBeforeWriteIndex` and the last 4 are the
|
||||
//! writeIndex. `(.cs:132-144)`.
|
||||
//!
|
||||
//! ## Round-trip preservation
|
||||
//!
|
||||
//! `Encode` (`.cs:51-64`) writes:
|
||||
//!
|
||||
//! 1. The captured prefix (`_prefix`, raw bytes) — preserved verbatim.
|
||||
//! 2. The freshly-encoded value bytes from [`encode_value`].
|
||||
//! 3. The captured suffix (`_suffixBeforeWriteIndex`) — preserved verbatim.
|
||||
//! 4. The fresh `writeIndex` as i32 LE in the trailing 4 bytes.
|
||||
//!
|
||||
//! Then it calls `PatchVariableLengths` and `PatchArrayDescriptor` to keep
|
||||
//! the embedded length fields consistent with the new value bytes
|
||||
//! (`.cs:378-411`).
|
||||
//!
|
||||
//! ## hasDetailStatus audit (Q7 follow-up)
|
||||
//!
|
||||
//! `ObservedWriteBodyTemplate.cs` does not take any `has_*` boolean
|
||||
//! parameter. `CreateVariable` reads `body.Slice(22, 4)` unconditionally
|
||||
//! (`.cs:118`); `CreateArray` reads `body.Slice(22, 2)` unconditionally
|
||||
//! (via the count in DecodeBooleanArray etc., `.cs:198, 221, 251, 281, 337`);
|
||||
//! `Decode*` functions read fixed-offset fields unconditionally per kind.
|
||||
//! No conditional read patterns to mirror. **Audit: clean.**
|
||||
//!
|
||||
//! ## Public-API differences from .NET
|
||||
//!
|
||||
//! - The .NET `Decode` returns `object` and accepts the body to decode at
|
||||
//! call time. The Rust port exposes [`ObservedWriteBodyTemplate::with_value`]
|
||||
//! that returns a fresh body with the value replaced (the single most
|
||||
//! common use case in probes / replay), plus `with_int32`, `with_boolean`,
|
||||
//! etc. helpers.
|
||||
//! - The .NET `Encode` takes a boxed `object` value; the Rust port takes a
|
||||
//! typed [`crate::MxValue`] (no runtime conversion).
|
||||
//! - The Rust port requires the captured kind to match the kind of the
|
||||
//! replacement value — preventing accidentally replacing an Int32 with a
|
||||
//! Float32 (which would corrupt the suffix offsets).
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::{MxValue, MxValueKind};
|
||||
|
||||
/// Value-slot offset for fixed-width scalars (`ObservedWriteBodyTemplate.cs:9`).
|
||||
pub const FIXED_VALUE_OFFSET: usize = 18;
|
||||
|
||||
/// Value-slot offset for variable-length scalars
|
||||
/// (`ObservedWriteBodyTemplate.cs:10`).
|
||||
pub const VARIABLE_VALUE_OFFSET: usize = 26;
|
||||
|
||||
/// Value-slot offset for arrays (`ObservedWriteBodyTemplate.cs:11`).
|
||||
pub const ARRAY_VALUE_OFFSET: usize = 28;
|
||||
|
||||
/// Round-trip preserver for a captured Write body (`0x37`) or
|
||||
/// SecuredWrite2 body (`0x38`).
|
||||
///
|
||||
/// Stores three pieces:
|
||||
///
|
||||
/// - The captured *prefix* up to but not including the value slot.
|
||||
/// - The captured *suffix* starting just after the value slot, up to but
|
||||
/// not including the trailing 4-byte writeIndex.
|
||||
/// - The captured kind (so we can later patch length fields correctly).
|
||||
///
|
||||
/// The prefix and suffix carry every byte unchanged; the value slot is
|
||||
/// rewritten on each [`Self::with_value`] call, and the trailing writeIndex
|
||||
/// is rewritten with whatever the caller passes to encode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObservedWriteBodyTemplate {
|
||||
kind: MxValueKind,
|
||||
/// Captured opcode at body[0]. Either `0x37` (Write) or `0x38`
|
||||
/// (SecuredWrite2) — both share the value-slot layout per the .cs
|
||||
/// constants. Stored separately for [`Self::command`] without re-reading
|
||||
/// `prefix`.
|
||||
command: u8,
|
||||
prefix: Vec<u8>,
|
||||
suffix_before_write_index: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ObservedWriteBodyTemplate {
|
||||
/// Capture a Write body and split it into prefix / suffix around the
|
||||
/// value slot.
|
||||
///
|
||||
/// Mirrors `FromObserved` (`ObservedWriteBodyTemplate.cs:26-49`). The
|
||||
/// caller declares the `kind` because the captured body alone does not
|
||||
/// disambiguate `0x05` String vs DateTime or `0x45` StringArray vs
|
||||
/// DateTimeArray (the encoder collapses them — see
|
||||
/// `crate::write_message` module doc).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `observed_body.len() < 24`
|
||||
/// (`.cs:28-31`).
|
||||
/// - [`CodecError::Decode`] if the kind is unsupported, or if the body
|
||||
/// is too short for its declared kind, or if a variable-length body
|
||||
/// has invalid embedded lengths.
|
||||
pub fn from_observed(kind: MxValueKind, observed_body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:28-31` — minimum length 24 bytes regardless of kind.
|
||||
if observed_body.len() < 24 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 24,
|
||||
actual: observed_body.len(),
|
||||
});
|
||||
}
|
||||
|
||||
match kind {
|
||||
// `.cs:35-38` — fixed-width scalars.
|
||||
MxValueKind::Boolean | MxValueKind::Int32 | MxValueKind::Float32 => {
|
||||
Self::create_fixed(kind, observed_body, 4)
|
||||
}
|
||||
MxValueKind::Float64 => Self::create_fixed(kind, observed_body, 8),
|
||||
// `.cs:39-40` — variable-width scalars.
|
||||
MxValueKind::String | MxValueKind::DateTime => {
|
||||
Self::create_variable(kind, observed_body)
|
||||
}
|
||||
// `.cs:41-46` — arrays.
|
||||
MxValueKind::BoolArray
|
||||
| MxValueKind::Int32Array
|
||||
| MxValueKind::Float32Array
|
||||
| MxValueKind::Float64Array
|
||||
| MxValueKind::StringArray
|
||||
| MxValueKind::DateTimeArray => Self::create_array(kind, observed_body),
|
||||
// `.cs:47` — anything else throws.
|
||||
MxValueKind::ElapsedTime | MxValueKind::Unknown => Err(CodecError::Decode {
|
||||
offset: 17,
|
||||
reason: "observed-write template: unsupported value kind",
|
||||
buffer_len: observed_body.len(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Captured opcode at body[0]. Mirrors `_prefix[0]`.
|
||||
pub fn command(&self) -> u8 {
|
||||
self.command
|
||||
}
|
||||
|
||||
/// Captured wire-kind byte at body[17]. Drawn from the captured prefix,
|
||||
/// not from the runtime [`MxValueKind`] (which can disambiguate
|
||||
/// String vs DateTime past the encoder collapse).
|
||||
pub fn wire_kind(&self) -> u8 {
|
||||
// body[17] sits inside the prefix for all three families.
|
||||
self.prefix[17]
|
||||
}
|
||||
|
||||
/// The kind this template was captured against.
|
||||
pub fn kind(&self) -> MxValueKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Borrow the captured prefix bytes (everything before the value slot).
|
||||
pub fn prefix(&self) -> &[u8] {
|
||||
&self.prefix
|
||||
}
|
||||
|
||||
/// Borrow the captured suffix bytes (between the value slot and the
|
||||
/// trailing 4-byte writeIndex).
|
||||
pub fn suffix_before_write_index(&self) -> &[u8] {
|
||||
&self.suffix_before_write_index
|
||||
}
|
||||
|
||||
/// Emit a Write body with the value slot replaced.
|
||||
///
|
||||
/// Mirrors `Encode` (`ObservedWriteBodyTemplate.cs:51-64`):
|
||||
/// 1. Allocate `prefix.len() + value_bytes.len() + suffix.len() + 4`.
|
||||
/// 2. Copy prefix verbatim.
|
||||
/// 3. Copy fresh value bytes.
|
||||
/// 4. Copy suffix verbatim.
|
||||
/// 5. Write i32 LE writeIndex into the trailing 4 bytes.
|
||||
/// 6. Patch embedded length fields (variable / array).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::Decode`] if `value.kind()` doesn't match the
|
||||
/// captured kind. The .NET reference does not check this — it boxes
|
||||
/// any `object` and lets the per-kind encoder throw — but mismatched
|
||||
/// replacement would silently corrupt the suffix offsets, so the Rust
|
||||
/// port enforces it.
|
||||
pub fn with_value(&self, value: &MxValue, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
// Strict kind check (Rust-port tightening; see module doc).
|
||||
// The encoder collapses StringArray and DateTimeArray onto the same
|
||||
// wire kind, so accept that pair as compatible.
|
||||
let val_kind = value.kind();
|
||||
let kinds_match = val_kind == self.kind
|
||||
|| (val_kind == MxValueKind::StringArray && self.kind == MxValueKind::DateTimeArray)
|
||||
|| (val_kind == MxValueKind::DateTimeArray && self.kind == MxValueKind::StringArray)
|
||||
|| (val_kind == MxValueKind::String && self.kind == MxValueKind::DateTime)
|
||||
|| (val_kind == MxValueKind::DateTime && self.kind == MxValueKind::String);
|
||||
if !kinds_match {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 17,
|
||||
reason: "observed-write template: replacement value kind does not match captured kind",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let value_bytes = encode_value_bytes(value, self.kind)?;
|
||||
let body_len =
|
||||
self.prefix.len() + value_bytes.len() + self.suffix_before_write_index.len() + 4;
|
||||
let mut body = vec![0u8; body_len];
|
||||
body[..self.prefix.len()].copy_from_slice(&self.prefix);
|
||||
let value_start = self.prefix.len();
|
||||
body[value_start..value_start + value_bytes.len()].copy_from_slice(&value_bytes);
|
||||
let suffix_start = value_start + value_bytes.len();
|
||||
body[suffix_start..suffix_start + self.suffix_before_write_index.len()]
|
||||
.copy_from_slice(&self.suffix_before_write_index);
|
||||
write_i32_le(&mut body, body_len - 4, write_index);
|
||||
self.patch_variable_lengths(&mut body, value_bytes.len());
|
||||
self.patch_array_descriptor(&mut body, value_bytes.len());
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an i32. Errors if the captured
|
||||
/// kind isn't [`MxValueKind::Int32`].
|
||||
pub fn with_int32(&self, value: i32, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Int32(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with a bool. Errors if the captured
|
||||
/// kind isn't [`MxValueKind::Boolean`].
|
||||
pub fn with_boolean(&self, value: bool, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Boolean(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an f32.
|
||||
pub fn with_float32(&self, value: f32, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Float32(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with an f64.
|
||||
pub fn with_float64(&self, value: f64, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::Float64(value), write_index)
|
||||
}
|
||||
|
||||
/// Convenience: replace the value with a UTF-16LE string.
|
||||
pub fn with_string(&self, value: &str, write_index: i32) -> Result<Vec<u8>, CodecError> {
|
||||
self.with_value(&MxValue::String(value.to_string()), write_index)
|
||||
}
|
||||
|
||||
/// Decode the trailing writeIndex from a body that was emitted by this
|
||||
/// template. Mirrors `DecodeWriteIndex` (`.cs:86-94`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`CodecError::ShortRead`] if `body` has fewer than 4 bytes.
|
||||
pub fn decode_write_index(body: &[u8]) -> Result<i32, CodecError> {
|
||||
if body.len() < 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
Ok(read_i32_le(body, body.len() - 4))
|
||||
}
|
||||
|
||||
// ---- Private constructors --------------------------------------------
|
||||
|
||||
/// `CreateFixed` (`ObservedWriteBodyTemplate.cs:96-109`).
|
||||
fn create_fixed(
|
||||
kind: MxValueKind,
|
||||
body: &[u8],
|
||||
value_width: usize,
|
||||
) -> Result<Self, CodecError> {
|
||||
let suffix_start = FIXED_VALUE_OFFSET + value_width;
|
||||
// `.cs:99-103` — suffix length must be non-negative.
|
||||
if body.len() < suffix_start + 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: suffix_start + 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_length = body.len() - suffix_start - 4;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
prefix: body[..FIXED_VALUE_OFFSET].to_vec(),
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `CreateVariable` (`ObservedWriteBodyTemplate.cs:111-130`). Reads the
|
||||
/// inner length at offset 22 **unconditionally** — there is no
|
||||
/// "has X" boolean here, mirroring the .NET source.
|
||||
fn create_variable(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:113-116` — minimum length check.
|
||||
if body.len() < VARIABLE_VALUE_OFFSET + 4 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: VARIABLE_VALUE_OFFSET + 4,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
// `.cs:118` — value byte length read at offset 22 unconditionally.
|
||||
let value_byte_length = read_i32_le(body, 22);
|
||||
if value_byte_length < 2 {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 22,
|
||||
reason: "observed-write template: variable value_byte_length < 2",
|
||||
buffer_len: body.len(),
|
||||
});
|
||||
}
|
||||
let value_byte_length = value_byte_length as usize;
|
||||
let suffix_start = VARIABLE_VALUE_OFFSET + value_byte_length;
|
||||
if body.len() < suffix_start + 4 {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 22,
|
||||
reason: "observed-write template: variable body too short for declared lengths",
|
||||
buffer_len: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_length = body.len() - suffix_start - 4;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
prefix: body[..VARIABLE_VALUE_OFFSET].to_vec(),
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + suffix_length].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `CreateArray` (`ObservedWriteBodyTemplate.cs:132-144`). The .NET
|
||||
/// reference takes the **last 18 bytes** as the suffix
|
||||
/// (`suffixStart = body.Length - 18`), of which the first 14 are stored
|
||||
/// in `_suffixBeforeWriteIndex` (`.cs:143`). This loses the array
|
||||
/// payload bytes between offset 28 and `body.Length - 18` — they are
|
||||
/// regenerated by [`encode_value_bytes`] from the supplied value.
|
||||
fn create_array(kind: MxValueKind, body: &[u8]) -> Result<Self, CodecError> {
|
||||
// `.cs:134-137` — body must hold at least 18 trailing bytes plus
|
||||
// the 28-byte prefix.
|
||||
if body.len() < ARRAY_VALUE_OFFSET + 18 {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: ARRAY_VALUE_OFFSET + 18,
|
||||
actual: body.len(),
|
||||
});
|
||||
}
|
||||
let suffix_start = body.len() - 18;
|
||||
Ok(Self {
|
||||
kind,
|
||||
command: body[0],
|
||||
// `.cs:142` — prefix is the leading 28 bytes.
|
||||
prefix: body[..ARRAY_VALUE_OFFSET].to_vec(),
|
||||
// `.cs:143` — suffix_before_write_index is exactly 14 bytes
|
||||
// (the trailing 18 minus the 4-byte writeIndex slot).
|
||||
suffix_before_write_index: body[suffix_start..suffix_start + 14].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Length / descriptor patches -------------------------------------
|
||||
|
||||
/// `PatchVariableLengths` (`ObservedWriteBodyTemplate.cs:378-388`).
|
||||
/// Updates the embedded outer/inner length fields at offsets 18 and 22
|
||||
/// for variable-width kinds. No-op for everything else.
|
||||
fn patch_variable_lengths(&self, body: &mut [u8], value_byte_length: usize) {
|
||||
if !matches!(self.kind, MxValueKind::String | MxValueKind::DateTime) {
|
||||
return;
|
||||
}
|
||||
// `.cs:385` — body[18..22] = value_byte_length + 4.
|
||||
write_i32_le(body, 18, value_byte_length as i32 + 4);
|
||||
// `.cs:386` — body[22..26] = value_byte_length.
|
||||
write_i32_le(body, 22, value_byte_length as i32);
|
||||
}
|
||||
|
||||
/// `PatchArrayDescriptor` (`ObservedWriteBodyTemplate.cs:390-411`).
|
||||
/// Updates the array element count u16 at offset 22 for fixed-element
|
||||
/// arrays. No-op for variable-element arrays (StringArray /
|
||||
/// DateTimeArray) and for non-array kinds — matching the early returns
|
||||
/// at `.cs:392-400`.
|
||||
fn patch_array_descriptor(&self, body: &mut [u8], value_byte_length: usize) {
|
||||
let element_size = match self.kind {
|
||||
MxValueKind::BoolArray => 2,
|
||||
MxValueKind::Int32Array | MxValueKind::Float32Array => 4,
|
||||
MxValueKind::Float64Array => 8,
|
||||
// `.cs:397-400` — variable arrays return early; descriptor not patched.
|
||||
_ => return,
|
||||
};
|
||||
let count = value_byte_length / element_size;
|
||||
// `.cs:410` — body[22..24] = checked u16 count.
|
||||
let count_u16: u16 = u16::try_from(count).unwrap_or(u16::MAX);
|
||||
write_u16_le(body, 22, count_u16);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Value-byte encoders --------------------------------------------------
|
||||
|
||||
/// Encode the value bytes that go into the value slot. Mirrors `EncodeValue`
|
||||
/// (`ObservedWriteBodyTemplate.cs:146-164`).
|
||||
///
|
||||
/// `kind` is the captured kind (used to disambiguate the encoder collapse
|
||||
/// for String/DateTime and StringArray/DateTimeArray — though in this
|
||||
/// template path the actual byte layout is identical for both halves of
|
||||
/// each pair, so the distinction is presentational).
|
||||
fn encode_value_bytes(value: &MxValue, _kind: MxValueKind) -> Result<Vec<u8>, CodecError> {
|
||||
Ok(match value {
|
||||
// `.cs:150` / `EncodeBoolean` (`.cs:166-171`). Literal byte
|
||||
// patterns: true -> [0xff,0xff,0xff,0x00], false -> [0x00,0xff,0xff,0x00].
|
||||
MxValue::Boolean(b) => encode_boolean_bytes(*b).to_vec(),
|
||||
// `.cs:151` — i32 LE.
|
||||
MxValue::Int32(v) => v.to_le_bytes().to_vec(),
|
||||
// `.cs:152` — f32 via SingleToInt32Bits + i32 LE
|
||||
// (`.cs:231-236` `EncodeFloat32`).
|
||||
MxValue::Float32(v) => f32::to_bits(*v).to_le_bytes().to_vec(),
|
||||
// `.cs:153` — f64 via DoubleToInt64Bits + i64 LE
|
||||
// (`.cs:261-266` `EncodeFloat64`).
|
||||
MxValue::Float64(v) => f64::to_bits(*v).to_le_bytes().to_vec(),
|
||||
// `.cs:154` — UTF-16LE with 2-byte NUL trailer
|
||||
// (`.cs:291-297` `EncodeUtf16String`).
|
||||
MxValue::String(s) => encode_utf16_with_nul(s),
|
||||
// `.cs:155` — DateTime is formatted with
|
||||
// `"M/d/yyyy h:mm:ss tt"` and encoded as UTF-16LE+NUL. The Rust
|
||||
// port carries the i64 FILETIME ticks; converting that to the
|
||||
// .NET formatted string requires a calendar dependency outside
|
||||
// the codec layer. The carrier expects a pre-formatted string.
|
||||
MxValue::DateTime(_ticks) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: DateTime replacement requires a pre-formatted string; pass MxValue::String instead",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
// `.cs:156` `EncodeBooleanArray` (`.cs:185-194`) — each element
|
||||
// is i16 LE (-1 or 0).
|
||||
MxValue::BoolArray(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 2);
|
||||
for v in arr {
|
||||
let i: i16 = if *v { -1 } else { 0 };
|
||||
bytes.extend_from_slice(&i.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:157` `EncodeInt32Array` (`.cs:208-217`).
|
||||
MxValue::Int32Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 4);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:158` `EncodeFloat32Array` (`.cs:238-247`).
|
||||
MxValue::Float32Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 4);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&f32::to_bits(*v).to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:159` `EncodeFloat64Array` (`.cs:268-277`).
|
||||
MxValue::Float64Array(arr) => {
|
||||
let mut bytes = Vec::with_capacity(arr.len() * 8);
|
||||
for v in arr {
|
||||
bytes.extend_from_slice(&f64::to_bits(*v).to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
// `.cs:160` `EncodeVariableArray` (`.cs:317-333`).
|
||||
MxValue::StringArray(arr) => encode_variable_array(arr.iter().map(String::as_str)),
|
||||
// `.cs:161` — DateTimeArray formats each element. Same
|
||||
// limitation as scalar DateTime; require pre-formatted strings.
|
||||
MxValue::DateTimeArray(_arr) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: DateTimeArray replacement requires pre-formatted strings; pass MxValue::StringArray instead",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
// `.cs:162` — InvalidOperationException for unsupported.
|
||||
MxValue::ElapsedTime(_) => {
|
||||
return Err(CodecError::Decode {
|
||||
offset: 0,
|
||||
reason: "observed-write template: ElapsedTime is not supported on the write side",
|
||||
buffer_len: 0,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Boolean payload bytes — LITERALLY the same 4-byte pattern used by the
|
||||
/// normal-write encoder (`.cs:166-171`,
|
||||
/// matches `crate::write_message` `encode_boolean_value`).
|
||||
const fn encode_boolean_bytes(value: bool) -> [u8; 4] {
|
||||
if value {
|
||||
[0xff, 0xff, 0xff, 0x00]
|
||||
} else {
|
||||
[0x00, 0xff, 0xff, 0x00]
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-16LE encoding with a trailing 2-byte NUL terminator. Mirrors
|
||||
/// `EncodeUtf16String` (`ObservedWriteBodyTemplate.cs:291-297`).
|
||||
fn encode_utf16_with_nul(value: &str) -> Vec<u8> {
|
||||
let utf16: Vec<u16> = value.encode_utf16().collect();
|
||||
let mut bytes = Vec::with_capacity(utf16.len() * 2 + 2);
|
||||
for unit in &utf16 {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
bytes.push(0x00);
|
||||
bytes.push(0x00);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Variable-array payload. Mirrors `EncodeVariableArray`
|
||||
/// (`ObservedWriteBodyTemplate.cs:317-333`).
|
||||
fn encode_variable_array<'a, I>(values: I) -> Vec<u8>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let mut bytes = Vec::new();
|
||||
for value in values {
|
||||
let text_bytes = encode_utf16_with_nul(value);
|
||||
let mut header = [0u8; 13];
|
||||
// header[0..4] = 1 + 4 + 4 + textBytes.Length (`.cs:324`).
|
||||
write_i32_le(&mut header, 0, 1i32 + 4 + 4 + text_bytes.len() as i32);
|
||||
// header[4] = 0x05 (`.cs:325`).
|
||||
header[4] = 0x05;
|
||||
// header[5..9] = textBytes.Length + 4 (`.cs:326`).
|
||||
write_i32_le(&mut header, 5, text_bytes.len() as i32 + 4);
|
||||
// header[9..13] = textBytes.Length (`.cs:327`).
|
||||
write_i32_le(&mut header, 9, text_bytes.len() as i32);
|
||||
bytes.extend_from_slice(&header);
|
||||
bytes.extend_from_slice(&text_bytes);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
// ---- LE primitive helpers -------------------------------------------------
|
||||
|
||||
#[inline]
|
||||
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
||||
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
||||
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
|
||||
i32::from_le_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
])
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::MxReferenceHandle;
|
||||
use crate::write_message::{self, WriteValue};
|
||||
|
||||
fn sample_handle() -> MxReferenceHandle {
|
||||
MxReferenceHandle::from_names(
|
||||
1,
|
||||
42,
|
||||
17,
|
||||
300,
|
||||
"TestChildObject",
|
||||
-1,
|
||||
7,
|
||||
0,
|
||||
"TestInt",
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_int32_body(value: i32, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Int32(value),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_boolean_body(value: bool, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Boolean(value),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_string_body(value: &str, write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::String(value.to_string()),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn observed_int32_array_body(values: &[i32], write_index: i32, client_token: u32) -> Vec<u8> {
|
||||
write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::Int32Array(values.to_vec()),
|
||||
write_index,
|
||||
client_token,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---- Constants -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn offset_constants_match_dotnet() {
|
||||
// `ObservedWriteBodyTemplate.cs:9-11`.
|
||||
assert_eq!(FIXED_VALUE_OFFSET, 18);
|
||||
assert_eq!(VARIABLE_VALUE_OFFSET, 26);
|
||||
assert_eq!(ARRAY_VALUE_OFFSET, 28);
|
||||
}
|
||||
|
||||
// ---- Round-trip identity --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fixed_round_trip_preserves_bytes_when_value_unchanged() {
|
||||
// Capture an Int32(123) body, replay with the same value — every
|
||||
// byte should match.
|
||||
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replayed = template.with_int32(123, 7).unwrap();
|
||||
assert_eq!(replayed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_round_trip_preserves_bytes_when_value_unchanged() {
|
||||
let original = observed_string_body("hello", 3, 0x12345678);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
|
||||
let replayed = template.with_string("hello", 3).unwrap();
|
||||
assert_eq!(replayed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_round_trip_preserves_suffix_when_value_unchanged() {
|
||||
let original = observed_int32_array_body(&[1, 2, 3], 1, 0xABCD);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
|
||||
let replayed = template
|
||||
.with_value(&MxValue::Int32Array(vec![1, 2, 3]), 1)
|
||||
.unwrap();
|
||||
// The 14-byte suffix (last 18 minus writeIndex) must match.
|
||||
let original_suffix_start = original.len() - 18;
|
||||
let replayed_suffix_start = replayed.len() - 18;
|
||||
assert_eq!(
|
||||
&original[original_suffix_start..original_suffix_start + 14],
|
||||
&replayed[replayed_suffix_start..replayed_suffix_start + 14]
|
||||
);
|
||||
// Trailing writeIndex field too.
|
||||
assert_eq!(
|
||||
&original[original.len() - 4..],
|
||||
&replayed[replayed.len() - 4..]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Selective replacement -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn replace_int32_only_changes_value_slot() {
|
||||
let original = observed_int32_body(123, 7, 0xCAFE_BABE);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replaced = template.with_int32(456, 7).unwrap();
|
||||
assert_eq!(replaced.len(), original.len());
|
||||
// Everything except body[18..22] should match.
|
||||
for (i, (&a, &b)) in original.iter().zip(replaced.iter()).enumerate() {
|
||||
if (FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(a, b, "byte at offset {i} should be preserved");
|
||||
}
|
||||
// The value slot reflects 456.
|
||||
assert_eq!(
|
||||
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&456i32.to_le_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_boolean_uses_literal_4byte_pattern() {
|
||||
// Boolean payload uses the literal 4-byte pattern, not a single byte.
|
||||
let original = observed_boolean_body(true, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Boolean, &original).unwrap();
|
||||
let replaced = template.with_boolean(false, 1).unwrap();
|
||||
assert_eq!(
|
||||
&replaced[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&[0x00, 0xff, 0xff, 0x00]
|
||||
);
|
||||
// Re-replacing with true gives the original.
|
||||
let back = template.with_boolean(true, 1).unwrap();
|
||||
assert_eq!(
|
||||
&back[FIXED_VALUE_OFFSET..FIXED_VALUE_OFFSET + 4],
|
||||
&[0xff, 0xff, 0xff, 0x00]
|
||||
);
|
||||
assert_eq!(back, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_string_with_different_length_grows_body_and_patches_lengths() {
|
||||
// The .cs path supports same-length AND different-length string
|
||||
// replacements: PatchVariableLengths rewrites both length fields
|
||||
// (`.cs:378-388`).
|
||||
let original = observed_string_body("hi", 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &original).unwrap();
|
||||
let replaced = template.with_string("hello world", 1).unwrap();
|
||||
// New length must reflect the new payload (UTF-16 + NUL = 24 bytes).
|
||||
let utf16_len = "hello world".encode_utf16().count() * 2 + 2;
|
||||
let outer = read_i32_le(&replaced, 18);
|
||||
let inner = read_i32_le(&replaced, 22);
|
||||
assert_eq!(inner, utf16_len as i32);
|
||||
assert_eq!(outer, utf16_len as i32 + 4);
|
||||
// The captured suffix (everything after the value slot) must
|
||||
// still appear verbatim.
|
||||
let suffix_start = VARIABLE_VALUE_OFFSET + utf16_len;
|
||||
let suffix_len = template.suffix_before_write_index().len();
|
||||
assert_eq!(
|
||||
&replaced[suffix_start..suffix_start + suffix_len],
|
||||
template.suffix_before_write_index()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Suffix preservation ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suffix_clienttoken_and_writeindex_preserved_across_with_value_calls() {
|
||||
let original = observed_int32_body(123, 0xAA, 0xDEADBEEF);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
// Replace value but keep writeIndex; the captured clientToken
|
||||
// (in the suffix) must round-trip.
|
||||
let replaced = template.with_int32(999, 0xAA).unwrap();
|
||||
// For Int32 normal write, the suffix layout is:
|
||||
// body[22..24] = -1 i16
|
||||
// body[24..32] = 8-byte filler
|
||||
// body[32..36] = clientToken u32
|
||||
// body[36..40] = writeIndex i32
|
||||
let client_token =
|
||||
u32::from_le_bytes([replaced[32], replaced[33], replaced[34], replaced[35]]);
|
||||
assert_eq!(client_token, 0xDEADBEEF);
|
||||
let write_index =
|
||||
i32::from_le_bytes([replaced[36], replaced[37], replaced[38], replaced[39]]);
|
||||
assert_eq!(write_index, 0xAA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_minus_one_marker_preserved() {
|
||||
// The leading i16 of the normal suffix is -1 (0xFFFF); it lives in
|
||||
// the captured suffix bytes, so any with_value call should preserve it.
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let replaced = template.with_int32(0xFEED, 99).unwrap();
|
||||
let leading = i16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
assert_eq!(leading, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_index_decoder() {
|
||||
let original = observed_int32_body(0, 0xABCDEF, 0);
|
||||
let decoded = ObservedWriteBodyTemplate::decode_write_index(&original).unwrap();
|
||||
assert_eq!(decoded, 0xABCDEF);
|
||||
}
|
||||
|
||||
// ---- Array kind: count patch and suffix -----------------------------
|
||||
|
||||
#[test]
|
||||
fn array_descriptor_count_patched_on_replacement() {
|
||||
// Original: 3 Int32 elements -> body[22..24] count = 3.
|
||||
let original = observed_int32_array_body(&[10, 20, 30], 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &original).unwrap();
|
||||
// Replace with a 5-element array; descriptor count u16 must be 5.
|
||||
let replaced = template
|
||||
.with_value(&MxValue::Int32Array(vec![1, 2, 3, 4, 5]), 1)
|
||||
.unwrap();
|
||||
let count = u16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_string_descriptor_not_patched_on_replacement() {
|
||||
// StringArray / DateTimeArray skip PatchArrayDescriptor (.cs:397-400).
|
||||
let original = write_message::encode(
|
||||
&sample_handle(),
|
||||
&WriteValue::StringArray(vec!["a".into(), "b".into()]),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::StringArray, &original).unwrap();
|
||||
let count_before = u16::from_le_bytes([original[22], original[23]]);
|
||||
let replaced = template
|
||||
.with_value(
|
||||
&MxValue::StringArray(vec!["xx".into(), "yy".into(), "zz".into()]),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let count_after = u16::from_le_bytes([replaced[22], replaced[23]]);
|
||||
// Per .NET behaviour, the count u16 is NOT patched for variable arrays.
|
||||
assert_eq!(count_after, count_before);
|
||||
}
|
||||
|
||||
// ---- Error paths -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_short_buffer() {
|
||||
let err =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &[0u8; 23]).unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_unsupported_kind() {
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::ElapsedTime, &original)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_short_array_body() {
|
||||
// 28 + 18 - 1 = 45 bytes (one byte short).
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32Array, &[0u8; 45])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CodecError::ShortRead { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_observed_rejects_invalid_variable_lengths() {
|
||||
// Build a 30-byte body where body[22..26] declares a value length of 0.
|
||||
let mut buf = vec![0u8; 30];
|
||||
write_i32_le(&mut buf, 22, 0);
|
||||
let err = ObservedWriteBodyTemplate::from_observed(MxValueKind::String, &buf).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_value_rejects_kind_mismatch() {
|
||||
let original = observed_int32_body(0, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
let err = template.with_value(&MxValue::Float32(1.0), 1).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Decode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_and_wire_kind_accessors() {
|
||||
let original = observed_int32_body(123, 1, 0);
|
||||
let template =
|
||||
ObservedWriteBodyTemplate::from_observed(MxValueKind::Int32, &original).unwrap();
|
||||
// Normal Write opcode is 0x37; Int32 wire kind is 0x02.
|
||||
assert_eq!(template.command(), 0x37);
|
||||
assert_eq!(template.wire_kind(), 0x02);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! `NmxOperationStatusMessage` — completion / status-word frames.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/NmxOperationStatusMessage.cs`.
|
||||
//!
|
||||
//! Two on-the-wire shapes are recognised by the inner-body parser:
|
||||
//!
|
||||
//! 1. **5-byte status-word frame** — `00 00 SS SS CC` where `SS SS` is a u16
|
||||
//! LE status code and `CC` is a completion code. The single proven mapping
|
||||
//! is `00 00 50 80 00` → [`MxStatus::WRITE_COMPLETE_OK`]
|
||||
//! (`NmxOperationStatusMessage.cs:48-62`,
|
||||
//! `design/40-protocol-invariants.md:346`).
|
||||
//! 2. **1-byte completion-only frame** — a single byte `CC`. Three values are
|
||||
//! observed in the wild (`0x00`, `0x41`, `0xEF`) but the byte→status
|
||||
//! mapping is unproven; they are preserved verbatim per
|
||||
//! `design/70-risks-and-open-questions.md` R3/R4 and
|
||||
//! `NmxOperationStatusMessage.cs:36-46,69-76`.
|
||||
//!
|
||||
//! The .NET reference also exposes `TryParseProcessDataReceivedBody`, which
|
||||
//! peels an outer `NmxObservedEnvelope` before delegating to the inner-body
|
||||
//! parser. The outer envelope codec has not yet been ported to Rust; only
|
||||
//! [`NmxOperationStatusMessage::try_parse_inner`] is provided here. When
|
||||
//! `NmxObservedEnvelope` lands, add `try_parse_process_data_received_body` as
|
||||
//! a thin wrapper.
|
||||
|
||||
// Direct byte indexing — see reference_handle.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::status::{MxStatus, MxStatusCategory, MxStatusSource};
|
||||
|
||||
/// Which of the two recognised inner-frame shapes was decoded
|
||||
/// (`NmxOperationStatusMessage.cs:3-7`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum NmxOperationStatusFormat {
|
||||
/// Single-byte completion frame (`NmxOperationStatusMessage.cs:5,36-46`).
|
||||
CompletionOnly,
|
||||
/// 5-byte `00 00 SS SS CC` status-word frame
|
||||
/// (`NmxOperationStatusMessage.cs:6,48-62`).
|
||||
StatusWord,
|
||||
}
|
||||
|
||||
/// Decoded operation-status frame
|
||||
/// (`NmxOperationStatusMessage.cs:9-15` — record fields).
|
||||
///
|
||||
/// The four payload fields preserve the raw on-wire bytes; [`Self::status`]
|
||||
/// carries the promoted [`MxStatus`] when a known mapping exists, or an
|
||||
/// unpromoted placeholder otherwise (see `CompletionOnly` and the fallback
|
||||
/// branch of `StatusWord`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct NmxOperationStatusMessage {
|
||||
/// Which inner frame shape was observed.
|
||||
pub format: NmxOperationStatusFormat,
|
||||
/// First byte of the 5-byte frame (`NmxOperationStatusMessage.cs:54`).
|
||||
/// `0` for `CompletionOnly` frames.
|
||||
pub command: u8,
|
||||
/// `inner[2..4]` u16 LE for `StatusWord` (`NmxOperationStatusMessage.cs:50`).
|
||||
/// `0` for `CompletionOnly` frames.
|
||||
pub status_code: u16,
|
||||
/// Completion byte. `inner[0]` for `CompletionOnly`
|
||||
/// (`NmxOperationStatusMessage.cs:38`); `inner[4]` for `StatusWord`
|
||||
/// (`NmxOperationStatusMessage.cs:51`).
|
||||
pub completion_code: u8,
|
||||
/// Promoted status. The only proven promotion is
|
||||
/// `status_code == 0x8050 && completion_code == 0x00 → WRITE_COMPLETE_OK`
|
||||
/// (`NmxOperationStatusMessage.cs:57`,
|
||||
/// `design/40-protocol-invariants.md:346`). Every other shape is wrapped
|
||||
/// in an `Unknown`/`Unknown` placeholder with the raw byte preserved in
|
||||
/// `detail`.
|
||||
pub status: MxStatus,
|
||||
}
|
||||
|
||||
impl NmxOperationStatusMessage {
|
||||
/// `true` for the proven `00 00 50 80 00` frame
|
||||
/// (`NmxOperationStatusMessage.cs:16-18`).
|
||||
pub fn is_mx_access_write_complete(&self) -> bool {
|
||||
self.format == NmxOperationStatusFormat::StatusWord
|
||||
&& self.status_code == 0x8050
|
||||
&& self.completion_code == 0x00
|
||||
}
|
||||
|
||||
/// Parse an inner body — either 1 byte (`CompletionOnly`) or 5 bytes
|
||||
/// (`StatusWord` with leading `00 00`).
|
||||
///
|
||||
/// Mirrors `NmxOperationStatusMessage.TryParseInner`
|
||||
/// (`NmxOperationStatusMessage.cs:34-67`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CodecError::ShortRead`] when the buffer length matches no
|
||||
/// recognised shape. The .NET reference returns `false` and a `null!`
|
||||
/// out-param; the Rust port surfaces the failure as a typed error so
|
||||
/// callers can distinguish "not an operation-status frame" from
|
||||
/// "successfully parsed". Match on the error to mirror the bool API.
|
||||
pub fn try_parse_inner(inner: &[u8]) -> Result<Self, CodecError> {
|
||||
if inner.len() == 1 {
|
||||
// CompletionOnly — `NmxOperationStatusMessage.cs:36-46`.
|
||||
let completion_code = inner[0];
|
||||
return Ok(Self {
|
||||
format: NmxOperationStatusFormat::CompletionOnly,
|
||||
command: 0,
|
||||
status_code: 0,
|
||||
completion_code,
|
||||
status: create_unpromoted_completion_status(completion_code),
|
||||
});
|
||||
}
|
||||
|
||||
if inner.len() == 5 && inner[0] == 0x00 && inner[1] == 0x00 {
|
||||
// StatusWord — `NmxOperationStatusMessage.cs:48-62`.
|
||||
let status_code = u16::from(inner[2]) | (u16::from(inner[3]) << 8);
|
||||
let completion_code = inner[4];
|
||||
|
||||
// Only the (0x8050, 0x00) shape is promoted to a typed status.
|
||||
// Every other (status_code, completion_code) pair is preserved as
|
||||
// an Unknown/Unknown placeholder with the raw byte in `detail`,
|
||||
// mirroring `NmxOperationStatusMessage.cs:57-61`.
|
||||
//
|
||||
// The .NET fallback packs `detail` as:
|
||||
// completion_code == 0x00 ? (short)status_code : completion_code
|
||||
// We replicate the same selection here, including the
|
||||
// `unchecked((short)statusCode)` reinterpretation (i.e. the u16's
|
||||
// bit pattern as i16).
|
||||
let status = if status_code == 0x8050 && completion_code == 0x00 {
|
||||
MxStatus::WRITE_COMPLETE_OK
|
||||
} else {
|
||||
let detail = if completion_code == 0x00 {
|
||||
// Reinterpret the u16 status_code as i16 (two's complement).
|
||||
status_code as i16
|
||||
} else {
|
||||
i16::from(completion_code)
|
||||
};
|
||||
MxStatus {
|
||||
success: 0,
|
||||
category: MxStatusCategory::Unknown,
|
||||
detected_by: MxStatusSource::Unknown,
|
||||
detail,
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(Self {
|
||||
format: NmxOperationStatusFormat::StatusWord,
|
||||
command: inner[0],
|
||||
status_code,
|
||||
completion_code,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
Err(CodecError::ShortRead {
|
||||
// 1 or 5 are the two valid lengths; report the smaller for the
|
||||
// diagnostic. Callers that need the strict bool API should pattern
|
||||
// match on `Err(_) => false`.
|
||||
expected: 1,
|
||||
actual: inner.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the unpromoted placeholder status used by `CompletionOnly` frames
|
||||
/// (`NmxOperationStatusMessage.cs:69-76`).
|
||||
fn create_unpromoted_completion_status(completion_code: u8) -> MxStatus {
|
||||
MxStatus {
|
||||
success: 0,
|
||||
category: MxStatusCategory::Unknown,
|
||||
detected_by: MxStatusSource::Unknown,
|
||||
detail: i16::from(completion_code),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn write_complete_ok_frame() {
|
||||
// The proven 5-byte mapping (`design/40-protocol-invariants.md:346`).
|
||||
let frame = [0x00, 0x00, 0x50, 0x80, 0x00];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
|
||||
assert_eq!(msg.command, 0x00);
|
||||
assert_eq!(msg.status_code, 0x8050);
|
||||
assert_eq!(msg.completion_code, 0x00);
|
||||
assert_eq!(msg.status, MxStatus::WRITE_COMPLETE_OK);
|
||||
assert!(msg.is_mx_access_write_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_word_unknown_with_completion_zero_packs_status_code_as_i16() {
|
||||
// status_code 0x8051, completion 0x00 — not the proven mapping; falls
|
||||
// through to the unpromoted branch with detail = (i16)0x8051 = -32687.
|
||||
// Mirrors `NmxOperationStatusMessage.cs:61` (`unchecked((short)statusCode)`).
|
||||
let frame = [0x00, 0x00, 0x51, 0x80, 0x00];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
|
||||
assert_eq!(msg.status_code, 0x8051);
|
||||
assert_eq!(msg.completion_code, 0x00);
|
||||
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
|
||||
assert_eq!(msg.status.detected_by, MxStatusSource::Unknown);
|
||||
assert_eq!(msg.status.detail, 0x8051u16 as i16);
|
||||
assert!(!msg.is_mx_access_write_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_word_unknown_with_nonzero_completion_packs_completion_in_detail() {
|
||||
// completion_code != 0 — detail = completion_code as i16
|
||||
// (`NmxOperationStatusMessage.cs:61`).
|
||||
let frame = [0x00, 0x00, 0x50, 0x80, 0x42];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.completion_code, 0x42);
|
||||
assert_eq!(msg.status.detail, 0x42);
|
||||
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
|
||||
assert!(!msg.is_mx_access_write_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_only_zero_byte() {
|
||||
// 1-byte 0x00 — preserved verbatim per design R3/R4.
|
||||
let frame = [0x00];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
|
||||
assert_eq!(msg.command, 0);
|
||||
assert_eq!(msg.status_code, 0);
|
||||
assert_eq!(msg.completion_code, 0x00);
|
||||
assert_eq!(msg.status.detail, 0x00);
|
||||
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
|
||||
// `CompletionOnly` is never promoted to WriteCompleteOk.
|
||||
assert!(!msg.is_mx_access_write_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_only_0x41_byte() {
|
||||
// 1-byte 0x41 — observed in the wild, mapping unproven
|
||||
// (`design/70-risks-and-open-questions.md` R4).
|
||||
let frame = [0x41];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
|
||||
assert_eq!(msg.completion_code, 0x41);
|
||||
assert_eq!(msg.status.detail, 0x41);
|
||||
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_only_0xef_byte() {
|
||||
// 1-byte 0xEF — observed in the wild, mapping unproven
|
||||
// (`design/70-risks-and-open-questions.md` R4).
|
||||
let frame = [0xEF];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
|
||||
assert_eq!(msg.completion_code, 0xEF);
|
||||
// 0xEF as i16 is 0x00EF (zero-extended), not -17.
|
||||
assert_eq!(msg.status.detail, 0xEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_length() {
|
||||
// 0 / 2 / 3 / 4 / 6 bytes — all non-recognised shapes.
|
||||
for len in [0_usize, 2, 3, 4, 6, 16] {
|
||||
let buf = vec![0u8; len];
|
||||
assert!(
|
||||
NmxOperationStatusMessage::try_parse_inner(&buf).is_err(),
|
||||
"length {len} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_5_byte_frame_without_leading_zeros() {
|
||||
// 5 bytes with non-zero leading bytes — not a StatusWord frame
|
||||
// (`NmxOperationStatusMessage.cs:48` requires `inner[0] == 0 && inner[1] == 0`).
|
||||
let frame = [0x01, 0x00, 0x50, 0x80, 0x00];
|
||||
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
|
||||
let frame = [0x00, 0x01, 0x50, 0x80, 0x00];
|
||||
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_code_is_little_endian() {
|
||||
// `inner[2..4]` is read as u16 LE — `inner[2] | (inner[3] << 8)`.
|
||||
// 0xAA at [2], 0xBB at [3] → 0xBBAA.
|
||||
let frame = [0x00, 0x00, 0xAA, 0xBB, 0x00];
|
||||
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
|
||||
assert_eq!(msg.status_code, 0xBBAA);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! `MxReferenceHandle` — 20-byte reference handle.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeCodec/MxReferenceHandle.cs`. CRC-16/IBM
|
||||
//! (poly `0xa001`, initial `0`) computed over lowercase UTF-16LE name bytes
|
||||
//! (low byte then high byte per char), per `MxReferenceHandle.cs:51,47-59`.
|
||||
|
||||
// Direct byte indexing is the right pattern for fixed-layout codec code:
|
||||
// every byte access is preceded by an explicit length check, and the resulting
|
||||
// code reads as a 1:1 mirror of the .NET source's `BinaryPrimitives` calls.
|
||||
// `.get(n)?` would obscure the byte map.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::error::CodecError;
|
||||
|
||||
const CRC16_IBM_POLYNOMIAL: u16 = 0xa001;
|
||||
|
||||
/// 20-byte reference handle. Encoded layout matches the .NET reference
|
||||
/// (`MxReferenceHandle.cs:88-106`):
|
||||
///
|
||||
/// ```text
|
||||
/// offset size field
|
||||
/// 0 1 galaxy_id
|
||||
/// 1 1 reserved (always 0; not exposed publicly)
|
||||
/// 2 2 platform_id u16 LE
|
||||
/// 4 2 engine_id u16 LE
|
||||
/// 6 2 object_id u16 LE
|
||||
/// 8 2 object_signature u16 LE (CRC-16/IBM of object tag name)
|
||||
/// 10 2 primitive_id i16 LE
|
||||
/// 12 2 attribute_id i16 LE
|
||||
/// 14 2 property_id i16 LE
|
||||
/// 16 2 attribute_signature u16 LE (CRC-16/IBM of attribute name)
|
||||
/// 18 2 attribute_index i16 LE (-1 array, 0 scalar)
|
||||
/// ```
|
||||
///
|
||||
/// `object_signature` and `attribute_signature` are derived values. The Rust
|
||||
/// port keeps them private — the only constructor that produces a handle from
|
||||
/// names is [`from_names`]; the only mutators that update one signature are
|
||||
/// [`with_object_tag_name`] and [`with_attribute_name`], which both
|
||||
/// recompute. This is a deliberate tightening over the .NET reference (which
|
||||
/// is a record with public init-only signature fields).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct MxReferenceHandle {
|
||||
pub galaxy_id: u8,
|
||||
pub platform_id: u16,
|
||||
pub engine_id: u16,
|
||||
pub object_id: u16,
|
||||
object_signature: u16,
|
||||
pub primitive_id: i16,
|
||||
pub attribute_id: i16,
|
||||
pub property_id: i16,
|
||||
attribute_signature: u16,
|
||||
pub attribute_index: i16,
|
||||
}
|
||||
|
||||
impl MxReferenceHandle {
|
||||
pub const ENCODED_LEN: usize = 20;
|
||||
|
||||
/// Construct a handle by computing the object/attribute signatures from
|
||||
/// their respective names. Mirrors `MxReferenceHandle.Create`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CodecError::InvalidName`] if either name is empty or
|
||||
/// whitespace-only — matching the .NET `ArgumentException.ThrowIfNullOrWhiteSpace`
|
||||
/// contract at `MxReferenceHandle.cs:49`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_names(
|
||||
galaxy_id: u8,
|
||||
platform_id: u16,
|
||||
engine_id: u16,
|
||||
object_id: u16,
|
||||
object_tag_name: &str,
|
||||
primitive_id: i16,
|
||||
attribute_id: i16,
|
||||
property_id: i16,
|
||||
attribute_name: &str,
|
||||
is_array: bool,
|
||||
) -> Result<Self, CodecError> {
|
||||
Ok(Self {
|
||||
galaxy_id,
|
||||
platform_id,
|
||||
engine_id,
|
||||
object_id,
|
||||
object_signature: compute_name_signature(object_tag_name)?,
|
||||
primitive_id,
|
||||
attribute_id,
|
||||
property_id,
|
||||
attribute_signature: compute_name_signature(attribute_name)?,
|
||||
attribute_index: if is_array { -1 } else { 0 },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn object_signature(self) -> u16 {
|
||||
self.object_signature
|
||||
}
|
||||
|
||||
pub fn attribute_signature(self) -> u16 {
|
||||
self.attribute_signature
|
||||
}
|
||||
|
||||
/// Returns a new handle with the object signature recomputed from
|
||||
/// `object_tag_name`. Every other field is preserved.
|
||||
pub fn with_object_tag_name(self, object_tag_name: &str) -> Result<Self, CodecError> {
|
||||
Ok(Self {
|
||||
object_signature: compute_name_signature(object_tag_name)?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a new handle with the attribute signature recomputed from
|
||||
/// `attribute_name`. Every other field is preserved.
|
||||
pub fn with_attribute_name(self, attribute_name: &str) -> Result<Self, CodecError> {
|
||||
Ok(Self {
|
||||
attribute_signature: compute_name_signature(attribute_name)?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a 20-byte encoded handle. Mirrors `MxReferenceHandle.Parse`
|
||||
/// (`MxReferenceHandle.cs:61-79`); byte 1 is read but discarded.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CodecError::ShortRead`] if `bytes` is not exactly 20 bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, CodecError> {
|
||||
if bytes.len() != Self::ENCODED_LEN {
|
||||
return Err(CodecError::ShortRead {
|
||||
expected: Self::ENCODED_LEN,
|
||||
actual: bytes.len(),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
galaxy_id: bytes[0],
|
||||
// byte 1 reserved (discarded, mirrors .NET Parse)
|
||||
platform_id: read_u16_le(bytes, 2),
|
||||
engine_id: read_u16_le(bytes, 4),
|
||||
object_id: read_u16_le(bytes, 6),
|
||||
object_signature: read_u16_le(bytes, 8),
|
||||
primitive_id: read_i16_le(bytes, 10),
|
||||
attribute_id: read_i16_le(bytes, 12),
|
||||
property_id: read_i16_le(bytes, 14),
|
||||
attribute_signature: read_u16_le(bytes, 16),
|
||||
attribute_index: read_i16_le(bytes, 18),
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode into a freshly-allocated 20-byte buffer.
|
||||
pub fn encode(self) -> [u8; Self::ENCODED_LEN] {
|
||||
let mut bytes = [0u8; Self::ENCODED_LEN];
|
||||
self.write_to(&mut bytes);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Encode into the provided destination. Mirrors `MxReferenceHandle.WriteTo`
|
||||
/// (`MxReferenceHandle.cs:88-106`); byte 1 is always written as 0.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `destination.len() < 20`. Use a 20-byte slice or call
|
||||
/// [`encode`] for a fresh buffer.
|
||||
pub fn write_to(self, destination: &mut [u8]) {
|
||||
assert!(
|
||||
destination.len() >= Self::ENCODED_LEN,
|
||||
"destination must be at least {} bytes",
|
||||
Self::ENCODED_LEN
|
||||
);
|
||||
destination[0] = self.galaxy_id;
|
||||
destination[1] = 0;
|
||||
write_u16_le(destination, 2, self.platform_id);
|
||||
write_u16_le(destination, 4, self.engine_id);
|
||||
write_u16_le(destination, 6, self.object_id);
|
||||
write_u16_le(destination, 8, self.object_signature);
|
||||
write_i16_le(destination, 10, self.primitive_id);
|
||||
write_i16_le(destination, 12, self.attribute_id);
|
||||
write_i16_le(destination, 14, self.property_id);
|
||||
write_u16_le(destination, 16, self.attribute_signature);
|
||||
write_i16_le(destination, 18, self.attribute_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC-16/IBM signature of a name. Lowercases the name, then for each `char`
|
||||
/// runs the low byte then high byte of the UTF-16LE representation through
|
||||
/// [`update_crc16_ibm`].
|
||||
///
|
||||
/// Mirrors `MxReferenceHandle.ComputeNameSignature` (`MxReferenceHandle.cs:47-59`).
|
||||
///
|
||||
/// **Unicode caveat**: This uses Rust's [`str::to_lowercase`], which performs
|
||||
/// the Unicode Default_Lowercase mapping. This is intended to match
|
||||
/// `String.ToLowerInvariant()` in .NET. Edge cases involving locale-tailored
|
||||
/// mappings (e.g. Turkish dotless-i) may diverge — see
|
||||
/// `design/10-raw-layer.md` L37 for the path forward via `icu_casemap`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CodecError::InvalidName`] if `name` is empty or whitespace-only.
|
||||
pub fn compute_name_signature(name: &str) -> Result<u16, CodecError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(CodecError::InvalidName);
|
||||
}
|
||||
let lower = name.to_lowercase();
|
||||
let mut crc: u16 = 0;
|
||||
for ch in lower.chars() {
|
||||
// UTF-16LE: low byte then high byte of each `char`'s UTF-16 code units.
|
||||
// Surrogate-pair chars (>= U+10000) emit two u16 code units; we feed
|
||||
// each as low-then-high. This mirrors the .NET enumeration which
|
||||
// iterates over UTF-16 code units (the `char` in C# is a u16).
|
||||
let mut buf = [0u16; 2];
|
||||
let utf16 = ch.encode_utf16(&mut buf);
|
||||
for unit in utf16 {
|
||||
crc = update_crc16_ibm(crc, *unit as u8);
|
||||
crc = update_crc16_ibm(crc, (*unit >> 8) as u8);
|
||||
}
|
||||
}
|
||||
Ok(crc)
|
||||
}
|
||||
|
||||
/// One iteration of the CRC-16/IBM update loop (poly `0xa001`, right-shifted
|
||||
/// variant). Mirrors `UpdateCrc16Ibm` (`MxReferenceHandle.cs:108-119`).
|
||||
pub const fn update_crc16_ibm(mut crc: u16, value: u8) -> u16 {
|
||||
crc ^= value as u16;
|
||||
let mut bit = 0u8;
|
||||
while bit < 8 {
|
||||
crc = if (crc & 1) != 0 {
|
||||
(crc >> 1) ^ CRC16_IBM_POLYNOMIAL
|
||||
} else {
|
||||
crc >> 1
|
||||
};
|
||||
bit += 1;
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
||||
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
|
||||
i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset] = le[0];
|
||||
bytes[offset + 1] = le[1];
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
|
||||
let le = value.to_le_bytes();
|
||||
bytes[offset] = le[0];
|
||||
bytes[offset + 1] = le[1];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// CRC vectors hand-traced from `MxReferenceHandle.cs` against the
|
||||
/// .NET `ToLowerInvariant` + per-char low/high UTF-16LE feed.
|
||||
///
|
||||
/// Single ASCII char "a" (0x61):
|
||||
/// low byte = 0x61 → after one iter: crc = ?
|
||||
/// high byte = 0x00 → after another iter
|
||||
///
|
||||
/// Easier sanity: empty string check; matches the .NET behaviour of
|
||||
/// throwing on whitespace-only input.
|
||||
/// **Cross-implementation parity**: the values on the right are the exact
|
||||
/// CRC-16/IBM outputs of `MxNativeCodec.MxReferenceHandle.ComputeNameSignature`
|
||||
/// in the .NET reference, captured via `tools/Compute-Crc.ps1`. If the
|
||||
/// Rust port ever diverges, these tests catch it. Regenerate with
|
||||
/// `pwsh -NoProfile -File tools\Compute-Crc.ps1` after adding new vectors.
|
||||
#[test]
|
||||
fn dotnet_reference_parity_vectors() {
|
||||
let cases = [
|
||||
("TestObject", 0x0B25),
|
||||
("TestInt", 0xDA3E),
|
||||
("$Object", 0x22A4),
|
||||
("a", 0x9029),
|
||||
("TestChildObject", 0xD736),
|
||||
// Case-insensitivity: all three of these collapse to the same CRC
|
||||
// because `to_lowercase` matches `String.ToLowerInvariant`.
|
||||
("testobject", 0x0B25),
|
||||
("TESTOBJECT", 0x0B25),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(
|
||||
compute_name_signature(name).unwrap(),
|
||||
expected,
|
||||
"CRC for {name:?} diverged from .NET reference"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_rejected() {
|
||||
assert!(compute_name_signature("").is_err());
|
||||
assert!(compute_name_signature(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercasing_is_invariant() {
|
||||
// Same name in different cases produces the same signature.
|
||||
let a = compute_name_signature("TestObject").unwrap();
|
||||
let b = compute_name_signature("testobject").unwrap();
|
||||
let c = compute_name_signature("TESTOBJECT").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_names_distinct_signatures() {
|
||||
// Different names should hash to different values for any reasonable
|
||||
// hash. (CRC-16 collisions exist, but these short distinct strings
|
||||
// shouldn't collide.)
|
||||
let a = compute_name_signature("TestObject").unwrap();
|
||||
let b = compute_name_signature("TestInt").unwrap();
|
||||
let c = compute_name_signature("$Object").unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_ne!(b, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_init_is_zero() {
|
||||
// CRC of a single null byte under poly 0xa001 with init 0:
|
||||
// crc = 0 XOR 0 = 0; eight right-shifts on 0 stay 0.
|
||||
// So CRC of [0u8] under update_crc16_ibm is 0.
|
||||
assert_eq!(update_crc16_ibm(0, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_zero_handle() {
|
||||
let handle = MxReferenceHandle::default();
|
||||
let encoded = handle.encode();
|
||||
let decoded = MxReferenceHandle::parse(&encoded).unwrap();
|
||||
assert_eq!(handle, decoded);
|
||||
assert_eq!(encoded, [0u8; 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_populated_handle() {
|
||||
let handle = MxReferenceHandle::from_names(
|
||||
1, // galaxy_id
|
||||
42, // platform_id
|
||||
17, // engine_id
|
||||
300, // object_id
|
||||
"TestChildObject", // object_tag_name
|
||||
-1, // primitive_id
|
||||
7, // attribute_id
|
||||
0, // property_id
|
||||
"TestInt", // attribute_name
|
||||
false, // is_array
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let encoded = handle.encode();
|
||||
let decoded = MxReferenceHandle::parse(&encoded).unwrap();
|
||||
assert_eq!(handle, decoded);
|
||||
assert_eq!(decoded.galaxy_id, 1);
|
||||
assert_eq!(decoded.platform_id, 42);
|
||||
assert_eq!(decoded.engine_id, 17);
|
||||
assert_eq!(decoded.object_id, 300);
|
||||
assert_eq!(decoded.primitive_id, -1);
|
||||
assert_eq!(decoded.attribute_id, 7);
|
||||
assert_eq!(decoded.property_id, 0);
|
||||
assert_eq!(decoded.attribute_index, 0);
|
||||
assert_eq!(decoded.object_signature(), handle.object_signature());
|
||||
assert_eq!(decoded.attribute_signature(), handle.attribute_signature());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_flag_is_minus_one() {
|
||||
let handle = MxReferenceHandle::from_names(1, 1, 1, 1, "X", 0, 0, 0, "Y", true).unwrap();
|
||||
assert_eq!(handle.attribute_index, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_1_always_zero_on_encode() {
|
||||
let handle = MxReferenceHandle {
|
||||
galaxy_id: 0xff,
|
||||
..MxReferenceHandle::default()
|
||||
};
|
||||
let encoded = handle.encode();
|
||||
assert_eq!(encoded[0], 0xff);
|
||||
assert_eq!(encoded[1], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_buffer() {
|
||||
assert!(MxReferenceHandle::parse(&[0u8; 19]).is_err());
|
||||
assert!(MxReferenceHandle::parse(&[0u8; 21]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_attribute_name_recomputes_signature() {
|
||||
let h1 = MxReferenceHandle::from_names(1, 1, 1, 1, "Obj", 0, 0, 0, "AttrA", false).unwrap();
|
||||
let h2 = h1.with_attribute_name("AttrB").unwrap();
|
||||
assert_ne!(h1.attribute_signature(), h2.attribute_signature());
|
||||
// Object signature unchanged.
|
||||
assert_eq!(h1.object_signature(), h2.object_signature());
|
||||
// Other fields preserved.
|
||||
assert_eq!(h1.galaxy_id, h2.galaxy_id);
|
||||
assert_eq!(h1.platform_id, h2.platform_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endianness_is_little() {
|
||||
// Verify that platform_id 0x1234 ends up as bytes [0x34, 0x12] at
|
||||
// offset 2..4.
|
||||
let h = MxReferenceHandle {
|
||||
platform_id: 0x1234,
|
||||
..MxReferenceHandle::default()
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded[2], 0x34);
|
||||
assert_eq!(encoded[3], 0x12);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,820 @@
|
||||
//! `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(×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<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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! `MxStatus` — 4-tuple `(Success, Category, DetectedBy, Detail)` per
|
||||
//! `src/MxNativeCodec/MxStatus.cs:28-65`.
|
||||
//!
|
||||
//! `Success=-1` is the documented OK sentinel. Detail is a signed 16-bit
|
||||
//! lookup code; canonical text for known codes is in [`detail_text`].
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[non_exhaustive]
|
||||
#[repr(i16)]
|
||||
pub enum MxStatusCategory {
|
||||
#[default]
|
||||
Unknown = -1,
|
||||
Ok = 0,
|
||||
Pending = 1,
|
||||
Warning = 2,
|
||||
CommunicationError = 3,
|
||||
ConfigurationError = 4,
|
||||
OperationalError = 5,
|
||||
SecurityError = 6,
|
||||
SoftwareError = 7,
|
||||
OtherError = 8,
|
||||
}
|
||||
|
||||
impl MxStatusCategory {
|
||||
pub fn from_i16(value: i16) -> Self {
|
||||
match value {
|
||||
0 => Self::Ok,
|
||||
1 => Self::Pending,
|
||||
2 => Self::Warning,
|
||||
3 => Self::CommunicationError,
|
||||
4 => Self::ConfigurationError,
|
||||
5 => Self::OperationalError,
|
||||
6 => Self::SecurityError,
|
||||
7 => Self::SoftwareError,
|
||||
8 => Self::OtherError,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_i16(self) -> i16 {
|
||||
self as i16
|
||||
}
|
||||
}
|
||||
|
||||
/// Seven values per `MxStatus.cs:17-26`. The `DetectedBy` field is essential
|
||||
/// for diagnostics — it identifies which layer detected the fault.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[non_exhaustive]
|
||||
#[repr(i16)]
|
||||
pub enum MxStatusSource {
|
||||
#[default]
|
||||
Unknown = -1,
|
||||
RequestingLmx = 0,
|
||||
RespondingLmx = 1,
|
||||
RequestingNmx = 2,
|
||||
RespondingNmx = 3,
|
||||
RequestingAutomationObject = 4,
|
||||
RespondingAutomationObject = 5,
|
||||
}
|
||||
|
||||
impl MxStatusSource {
|
||||
pub fn from_i16(value: i16) -> Self {
|
||||
match value {
|
||||
0 => Self::RequestingLmx,
|
||||
1 => Self::RespondingLmx,
|
||||
2 => Self::RequestingNmx,
|
||||
3 => Self::RespondingNmx,
|
||||
4 => Self::RequestingAutomationObject,
|
||||
5 => Self::RespondingAutomationObject,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_i16(self) -> i16 {
|
||||
self as i16
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct MxStatus {
|
||||
pub success: i16,
|
||||
pub category: MxStatusCategory,
|
||||
pub detected_by: MxStatusSource,
|
||||
pub detail: i16,
|
||||
}
|
||||
|
||||
impl MxStatus {
|
||||
/// `(success=-1, Ok, RequestingLmx, detail=0)` — `MxStatus.DataChangeOk`
|
||||
/// from `MxStatus.cs:36-40`.
|
||||
pub const DATA_CHANGE_OK: Self = Self {
|
||||
success: -1,
|
||||
category: MxStatusCategory::Ok,
|
||||
detected_by: MxStatusSource::RequestingLmx,
|
||||
detail: 0,
|
||||
};
|
||||
|
||||
/// `(success=-1, Ok, RespondingAutomationObject, detail=0)` —
|
||||
/// `MxStatus.WriteCompleteOk` from `MxStatus.cs:42-46`.
|
||||
pub const WRITE_COMPLETE_OK: Self = Self {
|
||||
success: -1,
|
||||
category: MxStatusCategory::Ok,
|
||||
detected_by: MxStatusSource::RespondingAutomationObject,
|
||||
detail: 0,
|
||||
};
|
||||
|
||||
/// `(success=-1, Pending, RequestingLmx, detail=0)` —
|
||||
/// `MxStatus.SuspendPending` from `MxStatus.cs:48-52`.
|
||||
pub const SUSPEND_PENDING: Self = Self {
|
||||
success: -1,
|
||||
category: MxStatusCategory::Pending,
|
||||
detected_by: MxStatusSource::RequestingLmx,
|
||||
detail: 0,
|
||||
};
|
||||
|
||||
/// `(success=-1, Ok, RequestingLmx, detail=0)` — `MxStatus.ActivateOk`
|
||||
/// from `MxStatus.cs:54-58`.
|
||||
pub const ACTIVATE_OK: Self = Self {
|
||||
success: -1,
|
||||
category: MxStatusCategory::Ok,
|
||||
detected_by: MxStatusSource::RequestingLmx,
|
||||
detail: 0,
|
||||
};
|
||||
|
||||
/// `(success=0, ConfigurationError, RequestingLmx, detail=6)` —
|
||||
/// `MxStatus.InvalidReferenceConfiguration` from `MxStatus.cs:60-64`.
|
||||
pub const INVALID_REFERENCE_CONFIGURATION: Self = Self {
|
||||
success: 0,
|
||||
category: MxStatusCategory::ConfigurationError,
|
||||
detected_by: MxStatusSource::RequestingLmx,
|
||||
detail: 6,
|
||||
};
|
||||
|
||||
/// Look up the canonical text for `self.detail`, mirroring
|
||||
/// `MxStatus.DetailText` (`MxStatus.cs:34`). Returns `None` for unknown
|
||||
/// detail codes.
|
||||
pub fn detail_text(&self) -> Option<&'static str> {
|
||||
detail_text(self.detail)
|
||||
}
|
||||
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.category == MxStatusCategory::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical detail-code text per `MxStatusDetails.KnownDetails`
|
||||
/// (`MxStatus.cs:69-120`). Returns `None` for unknown codes.
|
||||
pub fn detail_text(detail: i16) -> Option<&'static str> {
|
||||
match detail {
|
||||
16 => Some("Request timed out"),
|
||||
17 => Some("Platform communication error"),
|
||||
18 => Some("Invalid platform ID"),
|
||||
19 => Some("Invalid engine ID"),
|
||||
20 => Some("Engine communication error"),
|
||||
21 => Some("Invalid reference"),
|
||||
22 => Some("No Galaxy Repository"),
|
||||
23 => Some("Invalid object ID"),
|
||||
24 => Some("Object signature mismatch"),
|
||||
25 => Some("Invalid primitive ID"),
|
||||
26 => Some("Invalid attribute ID"),
|
||||
27 => Some("Invalid property ID"),
|
||||
28 => Some("Index out of range"),
|
||||
29 => Some("Data out of range"),
|
||||
30 => Some("Incorrect data type"),
|
||||
31 => Some("Attribute not readable"),
|
||||
32 => Some("Attribute not writeable"),
|
||||
33 => Some("Write access denied"),
|
||||
34 => Some("Unknown error"),
|
||||
35 => Some("detected by"),
|
||||
36 => Some("Wrong data type"),
|
||||
37 => Some("Wrong number of dimensions"),
|
||||
38 => Some("Invalid index"),
|
||||
39 => Some("Index out of order"),
|
||||
40 => Some("Dimension does not exist"),
|
||||
41 => Some("Conversion not supported"),
|
||||
42 => Some("Unable to convert string"),
|
||||
43 => Some("Overflow"),
|
||||
44 => Some("Attribute signature mismatch"),
|
||||
45 => Some("Resolving local portion of reference"),
|
||||
46 => Some("Resolving global portion of reference"),
|
||||
47 => Some("Nmx version mismatch"),
|
||||
48 => Some("Nmx command not valid"),
|
||||
49 => Some("Lmx version mismatch"),
|
||||
50 => Some("Lmx command not valid"),
|
||||
51 => Some(
|
||||
"However, the object could not be put On Scan - Permission to modify \"Operate\" attributes is required",
|
||||
),
|
||||
52 => Some(
|
||||
"Unable to resolve reference for 'set' request because Galaxy Repository is busy performing a 'Deploy/Undeploy' operation",
|
||||
),
|
||||
53 => Some("Too many outstanding pending requests to engine"),
|
||||
54 => Some("Object Initializing"),
|
||||
55 => Some("Engine Initializing"),
|
||||
56 => Some("Secured Write"),
|
||||
57 => Some("Verified Write"),
|
||||
58 => Some("No Alarm Ack Privilege"),
|
||||
59 => Some("Alarm Acked Already"),
|
||||
60 => Some("User did not have the necessary permissions to write"),
|
||||
61 => Some("Verifier did not have the necessary permissions to verify"),
|
||||
541 => Some("Conversion to intended data type is not supported"),
|
||||
542 => Some("Unable to convert the input string to intended data type"),
|
||||
8017 => Some(
|
||||
"Object must be offscan to modify attributes that have an MxSecurityConfigure security classification",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn category_round_trip() {
|
||||
for cat in [
|
||||
MxStatusCategory::Unknown,
|
||||
MxStatusCategory::Ok,
|
||||
MxStatusCategory::Pending,
|
||||
MxStatusCategory::Warning,
|
||||
MxStatusCategory::CommunicationError,
|
||||
MxStatusCategory::ConfigurationError,
|
||||
MxStatusCategory::OperationalError,
|
||||
MxStatusCategory::SecurityError,
|
||||
MxStatusCategory::SoftwareError,
|
||||
MxStatusCategory::OtherError,
|
||||
] {
|
||||
assert_eq!(MxStatusCategory::from_i16(cat.to_i16()), cat);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_round_trip() {
|
||||
for src in [
|
||||
MxStatusSource::Unknown,
|
||||
MxStatusSource::RequestingLmx,
|
||||
MxStatusSource::RespondingLmx,
|
||||
MxStatusSource::RequestingNmx,
|
||||
MxStatusSource::RespondingNmx,
|
||||
MxStatusSource::RequestingAutomationObject,
|
||||
MxStatusSource::RespondingAutomationObject,
|
||||
] {
|
||||
assert_eq!(MxStatusSource::from_i16(src.to_i16()), src);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_codes_map_to_unknown_variants() {
|
||||
assert_eq!(MxStatusCategory::from_i16(99), MxStatusCategory::Unknown);
|
||||
assert_eq!(MxStatusCategory::from_i16(-99), MxStatusCategory::Unknown);
|
||||
assert_eq!(MxStatusSource::from_i16(99), MxStatusSource::Unknown);
|
||||
assert_eq!(MxStatusSource::from_i16(-2), MxStatusSource::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_sentinels_match_dotnet() {
|
||||
// `MxStatus.cs:36-58` defines five canonical sentinels.
|
||||
assert_eq!(MxStatus::DATA_CHANGE_OK.success, -1);
|
||||
assert_eq!(MxStatus::DATA_CHANGE_OK.category, MxStatusCategory::Ok);
|
||||
assert_eq!(
|
||||
MxStatus::DATA_CHANGE_OK.detected_by,
|
||||
MxStatusSource::RequestingLmx
|
||||
);
|
||||
assert_eq!(MxStatus::DATA_CHANGE_OK.detail, 0);
|
||||
|
||||
assert_eq!(
|
||||
MxStatus::WRITE_COMPLETE_OK.detected_by,
|
||||
MxStatusSource::RespondingAutomationObject
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MxStatus::INVALID_REFERENCE_CONFIGURATION.success,
|
||||
0,
|
||||
"InvalidReferenceConfiguration uses success=0, not -1"
|
||||
);
|
||||
assert_eq!(MxStatus::INVALID_REFERENCE_CONFIGURATION.detail, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_text_known_codes() {
|
||||
assert_eq!(detail_text(16), Some("Request timed out"));
|
||||
assert_eq!(detail_text(21), Some("Invalid reference"));
|
||||
assert_eq!(detail_text(33), Some("Write access denied"));
|
||||
assert_eq!(detail_text(57), Some("Verified Write"));
|
||||
assert_eq!(
|
||||
detail_text(541),
|
||||
Some("Conversion to intended data type is not supported")
|
||||
);
|
||||
assert_eq!(
|
||||
detail_text(8017),
|
||||
Some(
|
||||
"Object must be offscan to modify attributes that have an MxSecurityConfigure security classification"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_text_unknown_codes() {
|
||||
assert_eq!(detail_text(0), None);
|
||||
assert_eq!(detail_text(15), None);
|
||||
assert_eq!(detail_text(62), None);
|
||||
assert_eq!(detail_text(540), None);
|
||||
assert_eq!(detail_text(8018), None);
|
||||
assert_eq!(detail_text(-1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_ok_categorisation() {
|
||||
assert!(MxStatus::DATA_CHANGE_OK.is_ok());
|
||||
assert!(MxStatus::WRITE_COMPLETE_OK.is_ok());
|
||||
assert!(MxStatus::ACTIVATE_OK.is_ok());
|
||||
assert!(!MxStatus::SUSPEND_PENDING.is_ok());
|
||||
assert!(!MxStatus::INVALID_REFERENCE_CONFIGURATION.is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,471 @@
|
||||
//! Value model — `MxValueKind`, `MxDataType`, and `MxValue`.
|
||||
//!
|
||||
//! Ports `src/MxNativeCodec/MxValueKind.cs`, `src/MxNativeCodec/MxDataType.cs`,
|
||||
//! and the wire-kind/value mapping that `NmxSubscriptionMessage.cs` and
|
||||
//! `NmxWriteMessage.cs` use. `MxValueKind` carries the on-the-wire numeric
|
||||
//! tags observed in NMX subscription / write bodies; `MxDataType` is the
|
||||
//! attribute-model side of the .NET enum and is independent of the wire
|
||||
//! kind; `MxValue` is the runtime carrier used by codecs.
|
||||
//!
|
||||
//! The wire-kind values are not encoded as an enum in the .NET reference
|
||||
//! (`MxValueKind.cs:3-18` uses default integer ordering) — the `0x01..0x07`
|
||||
//! and `0x41..0x46` byte tags come from the encoder/decoder switches in
|
||||
//! `NmxWriteMessage.cs:94-110` and `NmxSubscriptionMessage.cs:164-176`.
|
||||
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
/// On-the-wire value kind tag.
|
||||
///
|
||||
/// Per `NmxWriteMessage.cs:94-110` (`GetWireKind`) and
|
||||
/// `NmxSubscriptionMessage.cs:164-176` (`DecodeValue`). The byte values are
|
||||
/// the actual wire tags written into / read out of NMX message bodies; they
|
||||
/// are NOT the `int` ordinals of the C# `MxValueKind` enum
|
||||
/// (`MxValueKind.cs:3-18`).
|
||||
///
|
||||
/// Encoder asymmetry: both `StringArray` and `DateTimeArray` are written as
|
||||
/// `0x45` on the wire (`NmxWriteMessage.cs:107`), but the decoder
|
||||
/// distinguishes `0x46` for `DateTimeArray`
|
||||
/// (`NmxSubscriptionMessage.cs:173,275`). [`MxValue::kind`] reflects the
|
||||
/// encoder behaviour — see its docs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[non_exhaustive]
|
||||
#[repr(u8)]
|
||||
pub enum MxValueKind {
|
||||
/// Sentinel for unrecognised wire tags. Not on the wire; used by
|
||||
/// [`MxValueKind::from_u8`] to surface "unknown kind" without panicking.
|
||||
#[default]
|
||||
Unknown = 0x00,
|
||||
/// `MxValueKind.cs:5` / wire `0x01` (`NmxWriteMessage.cs:98`,
|
||||
/// `NmxSubscriptionMessage.cs:166`).
|
||||
Boolean = 0x01,
|
||||
/// `MxValueKind.cs:6` / wire `0x02` (`NmxWriteMessage.cs:99`,
|
||||
/// `NmxSubscriptionMessage.cs:167`).
|
||||
Int32 = 0x02,
|
||||
/// `MxValueKind.cs:7` / wire `0x03` (`NmxWriteMessage.cs:100`,
|
||||
/// `NmxSubscriptionMessage.cs:168`).
|
||||
Float32 = 0x03,
|
||||
/// `MxValueKind.cs:8` / wire `0x04` (`NmxWriteMessage.cs:101`,
|
||||
/// `NmxSubscriptionMessage.cs:169`).
|
||||
Float64 = 0x04,
|
||||
/// `MxValueKind.cs:9` / wire `0x05` (`NmxWriteMessage.cs:102`,
|
||||
/// `NmxSubscriptionMessage.cs:170`). Encoder collapses `String` and
|
||||
/// `DateTime` to the same tag (`NmxWriteMessage.cs:102`).
|
||||
String = 0x05,
|
||||
/// `MxValueKind.cs:10` / wire `0x06` on the decode path
|
||||
/// (`NmxSubscriptionMessage.cs:171`). Encoder collapses to `0x05`
|
||||
/// (`NmxWriteMessage.cs:102`).
|
||||
DateTime = 0x06,
|
||||
/// `MxValueKind.cs:11` / wire `0x07` (`NmxSubscriptionMessage.cs:172,253`).
|
||||
/// Decoder reads a signed `i32` milliseconds value.
|
||||
ElapsedTime = 0x07,
|
||||
/// `MxValueKind.cs:12` / wire `0x41` (`NmxWriteMessage.cs:103`,
|
||||
/// `NmxSubscriptionMessage.cs:173,270`).
|
||||
BoolArray = 0x41,
|
||||
/// `MxValueKind.cs:13` / wire `0x42` (`NmxWriteMessage.cs:104`,
|
||||
/// `NmxSubscriptionMessage.cs:173,271`).
|
||||
Int32Array = 0x42,
|
||||
/// `MxValueKind.cs:14` / wire `0x43` (`NmxWriteMessage.cs:105`,
|
||||
/// `NmxSubscriptionMessage.cs:173,272`).
|
||||
Float32Array = 0x43,
|
||||
/// `MxValueKind.cs:15` / wire `0x44` (`NmxWriteMessage.cs:106`,
|
||||
/// `NmxSubscriptionMessage.cs:173,273`).
|
||||
Float64Array = 0x44,
|
||||
/// `MxValueKind.cs:16` / wire `0x45` (`NmxWriteMessage.cs:107`,
|
||||
/// `NmxSubscriptionMessage.cs:173,274`). Encoder collapses
|
||||
/// `StringArray` and `DateTimeArray` to this tag
|
||||
/// (`NmxWriteMessage.cs:107`).
|
||||
StringArray = 0x45,
|
||||
/// `MxValueKind.cs:17` / wire `0x46` on the decode path
|
||||
/// (`NmxSubscriptionMessage.cs:173,275`). Encoder collapses to `0x45`
|
||||
/// (`NmxWriteMessage.cs:107`).
|
||||
DateTimeArray = 0x46,
|
||||
// ElapsedTimeArray (0x47) is not enumerated by `MxValueKind.cs` and is
|
||||
// not handled by either the encoder or decoder. Known gap, parity with
|
||||
// .NET reference.
|
||||
}
|
||||
|
||||
impl MxValueKind {
|
||||
/// Decode a wire byte into a kind. Returns [`MxValueKind::Unknown`] for
|
||||
/// any tag not in `0x01..=0x07` or `0x41..=0x46` — mirrors the fall-
|
||||
/// through arms of `NmxSubscriptionMessage.DecodeValue`
|
||||
/// (`NmxSubscriptionMessage.cs:174`) and `ToValueKindOrNull` in the
|
||||
/// .NET reference.
|
||||
pub fn from_u8(value: u8) -> Self {
|
||||
match value {
|
||||
0x01 => Self::Boolean,
|
||||
0x02 => Self::Int32,
|
||||
0x03 => Self::Float32,
|
||||
0x04 => Self::Float64,
|
||||
0x05 => Self::String,
|
||||
0x06 => Self::DateTime,
|
||||
0x07 => Self::ElapsedTime,
|
||||
0x41 => Self::BoolArray,
|
||||
0x42 => Self::Int32Array,
|
||||
0x43 => Self::Float32Array,
|
||||
0x44 => Self::Float64Array,
|
||||
0x45 => Self::StringArray,
|
||||
0x46 => Self::DateTimeArray,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the kind to its wire byte. `Unknown` returns `0x00`; do not
|
||||
/// emit `Unknown` to the wire — the .NET encoder
|
||||
/// (`NmxWriteMessage.cs:108`) throws `ArgumentOutOfRangeException` for
|
||||
/// any kind not in its match.
|
||||
pub fn to_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute-model data type — port of `MxDataType.cs:3-24`.
|
||||
///
|
||||
/// This is the model-side attribute classification, distinct from the wire
|
||||
/// `MxValueKind`. The numeric values are the explicit `short` discriminants
|
||||
/// from `MxDataType.cs:3` (`enum MxDataType : short`). Used by the runtime
|
||||
/// model and by `RegisterMxReferences` results, not by NMX value bodies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[non_exhaustive]
|
||||
#[repr(i16)]
|
||||
pub enum MxDataType {
|
||||
/// `MxDataType.cs:5` — sentinel for "no type known".
|
||||
#[default]
|
||||
Unknown = -1,
|
||||
/// `MxDataType.cs:6`.
|
||||
NoData = 0,
|
||||
/// `MxDataType.cs:7`.
|
||||
Boolean = 1,
|
||||
/// `MxDataType.cs:8`.
|
||||
Integer = 2,
|
||||
/// `MxDataType.cs:9`.
|
||||
Float = 3,
|
||||
/// `MxDataType.cs:10`.
|
||||
Double = 4,
|
||||
/// `MxDataType.cs:11`.
|
||||
String = 5,
|
||||
/// `MxDataType.cs:12`.
|
||||
Time = 6,
|
||||
/// `MxDataType.cs:13`.
|
||||
ElapsedTime = 7,
|
||||
/// `MxDataType.cs:14`.
|
||||
ReferenceType = 8,
|
||||
/// `MxDataType.cs:15`.
|
||||
StatusType = 9,
|
||||
/// `MxDataType.cs:16`.
|
||||
Enum = 10,
|
||||
/// `MxDataType.cs:17`.
|
||||
SecurityClassificationEnum = 11,
|
||||
/// `MxDataType.cs:18`.
|
||||
DataQualityType = 12,
|
||||
/// `MxDataType.cs:19`.
|
||||
QualifiedEnum = 13,
|
||||
/// `MxDataType.cs:20`.
|
||||
QualifiedStruct = 14,
|
||||
/// `MxDataType.cs:21`.
|
||||
InternationalizedString = 15,
|
||||
/// `MxDataType.cs:22`.
|
||||
BigString = 16,
|
||||
/// `MxDataType.cs:23` — terminator sentinel from the .NET enum.
|
||||
End = 17,
|
||||
}
|
||||
|
||||
impl MxDataType {
|
||||
/// Decode the model-side type id. Out-of-range values map to
|
||||
/// [`MxDataType::Unknown`]. Mirrors the `Unknown = -1` sentinel from
|
||||
/// `MxDataType.cs:5`.
|
||||
pub fn from_i16(value: i16) -> Self {
|
||||
match value {
|
||||
0 => Self::NoData,
|
||||
1 => Self::Boolean,
|
||||
2 => Self::Integer,
|
||||
3 => Self::Float,
|
||||
4 => Self::Double,
|
||||
5 => Self::String,
|
||||
6 => Self::Time,
|
||||
7 => Self::ElapsedTime,
|
||||
8 => Self::ReferenceType,
|
||||
9 => Self::StatusType,
|
||||
10 => Self::Enum,
|
||||
11 => Self::SecurityClassificationEnum,
|
||||
12 => Self::DataQualityType,
|
||||
13 => Self::QualifiedEnum,
|
||||
14 => Self::QualifiedStruct,
|
||||
15 => Self::InternationalizedString,
|
||||
16 => Self::BigString,
|
||||
17 => Self::End,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_i16(self) -> i16 {
|
||||
self as i16
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime carrier for a decoded MXAccess value.
|
||||
///
|
||||
/// Variant set tracks `NmxSubscriptionMessage.DecodeValue`
|
||||
/// (`NmxSubscriptionMessage.cs:164-176`): the seven scalar wire kinds
|
||||
/// (`0x01..=0x07`) plus the six array wire kinds (`0x41..=0x46`,
|
||||
/// minus `ElapsedTimeArray` which has no .NET support).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum MxValue {
|
||||
/// Scalar boolean, wire `0x01` (`NmxSubscriptionMessage.cs:166`).
|
||||
Boolean(bool),
|
||||
/// Scalar `i32`, wire `0x02` (`NmxSubscriptionMessage.cs:167`).
|
||||
Int32(i32),
|
||||
/// Scalar `f32`, wire `0x03` (`NmxSubscriptionMessage.cs:168`).
|
||||
Float32(f32),
|
||||
/// Scalar `f64`, wire `0x04` (`NmxSubscriptionMessage.cs:169`).
|
||||
Float64(f64),
|
||||
/// Scalar UTF-16LE string, wire `0x05`
|
||||
/// (`NmxSubscriptionMessage.cs:170,178-210`).
|
||||
String(String),
|
||||
/// Windows `FILETIME` ticks (100ns since 1601-01-01 UTC), wire `0x06`
|
||||
/// (`NmxSubscriptionMessage.cs:171,212-243`). Carries the raw `i64`
|
||||
/// rather than a `DateTime` to preserve byte-for-byte parity even when
|
||||
/// the value falls outside `chrono`/`time` clamp ranges.
|
||||
DateTime(i64),
|
||||
/// Signed milliseconds, wire `0x07`
|
||||
/// (`NmxSubscriptionMessage.cs:172,245-254`). Wire is `i32`; widened to
|
||||
/// `i64` here to allow the model to be unambiguous about sign and to
|
||||
/// avoid forcing the `std::time::Duration` (unsigned) shape.
|
||||
ElapsedTime(i64),
|
||||
/// Boolean array, wire `0x41`
|
||||
/// (`NmxSubscriptionMessage.cs:173,280-294`). On the wire each element
|
||||
/// is an `i16` per `elementWidth==sizeof(short)` check.
|
||||
BoolArray(Vec<bool>),
|
||||
/// `i32` array, wire `0x42`
|
||||
/// (`NmxSubscriptionMessage.cs:173,296-310`).
|
||||
Int32Array(Vec<i32>),
|
||||
/// `f32` array, wire `0x43`
|
||||
/// (`NmxSubscriptionMessage.cs:173,312-326`).
|
||||
Float32Array(Vec<f32>),
|
||||
/// `f64` array, wire `0x44`
|
||||
/// (`NmxSubscriptionMessage.cs:173,328-342`).
|
||||
Float64Array(Vec<f64>),
|
||||
/// String array, wire `0x45` on encode AND decode
|
||||
/// (`NmxWriteMessage.cs:107`, `NmxSubscriptionMessage.cs:173,274`).
|
||||
StringArray(Vec<String>),
|
||||
/// `DateTime` array; decoded from wire `0x46`
|
||||
/// (`NmxSubscriptionMessage.cs:173,275`), but encoded as `0x45`
|
||||
/// (`NmxWriteMessage.cs:107`). Elements are raw `FILETIME` ticks.
|
||||
DateTimeArray(Vec<i64>),
|
||||
}
|
||||
|
||||
impl MxValue {
|
||||
/// Wire kind for this value. Mirrors `NmxWriteMessage.GetWireKind`
|
||||
/// (`NmxWriteMessage.cs:94-110`) — i.e. the *encoder* behaviour.
|
||||
///
|
||||
/// **Encoder collapse:** both [`MxValue::StringArray`] and
|
||||
/// [`MxValue::DateTimeArray`] return [`MxValueKind::StringArray`]
|
||||
/// (`0x45`) here, matching `NmxWriteMessage.cs:107`. The decoder is
|
||||
/// asymmetric (`0x46` round-trips back into `DateTimeArray`); see
|
||||
/// [`MxValueKind`] docs.
|
||||
pub fn kind(&self) -> MxValueKind {
|
||||
match self {
|
||||
Self::Boolean(_) => MxValueKind::Boolean,
|
||||
Self::Int32(_) => MxValueKind::Int32,
|
||||
Self::Float32(_) => MxValueKind::Float32,
|
||||
Self::Float64(_) => MxValueKind::Float64,
|
||||
Self::String(_) => MxValueKind::String,
|
||||
// Per NmxWriteMessage.cs:102 the encoder collapses DateTime to
|
||||
// the String wire tag (0x05). Returning `DateTime` here keeps
|
||||
// the model side honest; encoders should re-map via
|
||||
// `MxValueKind::to_u8` semantics or call out to a future
|
||||
// `to_wire_byte` helper.
|
||||
Self::DateTime(_) => MxValueKind::DateTime,
|
||||
Self::ElapsedTime(_) => MxValueKind::ElapsedTime,
|
||||
Self::BoolArray(_) => MxValueKind::BoolArray,
|
||||
Self::Int32Array(_) => MxValueKind::Int32Array,
|
||||
Self::Float32Array(_) => MxValueKind::Float32Array,
|
||||
Self::Float64Array(_) => MxValueKind::Float64Array,
|
||||
// Encoder collapse: NmxWriteMessage.cs:107 maps both
|
||||
// StringArray and DateTimeArray to wire 0x45. Matches the
|
||||
// .NET encoder; the decoder asymmetrically uses 0x46 for
|
||||
// DateTimeArray (NmxSubscriptionMessage.cs:275).
|
||||
Self::StringArray(_) => MxValueKind::StringArray,
|
||||
Self::DateTimeArray(_) => MxValueKind::StringArray,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-side data type hint. This is best-effort: the wire never
|
||||
/// carries `MxDataType` — it's the model classification used by
|
||||
/// register results and the public API. Arrays return their scalar
|
||||
/// element's `MxDataType` (the .NET reference does not have an
|
||||
/// "array of X" `MxDataType` discriminant).
|
||||
pub fn data_type(&self) -> MxDataType {
|
||||
match self {
|
||||
Self::Boolean(_) | Self::BoolArray(_) => MxDataType::Boolean,
|
||||
Self::Int32(_) | Self::Int32Array(_) => MxDataType::Integer,
|
||||
Self::Float32(_) | Self::Float32Array(_) => MxDataType::Float,
|
||||
Self::Float64(_) | Self::Float64Array(_) => MxDataType::Double,
|
||||
Self::String(_) | Self::StringArray(_) => MxDataType::String,
|
||||
Self::DateTime(_) | Self::DateTimeArray(_) => MxDataType::Time,
|
||||
Self::ElapsedTime(_) => MxDataType::ElapsedTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL_KINDS: &[MxValueKind] = &[
|
||||
MxValueKind::Unknown,
|
||||
MxValueKind::Boolean,
|
||||
MxValueKind::Int32,
|
||||
MxValueKind::Float32,
|
||||
MxValueKind::Float64,
|
||||
MxValueKind::String,
|
||||
MxValueKind::DateTime,
|
||||
MxValueKind::ElapsedTime,
|
||||
MxValueKind::BoolArray,
|
||||
MxValueKind::Int32Array,
|
||||
MxValueKind::Float32Array,
|
||||
MxValueKind::Float64Array,
|
||||
MxValueKind::StringArray,
|
||||
MxValueKind::DateTimeArray,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn value_kind_round_trip() {
|
||||
for &kind in ALL_KINDS {
|
||||
assert_eq!(MxValueKind::from_u8(kind.to_u8()), kind, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kind_unknown_byte_maps_to_unknown() {
|
||||
assert_eq!(MxValueKind::from_u8(0xff), MxValueKind::Unknown);
|
||||
// Tags between scalar (0x07) and array (0x41) ranges are not
|
||||
// assigned in the .NET reference.
|
||||
assert_eq!(MxValueKind::from_u8(0x08), MxValueKind::Unknown);
|
||||
assert_eq!(MxValueKind::from_u8(0x40), MxValueKind::Unknown);
|
||||
// 0x47 (would-be ElapsedTimeArray) is a documented gap — must
|
||||
// currently surface as Unknown.
|
||||
assert_eq!(MxValueKind::from_u8(0x47), MxValueKind::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kind_to_u8_matches_wire_tags() {
|
||||
// Spot-check the wire bytes against the .cs sources.
|
||||
assert_eq!(MxValueKind::Boolean.to_u8(), 0x01);
|
||||
assert_eq!(MxValueKind::Int32.to_u8(), 0x02);
|
||||
assert_eq!(MxValueKind::Float32.to_u8(), 0x03);
|
||||
assert_eq!(MxValueKind::Float64.to_u8(), 0x04);
|
||||
assert_eq!(MxValueKind::String.to_u8(), 0x05);
|
||||
assert_eq!(MxValueKind::DateTime.to_u8(), 0x06);
|
||||
assert_eq!(MxValueKind::ElapsedTime.to_u8(), 0x07);
|
||||
assert_eq!(MxValueKind::BoolArray.to_u8(), 0x41);
|
||||
assert_eq!(MxValueKind::Int32Array.to_u8(), 0x42);
|
||||
assert_eq!(MxValueKind::Float32Array.to_u8(), 0x43);
|
||||
assert_eq!(MxValueKind::Float64Array.to_u8(), 0x44);
|
||||
assert_eq!(MxValueKind::StringArray.to_u8(), 0x45);
|
||||
assert_eq!(MxValueKind::DateTimeArray.to_u8(), 0x46);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_round_trip() {
|
||||
let all = [
|
||||
MxDataType::Unknown,
|
||||
MxDataType::NoData,
|
||||
MxDataType::Boolean,
|
||||
MxDataType::Integer,
|
||||
MxDataType::Float,
|
||||
MxDataType::Double,
|
||||
MxDataType::String,
|
||||
MxDataType::Time,
|
||||
MxDataType::ElapsedTime,
|
||||
MxDataType::ReferenceType,
|
||||
MxDataType::StatusType,
|
||||
MxDataType::Enum,
|
||||
MxDataType::SecurityClassificationEnum,
|
||||
MxDataType::DataQualityType,
|
||||
MxDataType::QualifiedEnum,
|
||||
MxDataType::QualifiedStruct,
|
||||
MxDataType::InternationalizedString,
|
||||
MxDataType::BigString,
|
||||
MxDataType::End,
|
||||
];
|
||||
for dt in all {
|
||||
assert_eq!(MxDataType::from_i16(dt.to_i16()), dt, "{dt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_out_of_range_maps_to_unknown() {
|
||||
assert_eq!(MxDataType::from_i16(-2), MxDataType::Unknown);
|
||||
assert_eq!(MxDataType::from_i16(18), MxDataType::Unknown);
|
||||
assert_eq!(MxDataType::from_i16(i16::MAX), MxDataType::Unknown);
|
||||
assert_eq!(MxDataType::from_i16(i16::MIN), MxDataType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kind_for_each_variant() {
|
||||
assert_eq!(MxValue::Boolean(true).kind(), MxValueKind::Boolean);
|
||||
assert_eq!(MxValue::Int32(0).kind(), MxValueKind::Int32);
|
||||
assert_eq!(MxValue::Float32(0.0).kind(), MxValueKind::Float32);
|
||||
assert_eq!(MxValue::Float64(0.0).kind(), MxValueKind::Float64);
|
||||
assert_eq!(MxValue::String(String::new()).kind(), MxValueKind::String);
|
||||
assert_eq!(MxValue::DateTime(0).kind(), MxValueKind::DateTime);
|
||||
assert_eq!(MxValue::ElapsedTime(0).kind(), MxValueKind::ElapsedTime);
|
||||
assert_eq!(MxValue::BoolArray(vec![]).kind(), MxValueKind::BoolArray);
|
||||
assert_eq!(MxValue::Int32Array(vec![]).kind(), MxValueKind::Int32Array);
|
||||
assert_eq!(
|
||||
MxValue::Float32Array(vec![]).kind(),
|
||||
MxValueKind::Float32Array
|
||||
);
|
||||
assert_eq!(
|
||||
MxValue::Float64Array(vec![]).kind(),
|
||||
MxValueKind::Float64Array
|
||||
);
|
||||
}
|
||||
|
||||
/// Both `StringArray` and `DateTimeArray` collapse to the same wire
|
||||
/// kind on the encode path. This mirrors `NmxWriteMessage.cs:107`:
|
||||
///
|
||||
/// ```text
|
||||
/// MxValueKind.StringArray or MxValueKind.DateTimeArray => 0x45,
|
||||
/// ```
|
||||
#[test]
|
||||
fn string_and_datetime_arrays_collapse_to_string_array_wire_kind() {
|
||||
let s = MxValue::StringArray(vec!["a".to_string()]);
|
||||
let d = MxValue::DateTimeArray(vec![0_i64]);
|
||||
|
||||
assert_eq!(s.kind(), MxValueKind::StringArray);
|
||||
assert_eq!(d.kind(), MxValueKind::StringArray);
|
||||
assert_eq!(s.kind(), d.kind());
|
||||
assert_eq!(s.kind().to_u8(), 0x45);
|
||||
assert_eq!(d.kind().to_u8(), 0x45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_for_each_value() {
|
||||
assert_eq!(MxValue::Boolean(false).data_type(), MxDataType::Boolean);
|
||||
assert_eq!(MxValue::Int32(0).data_type(), MxDataType::Integer);
|
||||
assert_eq!(MxValue::Float32(0.0).data_type(), MxDataType::Float);
|
||||
assert_eq!(MxValue::Float64(0.0).data_type(), MxDataType::Double);
|
||||
assert_eq!(
|
||||
MxValue::String(String::new()).data_type(),
|
||||
MxDataType::String
|
||||
);
|
||||
assert_eq!(MxValue::DateTime(0).data_type(), MxDataType::Time);
|
||||
assert_eq!(MxValue::ElapsedTime(0).data_type(), MxDataType::ElapsedTime);
|
||||
assert_eq!(MxValue::BoolArray(vec![]).data_type(), MxDataType::Boolean);
|
||||
assert_eq!(MxValue::DateTimeArray(vec![]).data_type(), MxDataType::Time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_dotnet_sentinels() {
|
||||
// MxValueKind::Unknown is the from_u8 fall-through and the Default
|
||||
// (matches `ToValueKindOrNull` semantics in
|
||||
// `NmxSubscriptionMessage.cs:174`).
|
||||
assert_eq!(MxValueKind::default(), MxValueKind::Unknown);
|
||||
// MxDataType::Unknown == -1 per `MxDataType.cs:5`.
|
||||
assert_eq!(MxDataType::default(), MxDataType::Unknown);
|
||||
assert_eq!(MxDataType::default().to_i16(), -1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
//! Cross-implementation parity: Rust codec vs the .NET reference.
|
||||
//!
|
||||
//! Closes out the M1 DoD line in `design/60-roadmap.md`:
|
||||
//!
|
||||
//! > every Frida-captured write/advise/subscribe body that the .NET reference
|
||||
//! > encodes today round-trips byte-identical through `mxaccess-codec` — i.e.
|
||||
//! > the proven matrix in `work_remain.md` (scalar/array writes, advise/unadvise,
|
||||
//! > single-record `0x33` DataUpdate, single-record SubscriptionStatus, the
|
||||
//! > 5-byte `00 00 50 80 00` write-complete frame, and the 1-byte completion
|
||||
//! > frames `0x00`/`0x41`/`0xEF` preserved verbatim). Cross-validated against
|
||||
//! > `src/MxNativeCodec.Tests/` outputs.
|
||||
//!
|
||||
//! Each fixture in this file is **the canonical hex byte sequence the .NET
|
||||
//! `MxNativeCodec.Tests` program asserts against** today. They are copied
|
||||
//! verbatim from `src/MxNativeCodec.Tests/Program.cs` lines 4-100. The
|
||||
//! reference is the bytes themselves, not the source program — if the .NET
|
||||
//! reference's encoder ever changes, regenerate via
|
||||
//! `dotnet run --project src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj`
|
||||
//! and capture the new hex.
|
||||
//!
|
||||
//! The parity strategy mirrors the .NET `RunRoundTrip` helper
|
||||
//! (`Program.cs:119-156`):
|
||||
//! 1. Take the observed bytes.
|
||||
//! 2. Hand them to [`ObservedWriteBodyTemplate::from_observed`] to capture the
|
||||
//! prefix/suffix slots verbatim.
|
||||
//! 3. Call [`ObservedWriteBodyTemplate::with_value`] with the original value.
|
||||
//! 4. Assert byte-identical.
|
||||
//!
|
||||
//! `with_value` preserves the captured prefix (handle, clientToken stash) and
|
||||
//! suffix (including FILETIME for timestamped Write2 bodies), so a single
|
||||
//! parity check works uniformly for normal and timestamped fixtures.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic
|
||||
)]
|
||||
|
||||
use mxaccess_codec::{
|
||||
MxValue, MxValueKind, NmxTransferEnvelopeTemplate, ObservedWriteBodyTemplate,
|
||||
};
|
||||
|
||||
/// Decode a space-separated hex string into bytes. Mirrors `Convert.FromHexString`
|
||||
/// in the .NET test helper.
|
||||
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
||||
s.split_whitespace()
|
||||
.map(|tok| u8::from_str_radix(tok, 16).expect("malformed hex token in fixture"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One write-body parity fixture. Mirrors a single `RunRoundTrip(...)` call
|
||||
/// in `src/MxNativeCodec.Tests/Program.cs`.
|
||||
struct Fixture {
|
||||
name: &'static str,
|
||||
kind: MxValueKind,
|
||||
/// Pre-built `MxValue` matching what the .NET test passes. For DateTime
|
||||
/// fixtures, the .NET helper formats the `DateTime` to `M/d/yyyy h:mm:ss tt`
|
||||
/// before encoding; we pass the same pre-formatted string via
|
||||
/// `MxValue::String` (the encoder collapses DateTime onto the String wire
|
||||
/// kind 0x05).
|
||||
value: MxValue,
|
||||
/// Canonical hex from the .NET test program. Space-separated bytes.
|
||||
hex: &'static str,
|
||||
}
|
||||
|
||||
fn write_body_fixtures() -> Vec<Fixture> {
|
||||
vec![
|
||||
// -------- Scalar Write (0x37) --------
|
||||
Fixture {
|
||||
name: "int",
|
||||
kind: MxValueKind::Int32,
|
||||
value: MxValue::Int32(109),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "bool",
|
||||
kind: MxValueKind::Boolean,
|
||||
value: MxValue::Boolean(true),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9a 00 0a 00 fa 7d 00 00 01 ff ff ff 00 00 00 00 00 00 00 00 e1 d8 b5 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "float",
|
||||
kind: MxValueKind::Float32,
|
||||
value: MxValue::Float32(1.25),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9c 00 0a 00 a6 ed 00 00 03 00 00 a0 3f ff ff 00 00 00 00 00 00 00 00 64 6f b6 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "double",
|
||||
kind: MxValueKind::Float64,
|
||||
value: MxValue::Float64(1.125),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9d 00 0a 00 1e 95 00 00 04 00 00 00 00 00 00 f2 3f ff ff 00 00 00 00 00 00 00 00 bf 04 b7 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "string",
|
||||
kind: MxValueKind::String,
|
||||
value: MxValue::String("AlphaMX".to_string()),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 14 00 00 00 10 00 00 00 41 00 6c 00 70 00 68 00 61 00 4d 00 58 00 00 00 ff ff 00 00 00 00 00 00 00 00 3d a1 b7 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
// .NET formats DateTime via `M/d/yyyy h:mm:ss tt` InvariantCulture.
|
||||
// `new DateTime(2026, 4, 25, 2, 30, 0)` → "4/25/2026 2:30:00 AM".
|
||||
// Wire kind collapses to 0x05 String per `NmxWriteMessage.cs:107`.
|
||||
name: "datetime (M/d/yyyy h:mm:ss tt)",
|
||||
kind: MxValueKind::DateTime,
|
||||
value: MxValue::String("4/25/2026 2:30:00 AM".to_string()),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9f 00 0a 00 62 49 00 00 05 2e 00 00 00 2a 00 00 00 34 00 2f 00 32 00 35 00 2f 00 32 00 30 00 32 00 36 00 20 00 32 00 3a 00 33 00 30 00 3a 00 30 00 30 00 20 00 41 00 4d 00 00 00 ff ff 00 00 00 00 00 00 00 00 58 94 b8 08 01 00 00 00",
|
||||
},
|
||||
// -------- Timestamped Write (Write2). Suffix `i16` is `0` (not -1)
|
||||
// and the 8-byte filler at suffix offsets 2..10 is replaced
|
||||
// by the FILETIME — see `design/40-protocol-invariants.md`
|
||||
// for the layout. `with_value` preserves the FILETIME from
|
||||
// the captured suffix, so the parity check is uniform.
|
||||
Fixture {
|
||||
name: "write2 int timestamp",
|
||||
kind: MxValueKind::Int32,
|
||||
value: MxValue::Int32(114),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 72 00 00 00 00 00 00 72 68 3a 83 d4 dc 01 20 2e d8 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "write2 string timestamp",
|
||||
kind: MxValueKind::String,
|
||||
value: MxValue::String("Write2Alpha".to_string()),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 9e 00 0a 00 1a 94 00 00 05 1c 00 00 00 18 00 00 00 57 00 72 00 69 00 74 00 65 00 32 00 41 00 6c 00 70 00 68 00 61 00 00 00 00 00 80 68 d5 9c ab d4 dc 01 9f 6e a6 0b 01 00 00 00",
|
||||
},
|
||||
// -------- Arrays --------
|
||||
Fixture {
|
||||
name: "int[]",
|
||||
kind: MxValueKind::Int32Array,
|
||||
value: MxValue::Int32Array(vec![201, 202, 203, 204, 205, 206, 207, 208, 209, 210]),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 c9 00 00 00 ca 00 00 00 cb 00 00 00 cc 00 00 00 cd 00 00 00 ce 00 00 00 cf 00 00 00 d0 00 00 00 d1 00 00 00 d2 00 00 00 ff ff 00 00 00 00 00 00 00 00 4d c3 c4 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "bool[]",
|
||||
kind: MxValueKind::BoolArray,
|
||||
value: MxValue::BoolArray(vec![
|
||||
true, true, false, false, true, true, false, false, true, true,
|
||||
]),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 a0 00 0a 00 fa 89 ff ff 41 00 00 00 00 0a 00 02 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 ff ff ff ff ff ff 00 00 00 00 00 00 00 00 a8 7f c5 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "float[]",
|
||||
kind: MxValueKind::Float32Array,
|
||||
value: MxValue::Float32Array(vec![
|
||||
1.25, 2.5, 3.75, 4.25, 5.5, 6.75, 7.25, 8.5, 9.75, 10.25,
|
||||
]),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 a3 00 0a 00 95 f1 ff ff 43 00 00 00 00 0a 00 04 00 00 00 00 00 a0 3f 00 00 20 40 00 00 70 40 00 00 88 40 00 00 b0 40 00 00 d8 40 00 00 e8 40 00 00 08 41 00 00 1c 41 00 00 24 41 ff ff 00 00 00 00 00 00 00 00 0c f4 c5 08 01 00 00 00",
|
||||
},
|
||||
Fixture {
|
||||
name: "string[]",
|
||||
kind: MxValueKind::StringArray,
|
||||
value: MxValue::StringArray(vec![
|
||||
"A01".to_string(),
|
||||
"B02".to_string(),
|
||||
"C03".to_string(),
|
||||
"D04".to_string(),
|
||||
"E05".to_string(),
|
||||
"F06".to_string(),
|
||||
"G07".to_string(),
|
||||
"H08".to_string(),
|
||||
"I09".to_string(),
|
||||
"J10".to_string(),
|
||||
]),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 a5 00 0a 00 5d 4b ff ff 45 00 00 00 00 0a 00 04 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 41 00 30 00 31 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 42 00 30 00 32 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 43 00 30 00 33 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 44 00 30 00 34 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 45 00 30 00 35 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 46 00 30 00 36 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 47 00 30 00 37 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 48 00 30 00 38 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 49 00 30 00 39 00 00 00 11 00 00 00 05 0c 00 00 00 08 00 00 00 4a 00 31 00 30 00 00 00 ff ff 00 00 00 00 00 00 00 00 c3 da c6 08 01 00 00 00",
|
||||
},
|
||||
// -------- Timestamped arrays --------
|
||||
Fixture {
|
||||
name: "write2 int[] timestamp",
|
||||
kind: MxValueKind::Int32Array,
|
||||
value: MxValue::Int32Array(vec![301, 302, 303, 304, 305, 306, 307, 308, 309, 310]),
|
||||
hex: "37 01 00 05 00 36 d7 02 00 a4 00 0a 00 60 57 ff ff 42 00 00 00 00 0a 00 04 00 00 00 2d 01 00 00 2e 01 00 00 2f 01 00 00 30 01 00 00 31 01 00 00 32 01 00 00 33 01 00 00 34 01 00 00 35 01 00 00 36 01 00 00 00 00 80 93 fc 76 ac d4 dc 01 43 35 ac 0b 01 00 00 00",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_bodies_round_trip_byte_identical_against_dotnet_reference() {
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for fx in write_body_fixtures() {
|
||||
let observed = hex_to_bytes(fx.hex);
|
||||
let template = match ObservedWriteBodyTemplate::from_observed(fx.kind, &observed) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
failures.push(format!(
|
||||
"fixture {:?} from_observed errored: {e:?}",
|
||||
fx.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// .NET helper passes `writeIndex: 1` uniformly (Program.cs:128).
|
||||
let regen = match template.with_value(&fx.value, 1) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failures.push(format!("fixture {:?} with_value errored: {e:?}", fx.name));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if regen != observed {
|
||||
// Find first divergent byte for fast triage.
|
||||
let len = regen.len().min(observed.len());
|
||||
let first = (0..len).find(|i| regen[*i] != observed[*i]);
|
||||
failures.push(format!(
|
||||
"fixture {:?} diverged: rust_len={} dotnet_len={} first_diff_at={:?}",
|
||||
fx.name,
|
||||
regen.len(),
|
||||
observed.len(),
|
||||
first,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"{} fixture(s) failed parity vs .NET reference:\n {}",
|
||||
failures.len(),
|
||||
failures.join("\n ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Transfer envelope round-trip ----------------------------------------
|
||||
//
|
||||
// Mirrors `RunTransferEnvelopeRoundTrip` (Program.cs:174-182) and the canonical
|
||||
// fixture at Program.cs:102-104. The envelope template captures the 46-byte
|
||||
// header verbatim and re-emits it byte-identical given the same inner body.
|
||||
|
||||
#[test]
|
||||
fn transfer_envelope_round_trips_byte_identical() {
|
||||
// Inner body and full transfer body — taken verbatim from
|
||||
// src/MxNativeCodec.Tests/Program.cs:102-104.
|
||||
let inner_hex = "37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00";
|
||||
let transfer_hex = "01 00 28 00 00 00 00 00 00 00 03 00 00 00 01 00 00 00 01 00 00 00 fb 7f 00 00 01 00 00 00 01 00 00 00 02 00 00 00 01 02 00 00 30 75 00 00 \
|
||||
37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00 02 6d 00 00 00 ff ff 00 00 00 00 00 00 00 00 c9 14 b1 08 01 00 00 00";
|
||||
|
||||
let inner = hex_to_bytes(inner_hex);
|
||||
let transfer = hex_to_bytes(transfer_hex);
|
||||
|
||||
let template = NmxTransferEnvelopeTemplate::from_observed(&transfer)
|
||||
.expect("envelope template parse failed");
|
||||
|
||||
// Decode inner — should match `inner_hex` byte-for-byte.
|
||||
let decoded_inner = template
|
||||
.decode_inner(&transfer)
|
||||
.expect("decode_inner errored");
|
||||
assert_eq!(
|
||||
decoded_inner,
|
||||
inner.as_slice(),
|
||||
"decoded inner body diverged from canonical hex"
|
||||
);
|
||||
|
||||
// Re-encode the full 46+inner buffer — should match `transfer_hex`.
|
||||
let regen = template.encode(&inner);
|
||||
assert_eq!(
|
||||
regen, transfer,
|
||||
"re-encoded transfer body diverged from canonical hex"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "mxaccess-compat"
|
||||
description = "LMXProxyServer-shaped Rust facade on top of `mxaccess::Session`. Optional / post-V1."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mxaccess = { path = "../mxaccess" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
//! `mxaccess-compat` — `LMXProxyServer`-shaped methods on top of `mxaccess::Session`.
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M6 — see `design/60-roadmap.md`.
|
||||
//! The compat surface is Rust-shaped (Streams + async fns); a separate
|
||||
//! `mxaccess-compat-com` crate (post-V1) registers `windows-rs`-generated COM
|
||||
//! classes, per `design/70-risks-and-open-questions.md` Q4.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "mxaccess-galaxy"
|
||||
description = "Galaxy Repository SQL resolver: tag → metadata, user → identity. Resolves tag_name-form input only (see design/30-crate-topology.md)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `galaxy-resolver` (off by default in M0) pulls `tiberius`. Consumers using a
|
||||
# custom `Resolver` impl leave it off and avoid pulling TDS / native-tls /
|
||||
# winauth. Per design/30-crate-topology.md `mxaccess-galaxy` section.
|
||||
galaxy-resolver = []
|
||||
auth-windows = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
//! `mxaccess-galaxy` — Galaxy Repository SQL resolver.
|
||||
//!
|
||||
//! M0 stub. The real resolver lands in M3 — see `design/60-roadmap.md`.
|
||||
//! Replicates the recursive CTE from
|
||||
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:209-293`
|
||||
//! (`deployed_package_chain`) against the verified table set
|
||||
//! `dbo.gobject` / `dbo.instance` / `dbo.dynamic_attribute` /
|
||||
//! `dbo.attribute_definition` / `dbo.primitive_instance` / `dbo.package`.
|
||||
//!
|
||||
//! **Resolver input contract**: `tag_name`-form only (e.g. `DelmiaReceiver_001`),
|
||||
//! not `contained_name`-form (e.g. `TestMachine_001.DelmiaReceiver`). See
|
||||
//! `wwtools/grdb/README.md` for the asymmetry.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "mxaccess-nmx"
|
||||
description = "INmxService2 client + raw NMX session façade. Exposes a Resolver trait so consumers can plug in any tag-handle resolver."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
||||
mxaccess-callback = { path = "../mxaccess-callback" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,18 @@
|
||||
//! `mxaccess-nmx` — `INmxService2` client + raw NMX session façade.
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M3 — see `design/60-roadmap.md`.
|
||||
//!
|
||||
//! Opnums (verified against `src/MxNativeClient/NmxComContracts.cs:55-73`,
|
||||
//! and on the wire — sequential because `INmxService2 : INmxService` continues
|
||||
//! the same vtable; see `design/40-protocol-invariants.md`):
|
||||
//! - `3` RegisterEngine
|
||||
//! - `4` UnRegisterEngine
|
||||
//! - `5` Connect
|
||||
//! - `6` TransferData
|
||||
//! - `7` AddSubscriberEngine
|
||||
//! - `8` RemoveSubscriberEngine
|
||||
//! - `9` SetHeartbeatSendInterval
|
||||
//! - `10` RegisterEngine2
|
||||
//! - `11` GetPartnerVersion
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "mxaccess-rpc"
|
||||
description = "DCE/RPC PDU codec + NTLMv2 + OBJREF + OXID resolution + RemQI for the NMX transport."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
windows-com = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,10 @@
|
||||
//! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface.
|
||||
//!
|
||||
//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`.
|
||||
//!
|
||||
//! Internal `unsafe` is permitted only for `windows-rs` COM activation paths
|
||||
//! (per `design/00-overview.md` principle 3); all such calls must be wrapped
|
||||
//! in safe abstractions at the crate boundary.
|
||||
|
||||
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
|
||||
// windows-rs COM calls). Public API stays safe.
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "mxaccess"
|
||||
description = "Async Tokio façade for AVEVA / Wonderware MXAccess. Exposes Session, Subscription, Transport trait, and the public Error model."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Transport feature gates land in M2-M5.
|
||||
nmx = []
|
||||
asb = []
|
||||
metrics = []
|
||||
serde = ["mxaccess-codec/serde"]
|
||||
# `live` gates integration tests that hit a running AVEVA install. Driven by
|
||||
# the `MX_LIVE` env var via `tools/Setup-LiveProbeEnv.ps1`.
|
||||
live = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `asb-subscribe` — subscribe via the ASB transport.
|
||||
//!
|
||||
//! M0 stub. See `design/60-roadmap.md` M5.
|
||||
|
||||
fn main() {
|
||||
eprintln!("asb-subscribe: stubbed (M0). See design/60-roadmap.md M5.");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `connect-write-read` — connect, write a value, read it back.
|
||||
//!
|
||||
//! M0 stub. Filled in as M4 lands. See `design/60-roadmap.md` M4 DoD.
|
||||
|
||||
fn main() {
|
||||
eprintln!("connect-write-read: stubbed (M0). See design/60-roadmap.md M4.");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! `multi-tag` — `subscribe_many` over several tags. Non-atomic per-tag
|
||||
//! advise loop matching `MxNativeSession.SubscribeAsync` (`MxNativeSession.cs:250-270`).
|
||||
//!
|
||||
//! M0 stub.
|
||||
|
||||
fn main() {
|
||||
eprintln!("multi-tag: stubbed (M0). See design/60-roadmap.md M4.");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! `recovery` — caller-driven `Session::recover_connection(policy)` after
|
||||
//! heartbeat-loss.
|
||||
//!
|
||||
//! Recovery is opt-in, not automatic — see `design/20-async-layer.md` and
|
||||
//! `MxNativeSession.cs:383-440`. In-flight calls during recovery fail.
|
||||
//! M0 stub.
|
||||
|
||||
fn main() {
|
||||
eprintln!("recovery: stubbed (M0). See design/60-roadmap.md M4.");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! `secured-write` — Verified Write demonstrations.
|
||||
//!
|
||||
//! `write_secured` (no timestamp) and `write_secured_at` (timestamped), each
|
||||
//! taking `(current_user_id, verifier_user_id)`. Demonstrates both the
|
||||
//! single-user path (`current == verifier`) and the two-person verification
|
||||
//! path. Per `wwtools/mxaccesscli/`, the LMX `WriteSecured` always takes two
|
||||
//! ids — single-user is just same-id-twice, not a separate API. M0 stub.
|
||||
|
||||
fn main() {
|
||||
eprintln!("secured-write: stubbed (M0). See design/60-roadmap.md M4.");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! `subscribe-buffered` — buffered subscription with delivery cadence.
|
||||
//!
|
||||
//! Per `wwtools/mxaccesscli/docs/api-notes.md:138-140`, "buffered" is a
|
||||
//! delivery-cadence knob (`SetBufferedUpdateInterval`), not multi-sample
|
||||
//! payload bundling. M0 stub.
|
||||
|
||||
fn main() {
|
||||
eprintln!("subscribe-buffered: stubbed (M0). See design/60-roadmap.md M6.");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `subscribe` — subscribe to a single tag and stream `DataChange` events.
|
||||
//!
|
||||
//! M0 stub.
|
||||
|
||||
fn main() {
|
||||
eprintln!("subscribe: stubbed (M0). See design/60-roadmap.md M4.");
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! `mxaccess` — async Tokio façade for AVEVA / Wonderware MXAccess.
|
||||
//!
|
||||
//! Public API surface: `Session`, `Subscription`, `DataChange`, `Transport`
|
||||
//! trait, `Error` taxonomy. Two transports plug into the same trait
|
||||
//! (`NmxTransport`, `AsbTransport`) so M5 (ASB) can develop in parallel with
|
||||
//! M3/M4 (NMX) — see `design/60-roadmap.md` sequencing notes.
|
||||
//!
|
||||
//! M0 stub. Everything `todo!()`s. Real implementation lands across M3
|
||||
//! (`mxaccess-nmx` wiring), M4 (NMX façade complete), and M5 (ASB transport).
|
||||
//! See `design/20-async-layer.md` for the full API specification.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
pub use mxaccess_codec::{
|
||||
MxDataType, MxReferenceHandle, MxStatus, MxStatusCategory, MxStatusSource, MxValue, MxValueKind,
|
||||
};
|
||||
|
||||
// ---- Public types --------------------------------------------------------
|
||||
|
||||
/// Async session façade. Cheap clones share the inner state; drop of the last
|
||||
/// clone fires `UnregisterEngine` best-effort. For deterministic shutdown,
|
||||
/// call `Session::shutdown(timeout).await`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
_inner: Arc<SessionInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionInner;
|
||||
|
||||
/// Stream of `DataChange` items. Drop sends `UnAdvise` via the long-lived
|
||||
/// connection task (no `tokio::spawn` from `Drop`).
|
||||
#[derive(Debug)]
|
||||
pub struct Subscription;
|
||||
|
||||
/// One inbound update. Carries both `quality: u16` (legacy 16-bit OPC quality,
|
||||
/// e.g. `0xC0` = "Good") and `status: MxStatus` (the richer category model).
|
||||
/// Both are surfaced because real MxAccess emits them as separate fields per
|
||||
/// `wwtools/mxaccesscli/docs/api-notes.md:104-105`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataChange {
|
||||
pub reference: Arc<str>,
|
||||
pub value: MxValue,
|
||||
/// Legacy 16-bit OPC quality. Distinct from `status: MxStatus`.
|
||||
pub quality: u16,
|
||||
pub timestamp: SystemTime,
|
||||
pub status: MxStatus,
|
||||
}
|
||||
|
||||
/// Buffered subscription delivery — single-sample-per-event with a configurable
|
||||
/// flush cadence. **Not** multi-sample payload bundles per
|
||||
/// `wwtools/mxaccesscli/docs/api-notes.md:138-140` (R2 verified).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufferedSubscription;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BufferedOptions {
|
||||
pub update_interval_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecurityContext {
|
||||
pub current_user_id: i32,
|
||||
pub verifier_user_id: i32,
|
||||
}
|
||||
|
||||
/// Build-time transport selection. NMX-only, ASB-only, or both.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionOptions;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportKind {
|
||||
Nmx,
|
||||
Asb,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TransportCapabilities {
|
||||
pub buffered_subscribe: bool,
|
||||
pub activate_suspend: bool,
|
||||
pub operation_complete_frame: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RecoveryPolicy;
|
||||
|
||||
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
|
||||
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
|
||||
/// event should wrap it in `Arc`.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum RecoveryEvent {
|
||||
Started {
|
||||
attempt: u32,
|
||||
},
|
||||
Failed {
|
||||
attempt: u32,
|
||||
error: Error,
|
||||
/// Whether the configured policy will retry. Mirrors
|
||||
/// `MxNativeRecoveryFailureEvent.WillRetry` (`MxNativeSession.cs:47-51`).
|
||||
will_retry: bool,
|
||||
},
|
||||
Recovered {
|
||||
attempt: u32,
|
||||
},
|
||||
}
|
||||
|
||||
// ---- Error taxonomy ------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("connection: {0}")]
|
||||
Connection(#[from] ConnectionError),
|
||||
|
||||
#[error("authentication: {0}")]
|
||||
Auth(#[from] AuthError),
|
||||
|
||||
#[error("protocol: {0}")]
|
||||
Protocol(#[from] ProtocolError),
|
||||
|
||||
#[error("configuration: {0}")]
|
||||
Configuration(#[from] ConfigError),
|
||||
|
||||
#[error("type mismatch on {reference}: expected {expected:?}, got {actual:?}")]
|
||||
TypeMismatch {
|
||||
reference: Arc<str>,
|
||||
expected: MxValueKind,
|
||||
actual: MxValueKind,
|
||||
},
|
||||
|
||||
#[error("security: {0}")]
|
||||
Security(#[from] SecurityError),
|
||||
|
||||
#[error("unsupported on {transport:?} transport: {operation}")]
|
||||
Unsupported {
|
||||
operation: Cow<'static, str>,
|
||||
transport: TransportKind,
|
||||
},
|
||||
|
||||
#[error("operation timed out after {0:?}")]
|
||||
Timeout(Duration),
|
||||
|
||||
#[error("operation cancelled")]
|
||||
Cancelled,
|
||||
|
||||
// Field is named `detected_by` (not `source`) to match the codec's
|
||||
// `MxStatus.detected_by` and to avoid thiserror's `#[source]` attribute
|
||||
// semantics (which would require `MxStatusSource: std::error::Error`).
|
||||
#[error(
|
||||
"status: success={success} category={category:?} detected_by={detected_by:?} detail={detail}"
|
||||
)]
|
||||
Status {
|
||||
success: i16,
|
||||
category: MxStatusCategory,
|
||||
detected_by: MxStatusSource,
|
||||
detail: i16,
|
||||
},
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ConnectionError {
|
||||
#[error("RPC server unavailable")]
|
||||
ServerUnavailable,
|
||||
#[error("callback proxy/stub not registered (REGDB_E_CLASSNOTREG)")]
|
||||
CallbackProxyMissing,
|
||||
#[error("engine not registered (UninitializedObject / ERROR_INVALID_STATE)")]
|
||||
EngineNotRegistered,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum AuthError {
|
||||
#[error("NTLM rejected: {reason}")]
|
||||
Ntlm { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProtocolError {
|
||||
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
|
||||
Decode {
|
||||
offset: usize,
|
||||
reason: &'static str,
|
||||
buffer_len: usize,
|
||||
},
|
||||
#[error("inner length {declared} does not match body length {actual}")]
|
||||
InnerLengthMismatch { declared: i32, actual: usize },
|
||||
#[error("unexpected opcode {0:#x}")]
|
||||
UnexpectedOpcode(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ConfigError {
|
||||
#[error("invalid argument: {detail}")]
|
||||
InvalidArgument { detail: String },
|
||||
#[error("galaxy resolver: {reason}")]
|
||||
Galaxy { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum SecurityError {
|
||||
#[error("callback OBJREF rejected (HRESULT 0x8001011D)")]
|
||||
CallbackObjRefRejected,
|
||||
#[error("verifier user token required for secured write")]
|
||||
VerifierRequired,
|
||||
}
|
||||
|
||||
// ---- Transport trait -----------------------------------------------------
|
||||
|
||||
/// Generic-only trait — `dyn Transport` is intentionally unsupported (see
|
||||
/// design/20-async-layer.md L53 fix). Consumers parameterise on `<T: Transport>`.
|
||||
pub trait Transport: Send + Sync + 'static {
|
||||
fn capabilities(&self) -> TransportCapabilities;
|
||||
fn kind(&self) -> TransportKind;
|
||||
}
|
||||
|
||||
// ---- Session API surface (stubs) -----------------------------------------
|
||||
|
||||
impl Session {
|
||||
pub async fn connect(_options: ConnectionOptions) -> Result<Self, Error> {
|
||||
// M3+
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::connect"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write(&self, _reference: &str, _value: MxValue) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write_with_completion(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_client_token: u32,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_with_completion"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write_with_timestamp(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_timestamp: SystemTime,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_with_timestamp"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verified Write — always two-id per `wwtools/mxaccesscli/`. Single-user
|
||||
/// secured writes pass `current_user_id == verifier_user_id`.
|
||||
pub async fn write_secured(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_security: SecurityContext,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_secured"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write_secured_at(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_timestamp: SystemTime,
|
||||
_security: SecurityContext,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_secured_at"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read-as-subscribe per `MxNativeSession.ReadAsync` — requires a positive
|
||||
/// timeout, drop guarantees `UnAdvise`.
|
||||
pub async fn read(&self, _reference: &str, _timeout: Duration) -> Result<DataChange, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::read"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe(&self, _reference: &str) -> Result<Subscription, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::subscribe"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe_many(&self, _references: &[&str]) -> Result<Subscription, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::subscribe_many"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe_buffered(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_options: BufferedOptions,
|
||||
) -> Result<Subscription, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::subscribe_buffered"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn recover_connection(&self, _policy: RecoveryPolicy) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::recover_connection"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Orderly shutdown — flushes `UnAdvise` for every live subscription,
|
||||
/// then `UnregisterEngine`. Recommended exit path for production code.
|
||||
pub async fn shutdown(self, _timeout: Duration) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::shutdown"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.85"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
Reference in New Issue
Block a user