Files
mxaccess/rust/crates/mxaccess-rpc/src/objref.rs
T
Joseph Doherty ecbf282f6d [M2] mxaccess-rpc: NMX metadata + callback messages + OBJREF builder
Lands the codec-only prerequisites for M2 wave 3 (callback exporter).
The TCP server itself (port of ManagedCallbackExporter.cs's TcpListener
+ accept loop) follows next iteration in the mxaccess-callback crate.

New modules
- nmx_metadata.rs (9 tests) — port of NmxProcedureMetadata.cs.
  INmxService2 + INmxSvcCallback IIDs, NdrProcedureDescriptor with
  per-opnum metadata for the 9 INmxService2 procedures (opnums 3..11)
  and 2 INmxSvcCallback procedures (opnums 3, 4).
- nmx_callback_messages.rs (8 tests) — port of NmxSvcCallbackMessages.cs.
  parse_callback_request decodes OrpcThis + i32 size + i32 max_count +
  body bytes; encode_callback_response produces the 12-byte OrpcThat +
  HRESULT response.

objref.rs additions
- ComObjRefBuilder::create_standard_objref (8 tests) — port of the
  second class in ManagedCallbackExporter.cs:337-393. Pure-Rust OBJREF
  emitter that builds 68-byte header + dual-string array. Note this is
  *not* the Win32 CoMarshalInterface-based ComObjRefProvider.cs (still
  open as F6); it's the higher-level emitter the callback exporter
  uses to build OBJREF bytes from primitives.
- CALLBACK_OBJREF_AUTH_SERVICES const exposes the 7-entry auth-service
  tower-id table (NTLM SSP through Kerberos extension) the .NET
  reference advertises in every callback OBJREF.

Test count delta: 319 -> 344 (+25; mxaccess-rpc 102 -> 127, codec
unchanged at 215, parity unchanged at 2). All four DoD gates green.
Open followups touched: none new; F6 advances toward resolution but
the windows-rs Win32 wrapper part stays open.

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

916 lines
37 KiB
Rust

