Files
mxaccess/rust/crates/mxaccess-rpc/src/object_exporter.rs
T
Joseph Doherty 30138629d3 [M2] mxaccess-rpc: OXID + RemQI body codecs (wave 2)
Lands M2 wave 2 — two pure-Rust body-codec modules under
crates/mxaccess-rpc, plus a small inline ORPC framing port and a
crate-level type consolidation. Resolves F7+F8 from wave 1.

New modules
- guid.rs (4 tests) — hoisted from objref::Guid; shared by all of
  mxaccess-rpc. Resolves F7.
- error.rs — hoisted RpcError union (ShortRead, UnexpectedPacketType,
  UnknownPacketType, InvalidFragmentLength, TruncatedBindBody,
  InvalidAuthTrailer, MissingAuthValue, Decode). Resolves F8.
- orpc.rs (8 tests) — port of OrpcStructures.cs:1-141. ComVersion,
  OrpcThis (32-byte header), OrpcThat (8-byte header),
  MInterfacePointer (length-prefixed OBJREF), StdObjRef (40 bytes).
- object_exporter.rs (~530 LoC, 20 tests) — port of
  ObjectExporterMessages.cs:1-141. IObjectExporter IID, opnums,
  ResolveOxid request encoder + ResolveOxidResult/Failure parsers.
  Owned-string protocol labels cleaned up via Cow upgrade rather than
  Box::leak (ComDualStringEntry::protocol is now Cow<'static, str>).
- rem_unknown.rs (~340 LoC, 11 tests) — port of RemUnknownMessages.cs.
  IRemUnknown IID, RemQueryInterface request/response, RemQiResult.
  4-byte NDR pad in REMQIRESULT preserved as pad_after_hresult per
  CLAUDE.md unknown-bytes rule.

Test count delta: 277 -> 319 (+42; codec 215 unchanged, mxaccess-rpc
60 -> 102, codec parity 2 unchanged).
Open followups touched: F7 + F8 resolved; F9, F10, F11 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:14:29 -04:00

750 lines
28 KiB
Rust

//! `IObjectExporter` body codec — `ResolveOxid` request/response.
//!
//! Direct port of the codec-only members of
//! `src/MxNativeClient/ObjectExporterMessages.cs`. This module covers:
//!
//! - The `IObjectExporter` interface IID and opnum constants
//! (`ObjectExporterMessages.cs:7-13`).
//! - DCE/RPC protocol-sequence ids used by `ResolveOxid`
//! (`ObjectExporterMessages.cs:15-16`, `[MS-DCOM]` §2.2.10).
//! - [`encode_resolve_oxid_request`] — produces the marshalled request stub
//! for `IObjectExporter::ResolveOxid` (opnum 0). Mirrors
//! `EncodeResolveOxidRequest` (`ObjectExporterMessages.cs:18-37`).
//! - [`parse_resolve_oxid_failure`] — extracts the trailing 4-byte error
//! status from a failure response stub. Mirrors
//! `ParseResolveOxidFailure` (`ObjectExporterMessages.cs:39-47`).
//! - [`parse_resolve_oxid_result`] — decodes the success-shape response
//! stub (DUALSTRINGARRAY of bindings + IPID + authn-hint + status).
//! Mirrors `ParseResolveOxidResult` (`ObjectExporterMessages.cs:49-90`).
//!
//! **Not ported here:** `src/MxNativeClient/ObjectExporterClient.cs`. Those
//! four methods (`ResolveOxidUnauthenticated`,
//! `ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`,
//! `ResolveOxidWithManagedNtlmPacketIntegrity`) are transport-layer code
//! that depend on a `DceRpcTcpClient` we have not yet ported. They will
//! follow once the transport crate exists.
//!
//! The dual-string-array decode in this module is intentionally **not**
//! consolidated with [`crate::objref::ComObjRef`]'s decoder. The two
//! shapes differ in three documented ways
//! (`ObjectExporterMessages.cs:92-126`):
//!
//! 1. The loop iterates `entries` u16 code units exactly — **not**
//! `min(entries, data.len()/2)` like the OBJREF parser
//! (`ComObjRef.cs:59`). The caller is responsible for slicing the input
//! to the expected byte length up front.
//! 2. Non-printable code units are escaped as a single `'?'` character —
//! **not** the `<XXXX>` lowercase-hex form used by `ComObjRef`.
//! 3. The protocol label is either `"ncacn_ip_tcp"` (for `0x0007`) or a
//! decimal-formatted `"protseq_0x{:04x}"` fallback — there is no other
//! tower-id table.
// Direct byte indexing — every access is guarded by an explicit length check
// and the result reads as a 1:1 mirror of the .NET `BinaryPrimitives` calls.
// `.get(n)?` would obscure the byte map. Mirrors the rationale documented in
// `crates/mxaccess-codec/src/reference_handle.rs:7-11` and `objref.rs:25`.
#![allow(clippy::indexing_slicing)]
use crate::error::RpcError;
use crate::guid::Guid;
use crate::objref::ComDualStringEntry;
/// `IObjectExporter` IID `99FCFEC4-5260-101B-BBCB-00AA0021347A`
/// (`ObjectExporterMessages.cs:7`, `[MS-DCOM]` §1.9). The wire bytes are
/// .NET `Guid.TryWriteBytes(span)` order: first three groups
/// little-endian (`Data1` u32 LE, `Data2` u16 LE, `Data3` u16 LE) followed
/// by 8 big-endian `Data4` bytes.
pub const IOBJECT_EXPORTER_IID: Guid = Guid::new([
0xC4, 0xFE, 0xFC, 0x99, 0x60, 0x52, 0x1B, 0x10, 0xBB, 0xCB, 0x00, 0xAA, 0x00, 0x21, 0x34, 0x7A,
]);
/// Opnum 0 — `ResolveOxid` (`ObjectExporterMessages.cs:8`,
/// `[MS-DCOM]` §3.1.2.5.1.1).
pub const RESOLVE_OXID_OPNUM: u16 = 0;
/// Opnum 1 — `SimplePing` (`ObjectExporterMessages.cs:9`).
pub const SIMPLE_PING_OPNUM: u16 = 1;
/// Opnum 2 — `ComplexPing` (`ObjectExporterMessages.cs:10`).
pub const COMPLEX_PING_OPNUM: u16 = 2;
/// Opnum 3 — `ServerAlive` (`ObjectExporterMessages.cs:11`).
pub const SERVER_ALIVE_OPNUM: u16 = 3;
/// Opnum 4 — `ResolveOxid2` (`ObjectExporterMessages.cs:12`).
pub const RESOLVE_OXID2_OPNUM: u16 = 4;
/// Opnum 5 — `ServerAlive2` (`ObjectExporterMessages.cs:13`).
pub const SERVER_ALIVE2_OPNUM: u16 = 5;
/// Protocol sequence `ncacn_ip_tcp` (`ObjectExporterMessages.cs:15`,
/// `[MS-DCOM]` §2.2.10).
pub const PROTSEQ_NCACN_IP_TCP: u16 = 0x0007;
/// Protocol sequence `ncalrpc` (`ObjectExporterMessages.cs:16`).
pub const PROTSEQ_NCALRPC: u16 = 0x001f;
/// 4-byte alignment helper. Mirrors `Align`
/// (`ObjectExporterMessages.cs:128-132`).
const fn align(value: usize, alignment: usize) -> usize {
let remainder = value % alignment;
if remainder == 0 {
value
} else {
value + alignment - remainder
}
}
/// Encode the `IObjectExporter::ResolveOxid` request stub.
///
/// Wire layout (`ObjectExporterMessages.cs:18-37`):
///
/// ```text
/// offset size field
/// 0 8 oxid u64 LE
/// 8 2 count (short) u16 LE (= requested_protseqs.len())
/// 10 2 <padding> u16 (zero — implicit from buffer init)
/// 12 4 count (max) u32 LE (= requested_protseqs.len())
/// 16 N*2 protseqs[] u16 LE each
/// ```
///
/// The buffer length is then 4-byte aligned per `Align(length, 4)`
/// (`:26`); for an odd-length protseq array this adds 2 trailing zero
/// bytes.
///
/// # Errors
///
/// Returns [`RpcError::Decode`] if `requested_protseqs` is empty —
/// mirrors the .NET `ArgumentException` at
/// `ObjectExporterMessages.cs:21-23`.
pub fn encode_resolve_oxid_request(
oxid: u64,
requested_protseqs: &[u16],
) -> Result<Vec<u8>, RpcError> {
if requested_protseqs.is_empty() {
return Err(RpcError::Decode {
offset: 0,
reason: "ResolveOxid request requires at least one protseq",
buffer_len: 0,
});
}
// u16 protseq array — `len * 2` is identical to .NET's
// `requestedProtseqs.Count * sizeof(ushort)` (cs:25).
let mut length = 8 + 2 + 2 + 4 + std::mem::size_of_val(requested_protseqs);
length = align(length, 4);
let mut buffer = vec![0u8; length];
buffer[0..8].copy_from_slice(&oxid.to_le_bytes());
// Truncating cast mirrors the .NET `(ushort)requestedProtseqs.Count`.
let count_u16: u16 = (requested_protseqs.len() as u32) as u16;
buffer[8..10].copy_from_slice(&count_u16.to_le_bytes());
let count_u32: u32 = requested_protseqs.len() as u32;
buffer[12..16].copy_from_slice(&count_u32.to_le_bytes());
for (i, ps) in requested_protseqs.iter().enumerate() {
let off = 16 + i * size_of::<u16>();
buffer[off..off + 2].copy_from_slice(&ps.to_le_bytes());
}
Ok(buffer)
}
/// Failure-shape response of `IObjectExporter::ResolveOxid` — only the
/// trailing 4-byte HRESULT/`error_status` is meaningful.
///
/// Mirrors `ResolveOxidFailure` (`ObjectExporterMessages.cs:135`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ResolveOxidFailure {
pub error_status: u32,
}
/// Success-shape response of `IObjectExporter::ResolveOxid` — the
/// DUALSTRINGARRAY of server bindings + IPID for `IRemUnknown` +
/// authn-svc hint + final status.
///
/// Mirrors `ResolveOxidResult` (`ObjectExporterMessages.cs:137-141`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResolveOxidResult {
pub bindings: Vec<ComDualStringEntry>,
pub rem_unknown_ipid: Guid,
pub authn_hint: u32,
pub error_status: u32,
}
/// Parse a failure-shape `ResolveOxid` response stub. The 4-byte status
/// sits at the **end** of the stub (`stub[^4..]`,
/// `ObjectExporterMessages.cs:46`).
///
/// # Errors
///
/// Returns [`RpcError::ShortRead`] if the stub is shorter than 4 bytes —
/// mirrors the .NET `ArgumentException` at
/// `ObjectExporterMessages.cs:41-44`.
pub fn parse_resolve_oxid_failure(stub: &[u8]) -> Result<ResolveOxidFailure, RpcError> {
if stub.len() < 4 {
return Err(RpcError::ShortRead {
expected: 4,
actual: stub.len(),
});
}
let tail = &stub[stub.len() - 4..];
Ok(ResolveOxidFailure {
error_status: u32::from_le_bytes([tail[0], tail[1], tail[2], tail[3]]),
})
}
/// Parse a success-shape `ResolveOxid` response stub.
///
/// Wire layout (`ObjectExporterMessages.cs:49-90`):
///
/// ```text
/// offset size field
/// 0 4 referent_id u32 LE
/// 4 4 max_count u32 LE (NDR conformant array max)
/// 8 2 entries u16 LE (DUALSTRINGARRAY wNumEntries)
/// 10 2 security_offset u16 LE (DUALSTRINGARRAY wSecurityOffset)
/// 12 .. dual-string array u16 LE each, length = entries * 2 bytes
/// ... .. padding to next 4-byte boundary
/// ... 16 rem_unknown_ipid GUID
/// ... 4 authn_hint u32 LE
/// ... 4 error_status u32 LE
/// ```
///
/// Notable behaviors mirrored from the .NET source:
///
/// - If `referent_id == 0` the bindings are empty, IPID is zero, authn
/// hint is zero, and the error status is read from the **trailing** 4
/// bytes (`ObjectExporterMessages.cs:57-61`).
/// - If `max_count < entries` the input is rejected
/// (`ObjectExporterMessages.cs:66-69`).
/// - `arrayBytes = max_count * sizeof(u16)` is the conformant-array byte
/// length; the dual-string decode is sliced to `entries * 2` bytes
/// (`:78`). The trailing fields read offset is then 4-byte aligned
/// (`:79`).
///
/// # Errors
///
/// - [`RpcError::ShortRead`] when `stub.len() < 32`
/// (`ObjectExporterMessages.cs:51-54`).
/// - [`RpcError::Decode`] when `max_count < entries`
/// (`:66-69`), the conformant array runs past the buffer (`:73-76`),
/// or the trailing 24 bytes are truncated (`:80-83`).
pub fn parse_resolve_oxid_result(stub: &[u8]) -> Result<ResolveOxidResult, RpcError> {
if stub.len() < 32 {
return Err(RpcError::ShortRead {
expected: 32,
actual: stub.len(),
});
}
let referent_id = u32::from_le_bytes([stub[0], stub[1], stub[2], stub[3]]);
if referent_id == 0 {
let tail = &stub[stub.len() - 4..];
let null_status = u32::from_le_bytes([tail[0], tail[1], tail[2], tail[3]]);
return Ok(ResolveOxidResult {
bindings: Vec::new(),
rem_unknown_ipid: Guid::ZERO,
authn_hint: 0,
error_status: null_status,
});
}
let max_count = u32::from_le_bytes([stub[4], stub[5], stub[6], stub[7]]);
let entries = u16::from_le_bytes([stub[8], stub[9]]);
let security_offset = u16::from_le_bytes([stub[10], stub[11]]);
if (max_count as u64) < (entries as u64) {
return Err(RpcError::Decode {
offset: 4,
reason: "ResolveOxid DUALSTRINGARRAY max count is smaller than entry count",
buffer_len: stub.len(),
});
}
let array_offset: usize = 12;
// `checked((int)maxCount * sizeof(ushort))` (`:72`). max_count fits in
// u32; multiplying by 2 fits in u64 with no overflow on any platform.
let array_bytes: usize = match (max_count as usize).checked_mul(2) {
Some(n) => n,
None => {
return Err(RpcError::Decode {
offset: 4,
reason: "ResolveOxid DUALSTRINGARRAY max count overflows usize",
buffer_len: stub.len(),
});
}
};
if array_offset
.checked_add(array_bytes)
.is_none_or(|end| end > stub.len())
{
return Err(RpcError::Decode {
offset: array_offset,
reason: "ResolveOxid DUALSTRINGARRAY is truncated",
buffer_len: stub.len(),
});
}
let entries_bytes: usize = (entries as usize) * 2;
let array_slice = &stub[array_offset..array_offset + entries_bytes];
let decoded = decode_dual_string_array(array_slice, entries, security_offset);
let offset = align(array_offset + array_bytes, 4);
if offset.checked_add(24).is_none_or(|end| end > stub.len()) {
return Err(RpcError::Decode {
offset,
reason: "ResolveOxid trailing fields are truncated",
buffer_len: stub.len(),
});
}
let ipid = Guid::parse(&stub[offset..offset + 16])?;
let authn_hint = u32::from_le_bytes([
stub[offset + 16],
stub[offset + 17],
stub[offset + 18],
stub[offset + 19],
]);
let error_status = u32::from_le_bytes([
stub[offset + 20],
stub[offset + 21],
stub[offset + 22],
stub[offset + 23],
]);
Ok(ResolveOxidResult {
bindings: decoded,
rem_unknown_ipid: ipid,
authn_hint,
error_status,
})
}
/// Decode the dual-string-array slice produced by
/// `IObjectExporter::ResolveOxid`.
///
/// Mirrors `DecodeDualStringArray` (`ObjectExporterMessages.cs:92-126`).
///
/// **This is intentionally a different shape than
/// [`crate::objref::ComObjRef`]'s dual-string decoder.** Three differences
/// vs. `ComObjRef.cs:57-102`:
///
/// 1. The loop iterates `entries` u16 code units exactly. The caller is
/// expected to have sliced `data` to `entries * 2` bytes already
/// (`ObjectExporterMessages.cs:78`).
/// 2. Non-printable code units are emitted as **`'?'`** rather than
/// `<XXXX>` (`:115`).
/// 3. The protocol label is either `"ncacn_ip_tcp"` (for tower id
/// `0x0007`) or `format!("protseq_0x{:04x}", tower_id)` — no other
/// tower table is consulted (`:120`).
///
/// `is_security_binding` is set when the entry's start index (in u16
/// code units) is at or past `security_offset` (`:122`).
pub fn decode_dual_string_array(
data: &[u8],
entries: u16,
security_offset: u16,
) -> Vec<ComDualStringEntry> {
let entries = entries as usize;
let mut strings = Vec::new();
let mut i: usize = 0;
while i < entries {
let entry_start = i;
// Bound u16 reads to the supplied slice; the .NET source assumes
// the caller pre-sliced to `entries * 2` and would otherwise throw
// an `ArgumentOutOfRangeException`. Mirror that contract by
// stopping early if the data was over-trimmed.
if i * 2 + 2 > data.len() {
break;
}
let tower_id = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
i += 1;
if tower_id == 0 {
continue;
}
let mut text = String::new();
while i < entries {
if i * 2 + 2 > data.len() {
break;
}
let value = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
i += 1;
if value == 0 {
break;
}
// `value >= 0x20 && value <= 0x7e ? (char)value : '?'` (:115).
if (0x20..=0x7e).contains(&value) {
text.push(value as u8 as char);
} else {
text.push('?');
}
}
// The canonical `"ncacn_ip_tcp"` label (tower 0x0007) is borrowed
// from a `&'static str`; everything else is owned. `ComDualStringEntry::protocol`
// is `Cow<'static, str>` — see the type-doc on that struct for why
// the OBJREF and OXID parsers emit different protocol labels for
// the same tower id.
let protocol: std::borrow::Cow<'static, str> = if tower_id == PROTSEQ_NCACN_IP_TCP {
std::borrow::Cow::Borrowed("ncacn_ip_tcp")
} else {
std::borrow::Cow::Owned(format!("protseq_0x{:04x}", tower_id))
};
strings.push(ComDualStringEntry {
tower_id,
protocol,
value: text,
is_security_binding: entry_start >= security_offset as usize,
});
}
strings
}
// Compile-time invariants: opnums and protseq constants match
// `ObjectExporterMessages.cs:8-16`.
const _: () = assert!(RESOLVE_OXID_OPNUM == 0);
const _: () = assert!(SIMPLE_PING_OPNUM == 1);
const _: () = assert!(COMPLEX_PING_OPNUM == 2);
const _: () = assert!(SERVER_ALIVE_OPNUM == 3);
const _: () = assert!(RESOLVE_OXID2_OPNUM == 4);
const _: () = assert!(SERVER_ALIVE2_OPNUM == 5);
const _: () = assert!(PROTSEQ_NCACN_IP_TCP == 0x0007);
const _: () = assert!(PROTSEQ_NCALRPC == 0x001f);
// Spot-check the IID wire layout: first byte is `Data1` LSB (0xC4) and
// the trailing big-endian half of `Data4` ends in 0x7A.
const _: () = assert!(IOBJECT_EXPORTER_IID.0[0] == 0xC4);
const _: () = assert!(IOBJECT_EXPORTER_IID.0[15] == 0x7A);
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
/// Wire bytes of `99FCFEC4-5260-101B-BBCB-00AA0021347A` as produced
/// by .NET `new Guid("...").TryWriteBytes(span)`. First three groups
/// are little-endian, last 8 bytes big-endian. Hand-computed:
/// `Data1` = 0x99FCFEC4 → [C4 FE FC 99]
/// `Data2` = 0x5260 → [60 52]
/// `Data3` = 0x101B → [1B 10]
/// `Data4` = BB CB 00 AA 00 21 34 7A (already BE)
const IID_WIRE_BYTES: [u8; 16] = [
0xC4, 0xFE, 0xFC, 0x99, 0x60, 0x52, 0x1B, 0x10, 0xBB, 0xCB, 0x00, 0xAA, 0x00, 0x21, 0x34,
0x7A,
];
#[test]
fn iid_constant_matches_dotnet_wire_bytes() {
assert_eq!(*IOBJECT_EXPORTER_IID.as_bytes(), IID_WIRE_BYTES);
}
#[test]
fn iid_display_matches_dotnet_d_format() {
// .NET `new Guid("99FCFEC4-5260-101B-BBCB-00AA0021347A").ToString("D")`
// is lowercase `"99fcfec4-5260-101b-bbcb-00aa0021347a"`.
assert_eq!(
IOBJECT_EXPORTER_IID.to_string(),
"99fcfec4-5260-101b-bbcb-00aa0021347a"
);
}
#[test]
fn opnum_constants() {
// Mirrors ObjectExporterMessages.cs:8-13.
assert_eq!(RESOLVE_OXID_OPNUM, 0);
assert_eq!(SIMPLE_PING_OPNUM, 1);
assert_eq!(COMPLEX_PING_OPNUM, 2);
assert_eq!(SERVER_ALIVE_OPNUM, 3);
assert_eq!(RESOLVE_OXID2_OPNUM, 4);
assert_eq!(SERVER_ALIVE2_OPNUM, 5);
}
#[test]
fn protseq_constants() {
// Mirrors ObjectExporterMessages.cs:15-16.
assert_eq!(PROTSEQ_NCACN_IP_TCP, 0x0007);
assert_eq!(PROTSEQ_NCALRPC, 0x001f);
}
#[test]
fn align_helper_matches_dotnet() {
// ObjectExporterMessages.cs:128-132.
assert_eq!(align(0, 4), 0);
assert_eq!(align(1, 4), 4);
assert_eq!(align(3, 4), 4);
assert_eq!(align(4, 4), 4);
assert_eq!(align(5, 4), 8);
assert_eq!(align(18, 4), 20);
}
#[test]
fn encode_resolve_oxid_request_one_protseq() {
// protseqs = [0x0007] -> body length = 8 + 2 + 2 + 4 + 2 = 18 →
// aligned up to 20.
let oxid = 0x1122_3344_5566_7788u64;
let buf = encode_resolve_oxid_request(oxid, &[PROTSEQ_NCACN_IP_TCP]).unwrap();
assert_eq!(buf.len(), 20);
// Layout asserts.
assert_eq!(&buf[0..8], &oxid.to_le_bytes());
assert_eq!(&buf[8..10], &1u16.to_le_bytes());
// padding at 10..12 must be zero.
assert_eq!(&buf[10..12], &[0u8, 0u8]);
assert_eq!(&buf[12..16], &1u32.to_le_bytes());
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
// 4-byte alignment padding at the tail.
assert_eq!(&buf[18..20], &[0u8, 0u8]);
}
#[test]
fn encode_resolve_oxid_request_two_protseqs() {
// [0x0007, 0x001f] → 8 + 2 + 2 + 4 + 4 = 20 (already aligned).
let buf = encode_resolve_oxid_request(0, &[PROTSEQ_NCACN_IP_TCP, PROTSEQ_NCALRPC]).unwrap();
assert_eq!(buf.len(), 20);
assert_eq!(&buf[8..10], &2u16.to_le_bytes());
assert_eq!(&buf[12..16], &2u32.to_le_bytes());
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
assert_eq!(&buf[18..20], &PROTSEQ_NCALRPC.to_le_bytes());
}
#[test]
fn encode_resolve_oxid_request_three_protseqs_aligned() {
// [0x0007, 0x001f, 0x0007] → 8 + 2 + 2 + 4 + 6 = 22 → aligned to 24.
let buf = encode_resolve_oxid_request(
0,
&[PROTSEQ_NCACN_IP_TCP, PROTSEQ_NCALRPC, PROTSEQ_NCACN_IP_TCP],
)
.unwrap();
assert_eq!(buf.len(), 24);
assert_eq!(&buf[8..10], &3u16.to_le_bytes());
assert_eq!(&buf[12..16], &3u32.to_le_bytes());
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
assert_eq!(&buf[18..20], &PROTSEQ_NCALRPC.to_le_bytes());
assert_eq!(&buf[20..22], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
// Trailing alignment padding.
assert_eq!(&buf[22..24], &[0u8, 0u8]);
}
#[test]
fn encode_resolve_oxid_request_empty_errors() {
let err = encode_resolve_oxid_request(0, &[]).unwrap_err();
match err {
RpcError::Decode { reason, .. } => {
assert!(reason.contains("at least one protseq"));
}
other => panic!("expected RpcError::Decode, got {other:?}"),
}
}
#[test]
fn parse_resolve_oxid_failure_4_bytes() {
// Single 4-byte status.
let stub = 0x8000_4005u32.to_le_bytes();
let parsed = parse_resolve_oxid_failure(&stub).unwrap();
assert_eq!(parsed.error_status, 0x8000_4005);
}
#[test]
fn parse_resolve_oxid_failure_12_bytes_takes_tail_4() {
// Last 4 bytes only.
let mut stub = vec![0u8; 12];
stub[8..12].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
let parsed = parse_resolve_oxid_failure(&stub).unwrap();
assert_eq!(parsed.error_status, 0xDEAD_BEEF);
}
#[test]
fn parse_resolve_oxid_failure_short_buffer_errors() {
let err = parse_resolve_oxid_failure(&[0u8; 3]).unwrap_err();
assert!(matches!(
err,
RpcError::ShortRead {
expected: 4,
actual: 3
}
));
}
/// Hand-build a success-shape `ResolveOxid` response stub with one
/// `ncacn_ip_tcp` binding `"AB"` and a single `0x0000` security
/// terminator. Returns `(stub, expected_ipid)`.
fn build_success_stub() -> (Vec<u8>, Guid) {
let mut buf = Vec::new();
// referent_id (non-zero).
buf.extend_from_slice(&0x0000_0001u32.to_le_bytes());
// dual-string array u16 code units:
// [0] tower_id = 0x0007
// [1] 'A' = 0x0041
// [2] 'B' = 0x0042
// [3] 0x0000 terminator
// [4] 0x0000 security-binding terminator
// entries = 5; security_offset = 4 (entry-start >= 4 are security).
let entries: u16 = 5;
let max_count: u32 = entries as u32;
let security_offset: u16 = 4;
buf.extend_from_slice(&max_count.to_le_bytes()); // offset 4..8
buf.extend_from_slice(&entries.to_le_bytes()); // offset 8..10
buf.extend_from_slice(&security_offset.to_le_bytes()); // offset 10..12
// dual-string array bytes (entries * 2 = 10 bytes; max_count * 2 = 10 — same here).
for unit in [0x0007u16, b'A' as u16, b'B' as u16, 0x0000, 0x0000] {
buf.extend_from_slice(&unit.to_le_bytes());
}
// After 12 + 10 = 22 bytes, align to 4 → offset 24. Pad 2 bytes.
assert_eq!(buf.len(), 22);
buf.extend_from_slice(&[0u8, 0u8]);
assert_eq!(buf.len(), 24);
// Trailing 24 bytes: 16-byte IPID + 4-byte authn_hint + 4-byte status.
let ipid_bytes: [u8; 16] = [
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad,
0xae, 0xaf,
];
buf.extend_from_slice(&ipid_bytes);
buf.extend_from_slice(&0x0000_000Au32.to_le_bytes()); // authn_hint
buf.extend_from_slice(&0u32.to_le_bytes()); // status
(buf, Guid::new(ipid_bytes))
}
#[test]
fn parse_resolve_oxid_result_happy_path() {
let (stub, expected_ipid) = build_success_stub();
let parsed = parse_resolve_oxid_result(&stub).unwrap();
assert_eq!(parsed.rem_unknown_ipid, expected_ipid);
assert_eq!(parsed.authn_hint, 0xA);
assert_eq!(parsed.error_status, 0);
// One ncacn_ip_tcp string-binding "AB".
assert_eq!(parsed.bindings.len(), 1);
let entry = &parsed.bindings[0];
assert_eq!(entry.tower_id, 0x0007);
assert_eq!(entry.protocol, "ncacn_ip_tcp");
assert_eq!(entry.value, "AB");
// entry_start (0) < security_offset (4) → string binding.
assert!(!entry.is_security_binding);
}
#[test]
fn parse_resolve_oxid_result_referent_id_zero() {
// referent_id = 0 → empty bindings, IPID zero, authn_hint 0,
// status from the trailing 4 bytes (`:57-61`).
let mut stub = vec![0u8; 32];
// referent_id zero (already).
// Put the status at the end.
stub[28..32].copy_from_slice(&0x8000_0001u32.to_le_bytes());
let parsed = parse_resolve_oxid_result(&stub).unwrap();
assert!(parsed.bindings.is_empty());
assert_eq!(parsed.rem_unknown_ipid, Guid::ZERO);
assert_eq!(parsed.authn_hint, 0);
assert_eq!(parsed.error_status, 0x8000_0001);
}
#[test]
fn parse_resolve_oxid_result_max_count_lt_entries_errors() {
let mut stub = vec![0u8; 64];
stub[0..4].copy_from_slice(&1u32.to_le_bytes()); // referent_id != 0
stub[4..8].copy_from_slice(&1u32.to_le_bytes()); // max_count = 1
stub[8..10].copy_from_slice(&5u16.to_le_bytes()); // entries = 5
stub[10..12].copy_from_slice(&0u16.to_le_bytes());
let err = parse_resolve_oxid_result(&stub).unwrap_err();
match err {
RpcError::Decode { reason, .. } => {
assert!(reason.contains("max count"));
}
other => panic!("expected RpcError::Decode, got {other:?}"),
}
}
#[test]
fn parse_resolve_oxid_result_truncated_trailing_errors() {
// Build a valid header but drop the trailing 24 bytes. The
// dual-string array is empty (entries=0, max_count=0), so offset
// after alignment = 12 + 0 = 12. Buffer length 32 leaves only 20
// bytes of trailing space — but the parser needs 24, so it must
// error.
let mut stub = vec![0u8; 32];
stub[0..4].copy_from_slice(&1u32.to_le_bytes()); // referent_id != 0
stub[4..8].copy_from_slice(&0u32.to_le_bytes()); // max_count = 0
stub[8..10].copy_from_slice(&0u16.to_le_bytes()); // entries = 0
stub[10..12].copy_from_slice(&0u16.to_le_bytes()); // security_offset = 0
let err = parse_resolve_oxid_result(&stub).unwrap_err();
match err {
RpcError::Decode { reason, .. } => {
assert!(reason.contains("trailing fields are truncated"));
}
other => panic!("expected RpcError::Decode, got {other:?}"),
}
}
#[test]
fn parse_resolve_oxid_result_short_buffer_errors() {
let err = parse_resolve_oxid_result(&[0u8; 31]).unwrap_err();
assert!(matches!(
err,
RpcError::ShortRead {
expected: 32,
actual: 31
}
));
}
#[test]
fn decode_dual_string_array_question_mark_escape() {
// `?` (not `<XXXX>`) is the non-printable escape per
// ObjectExporterMessages.cs:115. Build:
// [0] tower 0x0007
// [1] 0x0100 (non-printable)
// [2] 'a' (printable)
// [3] 0x0000 terminator
// entries = 4, security_offset = 4 → no security binding.
let mut data = Vec::new();
for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] {
data.extend_from_slice(&unit.to_le_bytes());
}
let decoded = decode_dual_string_array(&data, 4, 4);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].tower_id, 0x0007);
assert_eq!(decoded[0].protocol, "ncacn_ip_tcp");
// `?` escape (single character), not `<0100>`.
assert_eq!(decoded[0].value, "?a");
assert!(!decoded[0].is_security_binding);
}
#[test]
fn decode_dual_string_array_unknown_protseq_label() {
// Tower 0x0009 (ncacn_np in ComObjRef) gets the
// `protseq_0x0009` fallback here, **not** the table lookup.
let mut data = Vec::new();
for unit in [0x0009u16, b'X' as u16, 0x0000] {
data.extend_from_slice(&unit.to_le_bytes());
}
let decoded = decode_dual_string_array(&data, 3, 3);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].protocol, "protseq_0x0009");
assert_eq!(decoded[0].value, "X");
}
#[test]
fn decode_dual_string_array_security_offset_split() {
// Two entries, each `tower=0x0007 / value / 0x0000`, total 6 u16
// code units. security_offset = 3 means the second entry (start
// index 3) is a security binding.
let mut data = Vec::new();
for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] {
data.extend_from_slice(&unit.to_le_bytes());
}
let decoded = decode_dual_string_array(&data, 6, 3);
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].value, "A");
assert!(!decoded[0].is_security_binding);
assert_eq!(decoded[1].value, "B");
assert!(decoded[1].is_security_binding);
}
}