[M3] mxaccess-nmx: high-level write/advise/un_advise wrappers (resolves F13)
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) <noreply@anthropic.com>
This commit is contained in:
+3
-5
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<i32, NmxClientError> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user