//! `ComObjRef` — DCOM OBJREF parser.
//!
//! Direct port of `src/MxNativeClient/ComObjRef.cs`. Parses the marshalled
//! interface byte stream produced by `CoMarshalInterface` (per `[MS-DCOM]`
//! §2.2.18) into a structured form the RPC layer can inspect for OXID/OID/IPID
//! and the dual-string-array bindings (string + security towers).
//!
//! The .NET reference parses 68 fixed bytes followed by the dual-string array,
//! which is decoded character-by-character and used purely for diagnostics.
//! The Rust port mirrors that parser shape exactly — every field offset,
//! the `Math.Min(entries, data.Length / 2)` bound, and the printable-ASCII
//! escaping of each UTF-16 code unit are 1:1 with `ComObjRef.cs:18-117`.
//!
//! `ComObjRefProvider.cs` is **not** ported here — it is a thin wrapper around
//! Win32 `CoMarshalInterface` / `IStream` / `GlobalLock` and produces OBJREF
//! bytes by calling into ole32. That belongs behind `windows-rs` in a later
//! M2/M3 wave; the pure-Rust parser stands alone and is what M2 wave 1 needs
//! for OBJREF inspection on inbound activation responses. See followup F1
//! in this module's report.
// 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`.
#![allow(clippy::indexing_slicing)]
use std::fmt::Write as _;
// `Guid` and `RpcError` are crate-shared since M2 wave 2 — see
// `design/followups.md` F7+F8.
pub use crate::error::RpcError;
pub use crate::guid::Guid;
/// Encoded layout per `ComObjRef.cs:25-39`:
///
/// ```text
/// offset size field
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
/// 4 4 flags u32 LE (1 = OBJREF_STANDARD; only standard parsed)
/// 8 16 iid GUID
/// 24 4 std_flags u32 LE (STDOBJREF flags)
/// 28 4 public_refs u32 LE
/// 32 8 oxid u64 LE
/// 40 8 oid u64 LE
/// 48 16 ipid GUID
/// 64 2 dual_string_entries u16 LE (count of u16 code units in the array)
/// 66 2 dual_string_security_offset u16 LE (boundary index between string and security bindings)
/// 68 .. dual-string array (variable; UTF-16LE code units terminated by 0x0000 per entry)
/// ```
///
/// **Header length** is 68 bytes; the dual-string array follows starting at
/// offset 68. The `dual_string_entries` count is bounded by the actual byte
/// length of the trailing bytes via `min(entries, data.len() / 2)`
/// (`ComObjRef.cs:59`).
pub const OBJREF_HEADER_LEN: usize = 68;
/// "MEOW" — the OBJREF signature (`ComObjRef.cs:29`, also `[MS-DCOM]` §2.2.18.1).
pub const OBJREF_SIGNATURE: u32 = 0x574F_454D;
const FLAGS_OFFSET: usize = 4;
const IID_OFFSET: usize = 8;
const STD_FLAGS_OFFSET: usize = 24;
const PUBLIC_REFS_OFFSET: usize = 28;
const OXID_OFFSET: usize = 32;
const OID_OFFSET: usize = 40;
const IPID_OFFSET: usize = 48;
const DUAL_STRING_ENTRIES_OFFSET: usize = 64;
const DUAL_STRING_SECURITY_OFFSET_OFFSET: usize = 66;
/// One decoded entry of the OBJREF dual-string array. `value` is the
/// printable-ASCII escaping of the UTF-16 string per `ComObjRef.cs:82-91` —
/// non-printable code units appear as `<XXXX>` lowercase hex. `is_security_binding`
/// is set when the entry's start offset (in u16 units) is at or past
/// `DualStringSecurityOffset`.
///
/// Mirrors `ComDualStringEntry` (`ComObjRef.cs:138-145`).
///
/// `protocol` is `Cow<'static, str>` because the OBJREF parser uses the
/// 7-entry static table (`Cow::Borrowed`) while the M2 wave 2 OXID-resolve
/// parser uses `format!("protseq_0x{:04x}", tower_id)` (`Cow::Owned`) for
/// unknown tower ids (`ObjectExporterMessages.cs:120`). The two parsers
/// share the entry type but emit different protocol labels for the same
/// tower id — this is intentional and matches the .NET reference.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComDualStringEntry {
pub tower_id: u16,
pub protocol: std::borrow::Cow<'static, str>,
pub value: String,
pub is_security_binding: bool,
}
impl ComDualStringEntry {
/// Mirrors `ComDualStringEntry.ToDiagnosticString` (`ComObjRef.cs:140-144`):
/// `"<kind>:0x<tower_id_lc>:<protocol>:<value>"`.
pub fn to_diagnostic_string(&self) -> String {
let kind = if self.is_security_binding {
"security"
} else {
"string"
};
format!(
"{}:0x{:04x}:{}:{}",
kind, self.tower_id, self.protocol, self.value
)
}
}
/// Parsed DCOM OBJREF (standard form).
///
/// Mirrors `ComObjRef` record (`ComObjRef.cs:5-16`). All eleven fields of the
/// .NET record are preserved including `signature`, `flags`, `std_flags`,
/// `dual_string_entries`, and `dual_string_security_offset` — even though the
/// signature is a known constant, the parser does not validate it (the .NET
/// reference doesn't either; bytes are surfaced verbatim per CLAUDE.md
/// preserve-unknown-bytes rule).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComObjRef {
pub signature: u32,
pub flags: u32,
pub iid: Guid,
pub standard_flags: u32,
pub public_refs: u32,
pub oxid: u64,
pub oid: u64,
pub ipid: Guid,
/// Raw entry-count field — measured in **u16 code units**, not entries.
/// Preserved verbatim from the wire even when it overruns the buffer; the
/// parse loop bounds itself by `min(entries, data.len() / 2)`
/// (`ComObjRef.cs:59`).
pub dual_string_entries: u16,
/// Boundary (in u16 code-unit indices) between string bindings and
/// security bindings within the dual-string array (`ComObjRef.cs:98`).
pub dual_string_security_offset: u16,
pub dual_string_entries_decoded: Vec<ComDualStringEntry>,
}
impl ComObjRef {
/// Header length (68 bytes) before the dual-string array.
pub const HEADER_LEN: usize = OBJREF_HEADER_LEN;
/// Parse an OBJREF buffer. Mirrors `ComObjRef.Parse` (`ComObjRef.cs:18-40`)
/// byte-for-byte: 68-byte fixed header followed by a UTF-16LE
/// dual-string array bounded by `min(entries, tail.len() / 2)`.
///
/// The signature field is read but not validated — the .NET reference
/// surfaces it verbatim so callers can diff against captures.
///
/// # Errors
///
/// - [`RpcError::ShortRead`] if `buffer.len() < 68`
/// (matches `ComObjRef.cs:20-23`).
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::HEADER_LEN {
return Err(RpcError::ShortRead {
expected: Self::HEADER_LEN,
actual: buffer.len(),
});
}
let dual_string_entries = read_u16_le(buffer, DUAL_STRING_ENTRIES_OFFSET);
let security_offset = read_u16_le(buffer, DUAL_STRING_SECURITY_OFFSET_OFFSET);
let mut iid_bytes = [0u8; 16];
iid_bytes.copy_from_slice(&buffer[IID_OFFSET..IID_OFFSET + 16]);
let mut ipid_bytes = [0u8; 16];
ipid_bytes.copy_from_slice(&buffer[IPID_OFFSET..IPID_OFFSET + 16]);
let tail = &buffer[Self::HEADER_LEN..];
let decoded = decode_dual_string_array(tail, dual_string_entries, security_offset);
Ok(Self {
signature: read_u32_le(buffer, 0),
flags: read_u32_le(buffer, FLAGS_OFFSET),
iid: Guid(iid_bytes),
standard_flags: read_u32_le(buffer, STD_FLAGS_OFFSET),
public_refs: read_u32_le(buffer, PUBLIC_REFS_OFFSET),
oxid: read_u64_le(buffer, OXID_OFFSET),
oid: read_u64_le(buffer, OID_OFFSET),
ipid: Guid(ipid_bytes),
dual_string_entries,
dual_string_security_offset: security_offset,
dual_string_entries_decoded: decoded,
})
}
/// Diagnostic line emitter — byte-identical to `ToDiagnosticLines`
/// (`ComObjRef.cs:42-55`). The output is intended for matching against
/// Frida-captured probe output (`captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt`).
pub fn to_diagnostic_lines(&self) -> Vec<String> {
let dual_strings = self
.dual_string_entries_decoded
.iter()
.map(ComDualStringEntry::to_diagnostic_string)
.collect::<Vec<_>>()
.join("|");
vec![
format!("objref_signature=0x{:08X}", self.signature),
format!("objref_flags=0x{:08X}", self.flags),
format!("objref_iid={}", self.iid),
format!("std_flags=0x{:08X}", self.standard_flags),
format!("std_public_refs={}", self.public_refs),
format!("std_oxid=0x{:016X}", self.oxid),
format!("std_oid=0x{:016X}", self.oid),
format!("std_ipid={}", self.ipid),
format!("dual_string_entries={}", self.dual_string_entries),
format!(
"dual_string_security_offset={}",
self.dual_string_security_offset
),
format!("dual_strings={}", dual_strings),
]
}
}
/// Decode the trailing dual-string array. Mirrors
/// `DecodeDualStringArray` (`ComObjRef.cs:57-102`).
///
/// The loop walks `i` in u16 code-unit indices, capped by
/// `min(entries, data.len() / 2)`. Each entry begins with a 16-bit
/// `tower_id`; if zero, it terminates the string-binding region (the
/// `continue` skips to the next index without producing an entry — same as
/// the .NET source). Otherwise the following u16 code units up to (but not
/// including) the next 0x0000 terminator form the entry's value, escaped
/// printable-ASCII per the `0x20..=0x7e` rule.
fn decode_dual_string_array(
data: &[u8],
entries: u16,
security_offset: u16,
) -> Vec<ComDualStringEntry> {
let entries = entries as usize;
let count = entries.min(data.len() / 2);
let mut strings = Vec::new();
let mut i: usize = 0;
while i < count {
let entry_start = i;
let tower_id = read_u16_le(data, i * 2);
i += 1;
if tower_id == 0 {
continue;
}
let mut text = String::new();
while i < count {
let value = read_u16_le(data, i * 2);
i += 1;
if value == 0 {
break;
}
if (0x20..=0x7e).contains(&value) {
// Safe: 0x20..=0x7e is printable ASCII, valid UTF-8.
text.push(value as u8 as char);
} else {
// Non-printable: emit "<XXXX>" lowercase hex (mirrors .NET
// `value.ToString("x4", InvariantCulture)`).
// write! to a String never fails; ignore the Result.
let _ = write!(&mut text, "<{:04x}>", value);
}
}
strings.push(ComDualStringEntry {
tower_id,
protocol: std::borrow::Cow::Borrowed(protocol_tower_name(tower_id)),
value: text,
is_security_binding: entry_start >= security_offset as usize,
});
}
strings
}
/// Protocol-tower name table per `ComObjRef.cs:104-117`. Returns `"unknown"`
/// for unrecognised tower ids — mirrors the `_ =>` fall-through.
pub const fn protocol_tower_name(tower_id: u16) -> &'static str {
match tower_id {
0x0007 => "ncacn_ip_tcp",
0x0008 => "ncadg_ip_udp",
0x0009 => "ncacn_np",
0x000f => "ncacn_spx",
0x0010 => "ncacn_nb_nb",
0x0016 => "ncadg_ip_udp_or_netbios",
0x001f => "ncalrpc",
_ => "unknown",
}
}
#[inline]
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
#[inline]
fn read_u64_le(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
])
}
// Compile-time invariant: header length matches the documented byte layout.
const _: () = assert!(OBJREF_HEADER_LEN == 68);
const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D);
// ---------------------------------------------------------------------------
// ComObjRefBuilder — pure-Rust OBJREF emitter.
// Direct port of the second class in
// `src/MxNativeClient/ManagedCallbackExporter.cs:337-393`.
// ---------------------------------------------------------------------------
/// Auth-service tower IDs the .NET reference advertises in every callback
/// OBJREF. Mirrors the hard-coded array at
/// `ManagedCallbackExporter.cs:362`. Each id appears in the security-binding
/// portion of the dual-string array followed by `0xFFFF` and a terminator.
///
/// IDs in order: NTLM SSP (0x0009), GSS Negotiate (0x001E), Kerberos (0x0010),
/// SSL/TLS (0x000A), Schannel (0x0016), DPA (0x001F), Kerberos extension
/// (0x000E). The Rust port carries the same set verbatim — no synthesis.
pub const CALLBACK_OBJREF_AUTH_SERVICES: [u16; 7] =
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E];
/// Builds standard OBJREF byte buffers for the callback exporter to publish.
///
/// Mirrors the static `ComObjRefBuilder` class
/// (`src/MxNativeClient/ManagedCallbackExporter.cs:337-393`). The .NET reference
/// only ever emits *standard* OBJREFs (`flags = 1`); the Rust port matches.
///
/// This is the higher-level emitter that builds OBJREF bytes from primitives.
/// It is **not** the Win32 `CoMarshalInterface`-based emitter from
/// `ComObjRefProvider.cs` — that wrapper around `ole32` is still tracked as
/// open follow-up F6 (it requires `windows-rs` and the M2 wave 3 callback
/// exporter to register the emitted OBJREF with COM).
pub struct ComObjRefBuilder;
impl ComObjRefBuilder {
/// Build a standard-OBJREF buffer for a given IID, OXID/OID/IPID, and one
/// or more `ncacn_ip_tcp` string bindings (e.g. `"hostname[5985]"`).
/// Mirrors `ComObjRefBuilder.CreateStandardObjRef`
/// (`ManagedCallbackExporter.cs:339-392`).
///
/// # Layout (`cs:348-389`)
///
/// ```text
/// offset size field
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
/// 4 4 flags u32 LE = 1
/// 8 16 iid GUID
/// 24 4 std_flags u32 LE
/// 28 4 public_refs u32 LE
/// 32 8 oxid u64 LE
/// 40 8 oid u64 LE
/// 48 16 ipid GUID
/// 64 2 entries u16 LE (count of u16 code units below)
/// 66 2 security_offset u16 LE (in u16 code units)
/// 68 .. dual-string array (variable-length u16 LE words)
/// ```
///
/// # `entries` and `security_offset`
///
/// `entries` is the **total u16-code-unit count** of the dual-string
/// array (string bindings + 0 separator + 7 security entries + final 0).
/// `security_offset` is the index (in u16 units) where security bindings
/// begin — `cs:348` computes this as
/// `sum(1 + binding.len() + 1 for binding in stringBindings) + 1`, i.e.
/// per-binding `tower_id` (1 word) + `binding.len()` ASCII chars (one
/// word each) + null terminator (1 word), plus the trailing 0 separator
/// that ends the string section.
///
/// # Panics
///
/// Never panics. All length math saturates: bindings longer than
/// `u16::MAX - HEADER_LEN/2 - SECURITY_TAIL_LEN` are not representable
/// in the 16-bit `entries` field, and the .NET reference does not guard
/// against this either; callers are expected to keep bindings short
/// (typical `hostname[port]` is < 100 chars).
#[must_use]
pub fn create_standard_objref(
iid: Guid,
std_flags: u32,
public_refs: u32,
oxid: u64,
oid: u64,
ipid: Guid,
string_bindings: &[&str],
) -> Vec<u8> {
// security_offset = sum_{b in string_bindings}(1 + b.len() + 1) + 1
// (cs:348). u16-truncating cast mirrors `(ushort)`.
let security_offset: u16 = string_bindings
.iter()
.map(|b| 1 + b.len() + 1)
.sum::<usize>()
.saturating_add(1)
.min(u16::MAX as usize) as u16;
// Build the u16 word array.
let mut words: Vec<u16> = Vec::new();
// String-bindings section: per binding, [0x0007 (ncacn_ip_tcp), each
// ASCII char as u16, terminator 0] (cs:350-359).
for binding in string_bindings {
words.push(0x0007);
for ch in binding.chars() {
words.push(ch as u16);
}
words.push(0);
}
// 0 separator that ends the string section (cs:361).
words.push(0);
// Security-bindings section: 7 hard-coded tower entries, each
// [tower_id, 0xFFFF, 0] (cs:362-367).
for &auth in &CALLBACK_OBJREF_AUTH_SERVICES {
words.push(auth);
words.push(0xFFFF);
words.push(0);
}
// Final terminator (cs:369).
words.push(0);
// u16-truncating cast mirrors `(ushort)words.Count` (cs:371).
let entries: u16 = words.len().min(u16::MAX as usize) as u16;
let mut buffer = vec![0u8; OBJREF_HEADER_LEN + words.len() * 2];
// Fixed 68-byte header (cs:373-382).
buffer[0..4].copy_from_slice(&OBJREF_SIGNATURE.to_le_bytes());
buffer[4..8].copy_from_slice(&1u32.to_le_bytes()); // flags = 1 (OBJREF_STANDARD)
buffer[8..24].copy_from_slice(iid.as_bytes());
buffer[24..28].copy_from_slice(&std_flags.to_le_bytes());
buffer[28..32].copy_from_slice(&public_refs.to_le_bytes());
buffer[32..40].copy_from_slice(&oxid.to_le_bytes());
buffer[40..48].copy_from_slice(&oid.to_le_bytes());
buffer[48..64].copy_from_slice(ipid.as_bytes());
buffer[64..66].copy_from_slice(&entries.to_le_bytes());
buffer[66..68].copy_from_slice(&security_offset.to_le_bytes());
// Dual-string array body (cs:384-389).
let mut offset = OBJREF_HEADER_LEN;
for word in &words {
buffer[offset..offset + 2].copy_from_slice(&word.to_le_bytes());
offset += 2;
}
buffer
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
/// Hand-built OBJREF: signature + flags=1 + sample IID + std_flags +
/// public_refs=5 + fake OXID/OID/IPID + dual_string array containing one
/// `ncacn_ip_tcp` entry then a `0x0000` terminator. Returns the bytes.
fn build_minimal_objref() -> Vec<u8> {
let mut buf = Vec::new();
// signature "MEOW" 0x574F454D LE
buf.extend_from_slice(&0x574F_454Du32.to_le_bytes());
// flags = 1 (OBJREF_STANDARD)
buf.extend_from_slice(&1u32.to_le_bytes());
// iid (16 bytes; arbitrary)
buf.extend_from_slice(&[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10,
]);
// std_flags
buf.extend_from_slice(&0u32.to_le_bytes());
// public_refs = 5
buf.extend_from_slice(&5u32.to_le_bytes());
// oxid
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
// oid
buf.extend_from_slice(&0xAABB_CCDD_EEFF_0011u64.to_le_bytes());
// ipid
buf.extend_from_slice(&[
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad,
0xae, 0xaf,
]);
// Build the dual-string array:
// tower_id 0x0007 (ncacn_ip_tcp)
// "AB" as UTF-16LE
// 0x0000 terminator
// That's 4 u16 code units = 8 bytes.
let array_units: [u16; 4] = [0x0007, b'A' as u16, b'B' as u16, 0x0000];
let dual_entries: u16 = array_units.len() as u16;
let security_offset: u16 = dual_entries; // no security bindings
// dual_string_entries (count of u16 code units)
buf.extend_from_slice(&dual_entries.to_le_bytes());
// dual_string_security_offset
buf.extend_from_slice(&security_offset.to_le_bytes());
// header now exactly 68 bytes
assert_eq!(buf.len(), 68);
for unit in array_units {
buf.extend_from_slice(&unit.to_le_bytes());
}
buf
}
#[test]
fn parse_minimal_objref() {
let bytes = build_minimal_objref();
let parsed = ComObjRef::parse(&bytes).unwrap();
assert_eq!(parsed.signature, 0x574F_454D);
assert_eq!(parsed.flags, 1);
assert_eq!(parsed.standard_flags, 0);
assert_eq!(parsed.public_refs, 5);
assert_eq!(parsed.oxid, 0x1122_3344_5566_7788);
assert_eq!(parsed.oid, 0xAABB_CCDD_EEFF_0011);
assert_eq!(parsed.dual_string_entries, 4);
assert_eq!(parsed.dual_string_security_offset, 4);
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
let entry = &parsed.dual_string_entries_decoded[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 diagnostic_lines_format_minimal() {
let bytes = build_minimal_objref();
let parsed = ComObjRef::parse(&bytes).unwrap();
let lines = parsed.to_diagnostic_lines();
// Per ComObjRef.cs:42-55 there are exactly 11 lines.
assert_eq!(lines.len(), 11);
assert_eq!(lines[0], "objref_signature=0x574F454D");
assert_eq!(lines[1], "objref_flags=0x00000001");
assert_eq!(lines[3], "std_flags=0x00000000");
assert_eq!(lines[4], "std_public_refs=5");
assert_eq!(lines[5], "std_oxid=0x1122334455667788");
assert_eq!(lines[6], "std_oid=0xAABBCCDDEEFF0011");
assert_eq!(lines[8], "dual_string_entries=4");
assert_eq!(lines[9], "dual_string_security_offset=4");
assert_eq!(lines[10], "dual_strings=string:0x0007:ncacn_ip_tcp:AB");
}
#[test]
fn parse_rejects_short_buffer() {
// 67-byte buffer (one shy of header) must error, not panic.
let err = ComObjRef::parse(&[0u8; 67]).unwrap_err();
match err {
RpcError::ShortRead { expected, actual } => {
assert_eq!(expected, 68);
assert_eq!(actual, 67);
}
other => panic!("expected ShortRead, got {other:?}"),
}
}
#[test]
fn parse_accepts_exact_header_no_array() {
// 68 bytes with dual_string_entries=0 → no decoded entries.
let mut buf = vec![0u8; 68];
// signature
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries, 0);
assert_eq!(parsed.dual_string_security_offset, 0);
assert!(parsed.dual_string_entries_decoded.is_empty());
}
#[test]
fn protocol_tower_name_table() {
// All 7 documented tower ids per ComObjRef.cs:106-117.
assert_eq!(protocol_tower_name(0x0007), "ncacn_ip_tcp");
assert_eq!(protocol_tower_name(0x0008), "ncadg_ip_udp");
assert_eq!(protocol_tower_name(0x0009), "ncacn_np");
assert_eq!(protocol_tower_name(0x000f), "ncacn_spx");
assert_eq!(protocol_tower_name(0x0010), "ncacn_nb_nb");
assert_eq!(protocol_tower_name(0x0016), "ncadg_ip_udp_or_netbios");
assert_eq!(protocol_tower_name(0x001f), "ncalrpc");
// Fall-through.
assert_eq!(protocol_tower_name(0x0000), "unknown");
assert_eq!(protocol_tower_name(0xFFFF), "unknown");
}
#[test]
fn dual_string_array_overrun_bounded() {
// Build a header that claims 1000 dual-string code units but only
// includes 4 bytes (= 2 code units) of trailing data. The parser
// must bound itself via min(entries, data.len()/2) per
// ComObjRef.cs:59 and not read past the end.
let mut buf = build_minimal_objref();
// Truncate the trailing dual-string bytes back to 0 and lie about
// entries=1000.
buf.truncate(68);
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&1000u16.to_le_bytes());
let parsed = ComObjRef::parse(&buf).unwrap();
// The wire-declared entry count is preserved verbatim per
// CLAUDE.md unknown-bytes rule.
assert_eq!(parsed.dual_string_entries, 1000);
// But the loop bound prevents any decoding.
assert!(parsed.dual_string_entries_decoded.is_empty());
}
#[test]
fn security_binding_flag_split() {
// Build a dual-string array with one string binding then one security
// binding. Layout (u16 code units):
// [0] tower=0x0007 (string binding starts at index 0)
// [1] 'A'
// [2] 0x0000 terminator
// [3] tower=0x0007 (security binding starts at index 3)
// [4] 'B'
// [5] 0x0000 terminator
// dual_string_entries = 6, security_offset = 3 (entries with start
// index >= 3 are security bindings).
let mut buf = vec![0u8; 68];
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let entries: u16 = 6;
let sec_off: u16 = 3;
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
.copy_from_slice(&sec_off.to_le_bytes());
for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] {
buf.extend_from_slice(&unit.to_le_bytes());
}
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries_decoded.len(), 2);
assert_eq!(parsed.dual_string_entries_decoded[0].value, "A");
assert!(!parsed.dual_string_entries_decoded[0].is_security_binding);
assert_eq!(parsed.dual_string_entries_decoded[1].value, "B");
assert!(parsed.dual_string_entries_decoded[1].is_security_binding);
}
#[test]
fn non_printable_codeunit_escaped_as_hex() {
// tower=0x0007, then a non-printable u16 (0x0100), then 'a', then 0x0000.
let mut buf = vec![0u8; 68];
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let entries: u16 = 4;
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] {
buf.extend_from_slice(&unit.to_le_bytes());
}
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
// Expect "<0100>a" per the printable-ASCII escape rule
// (ComObjRef.cs:82-91).
assert_eq!(parsed.dual_string_entries_decoded[0].value, "<0100>a");
}
/// Captured OBJREF from `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6`
/// (`managed_callback_objref_hex`). 366 bytes; produced by the .NET
/// managed-callback exporter via `MarshalIUnknownObjRef` in the live
/// probe. Used to verify that the Rust parser interprets a real-world
/// OBJREF identically to the .NET reference.
const CAPTURED_OBJREF_HEX: &str = "4D454F5701000000F7929FB448C769418ECAA0670B012746800A000005000000750CC6C8BA9B1EFD3BF71E12FEE5B5C1022C0000DC32FFFF8AC67645E6ED23FF95007F0007004400450053004B0054004F0050002D0036004A004C0033004B004B004F0000000700310030002E003100300030002E0030002E0034003800000007003100370032002E00320039002E003200320034002E0031000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0062006200340031003A0065006500370065003A0035006600640034003A0064006300310038000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0035003000620031003A0038003400360066003A0037006200350031003A006500610034003000000000000900FFFF00001E00FFFF00001000FFFF00000A00FFFF00001600FFFF00001F00FFFF00000E00FFFF00000000";
fn hex_decode(hex: &str) -> Vec<u8> {
let bytes = hex.as_bytes();
assert!(bytes.len() % 2 == 0);
let mut out = Vec::with_capacity(bytes.len() / 2);
for chunk in bytes.chunks(2) {
let hi = (chunk[0] as char).to_digit(16).unwrap() as u8;
let lo = (chunk[1] as char).to_digit(16).unwrap() as u8;
out.push((hi << 4) | lo);
}
out
}
#[test]
fn captured_objref_parses() {
let bytes = hex_decode(CAPTURED_OBJREF_HEX);
// probe.stdout.txt:5 reports managed_callback_objref_size=366.
assert_eq!(bytes.len(), 366);
let parsed = ComObjRef::parse(&bytes).unwrap();
// Signature is the canonical "MEOW".
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
// OBJREF_STANDARD.
assert_eq!(parsed.flags, 1);
// public_refs = 5 (per the captured bytes 28..32 = 05 00 00 00).
assert_eq!(parsed.public_refs, 5);
// Captured bytes at offset 64..68 are `95 00 7F 00`:
// dual_string_entries (u16 LE) = 0x0095 = 149
// dual_string_security_offset (u16 LE) = 0x007F = 127
// 149 u16 units from offset 68 onwards exactly fills the remaining
// 366 - 68 = 298 bytes (149 * 2), so the entries count saturates the
// buffer — confirming the parser's `min(entries, data.len()/2)`
// bound at `ComObjRef.cs:59` produces the same effective walk length.
assert_eq!(parsed.dual_string_entries, 0x0095);
assert_eq!(parsed.dual_string_security_offset, 0x007F);
// First decoded string-binding is the hostname over ncacn_ip_tcp.
// The probe was run on host DESKTOP-6JL3KKO per the captured UTF-16
// bytes immediately following the dual-string header.
let first = &parsed.dual_string_entries_decoded[0];
assert_eq!(first.tower_id, 0x0007);
assert_eq!(first.protocol, "ncacn_ip_tcp");
assert_eq!(first.value, "DESKTOP-6JL3KKO");
assert!(!first.is_security_binding);
// Subsequent string-bindings are the IPv4 + IPv6 endpoint addresses.
// Confirm we got at least 4 string-bindings (host + v4 + 2x v6) plus
// the security-binding entries.
assert!(parsed.dual_string_entries_decoded.len() >= 5);
// At least one entry must be a security binding (entries past
// security_offset). The "0900FFFF" sequence in the captured bytes
// decodes as tower=0x0009 (ncacn_np) with a single non-printable
// u16 0xFFFF — appears in the security-binding tail.
assert!(
parsed
.dual_string_entries_decoded
.iter()
.any(|e| e.is_security_binding),
"expected at least one security binding in captured OBJREF"
);
// The diagnostic emitter must produce exactly 11 lines.
let lines = parsed.to_diagnostic_lines();
assert_eq!(lines.len(), 11);
assert_eq!(lines[0], "objref_signature=0x574F454D");
}
#[test]
fn guid_display_matches_dotnet_d_format() {
// .NET Guid("F7929FB4-48C7-6941-8ECA-A0670B012746".replace order):
// The 16-byte sequence F7 92 9F B4 48 C7 69 41 8E CA A0 67 0B 01 27 46
// displays as "b49f92f7-c748-4169-8eca-a0670b012746" — first three
// groups are byte-swapped (LE on wire, BE in display).
let g = Guid([
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
0x27, 0x46,
]);
assert_eq!(format!("{}", g), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn header_length_constant() {
assert_eq!(ComObjRef::HEADER_LEN, 68);
assert_eq!(OBJREF_HEADER_LEN, 68);
assert_eq!(OBJREF_SIGNATURE, 0x574F_454D);
}
// ---- ComObjRefBuilder tests --------------------------------------
#[test]
fn builder_emits_meow_header_and_flags() {
let iid = Guid::new([0xAA; 16]);
let ipid = Guid::new([0xBB; 16]);
let buf = ComObjRefBuilder::create_standard_objref(
iid,
0x280,
5,
0x0123_4567_89AB_CDEF,
0xFEDC_BA98_7654_3210,
ipid,
&["host[5985]"],
);
assert!(buf.len() >= OBJREF_HEADER_LEN);
// Signature
assert_eq!(&buf[0..4], &OBJREF_SIGNATURE.to_le_bytes());
// flags = 1 (OBJREF_STANDARD)
assert_eq!(&buf[4..8], &1u32.to_le_bytes());
// IID
assert_eq!(&buf[8..24], iid.as_bytes());
// std_flags
assert_eq!(&buf[24..28], &0x280u32.to_le_bytes());
// public_refs
assert_eq!(&buf[28..32], &5u32.to_le_bytes());
// OXID/OID
assert_eq!(&buf[32..40], &0x0123_4567_89AB_CDEFu64.to_le_bytes());
assert_eq!(&buf[40..48], &0xFEDC_BA98_7654_3210u64.to_le_bytes());
// IPID
assert_eq!(&buf[48..64], ipid.as_bytes());
}
#[test]
fn builder_round_trips_through_parser() {
// The emitted OBJREF must parse back through ComObjRef::parse with
// the same key fields.
let iid = Guid::new([0x11; 16]);
let ipid = Guid::new([0x22; 16]);
let buf = ComObjRefBuilder::create_standard_objref(
iid,
0x280,
5,
0x1111_2222_3333_4444,
0x5555_6666_7777_8888,
ipid,
&["DESKTOP[12345]"],
);
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
assert_eq!(parsed.flags, 1);
assert_eq!(parsed.iid, iid);
assert_eq!(parsed.standard_flags, 0x280);
assert_eq!(parsed.public_refs, 5);
assert_eq!(parsed.oxid, 0x1111_2222_3333_4444);
assert_eq!(parsed.oid, 0x5555_6666_7777_8888);
assert_eq!(parsed.ipid, ipid);
// First decoded entry should be the ncacn_ip_tcp string binding,
// and at least one security binding (auth-service tail) follows.
let first = &parsed.dual_string_entries_decoded[0];
assert_eq!(first.tower_id, 0x0007);
assert_eq!(first.protocol, "ncacn_ip_tcp");
assert_eq!(first.value, "DESKTOP[12345]");
assert!(!first.is_security_binding);
let security_count = parsed
.dual_string_entries_decoded
.iter()
.filter(|e| e.is_security_binding)
.count();
assert!(
security_count >= 1,
"expected at least one security binding, got {security_count}"
);
}
#[test]
fn builder_security_offset_matches_dotnet_formula() {
// security_offset = sum(1 + binding.len() + 1) + 1 (cs:348).
// For one binding "host[12]" (8 chars): 1 + 8 + 1 + 1 = 11.
let buf = ComObjRefBuilder::create_standard_objref(
Guid::ZERO,
0,
5,
0,
0,
Guid::ZERO,
&["host[12]"],
);
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
assert_eq!(security_offset, 11);
}
#[test]
fn builder_two_bindings_security_offset() {
// Two bindings: "a[1]" (4) + "b[2]" (4):
// (1+4+1) + (1+4+1) + 1 = 13.
let buf = ComObjRefBuilder::create_standard_objref(
Guid::ZERO,
0,
5,
0,
0,
Guid::ZERO,
&["a[1]", "b[2]"],
);
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
assert_eq!(security_offset, 13);
}
#[test]
fn builder_emits_seven_security_entries() {
// Each security entry contributes 3 u16 words [tower_id, 0xFFFF, 0].
// Total security words = 7 * 3 = 21, plus a trailing 0 = 22.
// String section for one binding "h[1]" (4 chars): 1+4+1+1 = 7 words.
// Total entries = 7 + 22 = 29.
let buf =
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["h[1]"]);
let entries = u16::from_le_bytes([buf[64], buf[65]]);
assert_eq!(entries, 29);
}
#[test]
fn builder_auth_services_table_matches_dotnet_order() {
// The auth-service tower ids in the security tail must appear in the
// order the .NET reference writes them (cs:362).
assert_eq!(
CALLBACK_OBJREF_AUTH_SERVICES,
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E]
);
}
#[test]
fn builder_total_buffer_length_matches_words_count() {
// entries * 2 + HEADER_LEN
let buf =
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["x[1]"]);
let entries = u16::from_le_bytes([buf[64], buf[65]]) as usize;
assert_eq!(buf.len(), OBJREF_HEADER_LEN + entries * 2);
}
}