diff --git a/design/followups.md b/design/followups.md index 60f78fc..e849353 100644 --- a/design/followups.md +++ b/design/followups.md @@ -6,6 +6,48 @@ move to `## Resolved` with a date + commit hash. ## Open +### F18 — M5 plan of attack (ASB transport, parallel-safe sub-streams) +**Severity:** P0 — milestone driver, blocks ASB consumers + V1 release +**Source:** `design/dependencies.md:73-89` + `design/60-roadmap.md:84-91` + `design/70-risks-and-open-questions.md:5-25` (R1 estimates ~3000 LoC for framing+encoders). + +**Scope.** Build the ASB data-plane end-to-end: +- `mxaccess-asb-nettcp` — `[MS-NMF]` framing + `[MC-NBFX]` binary-XML node codec + `[MC-NBFS]` static dictionary table + DH/HMAC/AES authentication crypto. +- `mxaccess-asb` — `IASBIDataV2` client (Connect, RegisterItems, Read, Write, PublishWriteComplete, CreateSubscription, AddMonitoredItems, Publish, Disconnect) + `SecretProvider` trait + DPAPI default impl + ASB Variant codec port (currently a stub at `crates/mxaccess-codec/src/lib.rs:74,77,80`). +- `mxaccess::Session` over an `AsbTransport` impl; capabilities surface ASB limits (no `subscribe_buffered`, no Activate/Suspend, no OperationComplete outside the proven write-completion frame — see `design/60-roadmap.md:88`). +- `examples/asb-subscribe.rs` exercises the whole path against a live ASB endpoint with parity vs `dotnet run --project src\MxAsbClient.Probe`. + +**Sub-stream breakdown** (matches `design/dependencies.md:78-89`). Each sub-stream is a separate followup so it can be claimed by a separate agent in a worktree without merge conflict: + +| Sub-followup | Stream | Owns | Depends on | +|---|---|---|---| +| F19 | (workspace prereq) | Add the M5 dep set to `rust/Cargo.toml` workspace deps + per-crate `Cargo.toml`: `aes`, `hmac`, `md-5`, `sha1`, `sha2`, `pbkdf2`, `flate2`, `rand`, `crypto-bigint` (constant-time DH per `review.md` MAJOR), `quick-xml`, `tokio-util`. Pinned to the `digest 0.11`/`cipher 0.5` generation per `design/30-crate-topology.md:251-289`. Sequential prereq for the others. | M0 | +| F20 | A — MS-NMF framing | `mxaccess-asb-nettcp::nmf` — preamble (`0x00 ver=1 mode=2 via=encoded-string`), preamble-ack, sized-envelope (`0x06 var-int len bytes`), end (`0x07`), fault (`0x08`), upgrade-request, known-encoding via lookup. Reliable-session ack handling. Round-trip against `analysis/proxy/mxasbclient-register-message.txt` and `mxasbclient-probe-stage*.txt` byte traces. | F19 | +| F21 | B — MC-NBFX | `mxaccess-asb-nettcp::nbfx` — record types (`0x40` ShortElement, `0x41` Element, `0x44` ShortDictionaryAttribute, `0x04` PrefixDictionary*A-Z, `0x84` BoolText, `0x88` Int32Text, `0x86` BoolFalseText, etc., per `[MC-NBFX]` §2.2). Length-prefixed strings (var-int 7-bit groups). Read/write over `bytes::BytesMut`. | F19 | +| F22 | C — MC-NBFS | `mxaccess-asb-nettcp::nbfs` — the static dictionary table. SOAP/WS-Addressing tokens + `IASBIDataV2`-action strings used by the operation set (`http://ASB.IDataV2:registerItemsIn`, `:readIn`, `:writeIn`, `:createSubscriptionIn`, `:publishIn`, etc., see `src/MxAsbClient/AsbContracts.cs:14-58`). Hand-rolled from the proven action set; the full WCF dictionary is much larger but only the action subset is on the wire. | F19 | +| F23 | D — Auth crypto | `mxaccess-asb-nettcp::auth` — port `src/MxAsbClient/AsbSystemAuthenticator.cs` (167 LoC): DH key exchange with `crypto-bigint` constant-time `mod_exp` (review.md MAJOR finding — .NET `BigInteger.ModPow` is **not** constant-time and the DH private exponent is long-lived per `cs:153-166`); HMAC-MD5/SHA1/SHA512 (negotiated per `AsbSolutionCryptoParameters.HashAlgorithm`); AES-128 with PBKDF2-SHA1 1000-iteration key derivation; deflate-then-encrypt `EncryptBaktun` vs raw-encrypt `EncryptApollo` distinguished by `:V2` lifetime suffix (`cs:48`); ASCII salt `"ArchestrAService"`; UTF-16LE passphrase. Plus DPAPI shared-secret read on Windows behind the existing `dpapi` feature gate, with a `SecretProvider::shared_secret(&[u8])` escape hatch for tests/CI (`design/30-crate-topology.md:150`). | F19 | +| F24 | (codec) | `mxaccess-codec::asb_variant` — fill in the stubbed `AsbVariant`, `AsbStatus`, `RuntimeValue` (`crates/mxaccess-codec/src/lib.rs:74,77,80`) per `docs/ASB-Variant-Wire-Format.md`. Decode/encode for the proven type matrix: `TypeBool`, `TypeInt32`, `TypeFloat`, `TypeDouble`, `TypeString`, `TypeDateTime`, `TypeDuration`, plus deployed array shapes (`work_remain.md:108-113`). Less-common scalars stay as raw bytes (matches .NET `DecodeVariant` fallback at `MxAsbDataClient.cs:748`). Independent of the framing/encoder work — separate crate. | M1 (envelope/status types) | +| F25 | E — IASBIDataV2 client | `mxaccess-asb::client` — top-level `AsbClient` with `connect`, `register_items`, `read`, `write`, `publish_write_complete`, `create_subscription`, `add_monitored_items`, `publish`, `disconnect`. Wires the contract → NBFX-encoded SOAP envelope → NMF-framed TCP. `ConnectedRequest::ConnectionValidator` HMAC signing per `AsbSystemAuthenticator::Sign`. Receives `Publish` callbacks via a long-lived background task (mirrors the M4 NMX `callback_router` pattern). Depends on F20+F21+F22+F23+F24. | A+B+C+D+codec | +| F26 | (session) | `mxaccess::Session` over `AsbTransport`. New transport impl alongside `NmxTransport`. Surface ASB capability flags so `subscribe_buffered`/`activate`/`suspend` return `Error::Unsupported(Capability::*)` rather than a runtime fallthrough. Update `examples/asb-subscribe.rs` to drive the path end-to-end. Live-probe DoD: round-trip parity with `dotnet run --project src\MxAsbClient.Probe`. | F25 | + +**Parallel-safety analysis.** +- F19 (workspace deps) is the **single sequential bottleneck** — F20-F25 all reference workspace deps that don't exist yet, so they cannot start in parallel until F19 lands. Tight & small (~30 lines of TOML). +- F20, F21, F22, F23, F24 are **fully parallel-safe** after F19: each owns a different module under a different crate (or different sibling module within `mxaccess-asb-nettcp`). No shared state, no cross-import — each can land in its own commit. Per `dependencies.md:88` "Peak agents in parallel: 4 in the framing/encoding wave (A+B+C+D)". +- F25 is sequential after the four framing/encoder streams + F24 land — it composes them. The .NET `MxAsbDataClient` is monolithic enough that splitting F25 across agents costs more in coordination than it saves. +- F26 is sequential after F25. +- **Cross-milestone parallelism still holds.** M5 (this whole F18-F26 cluster) runs in parallel with M3+M4 per `design/60-roadmap.md:14-17` because the `Transport` trait was lifted into M0. M4's `Session` core landed (commits `4863c6d`, `2dc091d`, `a31237d`); the F26 `AsbTransport` plugs into the same trait without re-design. + +**Risk-driven sequencing inside the parallel wave.** R1 in `design/70-risks-and-open-questions.md:9` is the project-blocker. Of the four parallel streams, F23 (auth crypto) carries the most live-probe risk (DH handshake against the live VM is the first irreversible test of the spec port) but is the smallest in LoC. F22 (NBFS) is the largest unknown — the dictionary table size is bounded only by the action subset we exercise. Recommended order *if* agents are constrained: F23 (smallest, highest-leverage) → F20 (foundational for any wire test) → F21 (encoder) → F22 (dictionary) → F24 (codec, independent). + +**Definition of done** for F18 as a whole (= M5 DoD per `design/60-roadmap.md:91`): +1. `cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt` succeeds against a live ASB endpoint. +2. Round-trip parity with `dotnet run --project src\MxAsbClient.Probe` (Frida/Wireshark diff is byte-identical for the proven type matrix). +3. The `mxaccess-asb` type matrix covers what `work_remain.md:108-113` documents as proven: scalar Boolean, Int32, Float, Double, String, DateTime, Duration plus deployed array tags. +4. `cargo build --workspace` and `cargo test --workspace` green; `cargo clippy --workspace -- -D warnings` clean. + +**Resolves when:** F19-F26 are all closed and the four DoD bullets above pass. + +**This-iteration execution slice.** Land F19 (workspace deps) sequentially first, then F23 (auth crypto port — smallest stream, fully self-contained, exercises the largest set of new deps in one place to validate the dep choice). F20/F21/F22/F24/F25/F26 stay open for follow-up iterations or parallel agent fan-out. + ### F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction) **Severity:** P2 **Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs` diff --git a/rust/crates/mxaccess/src/lib.rs b/rust/crates/mxaccess/src/lib.rs index c779c5f..3326742 100644 --- a/rust/crates/mxaccess/src/lib.rs +++ b/rust/crates/mxaccess/src/lib.rs @@ -1,13 +1,21 @@ //! `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. +//! 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. +//! ## Status +//! +//! M4 (NMX façade) is feature-complete: connect → write → read → +//! subscribe → unsubscribe → recovery → shutdown all work end-to-end +//! against a live AVEVA install (see `examples/connect-write-read.rs`). +//! M5 (ASB transport) lands in a future iteration; the +//! [`Session::connect`] generic constructor returns +//! [`Error::Unsupported`] until then. See `design/20-async-layer.md` +//! for the full API specification and `design/followups.md` for the +//! deferred work tracker. #![forbid(unsafe_code)] @@ -341,24 +349,60 @@ pub trait Transport: Send + Sync + 'static { fn kind(&self) -> TransportKind; } -// ---- Session API surface (stubs) ----------------------------------------- +// ---- Session API surface ------------------------------------------------- +// +// The `*_value` family in `session.rs` takes `WriteValue` (the codec's +// encoder-side variant set). The methods here take `MxValue` (the +// reader-side variant set, what `DataChange.value` carries) and shim +// to the `*_value` family via `mxvalue_to_writevalue`. Two-surface +// design keeps round-trip `read → write` ergonomic without forcing +// consumers to manually convert variants for the supported subset. +// +// Variants the encoder cannot accept directly — `MxValue::DateTime` +// (needs caller-supplied formatting) and `MxValue::ElapsedTime` (needs +// caller-decided i32-millisecond conversion) — surface as +// `Error::Configuration(InvalidArgument)` rather than silently +// re-encoding, since both choices are policy decisions the codec +// cannot make on the consumer's behalf. impl Session { + /// Generic transport-selection constructor. Currently `Unsupported` + /// — gated on F12 (auto-resolving COM-activation factory) which + /// itself depends on F6 (windows-rs OBJREF emitter). Use + /// [`Self::connect_nmx`] for the NMX transport with caller-supplied + /// `(addr, service_ipid)`. M5 will add an analogous `connect_asb` + /// for the ASB transport. pub async fn connect(_options: ConnectionOptions) -> Result { - // M3+ Err(Error::Unsupported { - operation: Cow::Borrowed("Session::connect"), + operation: Cow::Borrowed("Session::connect (use Session::connect_nmx; F12)"), 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, - }) + /// Write a value to a tag (`MxValue` overload). Delegates to + /// [`Self::write_value`] after converting `value` via + /// [`mxvalue_to_writevalue`]. + /// + /// # Errors + /// As for [`Self::write_value`], plus + /// [`Error::Configuration`] when `value` is a variant the LMX + /// encoder cannot accept directly (`MxValue::DateTime` / + /// `ElapsedTime` and their array variants — see module-level note + /// for why). + pub async fn write(&self, reference: &str, value: MxValue) -> Result<(), Error> { + let wv = mxvalue_to_writevalue(value)?; + self.write_value(reference, wv).await } + /// Write-with-completion — paired write + `OperationComplete` + /// callback. Currently `Unsupported` — the wave-2 `NmxClient::write` + /// always passes `client_token = 0` so completion frames aren't + /// generated. Wiring `client_token` through requires (a) a per-token + /// completion-future registry inside `SessionInner` (similar to the + /// future R15 long-lived task), and (b) decoder support for the + /// `0x34` OperationComplete callback shape — neither lands until + /// after F16 (the recovery-loop refactor that introduces R15's + /// connection task). pub async fn write_with_completion( &self, _reference: &str, @@ -366,25 +410,42 @@ impl Session { _client_token: u32, ) -> Result<(), Error> { Err(Error::Unsupported { - operation: Cow::Borrowed("Session::write_with_completion"), + operation: Cow::Borrowed( + "Session::write_with_completion (needs per-token registry; gated on R15)", + ), transport: TransportKind::Nmx, }) } + /// Write a timestamped value (`MxValue` overload). Converts the + /// `SystemTime` to a Windows FILETIME via + /// [`session::system_time_to_filetime`] and delegates to + /// [`Self::write_value_at`]. + /// + /// # Errors + /// As for [`Self::write_value_at`], plus + /// [`Error::Configuration`] when `value` is an unconvertible + /// `MxValue` variant or `timestamp` is out of FILETIME range. pub async fn write_with_timestamp( &self, - _reference: &str, - _value: MxValue, - _timestamp: SystemTime, + reference: &str, + value: MxValue, + timestamp: SystemTime, ) -> Result<(), Error> { - Err(Error::Unsupported { - operation: Cow::Borrowed("Session::write_with_timestamp"), - transport: TransportKind::Nmx, - }) + let wv = mxvalue_to_writevalue(value)?; + let ft = session::system_time_to_filetime(timestamp)?; + self.write_value_at(reference, wv, ft).await } - /// Verified Write — always two-id per `wwtools/mxaccesscli/`. Single-user - /// secured writes pass `current_user_id == verifier_user_id`. + /// Verified Write without an explicit timestamp. Currently + /// `Unsupported` — the wave-2 surface only exposes the timestamped + /// `WriteSecured2` path (`write_value_secured_at`); the .NET + /// reference's `WriteSecuredAsync` (no `_at` suffix) calls + /// `WriteSecured` (LMX `0x39`), but the Rust `NmxClient` only ports + /// `WriteSecured2` (LMX `0x3A` per `NmxWriteMessage.cs:215`). + /// Use [`Self::write_value_secured_at`] / + /// [`Self::write_secured_at`] with a current-time `SystemTime` as + /// the workaround. pub async fn write_secured( &self, _reference: &str, @@ -392,52 +453,142 @@ impl Session { _security: SecurityContext, ) -> Result<(), Error> { Err(Error::Unsupported { - operation: Cow::Borrowed("Session::write_secured"), + operation: Cow::Borrowed( + "Session::write_secured (no-timestamp; use write_secured_at with SystemTime::now())", + ), transport: TransportKind::Nmx, }) } + /// Verified Write with an explicit timestamp (`MxValue` overload). + /// Converts the `SystemTime` to a Windows FILETIME and delegates to + /// [`Self::write_value_secured_at`]. Single-user secured writes + /// pass `current_user_id == verifier_user_id`. + /// + /// # Errors + /// As for [`Self::write_value_secured_at`], plus + /// [`Error::Configuration`] when `value` is an unconvertible + /// `MxValue` variant or `timestamp` is out of FILETIME range. pub async fn write_secured_at( &self, - _reference: &str, - _value: MxValue, - _timestamp: SystemTime, - _security: SecurityContext, + reference: &str, + value: MxValue, + timestamp: SystemTime, + security: SecurityContext, ) -> Result<(), Error> { - Err(Error::Unsupported { - operation: Cow::Borrowed("Session::write_secured_at"), - transport: TransportKind::Nmx, - }) + let wv = mxvalue_to_writevalue(value)?; + let ft = session::system_time_to_filetime(timestamp)?; + self.write_value_secured_at(reference, wv, ft, security) + .await } + /// Subscribe to multiple tags atomically. Currently `Unsupported` + /// — the LMX wire has no atomic subscribe-many frame. Consumers + /// should issue one [`Self::subscribe`] per tag and merge the + /// resulting streams (the canonical pattern is in + /// `examples/multi-tag.rs`). Mirrors what the .NET reference does + /// at `MxNativeSession.cs:250-270`. pub async fn subscribe_many(&self, _references: &[&str]) -> Result { Err(Error::Unsupported { - operation: Cow::Borrowed("Session::subscribe_many"), + operation: Cow::Borrowed( + "Session::subscribe_many (no atomic frame on the wire; loop subscribe per tag)", + ), transport: TransportKind::Nmx, }) } + /// Buffered subscription with a delivery-cadence knob. Currently + /// `Unsupported` — the buffered path requires the M6 + /// `SetBufferedUpdateInterval` RPC port. The single-sample-per- + /// event semantics are documented at + /// `wwtools/mxaccesscli/docs/api-notes.md:138-140`. pub async fn subscribe_buffered( &self, _reference: &str, _options: BufferedOptions, ) -> Result { Err(Error::Unsupported { - operation: Cow::Borrowed("Session::subscribe_buffered"), + operation: Cow::Borrowed("Session::subscribe_buffered (M6)"), 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, - }) + /// Orderly shutdown with a wall-clock bound. Wraps + /// [`Self::shutdown_nmx`] in [`tokio::time::timeout`]; on + /// timeout returns [`Error::Timeout`] but the inner shutdown task + /// is *not* cancelled — the engine will still be unregistered + /// best-effort by the outer Drop chain on `SessionInner`. + /// + /// `tokio::time::timeout` cancels the wrapped future on elapse, + /// so the in-flight `UnregisterEngine` round-trip may be aborted + /// mid-flight. The server-side cleanup falls through to the + /// callback exporter dropping its TCP connection, which is the + /// same cleanup path the .NET reference relies on at + /// `MxNativeSession.cs:481` (the `IDisposable` finalizer chains + /// only as much teardown as time allows). + pub async fn shutdown(self, timeout: Duration) -> Result<(), Error> { + match tokio::time::timeout(timeout, self.shutdown_nmx()).await { + Ok(result) => result, + Err(_) => Err(Error::Timeout(timeout)), + } } } +/// Convert a [`MxValue`] (read-side runtime variant) into a +/// [`WriteValue`] (encoder-side variant). Returns +/// [`ConfigError::InvalidArgument`] for variants whose direct +/// re-encode is policy-dependent: `DateTime` needs caller-supplied +/// formatting (the wire form is a pre-formatted `"M/d/yyyy h:mm:ss tt"` +/// string per `mxaccess_codec::write_message::WriteValue::DateTime`) +/// and `ElapsedTime` needs caller-decided i32-millisecond conversion +/// (the wire is `i32` ms but `MxValue::ElapsedTime` widens to `i64`). +/// Array variants of those two carry the same restrictions. +fn mxvalue_to_writevalue(value: MxValue) -> Result { + use mxaccess_nmx::WriteValue; + let wv = match value { + MxValue::Boolean(b) => WriteValue::Boolean(b), + MxValue::Int32(i) => WriteValue::Int32(i), + MxValue::Float32(f) => WriteValue::Float32(f), + MxValue::Float64(f) => WriteValue::Float64(f), + MxValue::String(s) => WriteValue::String(s), + MxValue::DateTime(_) => { + return Err(Error::Configuration(ConfigError::InvalidArgument { + detail: "MxValue::DateTime carries raw FILETIME ticks; \ + construct WriteValue::DateTime(\"M/d/yyyy h:mm:ss tt\") directly" + .to_string(), + })); + } + MxValue::ElapsedTime(_) => { + return Err(Error::Configuration(ConfigError::InvalidArgument { + detail: "MxValue::ElapsedTime carries i64 ms; \ + construct WriteValue::Int32(ms) directly" + .to_string(), + })); + } + MxValue::BoolArray(v) => WriteValue::BooleanArray(v), + MxValue::Int32Array(v) => WriteValue::Int32Array(v), + MxValue::Float32Array(v) => WriteValue::Float32Array(v), + MxValue::Float64Array(v) => WriteValue::Float64Array(v), + MxValue::StringArray(v) => WriteValue::StringArray(v), + MxValue::DateTimeArray(_) => { + return Err(Error::Configuration(ConfigError::InvalidArgument { + detail: "MxValue::DateTimeArray carries raw FILETIME ticks; \ + construct WriteValue::DateTimeArray(Vec) with formatted entries" + .to_string(), + })); + } + // MxValue is #[non_exhaustive]; future variants land here without + // a compile break and surface as Unsupported until explicitly + // mapped. + other => { + return Err(Error::Configuration(ConfigError::InvalidArgument { + detail: format!("MxValue variant {other:?} has no WriteValue mapping"), + })); + } + }; + Ok(wv) +} + #[cfg(test)] #[allow( clippy::unwrap_used, @@ -565,4 +716,79 @@ mod tests { other => panic!("expected Recovered, got {other:?}"), } } + + // ---- mxvalue_to_writevalue conversion ------------------------------ + + use mxaccess_nmx::WriteValue; + + #[test] + fn mxvalue_to_writevalue_boolean_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::Boolean(true)).unwrap(); + assert_eq!(wv, WriteValue::Boolean(true)); + } + + #[test] + fn mxvalue_to_writevalue_int32_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::Int32(42)).unwrap(); + assert_eq!(wv, WriteValue::Int32(42)); + } + + #[test] + fn mxvalue_to_writevalue_float64_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::Float64(1.5)).unwrap(); + assert_eq!(wv, WriteValue::Float64(1.5)); + } + + #[test] + fn mxvalue_to_writevalue_string_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::String("hello".into())).unwrap(); + assert_eq!(wv, WriteValue::String("hello".into())); + } + + #[test] + fn mxvalue_to_writevalue_int32_array_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::Int32Array(vec![1, 2, 3])).unwrap(); + assert_eq!(wv, WriteValue::Int32Array(vec![1, 2, 3])); + } + + #[test] + fn mxvalue_to_writevalue_bool_array_round_trips() { + // Array variant rename: MxValue::BoolArray → WriteValue::BooleanArray. + let wv = mxvalue_to_writevalue(MxValue::BoolArray(vec![true, false])).unwrap(); + assert_eq!(wv, WriteValue::BooleanArray(vec![true, false])); + } + + #[test] + fn mxvalue_to_writevalue_string_array_round_trips() { + let wv = mxvalue_to_writevalue(MxValue::StringArray(vec!["a".into(), "b".into()])).unwrap(); + assert_eq!(wv, WriteValue::StringArray(vec!["a".into(), "b".into()])); + } + + #[test] + fn mxvalue_to_writevalue_datetime_rejects() { + // Pre-formatted string is required; raw FILETIME ticks are not enough. + let err = mxvalue_to_writevalue(MxValue::DateTime(132_867_600_000_000_000)).unwrap_err(); + let detail = format!("{err}"); + assert!(detail.contains("DateTime"), "got: {detail}"); + } + + #[test] + fn mxvalue_to_writevalue_elapsed_time_rejects() { + let err = mxvalue_to_writevalue(MxValue::ElapsedTime(5_000)).unwrap_err(); + let detail = format!("{err}"); + assert!(detail.contains("ElapsedTime"), "got: {detail}"); + } + + #[test] + fn mxvalue_to_writevalue_datetime_array_rejects() { + let err = mxvalue_to_writevalue(MxValue::DateTimeArray(vec![1, 2])).unwrap_err(); + let detail = format!("{err}"); + assert!(detail.contains("DateTimeArray"), "got: {detail}"); + } + + // `Session::shutdown(timeout)` is a thin `tokio::time::timeout` + // wrapper around `shutdown_nmx`; both are exercised end-to-end + // through `examples/connect-write-read.rs` (live-only). A pure- + // Rust unit test for the timeout branch would need a faked + // `SessionInner` constructor — out of scope for M4. }