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