[F6] mxaccess-rpc: ComObjRefProvider port via windows-rs (CoMarshalInterface)
rust / build / test / clippy / fmt (push) Has been cancelled
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:
+5
-8
@@ -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.
|
||||
|
||||
|
||||
Generated
+145
-10
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user