From 30138629d318f9f41ea9e838b5ae603cfe3d41c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 07:14:29 -0400 Subject: [PATCH] [M2] mxaccess-rpc: OXID + RemQI body codecs (wave 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- design/followups.md | 32 +- rust/crates/mxaccess-rpc/src/error.rs | 77 ++ rust/crates/mxaccess-rpc/src/guid.rs | 145 ++++ rust/crates/mxaccess-rpc/src/lib.rs | 18 +- .../mxaccess-rpc/src/object_exporter.rs | 749 ++++++++++++++++++ rust/crates/mxaccess-rpc/src/objref.rs | 83 +- rust/crates/mxaccess-rpc/src/orpc.rs | 397 ++++++++++ rust/crates/mxaccess-rpc/src/pdu.rs | 66 +- rust/crates/mxaccess-rpc/src/rem_unknown.rs | 469 +++++++++++ 9 files changed, 1894 insertions(+), 142 deletions(-) create mode 100644 rust/crates/mxaccess-rpc/src/error.rs create mode 100644 rust/crates/mxaccess-rpc/src/guid.rs create mode 100644 rust/crates/mxaccess-rpc/src/object_exporter.rs create mode 100644 rust/crates/mxaccess-rpc/src/orpc.rs create mode 100644 rust/crates/mxaccess-rpc/src/rem_unknown.rs diff --git a/design/followups.md b/design/followups.md index 4412eea..ed3ffeb 100644 --- a/design/followups.md +++ b/design/followups.md @@ -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. -### F7 — Consolidate `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. +### F9 — Port `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. diff --git a/rust/crates/mxaccess-rpc/src/error.rs b/rust/crates/mxaccess-rpc/src/error.rs new file mode 100644 index 0000000..44d7424 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/error.rs @@ -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, + }, +} diff --git a/rust/crates/mxaccess-rpc/src/guid.rs b/rust/crates/mxaccess-rpc/src/guid.rs new file mode 100644 index 0000000..3ec4e18 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/guid.rs @@ -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)` 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 { + 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" + ); + } +} diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index 9b85927..c4f9cbf 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -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; diff --git a/rust/crates/mxaccess-rpc/src/object_exporter.rs b/rust/crates/mxaccess-rpc/src/object_exporter.rs new file mode 100644 index 0000000..8e4318a --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/object_exporter.rs @@ -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 `` 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 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, 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::(); + 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, + 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 { + 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 { + 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 +/// `` (`: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 { + 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, 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 ``) 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); + } +} diff --git a/rust/crates/mxaccess-rpc/src/objref.rs b/rust/crates/mxaccess-rpc/src/objref.rs index 9f73ec7..ecaf557 100644 --- a/rust/crates/mxaccess-rpc/src/objref.rs +++ b/rust/crates/mxaccess-rpc/src/objref.rs @@ -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)` (`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 `` 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:?}"), } } diff --git a/rust/crates/mxaccess-rpc/src/orpc.rs b/rust/crates/mxaccess-rpc/src/orpc.rs new file mode 100644 index 0000000..e44b4d0 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/orpc.rs @@ -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) -> 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 { + 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 { + 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` (matching the .NET `byte[] ObjRefBytes`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MInterfacePointer { + pub objref_bytes: Vec, +} + +impl MInterfacePointer { + /// Header length (the `u32` size prefix). + pub const SIZE_PREFIX_LEN: usize = 4; + + pub fn new(objref_bytes: Vec) -> Self { + Self { objref_bytes } + } + + /// Encode as `size_le32 || objref_bytes`. Mirrors `Encode` + /// (`OrpcStructures.cs:81-87`). + pub fn encode(&self) -> Vec { + 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 { + 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::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 { + 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 + }) + )); + } +} diff --git a/rust/crates/mxaccess-rpc/src/pdu.rs b/rust/crates/mxaccess-rpc/src/pdu.rs index cefb003..1cb540a 100644 --- a/rust/crates/mxaccess-rpc/src/pdu.rs +++ b/rust/crates/mxaccess-rpc/src/pdu.rs @@ -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` diff --git a/rust/crates/mxaccess-rpc/src/rem_unknown.rs b/rust/crates/mxaccess-rpc/src/rem_unknown.rs new file mode 100644 index 0000000..6b92d30 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/rem_unknown.rs @@ -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 { + 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, + /// 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 { + 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 { + 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); + } +}