[F12] mxaccess-nmx: NmxClient::create — auto-resolving COM-activation factory
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
New constructor NmxClient::create(ntlm_factory) gated on
cfg(all(windows, feature = "windows-com")). New crate feature
mxaccess-nmx/windows-com propagates to mxaccess-rpc/windows-com.
Mirrors ManagedNmxService2Client.Create() (cs:30-64) plus
ResolveService (cs:491-523).
Six-step bring-up:
1. com_objref_provider::marshal_activated_iunknown_objref(
"NmxSvc.NmxService", MarshalContext::DifferentMachine)
activates and emits the OBJREF.
2. ComObjRef::parse extracts oxid + the activated server's IUnknown
IPID.
3. resolve_oxid_with_managed_ntlm_packet_integrity against
127.0.0.1:135 (RPCSS endpoint mapper) returns the server's
(host, port) bindings + IRemUnknown IPID.
4. parse_bracketed_host_port pulls the host + port out of the
ncacn_ip_tcp binding's `host[port]` text. Uses rfind for the
rightmost brackets so FQDN forms (foo.example.com[1234])
round-trip — matches the .NET ParseBracketedHost/Port shape at
cs:540-561.
5. A fresh DceRpcTcpClient binds to IRemUnknown and calls
RemQueryInterface(iunknown_ipid, INmxService2_IID,
fresh_causality_id, public_refs=5).
6. A second fresh transport binds to INmxService2 via Self::connect.
The ntlm_factory: impl FnMut() -> NtlmClientContext closure is
invoked three times (one per bind); each NtlmClientContext is
consumed by its bind, so the factory must produce fresh contexts.
New NmxClientError variants:
- Activation(ProviderError) — only emitted with windows-com on.
- EndpointResolution { reason } — covers no ncacn_ip_tcp binding,
malformed host[port], non-zero RemQueryInterface HRESULT.
6 offline tests on parse_bracketed_host_port: FQDN host extraction,
rfind for rightmost brackets, rejection of missing '[' / missing
']' / non-numeric port / port overflow.
1 live test (#[ignore], gated on MX_LIVE + MX_TEST_USER /
MX_TEST_PASSWORD / MX_TEST_DOMAIN populated by
tools/Setup-LiveProbeEnv.ps1): round-trips the full chain against
the AVEVA install on this host. Resolved INmxService2 IPID is
non-zero — verified end-to-end.
Workspace: mxaccess-nmx 17 → 23 (+6). All other crates unchanged.
Closes F12 in design/followups.md. F6 (ComObjRefProvider port) was
the prior blocker; with both landed, the COM-activation path is
end-to-end functional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-5
@@ -218,11 +218,6 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
|
||||
**Why deferred:** `RemUnknownMessages.cs` declares the opnums (`:9-10`) but does not implement encoders/decoders. The Rust port matches that exactly per "port what is already proven."
|
||||
**Resolves when:** The .NET reference adds bodies for opnums 4 / 5 (or a captured frame establishes the on-wire shape). At that point port them into `rem_unknown.rs` alongside the existing `RemQueryInterface` codec.
|
||||
|
||||
### F12 — `NmxClient::create` (auto-resolving COM-activation factory)
|
||||
**Severity:** P1
|
||||
**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs`
|
||||
**Why deferred:** `ManagedNmxService2Client.Create()` (`ManagedNmxService2Client.cs:30-64`) auto-discovers `(host, port, service_ipid)` by activating the `NmxSvc.NmxService` COM ProgID, marshalling the resulting `IUnknown` to an OBJREF, calling `IObjectExporter::ResolveOxid` against the OXID inside, then `IRemUnknown::RemQueryInterface` to get the `INmxService2` IPID. **F6 (the `ComObjRefProvider` port) is now resolved**, so the underlying `marshal_activated_iunknown_objref` primitive exists; what remains is wiring it into `NmxClient::create` and threading `mxaccess-rpc`'s `windows-com` feature through `mxaccess-nmx`.
|
||||
**Resolves when:** `NmxClient::create()` lands behind a matching `mxaccess-nmx` feature gate. ~30 lines that chain the existing primitives: `com_objref_provider::marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::Local)` → `ComObjRef::parse` → `object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity` → `rem_unknown::encode_rem_query_interface_request` over a temporary transport → `NmxClient::connect`.
|
||||
|
||||
### F16 — Real `Session::recover_connection` reconnect loop (re-bind + re-advise)
|
||||
**Severity:** P1
|
||||
@@ -232,6 +227,9 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
|
||||
|
||||
## Resolved
|
||||
|
||||
### F12 — `NmxClient::create` (auto-resolving COM-activation factory)
|
||||
**Resolved:** 2026-05-05 (commit `<this commit>`). Builds on F6: new `NmxClient::create(ntlm_factory)` constructor in `crates/mxaccess-nmx/src/client.rs`, gated on `cfg(all(windows, feature = "windows-com"))`. New crate-level feature `mxaccess-nmx/windows-com` propagates to `mxaccess-rpc/windows-com`. Mirrors `ManagedNmxService2Client.Create()` (`cs:30-64`) + `ResolveService` (`cs:491-523`) — six steps: (1) `com_objref_provider::marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::DifferentMachine)` activates the COM class and emits an OBJREF blob; (2) `ComObjRef::parse` extracts `oxid` + `ipid` (the activated server's `IUnknown` IPID); (3) `resolve_oxid_with_managed_ntlm_packet_integrity` against `127.0.0.1:135` (RPCSS endpoint mapper) returns the server's `(host, port)` bindings + `IRemUnknown` IPID; (4) the `ncacn_ip_tcp` non-security binding's `host[port]` text is parsed via the new `parse_bracketed_host_port` helper (mirrors the .NET `ParseBracketedHost` / `ParseBracketedPort` pair, using `rfind` so FQDNs with `.` round-trip — matches `cs:540-561`); (5) a fresh transport binds to `IRemUnknown` and calls `RemQueryInterface(iunknown_ipid, INmxService2_IID, fresh_causality_id, public_refs=5)` — the `RemQiResult` carries the new `INmxService2` IPID; (6) a second fresh transport binds to `INmxService2` via `Self::connect`. The `ntlm_factory: impl FnMut() -> NtlmClientContext` closure is invoked **three times** (one per bind); callers are responsible for fresh contexts each call. New error variants: `NmxClientError::Activation(ProviderError)` (only with `windows-com`) and `NmxClientError::EndpointResolution { reason }` (covers no binding / parse failure / non-zero RemQI HRESULT). 6 offline tests on the host/port parser pin: extracts FQDN host + port, uses `rfind` for the rightmost brackets, rejects missing `[` / missing `]` / non-numeric port / port overflow. 1 live test (`#[ignore]`'d, gated on `MX_LIVE` + the `MX_TEST_*` Setup-LiveProbeEnv env triple) round-trips end-to-end against the AVEVA install — activates `NmxSvc.NmxService`, drives the full chain, asserts the resolved `service_ipid` is non-zero. Live verification: passes. Workspace tests went 17 → 23 in mxaccess-nmx (+6).
|
||||
|
||||
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 `CoMarshalInterface`)
|
||||
**Resolved:** 2026-05-05 (commit `<this commit>`). New module `crates/mxaccess-rpc/src/com_objref_provider.rs` (~330 LoC including tests) gated on `cfg(all(windows, feature = "windows-com"))`. Pulls `windows = "0.59"` (features `Win32_Foundation` + `Win32_System_Com` + `Win32_System_Com_Marshal` + `Win32_System_Com_StructuredStorage` + `Win32_System_Memory`) as an optional dep behind the existing `windows-com` feature; default footprint stays slim. Public API mirrors `ComObjRefProvider.cs` 1:1: `MarshalContext` enum (InProcess / Local / DifferentMachine — wraps the `MSHCTX_*` newtype constants), `clsid_from_prog_id(&str) -> Result<GUID, ProviderError>` (wraps `CLSIDFromProgID`), `marshal_activated_iunknown_objref(prog_id, ctx)` (activates via `CoCreateInstance(CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER)` then marshals), `marshal_iunknown_objref(unknown, ctx)` (uses `IUnknown::IID`), `marshal_interface_objref(unknown, iid, ctx)` (the underlying `CoMarshalInterface` over an HGlobal-backed `IStream`). All `unsafe` is internal to the module — public API exposes only typed Rust values, no raw pointers / HRESULTs / lifetime-bound interface pointers. Each `unsafe` block carries an inline SAFETY comment. `ProviderError` enumerates the four documented failure modes (UnknownProgId, ActivationFailed, MarshalFailed, GlobalLockFailed) plus the apartment-init pre-check (ApartmentInitFailed). Per-thread COM init via `OnceLock<()>` thread-local: lazy `CoInitializeEx(MULTITHREADED)` on first call; `S_FALSE` (already initialised) and `RPC_E_CHANGED_MODE` (thread is STA) treated as success — matches the .NET runtime's tolerant apartment behaviour. 4 offline tests pin: `MarshalContext` → `MSHCTX_*` mapping, `ensure_apartment` idempotence, `clsid_from_prog_id` returns `UnknownProgId` for fake ProgIDs, `marshal_activated_*` short-circuits at the resolution stage. 1 live test (`#[ignore]`'d, gated on `MX_LIVE`) round-trips the real `NmxSvc.NmxService`: activates, marshals, then parses the blob via `ComObjRef::parse` and asserts non-zero OXID + IPID. Live verification: passes against the AVEVA install on this host. Workspace tests went 183 → was 179 in mxaccess-rpc (+4 new). Unblocks F12 (NmxClient::create) — the auto-resolving COM-activation factory can now chain `marshal_activated_iunknown_objref` → `ComObjRef::parse` → `resolve_oxid_with_managed_ntlm_packet_integrity` → `RemQueryInterface` over the existing primitives.
|
||||
|
||||
|
||||
@@ -18,5 +18,12 @@ tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = "0.8"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Pulls the COM-activation factory `NmxClient::create` (F12). Propagates
|
||||
# through to `mxaccess-rpc/windows-com` so the Win32 `CoMarshalInterface`
|
||||
# emitter is available. On non-Windows targets the gate is a no-op.
|
||||
windows-com = ["mxaccess-rpc/windows-com"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -95,6 +95,23 @@ pub enum NmxClientError {
|
||||
/// `GalaxyTagMetadata.ProjectWriteValue` (`cs:62,70`).
|
||||
#[error("unsupported data type: {0}")]
|
||||
UnsupportedDataType(#[from] UnsupportedDataType),
|
||||
|
||||
/// COM activation / OBJREF marshalling failed during
|
||||
/// [`NmxClient::create`] — typically `REGDB_E_CLASSNOTREG` (the AVEVA
|
||||
/// install is missing) or `CO_E_SERVER_EXEC_FAILURE` (NmxSvc.exe
|
||||
/// failed to launch). Only emitted when the `windows-com` feature is
|
||||
/// enabled.
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
#[error("NmxSvc COM activation failed: {0}")]
|
||||
Activation(#[from] mxaccess_rpc::com_objref_provider::ProviderError),
|
||||
|
||||
/// `ResolveOxid` returned without a usable `ncacn_ip_tcp` binding,
|
||||
/// the binding's `host[port]` couldn't be parsed, or `IRemUnknown::RemQueryInterface`
|
||||
/// returned a non-zero HRESULT / error code. Mirrors the
|
||||
/// `InvalidOperationException` at
|
||||
/// `ManagedNmxService2Client.cs:519,545,559`.
|
||||
#[error("NmxSvc endpoint resolution failed: {reason}")]
|
||||
EndpointResolution { reason: String },
|
||||
}
|
||||
|
||||
/// Generates a random correlation `Cid` for each outgoing `OrpcThis` —
|
||||
@@ -106,6 +123,34 @@ fn fresh_orpc_this() -> OrpcThis {
|
||||
OrpcThis::create(Guid::new(rand::random()), None)
|
||||
}
|
||||
|
||||
/// Parse a `host[port]` binding string of the shape `ManagedNmxService2Client`
|
||||
/// expects (`cs:540-561`). The host is everything before the **last** `[`,
|
||||
/// the port is the decimal text between that `[` and the **last** `]`.
|
||||
///
|
||||
/// Used by [`NmxClient::create`] only.
|
||||
fn parse_bracketed_host_port(binding: &str) -> Result<(String, u16), NmxClientError> {
|
||||
let open = binding.rfind('[').ok_or_else(|| NmxClientError::EndpointResolution {
|
||||
reason: format!("binding {binding:?} has no '['"),
|
||||
})?;
|
||||
let close = binding.rfind(']').ok_or_else(|| NmxClientError::EndpointResolution {
|
||||
reason: format!("binding {binding:?} has no ']'"),
|
||||
})?;
|
||||
if open == 0 || close <= open {
|
||||
return Err(NmxClientError::EndpointResolution {
|
||||
reason: format!("binding {binding:?} has malformed brackets"),
|
||||
});
|
||||
}
|
||||
let host = binding[..open].to_string();
|
||||
let port_text = &binding[open + 1..close];
|
||||
let port: u16 =
|
||||
port_text
|
||||
.parse()
|
||||
.map_err(|e: std::num::ParseIntError| NmxClientError::EndpointResolution {
|
||||
reason: format!("binding {binding:?} port {port_text:?} parse: {e}"),
|
||||
})?;
|
||||
Ok((host, port))
|
||||
}
|
||||
|
||||
/// Async `INmxService2` client. Owns one `DceRpcTcpClient` connection that
|
||||
/// has already completed an authenticated bind to the `INmxService2` IID.
|
||||
///
|
||||
@@ -152,6 +197,176 @@ impl NmxClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Auto-resolve `(host, port, service_ipid)` via COM activation +
|
||||
/// OXID resolution + `IRemUnknown::RemQueryInterface`, then bind to
|
||||
/// `INmxService2` and return a ready-to-use client.
|
||||
///
|
||||
/// Mirrors `ManagedNmxService2Client.Create()` (`cs:30-64`) +
|
||||
/// `ResolveService` (`cs:491-523`). Only available when the crate
|
||||
/// is built with the `windows-com` feature on Windows.
|
||||
///
|
||||
/// `ntlm_factory` is invoked **three times**: once for the
|
||||
/// `ResolveOxid` call against `127.0.0.1:135` (RPCSS endpoint
|
||||
/// mapper), once for the `IRemUnknown` bind against the discovered
|
||||
/// NmxSvc endpoint, and once for the final `INmxService2` bind on a
|
||||
/// fresh transport. Each NTLM context is consumed by its bind; the
|
||||
/// caller is responsible for producing fresh ones (typically by
|
||||
/// re-reading credentials from `MX_RPC_*` env vars via
|
||||
/// [`NtlmClientContext::from_env`]).
|
||||
///
|
||||
/// Steps:
|
||||
///
|
||||
/// 1. `marshal_activated_iunknown_objref("NmxSvc.NmxService", DifferentMachine)`
|
||||
/// activates the COM class and emits an OBJREF blob.
|
||||
/// 2. [`mxaccess_rpc::objref::ComObjRef::parse`] extracts `oxid` +
|
||||
/// `ipid` (the activated server's `IUnknown` IPID).
|
||||
/// 3. [`mxaccess_rpc::object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity`]
|
||||
/// against `127.0.0.1:135` returns the server's `(host, port)`
|
||||
/// bindings + `IRemUnknown` IPID.
|
||||
/// 4. The `ncacn_ip_tcp` binding's `host[port]` text is parsed.
|
||||
/// 5. A fresh transport binds to `IRemUnknown` and calls
|
||||
/// `RemQueryInterface(iunknown_ipid, INmxService2)` to obtain the
|
||||
/// `INmxService2` IPID.
|
||||
/// 6. A second fresh transport binds to `INmxService2` and is
|
||||
/// returned wrapped in this client.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`NmxClientError::Activation`] for COM activation /
|
||||
/// `CoMarshalInterface` failures;
|
||||
/// [`NmxClientError::EndpointResolution`] when `ResolveOxid`
|
||||
/// returns no `ncacn_ip_tcp` binding, the host/port string is
|
||||
/// malformed, or `RemQueryInterface` returns a non-zero HRESULT;
|
||||
/// [`NmxClientError::Transport`] for I/O / NTLM failures during
|
||||
/// any of the three binds.
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
pub async fn create(
|
||||
mut ntlm_factory: impl FnMut() -> NtlmClientContext,
|
||||
) -> Result<Self, NmxClientError> {
|
||||
use mxaccess_rpc::com_objref_provider::{
|
||||
marshal_activated_iunknown_objref, MarshalContext,
|
||||
};
|
||||
use mxaccess_rpc::object_exporter::PROTSEQ_NCACN_IP_TCP;
|
||||
use mxaccess_rpc::object_exporter_client::{
|
||||
resolve_oxid_with_managed_ntlm_packet_integrity, ResolveOxidOutcome,
|
||||
};
|
||||
use mxaccess_rpc::objref::ComObjRef;
|
||||
use mxaccess_rpc::rem_unknown::{
|
||||
encode_rem_query_interface_request, parse_rem_query_interface_response,
|
||||
IREM_UNKNOWN_IID, REM_QUERY_INTERFACE_OPNUM,
|
||||
};
|
||||
|
||||
// Step 1+2: Activate NmxSvc.NmxService and parse OBJREF.
|
||||
let blob = marshal_activated_iunknown_objref(
|
||||
"NmxSvc.NmxService",
|
||||
MarshalContext::DifferentMachine,
|
||||
)?;
|
||||
let objref = ComObjRef::parse(&blob).map_err(|e| NmxClientError::EndpointResolution {
|
||||
reason: format!("OBJREF parse: {e}"),
|
||||
})?;
|
||||
|
||||
// Step 3: ResolveOxid against the local RPCSS endpoint mapper.
|
||||
let exporter_addr: SocketAddr = "127.0.0.1:135"
|
||||
.parse()
|
||||
.map_err(|e: std::net::AddrParseError| NmxClientError::EndpointResolution {
|
||||
reason: format!("invalid 127.0.0.1:135 literal: {e}"),
|
||||
})?;
|
||||
let outcome = resolve_oxid_with_managed_ntlm_packet_integrity(
|
||||
exporter_addr,
|
||||
objref.oxid,
|
||||
&[PROTSEQ_NCACN_IP_TCP],
|
||||
ntlm_factory(),
|
||||
)
|
||||
.await?;
|
||||
let resolved = match outcome {
|
||||
ResolveOxidOutcome::Result(r) => r,
|
||||
ResolveOxidOutcome::Failure(f) => {
|
||||
return Err(NmxClientError::EndpointResolution {
|
||||
reason: format!(
|
||||
"ResolveOxid returned failure status 0x{:08X}",
|
||||
f.error_status
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
if resolved.error_status != 0 {
|
||||
return Err(NmxClientError::EndpointResolution {
|
||||
reason: format!(
|
||||
"ResolveOxid completed with non-zero error_status 0x{:08X}",
|
||||
resolved.error_status
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Find the ncacn_ip_tcp binding and parse host[port].
|
||||
let endpoint = resolved
|
||||
.bindings
|
||||
.iter()
|
||||
.find(|b| b.tower_id == PROTSEQ_NCACN_IP_TCP && !b.is_security_binding)
|
||||
.ok_or_else(|| NmxClientError::EndpointResolution {
|
||||
reason: "ResolveOxid returned no ncacn_ip_tcp binding".to_string(),
|
||||
})?;
|
||||
let (host, port) = parse_bracketed_host_port(&endpoint.value)?;
|
||||
let svc_addr: SocketAddr =
|
||||
tokio::net::lookup_host((host.as_str(), port))
|
||||
.await
|
||||
.map_err(|e| NmxClientError::EndpointResolution {
|
||||
reason: format!("DNS lookup of {host}:{port} failed: {e}"),
|
||||
})?
|
||||
.next()
|
||||
.ok_or_else(|| NmxClientError::EndpointResolution {
|
||||
reason: format!("DNS resolution of {host}:{port} produced no addresses"),
|
||||
})?;
|
||||
|
||||
// Step 5: Bind IRemUnknown on a fresh transport and call
|
||||
// RemQueryInterface(iunknown_ipid, INmxService2).
|
||||
let mut rem_qi_client = DceRpcTcpClient::connect(svc_addr)
|
||||
.await
|
||||
.map_err(TransportError::from)?;
|
||||
rem_qi_client
|
||||
.bind_with_managed_ntlm_packet_integrity(IREM_UNKNOWN_IID, 0, 0, ntlm_factory())
|
||||
.await?;
|
||||
// Native uses `public_refs = 5` (`RemUnknownMessages.cs:12`); the
|
||||
// Rust signature requires it explicitly so the default isn't
|
||||
// hidden in the call-site.
|
||||
let qi_request = encode_rem_query_interface_request(
|
||||
objref.ipid,
|
||||
svc::INTERFACE_ID,
|
||||
Guid::new(rand::random()),
|
||||
5,
|
||||
);
|
||||
let qi_response = rem_qi_client
|
||||
.call_bound_object(
|
||||
resolved.rem_unknown_ipid,
|
||||
REM_QUERY_INTERFACE_OPNUM,
|
||||
&qi_request,
|
||||
)
|
||||
.await?;
|
||||
let parsed = parse_rem_query_interface_response(&qi_response.stub_data)
|
||||
.map_err(TransportError::from)?;
|
||||
let qi_result = parsed.result.ok_or_else(|| NmxClientError::EndpointResolution {
|
||||
reason: format!(
|
||||
"RemQueryInterface response had no REMQIRESULT (error_code 0x{:08X})",
|
||||
parsed.error_code
|
||||
),
|
||||
})?;
|
||||
if qi_result.hresult != 0 || parsed.error_code != 0 {
|
||||
return Err(NmxClientError::EndpointResolution {
|
||||
reason: format!(
|
||||
"RemQueryInterface failed: hresult=0x{:08X}, error_code=0x{:08X}",
|
||||
qi_result.hresult, parsed.error_code
|
||||
),
|
||||
});
|
||||
}
|
||||
let service_ipid = qi_result.standard_object_reference.ipid;
|
||||
// Drop the QI transport; the .NET reference uses a `using` block
|
||||
// for the same reason — the IRemUnknown bind is single-use.
|
||||
drop(rem_qi_client);
|
||||
|
||||
// Step 6: Final transport bound to INmxService2.
|
||||
Self::connect(svc_addr, service_ipid, ntlm_factory()).await
|
||||
}
|
||||
|
||||
/// Construct from an already-bound transport. Useful when a caller
|
||||
/// has already negotiated the bind (e.g. for tests against a hand-rolled
|
||||
/// server, or for an unauthenticated probe path).
|
||||
@@ -897,6 +1112,93 @@ mod tests {
|
||||
"127.0.0.1:0".parse().unwrap()
|
||||
}
|
||||
|
||||
// ----- F12 host[port] parser ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_extracts_host_and_port() {
|
||||
let (h, p) = parse_bracketed_host_port("DESKTOP-6JL3KKO[55690]").unwrap();
|
||||
assert_eq!(h, "DESKTOP-6JL3KKO");
|
||||
assert_eq!(p, 55690);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_uses_last_brackets() {
|
||||
// The native ResolveOxid bindings can include FQDN forms like
|
||||
// `host.subdomain[12345]` — `rfind` keeps the right boundary.
|
||||
let (h, p) = parse_bracketed_host_port("foo.example.com[1234]").unwrap();
|
||||
assert_eq!(h, "foo.example.com");
|
||||
assert_eq!(p, 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_rejects_missing_open() {
|
||||
let err = parse_bracketed_host_port("hostonly").unwrap_err();
|
||||
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_rejects_missing_close() {
|
||||
let err = parse_bracketed_host_port("host[1234").unwrap_err();
|
||||
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_rejects_non_numeric_port() {
|
||||
let err = parse_bracketed_host_port("host[abc]").unwrap_err();
|
||||
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bracketed_rejects_port_overflow() {
|
||||
let err = parse_bracketed_host_port("host[100000]").unwrap_err();
|
||||
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
|
||||
}
|
||||
|
||||
/// Live integration test for [`NmxClient::create`]. Activates
|
||||
/// `NmxSvc.NmxService` and resolves the `INmxService2` IPID via the
|
||||
/// real OBJREF + OXID + RemQI chain. Gated on `MX_LIVE` plus the
|
||||
/// `MX_TEST_USER` / `MX_TEST_PASSWORD` / `MX_TEST_DOMAIN` triple
|
||||
/// populated by `tools/Setup-LiveProbeEnv.ps1` (which fetches them
|
||||
/// from Infisical).
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "requires AVEVA + MX_LIVE; gated on env vars from Setup-LiveProbeEnv.ps1"]
|
||||
async fn live_create_resolves_inmxservice2() {
|
||||
if std::env::var_os("MX_LIVE").is_none() {
|
||||
eprintln!("MX_LIVE not set; skipping");
|
||||
return;
|
||||
}
|
||||
let user = match std::env::var("MX_TEST_USER") {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
_ => {
|
||||
eprintln!("MX_TEST_USER not set; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let password = match std::env::var("MX_TEST_PASSWORD") {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
_ => {
|
||||
eprintln!("MX_TEST_PASSWORD not set; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let domain = std::env::var("MX_TEST_DOMAIN").unwrap_or_default();
|
||||
|
||||
let factory = || {
|
||||
mxaccess_rpc::ntlm::NtlmClientContext::new(&user, &password, &domain, None)
|
||||
};
|
||||
let client = NmxClient::create(factory)
|
||||
.await
|
||||
.expect("NmxClient::create round-trip");
|
||||
// The resolved IPID must be non-zero — the activated server
|
||||
// always picks a real GUID.
|
||||
assert_ne!(
|
||||
client.service_ipid().as_bytes(),
|
||||
&[0u8; 16],
|
||||
"service IPID is all-zero (RemQueryInterface didn't return a real IPID)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spin a hand-rolled DCE/RPC server that:
|
||||
/// 1. accepts one connection,
|
||||
/// 2. drains one Bind PDU and replies with a 16-byte BindAck shell,
|
||||
|
||||
Reference in New Issue
Block a user