[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
+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 { .. })
));
}
}