[M2] mxaccess-rpc: OXID + RemQI body codecs (wave 2)

Lands M2 wave 2 — two pure-Rust body-codec modules under
crates/mxaccess-rpc, plus a small inline ORPC framing port and a
crate-level type consolidation. Resolves F7+F8 from wave 1.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 07:14:29 -04:00
parent 95bd218183
commit 30138629d3
9 changed files with 1894 additions and 142 deletions
+469
View File
@@ -0,0 +1,469 @@
//! `IRemUnknown` request/response codecs.
//!
//! Direct port of `src/MxNativeClient/RemUnknownMessages.cs`. Provides:
//!
//! - [`IREM_UNKNOWN_IID`] — `IRemUnknown` interface IID
//! (`RemUnknownMessages.cs:7`).
//! - [`REM_QUERY_INTERFACE_OPNUM`], [`REM_ADD_REF_OPNUM`],
//! [`REM_RELEASE_OPNUM`] — DCE/RPC opnums (`RemUnknownMessages.cs:8-10`).
//! - [`encode_rem_query_interface_request`] — builds the body for the
//! `RemQueryInterface` request (`RemUnknownMessages.cs:12-33`).
//! - [`parse_rem_query_interface_response`] — decodes the response body
//! (`RemUnknownMessages.cs:35-59`).
//! - [`RemQueryInterfaceResponse`] (`RemUnknownMessages.cs:62`).
//! - [`RemQiResult`] — `REMQIRESULT` body (`RemUnknownMessages.cs:64-79`).
//!
//! All multi-byte fields are little-endian.
//!
//! The 4-byte pad in `REMQIRESULT` between `hresult` and the embedded
//! `STDOBJREF` is preserved on decode (`pad_after_hresult: [u8; 4]`) per
//! the CLAUDE.md "preserve unknown bytes" rule. The native .NET reference
//! reads-and-discards it (`RemUnknownMessages.cs:75-77`); Rust holds onto
//! the bytes so callers can round-trip captures byte-for-byte.
#![allow(clippy::indexing_slicing)]
use crate::error::RpcError;
use crate::guid::Guid;
use crate::orpc::{OrpcThat, OrpcThis, StdObjRef};
/// `IRemUnknown` IID `00000131-0000-0000-C000-000000000046`
/// (`RemUnknownMessages.cs:7`).
pub const IREM_UNKNOWN_IID: Guid = Guid::new([
0x31, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46,
]);
/// `RemQueryInterface` opnum (`RemUnknownMessages.cs:8`).
pub const REM_QUERY_INTERFACE_OPNUM: u16 = 3;
/// `RemAddRef` opnum (`RemUnknownMessages.cs:9`).
pub const REM_ADD_REF_OPNUM: u16 = 4;
/// `RemRelease` opnum (`RemUnknownMessages.cs:10`).
pub const REM_RELEASE_OPNUM: u16 = 5;
/// Total length of an encoded `RemQueryInterface` request body for a single
/// requested IID. `OrpcThis(32) + ipid(16) + public_refs(4) + iid_count(2) +
/// align(2) + max_count(4) + iid(16) = 76`. Mirrors the byte-by-byte sum in
/// `RemUnknownMessages.cs:15-32`.
const REM_QUERY_INTERFACE_REQUEST_LEN: usize = OrpcThis::ENCODED_LEN + 16 + 4 + 2 + 2 + 4 + 16;
const _: () = assert!(REM_QUERY_INTERFACE_REQUEST_LEN == 76);
/// Encode a `RemQueryInterface` request body for a single requested IID.
///
/// Mirrors `EncodeRemQueryInterfaceRequest` (`RemUnknownMessages.cs:12-33`).
/// Layout:
///
/// ```text
/// offset size field
/// 0 32 OrpcThis (header)
/// 32 16 source IPID (GUID)
/// 48 4 public_refs u32 LE
/// 52 2 iid_count u16 LE = 1
/// 54 2 NDR alignment 0xCE 0xCE (RemUnknownMessages.cs:26-27)
/// 56 4 max_count u32 LE = 1 (conformant array max count)
/// 60 16 requested IID (GUID)
/// ```
///
/// Native passes `public_refs = 5` by default (`RemUnknownMessages.cs:12`);
/// the Rust signature requires the caller to pass it explicitly so the
/// default isn't accidentally hidden.
#[must_use]
pub fn encode_rem_query_interface_request(
source_ipid: Guid,
requested_iid: Guid,
causality_id: Guid,
public_refs: u32,
) -> Vec<u8> {
let orpc_this = OrpcThis::create(causality_id, None).encode();
let mut body = Vec::with_capacity(REM_QUERY_INTERFACE_REQUEST_LEN);
// 0..32 — OrpcThis header.
body.extend_from_slice(&orpc_this);
// 32..48 — source IPID.
body.extend_from_slice(source_ipid.as_bytes());
// 48..52 — public refs (default 5 in native).
body.extend_from_slice(&public_refs.to_le_bytes());
// 52..54 — iid count = 1.
body.extend_from_slice(&1u16.to_le_bytes());
// 54..56 — NDR alignment before the conformant array max count
// (`RemUnknownMessages.cs:26-27`).
body.push(0xCE);
body.push(0xCE);
// 56..60 — max count = 1.
body.extend_from_slice(&1u32.to_le_bytes());
// 60..76 — requested IID.
body.extend_from_slice(requested_iid.as_bytes());
debug_assert_eq!(body.len(), REM_QUERY_INTERFACE_REQUEST_LEN);
body
}
/// Decoded `RemQueryInterface` response body.
/// Mirrors `RemQueryInterfaceResponse` (`RemUnknownMessages.cs:62`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemQueryInterfaceResponse {
pub orpc_that: OrpcThat,
/// `Some` when the wire `referent_id` is non-zero
/// (`RemUnknownMessages.cs:46-50`); otherwise the server sent no
/// `REMQIRESULT` array.
pub result: Option<RemQiResult>,
/// Trailing status word at a position that depends on whether `result`
/// was parsed (`RemUnknownMessages.cs:52-58`).
pub error_code: u32,
}
/// `REMQIRESULT` body. Mirrors `RemQiResult` (`RemUnknownMessages.cs:64-79`).
///
/// ```text
/// offset size field
/// 0 4 hresult i32 LE
/// 4 4 pad_after_hresult [u8; 4] (NDR padding ahead of STDOBJREF;
/// `RemUnknownMessages.cs:75-77`
/// skips offsets 4..8)
/// 8 40 standard_object_reference (STDOBJREF)
/// ```
///
/// The 4 bytes between `hresult` and `standard_object_reference` are the
/// `IPID`-aligned NDR pad noted in `RemUnknownMessages.cs:77`. Native
/// reads-and-discards them; the Rust port preserves them as
/// `pad_after_hresult` per the CLAUDE.md "preserve unknown bytes" rule so
/// captures round-trip exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemQiResult {
pub hresult: i32,
pub pad_after_hresult: [u8; 4],
pub standard_object_reference: StdObjRef,
}
impl RemQiResult {
/// Encoded length — `4 + 4 + StdObjRef::ENCODED_LEN = 48`
/// (`RemUnknownMessages.cs:66`).
pub const ENCODED_LEN: usize = 4 + 4 + StdObjRef::ENCODED_LEN;
/// Decode 48 bytes. Mirrors `RemQiResult.Parse`
/// (`RemUnknownMessages.cs:68-78`). The 4 bytes at offsets 4..8 are
/// captured into `pad_after_hresult` rather than discarded
/// (CLAUDE.md "preserve unknown bytes").
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 48`.
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::ENCODED_LEN {
return Err(RpcError::ShortRead {
expected: Self::ENCODED_LEN,
actual: buffer.len(),
});
}
let hresult = i32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
let mut pad_after_hresult = [0u8; 4];
pad_after_hresult.copy_from_slice(&buffer[4..8]);
let standard_object_reference = StdObjRef::parse(&buffer[8..Self::ENCODED_LEN])?;
Ok(Self {
hresult,
pad_after_hresult,
standard_object_reference,
})
}
/// Encode to 48 bytes. Native zeroes the 4-byte pad
/// (`RemUnknownMessages.cs` does not have a symmetric encoder, but the
/// pad slot is always 0 in captured server responses); the Rust port
/// writes whatever bytes the caller provided in `pad_after_hresult`.
#[must_use]
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
let mut buf = [0u8; Self::ENCODED_LEN];
buf[0..4].copy_from_slice(&self.hresult.to_le_bytes());
buf[4..8].copy_from_slice(&self.pad_after_hresult);
buf[8..Self::ENCODED_LEN].copy_from_slice(&self.standard_object_reference.encode());
buf
}
}
/// Minimum length of a `RemQueryInterface` response: `OrpcThat(8) +
/// referent_id(4) + REMQIRESULT(48) + error_code(4) = 64`. Mirrors the
/// pre-check at `RemUnknownMessages.cs:37`.
const REM_QUERY_INTERFACE_RESPONSE_MIN_LEN: usize =
OrpcThat::ENCODED_LEN + 4 + RemQiResult::ENCODED_LEN + 4;
const _: () = assert!(REM_QUERY_INTERFACE_RESPONSE_MIN_LEN == 64);
/// Decode a `RemQueryInterface` response body.
///
/// Mirrors `ParseRemQueryInterfaceResponse` (`RemUnknownMessages.cs:35-59`).
/// The `referent_id != 0` branch (`RemUnknownMessages.cs:46-50`) is the Q7
/// conditional read called out in `design/70-risks-and-open-questions.md:283-289`:
/// the `REMQIRESULT` array is parsed only when `referent_id != 0`, and the
/// trailing `error_code` lives at a different offset depending on whether
/// it was parsed (`RemUnknownMessages.cs:52-58`).
///
/// # Errors
/// Returns [`RpcError::ShortRead`] if the buffer is shorter than the
/// 64-byte minimum, or [`RpcError::Decode`] if the trailing `error_code`
/// runs past the buffer (the conditional path makes this possible even
/// when the minimum length is met).
pub fn parse_rem_query_interface_response(
buffer: &[u8],
) -> Result<RemQueryInterfaceResponse, RpcError> {
if buffer.len() < REM_QUERY_INTERFACE_RESPONSE_MIN_LEN {
return Err(RpcError::ShortRead {
expected: REM_QUERY_INTERFACE_RESPONSE_MIN_LEN,
actual: buffer.len(),
});
}
let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?;
let referent_id_offset = OrpcThat::ENCODED_LEN;
let referent_id = u32::from_le_bytes([
buffer[referent_id_offset],
buffer[referent_id_offset + 1],
buffer[referent_id_offset + 2],
buffer[referent_id_offset + 3],
]);
let mut offset = referent_id_offset + 4;
let result = if referent_id != 0 {
// Conformant array max count for the REMQIRESULT result array
// (`RemUnknownMessages.cs:48`).
offset += 4;
if buffer.len() < offset + RemQiResult::ENCODED_LEN {
return Err(RpcError::Decode {
offset,
reason: "RemQueryInterface response truncated before REMQIRESULT",
buffer_len: buffer.len(),
});
}
let parsed = RemQiResult::parse(&buffer[offset..offset + RemQiResult::ENCODED_LEN])?;
offset += RemQiResult::ENCODED_LEN;
Some(parsed)
} else {
None
};
if buffer.len() < offset + 4 {
return Err(RpcError::Decode {
offset,
reason: "RemQueryInterface response truncated before error_code",
buffer_len: buffer.len(),
});
}
let error_code = u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
]);
Ok(RemQueryInterfaceResponse {
orpc_that,
result,
error_code,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
fn sample_guid(seed: u8) -> Guid {
let mut b = [0u8; 16];
for (i, slot) in b.iter_mut().enumerate() {
*slot = seed.wrapping_add(i as u8);
}
Guid::new(b)
}
fn sample_std_objref() -> StdObjRef {
StdObjRef {
flags: 0,
public_refs: 5,
oxid: 0x1122_3344_5566_7788,
oid: 0x99AA_BBCC_DDEE_FF00,
ipid: sample_guid(0x55),
}
}
#[test]
fn irem_unknown_iid_matches_dotnet() {
// RemUnknownMessages.cs:7 — 00000131-0000-0000-C000-000000000046.
assert_eq!(
IREM_UNKNOWN_IID.as_bytes(),
&[
0x31, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x46,
]
);
// Display order also matches Guid.ToString("D").
assert_eq!(
IREM_UNKNOWN_IID.to_string(),
"00000131-0000-0000-c000-000000000046"
);
}
#[test]
fn opnums_match_dotnet() {
assert_eq!(REM_QUERY_INTERFACE_OPNUM, 3);
assert_eq!(REM_ADD_REF_OPNUM, 4);
assert_eq!(REM_RELEASE_OPNUM, 5);
}
#[test]
fn encode_rem_query_interface_request_layout() {
let source_ipid = sample_guid(0x10);
let requested_iid = sample_guid(0x20);
let causality_id = sample_guid(0x30);
let body = encode_rem_query_interface_request(source_ipid, requested_iid, causality_id, 5);
// 32 (OrpcThis) + 16 (ipid) + 4 (refs) + 2 (count) + 2 (align) + 4 (max) + 16 (iid).
assert_eq!(body.len(), 76);
// OrpcThis header round-trip (validates the first 32 bytes).
let parsed_this = OrpcThis::parse(&body[..OrpcThis::ENCODED_LEN]).unwrap();
assert_eq!(parsed_this.cid, causality_id);
assert_eq!(parsed_this.flags, 0);
assert_eq!(parsed_this.extensions_referent_id, 0);
// Source IPID at offset 32.
assert_eq!(&body[32..48], source_ipid.as_bytes());
// public_refs at offset 48.
assert_eq!(&body[48..52], &5u32.to_le_bytes());
// iid_count at offset 52.
assert_eq!(&body[52..54], &1u16.to_le_bytes());
// NDR alignment 0xCE 0xCE at offset 54 (RemUnknownMessages.cs:26-27).
assert_eq!(body[54], 0xCE);
assert_eq!(body[55], 0xCE);
// max_count at offset 56.
assert_eq!(&body[56..60], &1u32.to_le_bytes());
// requested IID at offset 60.
assert_eq!(&body[60..76], requested_iid.as_bytes());
}
#[test]
fn encode_rem_query_interface_request_respects_public_refs() {
let body =
encode_rem_query_interface_request(Guid::ZERO, Guid::ZERO, Guid::ZERO, 0xDEAD_BEEF);
assert_eq!(&body[48..52], &0xDEAD_BEEFu32.to_le_bytes());
}
#[test]
fn rem_qi_result_round_trip() {
let original = RemQiResult {
hresult: 0,
pad_after_hresult: [0xAA, 0xBB, 0xCC, 0xDD],
standard_object_reference: sample_std_objref(),
};
let encoded = original.encode();
assert_eq!(encoded.len(), RemQiResult::ENCODED_LEN);
assert_eq!(encoded.len(), 48);
// Pad bytes preserved exactly (CLAUDE.md "preserve unknown bytes").
assert_eq!(&encoded[4..8], &[0xAA, 0xBB, 0xCC, 0xDD]);
let decoded = RemQiResult::parse(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn rem_qi_result_short_buffer_errors() {
assert!(matches!(
RemQiResult::parse(&[0u8; 47]),
Err(RpcError::ShortRead {
expected: 48,
actual: 47
})
));
}
#[test]
fn parse_response_referent_id_zero_skips_result() {
// Layout when referent_id == 0:
// 0..8 OrpcThat
// 8..12 referent_id = 0
// 12..16 error_code
// Native (`RemUnknownMessages.cs:46-58`): when referent_id == 0,
// result is None and error_code is read from offset 12 directly.
// The pre-check at :37 still requires a 64-byte buffer, so we pad
// the trailing portion with junk that the parser must ignore once
// it has the error_code at offset 12.
let mut buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN];
// OrpcThat
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
buf[4..8].copy_from_slice(&0u32.to_le_bytes());
// referent_id = 0
buf[8..12].copy_from_slice(&0u32.to_le_bytes());
// error_code at offset 12 in this branch.
buf[12..16].copy_from_slice(&0x8000_4005u32.to_le_bytes());
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert!(resp.result.is_none());
assert_eq!(resp.error_code, 0x8000_4005);
}
#[test]
fn parse_response_referent_id_nonzero_parses_result() {
// Layout when referent_id != 0:
// 0..8 OrpcThat
// 8..12 referent_id != 0
// 12..16 conformant-array max_count (skipped per :48)
// 16..64 REMQIRESULT
// 64..68 error_code
let std_ref = sample_std_objref();
let inner = RemQiResult {
hresult: 0,
pad_after_hresult: [0u8; 4],
standard_object_reference: std_ref,
};
let mut buf = vec![0u8; OrpcThat::ENCODED_LEN + 4 + 4 + RemQiResult::ENCODED_LEN + 4];
// OrpcThat
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
buf[4..8].copy_from_slice(&0u32.to_le_bytes());
// referent_id != 0
buf[8..12].copy_from_slice(&0x0002_0000u32.to_le_bytes());
// max_count = 1 (skipped after read).
buf[12..16].copy_from_slice(&1u32.to_le_bytes());
// REMQIRESULT body at 16..64.
buf[16..16 + RemQiResult::ENCODED_LEN].copy_from_slice(&inner.encode());
// error_code at offset 64.
let err_off = 16 + RemQiResult::ENCODED_LEN;
buf[err_off..err_off + 4].copy_from_slice(&0u32.to_le_bytes());
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert_eq!(resp.error_code, 0);
let parsed = resp.result.expect("result present when referent_id != 0");
assert_eq!(parsed.hresult, 0);
assert_eq!(parsed.standard_object_reference, std_ref);
// The error_code lives at offset 64 in this branch:
// OrpcThat(8) + referent_id(4) + max_count(4) + REMQIRESULT(48) = 64.
assert_eq!(err_off, 64);
}
#[test]
fn parse_response_short_buffer_errors() {
// 63 bytes — one short of the 64-byte minimum (`:37`).
let buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN - 1];
let err = parse_rem_query_interface_response(&buf).unwrap_err();
assert!(matches!(
err,
RpcError::ShortRead {
expected: 64,
actual: 63
}
));
}
#[test]
fn parse_response_preserves_orpc_that() {
let mut buf = vec![0u8; REM_QUERY_INTERFACE_RESPONSE_MIN_LEN];
buf[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
buf[4..8].copy_from_slice(&0x1234_5678u32.to_le_bytes());
// referent_id = 0 so we don't need to populate the rest.
let resp = parse_rem_query_interface_response(&buf).unwrap();
assert_eq!(resp.orpc_that.flags, 0xDEAD_BEEF);
assert_eq!(resp.orpc_that.extensions_referent_id, 0x1234_5678);
}
}