[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:
Joseph Doherty
2026-05-05 08:45:03 -04:00
parent 68aa2e30ab
commit d59ce3571c
3 changed files with 707 additions and 23 deletions
+3 -5
View File
@@ -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.
+1
View File
@@ -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 }
+703 -18
View File
@@ -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();
}
}