[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:
+3
-6
@@ -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<ResolveOxidResult, _>` (mirrors `ObjectExporterClient.cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity(addr, oxid, protseqs, ntlm) -> Result<ResolveOxidResult, _>` (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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user