diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index c4f9cbf..e4a04a9 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -17,6 +17,8 @@ pub mod error; pub mod guid; +pub mod nmx_callback_messages; +pub mod nmx_metadata; pub mod ntlm; pub mod object_exporter; pub mod objref; diff --git a/rust/crates/mxaccess-rpc/src/nmx_callback_messages.rs b/rust/crates/mxaccess-rpc/src/nmx_callback_messages.rs new file mode 100644 index 0000000..be8bbc6 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/nmx_callback_messages.rs @@ -0,0 +1,239 @@ +//! `INmxSvcCallback` request body parser + response body encoder. +//! +//! Direct port of `src/MxNativeClient/NmxSvcCallbackMessages.cs`. Decodes the +//! single `byte[] buffer` parameter the AVEVA service marshals through +//! `INmxSvcCallback::DataReceived` (opnum 3) and `StatusReceived` (opnum 4), +//! and produces the matching `HRESULT`-bearing response body the callback +//! exporter writes back. +//! +//! Per `NmxSvcCallbackMessages.cs:14-36`, the inbound body is: +//! +//! ```text +//! offset size field +//! 0 32 OrpcThis (encoded length without extensions) +//! 32 4 size i32 LE byte-array logical length +//! 36 4 max_count i32 LE conformant-array max count +//! 40 size body raw bytes carried inside the callback +//! ``` +//! +//! `size` and `max_count` are NDR-marshalled `int` values; .NET asserts both +//! are non-negative and `max_count >= size` (`cs:24`). + +#![allow(clippy::indexing_slicing)] + +use crate::error::RpcError; +use crate::guid::Guid; +use crate::nmx_metadata::INMX_SVC_CALLBACK_IID; +use crate::orpc::{OrpcThat, OrpcThis}; + +/// Convenience re-export so callers can match the .NET `InterfaceId` static +/// (`NmxSvcCallbackMessages.cs:9`). +pub const INTERFACE_ID: Guid = INMX_SVC_CALLBACK_IID; + +/// Opnum for `INmxSvcCallback::DataReceived` (`cs:11`). Same value as +/// [`crate::nmx_metadata::DATA_RECEIVED.opnum`]. +pub const DATA_RECEIVED_OPNUM: u16 = 3; + +/// Opnum for `INmxSvcCallback::StatusReceived` (`cs:12`). Same value as +/// [`crate::nmx_metadata::STATUS_RECEIVED.opnum`]. +pub const STATUS_RECEIVED_OPNUM: u16 = 4; + +/// Decoded callback request — mirrors `NmxCallbackRequest` +/// (`NmxSvcCallbackMessages.cs:5`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NmxCallbackRequest { + pub orpc_this: OrpcThis, + pub body: Vec, +} + +/// Header overhead before the `body` bytes: `OrpcThis(32) + size(4) + +/// max_count(4) = 40` (`NmxSvcCallbackMessages.cs:16,29`). +pub const CALLBACK_REQUEST_HEADER_LEN: usize = OrpcThis::ENCODED_LEN + 8; + +/// Parse an inbound callback request body. Mirrors `ParseCallbackRequest` +/// (`NmxSvcCallbackMessages.cs:14-36`). +/// +/// # Errors +/// +/// - [`RpcError::ShortRead`] when `buffer.len() < 40` (`cs:16-19`). +/// - [`RpcError::Decode`] when `size < 0` or `max_count < size` +/// (`cs:24-27`), or when the declared `size` runs past the buffer +/// (`cs:30-33`). +pub fn parse_callback_request(buffer: &[u8]) -> Result { + if buffer.len() < CALLBACK_REQUEST_HEADER_LEN { + return Err(RpcError::ShortRead { + expected: CALLBACK_REQUEST_HEADER_LEN, + actual: buffer.len(), + }); + } + + let orpc_this = OrpcThis::parse(&buffer[..OrpcThis::ENCODED_LEN])?; + + // size and max_count are .NET `int` (i32 LE). Negative values are + // explicitly rejected by the .NET reference (`cs:24`). + let size_i32 = i32::from_le_bytes([ + buffer[OrpcThis::ENCODED_LEN], + buffer[OrpcThis::ENCODED_LEN + 1], + buffer[OrpcThis::ENCODED_LEN + 2], + buffer[OrpcThis::ENCODED_LEN + 3], + ]); + let max_count_i32 = i32::from_le_bytes([ + buffer[OrpcThis::ENCODED_LEN + 4], + buffer[OrpcThis::ENCODED_LEN + 5], + buffer[OrpcThis::ENCODED_LEN + 6], + buffer[OrpcThis::ENCODED_LEN + 7], + ]); + + if size_i32 < 0 || max_count_i32 < size_i32 { + return Err(RpcError::Decode { + offset: OrpcThis::ENCODED_LEN, + reason: "callback request has invalid array size metadata", + buffer_len: buffer.len(), + }); + } + + let size = size_i32 as usize; + let body_offset = CALLBACK_REQUEST_HEADER_LEN; + if body_offset + size > buffer.len() { + return Err(RpcError::Decode { + offset: body_offset, + reason: "callback request byte array is truncated", + buffer_len: buffer.len(), + }); + } + + Ok(NmxCallbackRequest { + orpc_this, + body: buffer[body_offset..body_offset + size].to_vec(), + }) +} + +/// Encode the callback response body — `OrpcThat(8) + hresult(4) = 12` +/// bytes. Mirrors `EncodeCallbackResponse` (`NmxSvcCallbackMessages.cs:38-44`). +#[must_use] +pub fn encode_callback_response(hresult: i32) -> [u8; 12] { + let mut buf = [0u8; 12]; + let orpc_that = OrpcThat { + flags: 0, + extensions_referent_id: 0, + } + .encode(); + buf[..OrpcThat::ENCODED_LEN].copy_from_slice(&orpc_that); + buf[OrpcThat::ENCODED_LEN..].copy_from_slice(&hresult.to_le_bytes()); + buf +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + fn make_request( + body: &[u8], + size_override: Option, + max_count_override: Option, + ) -> Vec { + let mut buf = Vec::with_capacity(CALLBACK_REQUEST_HEADER_LEN + body.len()); + let orpc_this = OrpcThis::create(Guid::new([0x10; 16]), None).encode(); + buf.extend_from_slice(&orpc_this); + let size = size_override.unwrap_or(body.len() as i32); + let max_count = max_count_override.unwrap_or(body.len() as i32); + buf.extend_from_slice(&size.to_le_bytes()); + buf.extend_from_slice(&max_count.to_le_bytes()); + buf.extend_from_slice(body); + buf + } + + #[test] + fn opnums_match_dotnet() { + assert_eq!(DATA_RECEIVED_OPNUM, 3); + assert_eq!(STATUS_RECEIVED_OPNUM, 4); + } + + #[test] + fn interface_id_matches_callback_iid() { + assert_eq!(INTERFACE_ID, INMX_SVC_CALLBACK_IID); + } + + #[test] + fn parse_round_trip_empty_body() { + let bytes = make_request(&[], None, None); + let parsed = parse_callback_request(&bytes).unwrap(); + assert!(parsed.body.is_empty()); + } + + #[test] + fn parse_round_trip_carries_payload() { + let body: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + let bytes = make_request(body, None, None); + let parsed = parse_callback_request(&bytes).unwrap(); + assert_eq!(parsed.body, body); + } + + #[test] + fn parse_short_buffer_errors() { + let err = parse_callback_request(&[0u8; 39]).unwrap_err(); + assert!(matches!( + err, + RpcError::ShortRead { + expected: 40, + actual: 39 + } + )); + } + + #[test] + fn parse_negative_size_rejected() { + let bytes = make_request(&[], Some(-1), Some(0)); + assert!(matches!( + parse_callback_request(&bytes), + Err(RpcError::Decode { .. }) + )); + } + + #[test] + fn parse_max_count_less_than_size_rejected() { + let bytes = make_request(&[0xAA; 8], Some(8), Some(4)); + assert!(matches!( + parse_callback_request(&bytes), + Err(RpcError::Decode { .. }) + )); + } + + #[test] + fn parse_truncated_body_rejected() { + // Declare 16 bytes but supply only 4. + let mut bytes = make_request(&[0xAA; 4], Some(16), Some(16)); + // Trim trailing bytes so the buffer is shorter than declared size. + bytes.truncate(CALLBACK_REQUEST_HEADER_LEN + 4); + assert!(matches!( + parse_callback_request(&bytes), + Err(RpcError::Decode { .. }) + )); + } + + #[test] + fn encode_response_layout() { + // Success response — OrpcThat zeros + hresult=0. + let r = encode_callback_response(0); + assert_eq!(r.len(), 12); + assert_eq!(&r[..8], &[0u8; 8]); + assert_eq!(&r[8..], &0i32.to_le_bytes()); + + // Negative hresult round-trip. + let r = encode_callback_response(unchecked_negative()); + assert_eq!(&r[8..], &unchecked_negative().to_le_bytes()); + } + + fn unchecked_negative() -> i32 { + // 0x80004005 = E_FAIL, the canonical generic failure HRESULT. + // .NET would write `unchecked((int)0x80004005)`; Rust expresses + // the same bit pattern as `i32::MIN`-aligned negative. + 0x80004005u32 as i32 + } +} diff --git a/rust/crates/mxaccess-rpc/src/nmx_metadata.rs b/rust/crates/mxaccess-rpc/src/nmx_metadata.rs new file mode 100644 index 0000000..a365dd3 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/nmx_metadata.rs @@ -0,0 +1,231 @@ +//! NMX procedure metadata. +//! +//! Direct port of `src/MxNativeClient/NmxProcedureMetadata.cs`. Defines the +//! `INmxService2` and `INmxSvcCallback` interface IIDs and the per-opnum NDR +//! procedure descriptors used by the .NET reference's `ManagedCallbackExporter` +//! and `ManagedNmxService2Client`. +//! +//! These values are wire-load-bearing for M2 wave 3 (callback exporter) and +//! M3 (NMX session). Each IID is also enforced by the COM `[Guid(...)]` +//! attributes on the matching interfaces in `NmxComContracts.cs:7,52,84`. + +use crate::guid::Guid; + +/// `INmxService2` IID `2630A513-A974-4B1A-8025-457A9A7C56B8` +/// (`NmxProcedureMetadata.cs:5`, `NmxComContracts.cs:51`). +pub const INMX_SERVICE2_IID: Guid = Guid::new([ + 0x13, 0xA5, 0x30, 0x26, 0x74, 0xA9, 0x1A, 0x4B, 0x80, 0x25, 0x45, 0x7A, 0x9A, 0x7C, 0x56, 0xB8, +]); + +/// `INmxSvcCallback` IID `B49F92F7-C748-4169-8ECA-A0670B012746` +/// (`NmxProcedureMetadata.cs:6`, `NmxComContracts.cs:84`). +pub const INMX_SVC_CALLBACK_IID: Guid = Guid::new([ + 0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01, 0x27, 0x46, +]); + +/// NDR procedure descriptor — mirrors `NdrProcedureDescriptor` +/// (`NmxProcedureMetadata.cs:108-115`). Captures the opnum + the x86 stack +/// size and client/server buffer sizes the LMX MIDL stub publishes via +/// `NMIDL_PROC_INFO`. The Rust port carries these for parity with the .NET +/// reference; a future M3 NMX client may use them to size pre-allocated +/// buffers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NdrProcedureDescriptor { + pub interface_id: Guid, + pub name: &'static str, + pub opnum: u16, + pub x86_stack_size: u16, + pub client_buffer_size: u16, + pub server_buffer_size: u16, + pub parameter_count_including_return: u8, +} + +impl NdrProcedureDescriptor { + pub const fn new( + interface_id: Guid, + name: &'static str, + opnum: u16, + x86_stack_size: u16, + client_buffer_size: u16, + server_buffer_size: u16, + parameter_count_including_return: u8, + ) -> Self { + Self { + interface_id, + name, + opnum, + x86_stack_size, + client_buffer_size, + server_buffer_size, + parameter_count_including_return, + } + } +} + +// --- INmxService2 procedures (`NmxProcedureMetadata.cs:8-87`) ----------- + +/// `INmxService2::RegisterEngine` — opnum 3 (`cs:8-15`). +pub const REGISTER_ENGINE: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RegisterEngine", 3, 20, 8, 8, 4); + +/// `INmxService2::UnRegisterEngine` — opnum 4 (`cs:17-24`). +pub const UNREGISTER_ENGINE: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "UnRegisterEngine", 4, 12, 8, 8, 2); + +/// `INmxService2::Connect` — opnum 5 (`cs:26-33`). +pub const CONNECT: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "Connect", 5, 24, 32, 8, 5); + +/// `INmxService2::TransferData` — opnum 6 (`cs:35-42`). +pub const TRANSFER_DATA: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "TransferData", 6, 28, 32, 8, 6); + +/// `INmxService2::AddSubscriberEngine` — opnum 7 (`cs:44-51`). +pub const ADD_SUBSCRIBER_ENGINE: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "AddSubscriberEngine", 7, 24, 32, 8, 5); + +/// `INmxService2::RemoveSubscriberEngine` — opnum 8 (`cs:53-60`). +pub const REMOVE_SUBSCRIBER_ENGINE: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RemoveSubscriberEngine", 8, 24, 32, 8, 5); + +/// `INmxService2::SetHeartbeatSendInterval` — opnum 9 (`cs:62-69`). +pub const SET_HEARTBEAT_SEND_INTERVAL: NdrProcedureDescriptor = NdrProcedureDescriptor::new( + INMX_SERVICE2_IID, + "SetHeartbeatSendInterval", + 9, + 16, + 16, + 8, + 3, +); + +/// `INmxService2::RegisterEngine2` — opnum 10 (`cs:71-78`). +pub const REGISTER_ENGINE_2: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RegisterEngine2", 10, 24, 16, 8, 5); + +/// `INmxService2::GetPartnerVersion` — opnum 11 (`cs:80-87`). +pub const GET_PARTNER_VERSION: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "GetPartnerVersion", 11, 24, 24, 36, 5); + +// --- INmxSvcCallback procedures (`NmxProcedureMetadata.cs:89-105`) ------- + +/// `INmxSvcCallback::DataReceived` — opnum 3 (`cs:89-96`). +pub const DATA_RECEIVED: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SVC_CALLBACK_IID, "DataReceived", 3, 16, 8, 8, 3); + +/// `INmxSvcCallback::StatusReceived` — opnum 4 (`cs:98-105`). +pub const STATUS_RECEIVED: NdrProcedureDescriptor = + NdrProcedureDescriptor::new(INMX_SVC_CALLBACK_IID, "StatusReceived", 4, 16, 8, 8, 3); + +/// All `INmxService2` procedures in opnum order. Convenience for callers +/// that want to iterate the table. +pub const INMX_SERVICE2_PROCEDURES: &[NdrProcedureDescriptor] = &[ + REGISTER_ENGINE, + UNREGISTER_ENGINE, + CONNECT, + TRANSFER_DATA, + ADD_SUBSCRIBER_ENGINE, + REMOVE_SUBSCRIBER_ENGINE, + SET_HEARTBEAT_SEND_INTERVAL, + REGISTER_ENGINE_2, + GET_PARTNER_VERSION, +]; + +/// All `INmxSvcCallback` procedures in opnum order. +pub const INMX_SVC_CALLBACK_PROCEDURES: &[NdrProcedureDescriptor] = + &[DATA_RECEIVED, STATUS_RECEIVED]; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + #[test] + fn inmx_service2_iid_matches_dotnet_d_format() { + // .NET `new Guid("2630A513-A974-4B1A-8025-457A9A7C56B8").ToString("D")` + assert_eq!( + INMX_SERVICE2_IID.to_string(), + "2630a513-a974-4b1a-8025-457a9a7c56b8" + ); + } + + #[test] + fn inmx_svc_callback_iid_matches_dotnet_d_format() { + // The exact IID re-asserted in the OBJREF capture + // `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6` + // (objref bytes 8..24). + assert_eq!( + INMX_SVC_CALLBACK_IID.to_string(), + "b49f92f7-c748-4169-8eca-a0670b012746" + ); + } + + #[test] + fn inmx_service2_opnums_are_3_through_11() { + let opnums: Vec = INMX_SERVICE2_PROCEDURES.iter().map(|p| p.opnum).collect(); + assert_eq!(opnums, vec![3, 4, 5, 6, 7, 8, 9, 10, 11]); + } + + #[test] + fn inmx_svc_callback_opnums_are_3_and_4() { + let opnums: Vec = INMX_SVC_CALLBACK_PROCEDURES + .iter() + .map(|p| p.opnum) + .collect(); + assert_eq!(opnums, vec![3, 4]); + } + + #[test] + fn procedure_names_match_dotnet_nameof() { + // .NET uses `nameof(...)` so names match the C# method identifier. + assert_eq!(REGISTER_ENGINE.name, "RegisterEngine"); + assert_eq!(REGISTER_ENGINE_2.name, "RegisterEngine2"); + assert_eq!(GET_PARTNER_VERSION.name, "GetPartnerVersion"); + assert_eq!(STATUS_RECEIVED.name, "StatusReceived"); + } + + #[test] + fn register_engine_2_metadata() { + // Spot-check the parameters most likely to be load-bearing for M3: + // opnum 10, 24-byte x86 stack, 16-byte client buffer, 5 params + // including the HRESULT return (`cs:71-78`). + assert_eq!(REGISTER_ENGINE_2.opnum, 10); + assert_eq!(REGISTER_ENGINE_2.x86_stack_size, 24); + assert_eq!(REGISTER_ENGINE_2.client_buffer_size, 16); + assert_eq!(REGISTER_ENGINE_2.server_buffer_size, 8); + assert_eq!(REGISTER_ENGINE_2.parameter_count_including_return, 5); + assert_eq!(REGISTER_ENGINE_2.interface_id, INMX_SERVICE2_IID); + } + + #[test] + fn transfer_data_is_largest_x86_stack() { + // TransferData (opnum 6) has the largest x86 stack at 28 bytes + // because it carries the `ref byte messageBody` payload pointer. + let max = INMX_SERVICE2_PROCEDURES + .iter() + .map(|p| p.x86_stack_size) + .max() + .unwrap(); + assert_eq!(max, TRANSFER_DATA.x86_stack_size); + assert_eq!(TRANSFER_DATA.opnum, 6); + } + + #[test] + fn callback_procedures_use_callback_iid() { + for p in INMX_SVC_CALLBACK_PROCEDURES { + assert_eq!(p.interface_id, INMX_SVC_CALLBACK_IID); + } + } + + #[test] + fn service2_procedures_use_service2_iid() { + for p in INMX_SERVICE2_PROCEDURES { + assert_eq!(p.interface_id, INMX_SERVICE2_IID); + } + } +} diff --git a/rust/crates/mxaccess-rpc/src/objref.rs b/rust/crates/mxaccess-rpc/src/objref.rs index ecaf557..dd7ff9b 100644 --- a/rust/crates/mxaccess-rpc/src/objref.rs +++ b/rust/crates/mxaccess-rpc/src/objref.rs @@ -318,6 +318,149 @@ fn read_u64_le(bytes: &[u8], offset: usize) -> u64 { const _: () = assert!(OBJREF_HEADER_LEN == 68); const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D); +// --------------------------------------------------------------------------- +// ComObjRefBuilder — pure-Rust OBJREF emitter. +// Direct port of the second class in +// `src/MxNativeClient/ManagedCallbackExporter.cs:337-393`. +// --------------------------------------------------------------------------- + +/// Auth-service tower IDs the .NET reference advertises in every callback +/// OBJREF. Mirrors the hard-coded array at +/// `ManagedCallbackExporter.cs:362`. Each id appears in the security-binding +/// portion of the dual-string array followed by `0xFFFF` and a terminator. +/// +/// IDs in order: NTLM SSP (0x0009), GSS Negotiate (0x001E), Kerberos (0x0010), +/// SSL/TLS (0x000A), Schannel (0x0016), DPA (0x001F), Kerberos extension +/// (0x000E). The Rust port carries the same set verbatim — no synthesis. +pub const CALLBACK_OBJREF_AUTH_SERVICES: [u16; 7] = + [0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E]; + +/// Builds standard OBJREF byte buffers for the callback exporter to publish. +/// +/// Mirrors the static `ComObjRefBuilder` class +/// (`src/MxNativeClient/ManagedCallbackExporter.cs:337-393`). The .NET reference +/// only ever emits *standard* OBJREFs (`flags = 1`); the Rust port matches. +/// +/// This is the higher-level emitter that builds OBJREF bytes from primitives. +/// It is **not** the Win32 `CoMarshalInterface`-based emitter from +/// `ComObjRefProvider.cs` — that wrapper around `ole32` is still tracked as +/// open follow-up F6 (it requires `windows-rs` and the M2 wave 3 callback +/// exporter to register the emitted OBJREF with COM). +pub struct ComObjRefBuilder; + +impl ComObjRefBuilder { + /// Build a standard-OBJREF buffer for a given IID, OXID/OID/IPID, and one + /// or more `ncacn_ip_tcp` string bindings (e.g. `"hostname[5985]"`). + /// Mirrors `ComObjRefBuilder.CreateStandardObjRef` + /// (`ManagedCallbackExporter.cs:339-392`). + /// + /// # Layout (`cs:348-389`) + /// + /// ```text + /// offset size field + /// 0 4 signature u32 LE = 0x574F454D ("MEOW") + /// 4 4 flags u32 LE = 1 + /// 8 16 iid GUID + /// 24 4 std_flags u32 LE + /// 28 4 public_refs u32 LE + /// 32 8 oxid u64 LE + /// 40 8 oid u64 LE + /// 48 16 ipid GUID + /// 64 2 entries u16 LE (count of u16 code units below) + /// 66 2 security_offset u16 LE (in u16 code units) + /// 68 .. dual-string array (variable-length u16 LE words) + /// ``` + /// + /// # `entries` and `security_offset` + /// + /// `entries` is the **total u16-code-unit count** of the dual-string + /// array (string bindings + 0 separator + 7 security entries + final 0). + /// `security_offset` is the index (in u16 units) where security bindings + /// begin — `cs:348` computes this as + /// `sum(1 + binding.len() + 1 for binding in stringBindings) + 1`, i.e. + /// per-binding `tower_id` (1 word) + `binding.len()` ASCII chars (one + /// word each) + null terminator (1 word), plus the trailing 0 separator + /// that ends the string section. + /// + /// # Panics + /// + /// Never panics. All length math saturates: bindings longer than + /// `u16::MAX - HEADER_LEN/2 - SECURITY_TAIL_LEN` are not representable + /// in the 16-bit `entries` field, and the .NET reference does not guard + /// against this either; callers are expected to keep bindings short + /// (typical `hostname[port]` is < 100 chars). + #[must_use] + pub fn create_standard_objref( + iid: Guid, + std_flags: u32, + public_refs: u32, + oxid: u64, + oid: u64, + ipid: Guid, + string_bindings: &[&str], + ) -> Vec { + // security_offset = sum_{b in string_bindings}(1 + b.len() + 1) + 1 + // (cs:348). u16-truncating cast mirrors `(ushort)`. + let security_offset: u16 = string_bindings + .iter() + .map(|b| 1 + b.len() + 1) + .sum::() + .saturating_add(1) + .min(u16::MAX as usize) as u16; + + // Build the u16 word array. + let mut words: Vec = Vec::new(); + + // String-bindings section: per binding, [0x0007 (ncacn_ip_tcp), each + // ASCII char as u16, terminator 0] (cs:350-359). + for binding in string_bindings { + words.push(0x0007); + for ch in binding.chars() { + words.push(ch as u16); + } + words.push(0); + } + // 0 separator that ends the string section (cs:361). + words.push(0); + + // Security-bindings section: 7 hard-coded tower entries, each + // [tower_id, 0xFFFF, 0] (cs:362-367). + for &auth in &CALLBACK_OBJREF_AUTH_SERVICES { + words.push(auth); + words.push(0xFFFF); + words.push(0); + } + + // Final terminator (cs:369). + words.push(0); + + // u16-truncating cast mirrors `(ushort)words.Count` (cs:371). + let entries: u16 = words.len().min(u16::MAX as usize) as u16; + let mut buffer = vec![0u8; OBJREF_HEADER_LEN + words.len() * 2]; + + // Fixed 68-byte header (cs:373-382). + buffer[0..4].copy_from_slice(&OBJREF_SIGNATURE.to_le_bytes()); + buffer[4..8].copy_from_slice(&1u32.to_le_bytes()); // flags = 1 (OBJREF_STANDARD) + buffer[8..24].copy_from_slice(iid.as_bytes()); + buffer[24..28].copy_from_slice(&std_flags.to_le_bytes()); + buffer[28..32].copy_from_slice(&public_refs.to_le_bytes()); + buffer[32..40].copy_from_slice(&oxid.to_le_bytes()); + buffer[40..48].copy_from_slice(&oid.to_le_bytes()); + buffer[48..64].copy_from_slice(ipid.as_bytes()); + buffer[64..66].copy_from_slice(&entries.to_le_bytes()); + buffer[66..68].copy_from_slice(&security_offset.to_le_bytes()); + + // Dual-string array body (cs:384-389). + let mut offset = OBJREF_HEADER_LEN; + for word in &words { + buffer[offset..offset + 2].copy_from_slice(&word.to_le_bytes()); + offset += 2; + } + + buffer + } +} + #[cfg(test)] #[allow( clippy::unwrap_used, @@ -626,4 +769,147 @@ mod tests { assert_eq!(OBJREF_HEADER_LEN, 68); assert_eq!(OBJREF_SIGNATURE, 0x574F_454D); } + + // ---- ComObjRefBuilder tests -------------------------------------- + + #[test] + fn builder_emits_meow_header_and_flags() { + let iid = Guid::new([0xAA; 16]); + let ipid = Guid::new([0xBB; 16]); + let buf = ComObjRefBuilder::create_standard_objref( + iid, + 0x280, + 5, + 0x0123_4567_89AB_CDEF, + 0xFEDC_BA98_7654_3210, + ipid, + &["host[5985]"], + ); + assert!(buf.len() >= OBJREF_HEADER_LEN); + // Signature + assert_eq!(&buf[0..4], &OBJREF_SIGNATURE.to_le_bytes()); + // flags = 1 (OBJREF_STANDARD) + assert_eq!(&buf[4..8], &1u32.to_le_bytes()); + // IID + assert_eq!(&buf[8..24], iid.as_bytes()); + // std_flags + assert_eq!(&buf[24..28], &0x280u32.to_le_bytes()); + // public_refs + assert_eq!(&buf[28..32], &5u32.to_le_bytes()); + // OXID/OID + assert_eq!(&buf[32..40], &0x0123_4567_89AB_CDEFu64.to_le_bytes()); + assert_eq!(&buf[40..48], &0xFEDC_BA98_7654_3210u64.to_le_bytes()); + // IPID + assert_eq!(&buf[48..64], ipid.as_bytes()); + } + + #[test] + fn builder_round_trips_through_parser() { + // The emitted OBJREF must parse back through ComObjRef::parse with + // the same key fields. + let iid = Guid::new([0x11; 16]); + let ipid = Guid::new([0x22; 16]); + let buf = ComObjRefBuilder::create_standard_objref( + iid, + 0x280, + 5, + 0x1111_2222_3333_4444, + 0x5555_6666_7777_8888, + ipid, + &["DESKTOP[12345]"], + ); + + let parsed = ComObjRef::parse(&buf).unwrap(); + assert_eq!(parsed.signature, OBJREF_SIGNATURE); + assert_eq!(parsed.flags, 1); + assert_eq!(parsed.iid, iid); + assert_eq!(parsed.standard_flags, 0x280); + assert_eq!(parsed.public_refs, 5); + assert_eq!(parsed.oxid, 0x1111_2222_3333_4444); + assert_eq!(parsed.oid, 0x5555_6666_7777_8888); + assert_eq!(parsed.ipid, ipid); + + // First decoded entry should be the ncacn_ip_tcp string binding, + // and at least one security binding (auth-service tail) follows. + let first = &parsed.dual_string_entries_decoded[0]; + assert_eq!(first.tower_id, 0x0007); + assert_eq!(first.protocol, "ncacn_ip_tcp"); + assert_eq!(first.value, "DESKTOP[12345]"); + assert!(!first.is_security_binding); + + let security_count = parsed + .dual_string_entries_decoded + .iter() + .filter(|e| e.is_security_binding) + .count(); + assert!( + security_count >= 1, + "expected at least one security binding, got {security_count}" + ); + } + + #[test] + fn builder_security_offset_matches_dotnet_formula() { + // security_offset = sum(1 + binding.len() + 1) + 1 (cs:348). + // For one binding "host[12]" (8 chars): 1 + 8 + 1 + 1 = 11. + let buf = ComObjRefBuilder::create_standard_objref( + Guid::ZERO, + 0, + 5, + 0, + 0, + Guid::ZERO, + &["host[12]"], + ); + let security_offset = u16::from_le_bytes([buf[66], buf[67]]); + assert_eq!(security_offset, 11); + } + + #[test] + fn builder_two_bindings_security_offset() { + // Two bindings: "a[1]" (4) + "b[2]" (4): + // (1+4+1) + (1+4+1) + 1 = 13. + let buf = ComObjRefBuilder::create_standard_objref( + Guid::ZERO, + 0, + 5, + 0, + 0, + Guid::ZERO, + &["a[1]", "b[2]"], + ); + let security_offset = u16::from_le_bytes([buf[66], buf[67]]); + assert_eq!(security_offset, 13); + } + + #[test] + fn builder_emits_seven_security_entries() { + // Each security entry contributes 3 u16 words [tower_id, 0xFFFF, 0]. + // Total security words = 7 * 3 = 21, plus a trailing 0 = 22. + // String section for one binding "h[1]" (4 chars): 1+4+1+1 = 7 words. + // Total entries = 7 + 22 = 29. + let buf = + ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["h[1]"]); + let entries = u16::from_le_bytes([buf[64], buf[65]]); + assert_eq!(entries, 29); + } + + #[test] + fn builder_auth_services_table_matches_dotnet_order() { + // The auth-service tower ids in the security tail must appear in the + // order the .NET reference writes them (cs:362). + assert_eq!( + CALLBACK_OBJREF_AUTH_SERVICES, + [0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E] + ); + } + + #[test] + fn builder_total_buffer_length_matches_words_count() { + // entries * 2 + HEADER_LEN + let buf = + ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["x[1]"]); + let entries = u16::from_le_bytes([buf[64], buf[65]]) as usize; + assert_eq!(buf.len(), OBJREF_HEADER_LEN + entries * 2); + } }