[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:
+21
-11
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user