diff --git a/design/followups.md b/design/followups.md index 17adb12..b7dd8f7 100644 --- a/design/followups.md +++ b/design/followups.md @@ -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 ``). 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` (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 ``). 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>` 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` (`@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. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0864fd9..63746a9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/crates/mxaccess-rpc/Cargo.toml b/rust/crates/mxaccess-rpc/Cargo.toml index 73e6321..75ee6e1 100644 --- a/rust/crates/mxaccess-rpc/Cargo.toml +++ b/rust/crates/mxaccess-rpc/Cargo.toml @@ -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 diff --git a/rust/crates/mxaccess-rpc/src/com_objref_provider.rs b/rust/crates/mxaccess-rpc/src/com_objref_provider.rs new file mode 100644 index 0000000..79394e9 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/com_objref_provider.rs @@ -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`. +//! +//! ## 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 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\\CLSID` (typically `REGDB_E_CLASSNOTREG = 0x80040154`). +pub fn clsid_from_prog_id(prog_id: &str) -> Result { + 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, 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, 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, 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::(), 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)" + ); + } +} diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index 3eb2d72..1c70449 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -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;