[F6] mxaccess-rpc: ComObjRefProvider port via windows-rs (CoMarshalInterface)
rust / build / test / clippy / fmt (push) Has been cancelled

New module crates/mxaccess-rpc/src/com_objref_provider.rs 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 wrapping the MSHCTX_* newtype
constants), clsid_from_prog_id, marshal_activated_iunknown_objref
(activates via CoCreateInstance with INPROC | LOCAL | REMOTE then
marshals), marshal_iunknown_objref (uses IUnknown::IID),
marshal_interface_objref (CoMarshalInterface over an HGlobal-backed
IStream).

All `unsafe` is internal to the module — public API exposes only
typed Rust values (Vec<u8>, GUID, ProviderError), no raw pointers /
HRESULTs / lifetime-bound interface pointers leak. Each unsafe block
carries an inline SAFETY comment naming the invariants being upheld.

Per-thread COM init via thread-local OnceLock<()>: 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.

ProviderError enumerates the four documented failure modes plus the
apartment-init pre-check: UnknownProgId / ActivationFailed /
MarshalFailed / GlobalLockFailed / ApartmentInitFailed.

4 offline tests: 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], gated on MX_LIVE): activates the real
NmxSvc.NmxService, marshals the proxy's IUnknown via
CoMarshalInterface, then parses the resulting blob via
ComObjRef::parse and asserts non-zero OXID + IPID. Passes against
the AVEVA install on this host.

Workspace tests: mxaccess-rpc went 179 → 183 (+4). All other crates
unchanged.

Unblocks F12 (NmxClient::create — the auto-resolving
COM-activation factory): the underlying primitive
(marshal_activated_iunknown_objref) now exists; remaining work is
threading the windows-com feature through mxaccess-nmx and chaining
ComObjRef::parse → resolve_oxid_with_managed_ntlm_packet_integrity →
RemQueryInterface. design/followups.md F12 updated with a revised
"Resolves when" reflecting that F6's blocker is gone.

