[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>
This commit is contained in:
Joseph Doherty
2026-05-05 07:14:29 -04:00
parent 95bd218183
commit 30138629d3
9 changed files with 1894 additions and 142 deletions
+21 -11
View File
@@ -42,18 +42,28 @@ 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.
### F7Consolidate `Guid` type across `mxaccess-rpc`
**Severity:** P3
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}`
**Why deferred:** `objref::Guid` is a self-contained `[u8; 16]` newtype with `Display` matching `.NET Guid.ToString("D")`. `pdu::SyntaxId` uses raw `[u8; 16]` for IIDs. Both work but a single shared type would be cleaner.
**Resolves when:** A small consolidation lands — either `pub use objref::Guid as Guid;` from `pdu`, or both move to a shared `crate::guid` module. Trivial; pick during M2 wave 2 when the next agent touches the crate.
### F9Port `ObjectExporterClient.cs` (4 NTLM-variant ResolveOxid transport wrappers)
**Severity:** P1
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`
**Why deferred:** Those four methods (`ResolveOxidUnauthenticated`, `ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`, `ResolveOxidWithManagedNtlmPacketIntegrity`) drive a `DceRpcTcpClient` that has not been ported. They are transport-layer code, not pure-codec, so they sit one wave below the OXID body codec that M2 wave 2 landed.
**Resolves when:** A `DceRpcTcpClient` Rust port lands (likely M2 wave 3 alongside the callback exporter, or M3 if the NMX session takes the lead). Then the four `resolve_oxid_with_*` flavors can be a thin call layer over `encode_resolve_oxid_request` + `parse_resolve_oxid_result`.
### F8 — `RpcError` is duplicated across `objref` and `pdu` modules
**Severity:** P3
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}`
**Why deferred:** Each module defined its own `RpcError` enum with a partial set of variants. Both are sound in isolation but the crate-public `RpcError` should be a single union. Not blocking — they don't collide because each module re-exports its own.
**Resolves when:** M2 wave 2 (OXID + `IRemUnknown::RemQueryInterface`) needs a third error surface. At that point, hoist `RpcError` to `crates/mxaccess-rpc/src/error.rs` mirroring `mxaccess-codec/src/error.rs`, and have each module use the shared enum.
### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec
**Severity:** P2
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`
**Why deferred:** `ObjectExporterMessages.cs` only models opnum 0 (`ResolveOxid`). Opnum 4 (`ResolveOxid2`) has a different response shape — it adds a `COMVERSION` plus an `AuthnHnt[]` array. The .NET reference does not exercise this path, so there's no executable spec to mirror.
**Resolves when:** Either a `[MS-DCOM]` §3.1.2.5.1.4-derived layout is verified against a captured `ResolveOxid2` exchange, or the .NET reference grows a `ParseResolveOxid2*` helper.
### F11 — `IRemUnknown::RemAddRef` and `RemRelease` body codecs
**Severity:** P2
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/rem_unknown.rs`
**Why deferred:** `RemUnknownMessages.cs` declares the opnums (`:9-10`) but does not implement encoders/decoders. The Rust port matches that exactly per "port what is already proven."
**Resolves when:** The .NET reference adds bodies for opnums 4 / 5 (or a captured frame establishes the on-wire shape). At that point port them into `rem_unknown.rs` alongside the existing `RemQueryInterface` codec.
## Resolved
(none yet)
### F7 — Consolidate `Guid` type across `mxaccess-rpc`
**Resolved:** 2026-05-05 in this iteration's commit. `Guid` was hoisted from `objref::Guid` into the new shared `crate::guid::Guid` module. `objref` and `pdu` now re-export from there; M2 wave 2's `orpc`, `object_exporter`, and `rem_unknown` import it directly. The OXID-resolve dual-string decoder additionally needs an owned protocol label (`format!("protseq_0x{:04x}", tower_id)` per `ObjectExporterMessages.cs:120`) — `ComDualStringEntry::protocol` was upgraded from `&'static str` to `Cow<'static, str>` to support both decoders without the agent's interim `Box::leak` workaround.
### 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.
+77
View File
@@ -0,0 +1,77 @@
//! Crate-level RPC error type.
//!
//! Hoisted from per-module enums in M2 wave 2 — see `design/followups.md` F8.
//! Every parser/encoder in `mxaccess-rpc` returns this single shared
//! [`RpcError`] so consumers can match on one error surface across PDU
//! decode, OBJREF parse, ORPC `ResolveOxid` body decode, and
//! `IRemUnknown::RemQueryInterface` response decode.
//!
//! Variants here are the union of what M1 wave 1 defined locally in
//! `pdu.rs` and `objref.rs` (`design/followups.md` F8 source list),
//! plus a generic [`RpcError::Decode`] for one-off conditions wave 2's
//! ORPC parsers need (referent-id mismatches, conformant-array max-count
//! underflow, NDR alignment overrun) without growing the enum further.
use thiserror::Error;
/// Errors raised by any codec under `mxaccess-rpc`.
#[derive(Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum RpcError {
/// Buffer was shorter than required to decode the type.
#[error("short read: expected {expected} bytes, got {actual}")]
ShortRead { expected: usize, actual: usize },
/// Packet type byte at offset 2 (`DceRpcPdu.cs:52`) did not match the
/// expected `DceRpcPacketType` for the parser invoked.
#[error("unexpected packet type {actual}, expected {expected}")]
UnexpectedPacketType { expected: u8, actual: u8 },
/// Packet type byte was not a known [`crate::pdu::PacketType`] value.
#[error("unknown packet type byte {0}")]
UnknownPacketType(u8),
/// `header.frag_length` is inconsistent with the supplied buffer or
/// `auth_length` (`DceRpcPdu.cs:94,150,188,226,101-104,156-159,195-198`).
#[error(
"fragment length {frag_length} inconsistent with buffer length {buffer_len} \
(auth_length={auth_length})"
)]
InvalidFragmentLength {
frag_length: usize,
buffer_len: usize,
auth_length: usize,
},
/// A bind PDU's per-context list ran past `frag_length`
/// (`DceRpcPdu.cs:237`) or a syntax identifier was truncated
/// (`DceRpcPdu.cs:354`).
#[error("truncated bind body at offset {offset}; need {need} bytes, frag_length={frag_length}")]
TruncatedBindBody {
offset: usize,
need: usize,
frag_length: usize,
},
/// Auth-trailer offset is below the 16-byte header
/// (`DceRpcPdu.cs:341-345`).
#[error("invalid auth trailer offset {offset}")]
InvalidAuthTrailer { offset: usize },
/// Tried to extract an auth value from a PDU whose `auth_length` is 0
/// (`DceRpcPdu.cs:336-339`).
#[error("PDU has no auth value")]
MissingAuthValue,
/// Generic decode failure with a position and reason. Used by ORPC
/// body decoders for one-off conditions that don't justify a typed
/// variant (e.g. NDR conformant-array max-count underflow per
/// `ObjectExporterMessages.cs:66-69`, referent-id of zero with no
/// trailing status per `:57-61`, NDR alignment overrun, etc.).
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
Decode {
offset: usize,
reason: &'static str,
buffer_len: usize,
},
}
+145
View File
@@ -0,0 +1,145 @@
//! 16-byte GUID with .NET-compatible display.
//!
//! Hoisted from `objref::Guid` in M2 wave 2 — see `design/followups.md` F7.
//! Both `objref` (for `iid`/`ipid`) and `pdu` (for `SyntaxId` IIDs) and the
//! M2 wave 2 `orpc::OrpcThis::cid` / `object_exporter::*` / `rem_unknown::*`
//! types share this single representation rather than each rolling their own.
//!
//! Stored as 16 wire bytes. The first three groups on the wire are
//! little-endian (`Data1` u32 LE, `Data2` u16 LE, `Data3` u16 LE) followed by
//! 8 big-endian `Data4` bytes — the byte layout produced by .NET
//! `new Guid(ReadOnlySpan<byte>)` and consumed by `Guid.TryWriteBytes` (used
//! across the .NET reference, e.g. `ComObjRef.cs:31,36`,
//! `OrpcStructures.cs:48,127`, `RemUnknownMessages.cs:20,30`).
#![allow(clippy::indexing_slicing)]
/// 16-byte GUID. See module docs for byte layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Guid(pub [u8; 16]);
impl Guid {
pub const ZERO: Guid = Guid([0u8; 16]);
pub const fn new(bytes: [u8; 16]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
/// Parse a `Guid` from a 16-byte little-endian-leading wire slice. Mirrors
/// the .NET `new Guid(span)` byte order.
///
/// # Errors
/// Returns [`crate::error::RpcError::ShortRead`] if `bytes.len() < 16`.
pub fn parse(bytes: &[u8]) -> Result<Self, crate::error::RpcError> {
if bytes.len() < 16 {
return Err(crate::error::RpcError::ShortRead {
expected: 16,
actual: bytes.len(),
});
}
let mut out = [0u8; 16];
out.copy_from_slice(&bytes[..16]);
Ok(Self(out))
}
/// Write the 16 wire bytes into `dst[..16]`. Mirrors .NET
/// `Guid.TryWriteBytes(span)`.
///
/// # Errors
/// Returns [`crate::error::RpcError::ShortRead`] if `dst.len() < 16`.
pub fn write_to(&self, dst: &mut [u8]) -> Result<(), crate::error::RpcError> {
if dst.len() < 16 {
return Err(crate::error::RpcError::ShortRead {
expected: 16,
actual: dst.len(),
});
}
dst[..16].copy_from_slice(&self.0);
Ok(())
}
}
impl std::fmt::Display for Guid {
/// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g.
/// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are
/// little-endian on the wire so are byte-swapped on display.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let b = &self.0;
write!(
f,
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[3],
b[2],
b[1],
b[0],
b[5],
b[4],
b[7],
b[6],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15],
)
}
}
impl From<[u8; 16]> for Guid {
fn from(bytes: [u8; 16]) -> Self {
Self(bytes)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
#[test]
fn display_matches_dotnet_d_format() {
// First 3 groups are byte-swapped on display (LE wire → BE display).
let g = Guid::new([
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
0x27, 0x46,
]);
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn parse_round_trip() {
let bytes = [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
let g = Guid::parse(&bytes).unwrap();
let mut out = [0u8; 16];
g.write_to(&mut out).unwrap();
assert_eq!(out, bytes);
}
#[test]
fn parse_short_buffer_errors() {
assert!(matches!(
Guid::parse(&[0u8; 15]),
Err(crate::error::RpcError::ShortRead { .. })
));
}
#[test]
fn zero_guid() {
assert_eq!(
Guid::ZERO.to_string(),
"00000000-0000-0000-0000-000000000000"
);
}
}
+13 -5
View File
@@ -1,17 +1,25 @@
//! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface.
//!
//! M2 wave 1 landed: `ntlm`, `pdu`, `objref`. OXID resolution and
//! `IRemUnknown::RemQueryInterface` follow in wave 2; the callback exporter
//! in wave 3 — see `design/60-roadmap.md` and `design/dependencies.md`.
//! - M2 wave 1 (landed): `ntlm`, `pdu`, `objref`.
//! - M2 wave 2 (landed): `guid` + `error` (shared types — resolves F7+F8),
//! `orpc` (ORPC framing), `object_exporter` (OXID resolution body codec),
//! `rem_unknown` (`IRemUnknown::RemQueryInterface` body codec).
//! - M2 wave 3 (next): callback exporter — see `design/60-roadmap.md` and
//! `design/dependencies.md`.
//!
//! Internal `unsafe` is permitted only for `windows-rs` COM activation paths
//! (per `design/00-overview.md` principle 3); all such calls must be wrapped
//! in safe abstractions at the crate boundary. Wave 1 modules are pure-Rust
//! and contain no `unsafe`.
//! in safe abstractions at the crate boundary. All modules to date are
//! pure-Rust and contain no `unsafe`.
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
// windows-rs COM calls). Public API stays safe.
pub mod error;
pub mod guid;
pub mod ntlm;
pub mod object_exporter;
pub mod objref;
pub mod orpc;
pub mod pdu;
pub mod rem_unknown;
@@ -0,0 +1,749 @@
//! `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);
}
}
+20 -63
View File
@@ -26,7 +26,10 @@
use std::fmt::Write as _;
use thiserror::Error;
// `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`:
///
@@ -64,65 +67,6 @@ const IPID_OFFSET: usize = 48;
const DUAL_STRING_ENTRIES_OFFSET: usize = 64;
const DUAL_STRING_SECURITY_OFFSET_OFFSET: usize = 66;
/// 16-byte GUID. Stored as little-endian wire bytes for the first three groups
/// (Data1 u32 LE, Data2 u16 LE, Data3 u16 LE) followed by 8 big-endian
/// `Data4` bytes — matches the byte layout produced by .NET
/// `new Guid(ReadOnlySpan<byte>)` (`ComObjRef.cs:31,36`).
///
/// Kept as a self-contained type to avoid pulling `uuid` into `mxaccess-rpc`;
/// the sibling DCE/RPC PDU codec may consolidate to a shared type at the
/// loop-driver level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Guid(pub [u8; 16]);
impl Guid {
pub const fn new(bytes: [u8; 16]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
impl std::fmt::Display for Guid {
/// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g.
/// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are
/// little-endian on the wire so are byte-swapped on display.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let b = &self.0;
write!(
f,
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[3],
b[2],
b[1],
b[0],
b[5],
b[4],
b[7],
b[6],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15],
)
}
}
/// Errors produced by the OBJREF parser.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RpcError {
/// Buffer too short to satisfy a fixed-layout read.
#[error("short read: expected {expected} bytes, got {actual}")]
ShortRead { expected: usize, actual: usize },
}
/// 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`
@@ -130,10 +74,17 @@ pub enum RpcError {
/// `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: &'static str,
pub protocol: std::borrow::Cow<'static, str>,
pub value: String,
pub is_security_binding: bool,
}
@@ -310,7 +261,7 @@ fn decode_dual_string_array(
strings.push(ComDualStringEntry {
tower_id,
protocol: protocol_tower_name(tower_id),
protocol: std::borrow::Cow::Borrowed(protocol_tower_name(tower_id)),
value: text,
is_security_binding: entry_start >= security_offset as usize,
});
@@ -368,7 +319,12 @@ const _: () = assert!(OBJREF_HEADER_LEN == 68);
const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D);
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
@@ -474,6 +430,7 @@ mod tests {
assert_eq!(expected, 68);
assert_eq!(actual, 67);
}
other => panic!("expected ShortRead, got {other:?}"),
}
}
+397
View File
@@ -0,0 +1,397 @@
//! ORPC structures shared by `IObjectExporter` and `IRemUnknown` requests.
//!
//! Direct port of `src/MxNativeClient/OrpcStructures.cs`. Provides:
//!
//! - [`ComVersion`] — 4-byte (Major u16, Minor u16) DCOM version pair.
//! - [`OrpcThis`] — 32-byte ORPC request header (`OrpcStructures.cs:10-52`).
//! - [`OrpcThat`] — 8-byte ORPC response header (`OrpcStructures.cs:54-77`).
//! - [`MInterfacePointer`] — length-prefixed OBJREF wrapper
//! (`OrpcStructures.cs:79-109`).
//! - [`StdObjRef`] — 40-byte STDOBJREF body (`OrpcStructures.cs:111-140`).
//!
//! All multi-byte fields are little-endian.
//!
//! These types are M2 wave 2 prerequisites for [`crate::object_exporter`] and
//! [`crate::rem_unknown`]; the wave 2 agents import them rather than each
//! defining their own ORPC framing.
#![allow(clippy::indexing_slicing)]
use crate::error::RpcError;
use crate::guid::Guid;
/// `OrpcStructures.cs:5-8` — DCOM version pair.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ComVersion {
pub major: u16,
pub minor: u16,
}
impl ComVersion {
/// Default version `5.7` per `OrpcStructures.cs:7`.
pub const VERSION_5_7: ComVersion = ComVersion { major: 5, minor: 7 };
pub const fn new(major: u16, minor: u16) -> Self {
Self { major, minor }
}
}
impl Default for ComVersion {
fn default() -> Self {
Self::VERSION_5_7
}
}
/// 32-byte ORPC request header (without extensions).
/// Mirrors `OrpcThis` (`OrpcStructures.cs:10-52`).
///
/// ```text
/// offset size field
/// 0 2 version.major u16 LE
/// 2 2 version.minor u16 LE
/// 4 4 flags u32 LE
/// 8 4 reserved1 u32 LE
/// 12 16 cid GUID
/// 28 4 extensions_referent_id u32 LE
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrpcThis {
pub version: ComVersion,
pub flags: u32,
pub reserved1: u32,
pub cid: Guid,
pub extensions_referent_id: u32,
}
impl OrpcThis {
/// Encoded length without extensions — `OrpcStructures.cs:17`.
pub const ENCODED_LEN: usize = 32;
/// Construct with default version 5.7 and zeroed flags/extensions.
/// Mirrors `OrpcThis.Create(cid, version)` (`OrpcStructures.cs:19-22`).
pub fn create(cid: Guid, version: Option<ComVersion>) -> Self {
Self {
version: version.unwrap_or_default(),
flags: 0,
reserved1: 0,
cid,
extensions_referent_id: 0,
}
}
/// Decode the 32-byte header. Mirrors `OrpcThis.Parse`
/// (`OrpcStructures.cs:24-39`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 32`.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::ENCODED_LEN {
return Err(RpcError::ShortRead {
expected: Self::ENCODED_LEN,
actual: buffer.len(),
});
}
Ok(Self {
version: ComVersion::new(
u16::from_le_bytes([buffer[0], buffer[1]]),
u16::from_le_bytes([buffer[2], buffer[3]]),
),
flags: u32::from_le_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]),
reserved1: u32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]),
cid: Guid::parse(&buffer[12..28])?,
extensions_referent_id: u32::from_le_bytes([
buffer[28], buffer[29], buffer[30], buffer[31],
]),
})
}
/// Encode to 32 bytes. Mirrors `OrpcThis.Encode`
/// (`OrpcStructures.cs:41-51`).
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
let mut buf = [0u8; Self::ENCODED_LEN];
buf[0..2].copy_from_slice(&self.version.major.to_le_bytes());
buf[2..4].copy_from_slice(&self.version.minor.to_le_bytes());
buf[4..8].copy_from_slice(&self.flags.to_le_bytes());
buf[8..12].copy_from_slice(&self.reserved1.to_le_bytes());
buf[12..28].copy_from_slice(self.cid.as_bytes());
buf[28..32].copy_from_slice(&self.extensions_referent_id.to_le_bytes());
buf
}
}
/// 8-byte ORPC response header (without extensions).
/// Mirrors `OrpcThat` (`OrpcStructures.cs:54-77`).
///
/// ```text
/// offset size field
/// 0 4 flags u32 LE
/// 4 4 extensions_referent_id u32 LE
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct OrpcThat {
pub flags: u32,
pub extensions_referent_id: u32,
}
impl OrpcThat {
/// Encoded length without extensions — `OrpcStructures.cs:56`.
pub const ENCODED_LEN: usize = 8;
/// Decode 8 bytes. Mirrors `OrpcThat.Parse` (`OrpcStructures.cs:58-68`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 8`.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::ENCODED_LEN {
return Err(RpcError::ShortRead {
expected: Self::ENCODED_LEN,
actual: buffer.len(),
});
}
Ok(Self {
flags: u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]),
extensions_referent_id: u32::from_le_bytes([
buffer[4], buffer[5], buffer[6], buffer[7],
]),
})
}
/// Encode to 8 bytes. Mirrors `OrpcThat.Encode`
/// (`OrpcStructures.cs:70-76`).
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
let mut buf = [0u8; Self::ENCODED_LEN];
buf[0..4].copy_from_slice(&self.flags.to_le_bytes());
buf[4..8].copy_from_slice(&self.extensions_referent_id.to_le_bytes());
buf
}
}
/// Length-prefixed OBJREF byte wrapper used to carry interface pointers in
/// ORPC bodies. Mirrors `MInterfacePointer` (`OrpcStructures.cs:79-109`).
///
/// Wire layout: `u32 LE size || size bytes of OBJREF`. The Rust port owns the
/// `objref_bytes` `Vec<u8>` (matching the .NET `byte[] ObjRefBytes`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MInterfacePointer {
pub objref_bytes: Vec<u8>,
}
impl MInterfacePointer {
/// Header length (the `u32` size prefix).
pub const SIZE_PREFIX_LEN: usize = 4;
pub fn new(objref_bytes: Vec<u8>) -> Self {
Self { objref_bytes }
}
/// Encode as `size_le32 || objref_bytes`. Mirrors `Encode`
/// (`OrpcStructures.cs:81-87`).
pub fn encode(&self) -> Vec<u8> {
let len = self.objref_bytes.len();
let mut buf = Vec::with_capacity(Self::SIZE_PREFIX_LEN + len);
let len_u32: u32 = len.try_into().unwrap_or(u32::MAX);
buf.extend_from_slice(&len_u32.to_le_bytes());
buf.extend_from_slice(&self.objref_bytes);
buf
}
/// Parse `size_le32 || size bytes` into an owned `MInterfacePointer`.
/// Mirrors `Parse` (`OrpcStructures.cs:89-103`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if the buffer is shorter than the
/// 4-byte size prefix, or [`RpcError::Decode`] if the declared size
/// runs past the buffer.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::SIZE_PREFIX_LEN {
return Err(RpcError::ShortRead {
expected: Self::SIZE_PREFIX_LEN,
actual: buffer.len(),
});
}
let size = u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
if size > buffer.len() - Self::SIZE_PREFIX_LEN {
return Err(RpcError::Decode {
offset: Self::SIZE_PREFIX_LEN,
reason: "MInterfacePointer OBJREF payload truncated",
buffer_len: buffer.len(),
});
}
Ok(Self {
objref_bytes: buffer[Self::SIZE_PREFIX_LEN..Self::SIZE_PREFIX_LEN + size].to_vec(),
})
}
/// Parse the inner OBJREF bytes through [`crate::objref::ComObjRef::parse`].
/// Mirrors `MInterfacePointer.ParseObjRef` (`OrpcStructures.cs:105-108`).
pub fn parse_objref(&self) -> Result<crate::objref::ComObjRef, RpcError> {
crate::objref::ComObjRef::parse(&self.objref_bytes)
}
}
/// 40-byte STDOBJREF body. Mirrors `StdObjRef` (`OrpcStructures.cs:111-140`).
///
/// ```text
/// offset size field
/// 0 4 flags u32 LE
/// 4 4 public_refs u32 LE
/// 8 8 oxid u64 LE
/// 16 8 oid u64 LE
/// 24 16 ipid GUID
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct StdObjRef {
pub flags: u32,
pub public_refs: u32,
pub oxid: u64,
pub oid: u64,
pub ipid: Guid,
}
impl StdObjRef {
/// Encoded length — `OrpcStructures.cs:113`.
pub const ENCODED_LEN: usize = 40;
/// Decode 40 bytes. Mirrors `StdObjRef.Parse`
/// (`OrpcStructures.cs:115-128`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 40`.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::ENCODED_LEN {
return Err(RpcError::ShortRead {
expected: Self::ENCODED_LEN,
actual: buffer.len(),
});
}
Ok(Self {
flags: u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]),
public_refs: u32::from_le_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]),
oxid: u64::from_le_bytes([
buffer[8], buffer[9], buffer[10], buffer[11], buffer[12], buffer[13], buffer[14],
buffer[15],
]),
oid: u64::from_le_bytes([
buffer[16], buffer[17], buffer[18], buffer[19], buffer[20], buffer[21], buffer[22],
buffer[23],
]),
ipid: Guid::parse(&buffer[24..40])?,
})
}
/// Encode to 40 bytes. Mirrors `StdObjRef.Encode`
/// (`OrpcStructures.cs:130-139`).
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
let mut buf = [0u8; Self::ENCODED_LEN];
buf[0..4].copy_from_slice(&self.flags.to_le_bytes());
buf[4..8].copy_from_slice(&self.public_refs.to_le_bytes());
buf[8..16].copy_from_slice(&self.oxid.to_le_bytes());
buf[16..24].copy_from_slice(&self.oid.to_le_bytes());
buf[24..40].copy_from_slice(self.ipid.as_bytes());
buf
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
fn sample_guid(seed: u8) -> Guid {
let mut b = [0u8; 16];
for (i, slot) in b.iter_mut().enumerate() {
*slot = seed.wrapping_add(i as u8);
}
Guid::new(b)
}
#[test]
fn com_version_default_is_5_7() {
assert_eq!(ComVersion::default(), ComVersion::new(5, 7));
}
#[test]
fn orpc_this_round_trip() {
let cid = sample_guid(0x10);
let original = OrpcThis::create(cid, None);
let encoded = original.encode();
assert_eq!(encoded.len(), OrpcThis::ENCODED_LEN);
let decoded = OrpcThis::parse(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn orpc_this_short_buffer_errors() {
assert!(matches!(
OrpcThis::parse(&[0u8; 31]),
Err(RpcError::ShortRead {
expected: 32,
actual: 31
})
));
}
#[test]
fn orpc_that_round_trip() {
let original = OrpcThat {
flags: 0xDEAD_BEEF,
extensions_referent_id: 0x1234_5678,
};
let encoded = original.encode();
assert_eq!(encoded.len(), OrpcThat::ENCODED_LEN);
let decoded = OrpcThat::parse(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn m_interface_pointer_round_trip() {
let mip = MInterfacePointer::new(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
let encoded = mip.encode();
assert_eq!(encoded.len(), 4 + 5);
// Size prefix is 5.
assert_eq!(&encoded[0..4], &5u32.to_le_bytes());
let decoded = MInterfacePointer::parse(&encoded).unwrap();
assert_eq!(decoded, mip);
}
#[test]
fn m_interface_pointer_truncated_payload_errors() {
// Declares 16 bytes but only supplies 4 after the prefix.
let mut bad = Vec::new();
bad.extend_from_slice(&16u32.to_le_bytes());
bad.extend_from_slice(&[0u8; 4]);
let err = MInterfacePointer::parse(&bad).unwrap_err();
assert!(matches!(err, RpcError::Decode { .. }));
}
#[test]
fn std_objref_round_trip() {
let original = StdObjRef {
flags: 0,
public_refs: 5,
oxid: 0x1122_3344_5566_7788,
oid: 0x99AA_BBCC_DDEE_FF00,
ipid: sample_guid(0x55),
};
let encoded = original.encode();
assert_eq!(encoded.len(), StdObjRef::ENCODED_LEN);
let decoded = StdObjRef::parse(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn std_objref_short_buffer_errors() {
assert!(matches!(
StdObjRef::parse(&[0u8; 39]),
Err(RpcError::ShortRead {
expected: 40,
actual: 39
})
));
}
}
+3 -63
View File
@@ -21,69 +21,9 @@
// the same rationale.
#![allow(clippy::indexing_slicing)]
use thiserror::Error;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors raised by the DCE/RPC PDU codec.
#[derive(Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum RpcError {
/// Buffer was shorter than required to decode the type.
/// Mirrors `ArgumentException("DCE/RPC PDU header is too short.")`
/// at `DceRpcPdu.cs:46` and the per-PDU truncation checks at
/// `DceRpcPdu.cs:94,150,188,226`.
#[error("short read: expected {expected} bytes, got {actual}")]
ShortRead { expected: usize, actual: usize },
/// Packet type byte at offset 2 (`DceRpcPdu.cs:52`) did not match the
/// expected `DceRpcPacketType` for the parser invoked. Mirrors
/// `ArgumentException("PDU is not a request.", ...)` at
/// `DceRpcPdu.cs:91,147,185,223`.
#[error("unexpected packet type {actual}, expected {expected}")]
UnexpectedPacketType { expected: u8, actual: u8 },
/// Packet type byte was not a known [`PacketType`] value
/// (`DceRpcPdu.cs:5-15`).
#[error("unknown packet type byte {0}")]
UnknownPacketType(u8),
/// `header.frag_length` exceeds the supplied buffer length (per
/// `DceRpcPdu.cs:94,150,188,226`) or the body declared by it would
/// produce a negative stub length (`DceRpcPdu.cs:101-104,156-159,
/// 195-198`).
#[error(
"fragment length {frag_length} inconsistent with buffer length {buffer_len} \
(auth_length={auth_length})"
)]
InvalidFragmentLength {
frag_length: usize,
buffer_len: usize,
auth_length: usize,
},
/// A bind PDU's per-context list ran past `frag_length`
/// (`DceRpcPdu.cs:237`) or a syntax identifier was truncated
/// (`DceRpcPdu.cs:354`).
#[error("truncated bind body at offset {offset}; need {need} bytes, frag_length={frag_length}")]
TruncatedBindBody {
offset: usize,
need: usize,
frag_length: usize,
},
/// Auth-trailer offset is below the 16-byte header
/// (`DceRpcPdu.cs:341-345`).
#[error("invalid auth trailer offset {offset}")]
InvalidAuthTrailer { offset: usize },
/// Tried to extract an auth value from a PDU whose `auth_length` is 0
/// (`DceRpcPdu.cs:336-339`).
#[error("PDU has no auth value")]
MissingAuthValue,
}
// `RpcError` is crate-shared since M2 wave 2 — see `design/followups.md` F8.
// Re-exported here so `pdu::RpcError` continues to be a stable path.
pub use crate::error::RpcError;
// ---------------------------------------------------------------------------
// Packet type — `DceRpcPdu.cs:5-15`
+469
View File
@@ -0,0 +1,469 @@
//! `IRemUnknown` request/response codecs.
//!
//! Direct port of `src/MxNativeClient/RemUnknownMessages.cs`. Provides:
//!
//! - [`IREM_UNKNOWN_IID`] — `IRemUnknown` interface IID
//! (`RemUnknownMessages.cs:7`).
//! - [`REM_QUERY_INTERFACE_OPNUM`], [`REM_ADD_REF_OPNUM`],
//! [`REM_RELEASE_OPNUM`] — DCE/RPC opnums (`RemUnknownMessages.cs:8-10`).
//! - [`encode_rem_query_interface_request`] — builds the body for the
//! `RemQueryInterface` request (`RemUnknownMessages.cs:12-33`).
//! - [`parse_rem_query_interface_response`] — decodes the response body
//! (`RemUnknownMessages.cs:35-59`).
//! - [`RemQueryInterfaceResponse`] (`RemUnknownMessages.cs:62`).
//! - [`RemQiResult`] — `REMQIRESULT` body (`RemUnknownMessages.cs:64-79`).
//!
//! All multi-byte fields are little-endian.
//!
//! The 4-byte pad in `REMQIRESULT` between `hresult` and the embedded
//! `STDOBJREF` is preserved on decode (`pad_after_hresult: [u8; 4]`) per
//! the CLAUDE.md "preserve unknown bytes" rule. The native .NET reference
//! reads-and-discards it (`RemUnknownMessages.cs:75-77`); Rust holds onto
//! the bytes so callers can round-trip captures byte-for-byte.
#![allow(clippy::indexing_slicing)]
use crate::error::RpcError;
use crate::guid::Guid;
use crate::orpc::{OrpcThat, OrpcThis, StdObjRef};
/// `IRemUnknown` IID `00000131-0000-0000-C000-000000000046`
/// (`RemUnknownMessages.cs:7`).
pub const IREM_UNKNOWN_IID: Guid = Guid::new([
0x31, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46,
]);
/// `RemQueryInterface` opnum (`RemUnknownMessages.cs:8`).
pub const REM_QUERY_INTERFACE_OPNUM: u16 = 3;
/// `RemAddRef` opnum (`RemUnknownMessages.cs:9`).
pub const REM_ADD_REF_OPNUM: u16 = 4;
/// `RemRelease` opnum (`RemUnknownMessages.cs:10`).
pub const REM_RELEASE_OPNUM: u16 = 5;
/// Total length of an encoded `RemQueryInterface` request body for a single
/// requested IID. `OrpcThis(32) + ipid(16) + public_refs(4) + iid_count(2) +
/// align(2) + max_count(4) + iid(16) = 76`. Mirrors the byte-by-byte sum in
/// `RemUnknownMessages.cs:15-32`.
const REM_QUERY_INTERFACE_REQUEST_LEN: usize = OrpcThis::ENCODED_LEN + 16 + 4 + 2 + 2 + 4 + 16;
const _: () = assert!(REM_QUERY_INTERFACE_REQUEST_LEN == 76);
/// Encode a `RemQueryInterface` request body for a single requested IID.
///
/// Mirrors `EncodeRemQueryInterfaceRequest` (`RemUnknownMessages.cs:12-33`).
/// Layout:
///
/// ```text
/// offset size field
/// 0 32 OrpcThis (header)
/// 32 16 source IPID (GUID)
/// 48 4 public_refs u32 LE
/// 52 2 iid_count u16 LE = 1
/// 54 2 NDR alignment 0xCE 0xCE (RemUnknownMessages.cs:26-27)
/// 56 4 max_count u32 LE = 1 (conformant array max count)
/// 60 16 requested IID (GUID)
/// ```
///
/// Native passes `public_refs = 5` by default (`RemUnknownMessages.cs:12`);
/// the Rust signature requires the caller to pass it explicitly so the
/// default isn't accidentally hidden.
#[must_use]
pub fn encode_rem_query_interface_request(
source_ipid: Guid,
requested_iid: Guid,
causality_id: Guid,
public_refs: u32,
) -> Vec<u8> {
let orpc_this = OrpcThis::create(causality_id, None).encode();
let mut body = Vec::with_capacity(REM_QUERY_INTERFACE_REQUEST_LEN);
// 0..32 — OrpcThis header.
body.extend_from_slice(&orpc_this);
// 32..48 — source IPID.
body.extend_from_slice(source_ipid.as_bytes());
// 48..52 — public refs (default 5 in native).
body.extend_from_slice(&public_refs.to_le_bytes());
// 52..54 — iid count = 1.
body.extend_from_slice(&1u16.to_le_bytes());
// 54..56 — NDR alignment before the conformant array max count
// (`RemUnknownMessages.cs:26-27`).
body.push(0xCE);
body.push(0xCE);
// 56..60 — max count = 1.
body.extend_from_slice(&1u32.to_le_bytes());
// 60..76 — requested IID.
body.extend_from_slice(requested_iid.as_bytes());
debug_assert_eq!(body.len(), REM_QUERY_INTERFACE_REQUEST_LEN);
body
}
/// Decoded `RemQueryInterface` response body.
/// Mirrors `RemQueryInterfaceResponse` (`RemUnknownMessages.cs:62`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemQueryInterfaceResponse {
pub orpc_that: OrpcThat,
/// `Some` when the wire `referent_id` is non-zero
/// (`RemUnknownMessages.cs:46-50`); otherwise the server sent no
/// `REMQIRESULT` array.
pub result: Option<RemQiResult>,
/// Trailing status word at a position that depends on whether `result`
/// was parsed (`RemUnknownMessages.cs:52-58`).
pub error_code: u32,
}
/// `REMQIRESULT` body. Mirrors `RemQiResult` (`RemUnknownMessages.cs:64-79`).
///
/// ```text
/// offset size field
/// 0 4 hresult i32 LE
/// 4 4 pad_after_hresult [u8; 4] (NDR padding ahead of STDOBJREF;
/// `RemUnknownMessages.cs:75-77`
/// skips offsets 4..8)
/// 8 40 standard_object_reference (STDOBJREF)
/// ```
///
/// The 4 bytes between `hresult` and `standard_object_reference` are the
/// `IPID`-aligned NDR pad noted in `RemUnknownMessages.cs:77`. Native
/// reads-and-discards them; the Rust port preserves them as
/// `pad_after_hresult` per the CLAUDE.md "preserve unknown bytes" rule so
/// captures round-trip exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemQiResult {
pub hresult: i32,
pub pad_after_hresult: [u8; 4],
pub standard_object_reference: StdObjRef,
}
impl RemQiResult {
/// Encoded length — `4 + 4 + StdObjRef::ENCODED_LEN = 48`
/// (`RemUnknownMessages.cs:66`).
pub const ENCODED_LEN: usize = 4 + 4 + StdObjRef::ENCODED_LEN;
/// Decode 48 bytes. Mirrors `RemQiResult.Parse`
/// (`RemUnknownMessages.cs:68-78`). The 4 bytes at offsets 4..8 are
/// captured into `pad_after_hresult` rather than discarded
/// (CLAUDE.md "preserve unknown bytes").
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 48`.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::ENCODED_LEN {
return Err(RpcError::ShortRead {
expected: Self::ENCODED_LEN,
actual: buffer.len(),
});
}
let hresult = i32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
let mut pad_after_hresult = [0u8; 4];
pad_after_hresult.copy_from_slice(&buffer[4..8]);
let standard_object_reference = StdObjRef::parse(&buffer[8..Self::ENCODED_LEN])?;
Ok(Self {
hresult,
pad_after_hresult,
standard_object_reference,
})
}
/// Encode to 48 bytes. Native zeroes the 4-byte pad
/// (`RemUnknownMessages.cs` does not have a symmetric encoder, but the
/// pad slot is always 0 in captured server responses); the Rust port
/// writes whatever bytes the caller provided in `pad_after_hresult`.
#[must_use]
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
let mut buf = [0u8; Self::ENCODED_LEN];
buf[0..4].copy_from_slice(&self.hresult.to_le_bytes());
buf[4..8].copy_from_slice(&self.pad_after_hresult);
buf[8..Self::ENCODED_LEN].copy_from_slice(&self.standard_object_reference.encode());
buf
}
}
/// Minimum length of a `RemQueryInterface` response: `OrpcThat(8) +
/// referent_id(4) + REMQIRESULT(48) + error_code(4) = 64`. Mirrors the
/// pre-check at `RemUnknownMessages.cs:37`.
const REM_QUERY_INTERFACE_RESPONSE_MIN_LEN: usize =
OrpcThat::ENCODED_LEN + 4 + RemQiResult::ENCODED_LEN + 4;
const _: () = assert!(REM_QUERY_INTERFACE_RESPONSE_MIN_LEN == 64);
/// Decode a `RemQueryInterface` response body.
///
/// Mirrors `ParseRemQueryInterfaceResponse` (`RemUnknownMessages.cs:35-59`).
/// The `referent_id != 0` branch (`RemUnknownMessages.cs:46-50`) is the Q7
/// conditional read called out in `design/70-risks-and-open-questions.md:283-289`:
/// the `REMQIRESULT` array is parsed only when `referent_id != 0`, and the
/// trailing `error_code` lives at a different offset depending on whether
/// it was parsed (`RemUnknownMessages.cs:52-58`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if the buffer is shorter than the
/// 64-byte minimum, or [`RpcError::Decode`] if the trailing `error_code`
/// runs past the buffer (the conditional path makes this possible even
/// when the minimum length is met).
pub fn parse_rem_query_interface_response(
buffer: &[u8],
) -> Result<RemQueryInterfaceResponse, RpcError> {
if buffer.len() < REM_QUERY_INTERFACE_RESPONSE_MIN_LEN {
return Err(RpcError::ShortRead {
expected: REM_QUERY_INTERFACE_RESPONSE_MIN_LEN,
actual: buffer.len(),
});
}
let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?;
let referent_id_offset = OrpcThat::ENCODED_LEN;
let referent_id = u32::from_le_bytes([
buffer[referent_id_offset],
buffer[referent_id_offset + 1],
buffer[referent_id_offset + 2],
buffer[referent_id_offset + 3],
]);
let mut offset = referent_id_offset + 4;
let result = if referent_id != 0 {
// Conformant array max count for the REMQIRESULT result array
// (`RemUnknownMessages.cs:48`).
offset += 4;
if buffer.len() < offset + RemQiResult::ENCODED_LEN {
return Err(RpcError::Decode {
offset,
reason: "RemQueryInterface response truncated before REMQIRESULT",
buffer_len: buffer.len(),
});
}
let parsed = RemQiResult::parse(&buffer[offset..offset + RemQiResult::ENCODED_LEN])?;
offset += RemQiResult::ENCODED_LEN;
Some(parsed)
} else {
None
};
if buffer.len() < offset + 4 {
return Err(RpcError::Decode {
offset,
reason: "RemQueryInterface response truncated before error_code",
buffer_len: buffer.len(),
});
}
let error_code = u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
]);
Ok(RemQueryInterfaceResponse {
orpc_that,
result,
error_code,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
fn sample_guid(seed: u8) -> Guid {
let mut b = [0u8; 16];
for (i, slot) in b.iter_mut().enumerate() {
*slot = seed.wrapping_add(i as u8);
}
Guid::new(b)
}
fn sample_std_objref() -> StdObjRef {
StdObjRef {
flags: 0,
public_refs: 5,
oxid: 0x1122_3344_5566_7788,
oid: 0x99AA_BBCC_DDEE_FF00,
ipid: sample_guid(0x55),
}
}
#[test]
fn irem_unknown_iid_matches_dotnet() {
// RemUnknownMessages.cs:7 — 00000131-0000-0000-C000-000000000046.
assert_eq!(
IREM_UNKNOWN_IID.as_bytes(),
&[
0x31, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x46,
]
);
// Display order also matches Guid.ToString("D").
assert_eq!(
IREM_UNKNOWN_IID.to_string(),
"00000131-0000-0000-c000-000000000046"
);
}
#[test]
fn opnums_match_dotnet() {
assert_eq!(REM_QUERY_INTERFACE_OPNUM, 3);
assert_eq!(REM_ADD_REF_OPNUM, 4);
assert_eq!(REM_RELEASE_OPNUM, 5);
}
#[test]
fn encode_rem_query_interface_request_layout() {
let source_ipid = sample_guid(0x10);
let requested_iid = sample_guid(0x20);
let causality_id = sample_guid(0x30);
let body = encode_rem_query_interface_request(source_ipid, requested_iid, causality_id, 5);
// 32 (OrpcThis) + 16 (ipid) + 4 (refs) + 2 (count) + 2 (align) + 4 (max) + 16 (iid).
assert_eq!(body.len(), 76);
// OrpcThis header round-trip (validates the first 32 bytes).
let parsed_this = OrpcThis::parse(&body[..OrpcThis::ENCODED_LEN]).unwrap();
assert_eq!(parsed_this.cid, causality_id);
assert_eq!(parsed_this.flags, 0);
assert_eq!(parsed_this.extensions_referent_id, 0);
// Source IPID at offset 32.
assert_eq!(&body[32..48], source_ipid.as_bytes());
// public_refs at offset 48.
assert_eq!(&body[48..52], &5u32.to_le_bytes());
// iid_count at offset 52.
assert_eq!(&body[52..54], &1u16.to_le_bytes());
// NDR alignment 0xCE 0xCE at offset 54 (RemUnknownMessages.cs:26-27).
assert_eq!(body[54], 0xCE);
assert_eq!(body[55], 0xCE);
// max_count at offset 56.
assert_eq!(&body[56..60], &1u32.to_le_bytes());
// requested IID at offset 60.
assert_eq!(&body[60..76], requested_iid.as_bytes());
}
#[test]
fn encode_rem_query_interface_request_respects_public_refs() {
let body =
encode_rem_query_interface_request(Guid::ZERO, Guid::ZERO, Guid::ZERO, 0xDEAD_BEEF);
assert_eq!(&body[48..52], &0xDEAD_BEEFu32.to_le_bytes());
}
#[test]
fn rem_qi_result_round_trip() {
let original = RemQiResult {
hresult: 0,
pad_after_hresult: [0xAA, 0xBB, 0xCC, 0xDD],
standard_object_reference: sample_std_objref(),
};
let encoded = original.encode();
assert_eq!(encoded.len(), RemQiResult::ENCODED_LEN);
assert_eq!(encoded.len(), 48);
// Pad bytes preserved exactly (CLAUDE.md "preserve unknown bytes").
assert_eq!(&encoded[4..8], &[0xAA, 0xBB, 0xCC, 0xDD]);
let decoded = RemQiResult::parse(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn rem_qi_result_short_buffer_errors() {
assert!(matches!(
RemQiResult::parse(&[0u8; 47]),
Err(RpcError::ShortRead {
expected: 48,
actual: 47
})
));
}
#[test]
fn parse_response_referent_id_zero_skips_result() {
// Layout when referent_id == 0:
// 0..8 OrpcThat
// 8..12 referent_id = 0
// 12..16 error_code
// Native (`RemUnknownMessages.cs:46-58`): when referent_id == 0,
// result is None and error_code is read from offset 12 directly.
// The pre-check at :37 still requires a 64-byte buffer, so we pad
// the trailing portion with junk that the parser must ignore once
// it has the error_code at offset 12.
let mut buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN];
// OrpcThat
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
buf[4..8].copy_from_slice(&0u32.to_le_bytes());
// referent_id = 0
buf[8..12].copy_from_slice(&0u32.to_le_bytes());
// error_code at offset 12 in this branch.
buf[12..16].copy_from_slice(&0x8000_4005u32.to_le_bytes());
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert!(resp.result.is_none());
assert_eq!(resp.error_code, 0x8000_4005);
}
#[test]
fn parse_response_referent_id_nonzero_parses_result() {
// Layout when referent_id != 0:
// 0..8 OrpcThat
// 8..12 referent_id != 0
// 12..16 conformant-array max_count (skipped per :48)
// 16..64 REMQIRESULT
// 64..68 error_code
let std_ref = sample_std_objref();
let inner = RemQiResult {
hresult: 0,
pad_after_hresult: [0u8; 4],
standard_object_reference: std_ref,
};
let mut buf = vec![0u8; OrpcThat::ENCODED_LEN + 4 + 4 + RemQiResult::ENCODED_LEN + 4];
// OrpcThat
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
buf[4..8].copy_from_slice(&0u32.to_le_bytes());
// referent_id != 0
buf[8..12].copy_from_slice(&0x0002_0000u32.to_le_bytes());
// max_count = 1 (skipped after read).
buf[12..16].copy_from_slice(&1u32.to_le_bytes());
// REMQIRESULT body at 16..64.
buf[16..16 + RemQiResult::ENCODED_LEN].copy_from_slice(&inner.encode());
// error_code at offset 64.
let err_off = 16 + RemQiResult::ENCODED_LEN;
buf[err_off..err_off + 4].copy_from_slice(&0u32.to_le_bytes());
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert_eq!(resp.error_code, 0);
let parsed = resp.result.expect("result present when referent_id != 0");
assert_eq!(parsed.hresult, 0);
assert_eq!(parsed.standard_object_reference, std_ref);
// The error_code lives at offset 64 in this branch:
// OrpcThat(8) + referent_id(4) + max_count(4) + REMQIRESULT(48) = 64.
assert_eq!(err_off, 64);
}
#[test]
fn parse_response_short_buffer_errors() {
// 63 bytes — one short of the 64-byte minimum (`:37`).
let buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN - 1];
let err = parse_rem_query_interface_response(&buf).unwrap_err();
assert!(matches!(
err,
RpcError::ShortRead {
expected: 64,
actual: 63
}
));
}
#[test]
fn parse_response_preserves_orpc_that() {
let mut buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN];
buf[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
buf[4..8].copy_from_slice(&0x1234_5678u32.to_le_bytes());
// referent_id = 0 so we don't need to populate the rest.
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert_eq!(resp.orpc_that.flags, 0xDEAD_BEEF);
assert_eq!(resp.orpc_that.extensions_referent_id, 0x1234_5678);
}
}