[M3] mxaccess-rpc: NmxService2 codec + F9 ResolveOxid wrappers

Two units of work in one commit:

1. nmx_service2_messages.rs (~470 LoC, 18 tests) — port of
   NmxService2Messages.cs. Encoders for all 9 INmxService2 opnums
   (RegisterEngine, UnRegisterEngine, Connect, TransferData,
   AddSubscriberEngine, RemoveSubscriberEngine, SetHeartbeatSendInterval,
   RegisterEngine2, GetPartnerVersion) plus BSTR + InterfacePointer NDR
   helpers used by RegisterEngine2 marshalling. Decoders for the
   GetPartnerVersion result and the generic HRESULT response. M3 stream
   B (NmxClient) will be a thin layer over these + the transport.

2. object_exporter_client.rs (~290 LoC, 6 tests including 2 real-socket
   tokio tests) — resolves followup F9. Implements:
   - resolve_oxid_unauthenticated (cs:14-30)
   - resolve_oxid_with_managed_ntlm_packet_integrity (cs:66-81)
   ResolveOxidOutcome enum disambiguates the two response shapes the
   .NET reference parses (typed result vs 4-byte failure). The two SSPI
   flavours (cs:32-47, cs:49-64) are permanently skipped — they wrap
   .NET-only System.Net.Security.SspiClientContext.

design/followups.md: F9 moved to Resolved with this commit's hash.

Test count delta: 364 -> 389 (+25; mxaccess-rpc 137 -> 162; +18 from
nmx_service2_messages, +7 from object_exporter_client which includes
the +2 fall-through tests for the dual-shape response decoder).
Open followups touched: F9 resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 07:56:11 -04:00
parent 432f1102b7
commit ecfcc3f429
4 changed files with 842 additions and 6 deletions
+2
View File
@@ -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;
@@ -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<u8>`
//! 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
let utf16: Vec<u16> = 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<u8> {
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<NmxGetPartnerVersionResult, RpcError> {
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<NmxHResultResponse, RpcError> {
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
})
));
}
}
@@ -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<ResolveOxidOutcome, TransportError> {
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<ResolveOxidOutcome, TransportError> {
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<ResolveOxidOutcome, TransportError> {
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<u8>) -> (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());
}
}