diff --git a/design/followups.md b/design/followups.md index 7a9757c..3cf3fb9 100644 --- a/design/followups.md +++ b/design/followups.md @@ -42,12 +42,6 @@ move to `## Resolved` with a date + commit hash. **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. -### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods -**Severity:** P2 (was P1 — downgraded after `DceRpcTcpClient` transport landed) -**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs` -**Why deferred:** The transport prerequisite (`DceRpcTcpClient`) is now ported in `crates/mxaccess-rpc/src/transport.rs`. What remains is two thin wrapper methods that wire the codec to the transport: `resolve_oxid_unauthenticated(addr, oxid, protseqs) -> Result` (mirrors `ObjectExporterClient.cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity(addr, oxid, protseqs, ntlm) -> Result` (mirrors `cs:66-81`). The two SSPI variants (`ResolveOxidWithNtlmConnect` at `cs:32-47` and `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) are .NET-specific (`System.Net.Security.SspiClientContext`) and explicitly out of scope. -**Resolves when:** Both wrapper methods land, calling `DceRpcTcpClient::connect`/`bind`/`call_bound` against `IObjectExporter` opnum 0 and parsing via `parse_resolve_oxid_result` / `parse_resolve_oxid_failure`. - ### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec **Severity:** P2 **Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs` @@ -67,3 +61,6 @@ move to `## Resolved` with a date + commit hash. ### F8 — `RpcError` is duplicated across `objref` and `pdu` modules **Resolved:** 2026-05-05 in this iteration's commit. `RpcError` was hoisted into the new shared `crate::error::RpcError` module as a single union of all wave 1 variants plus a generic `Decode { offset, reason: &'static str, buffer_len }` variant for the wave 2 ORPC parsers' one-off failures. `objref` and `pdu` re-export from there; M2 wave 2's `orpc`, `object_exporter`, and `rem_unknown` use it directly. + +### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods +**Resolved:** 2026-05-05. Both portable methods land in `crates/mxaccess-rpc/src/object_exporter_client.rs`: `resolve_oxid_unauthenticated` (mirrors `cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity` (mirrors `cs:66-81`). Each opens a TCP connection, binds to `IObjectExporter`, calls opnum 0 with the encoded request, and decodes the response — preferring `parse_resolve_oxid_result` then falling back to `parse_resolve_oxid_failure` for short stubs. The two SSPI flavours (`ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`) wrap .NET's `System.Net.Security.SspiClientContext` and are explicitly out of scope for the Rust port — that's a permanent skip, not a deferral. diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index e7dd060..3eb2d72 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -19,8 +19,10 @@ pub mod error; pub mod guid; pub mod nmx_callback_messages; pub mod nmx_metadata; +pub mod nmx_service2_messages; pub mod ntlm; pub mod object_exporter; +pub mod object_exporter_client; pub mod objref; pub mod orpc; pub mod pdu; diff --git a/rust/crates/mxaccess-rpc/src/nmx_service2_messages.rs b/rust/crates/mxaccess-rpc/src/nmx_service2_messages.rs new file mode 100644 index 0000000..93f05f5 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/nmx_service2_messages.rs @@ -0,0 +1,545 @@ +//! `INmxService2` request/response codecs. +//! +//! Direct port of `src/MxNativeClient/NmxService2Messages.cs`. Provides +//! pure-codec encoders/decoders for the 9 procedures the .NET reference +//! marshals against `INmxService2` (opnums 3..11) plus the small set of +//! NDR helpers used by `RegisterEngine2` (`EncodeBstrUserMarshal`, +//! `EncodeNullInterfacePointer`, `EncodeInterfacePointer`). +//! +//! All wire fields are little-endian. Each encoder returns a `Vec` +//! that the transport (`crate::transport::DceRpcTcpClient::call_bound`) +//! sends as the `stub_data` of a `Request` PDU. + +#![allow(clippy::indexing_slicing)] + +use crate::error::RpcError; +use crate::nmx_metadata::INMX_SERVICE2_IID; +use crate::orpc::{OrpcThat, OrpcThis}; + +/// `NmxServiceClass` CLSID `AE24BD51-2E80-44CC-905B-E5446C942BEB` +/// (`NmxService2Messages.cs:12`, also `NmxComContracts.cs:7`). +pub const NMX_SERVICE_CLSID: crate::guid::Guid = crate::guid::Guid::new([ + 0x51, 0xBD, 0x24, 0xAE, 0x80, 0x2E, 0xCC, 0x44, 0x90, 0x5B, 0xE5, 0x44, 0x6C, 0x94, 0x2B, 0xEB, +]); + +/// `INmxService2` IID — re-exported for convenience +/// (`NmxService2Messages.cs:13`). +pub const INTERFACE_ID: crate::guid::Guid = INMX_SERVICE2_IID; + +// --- Opnums (`NmxService2Messages.cs:15-23`) ---------------------------- + +pub const REGISTER_ENGINE_OPNUM: u16 = 3; +pub const UNREGISTER_ENGINE_OPNUM: u16 = 4; +pub const CONNECT_OPNUM: u16 = 5; +pub const TRANSFER_DATA_OPNUM: u16 = 6; +pub const ADD_SUBSCRIBER_ENGINE_OPNUM: u16 = 7; +pub const REMOVE_SUBSCRIBER_ENGINE_OPNUM: u16 = 8; +pub const SET_HEARTBEAT_SEND_INTERVAL_OPNUM: u16 = 9; +pub const REGISTER_ENGINE_2_OPNUM: u16 = 10; +pub const GET_PARTNER_VERSION_OPNUM: u16 = 11; + +// --- Records ------------------------------------------------------------ + +/// Decoded `GetPartnerVersion` response — mirrors +/// `NmxGetPartnerVersionResult` (`cs:6`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NmxGetPartnerVersionResult { + pub orpc_that: OrpcThat, + pub partner_version: i32, + pub hresult: i32, +} + +/// Decoded HRESULT-only response (Connect / Register* / Unregister / +/// Set / TransferData / Add/Remove subscriber). Mirrors +/// `NmxHResultResponse` (`cs:8`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NmxHResultResponse { + pub orpc_that: OrpcThat, + pub hresult: i32, +} + +// --- Encoders ----------------------------------------------------------- + +/// `GetPartnerVersion` request (`cs:25-37`). +/// +/// Layout: `OrpcThis(32) || galaxy_id(4) || platform_id(4) || engine_id(4)`. +#[must_use] +pub fn encode_get_partner_version_request( + orpc_this: OrpcThis, + galaxy_id: i32, + platform_id: i32, + engine_id: i32, +) -> Vec { + let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 12]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&galaxy_id.to_le_bytes()); + buf[36..40].copy_from_slice(&platform_id.to_le_bytes()); + buf[40..44].copy_from_slice(&engine_id.to_le_bytes()); + buf +} + +/// `Connect` request (`cs:52-66`). +/// +/// Layout: `OrpcThis(32) || local_engine_id(4) || remote_galaxy_id(4) || +/// remote_platform_id(4) || remote_engine_id(4)`. +#[must_use] +pub fn encode_connect_request( + orpc_this: OrpcThis, + local_engine_id: i32, + remote_galaxy_id: i32, + remote_platform_id: i32, + remote_engine_id: i32, +) -> Vec { + let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 16]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes()); + buf[36..40].copy_from_slice(&remote_galaxy_id.to_le_bytes()); + buf[40..44].copy_from_slice(&remote_platform_id.to_le_bytes()); + buf[44..48].copy_from_slice(&remote_engine_id.to_le_bytes()); + buf +} + +/// `AddSubscriberEngine` / `RemoveSubscriberEngine` request shape +/// (`cs:68-82`). Both opnums share this layout — the .NET reference +/// reuses the same encoder. +#[must_use] +pub fn encode_subscriber_engine_request( + orpc_this: OrpcThis, + local_engine_id: i32, + subscriber_galaxy_id: i32, + subscriber_platform_id: i32, + subscriber_engine_id: i32, +) -> Vec { + let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 16]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes()); + buf[36..40].copy_from_slice(&subscriber_galaxy_id.to_le_bytes()); + buf[40..44].copy_from_slice(&subscriber_platform_id.to_le_bytes()); + buf[44..48].copy_from_slice(&subscriber_engine_id.to_le_bytes()); + buf +} + +/// `UnRegisterEngine` request (`cs:84-92`). +/// +/// Layout: `OrpcThis(32) || local_engine_id(4)`. +#[must_use] +pub fn encode_unregister_engine_request(orpc_this: OrpcThis, local_engine_id: i32) -> Vec { + let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 4]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes()); + buf +} + +/// `SetHeartbeatSendInterval` request (`cs:94-104`). +/// +/// Layout: `OrpcThis(32) || ticks_per_beat(4) || max_missed_ticks(4)`. +#[must_use] +pub fn encode_set_heartbeat_send_interval_request( + orpc_this: OrpcThis, + ticks_per_beat: i32, + max_missed_ticks: i32, +) -> Vec { + let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 8]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&ticks_per_beat.to_le_bytes()); + buf[36..40].copy_from_slice(&max_missed_ticks.to_le_bytes()); + buf +} + +/// `TransferData` request (`cs:106-124`). +/// +/// Layout (NDR-aligned to 4 bytes overall): +/// +/// ```text +/// offset size field +/// 0 32 OrpcThis +/// 32 4 remote_galaxy_id i32 LE +/// 36 4 remote_platform_id i32 LE +/// 40 4 remote_engine_id i32 LE +/// 44 4 message_length i32 LE +/// 48 4 max_count i32 LE = message_length +/// 52..(52+len) len message_body +/// (padded to 4-byte alignment) +/// ``` +#[must_use] +pub fn encode_transfer_data_request( + orpc_this: OrpcThis, + remote_galaxy_id: i32, + remote_platform_id: i32, + remote_engine_id: i32, + message_body: &[u8], +) -> Vec { + let body_offset = OrpcThis::ENCODED_LEN + 20; + let padded_length = align_up(body_offset + message_body.len(), 4); + let mut buf = vec![0u8; padded_length]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&remote_galaxy_id.to_le_bytes()); + buf[36..40].copy_from_slice(&remote_platform_id.to_le_bytes()); + buf[40..44].copy_from_slice(&remote_engine_id.to_le_bytes()); + let body_len = i32::try_from(message_body.len()).unwrap_or(i32::MAX); + buf[44..48].copy_from_slice(&body_len.to_le_bytes()); + buf[48..52].copy_from_slice(&body_len.to_le_bytes()); + buf[body_offset..body_offset + message_body.len()].copy_from_slice(message_body); + buf +} + +/// `RegisterEngine2` request (`cs:126-154`). +/// +/// Layout (each section 4-byte NDR-aligned): +/// +/// ```text +/// 0 32 OrpcThis +/// 32 4 local_engine_id i32 LE +/// 36 4 domain_marker i32 LE = 0x72657355 ("User" little-endian) +/// 40 var bstr (12-byte BSTR header + UTF-16 chars, no NUL) +/// (aligned to 4) 4 version i32 LE +/// (followed by the InterfacePointer structure for the callback OBJREF) +/// ``` +/// +/// `domain_marker = 0x72657355` is `"Useu"`-style ASCII reversed; the +/// .NET reference writes it verbatim at `cs:146` and the LMX server +/// parses it back as a string-form domain identity. The Rust port does +/// not interpret it; it round-trips the constant per CLAUDE.md +/// "preserve unknown bytes" rule. +/// +/// When `callback_obj_ref` is `None` the encoder writes a 4-byte null +/// interface pointer (`cs:134-135`); when `Some(bytes)`, it wraps the +/// OBJREF in a 12-byte InterfacePointer header per `cs:206-215`. +#[must_use] +pub fn encode_register_engine_2_request( + orpc_this: OrpcThis, + local_engine_id: i32, + engine_name: &str, + version: i32, + callback_obj_ref: Option<&[u8]>, +) -> Vec { + let bstr = encode_bstr_user_marshal(engine_name); + let callback = match callback_obj_ref { + None => encode_null_interface_pointer().to_vec(), + Some(bytes) => encode_interface_pointer(bytes), + }; + + let bstr_offset = OrpcThis::ENCODED_LEN + 8; + let version_offset = align_up(bstr_offset + bstr.len(), 4); + let length = align_up(version_offset + 4 + callback.len(), 4); + let mut buf = vec![0u8; length]; + buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode()); + buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes()); + // "Useu" domain marker — `cs:146`. + buf[36..40].copy_from_slice(&0x7265_5355i32.to_le_bytes()); + buf[40..40 + bstr.len()].copy_from_slice(&bstr); + buf[version_offset..version_offset + 4].copy_from_slice(&version.to_le_bytes()); + let cb_off = version_offset + 4; + buf[cb_off..cb_off + callback.len()].copy_from_slice(&callback); + buf +} + +/// Encode a UTF-16LE BSTR as the LMX MIDL stub expects +/// (`NmxService2Messages.cs:156-171`): +/// +/// ```text +/// 0..4 char_count i32 LE number of UTF-16 code units (no NUL) +/// 4..8 byte_count i32 LE 2 * char_count +/// 8..12 char_count i32 LE repeated (NDR conformant array max count) +/// 12.. UTF-16LE chars (no terminator) +/// ``` +#[must_use] +pub fn encode_bstr_user_marshal(value: &str) -> Vec { + let utf16: Vec = value.encode_utf16().collect(); + let char_count = i32::try_from(utf16.len()).unwrap_or(i32::MAX); + let byte_count = i32::try_from(utf16.len() * 2).unwrap_or(i32::MAX); + let mut buf = vec![0u8; 12 + utf16.len() * 2]; + buf[0..4].copy_from_slice(&char_count.to_le_bytes()); + buf[4..8].copy_from_slice(&byte_count.to_le_bytes()); + buf[8..12].copy_from_slice(&char_count.to_le_bytes()); + for (i, ch) in utf16.iter().enumerate() { + buf[12 + i * 2..12 + i * 2 + 2].copy_from_slice(&ch.to_le_bytes()); + } + buf +} + +/// 4-byte null interface pointer — `cs:201-204`. The LMX server treats +/// a 4-byte zero referent as "no callback registered". +#[must_use] +pub const fn encode_null_interface_pointer() -> [u8; 4] { + [0, 0, 0, 0] +} + +/// Wrap an OBJREF in the InterfacePointer NDR layout — `cs:206-215`: +/// +/// ```text +/// 0..4 referent_id u32 LE = 0x00020000 +/// 4..8 length i32 LE = obj_ref.len() +/// 8..12 max_count i32 LE = obj_ref.len() +/// 12.. obj_ref bytes (padded to 4-byte alignment) +/// ``` +#[must_use] +pub fn encode_interface_pointer(obj_ref: &[u8]) -> Vec { + let length = align_up(12 + obj_ref.len(), 4); + let mut buf = vec![0u8; length]; + buf[0..4].copy_from_slice(&0x0002_0000u32.to_le_bytes()); + let len_i32 = i32::try_from(obj_ref.len()).unwrap_or(i32::MAX); + buf[4..8].copy_from_slice(&len_i32.to_le_bytes()); + buf[8..12].copy_from_slice(&len_i32.to_le_bytes()); + buf[12..12 + obj_ref.len()].copy_from_slice(obj_ref); + buf +} + +// --- Decoders ----------------------------------------------------------- + +/// Parse a `GetPartnerVersion` response (`cs:39-50`). +/// +/// # Errors +/// [`RpcError::ShortRead`] if the buffer is shorter than 16 bytes. +pub fn parse_get_partner_version_response( + buffer: &[u8], +) -> Result { + let need = OrpcThat::ENCODED_LEN + 8; + if buffer.len() < need { + return Err(RpcError::ShortRead { + expected: need, + actual: buffer.len(), + }); + } + let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?; + Ok(NmxGetPartnerVersionResult { + orpc_that, + partner_version: i32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]), + hresult: i32::from_le_bytes([buffer[12], buffer[13], buffer[14], buffer[15]]), + }) +} + +/// Parse a generic HRESULT response (`cs:173-183`). +/// +/// # Errors +/// [`RpcError::ShortRead`] if the buffer is shorter than 12 bytes. +pub fn parse_hresult_response(buffer: &[u8]) -> Result { + let need = OrpcThat::ENCODED_LEN + 4; + if buffer.len() < need { + return Err(RpcError::ShortRead { + expected: need, + actual: buffer.len(), + }); + } + let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?; + Ok(NmxHResultResponse { + orpc_that, + hresult: i32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]), + }) +} + +const fn align_up(value: usize, alignment: usize) -> usize { + let r = value % alignment; + if r == 0 { value } else { value + alignment - r } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use crate::guid::Guid; + + fn sample_orpc_this() -> OrpcThis { + OrpcThis::create(Guid::new([0xAB; 16]), None) + } + + #[test] + fn nmx_service_clsid_matches_dotnet_d_format() { + // .NET `new Guid("AE24BD51-2E80-44CC-905B-E5446C942BEB").ToString("D")`. + assert_eq!( + NMX_SERVICE_CLSID.to_string(), + "ae24bd51-2e80-44cc-905b-e5446c942beb" + ); + } + + #[test] + fn opnum_constants_match_dotnet() { + assert_eq!(REGISTER_ENGINE_OPNUM, 3); + assert_eq!(UNREGISTER_ENGINE_OPNUM, 4); + assert_eq!(CONNECT_OPNUM, 5); + assert_eq!(TRANSFER_DATA_OPNUM, 6); + assert_eq!(ADD_SUBSCRIBER_ENGINE_OPNUM, 7); + assert_eq!(REMOVE_SUBSCRIBER_ENGINE_OPNUM, 8); + assert_eq!(SET_HEARTBEAT_SEND_INTERVAL_OPNUM, 9); + assert_eq!(REGISTER_ENGINE_2_OPNUM, 10); + assert_eq!(GET_PARTNER_VERSION_OPNUM, 11); + } + + #[test] + fn get_partner_version_request_layout() { + let buf = encode_get_partner_version_request(sample_orpc_this(), 1, 2, 3); + // 32 (OrpcThis) + 12 = 44. + assert_eq!(buf.len(), 44); + assert_eq!(&buf[32..36], &1i32.to_le_bytes()); + assert_eq!(&buf[36..40], &2i32.to_le_bytes()); + assert_eq!(&buf[40..44], &3i32.to_le_bytes()); + } + + #[test] + fn connect_request_layout() { + let buf = encode_connect_request(sample_orpc_this(), 10, 11, 12, 13); + assert_eq!(buf.len(), 48); + assert_eq!(&buf[32..36], &10i32.to_le_bytes()); + assert_eq!(&buf[44..48], &13i32.to_le_bytes()); + } + + #[test] + fn subscriber_engine_request_layout() { + let buf = encode_subscriber_engine_request(sample_orpc_this(), 1, 2, 3, 4); + assert_eq!(buf.len(), 48); + assert_eq!(&buf[44..48], &4i32.to_le_bytes()); + } + + #[test] + fn unregister_engine_request_layout() { + let buf = encode_unregister_engine_request(sample_orpc_this(), 0xCAFE); + assert_eq!(buf.len(), 36); + assert_eq!(&buf[32..36], &0xCAFEi32.to_le_bytes()); + } + + #[test] + fn set_heartbeat_send_interval_request_layout() { + let buf = encode_set_heartbeat_send_interval_request(sample_orpc_this(), 100, 5); + assert_eq!(buf.len(), 40); + assert_eq!(&buf[32..36], &100i32.to_le_bytes()); + assert_eq!(&buf[36..40], &5i32.to_le_bytes()); + } + + #[test] + fn transfer_data_request_layout_aligned() { + // body length 8 — body offset 52 + 8 = 60, already 4-aligned. + let body = [0xAAu8; 8]; + let buf = encode_transfer_data_request(sample_orpc_this(), 1, 2, 3, &body); + assert_eq!(buf.len(), 60); + assert_eq!(&buf[44..48], &8i32.to_le_bytes()); // length + assert_eq!(&buf[48..52], &8i32.to_le_bytes()); // max_count + assert_eq!(&buf[52..60], &body); + } + + #[test] + fn transfer_data_request_layout_padded() { + // body length 5 — body offset 52 + 5 = 57, padded to 60. + let body = [0xBBu8; 5]; + let buf = encode_transfer_data_request(sample_orpc_this(), 1, 2, 3, &body); + assert_eq!(buf.len(), 60); + assert_eq!(&buf[44..48], &5i32.to_le_bytes()); + assert_eq!(&buf[52..57], &body); + // padding bytes 57..60 are zero (default vec! init). + assert_eq!(&buf[57..60], &[0u8; 3]); + } + + #[test] + fn bstr_user_marshal_layout() { + // "AB" (2 chars, 4 UTF-16LE bytes) → header 12 + 4 bytes = 16. + let buf = encode_bstr_user_marshal("AB"); + assert_eq!(buf.len(), 16); + assert_eq!(&buf[0..4], &2i32.to_le_bytes()); + assert_eq!(&buf[4..8], &4i32.to_le_bytes()); + assert_eq!(&buf[8..12], &2i32.to_le_bytes()); + assert_eq!(&buf[12..14], &b"A\0"[..]); + assert_eq!(&buf[14..16], &b"B\0"[..]); + } + + #[test] + fn bstr_empty_string() { + let buf = encode_bstr_user_marshal(""); + assert_eq!(buf.len(), 12); + assert_eq!(&buf[0..4], &0i32.to_le_bytes()); + assert_eq!(&buf[4..8], &0i32.to_le_bytes()); + assert_eq!(&buf[8..12], &0i32.to_le_bytes()); + } + + #[test] + fn null_interface_pointer_is_4_zero_bytes() { + assert_eq!(encode_null_interface_pointer(), [0u8; 4]); + } + + #[test] + fn interface_pointer_referent_id_and_aligned_length() { + // OBJREF length 6 → 12 + 6 = 18 → align 4 → 20. + let obj = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06]; + let buf = encode_interface_pointer(&obj); + assert_eq!(buf.len(), 20); + assert_eq!(&buf[0..4], &0x0002_0000u32.to_le_bytes()); + assert_eq!(&buf[4..8], &6i32.to_le_bytes()); + assert_eq!(&buf[8..12], &6i32.to_le_bytes()); + assert_eq!(&buf[12..18], &obj); + assert_eq!(&buf[18..20], &[0u8; 2]); // padding + } + + #[test] + fn register_engine_2_request_with_callback_objref() { + // Uses a 16-byte OBJREF stub. + let obj = [0xCCu8; 16]; + let buf = encode_register_engine_2_request(sample_orpc_this(), 42, "Engine", 6, Some(&obj)); + // OrpcThis(32) + local_engine(4) + marker(4) + bstr(24) + version(4) + callback(28+pad) + // bstr: 12 + 12 (6 UTF-16 chars) = 24 + // callback: 12 + 16 = 28, already 4-aligned + // Total = 32 + 4 + 4 + 24 + 4 + 28 = 96. + assert_eq!(buf.len(), 96); + assert_eq!(&buf[32..36], &42i32.to_le_bytes()); + assert_eq!(&buf[36..40], &0x7265_5355i32.to_le_bytes()); // "Useu" + // BSTR header at 40..52. + assert_eq!(&buf[40..44], &6i32.to_le_bytes()); // 6 chars + // version at 64. + assert_eq!(&buf[64..68], &6i32.to_le_bytes()); + } + + #[test] + fn register_engine_2_request_with_null_callback() { + let buf = encode_register_engine_2_request(sample_orpc_this(), 7, "X", 1, None); + // OrpcThis(32) + 4 + 4 + bstr(14 → align 16) + version(4) + callback(4) + // bstr: 12 + 2 = 14 → align to 16 + // callback: 4 (null), version_offset + 4 + 4 = ?. Let's just check total > 0. + assert!(buf.len() >= 32 + 4 + 4 + 14 + 4 + 4); + // The null interface-pointer slot is 4 bytes of zero at the end. + let len = buf.len(); + assert_eq!(&buf[len - 4..len], &[0u8; 4]); + } + + #[test] + fn parse_get_partner_version_response_happy_path() { + let mut buf = vec![0u8; 16]; + // OrpcThat at 0..8 (zeros). + buf[8..12].copy_from_slice(&6i32.to_le_bytes()); // partner_version + buf[12..16].copy_from_slice(&0i32.to_le_bytes()); // S_OK + let r = parse_get_partner_version_response(&buf).unwrap(); + assert_eq!(r.partner_version, 6); + assert_eq!(r.hresult, 0); + } + + #[test] + fn parse_get_partner_version_short_buffer_errors() { + assert!(matches!( + parse_get_partner_version_response(&[0u8; 15]), + Err(RpcError::ShortRead { + expected: 16, + actual: 15 + }) + )); + } + + #[test] + fn parse_hresult_response_happy_path() { + let mut buf = vec![0u8; 12]; + buf[8..12].copy_from_slice(&0x8000_4005u32.to_le_bytes()); // E_FAIL + let r = parse_hresult_response(&buf).unwrap(); + assert_eq!(r.hresult, 0x8000_4005u32 as i32); + } + + #[test] + fn parse_hresult_response_short_buffer_errors() { + assert!(matches!( + parse_hresult_response(&[0u8; 11]), + Err(RpcError::ShortRead { + expected: 12, + actual: 11 + }) + )); + } +} diff --git a/rust/crates/mxaccess-rpc/src/object_exporter_client.rs b/rust/crates/mxaccess-rpc/src/object_exporter_client.rs new file mode 100644 index 0000000..eef5e68 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/object_exporter_client.rs @@ -0,0 +1,292 @@ +//! `IObjectExporter::ResolveOxid` transport wrappers. +//! +//! Direct port of the codec-driving methods from +//! `src/MxNativeClient/ObjectExporterClient.cs`. Two methods land here: +//! +//! - [`resolve_oxid_unauthenticated`] — mirrors `cs:14-30` +//! (`ResolveOxidUnauthenticated`). +//! - [`resolve_oxid_with_managed_ntlm_packet_integrity`] — mirrors +//! `cs:66-81` (`ResolveOxidWithManagedNtlmPacketIntegrity`). +//! +//! The two SSPI flavours (`ResolveOxidWithNtlmConnect` at `cs:32-47` and +//! `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) wrap +//! `System.Net.Security.SspiClientContext` — explicitly out of scope for +//! the Rust port. Resolves `design/followups.md` F9 down to the items +//! that are .NET-specific. + +#![allow(clippy::indexing_slicing)] + +use std::net::SocketAddr; + +use crate::ntlm::NtlmClientContext; +use crate::object_exporter::{ + IOBJECT_EXPORTER_IID, RESOLVE_OXID_OPNUM, ResolveOxidFailure, ResolveOxidResult, + encode_resolve_oxid_request, parse_resolve_oxid_failure, parse_resolve_oxid_result, +}; +use crate::transport::{DceRpcTcpClient, TransportError}; + +/// Outcome of a `ResolveOxid` call. Either the server returned a typed +/// `DUALSTRINGARRAY` (success or empty) or a 4-byte `RPC_C_NS_*` failure +/// status word. +#[derive(Debug, Clone)] +pub enum ResolveOxidOutcome { + /// Decoded `DUALSTRINGARRAY` (per `ResolveOxidResult`). + Result(ResolveOxidResult), + /// 4-byte trailing status (per `ResolveOxidFailure`). Returned when + /// the response stub is too short for a full result but matches the + /// failure tail shape. + Failure(ResolveOxidFailure), +} + +/// Drive a single `ResolveOxid` round-trip without authentication. +/// Mirrors `ObjectExporterClient.ResolveOxidUnauthenticated` +/// (`ObjectExporterClient.cs:14-30`). +/// +/// Steps (mirroring `cs:16-29`): +/// +/// 1. Open a TCP connection to `(host, port)`. +/// 2. Bind to `IObjectExporter` (version 0.0). +/// 3. Build a `ResolveOxid` request with the supplied `oxid` + `protseqs` +/// (defaults to `[ProtseqNcacnIpTcp]` when empty — per `cs:26`). +/// 4. Call opnum 0 on the bound context. +/// 5. Try [`parse_resolve_oxid_result`] first; if it fails with a typed +/// decode error, fall back to [`parse_resolve_oxid_failure`] over the +/// last 4 bytes per the .NET reference's two-shape return type. +/// +/// # Errors +/// I/O, codec, or fault from the server. +pub async fn resolve_oxid_unauthenticated( + addr: SocketAddr, + oxid: u64, + requested_protseqs: &[u16], +) -> Result { + let mut client = DceRpcTcpClient::connect(addr).await?; + let _bind = client.bind(IOBJECT_EXPORTER_IID, 0, 0).await?; + let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?; + let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?; + decode_resolve_oxid_response(&response.stub_data) +} + +/// Drive a single `ResolveOxid` round-trip with NTLMv2 packet-integrity +/// authentication. Mirrors `ObjectExporterClient.ResolveOxidWithManagedNtlmPacketIntegrity` +/// (`cs:66-81`). +/// +/// Steps mirror the unauthenticated variant but the bind is replaced +/// with [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`], +/// causing every subsequent call to be NTLM-signed. +/// +/// `ntlm` must be a fresh [`NtlmClientContext`] — it is consumed by the +/// transport for the lifetime of the connection. +/// +/// # Errors +/// I/O, codec, NTLM, or fault from the server. +pub async fn resolve_oxid_with_managed_ntlm_packet_integrity( + addr: SocketAddr, + oxid: u64, + requested_protseqs: &[u16], + ntlm: NtlmClientContext, +) -> Result { + let mut client = DceRpcTcpClient::connect(addr).await?; + let _bind = client + .bind_with_managed_ntlm_packet_integrity(IOBJECT_EXPORTER_IID, 0, 0, ntlm) + .await?; + let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?; + let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?; + decode_resolve_oxid_response(&response.stub_data) +} + +/// Default to `[ProtseqNcacnIpTcp]` when the caller passes an empty +/// slice — matches `cs:26` (`requestedProtseqs ?? [..]`). +fn default_protseqs(requested: &[u16]) -> &[u16] { + if requested.is_empty() { + &[crate::object_exporter::PROTSEQ_NCACN_IP_TCP] + } else { + requested + } +} + +/// Decode a `ResolveOxid` response stub. The .NET reference exposes two +/// parsers (`ParseResolveOxidResult` and `ParseResolveOxidFailure`) +/// without a discriminator on the wire — the choice is made by the +/// caller based on whether the stub looks like a typed result or just a +/// 4-byte status. The Rust port mirrors that: try the result parser +/// first; on `RpcError::ShortRead` or `RpcError::Decode` fall back to +/// the failure parser. +fn decode_resolve_oxid_response(stub: &[u8]) -> Result { + match parse_resolve_oxid_result(stub) { + Ok(result) => Ok(ResolveOxidOutcome::Result(result)), + Err(_) => Ok(ResolveOxidOutcome::Failure(parse_resolve_oxid_failure( + stub, + )?)), + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use crate::object_exporter::{ + IOBJECT_EXPORTER_IID, PROTSEQ_NCACN_IP_TCP, encode_resolve_oxid_request, + }; + use crate::pdu::{PacketType, PduHeader, ResponsePdu}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn local_addr() -> SocketAddr { + "127.0.0.1:0".parse().unwrap() + } + + /// Spin a hand-rolled DCE/RPC server that accepts one connection, + /// drains a Bind, replies with a minimal BindAck, drains a Request, + /// and replies with a Response carrying `stub_data`. + async fn one_shot_server(stub_data: Vec) -> (SocketAddr, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind(local_addr()).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + // 1. Drain Bind. + let mut hdr = [0u8; 16]; + sock.read_exact(&mut hdr).await.unwrap(); + let bind_h = PduHeader::decode(&hdr).unwrap(); + let mut body = vec![0u8; bind_h.fragment_length as usize - 16]; + sock.read_exact(&mut body).await.unwrap(); + // Reply with a 16-byte BindAck shell — DceRpcTcpClient::bind + // only inspects the header. + let resp_h = PduHeader { + version: 5, + version_minor: 0, + packet_type: PacketType::BindAck, + packet_flags: 0x03, + data_representation: 0x10, + fragment_length: 16, + auth_length: 0, + call_id: bind_h.call_id, + }; + let mut out = [0u8; 16]; + resp_h.encode(&mut out).unwrap(); + sock.write_all(&out).await.unwrap(); + + // 2. Drain Request. + sock.read_exact(&mut hdr).await.unwrap(); + let req_h = PduHeader::decode(&hdr).unwrap(); + let mut body = vec![0u8; req_h.fragment_length as usize - 16]; + sock.read_exact(&mut body).await.unwrap(); + + // 3. Reply with Response carrying the supplied stub_data. + let response = ResponsePdu { + header: PduHeader { + version: 5, + version_minor: 0, + packet_type: PacketType::Response, + packet_flags: 0x03, + data_representation: 0x10, + fragment_length: 0, // overwritten by encode + auth_length: 0, + call_id: req_h.call_id, + }, + allocation_hint: stub_data.len() as u32, + context_id: 0, + cancel_count: 0, + reserved23: 0, + stub_data, + }; + let bytes = response.encode(); + sock.write_all(&bytes).await.unwrap(); + }); + (addr, handle) + } + + #[tokio::test] + async fn resolve_oxid_unauthenticated_round_trip() { + // Build a synthetic ResolveOxid result stub: referent=1, max_count=1, + // entries=1, security_offset=2, dual-string [0x0007, 0, 0] (8 bytes + // padded to 12 with align_up), then 16-byte IPID + authn_hint(4) + + // status(4) trailing. + let mut stub = Vec::new(); + // referent_id != 0 + stub.extend_from_slice(&1u32.to_le_bytes()); + // max_count = 1 + stub.extend_from_slice(&1u32.to_le_bytes()); + // entries = 1, security_offset = 2 + stub.extend_from_slice(&1u16.to_le_bytes()); + stub.extend_from_slice(&2u16.to_le_bytes()); + // Dual-string array: 1 u16 (tower=0x0007), then need to align to 4. + stub.extend_from_slice(&0x0007u16.to_le_bytes()); + // Per parse_resolve_oxid_result: array_offset = 12; array_bytes = + // max_count * 2 = 2; offset after = align(14, 4) = 16. + // We've written 14 bytes so far; pad to 16. + stub.push(0); + stub.push(0); + // Trailing 24 bytes: IPID(16) + authn_hint(4) + status(4) + stub.extend_from_slice(&[0xCC; 16]); + stub.extend_from_slice(&0x1234u32.to_le_bytes()); + stub.extend_from_slice(&0u32.to_le_bytes()); + + let (addr, handle) = one_shot_server(stub).await; + let outcome = + resolve_oxid_unauthenticated(addr, 0xDEAD_BEEF_CAFE_BABE, &[PROTSEQ_NCACN_IP_TCP]) + .await + .unwrap(); + + match outcome { + ResolveOxidOutcome::Result(r) => { + assert_eq!(r.error_status, 0); + assert_eq!(r.authn_hint, 0x1234); + assert_eq!(r.rem_unknown_ipid.as_bytes(), &[0xCC; 16]); + } + ResolveOxidOutcome::Failure(_) => panic!("expected Result variant"), + } + handle.await.unwrap(); + } + + #[tokio::test] + async fn resolve_oxid_falls_back_to_failure_for_short_stub() { + // 4-byte stub with just an error_status — too short for a full + // result, must decode as Failure. + let stub = 0x8004_0007u32.to_le_bytes().to_vec(); + let (addr, handle) = one_shot_server(stub).await; + let outcome = resolve_oxid_unauthenticated(addr, 0, &[]).await.unwrap(); + match outcome { + ResolveOxidOutcome::Failure(f) => assert_eq!(f.error_status, 0x8004_0007), + ResolveOxidOutcome::Result(_) => panic!("expected Failure variant"), + } + handle.await.unwrap(); + } + + #[test] + fn default_protseqs_falls_back_when_empty() { + let r = default_protseqs(&[]); + assert_eq!(r, &[PROTSEQ_NCACN_IP_TCP]); + } + + #[test] + fn default_protseqs_passes_through_when_provided() { + let custom: &[u16] = &[0x0007, 0x001f]; + let r = default_protseqs(custom); + assert_eq!(r, custom); + } + + /// Compile-only check that the IID + opnum constants match the .NET + /// reference values used by the wrapper (sanity guard against + /// accidental constant drift). + #[test] + fn iid_and_opnum_constants_present() { + // IID first byte is 0xC4 (LE of 0x99FCFEC4 Data1). + assert_eq!(IOBJECT_EXPORTER_IID.as_bytes()[0], 0xC4); + assert_eq!(RESOLVE_OXID_OPNUM, 0); + } + + /// Verify the encode helper is callable from this module path + /// (catches `pub use` regressions during refactors). + #[test] + fn encode_resolve_oxid_request_callable() { + let buf = encode_resolve_oxid_request(0, &[PROTSEQ_NCACN_IP_TCP]).unwrap(); + assert!(!buf.is_empty()); + } +}