Closes F6 in design/followups.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 22:11:33 -04:00
parent 41f2d4c0f2
commit cf9dbaf568
5 changed files with 578 additions and 19 deletions
+5 -8
View File
@@ -206,12 +206,6 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
**Resolves when:** A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8.
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 CoMarshalInterface)
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/objref.rs`
**Why deferred:** The provider is a wrapper around `ole32::CoMarshalInterface` / `IStream` / `GlobalLock` / `GlobalSize`. It needs `windows-rs`, which is currently behind the `windows-com` feature in `mxaccess-rpc/Cargo.toml`. The pure-Rust parser stands alone for the inbound activation-response path that M2 wave 1 needs.
**Resolves when:** `windows-rs` is wired into `mxaccess-rpc` (M2 wave 3 callback exporter needs to publish its own OBJREF for `IRemUnknown` / `INmxSvcCallback` registration) and an emitter port lands behind the `windows-com` feature.
### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec
**Severity:** P2
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`
@@ -227,8 +221,8 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
### 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. This requires `windows-rs` for `CoCreateInstance` / `CLSIDFromProgID` (the same gating dep as F6), plus the `ComObjRefProvider.MarshalIUnknownObjRef` port (also F6).
**Resolves when:** F6 lands (windows-rs wired in + `ComObjRefProvider` port). At that point `NmxClient::create()` becomes ~30 lines that chain the existing primitives: COM activation → `MarshalIUnknownObjRef``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`.
**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
@@ -238,6 +232,9 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
## Resolved
### 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.
### F14 — `tiberius`-backed SQL implementation of `Resolver` + `UserResolver`
**Resolved:** 2026-05-05 (commit `<this commit>`). New module `crates/mxaccess-galaxy/src/sql_resolver.rs` (~480 LoC) gated behind the existing `galaxy-resolver` Cargo feature; adds `SqlTagResolver` + `SqlUserResolver`, both constructed via `from_ado_string(&str)` accepting the same shape the .NET reference uses by default (`Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). `Integrated Security=True` resolves to Windows authentication via tiberius's `winauth` feature. Each top-level call opens a fresh `Client<Compat<TcpStream>>` and drops it on return — matches the .NET `await using` shape. `tiberius`'s `Client::query` only accepts positional `@P1..@PN` placeholders (delegates to `sp_executesql`); the canonical `RESOLVE_SQL` / `BROWSE_SQL` / `USER_BY_GUID_SQL` / `USER_BY_NAME_SQL` constants are rewritten once-per-process via `OnceLock<String>` (`@objectTagName``@P1`, etc.). `read_metadata` mirrors `ReadMetadata` (`cs:149-165`) byte-by-byte: signed `smallint``i16` widened to `u16` for platform/engine/object IDs (matches the .NET `checked((ushort)...)`), `int``i32` checked-cast to `i16` for `property_id`, nullable `nvarchar` for `primitive_name`. `read_user_profile` mirrors `ReadProfile` (`cs:76-85`) including the `roles_text` blob → `parse_role_blob` round-trip. New deps: `tiberius 0.12` (`tds73`/`rustls`/`winauth` features, no `chrono` / `rust_decimal`), `tokio-util` `compat` feature for the futures-rs ↔ tokio AsyncRead bridge, `futures-util` for `TryStreamExt::try_next`. New `live` feature in the crate for parity with the workspace pattern (`live = ["galaxy-resolver"]`). 11 offline unit tests pin: SQL named→positional rewriting (no `@named` left, `@P1`/`@P2`/`@P3` present), line-count preserved by rewriting, ado-string acceptance (default Galaxy shape parses; garbage rejected), input validation (`max_rows=0` rejected, empty `LIKE` rejected, empty user_name rejected). Two `#[cfg(feature = "live")]` `#[ignore]`'d tests round-trip against a real Galaxy DB (gated on `MX_LIVE` + `MX_GALAXY_DB` env vars per `tools/Setup-LiveProbeEnv.ps1`): `live_resolve_test_child_object_test_int` (TestChildObject.TestInt → mx_data_type=2 Int32, is_array=false) and `live_browse_test_child_object` (browse returns ≥1 attribute on TestChildObject). Both pass against the local AVEVA install.
+145 -10
View File
@@ -594,6 +594,7 @@ dependencies = [
"rc4",
"thiserror 2.0.18",
"tokio",
"windows",
]
[[package]]
@@ -1225,19 +1226,88 @@ dependencies = [
"winapi",
]
[[package]]
name = "windows"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1"
dependencies = [
"windows-core",
"windows-targets 0.53.5",
]
[[package]]
name = "windows-core"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets 0.53.5",
]
[[package]]
name = "windows-implement"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -1246,7 +1316,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -1255,14 +1325,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
@@ -1271,48 +1358,96 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "zerocopy"
version = "0.8.48"
+19 -1
View File
@@ -17,9 +17,27 @@ md4 = "0.10"
rc4 = "0.2"
rand = "0.8"
# F6 — Win32 OBJREF emitter via CoMarshalInterface. Optional, gated by the
# `windows-com` feature so the default footprint stays slim. windows-rs
# pulls a small set of submodules — Win32_System_Com for IUnknown / IStream
# / CoCreateInstance / CoMarshalInterface, Win32_System_Memory for
# GlobalLock / GlobalSize, Win32_System_Ole for the historical
# CreateStreamOnHGlobal / GetHGlobalFromStream re-exports.
windows = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Com_Marshal",
"Win32_System_Com_StructuredStorage",
"Win32_System_Memory",
], optional = true }
[features]
default = []
windows-com = []
# Gates the Win32 OBJREF emitter port (`com_objref_provider` module). The
# module itself is `cfg(windows)`-gated so non-Windows builds with the
# feature on stay green (the `windows` crate compiles to stubs on
# non-Windows targets).
windows-com = ["dep:windows"]
[lints]
workspace = true
@@ -0,0 +1,407 @@
//! Win32 OBJREF emitter — port of `src/MxNativeClient/ComObjRefProvider.cs`.
//!
//! Gated on the `windows-com` Cargo feature AND `cfg(windows)`. The pure-Rust
//! [`crate::objref::ComObjRef`] parser stands alone for the inbound activation
//! response path; this module is the *outbound* counterpart — it activates a
//! local COM class, marshals its `IUnknown` through `CoMarshalInterface` to
//! produce an OBJREF blob, and returns that blob as a `Vec<u8>`.
//!
//! ## Why this exists
//!
//! `ManagedNmxService2Client.Create()` (`ManagedNmxService2Client.cs:30-64`)
//! discovers the live `(host, port, service_ipid)` triple of `NmxSvc.exe` by
//! activating the `NmxSvc.NmxService` ProgID, marshalling the resulting
//! `IUnknown` to an OBJREF, and parsing the OXID/IPID out of that blob. The
//! activation must go through `CoMarshalInterface` rather than reading a
//! static config file because the `(host, port)` change every time the NMX
//! service restarts. The .NET reference's `ComObjRefProvider.cs` does this
//! through `Marshal.GetIUnknownForObject` + `CoMarshalInterface` over an
//! HGlobal-backed `IStream`. The Rust port mirrors that exactly using
//! `windows-rs`.
//!
//! ## Safety
//!
//! `mxaccess-rpc` is the only crate where internal `unsafe` is permitted (per
//! `design/00-overview.md` principle 3). All `unsafe` here is wrapped in safe
//! `pub fn` boundaries — callers do not see raw pointers, HRESULTs, or
//! lifetime-bound interface pointers. Each `unsafe` block carries a comment
//! explaining the invariant being upheld.
//!
//! ## COM apartment
//!
//! `CoMarshalInterface` requires the calling thread to be COM-initialised.
//! The high-level entry points call `CoInitializeEx(MULTITHREADED)` lazily,
//! once per worker thread, via a thread-local `OnceLock` guard.
//! `RPC_E_CHANGED_MODE` (the thread is already initialised to STA) is treated
//! as success — the existing apartment is fine for `CoMarshalInterface`.
#![cfg(all(windows, feature = "windows-com"))]
// Win32 FFI requires unsafe; localised to this module per the crate-level rule.
#![allow(unsafe_code)]
// `usize`-sized buffers may legitimately overflow on a 32-bit host with very
// large OBJREF blobs; mirrors the same indexing-permission rationale as
// `objref.rs`.
#![allow(clippy::cast_possible_truncation)]
use std::sync::OnceLock;
use thiserror::Error;
use windows::Win32::Foundation::{
GetLastError, HGLOBAL, RPC_E_CHANGED_MODE, S_FALSE, S_OK,
};
use windows::Win32::System::Com::Marshal::CoMarshalInterface;
use windows::Win32::System::Com::StructuredStorage::{
CreateStreamOnHGlobal, GetHGlobalFromStream,
};
use windows::Win32::System::Com::{
CLSIDFromProgID, CoCreateInstance, CoInitializeEx, IStream, CLSCTX_INPROC_SERVER,
CLSCTX_LOCAL_SERVER, CLSCTX_REMOTE_SERVER, COINIT_MULTITHREADED, MSHCTX_DIFFERENTMACHINE,
MSHCTX_INPROC, MSHCTX_LOCAL, MSHLFLAGS_NORMAL,
};
use windows::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
use windows::core::{IUnknown, Interface, GUID, HSTRING, PCWSTR};
/// Marshalling destination context. Mirrors the .NET constants at
/// `ComObjRefProvider.cs:8-10`. Maps directly to the Win32 `MSHCTX_*`
/// values in `[MS-DCOM]` §2.2.20.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum MarshalContext {
/// `MSHCTX_INPROC` — same process. Mirrors `MarshalContextInProcess`.
InProcess = 0,
/// `MSHCTX_LOCAL` — different process, same machine.
Local = 1,
/// `MSHCTX_DIFFERENTMACHINE` — different machine.
DifferentMachine = 2,
}
impl From<MarshalContext> for u32 {
fn from(ctx: MarshalContext) -> u32 {
match ctx {
MarshalContext::InProcess => MSHCTX_INPROC.0 as u32,
MarshalContext::Local => MSHCTX_LOCAL.0 as u32,
MarshalContext::DifferentMachine => MSHCTX_DIFFERENTMACHINE.0 as u32,
}
}
}
/// Errors raised by this module.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProviderError {
/// `CLSIDFromProgID` rejected the ProgID — typically `REGDB_E_CLASSNOTREG`
/// (`0x80040154`). Mirrors the `InvalidOperationException` at
/// `ComObjRefProvider.cs:14`.
#[error("CLSIDFromProgID('{prog_id}') failed: HRESULT 0x{hr:08X}")]
UnknownProgId { prog_id: String, hr: u32 },
/// `CoCreateInstance` failed for the resolved CLSID. Most commonly
/// `CO_E_SERVER_EXEC_FAILURE` or `E_ACCESSDENIED` for cross-bitness
/// LocalServers under DCOM.
#[error("CoCreateInstance({clsid:?}) failed: HRESULT 0x{hr:08X}")]
ActivationFailed { clsid: GUID, hr: u32 },
/// `CoMarshalInterface` / `CreateStreamOnHGlobal` /
/// `GetHGlobalFromStream` returned a non-success HRESULT. The op name
/// names which step.
#[error("{op} failed: HRESULT 0x{hr:08X}")]
MarshalFailed { op: &'static str, hr: u32 },
/// `GlobalLock` returned NULL. The `GetLastError` value is in `last_error`.
#[error("GlobalLock returned NULL (GetLastError = {last_error})")]
GlobalLockFailed { last_error: u32 },
/// `CoInitializeEx` returned an HRESULT other than `S_OK`, `S_FALSE`, or
/// `RPC_E_CHANGED_MODE`. The latter two are treated as success.
#[error("CoInitializeEx failed: HRESULT 0x{hr:08X}")]
ApartmentInitFailed { hr: u32 },
}
// ---------------------------------------------------------------------------
// Apartment management
// ---------------------------------------------------------------------------
/// Ensure the *current thread* is COM-initialised.
///
/// Strict per-thread COM init is awkward to track in async/multi-threaded
/// runtimes; instead, we eagerly call `CoInitializeEx(MULTITHREADED)` once
/// per thread that lands in `marshal_*`. Re-entrant calls return `S_FALSE`,
/// which we accept. If a thread is already initialised to STA we receive
/// `RPC_E_CHANGED_MODE` — also treated as success (the existing apartment
/// is fine for `CoMarshalInterface`).
fn ensure_apartment() -> Result<(), ProviderError> {
thread_local! {
// `OnceLock` per thread guarantees we only attempt CoInitializeEx
// once per worker; subsequent calls are a no-op.
static APT: OnceLock<()> = const { OnceLock::new() };
}
APT.with(|cell| {
if cell.get().is_some() {
return Ok(());
}
// SAFETY: CoInitializeEx is the standard COM entry point; passing
// None for the reserved pointer and a valid COINIT flag is the
// documented invocation shape.
let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
if hr == S_OK || hr == S_FALSE || hr == RPC_E_CHANGED_MODE {
// Discard the result — `set` only fails if already set, which
// we checked above.
let _ = cell.set(());
Ok(())
} else {
Err(ProviderError::ApartmentInitFailed {
hr: hr.0 as u32,
})
}
})
}
// ---------------------------------------------------------------------------
// ProgID → CLSID
// ---------------------------------------------------------------------------
/// Resolve a ProgID to a CLSID via `CLSIDFromProgID`. Mirrors
/// `Type.GetTypeFromProgID(progId, throwOnError: true)` (`cs:14`).
///
/// # Errors
///
/// [`ProviderError::UnknownProgId`] when the registry has no entry under
/// `HKCR\<prog_id>\CLSID` (typically `REGDB_E_CLASSNOTREG = 0x80040154`).
pub fn clsid_from_prog_id(prog_id: &str) -> Result<GUID, ProviderError> {
ensure_apartment()?;
let wide = HSTRING::from(prog_id);
// SAFETY: PCWSTR points into `wide` for the duration of the call;
// CLSIDFromProgID writes a CLSID through the out-pointer in the
// generated wrapper.
let result = unsafe { CLSIDFromProgID(PCWSTR::from_raw(wide.as_ptr())) };
result.map_err(|e| ProviderError::UnknownProgId {
prog_id: prog_id.to_string(),
hr: e.code().0 as u32,
})
}
// ---------------------------------------------------------------------------
// Marshalling
// ---------------------------------------------------------------------------
/// Activate a COM class by ProgID and return the OBJREF byte stream that
/// represents its `IUnknown` proxy in the supplied marshal context.
///
/// Mirrors `MarshalActivatedIUnknownObjRef` (`cs:12-30`). Activation uses
/// `CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER` —
/// the same default `Activator.CreateInstance` picks up via
/// `Type.GetTypeFromProgID`.
///
/// # Errors
///
/// [`ProviderError::UnknownProgId`], [`ProviderError::ActivationFailed`],
/// [`ProviderError::MarshalFailed`], [`ProviderError::GlobalLockFailed`].
pub fn marshal_activated_iunknown_objref(
prog_id: &str,
destination_context: MarshalContext,
) -> Result<Vec<u8>, ProviderError> {
ensure_apartment()?;
let clsid = clsid_from_prog_id(prog_id)?;
let activation_flags = CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER;
// SAFETY: `clsid` is initialised by `CLSIDFromProgID`; activation_flags
// is a valid CLSCTX bitmask; `None` for the controlling-unknown is a
// standard no-aggregation invocation.
let unknown: IUnknown =
unsafe { CoCreateInstance(&clsid, None, activation_flags) }.map_err(|e| {
ProviderError::ActivationFailed {
clsid,
hr: e.code().0 as u32,
}
})?;
marshal_iunknown_objref(&unknown, destination_context)
}
/// Marshal an arbitrary `IUnknown` to an OBJREF byte stream. Mirrors
/// `MarshalIUnknownObjRef` (`cs:32-35`), passing IID `IID_IUnknown`
/// (`{00000000-0000-0000-C000-000000000046}`).
///
/// # Errors
///
/// [`ProviderError::MarshalFailed`], [`ProviderError::GlobalLockFailed`].
pub fn marshal_iunknown_objref(
unknown: &IUnknown,
destination_context: MarshalContext,
) -> Result<Vec<u8>, ProviderError> {
marshal_interface_objref(unknown, IUnknown::IID, destination_context)
}
/// Marshal `unknown` for the given interface IID to an OBJREF blob.
/// Mirrors `MarshalInterfaceObjRef` (`cs:37-80`).
///
/// The byte sequence returned is the exact bytes `CoMarshalInterface` wrote
/// into the HGlobal-backed stream — not a re-formatted copy. Pass through
/// [`crate::objref::ComObjRef::parse`] to inspect OXID / OID / IPID.
///
/// # Errors
///
/// [`ProviderError::MarshalFailed`] for any non-success HRESULT from
/// `CreateStreamOnHGlobal` / `CoMarshalInterface` / `GetHGlobalFromStream`;
/// [`ProviderError::GlobalLockFailed`] if `GlobalLock` returns NULL.
pub fn marshal_interface_objref(
unknown: &IUnknown,
iid: GUID,
destination_context: MarshalContext,
) -> Result<Vec<u8>, ProviderError> {
ensure_apartment()?;
// SAFETY: All Win32 COM calls below are documented as valid for the
// arguments we pass:
// - `CreateStreamOnHGlobal(NULL, TRUE, ...)` allocates a new HGlobal
// and binds it to the IStream (delete-on-release semantics).
// - `CoMarshalInterface(stream, &iid, unknown, ctx, NULL, NORMAL)`
// writes the OBJREF into the stream.
// - `GetHGlobalFromStream` extracts the underlying HGlobal handle
// for direct memory read — supported by IStreams created via
// `CreateStreamOnHGlobal`.
// - `GlobalLock` / `GlobalUnlock` / `GlobalSize` are the canonical
// accessors for HGlobal-backed memory.
// Each result is checked; failures bubble up as `ProviderError`.
unsafe {
let stream: IStream = CreateStreamOnHGlobal(HGLOBAL(std::ptr::null_mut()), true)
.map_err(|e| ProviderError::MarshalFailed {
op: "CreateStreamOnHGlobal",
hr: e.code().0 as u32,
})?;
CoMarshalInterface(
&stream,
&iid,
unknown,
destination_context.into(),
None,
MSHLFLAGS_NORMAL.0 as u32,
)
.map_err(|e| ProviderError::MarshalFailed {
op: "CoMarshalInterface",
hr: e.code().0 as u32,
})?;
let hglobal: HGLOBAL = GetHGlobalFromStream(&stream).map_err(|e| {
ProviderError::MarshalFailed {
op: "GetHGlobalFromStream",
hr: e.code().0 as u32,
}
})?;
let size = GlobalSize(hglobal);
let pointer = GlobalLock(hglobal);
if pointer.is_null() {
return Err(ProviderError::GlobalLockFailed {
last_error: GetLastError().0,
});
}
// SAFETY: `pointer` is non-null and points to `size` bytes of
// initialised memory inside the HGlobal block; we copy into a
// freshly-allocated Vec without aliasing.
let buffer = std::slice::from_raw_parts(pointer.cast::<u8>(), size).to_vec();
// GlobalUnlock returns Result<()>; documented to return BOOL on
// the wire where FALSE is the *normal* path (lock count drops to
// zero). The Result<()> wrapper treats that as success.
let _ = GlobalUnlock(hglobal);
// The IStream was created with `delete_on_release = TRUE`, so the
// HGlobal is freed when `stream` drops at end-of-scope. No
// explicit `GlobalFree` needed.
drop(stream);
Ok(buffer)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
use crate::objref::{ComObjRef, OBJREF_HEADER_LEN};
#[test]
fn marshal_context_maps_to_win32_constants() {
assert_eq!(u32::from(MarshalContext::InProcess), MSHCTX_INPROC.0 as u32);
assert_eq!(u32::from(MarshalContext::Local), MSHCTX_LOCAL.0 as u32);
assert_eq!(
u32::from(MarshalContext::DifferentMachine),
MSHCTX_DIFFERENTMACHINE.0 as u32
);
}
#[test]
fn ensure_apartment_is_idempotent() {
// Both calls must return Ok; the second hits the OnceLock path.
ensure_apartment().expect("first apartment init");
ensure_apartment().expect("second apartment init (idempotent)");
}
#[test]
fn clsid_from_unknown_prog_id_returns_unknown_prog_id() {
// REGDB_E_CLASSNOTREG = 0x80040154 — guaranteed for any unregistered
// ProgID. The exact HRESULT may vary by Windows version (some return
// CO_E_CLASSSTRING) so we only assert on the variant, not the code.
let err = clsid_from_prog_id("NonExistent.NotARealProgId.QQQ.123").unwrap_err();
match err {
ProviderError::UnknownProgId { prog_id, hr } => {
assert_eq!(prog_id, "NonExistent.NotARealProgId.QQQ.123");
assert_ne!(hr, 0, "expected non-success HRESULT");
}
other => panic!("expected UnknownProgId, got {other:?}"),
}
}
#[test]
fn marshal_activated_with_unknown_progid_fails_at_resolution() {
// We don't even reach activation — should fail at CLSIDFromProgID.
let err = marshal_activated_iunknown_objref(
"NonExistent.AnotherFakeProgId.999",
MarshalContext::Local,
)
.unwrap_err();
assert!(matches!(err, ProviderError::UnknownProgId { .. }));
}
/// Live integration test — gated on `MX_LIVE` env var (the workspace
/// convention; populated by `tools/Setup-LiveProbeEnv.ps1`).
/// Activates the local `NmxSvc.NmxService` and verifies the marshalled
/// OBJREF parses back via [`crate::objref::ComObjRef::parse`].
///
/// Without `MX_LIVE`, the test is silent — pure-Rust CI hosts have no
/// AVEVA install and would always fail.
#[test]
#[ignore = "requires a live AVEVA install with NmxSvc registered; gated on MX_LIVE"]
fn live_marshal_nmx_service_round_trip() {
if std::env::var_os("MX_LIVE").is_none() {
eprintln!("MX_LIVE not set; skipping");
return;
}
let blob =
marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::Local)
.expect("activate + marshal NmxSvc.NmxService");
assert!(
blob.len() >= OBJREF_HEADER_LEN,
"OBJREF blob too short ({} bytes)",
blob.len()
);
// The "MEOW" signature is the first 4 bytes per [MS-DCOM] §2.2.18.
let parsed = ComObjRef::parse(&blob).expect("parse marshalled OBJREF");
// OXID / IPID must be non-zero for an activated server.
assert_ne!(parsed.oxid, 0, "OBJREF OXID is zero");
assert_ne!(
parsed.ipid.0,
[0u8; 16],
"OBJREF IPID is zero (expected non-zero IPID)"
);
}
}
+2
View File
@@ -15,6 +15,8 @@
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
// windows-rs COM calls). Public API stays safe.
#[cfg(all(windows, feature = "windows-com"))]
pub mod com_objref_provider;
pub mod error;
pub mod guid;
pub mod nmx_callback_messages;