Files
mxaccess/rust/crates/mxaccess-rpc/src/object_exporter_client.rs
T
Joseph Doherty ecfcc3f429 [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>
2026-05-05 07:56:11 -04:00

293 lines
12 KiB
Rust

//! `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());
}
}