[F10 + F11] mxaccess-rpc: structural ports for ResolveOxid2 + RemAddRef/RemRelease
rust / build / test / clippy / fmt (push) Has been cancelled
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:
+14
-11
@@ -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 `<this 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 `<this 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 `<this 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.
|
||||
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user