From 161b0cedfafee1d6772de684295cc1cf49e34b3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 6 May 2026 03:24:12 -0400 Subject: [PATCH] [F10 + F11] mxaccess-rpc: structural ports for ResolveOxid2 + RemAddRef/RemRelease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- design/followups.md | 25 +- .../mxaccess-rpc/src/object_exporter.rs | 240 +++++++++++++++ rust/crates/mxaccess-rpc/src/rem_unknown.rs | 288 ++++++++++++++++++ 3 files changed, 542 insertions(+), 11 deletions(-) diff --git a/design/followups.md b/design/followups.md index e4fd51d..76cde10 100644 --- a/design/followups.md +++ b/design/followups.md @@ -182,17 +182,6 @@ Both sides see the same `result_code=32` (= `AsbErrorCode.PublishComplete`, info **Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`. All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table. **Concrete next step:** Provision a two-domain Windows lab (e.g. `LAB-A` + `LAB-B` with cross-domain trust + an AVEVA install on `LAB-A` that authenticates a user from `LAB-B`). Run `cargo run -p mxaccess --example connect-write-read` from a `LAB-B`-domain user; capture the NTLM Type1 / Type2 / Challenge / Type3 bytes via `examples/asb-relay.rs` or a Wireshark NTLM filter. Save under `crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/`. The existing single-domain Type1/2/3 round-trip tests in `mxaccess-rpc::ntlm` then extend to validate the cross-domain shape (TargetInfo AV pairs differ when crossing domains; specifically `MsvAvDnsTreeName` and `MsvAvDnsComputerName` carry the trusted-domain DNS suffix instead of the local one). Clears R8 in the risks doc. -### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec -**Severity:** P2 — the ResolveOxid (opnum 0) path is what the .NET reference + our Rust port use; opnum 4 is only needed by callers that want the additional `COMVERSION` + `AuthnHnt[]` data. -**Status:** Awaiting wire-fixture capture or .NET helper. -**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`. `ObjectExporterMessages.cs` only models opnum 0; opnum 4 has a different response shape per `[MS-DCOM]` §3.1.2.5.1.4. No .NET executable spec to mirror. -**Concrete next step:** Either (a) extend `MxNativeClient.Probe` with a `--probe-resolve-oxid2` flag that calls `IObjectExporter::ResolveOxid2(oxid, &requested_protseqs)` against `localhost:135` and dumps the response stub to a file, then port the layout into `object_exporter.rs::parse_resolve_oxid2_result` mirroring the existing `parse_resolve_oxid_result` (`object_exporter.rs:226`); or (b) hand-roll the layout from `[MS-DCOM]` §3.1.2.5.1.4 (response = same as ResolveOxid + 8-byte `COMVERSION` + 4-byte `AuthnHnt[]` count + N×4-byte ushort entries + 4-byte status), commit the structural codec, and rely on a future captured frame to validate. - -### F11 — `IRemUnknown::RemAddRef` and `RemRelease` body codecs -**Severity:** P2 — neither opnum is exercised by the .NET reference's NMX session lifecycle, so the lack of a body codec doesn't block any current consumer. -**Status:** Awaiting wire-fixture capture or .NET helper. -**Source:** M2 wave 2, `crates/mxaccess-rpc/src/rem_unknown.rs`. `RemUnknownMessages.cs` declares the opnums (`:9-10`) but doesn't implement encoders/decoders. Rust port matches that per "port what is already proven." -**Concrete next step:** Either extend `MxNativeClient.Probe` with `--probe-rem-add-ref` / `--probe-rem-release` flags that exercise opnums 4 and 5 against an existing `IRemUnknown` IPID, capture the responses, and port the body layouts into `rem_unknown.rs` alongside the existing `RemQueryInterface` codec; OR derive the layouts from `[MS-DCOM]` §3.1.1.5.6 (`REMINTERFACEREF[]` array of IPID + public/private refs counts) and ship the codecs structurally. @@ -234,6 +223,20 @@ R15's "long-lived connection task" was previously listed as a hard prerequisite, Workspace `mxaccess` 65 → 67 tests; default-feature clippy clean. The `connect_nmx_auto`-side auto-population of the factory (capturing the `ntlm_factory` + discovered `(addr, service_ipid)` so consumers don't need to re-author the closure) is a future polish not required to close F16. +### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec +**Resolved:** 2026-05-06 (commit ``) per option (b) of the followup's resolve criterion: structural port from `[MS-DCOM]` §3.1.2.5.1.4. New `parse_resolve_oxid2_result` in `crates/mxaccess-rpc/src/object_exporter.rs` mirrors the opnum-0 parser exactly except for the extra `COMVERSION` slot (4 bytes: u16 major + u16 minor) wedged between `authn_hint` and `error_status`. New types: `ComVersion` and `ResolveOxid2Result`. The trailing-fields truncation check tightens from 24 bytes (opnum 0) to 28 bytes (opnum 4) to account for the COMVERSION slot. + +`referent_id == 0` short-circuits to an empty `bindings` + `ComVersion::default()` + status from the trailing 4 bytes — same shape pattern as the opnum-0 parser. `mxaccess-rpc` 183 → 188 tests (+4 structural tests covering: short-stub error, referent-zero short-circuit, full one-binding round-trip with COMVERSION assertion, truncated-trailing error). + +No live `ResolveOxid2` capture exists in this tree (the .NET reference doesn't call opnum 4); structural correctness is pinned against `[MS-DCOM]` §3.1.2.5.1.4 verbatim. Future captured frames will validate. + +### F11 — `IRemUnknown::RemAddRef` and `RemRelease` body codecs +**Resolved:** 2026-05-06 (commit ``) — structural port from `[MS-DCOM]` §3.1.1.5.6. Both opnums share the same `REMINTERFACEREF[]` request shape (per `[MS-DCOM]` §2.2.19: 16-byte IPID + 4-byte cPublicRefs + 4-byte cPrivateRefs per element, prefixed by an `OrpcThis` header + u16 count + 2-byte NDR padding + u32 max_count). New encoders `encode_rem_add_ref_request` and `encode_rem_release_request` (the latter delegates to a shared `encode_remref_array_request` helper since the wire shape is identical between the two ops). + +Response shape: `OrpcThat(8) + referent_id(4) + max_count(4) + N×4-byte HRESULT + error_code(4)` per the conformant-array convention established by `RemQueryInterface`'s response decoder. `referent_id == 0` short-circuits to an empty `per_ref_hresults` array. New `RemRefResponse` struct + `parse_remref_response` decoder shared between both opnums. New `RemInterfaceRef` struct. + +4 new structural tests: AddRef request layout pin (88-byte total for a 2-element refs array), Release-vs-AddRef wire-shape equivalence, full HRESULT[] round-trip with two HRESULTs (success + E_FAIL), referent-zero short-circuit. Like F10, the .NET reference doesn't call these opnums; structural correctness is pinned against `[MS-DCOM]` §3.1.1.5.6 verbatim. + ### F27 — Constant-time DH `mod_exp` (swap `num-bigint` → `crypto-bigint::DynResidue`) **Resolved:** 2026-05-06 (commit ``). Per the followup's own option (b): added a fixed-width `U2048` DH backend via `crypto-bigint::modular::runtime_mod::DynResidue`. New `auth.rs::constant_time_mod_exp(base, exp, modulus)` wrapper preserves the `BigUint`-in-`BigUint`-out API used by the byte-conversion helpers; the actual square-and-multiply chain runs in Montgomery form against the registry-supplied prime as a `U2048`. Both DH call sites (public-key generation in `AsbAuthenticator::new` at line 179, and shared-secret derivation in `crypto_key` at line 354) swap `BigUint::modpow` for the new wrapper. diff --git a/rust/crates/mxaccess-rpc/src/object_exporter.rs b/rust/crates/mxaccess-rpc/src/object_exporter.rs index 8e4318a..0e2a2b5 100644 --- a/rust/crates/mxaccess-rpc/src/object_exporter.rs +++ b/rust/crates/mxaccess-rpc/src/object_exporter.rs @@ -313,6 +313,163 @@ pub fn parse_resolve_oxid_result(stub: &[u8]) -> Result, + 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 { + 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 { .. }) + )); + } } diff --git a/rust/crates/mxaccess-rpc/src/rem_unknown.rs b/rust/crates/mxaccess-rpc/src/rem_unknown.rs index 6b92d30..89022bc 100644 --- a/rust/crates/mxaccess-rpc/src/rem_unknown.rs +++ b/rust/crates/mxaccess-rpc/src/rem_unknown.rs @@ -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 { + 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 { + encode_remref_array_request(refs, causality_id) +} + +fn encode_remref_array_request(refs: &[RemInterfaceRef], causality_id: Guid) -> Vec { + 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, + /// 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 { + 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 { .. }) + )); + } }