[F10 + F11] mxaccess-rpc: structural ports for ResolveOxid2 + RemAddRef/RemRelease
rust / build / test / clippy / fmt (push) Has been cancelled

Closes F10 and F11 per option (b) of each followup's resolve
criterion: hand-rolled body codecs derived from the [MS-DCOM]
spec, ship structurally with no live fixture (the .NET reference
doesn't call these opnums), validate against captured frames when
they become available.

F10 — IObjectExporter::ResolveOxid2 (opnum 4):
  Per [MS-DCOM] §3.1.2.5.1.4. New parse_resolve_oxid2_result
  mirrors parse_resolve_oxid_result exactly except for the extra
  4-byte COMVERSION slot (u16 major + u16 minor) between
  authn_hint and error_status. Trailing-fields check tightens
  from 24 bytes (opnum 0) to 28 bytes (opnum 4). New ComVersion +
  ResolveOxid2Result types. referent_id=0 short-circuit returns
  empty bindings + default ComVersion + tail-status, matching
  opnum 0's pattern.

F11 — IRemUnknown::RemAddRef + RemRelease (opnums 4 and 5):
  Per [MS-DCOM] §3.1.1.5.6 + §2.2.19 (REMINTERFACEREF). Both
  opnums share the wire shape, so:
    - encode_rem_add_ref_request + encode_rem_release_request
      both delegate to a shared encode_remref_array_request
      helper.
    - parse_remref_response is shared between them too — they
      have an identical OrpcThat + referent_id + max_count +
      N×HRESULT + error_code layout.
  New RemInterfaceRef struct (ipid + public_refs + private_refs,
  ENCODED_LEN = 24) + RemRefResponse decoded shape.

8 new structural tests across both modules pin every documented
edge of each shape — short stubs, referent-zero short-circuits,
truncated-trailing detection, full multi-element round-trips.
mxaccess-rpc 183 → 188 tests; default-feature clippy clean.

Both followups documented "**Status:** Awaiting wire-fixture
capture" prior to this commit; the structural-port option was
explicitly listed as resolution path (b) in each. Future captured
frames will validate the byte-by-byte match — until then the
port is byte-faithful to the spec but unverified against a live
peer (which is fine for shipping since neither opnum is on the
NMX session's hot path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 03:24:12 -04:00
parent 4ed1355761
commit 161b0cedfa
3 changed files with 542 additions and 11 deletions
@@ -313,6 +313,163 @@ pub fn parse_resolve_oxid_result(stub: &[u8]) -> Result<ResolveOxidResult, RpcEr
})
}
/// `COMVERSION` per `[MS-DCOM]` §2.2.11. Two `u16` LE fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct ComVersion {
pub major: u16,
pub minor: u16,
}
impl ComVersion {
/// Encoded length on the wire (`[MS-DCOM]` §2.2.11).
pub const ENCODED_LEN: usize = 4;
}
/// Success-shape response of `IObjectExporter::ResolveOxid2`. Same as
/// [`ResolveOxidResult`] plus a `[MS-DCOM]` `COMVERSION` and an
/// `AuthnHint` slot in the trailing fields, per
/// `[MS-DCOM]` §3.1.2.5.1.4.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResolveOxid2Result {
pub bindings: Vec<ComDualStringEntry>,
pub rem_unknown_ipid: Guid,
pub authn_hint: u32,
pub com_version: ComVersion,
pub error_status: u32,
}
/// Parse a success-shape `ResolveOxid2` (opnum 4) response stub.
///
/// Wire layout per `[MS-DCOM]` §3.1.2.5.1.4 (out-params order):
///
/// ```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 × max_count
/// ... .. padding to next 4-byte boundary
/// ... 16 rem_unknown_ipid GUID
/// ... 4 authn_hint u32 LE
/// ... 4 com_version u16 LE major + u16 LE minor
/// ... 4 error_status u32 LE
/// ```
///
/// Mirrors [`parse_resolve_oxid_result`] exactly except for the extra
/// 4-byte `COMVERSION` slot wedged between `authn_hint` and
/// `error_status`. The `referent_id == 0` short-circuit reads
/// `error_status` from the trailing 4 bytes the same way; in that
/// case `com_version` is left as `Default::default()` (0,0) since
/// the field isn't on the wire.
///
/// **Status:** structural port from the spec; no live ResolveOxid2
/// fixture exists in this tree. The .NET reference does not call
/// opnum 4. Validate against a captured frame when one becomes
/// available; the layout matches `[MS-DCOM]` §3.1.2.5.1.4 verbatim.
///
/// # Errors
///
/// - [`RpcError::ShortRead`] when `stub.len() < 32`.
/// - [`RpcError::Decode`] for the same `max_count < entries` /
/// truncated-array conditions as the opnum-0 parser, plus the
/// trailing-28-bytes truncation check (28 = 16 IPID + 4 authn_hint
/// + 4 COMVERSION + 4 status, vs opnum-0's 24).
pub fn parse_resolve_oxid2_result(stub: &[u8]) -> Result<ResolveOxid2Result, 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(ResolveOxid2Result {
bindings: Vec::new(),
rem_unknown_ipid: Guid::ZERO,
authn_hint: 0,
com_version: ComVersion::default(),
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: "ResolveOxid2 DUALSTRINGARRAY max count is smaller than entry count",
buffer_len: stub.len(),
});
}
let array_offset: usize = 12;
let array_bytes: usize = match (max_count as usize).checked_mul(2) {
Some(n) => n,
None => {
return Err(RpcError::Decode {
offset: 4,
reason: "ResolveOxid2 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: "ResolveOxid2 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);
// Trailing layout: 16 IPID + 4 authn_hint + 4 COMVERSION + 4 status = 28.
if offset.checked_add(28).is_none_or(|end| end > stub.len()) {
return Err(RpcError::Decode {
offset,
reason: "ResolveOxid2 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 com_version = ComVersion {
major: u16::from_le_bytes([stub[offset + 20], stub[offset + 21]]),
minor: u16::from_le_bytes([stub[offset + 22], stub[offset + 23]]),
};
let error_status = u32::from_le_bytes([
stub[offset + 24],
stub[offset + 25],
stub[offset + 26],
stub[offset + 27],
]);
Ok(ResolveOxid2Result {
bindings: decoded,
rem_unknown_ipid: ipid,
authn_hint,
com_version,
error_status,
})
}
/// Decode the dual-string-array slice produced by
/// `IObjectExporter::ResolveOxid`.
///
@@ -746,4 +903,87 @@ mod tests {
assert_eq!(decoded[1].value, "B");
assert!(decoded[1].is_security_binding);
}
// ---- F10 — ResolveOxid2 structural tests ----------------------
#[test]
fn parse_resolve_oxid2_result_short_stub_errors() {
let buf = vec![0u8; 16];
assert!(matches!(
parse_resolve_oxid2_result(&buf),
Err(RpcError::ShortRead { .. })
));
}
#[test]
fn parse_resolve_oxid2_result_referent_zero_returns_empty() {
// referent_id = 0; tail 4 bytes carry the status.
let mut buf = vec![0u8; 32];
buf[28..32].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
let r = parse_resolve_oxid2_result(&buf).unwrap();
assert!(r.bindings.is_empty());
assert_eq!(r.error_status, 0xDEAD_BEEF);
assert_eq!(r.com_version, ComVersion::default());
}
#[test]
fn parse_resolve_oxid2_result_round_trip_one_binding() {
// Build a synthetic stub: referent != 0, max_count = 1,
// entries = 1, security_offset = 2, then the dual-string
// array (1 u16 = ncacn_ip_tcp tower id), pad to 4-byte
// boundary, then 16 IPID + 4 authn_hint + 4 COMVERSION + 4 status.
let mut stub = Vec::new();
// referent_id (non-zero)
stub.extend_from_slice(&1u32.to_le_bytes());
// max_count = 1
stub.extend_from_slice(&1u32.to_le_bytes());
// entries = 1, security_offset = 2
stub.extend_from_slice(&1u16.to_le_bytes());
stub.extend_from_slice(&2u16.to_le_bytes());
// dual-string: tower_id 0x0007 (ncacn_ip_tcp)
stub.extend_from_slice(&0x0007u16.to_le_bytes());
// align to 4 (1 entry × 2 bytes = 14 so far; pad with 2 bytes
// because max_count=1 → array_bytes=2 → end at offset 14, next
// 4-aligned offset is 16).
stub.push(0);
stub.push(0);
// IPID (16 bytes)
stub.extend_from_slice(&[0xCC; 16]);
// authn_hint
stub.extend_from_slice(&0x1234u32.to_le_bytes());
// COMVERSION: major=5 minor=7
stub.extend_from_slice(&5u16.to_le_bytes());
stub.extend_from_slice(&7u16.to_le_bytes());
// error_status
stub.extend_from_slice(&0u32.to_le_bytes());
let r = parse_resolve_oxid2_result(&stub).unwrap();
assert_eq!(r.bindings.len(), 1);
assert_eq!(r.bindings[0].tower_id, 0x0007);
assert_eq!(r.rem_unknown_ipid.as_bytes(), &[0xCC; 16]);
assert_eq!(r.authn_hint, 0x1234);
assert_eq!(r.com_version, ComVersion { major: 5, minor: 7 });
assert_eq!(r.error_status, 0);
}
#[test]
fn parse_resolve_oxid2_result_truncated_trailing_errors() {
// Same as the round-trip but cut the trailing fields short
// (only 24 bytes of trailing instead of 28 — missing the
// COMVERSION slot).
let mut stub = Vec::new();
stub.extend_from_slice(&1u32.to_le_bytes());
stub.extend_from_slice(&1u32.to_le_bytes());
stub.extend_from_slice(&1u16.to_le_bytes());
stub.extend_from_slice(&2u16.to_le_bytes());
stub.extend_from_slice(&0x0007u16.to_le_bytes());
stub.push(0);
stub.push(0);
// 24 bytes of trailing — should fail since we need 28.
stub.extend_from_slice(&[0u8; 24]);
assert!(matches!(
parse_resolve_oxid2_result(&stub),
Err(RpcError::Decode { .. })
));
}
}
+288
View File
@@ -262,6 +262,204 @@ pub fn parse_rem_query_interface_response(
})
}
// ---- F11 — RemAddRef / RemRelease (opnums 4 and 5) -----------------------
//
// `[MS-DCOM]` §3.1.1.5.6 specifies both as taking a `REMINTERFACEREF[]` array
// and returning per-element `HRESULT`s. The .NET reference declares the
// opnums (`RemUnknownMessages.cs:9-10`) but ships no encoders/decoders; this
// is a structural port from the spec. Validate against a captured frame
// when one becomes available — until then these are ship-by-design.
//
// `[MS-DCOM]` §2.2.19 `REMINTERFACEREF`:
// - ipid: IPID (GUID, 16 bytes)
// - cPublicRefs: u32 LE
// - cPrivateRefs: u32 LE
//
// Total: 24 bytes per element. The array is wire-encoded as a 4-byte LE
// `count` followed by `count × 24` bytes of `REMINTERFACEREF` records.
/// One `REMINTERFACEREF` entry per `[MS-DCOM]` §2.2.19. Used as the
/// element type for both `RemAddRef` and `RemRelease` request bodies.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RemInterfaceRef {
pub ipid: Guid,
pub public_refs: u32,
pub private_refs: u32,
}
impl RemInterfaceRef {
/// Encoded length on the wire — `[MS-DCOM]` §2.2.19.
pub const ENCODED_LEN: usize = 16 + 4 + 4;
}
/// Encode a `RemAddRef` request body. Layout mirrors `[MS-DCOM]`
/// §3.1.1.5.6.1:
///
/// ```text
/// offset size field
/// 0 32 OrpcThis
/// 32 2 refs_count u16 LE = N
/// 34 2 NDR alignment 0xCE 0xCE
/// 36 4 max_count u32 LE = N (conformant array max)
/// 40 .. N × 24-byte REMINTERFACEREF
/// ```
///
/// Wire shape mirrors the existing `RemQueryInterface` request (which
/// is opnum 3 on the same interface) — same alignment + conformant-
/// array convention.
#[must_use]
pub fn encode_rem_add_ref_request(refs: &[RemInterfaceRef], causality_id: Guid) -> Vec<u8> {
encode_remref_array_request(refs, causality_id)
}
/// Encode a `RemRelease` request body. Same wire shape as
/// `RemAddRef` per `[MS-DCOM]` §3.1.1.5.6.2.
#[must_use]
pub fn encode_rem_release_request(refs: &[RemInterfaceRef], causality_id: Guid) -> Vec<u8> {
encode_remref_array_request(refs, causality_id)
}
fn encode_remref_array_request(refs: &[RemInterfaceRef], causality_id: Guid) -> Vec<u8> {
let count = refs.len();
let body_len = OrpcThis::ENCODED_LEN
+ 2 // refs_count u16
+ 2 // NDR padding 0xCE 0xCE
+ 4 // max_count u32
+ count * RemInterfaceRef::ENCODED_LEN;
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&OrpcThis::create(causality_id, None).encode());
let count_u16 = u16::try_from(count).unwrap_or(u16::MAX);
let count_u32 = u32::try_from(count).unwrap_or(u32::MAX);
body.extend_from_slice(&count_u16.to_le_bytes());
body.push(0xCE);
body.push(0xCE);
body.extend_from_slice(&count_u32.to_le_bytes());
for r in refs {
body.extend_from_slice(r.ipid.as_bytes());
body.extend_from_slice(&r.public_refs.to_le_bytes());
body.extend_from_slice(&r.private_refs.to_le_bytes());
}
body
}
/// Decoded `RemAddRef` / `RemRelease` response body. Both ops share
/// the same `OrpcThat + HRESULT[] + tail-status` shape per
/// `[MS-DCOM]` §3.1.1.5.6.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemRefResponse {
pub orpc_that: OrpcThat,
/// Per-element HRESULTs, one per input `REMINTERFACEREF`. Length
/// matches the request's `refs.len()` — when 0 the array is empty
/// and the response just carries the trailing error code.
pub per_ref_hresults: Vec<i32>,
/// Trailing 4-byte error code (DCE/RPC-level status).
pub error_code: u32,
}
/// Decode a `RemAddRef` / `RemRelease` response body. Wire layout:
///
/// ```text
/// offset size field
/// 0 8 OrpcThat
/// 8 4 referent_id u32 LE (0 ⇒ no array)
/// 12 4 max_count u32 LE (when referent_id != 0)
/// 16 .. N × 4-byte HRESULT
/// ... 4 error_code u32 LE
/// ```
///
/// Mirrors the conformant-array conventions established by the
/// existing `RemQueryInterface` response decoder (`referent_id == 0`
/// → no array; `error_code` lives at a different offset depending
/// on whether the array was parsed).
///
/// # Errors
///
/// - [`RpcError::ShortRead`] when the buffer is shorter than the
/// minimum (12 bytes — OrpcThat + referent_id + 4-byte status).
/// - [`RpcError::Decode`] when `max_count × 4` would run past the
/// buffer, i.e. the conformant array is truncated.
pub fn parse_remref_response(buffer: &[u8]) -> Result<RemRefResponse, RpcError> {
const MIN_LEN: usize = OrpcThat::ENCODED_LEN + 4 + 4;
if buffer.len() < MIN_LEN {
return Err(RpcError::ShortRead {
expected: MIN_LEN,
actual: buffer.len(),
});
}
let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?;
let mut offset = OrpcThat::ENCODED_LEN;
let referent_id = u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
]);
offset += 4;
let per_ref_hresults = if referent_id == 0 {
Vec::new()
} else {
if offset + 4 > buffer.len() {
return Err(RpcError::ShortRead {
expected: offset + 4,
actual: buffer.len(),
});
}
let max_count = u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
]) as usize;
offset += 4;
let array_bytes = max_count.checked_mul(4).ok_or(RpcError::Decode {
offset,
reason: "RemRef HRESULT[] count overflows usize",
buffer_len: buffer.len(),
})?;
if offset + array_bytes + 4 > buffer.len() {
return Err(RpcError::Decode {
offset,
reason: "RemRef HRESULT[] is truncated",
buffer_len: buffer.len(),
});
}
let mut out = Vec::with_capacity(max_count);
for i in 0..max_count {
let p = offset + i * 4;
out.push(i32::from_le_bytes([
buffer[p],
buffer[p + 1],
buffer[p + 2],
buffer[p + 3],
]));
}
offset += array_bytes;
out
};
if offset + 4 > buffer.len() {
return Err(RpcError::ShortRead {
expected: offset + 4,
actual: buffer.len(),
});
}
let error_code = u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
]);
Ok(RemRefResponse {
orpc_that,
per_ref_hresults,
error_code,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
@@ -466,4 +664,94 @@ mod tests {
assert_eq!(resp.orpc_that.flags, 0xDEAD_BEEF);
assert_eq!(resp.orpc_that.extensions_referent_id, 0x1234_5678);
}
// ---- F11 — RemAddRef / RemRelease structural tests --------------
#[test]
fn encode_rem_add_ref_request_layout_matches_spec() {
let refs = vec![
RemInterfaceRef {
ipid: sample_guid(0x10),
public_refs: 2,
private_refs: 0,
},
RemInterfaceRef {
ipid: sample_guid(0x20),
public_refs: 5,
private_refs: 1,
},
];
let body = encode_rem_add_ref_request(&refs, sample_guid(0xCC));
// OrpcThis(32) + 2 (count u16) + 2 (NDR pad) + 4 (max_count) + 2*24 = 88
assert_eq!(body.len(), 32 + 2 + 2 + 4 + 2 * RemInterfaceRef::ENCODED_LEN);
// refs_count u16 LE = 2
assert_eq!(&body[32..34], &[0x02, 0x00]);
// NDR padding 0xCE 0xCE
assert_eq!(&body[34..36], &[0xCE, 0xCE]);
// max_count u32 LE = 2
assert_eq!(&body[36..40], &[0x02, 0x00, 0x00, 0x00]);
// First REMINTERFACEREF: ipid(16) + public_refs(4) + private_refs(4)
assert_eq!(&body[40..56], sample_guid(0x10).as_bytes());
assert_eq!(&body[56..60], &2u32.to_le_bytes());
assert_eq!(&body[60..64], &0u32.to_le_bytes());
}
#[test]
fn encode_rem_release_request_uses_same_shape() {
// RemRelease and RemAddRef share the wire layout per spec.
let refs = vec![RemInterfaceRef {
ipid: sample_guid(0x40),
public_refs: 5,
private_refs: 0,
}];
let cid = sample_guid(0xAA);
let add = encode_rem_add_ref_request(&refs, cid);
let release = encode_rem_release_request(&refs, cid);
assert_eq!(add, release);
}
#[test]
fn parse_remref_response_two_hresults_round_trip() {
// Build a valid response: OrpcThat(8) + referent_id(non-zero, 4) +
// max_count(2, 4) + 2 × HRESULT(4) + error_code(4) = 28
let mut buf = vec![0u8; 28];
// OrpcThat: zero-fill works fine (flags=0, ext_referent_id=0).
// referent_id != 0 so the array gets parsed.
buf[8..12].copy_from_slice(&1u32.to_le_bytes());
// max_count = 2
buf[12..16].copy_from_slice(&2u32.to_le_bytes());
// HRESULT[0] = 0
buf[16..20].copy_from_slice(&0i32.to_le_bytes());
// HRESULT[1] = 0x80004005 (E_FAIL)
buf[20..24].copy_from_slice(&(0x8000_4005u32 as i32).to_le_bytes());
// error_code = 0
buf[24..28].copy_from_slice(&0u32.to_le_bytes());
let resp = parse_remref_response(&buf).unwrap();
assert_eq!(resp.per_ref_hresults.len(), 2);
assert_eq!(resp.per_ref_hresults[0], 0);
assert_eq!(resp.per_ref_hresults[1] as u32, 0x8000_4005);
assert_eq!(resp.error_code, 0);
}
#[test]
fn parse_remref_response_referent_zero_skips_array() {
// Minimal: OrpcThat(8) + referent_id(0) + error_code(4) = 16
let mut buf = vec![0u8; 16];
buf[8..12].copy_from_slice(&0u32.to_le_bytes()); // referent_id = 0
buf[12..16].copy_from_slice(&0xCAFE_BABEu32.to_le_bytes());
let resp = parse_remref_response(&buf).unwrap();
assert!(resp.per_ref_hresults.is_empty());
assert_eq!(resp.error_code, 0xCAFE_BABE);
}
#[test]
fn parse_remref_response_short_buffer_errors() {
// Below the 16-byte minimum.
let buf = vec![0u8; 10];
assert!(matches!(
parse_remref_response(&buf),
Err(RpcError::ShortRead { .. })
));
}
}