From d59ce3571c3186bcf96bc12c02b1f0279c30126a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 08:45:03 -0400 Subject: [PATCH] [M3] mxaccess-nmx: high-level write/advise/un_advise wrappers (resolves F13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven new high-level methods on NmxClient (port of cs:303-466). Each takes a GalaxyTagMetadata + typed WriteValue (re-exported from mxaccess-codec), builds the inner NMX body, wraps in NmxTransferEnvelope, and dispatches via the existing transfer_data opnum. Methods landed - write (cs:303-324) - write2 (cs:326-349, with explicit FILETIME timestamp) - write_secured2 (cs:351-380, dual user tokens via secured_write::resolve_observed_user_token; single-user secured = same id) - advise_supervisory (cs:382-399, ItemControl envelope) - send_observed_pre_advise_metadata (cs:401-420, hardcoded target platform/engine = (1, 1) per the .NET reference) - register_reference (cs:422-441, accepts caller-built NmxReferenceRegistrationMessage) - un_advise (cs:443-466, deliberately uses NmxTransferMessageKind::Write per cs:457 — the .NET reference's divergence from AdviseSupervisory's ItemControl envelope, preserved verbatim per CLAUDE.md unknown-bytes rule) Internal encode_*_transfer_body helpers extracted as pub(crate) fn for testability — mirrors the .NET reference's `internal static` shape. NmxClientError gained two new variants: Codec(CodecError) for metadata->reference-handle and value-encode failures, and UnsupportedDataType for the kind-resolution path. Cargo.toml: added mxaccess-galaxy as a dep on mxaccess-nmx. design/followups.md: F13 moved to Resolved. Test count delta: 459 -> 468 (+9 in mxaccess-nmx; 8 -> 17). Tests cover each encode helper standalone (envelope-kind + length checks) plus real-socket round-trip tests for write / advise_supervisory / send_observed_pre_advise_metadata. All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 8 +- rust/crates/mxaccess-nmx/Cargo.toml | 1 + rust/crates/mxaccess-nmx/src/client.rs | 721 ++++++++++++++++++++++++- 3 files changed, 707 insertions(+), 23 deletions(-) diff --git a/design/followups.md b/design/followups.md index 1a3a703..580b7d1 100644 --- a/design/followups.md +++ b/design/followups.md @@ -66,11 +66,6 @@ move to `## Resolved` with a date + commit hash. **Why deferred:** `tiberius` is the recommended Rust SQL Server client; pulling it as a non-default dep means the `mxaccess-galaxy` crate keeps a slim default footprint (consumers can plug their own `Resolver` / `UserResolver` impl without dragging in TDS / native-tls / winauth). The actual `GalaxyRepositoryTagResolver` and `GalaxyRepositoryUserResolver` impls are short — they just bind the canonical SQL constants in `crate::sql` (`RESOLVE_SQL`, `BROWSE_SQL`, `USER_BY_GUID_SQL`, `USER_BY_NAME_SQL`) and translate `tiberius::Row` → typed `GalaxyTagMetadata` / `GalaxyUserProfile`. **Resolves when:** A `tiberius`-backed module lands behind the existing `galaxy-resolver` Cargo feature flag in `mxaccess-galaxy/Cargo.toml`. Live-probe gating: needs a Galaxy DB to verify against (`MX_GALAXY_DB` env var, populated by `tools/Setup-LiveProbeEnv.ps1`). The pure-Rust foundation (data types, parser, trait, SQL strings) is already in place — this is "fill in the backend" rather than "design the surface." -### F13 — `NmxClient` high-level write/advise/subscribe wrappers -**Severity:** P1 -**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs` -**Why deferred:** The .NET `Write`, `Write2`, `WriteSecured2`, `AdviseSupervisory`, `SendObservedPreAdviseMetadata`, `RegisterReference`, and `UnAdvise` methods (`ManagedNmxService2Client.cs:303-466`) are short — each builds an inner NMX message via `mxaccess-codec` (`NmxWriteMessage`, `NmxItemControlMessage`, `NmxSecuredWrite2Message`, `NmxMetadataQueryMessage`, `NmxReferenceRegistrationMessage`), wraps it in an `NmxTransferEnvelope`, then calls `TransferData(...)`. They depend on `GalaxyTagMetadata` from M3 stream A (the Galaxy SQL resolver) for `PlatformId` / `EngineId` / `ToReferenceHandle(galaxyId)`. -**Resolves when:** M3 stream A (`mxaccess-galaxy`) lands `GalaxyTagMetadata` (or an equivalent type) and `MxReferenceHandle` from `mxaccess-codec` is wired to it. At that point ~120 lines of wrappers go into `NmxClient` that delegate to the existing `transfer_data` opnum. ## Resolved @@ -80,5 +75,8 @@ move to `## Resolved` with a date + commit hash. ### F8 — `RpcError` is duplicated across `objref` and `pdu` modules **Resolved:** 2026-05-05 in this iteration's commit. `RpcError` was hoisted into the new shared `crate::error::RpcError` module as a single union of all wave 1 variants plus a generic `Decode { offset, reason: &'static str, buffer_len }` variant for the wave 2 ORPC parsers' one-off failures. `objref` and `pdu` re-export from there; M2 wave 2's `orpc`, `object_exporter`, and `rem_unknown` use it directly. +### F13 — `NmxClient` high-level write/advise/subscribe wrappers +**Resolved:** 2026-05-05. All seven wrappers landed in `crates/mxaccess-nmx/src/client.rs`: `write`, `write2`, `write_secured2`, `advise_supervisory`, `send_observed_pre_advise_metadata`, `register_reference`, `un_advise`. Each takes a `GalaxyTagMetadata` + a typed `WriteValue` (re-exported from `mxaccess-codec`), builds the inner NMX body via `mxaccess-codec` (`write_message::encode` / `encode_timestamped` / `secured_write::encode` / `NmxItemControlMessage` / `NmxMetadataQueryMessage` / `NmxReferenceRegistrationMessage`), wraps in `NmxTransferEnvelope`, and routes through `transfer_data`. The pure-codec `encode_*_transfer_body` helpers are extracted as `pub(crate) fn` for testability, mirroring the .NET reference's `internal static` shape. `un_advise` preserves the .NET reference's quirky `NmxTransferMessageKind::Write` envelope (not `ItemControl`) per `cs:457`. + ### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods **Resolved:** 2026-05-05. Both portable methods land in `crates/mxaccess-rpc/src/object_exporter_client.rs`: `resolve_oxid_unauthenticated` (mirrors `cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity` (mirrors `cs:66-81`). Each opens a TCP connection, binds to `IObjectExporter`, calls opnum 0 with the encoded request, and decodes the response — preferring `parse_resolve_oxid_result` then falling back to `parse_resolve_oxid_failure` for short stubs. The two SSPI flavours (`ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`) wrap .NET's `System.Net.Security.SspiClientContext` and are explicitly out of scope for the Rust port — that's a permanent skip, not a deferral. diff --git a/rust/crates/mxaccess-nmx/Cargo.toml b/rust/crates/mxaccess-nmx/Cargo.toml index 1223bd6..e4470bb 100644 --- a/rust/crates/mxaccess-nmx/Cargo.toml +++ b/rust/crates/mxaccess-nmx/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true [dependencies] mxaccess-codec = { path = "../mxaccess-codec" } +mxaccess-galaxy = { path = "../mxaccess-galaxy" } mxaccess-rpc = { path = "../mxaccess-rpc" } mxaccess-callback = { path = "../mxaccess-callback" } tokio = { workspace = true } diff --git a/rust/crates/mxaccess-nmx/src/client.rs b/rust/crates/mxaccess-nmx/src/client.rs index 5c8115e..2fa9399 100644 --- a/rust/crates/mxaccess-nmx/src/client.rs +++ b/rust/crates/mxaccess-nmx/src/client.rs @@ -1,9 +1,26 @@ //! `INmxService2` async client. //! -//! Direct port of the **raw opnum surface** of -//! `src/MxNativeClient/ManagedNmxService2Client.cs` over tokio. Wraps a -//! single [`mxaccess_rpc::transport::DceRpcTcpClient`] connection and -//! dispatches the 9 procedures defined by `INmxService2` (opnums 3..11). +//! Direct port of the **raw opnum surface** + the **high-level +//! write/advise wrappers** of `src/MxNativeClient/ManagedNmxService2Client.cs` +//! over tokio. Wraps a single [`mxaccess_rpc::transport::DceRpcTcpClient`] +//! connection and exposes: +//! +//! - The 9 raw procedures defined by `INmxService2` (opnums 3..11) — +//! [`NmxClient::get_partner_version`], [`NmxClient::register_engine_2`], +//! [`NmxClient::unregister_engine`], [`NmxClient::connect_engine`], +//! [`NmxClient::add_subscriber_engine`], +//! [`NmxClient::remove_subscriber_engine`], +//! [`NmxClient::set_heartbeat_send_interval`], +//! [`NmxClient::transfer_data`]. +//! - The 7 high-level wrappers from `cs:303-466` — [`NmxClient::write`], +//! [`NmxClient::write2`], [`NmxClient::write_secured2`], +//! [`NmxClient::advise_supervisory`], +//! [`NmxClient::send_observed_pre_advise_metadata`], +//! [`NmxClient::register_reference`], [`NmxClient::un_advise`]. Each +//! takes a [`mxaccess_galaxy::GalaxyTagMetadata`] for routing +//! (`platform_id` / `engine_id` / `to_reference_handle`) and a typed +//! [`WriteValue`] re-exported from `mxaccess-codec` for the value +//! payload. //! //! ## Out of scope (deferred) //! @@ -12,31 +29,27 @@ //! the service host/port/IPID via three steps: //! `ComObjRefProvider.MarshalIUnknownObjRef`, then `ResolveOxid`, then //! `IRemUnknown::RemQueryInterface`. That dance needs `windows-rs` (the COM -//! activation) which is not yet wired — see `design/followups.md` for the -//! COM-activation follow-up. -//! -//! The high-level write/advise helpers (`Write`, `Write2`, `WriteSecured2`, -//! `AdviseSupervisory`, `SendObservedPreAdviseMetadata`, -//! `RegisterReference`, `UnAdvise` at `cs:303-466`) wrap `mxaccess-codec` -//! primitives in `NmxTransferEnvelope`. They depend on `GalaxyTagMetadata` -//! from M3 stream A (the Galaxy SQL resolver) for `PlatformId` / -//! `EngineId` / `ToReferenceHandle`. Those wrappers land in a future -//! iteration once the Galaxy resolver crate is populated. -//! -//! Callers that already have an inner transfer body bytes can still issue -//! [`NmxClient::transfer_data`] directly — it's the lowest-level write path -//! and matches `cs:159-170` exactly. +//! activation) which is not yet wired — see `design/followups.md` F12 for +//! the COM-activation follow-up. #![allow(clippy::indexing_slicing)] use std::net::SocketAddr; +use mxaccess_codec::{ + CodecError, NmxItemControlCommand, NmxItemControlMessage, NmxMetadataQueryMessage, + NmxReferenceRegistrationMessage, NmxTransferEnvelope, NmxTransferMessageKind, +}; +use mxaccess_codec::{secured_write, write_message}; +use mxaccess_galaxy::{GalaxyTagMetadata, UnsupportedDataType}; use mxaccess_rpc::guid::Guid; use mxaccess_rpc::nmx_service2_messages as svc; use mxaccess_rpc::ntlm::NtlmClientContext; use mxaccess_rpc::orpc::OrpcThis; use mxaccess_rpc::transport::{DceRpcTcpClient, TransportError}; +pub use mxaccess_codec::write_message::WriteValue; + /// Errors raised by [`NmxClient`]. Mirrors `ThrowIfFailed` /// (`ManagedNmxService2Client.cs:563-574`) and the codec-error pass-through /// from `CallForHResult`. @@ -60,6 +73,28 @@ pub enum NmxClientError { /// `transfer_data` was called with an empty body (`cs:174-177`). #[error("TransferData body cannot be empty")] EmptyTransferDataBody, + + /// One of the high-level write/advise wrappers failed to build the + /// inner NMX body — typically because the supplied + /// [`mxaccess_galaxy::GalaxyTagMetadata`] has an empty + /// `object_tag_name` / `attribute_name` (rejected by + /// `MxReferenceHandle::from_names`) or because a + /// `WriteValue::*Array` exceeds the `u16::MAX` element-count limit. + /// Mirrors the `CodecError` propagation paths inside the .NET + /// reference's `EncodeWriteTransferBody` family + /// (`ManagedNmxService2Client.cs:186-301`). + #[error("codec: {0}")] + Codec(#[from] CodecError), + + /// The metadata's `(mx_data_type, is_array)` pair has no LMX wire + /// encoding (e.g. arrays of `ElapsedTime`, scalars of + /// `ReferenceType`). Returned by [`NmxClient::resolve_write_kind`] + /// helpers when the caller asks for a kind that + /// [`mxaccess_codec::MxValueKind::for_data_type`] rejects. + /// Mirrors the `ArgumentOutOfRangeException` paths in the .NET + /// `GalaxyTagMetadata.ProjectWriteValue` (`cs:62,70`). + #[error("unsupported data type: {0}")] + UnsupportedDataType(#[from] UnsupportedDataType), } /// Generates a random correlation `Cid` for each outgoing `OrpcThis` — @@ -364,6 +399,484 @@ impl NmxClient { svc::parse_hresult_response(&response.stub_data).map_err(TransportError::from)?; Ok(parsed.hresult) } + + // ------------------------------------------------------------------ + // High-level write / advise / unadvise wrappers + // (port of ManagedNmxService2Client.cs:186-466 — resolves F13). + // + // Each wrapper builds an inner NMX message via mxaccess-codec, + // wraps it in NmxTransferEnvelope, and calls `transfer_data`. The + // return value is the LMX HRESULT verbatim (per CallForHResult at + // cs:484-489): non-zero is forwarded to the caller, not raised, so + // application-status responses can be distinguished from transport + // errors. + // ------------------------------------------------------------------ + + /// Write a value. Mirrors `Write` (`ManagedNmxService2Client.cs:303-324`). + /// + /// `value` is a typed [`WriteValue`] (re-exported from + /// `mxaccess-codec`). Use [`GalaxyTagMetadata::resolve_write_kind`] + /// to learn which variant to construct from the tag metadata. + /// + /// # Errors + /// Transport, codec, or non-zero HRESULT is forwarded as `Ok(hr)`. + #[allow(clippy::too_many_arguments)] + pub async fn write( + &mut self, + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner = encode_write_transfer_body( + local_engine_id, + tag, + value, + write_index, + client_token, + galaxy_id, + source_galaxy_id, + source_platform_id, + )?; + self.transfer_data( + i32::from(galaxy_id), + i32::from(tag.platform_id), + i32::from(tag.engine_id), + &inner, + ) + .await + } + + /// Write a value with explicit timestamp. Mirrors `Write2` + /// (`cs:326-349`). + /// + /// `timestamp_filetime` is a Windows FILETIME tick count (100-ns + /// intervals since 1601-01-01 UTC) — same encoding as `cs:248`'s + /// `DateTime.ToFileTime()`. + /// + /// # Errors + /// As for [`Self::write`]. + #[allow(clippy::too_many_arguments)] + pub async fn write2( + &mut self, + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + timestamp_filetime: i64, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner = encode_write2_transfer_body( + local_engine_id, + tag, + value, + timestamp_filetime, + write_index, + client_token, + galaxy_id, + source_galaxy_id, + source_platform_id, + )?; + self.transfer_data( + i32::from(galaxy_id), + i32::from(tag.platform_id), + i32::from(tag.engine_id), + &inner, + ) + .await + } + + /// Secured write with timestamp + dual user tokens (single-user + /// secured writes pass the same id twice). Mirrors `WriteSecured2` + /// (`cs:351-380`). + /// + /// `current_user_id` and `verifier_user_id` are `dbo.user_profile.user_profile_id` + /// values — convert to wire tokens via + /// [`mxaccess_codec::secured_write::resolve_observed_user_token`] + /// internally. `client_name` is the human-readable name of the + /// signing party (UTF-16LE NUL-terminated on the wire). + /// + /// # Errors + /// As for [`Self::write`]. + #[allow(clippy::too_many_arguments)] + pub async fn write_secured2( + &mut self, + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + timestamp_filetime: i64, + client_name: &str, + current_user_id: i32, + verifier_user_id: i32, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner = encode_write_secured2_transfer_body( + local_engine_id, + tag, + value, + timestamp_filetime, + client_name, + current_user_id, + verifier_user_id, + write_index, + client_token, + galaxy_id, + source_galaxy_id, + source_platform_id, + )?; + self.transfer_data( + i32::from(galaxy_id), + i32::from(tag.platform_id), + i32::from(tag.engine_id), + &inner, + ) + .await + } + + /// Advise (subscribe) for supervisory data. Mirrors + /// `AdviseSupervisory` (`cs:382-399`). + /// + /// `item_correlation_id` is a 16-byte caller-chosen identifier the + /// LMX server will echo back in subsequent `INmxSvcCallback` + /// frames so the consumer can correlate updates to subscriptions. + /// + /// # Errors + /// As for [`Self::write`]. + pub async fn advise_supervisory( + &mut self, + local_engine_id: i32, + tag: &GalaxyTagMetadata, + item_correlation_id: [u8; 16], + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner = encode_advise_supervisory_transfer_body( + local_engine_id, + tag, + item_correlation_id, + galaxy_id, + source_galaxy_id, + source_platform_id, + )?; + self.transfer_data( + i32::from(galaxy_id), + i32::from(tag.platform_id), + i32::from(tag.engine_id), + &inner, + ) + .await + } + + /// Send the observed pre-advise metadata frame. Mirrors + /// `SendObservedPreAdviseMetadata` (`cs:401-420`). + /// + /// Routes to platform/engine `(1, 1)` — the .NET reference hard-codes + /// `targetPlatformId: 1, targetEngineId: 1` at `cs:415`. + pub async fn send_observed_pre_advise_metadata( + &mut self, + local_engine_id: i32, + item_correlation_id: [u8; 16], + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner_body = NmxMetadataQueryMessage::encode_observed_pre_advise(item_correlation_id); + let envelope = NmxTransferEnvelope { + message_kind: NmxTransferMessageKind::Metadata, + local_engine_id, + target_galaxy_id: i32::from(galaxy_id), + target_platform_id: 1, + target_engine_id: 1, + source_galaxy_id, + source_platform_id, + ..Default::default() + }; + let transfer_body = envelope.encode_with_inner(&inner_body); + self.transfer_data(i32::from(galaxy_id), 1, 1, &transfer_body) + .await + } + + /// Register a reference. Mirrors `RegisterReference` (`cs:422-441`). + /// + /// `route_tag` provides the `platform_id` / `engine_id` to route the + /// envelope to. `message` is caller-built — typically constructed + /// via the codec's `NmxReferenceRegistrationMessage` builders. + pub async fn register_reference( + &mut self, + local_engine_id: i32, + route_tag: &GalaxyTagMetadata, + message: &NmxReferenceRegistrationMessage, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner_body = message.encode(); + let envelope = NmxTransferEnvelope { + message_kind: NmxTransferMessageKind::ItemControl, + local_engine_id, + target_galaxy_id: i32::from(galaxy_id), + target_platform_id: i32::from(route_tag.platform_id), + target_engine_id: i32::from(route_tag.engine_id), + source_galaxy_id, + source_platform_id, + ..Default::default() + }; + let transfer_body = envelope.encode_with_inner(&inner_body); + self.transfer_data( + i32::from(galaxy_id), + i32::from(route_tag.platform_id), + i32::from(route_tag.engine_id), + &transfer_body, + ) + .await + } + + /// Unadvise (unsubscribe). Mirrors `UnAdvise` (`cs:443-466`). + /// + /// Note the `.NET` reference uses `NmxTransferMessageKind.Write` + /// (not `ItemControl`) for the envelope on the unadvise path + /// (`cs:457`). The Rust port matches that exactly per CLAUDE.md + /// "preserve unknown bytes" — this is the .NET reference's choice, + /// not a generic NMX rule. + pub async fn un_advise( + &mut self, + local_engine_id: i32, + tag: &GalaxyTagMetadata, + item_correlation_id: [u8; 16], + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, + ) -> Result { + let inner = encode_un_advise_transfer_body( + local_engine_id, + tag, + item_correlation_id, + galaxy_id, + source_galaxy_id, + source_platform_id, + )?; + self.transfer_data( + i32::from(galaxy_id), + i32::from(tag.platform_id), + i32::from(tag.engine_id), + &inner, + ) + .await + } +} + +// ------------------------------------------------------------------ +// Pure-codec helpers — extracted for testability and to mirror the +// .NET reference's `internal static` `Encode*TransferBody` methods at +// `ManagedNmxService2Client.cs:186-301`. +// ------------------------------------------------------------------ + +/// Mirrors `EncodeWriteTransferBody` (`cs:186-212`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn encode_write_transfer_body( + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, +) -> Result, NmxClientError> { + let handle = tag.to_reference_handle(galaxy_id)?; + let inner = write_message::encode(&handle, value, write_index, client_token)?; + Ok(envelope_for( + NmxTransferMessageKind::Write, + local_engine_id, + galaxy_id, + tag, + source_galaxy_id, + source_platform_id, + ) + .encode_with_inner(&inner)) +} + +/// Mirrors `EncodeWrite2TransferBody` (`cs:214-242`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn encode_write2_transfer_body( + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + timestamp_filetime: i64, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, +) -> Result, NmxClientError> { + let handle = tag.to_reference_handle(galaxy_id)?; + let inner = write_message::encode_timestamped( + &handle, + value, + timestamp_filetime, + write_index, + client_token, + )?; + Ok(envelope_for( + NmxTransferMessageKind::Write, + local_engine_id, + galaxy_id, + tag, + source_galaxy_id, + source_platform_id, + ) + .encode_with_inner(&inner)) +} + +/// Mirrors `EncodeWriteSecured2TransferBody` (`cs:244-278`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn encode_write_secured2_transfer_body( + local_engine_id: i32, + tag: &GalaxyTagMetadata, + value: &WriteValue, + timestamp_filetime: i64, + client_name: &str, + current_user_id: i32, + verifier_user_id: i32, + write_index: i32, + client_token: u32, + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, +) -> Result, NmxClientError> { + let handle = tag.to_reference_handle(galaxy_id)?; + let current_token = secured_write::resolve_observed_user_token(current_user_id); + let verifier_token = secured_write::resolve_observed_user_token(verifier_user_id); + let inner = secured_write::encode( + &handle, + value, + current_token, + verifier_token, + client_name, + timestamp_filetime, + write_index, + client_token, + )?; + Ok(envelope_for( + NmxTransferMessageKind::Write, + local_engine_id, + galaxy_id, + tag, + source_galaxy_id, + source_platform_id, + ) + .encode_with_inner(&inner)) +} + +/// Mirrors `EncodeAdviseSupervisoryTransferBody` (`cs:280-301`). +pub(crate) fn encode_advise_supervisory_transfer_body( + local_engine_id: i32, + tag: &GalaxyTagMetadata, + item_correlation_id: [u8; 16], + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, +) -> Result, NmxClientError> { + let handle = tag.to_reference_handle(galaxy_id)?; + let inner = NmxItemControlMessage::from_reference_handle_fields( + NmxItemControlCommand::AdviseSupervisory, + item_correlation_id, + handle.object_id, + handle.object_signature(), + handle.primitive_id, + handle.attribute_id, + handle.property_id, + handle.attribute_signature(), + handle.attribute_index, + // `tail` defaults to `DEFAULT_TAIL = 3` per + // `mxaccess_codec::item_control` doc / `cs:88`. + 3, + ) + .encode(); + Ok(envelope_for( + NmxTransferMessageKind::ItemControl, + local_engine_id, + galaxy_id, + tag, + source_galaxy_id, + source_platform_id, + ) + .encode_with_inner(&inner)) +} + +/// Mirrors the `UnAdvise` body builder embedded in `cs:451-465`. Note +/// this uses [`NmxTransferMessageKind::Write`] for the envelope, not +/// `ItemControl` (cs:457) — same divergence from the AdviseSupervisory +/// envelope as the .NET reference. +pub(crate) fn encode_un_advise_transfer_body( + local_engine_id: i32, + tag: &GalaxyTagMetadata, + item_correlation_id: [u8; 16], + galaxy_id: u8, + source_galaxy_id: i32, + source_platform_id: i32, +) -> Result, NmxClientError> { + let handle = tag.to_reference_handle(galaxy_id)?; + let inner = NmxItemControlMessage::from_reference_handle_fields( + NmxItemControlCommand::UnAdvise, + item_correlation_id, + handle.object_id, + handle.object_signature(), + handle.primitive_id, + handle.attribute_id, + handle.property_id, + handle.attribute_signature(), + handle.attribute_index, + 3, + ) + .encode(); + Ok(envelope_for( + // Mirrors `cs:457` — the .NET reference uses `Write` here, not + // `ItemControl`. Preserved verbatim per CLAUDE.md. + NmxTransferMessageKind::Write, + local_engine_id, + galaxy_id, + tag, + source_galaxy_id, + source_platform_id, + ) + .encode_with_inner(&inner)) +} + +/// Build the `NmxTransferEnvelope` shared by every wrapper. Mirrors the +/// `NmxTransferEnvelope.Encode(...)` parameter assembly the .NET +/// reference repeats at `cs:203-211, 233-241, 269-277, 292-300, 410-418, +/// 431-439, 456-464`. +fn envelope_for( + message_kind: NmxTransferMessageKind, + local_engine_id: i32, + galaxy_id: u8, + tag: &GalaxyTagMetadata, + source_galaxy_id: i32, + source_platform_id: i32, +) -> NmxTransferEnvelope { + NmxTransferEnvelope { + message_kind, + local_engine_id, + target_galaxy_id: i32::from(galaxy_id), + target_platform_id: i32::from(tag.platform_id), + target_engine_id: i32::from(tag.engine_id), + source_galaxy_id, + source_platform_id, + ..Default::default() + } } #[cfg(test)] @@ -572,4 +1085,176 @@ mod tests { // verify the Guid type itself. assert_eq!(g.as_bytes()[0], 0xAB); } + + // ------------------------------------------------------------------ + // F13 wrapper tests — exercise the encode_*_transfer_body helpers + // standalone (no network), then a representative round-trip that + // routes the resulting envelope through the unauthenticated server. + // ------------------------------------------------------------------ + + fn sample_metadata() -> GalaxyTagMetadata { + GalaxyTagMetadata { + object_tag_name: "TestObj".to_string(), + attribute_name: "TestInt".to_string(), + primitive_name: None, + platform_id: 5, + engine_id: 7, + object_id: 42, + primitive_id: -1, + attribute_id: 99, + property_id: 10, + mx_data_type: 2, // Integer + is_array: false, + security_classification: 0, + attribute_source: "dynamic".to_string(), + } + } + + #[test] + fn encode_write_transfer_body_produces_46_byte_envelope_plus_inner() { + let meta = sample_metadata(); + let body = + encode_write_transfer_body(42, &meta, &WriteValue::Int32(123), 1, 0, 1, 1, 1).unwrap(); + // 46-byte envelope header + non-empty inner. + assert!(body.len() > NmxTransferEnvelope::HEADER_LEN); + // Envelope target_galaxy_id=1, target_platform_id=5, target_engine_id=7. + let env = NmxTransferEnvelope::parse(&body).unwrap(); + assert_eq!(env.message_kind, NmxTransferMessageKind::Write); + assert_eq!(env.target_galaxy_id, 1); + assert_eq!(env.target_platform_id, 5); + assert_eq!(env.target_engine_id, 7); + assert_eq!(env.local_engine_id, 42); + } + + #[test] + fn encode_write2_transfer_body_carries_timestamp() { + let meta = sample_metadata(); + let body = encode_write2_transfer_body( + 10, + &meta, + &WriteValue::Float64(2.5_f64), + 0x1234_5678_ABCD_EF00, + 5, + 0xCAFE_BABE, + 1, + 1, + 1, + ) + .unwrap(); + let env = NmxTransferEnvelope::parse(&body).unwrap(); + assert_eq!(env.message_kind, NmxTransferMessageKind::Write); + // Inner body contains the timestamp; we just confirm it exists + // (codec parity tests in mxaccess-codec verify the byte layout). + assert!(body.len() > NmxTransferEnvelope::HEADER_LEN); + } + + #[test] + fn encode_advise_supervisory_uses_item_control_kind() { + let meta = sample_metadata(); + let body = encode_advise_supervisory_transfer_body(42, &meta, [0xAA; 16], 1, 1, 1).unwrap(); + let env = NmxTransferEnvelope::parse(&body).unwrap(); + assert_eq!(env.message_kind, NmxTransferMessageKind::ItemControl); + } + + #[test] + fn encode_un_advise_uses_write_kind_per_dotnet_reference() { + // cs:457 — the .NET reference uses Write (not ItemControl) for + // the unadvise envelope. Preserved verbatim per CLAUDE.md. + let meta = sample_metadata(); + let body = encode_un_advise_transfer_body(42, &meta, [0xBB; 16], 1, 1, 1).unwrap(); + let env = NmxTransferEnvelope::parse(&body).unwrap(); + assert_eq!(env.message_kind, NmxTransferMessageKind::Write); + } + + #[test] + fn encode_write_secured2_includes_dual_user_tokens() { + let meta = sample_metadata(); + let body = encode_write_secured2_transfer_body( + 42, + &meta, + &WriteValue::Int32(99), + 0x1234_5678_ABCD_EF00, + "dohejw01", + 7, + 7, // single-user secured: same id twice + 1, + 0, + 1, + 1, + 1, + ) + .unwrap(); + let env = NmxTransferEnvelope::parse(&body).unwrap(); + assert_eq!(env.message_kind, NmxTransferMessageKind::Write); + // Inner body must be longer than a plain Write2 because of the + // 16+16 user tokens + UTF-16LE client name. + let plain_write2 = encode_write2_transfer_body( + 42, + &meta, + &WriteValue::Int32(99), + 0x1234_5678_ABCD_EF00, + 1, + 0, + 1, + 1, + 1, + ) + .unwrap(); + assert!(body.len() > plain_write2.len()); + } + + #[test] + fn encode_wrapper_propagates_invalid_name_as_codec_error() { + let mut meta = sample_metadata(); + meta.object_tag_name = " ".to_string(); + let err = encode_write_transfer_body(42, &meta, &WriteValue::Int32(0), 1, 0, 1, 1, 1) + .unwrap_err(); + assert!(matches!(err, NmxClientError::Codec(_))); + } + + #[tokio::test] + async fn write_round_trip_via_server() { + let responses = vec![(0i32, Vec::new())]; + let (addr, handle) = unauthenticated_server(responses).await; + let mut client = connect_unauth(addr, Guid::new([0xCC; 16])).await.unwrap(); + + let meta = sample_metadata(); + let hr = client + .write(42, &meta, &WriteValue::Int32(1234), 1, 0, 1, 1, 1) + .await + .unwrap(); + assert_eq!(hr, 0); + handle.await.unwrap(); + } + + #[tokio::test] + async fn advise_supervisory_round_trip_via_server() { + let responses = vec![(0i32, Vec::new())]; + let (addr, handle) = unauthenticated_server(responses).await; + let mut client = connect_unauth(addr, Guid::new([0xCC; 16])).await.unwrap(); + + let meta = sample_metadata(); + let hr = client + .advise_supervisory(42, &meta, [0xDD; 16], 1, 1, 1) + .await + .unwrap(); + assert_eq!(hr, 0); + handle.await.unwrap(); + } + + #[tokio::test] + async fn send_observed_pre_advise_metadata_routes_to_platform_engine_one() { + // The .NET reference hardcodes target (1, 1); verify the wrapper + // calls transfer_data with that routing. + let responses = vec![(0i32, Vec::new())]; + let (addr, handle) = unauthenticated_server(responses).await; + let mut client = connect_unauth(addr, Guid::new([0xCC; 16])).await.unwrap(); + + let hr = client + .send_observed_pre_advise_metadata(42, [0xEE; 16], 1, 1, 1) + .await + .unwrap(); + assert_eq!(hr, 0); + handle.await.unwrap(); + } }