Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33edc91234 | |||
| 4863c6dc1f | |||
| 2dc091d0be | |||
| a31237d1d0 | |||
| 2b849aed7a | |||
| f7139f1118 | |||
| 70feb63ea5 | |||
| bf95995573 | |||
| 12cb10c3a1 | |||
| 5cbc330f82 | |||
| d59ce3571c | |||
| 68aa2e30ab | |||
| baea6eaa41 | |||
| d84b066c62 | |||
| 0c772d273d | |||
| ecfcc3f429 | |||
| 432f1102b7 | |||
| b0954b2672 | |||
| ecbf282f6d | |||
| 30138629d3 | |||
| 95bd218183 |
@@ -0,0 +1,90 @@
|
|||||||
|
# Followups
|
||||||
|
|
||||||
|
Open work items deferred during /loop iterations. Triaged at the top of
|
||||||
|
every iteration. New items are appended under `## Open`; resolved items
|
||||||
|
move to `## Resolved` with a date + commit hash.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
### F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction)
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`
|
||||||
|
**Why deferred:** The .NET `ManagedNtlmClientContext` only implements client-to-server signing (`cs:30,124`); there is no implementation of server-to-client sign/seal keys or `verify_signature`. Both are needed when the callback exporter receives a signed inbound frame from `NmxSvc.exe`, but no such fixture exists yet.
|
||||||
|
**Resolves when:** M2 wave 3 (callback exporter) captures an `INmxSvcCallback::StatusReceived` frame with an `auth_value` trailer per `design/60-roadmap.md:56` (DoD #3) and a fixture lands under `tests/fixtures/m2-status-frame/`. Add `subtle = "2"` and gate the byte compare behind `ConstantTimeEq` at the same time.
|
||||||
|
|
||||||
|
### F3 — Cross-domain NTLM Type1/2/3 fixture
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`
|
||||||
|
**Why deferred:** 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.
|
||||||
|
**Resolves when:** A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8.
|
||||||
|
|
||||||
|
### F4 — BindAck / AlterContextResponse body parser
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
|
||||||
|
**Why deferred:** The .NET reference (`DceRpcPdu.cs:217-262`) parses Bind and AlterContext into the same struct but does not decode the corresponding *response* body (result list + secondary address). The Rust port's `BindPdu::decode` accepts `BindAck` packet type but does not interpret the body. The negotiated transfer syntax — needed before opnum dispatch — is currently inferred from request-side context.
|
||||||
|
**Resolves when:** A captured BindAck frame from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin` is decoded and the body shape is documented in `docs/Loopback-Protocol-Findings.md`.
|
||||||
|
|
||||||
|
### F5 — Captured DCE/RPC bind-frame fixture round-trip
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
|
||||||
|
**Why deferred:** Existing PDU tests build hand-constructed `[C706]`-conformant frames. A capture-driven round-trip (extract bind/alter PDUs from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin`, decode → encode → assert byte-identical) would be stronger evidence of parity with the live wire.
|
||||||
|
**Resolves when:** Bytes from that capture are extracted into `tests/fixtures/m2-pdu/` and the round-trip test lands.
|
||||||
|
|
||||||
|
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 CoMarshalInterface)
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/objref.rs`
|
||||||
|
**Why deferred:** The provider is a wrapper around `ole32::CoMarshalInterface` / `IStream` / `GlobalLock` / `GlobalSize`. It needs `windows-rs`, which is currently behind the `windows-com` feature in `mxaccess-rpc/Cargo.toml`. The pure-Rust parser stands alone for the inbound activation-response path that M2 wave 1 needs.
|
||||||
|
**Resolves when:** `windows-rs` is wired into `mxaccess-rpc` (M2 wave 3 callback exporter needs to publish its own OBJREF for `IRemUnknown` / `INmxSvcCallback` registration) and an emitter port lands behind the `windows-com` feature.
|
||||||
|
|
||||||
|
### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`
|
||||||
|
**Why deferred:** `ObjectExporterMessages.cs` only models opnum 0 (`ResolveOxid`). Opnum 4 (`ResolveOxid2`) has a different response shape — it adds a `COMVERSION` plus an `AuthnHnt[]` array. The .NET reference does not exercise this path, so there's no executable spec to mirror.
|
||||||
|
**Resolves when:** Either a `[MS-DCOM]` §3.1.2.5.1.4-derived layout is verified against a captured `ResolveOxid2` exchange, or the .NET reference grows a `ParseResolveOxid2*` helper.
|
||||||
|
|
||||||
|
### F11 — `IRemUnknown::RemAddRef` and `RemRelease` body codecs
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/rem_unknown.rs`
|
||||||
|
**Why deferred:** `RemUnknownMessages.cs` declares the opnums (`:9-10`) but does not implement encoders/decoders. The Rust port matches that exactly per "port what is already proven."
|
||||||
|
**Resolves when:** The .NET reference adds bodies for opnums 4 / 5 (or a captured frame establishes the on-wire shape). At that point port them into `rem_unknown.rs` alongside the existing `RemQueryInterface` codec.
|
||||||
|
|
||||||
|
### F12 — `NmxClient::create` (auto-resolving COM-activation factory)
|
||||||
|
**Severity:** P1
|
||||||
|
**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs`
|
||||||
|
**Why deferred:** `ManagedNmxService2Client.Create()` (`ManagedNmxService2Client.cs:30-64`) auto-discovers `(host, port, service_ipid)` by activating the `NmxSvc.NmxService` COM ProgID, marshalling the resulting `IUnknown` to an OBJREF, calling `IObjectExporter::ResolveOxid` against the OXID inside, then `IRemUnknown::RemQueryInterface` to get the `INmxService2` IPID. This requires `windows-rs` for `CoCreateInstance` / `CLSIDFromProgID` (the same gating dep as F6), plus the `ComObjRefProvider.MarshalIUnknownObjRef` port (also F6).
|
||||||
|
**Resolves when:** F6 lands (windows-rs wired in + `ComObjRefProvider` port). At that point `NmxClient::create()` becomes ~30 lines that chain the existing primitives: COM activation → `MarshalIUnknownObjRef` → `ComObjRef::parse` → `object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity` → `rem_unknown::encode_rem_query_interface_request` over a temporary transport → `NmxClient::connect`.
|
||||||
|
|
||||||
|
### F16 — Real `Session::recover_connection` reconnect loop (re-bind + re-advise)
|
||||||
|
**Severity:** P1
|
||||||
|
**Source:** M4 wave 2/3 boundary, `crates/mxaccess/src/session.rs`
|
||||||
|
**Why deferred:** Wave-2 `Session::recover_connection` validates the policy and emits `RecoveryEvent::Started` + `RecoveryEvent::Recovered` on each call but does **NOT** actually tear down + re-establish the NMX transport / re-advise active subscriptions. The .NET reference's `RecoverConnectionCore` (`MxNativeSession.cs:442-474`) does all three: builds a replacement `ManagedNmxService2Client` via `CreateRegisteredService`, re-`Connect`s every `_publisherEndpoints` entry, re-`AdviseSupervisory`s every entry in `_subscriptions`, then atomically swaps the old service for the new one. Porting this to Rust requires (a) tracking the active subscriptions inside `SessionInner` (currently they're owned by the consumer's `Subscription` handles, with no central registry); (b) the long-lived connection task per R15 in `design/70-risks-and-open-questions.md` so swap-in-place is safe under concurrent operations; (c) a way to re-create the `CallbackExporter` (or keep the existing one bound while the underlying transport is replaced — needs design work).
|
||||||
|
**Resolves when:** R15's long-lived connection task lands and `SessionInner` gains a subscription registry. At that point the recover loop becomes ~50 lines: for `attempt in 1..=max_attempts`, emit Started → drop+rebuild NmxClient → `register_engine_2` with the existing OBJREF → re-advise every registered correlation_id → emit Recovered (or Failed + sleep delay + continue, mirroring the `cs:407-440` shape exactly).
|
||||||
|
|
||||||
|
### F14 — `tiberius`-backed SQL implementation of `Resolver` + `UserResolver`
|
||||||
|
**Severity:** P2
|
||||||
|
**Source:** M3 stream A, `crates/mxaccess-galaxy/src/sql.rs` (constants present, no client wiring yet)
|
||||||
|
**Why deferred:** `tiberius` is the recommended Rust SQL Server client; pulling it as a non-default dep means the `mxaccess-galaxy` crate keeps a slim default footprint (consumers can plug their own `Resolver` / `UserResolver` impl without dragging in TDS / native-tls / winauth). The actual `GalaxyRepositoryTagResolver` and `GalaxyRepositoryUserResolver` impls are short — they just bind the canonical SQL constants in `crate::sql` (`RESOLVE_SQL`, `BROWSE_SQL`, `USER_BY_GUID_SQL`, `USER_BY_NAME_SQL`) and translate `tiberius::Row` → typed `GalaxyTagMetadata` / `GalaxyUserProfile`.
|
||||||
|
**Resolves when:** A `tiberius`-backed module lands behind the existing `galaxy-resolver` Cargo feature flag in `mxaccess-galaxy/Cargo.toml`. Live-probe gating: needs a Galaxy DB to verify against (`MX_GALAXY_DB` env var, populated by `tools/Setup-LiveProbeEnv.ps1`). The pure-Rust foundation (data types, parser, trait, SQL strings) is already in place — this is "fill in the backend" rather than "design the surface."
|
||||||
|
|
||||||
|
|
||||||
|
## Resolved
|
||||||
|
|
||||||
|
### F7 — Consolidate `Guid` type across `mxaccess-rpc`
|
||||||
|
**Resolved:** 2026-05-05 in this iteration's commit. `Guid` was hoisted from `objref::Guid` into the new shared `crate::guid::Guid` module. `objref` and `pdu` now re-export from there; M2 wave 2's `orpc`, `object_exporter`, and `rem_unknown` import it directly. The OXID-resolve dual-string decoder additionally needs an owned protocol label (`format!("protseq_0x{:04x}", tower_id)` per `ObjectExporterMessages.cs:120`) — `ComDualStringEntry::protocol` was upgraded from `&'static str` to `Cow<'static, str>` to support both decoders without the agent's interim `Box::leak` workaround.
|
||||||
|
|
||||||
|
### F8 — `RpcError` is duplicated across `objref` and `pdu` modules
|
||||||
|
**Resolved:** 2026-05-05 in this iteration's commit. `RpcError` was hoisted into the new shared `crate::error::RpcError` module as a single union of all wave 1 variants plus a generic `Decode { offset, reason: &'static str, buffer_len }` variant for the wave 2 ORPC parsers' one-off failures. `objref` and `pdu` re-export from there; M2 wave 2's `orpc`, `object_exporter`, and `rem_unknown` use it directly.
|
||||||
|
|
||||||
|
### F13 — `NmxClient` high-level write/advise/subscribe wrappers
|
||||||
|
**Resolved:** 2026-05-05. All seven wrappers landed in `crates/mxaccess-nmx/src/client.rs`: `write`, `write2`, `write_secured2`, `advise_supervisory`, `send_observed_pre_advise_metadata`, `register_reference`, `un_advise`. Each takes a `GalaxyTagMetadata` + a typed `WriteValue` (re-exported from `mxaccess-codec`), builds the inner NMX body via `mxaccess-codec` (`write_message::encode` / `encode_timestamped` / `secured_write::encode` / `NmxItemControlMessage` / `NmxMetadataQueryMessage` / `NmxReferenceRegistrationMessage`), wraps in `NmxTransferEnvelope`, and routes through `transfer_data`. The pure-codec `encode_*_transfer_body` helpers are extracted as `pub(crate) fn` for testability, mirroring the .NET reference's `internal static` shape. `un_advise` preserves the .NET reference's quirky `NmxTransferMessageKind::Write` envelope (not `ItemControl`) per `cs:457`.
|
||||||
|
|
||||||
|
### F15 — Callback router wires `CallbackExporter` events into `Subscription` stream
|
||||||
|
**Resolved:** 2026-05-05 across two commits.
|
||||||
|
- Step 1/2 (`2b849ae`): `Session::connect_nmx` now starts a `CallbackExporter` on a 127.0.0.1 ephemeral port, builds the OBJREF via `local_hostname()` + `127.0.0.1` fallback, registers it through `NmxClient::register_engine_2` (was `..._without_callback`). A `callback_router` task drains `CallbackEvent`s, decodes each `CallbackInvoked` body via `NmxSubscriptionMessage::parse_inner`, and broadcasts parsed messages on a `tokio::sync::broadcast` channel exposed via `Session::callbacks()`. Shutdown chains: UnregisterEngine → CallbackExporter::shutdown → wait for router task.
|
||||||
|
- Step 2/2 (this commit): `Subscription` now impls `Stream<Item = Result<DataChange, Error>>`. Filtering follows the .NET reference at `cs:333-343` exactly — `0x32` SubscriptionStatus messages are kept only when `message.item_correlation_id == subscription.correlation_id`; `0x33` DataUpdate messages pass through to ALL subscriptions because the codec exposes no per-record correlation field (matches the .NET `MxNativeCallbackEvent` filter behavior verbatim). Each `NmxSubscriptionRecord` with a parseable `value` becomes one `DataChange`. Records with `value: None` are dropped silently (mirrors the .NET `evt.Record.Value is null` filter at `cs:337`). Lag-loss surfaces as `Error::Configuration(InvalidArgument)` carrying the lag count. Stream-end (broadcast sender dropped) yields `None`. New helper: `filetime_to_system_time` (inverse of the existing `system_time_to_filetime`); saturates at Unix epoch for pre-1970 FILETIMEs. Tests cover correlation match/mismatch for `0x32`, `0x33` pass-through for any correlation, and FILETIME round-trip.
|
||||||
|
|
||||||
|
### F1 — NTLM consumer-layer helpers (workstation default + from_env constructor)
|
||||||
|
**Resolved:** 2026-05-05. `NtlmClientContext::from_env()` reads `MX_RPC_USER` / `MX_RPC_PASSWORD` / `MX_RPC_DOMAIN` (mirrors `ManagedNtlmClientContext.FromEnvironment` at `cs:41-49`); empty `MX_RPC_DOMAIN` is permitted. `local_hostname()` checks `COMPUTERNAME` then `HOSTNAME` and returns the empty string when neither is set — same "unavailable" semantics as `Environment.MachineName` returning null. Lives in `mxaccess-rpc/src/ntlm.rs`; deliberately doesn't pull `gethostname` (no native-libc deps, no `unsafe` for hostname lookup). Added `NtlmError::MissingEnvVar { name }` for the env-var-unset case. Test mod gained an `EnvScope` + `ENV_LOCK` mutex pattern for serializing process-global env mutation across parallel tests.
|
||||||
|
|
||||||
|
### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods
|
||||||
|
**Resolved:** 2026-05-05. Both portable methods land in `crates/mxaccess-rpc/src/object_exporter_client.rs`: `resolve_oxid_unauthenticated` (mirrors `cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity` (mirrors `cs:66-81`). Each opens a TCP connection, binds to `IObjectExporter`, calls opnum 0 with the encoded request, and decodes the response — preferring `parse_resolve_oxid_result` then falling back to `parse_resolve_oxid_failure` for short stubs. The two SSPI flavours (`ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`) wrap .NET's `System.Net.Security.SspiClientContext` and are explicitly out of scope for the Rust port — that's a permanent skip, not a deferral.
|
||||||
Generated
+538
@@ -2,12 +2,248 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.0",
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.10.4",
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.97"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md4"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mxaccess"
|
name = "mxaccess"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"futures-util",
|
||||||
|
"mxaccess-callback",
|
||||||
"mxaccess-codec",
|
"mxaccess-codec",
|
||||||
|
"mxaccess-galaxy",
|
||||||
|
"mxaccess-nmx",
|
||||||
|
"mxaccess-rpc",
|
||||||
|
"rand",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -28,6 +264,9 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"mxaccess-codec",
|
"mxaccess-codec",
|
||||||
"mxaccess-rpc",
|
"mxaccess-rpc",
|
||||||
|
"rand",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -47,6 +286,13 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "mxaccess-galaxy"
|
name = "mxaccess-galaxy"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"mxaccess-codec",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mxaccess-nmx"
|
name = "mxaccess-nmx"
|
||||||
@@ -54,12 +300,47 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"mxaccess-callback",
|
"mxaccess-callback",
|
||||||
"mxaccess-codec",
|
"mxaccess-codec",
|
||||||
|
"mxaccess-galaxy",
|
||||||
"mxaccess-rpc",
|
"mxaccess-rpc",
|
||||||
|
"rand",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mxaccess-rpc"
|
name = "mxaccess-rpc"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
"md-5",
|
||||||
|
"md4",
|
||||||
|
"rand",
|
||||||
|
"rc4",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
@@ -79,6 +360,73 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rc4"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -110,8 +458,198 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.52.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.120"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.120"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.120"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.120"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ authors.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
||||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
//! `mxaccess-callback` — `INmxSvcCallback` RPC server (the callback exporter).
|
//! `mxaccess-callback` — `INmxSvcCallback` RPC server (the callback exporter).
|
||||||
//!
|
//!
|
||||||
//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`.
|
//! M2 wave 3 landed: the [`exporter`] module ports
|
||||||
|
//! `src/MxNativeClient/ManagedCallbackExporter.cs` to a tokio-based TCP
|
||||||
|
//! server that serves `IRemUnknown` and `INmxSvcCallback` opnums and emits
|
||||||
|
//! typed [`exporter::CallbackEvent`]s for diagnostic observation.
|
||||||
//!
|
//!
|
||||||
//! Opnums (verified against `src/MxNativeClient/NmxSvcCallbackMessages.cs:11-12`):
|
//! Opnums (verified against `src/MxNativeClient/NmxSvcCallbackMessages.cs:11-12`):
|
||||||
//! - `3` `DataReceived(bufferSize: i32, dataBuffer: sbyte[bufferSize]) -> hresult`
|
//! - `3` `DataReceived(bufferSize: i32, dataBuffer: sbyte[bufferSize]) -> hresult`
|
||||||
@@ -10,3 +13,7 @@
|
|||||||
//! server-side handshake against our exported OBJREF (DoD condition for M2).
|
//! server-side handshake against our exported OBJREF (DoD condition for M2).
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod exporter;
|
||||||
|
|
||||||
|
pub use exporter::{CallbackEvent, CallbackExporter, ExporterIdentities, IUNKNOWN_IID};
|
||||||
|
|||||||
@@ -116,6 +116,61 @@ impl MxValueKind {
|
|||||||
pub fn to_u8(self) -> u8 {
|
pub fn to_u8(self) -> u8 {
|
||||||
self as u8
|
self as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a model-side `(MxDataType, is_array)` pair to the wire-side
|
||||||
|
/// `MxValueKind` the LMX server expects on a Write body.
|
||||||
|
///
|
||||||
|
/// Mirrors `NmxWriteMessage.GetValueKind` + `TryGetValueKind`
|
||||||
|
/// (`NmxWriteMessage.cs:58-86`) **plus** the two scalar fallbacks the
|
||||||
|
/// .NET `GalaxyTagMetadata.ProjectWriteValue`
|
||||||
|
/// (`GalaxyRepositoryTagResolver.cs:53-72`) layers on top:
|
||||||
|
///
|
||||||
|
/// - `ElapsedTime` (scalar) → `Int32`. The .NET reference converts a
|
||||||
|
/// `TimeSpan` value to `int totalMilliseconds` at `cs:67-68`; the
|
||||||
|
/// wire kind is `Int32` regardless of the source CLR type.
|
||||||
|
/// - `InternationalizedString` (scalar) → `String`
|
||||||
|
/// (`cs:69`).
|
||||||
|
///
|
||||||
|
/// Returns `None` for any other combination — including arrays of
|
||||||
|
/// `ElapsedTime` / `InternationalizedString` / `Enum` / `BigString`,
|
||||||
|
/// which the .NET reference explicitly rejects at `cs:60-63`.
|
||||||
|
///
|
||||||
|
/// The 12 base mappings (data types 1..=6, scalar and array each):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// (Boolean, false) → Boolean (Boolean, true) → BoolArray
|
||||||
|
/// (Integer, false) → Int32 (Integer, true) → Int32Array
|
||||||
|
/// (Float, false) → Float32 (Float, true) → Float32Array
|
||||||
|
/// (Double, false) → Float64 (Double, true) → Float64Array
|
||||||
|
/// (String, false) → String (String, true) → StringArray
|
||||||
|
/// (Time, false) → DateTime (Time, true) → DateTimeArray
|
||||||
|
/// ```
|
||||||
|
#[must_use]
|
||||||
|
pub fn for_data_type(data_type: MxDataType, is_array: bool) -> Option<MxValueKind> {
|
||||||
|
match (data_type, is_array) {
|
||||||
|
(MxDataType::Boolean, false) => Some(MxValueKind::Boolean),
|
||||||
|
(MxDataType::Integer, false) => Some(MxValueKind::Int32),
|
||||||
|
(MxDataType::Float, false) => Some(MxValueKind::Float32),
|
||||||
|
(MxDataType::Double, false) => Some(MxValueKind::Float64),
|
||||||
|
(MxDataType::String, false) => Some(MxValueKind::String),
|
||||||
|
(MxDataType::Time, false) => Some(MxValueKind::DateTime),
|
||||||
|
(MxDataType::Boolean, true) => Some(MxValueKind::BoolArray),
|
||||||
|
(MxDataType::Integer, true) => Some(MxValueKind::Int32Array),
|
||||||
|
(MxDataType::Float, true) => Some(MxValueKind::Float32Array),
|
||||||
|
(MxDataType::Double, true) => Some(MxValueKind::Float64Array),
|
||||||
|
(MxDataType::String, true) => Some(MxValueKind::StringArray),
|
||||||
|
(MxDataType::Time, true) => Some(MxValueKind::DateTimeArray),
|
||||||
|
// ProjectWriteValue scalar fallbacks (`cs:65-69`):
|
||||||
|
(MxDataType::ElapsedTime, false) => Some(MxValueKind::Int32),
|
||||||
|
(MxDataType::InternationalizedString, false) => Some(MxValueKind::String),
|
||||||
|
// Everything else (arrays of unsupported types, or unsupported
|
||||||
|
// scalars like ReferenceType / StatusType / Enum / etc.) is
|
||||||
|
// rejected. Mirrors the `_ => Return(default, out valueKind,
|
||||||
|
// success: false)` arm at `cs:84` plus the
|
||||||
|
// `ArgumentOutOfRangeException` paths at `cs:62,70`.
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attribute-model data type — port of `MxDataType.cs:3-24`.
|
/// Attribute-model data type — port of `MxDataType.cs:3-24`.
|
||||||
@@ -468,4 +523,126 @@ mod tests {
|
|||||||
assert_eq!(MxDataType::default(), MxDataType::Unknown);
|
assert_eq!(MxDataType::default(), MxDataType::Unknown);
|
||||||
assert_eq!(MxDataType::default().to_i16(), -1);
|
assert_eq!(MxDataType::default().to_i16(), -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_scalar_base_table() {
|
||||||
|
// Mirrors NmxWriteMessage.cs:72-77 (scalar arms of TryGetValueKind).
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Boolean, false),
|
||||||
|
Some(MxValueKind::Boolean)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Integer, false),
|
||||||
|
Some(MxValueKind::Int32)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Float, false),
|
||||||
|
Some(MxValueKind::Float32)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Double, false),
|
||||||
|
Some(MxValueKind::Float64)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::String, false),
|
||||||
|
Some(MxValueKind::String)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Time, false),
|
||||||
|
Some(MxValueKind::DateTime)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_array_base_table() {
|
||||||
|
// Mirrors NmxWriteMessage.cs:78-83 (array arms of TryGetValueKind).
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Boolean, true),
|
||||||
|
Some(MxValueKind::BoolArray)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Integer, true),
|
||||||
|
Some(MxValueKind::Int32Array)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Float, true),
|
||||||
|
Some(MxValueKind::Float32Array)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Double, true),
|
||||||
|
Some(MxValueKind::Float64Array)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::String, true),
|
||||||
|
Some(MxValueKind::StringArray)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::Time, true),
|
||||||
|
Some(MxValueKind::DateTimeArray)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_elapsed_time_scalar_falls_back_to_int32() {
|
||||||
|
// GalaxyRepositoryTagResolver.cs:67-68: ElapsedTime scalar maps to
|
||||||
|
// Int32 (caller is expected to convert TimeSpan to milliseconds).
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::ElapsedTime, false),
|
||||||
|
Some(MxValueKind::Int32)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_internationalized_string_scalar_falls_back_to_string() {
|
||||||
|
// GalaxyRepositoryTagResolver.cs:69.
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::InternationalizedString, false),
|
||||||
|
Some(MxValueKind::String)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_array_of_unsupported_returns_none() {
|
||||||
|
// GalaxyRepositoryTagResolver.cs:60-63 explicitly rejects array of
|
||||||
|
// unsupported types — no fallback applies in the array case.
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::ElapsedTime, true),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::InternationalizedString, true),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(MxValueKind::for_data_type(MxDataType::Enum, true), None);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::BigString, true),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_data_type_unsupported_scalars_return_none() {
|
||||||
|
// ReferenceType, StatusType, Enum, etc. are not in either the base
|
||||||
|
// table or the ProjectWriteValue fallbacks → None.
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::ReferenceType, false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::StatusType, false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(MxValueKind::for_data_type(MxDataType::Enum, false), None);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::DataQualityType, false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MxValueKind::for_data_type(MxDataType::BigString, false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(MxValueKind::for_data_type(MxDataType::Unknown, false), None);
|
||||||
|
assert_eq!(MxValueKind::for_data_type(MxDataType::NoData, false), None);
|
||||||
|
assert_eq!(MxValueKind::for_data_type(MxDataType::End, false), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ rust-version.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
uuid = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
//! `mxaccess-galaxy` — Galaxy Repository SQL resolver.
|
//! `mxaccess-galaxy` — Galaxy Repository tag resolver.
|
||||||
//!
|
//!
|
||||||
//! M0 stub. The real resolver lands in M3 — see `design/60-roadmap.md`.
|
//! M3 stream A landed: the trait + metadata + parser + canonical SQL
|
||||||
//! Replicates the recursive CTE from
|
//! constants. The actual `tiberius`-backed implementation behind the
|
||||||
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:209-293`
|
//! `galaxy-resolver` Cargo feature is a follow-up (see
|
||||||
//! (`deployed_package_chain`) against the verified table set
|
//! `design/followups.md`).
|
||||||
//! `dbo.gobject` / `dbo.instance` / `dbo.dynamic_attribute` /
|
|
||||||
//! `dbo.attribute_definition` / `dbo.primitive_instance` / `dbo.package`.
|
|
||||||
//!
|
//!
|
||||||
//! **Resolver input contract**: `tag_name`-form only (e.g. `DelmiaReceiver_001`),
|
//! Modules:
|
||||||
//! not `contained_name`-form (e.g. `TestMachine_001.DelmiaReceiver`). See
|
//!
|
||||||
//! `wwtools/grdb/README.md` for the asymmetry.
|
//! - [`metadata`] — [`metadata::GalaxyTagMetadata`] record (port of
|
||||||
|
//! `GalaxyTagMetadata` at `GalaxyRepositoryTagResolver.cs:6-73`).
|
||||||
|
//! - [`parser`] — [`parser::ParsedTagReference`] (port of `cs:167-206`).
|
||||||
|
//! Pure-Rust, no I/O. Handles `Object.Attribute` /
|
||||||
|
//! `Object.Primitive.Attribute` / `.property(buffer)` shapes.
|
||||||
|
//! - [`resolver`] — [`resolver::Resolver`] async trait + [`resolver::ResolverError`].
|
||||||
|
//! - [`sql`] — `RESOLVE_SQL` + `BROWSE_SQL` constants (the recursive
|
||||||
|
//! `deployed_package_chain` CTE from `cs:208-432`). Exposed publicly
|
||||||
|
//! so any backend (the future `tiberius` impl, a snapshot replay
|
||||||
|
//! harness, etc.) can grab the canonical query.
|
||||||
|
//!
|
||||||
|
//! **Resolver input contract**: `tag_name`-form only (e.g.
|
||||||
|
//! `DelmiaReceiver_001`), not `contained_name`-form (e.g.
|
||||||
|
//! `TestMachine_001.DelmiaReceiver`). See `wwtools/grdb/README.md` for
|
||||||
|
//! the asymmetry. The parser does not enforce this — the SQL queries do
|
||||||
|
//! by joining `g.tag_name = @objectTagName` (not `contained_name`).
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod parser;
|
||||||
|
pub mod resolver;
|
||||||
|
pub mod role_blob;
|
||||||
|
pub mod sql;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use metadata::{GalaxyTagMetadata, UnsupportedDataType};
|
||||||
|
pub use parser::{ParseError, ParsedTagReference};
|
||||||
|
pub use resolver::{Resolver, ResolverError};
|
||||||
|
pub use role_blob::parse_role_blob;
|
||||||
|
pub use user::{GalaxyUserProfile, UserResolver, UserResolverError};
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! `GalaxyTagMetadata` — the resolved attribute-metadata record.
|
||||||
|
//!
|
||||||
|
//! Direct port of the `GalaxyTagMetadata` record at the top of
|
||||||
|
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:6-73`. Carries the
|
||||||
|
//! exact set of fields the .NET reference reads from a Galaxy SQL row,
|
||||||
|
//! plus four derived helpers:
|
||||||
|
//!
|
||||||
|
//! - [`GalaxyTagMetadata::is_buffer_property`] (mirrors the `IsBufferProperty`
|
||||||
|
//! property at `cs:24`).
|
||||||
|
//! - [`GalaxyTagMetadata::to_reference_handle`] (mirrors `ToReferenceHandle`
|
||||||
|
//! at `cs:26-39`) — converts the metadata into a wire-ready
|
||||||
|
//! [`mxaccess_codec::MxReferenceHandle`].
|
||||||
|
//! - [`GalaxyTagMetadata::resolve_write_kind`] + [`GalaxyTagMetadata::is_writable`]
|
||||||
|
//! (mirrors the `MxDataType` → `MxValueKind` selection from
|
||||||
|
//! `ToValueKind` / `TryGetValueKind` / `IsSupportedValueKind` /
|
||||||
|
//! `ProjectWriteValue` at `cs:41-72`). Delegates to
|
||||||
|
//! [`MxValueKind::for_data_type`] in `mxaccess-codec` which fuses the
|
||||||
|
//! primary `NmxWriteMessage.GetValueKind` table with the two scalar
|
||||||
|
//! fallbacks (`ElapsedTime` → `Int32`, `InternationalizedString` →
|
||||||
|
//! `String`).
|
||||||
|
//!
|
||||||
|
//! What's still deferred: the value-side of `ProjectWriteValue`
|
||||||
|
//! (`cs:53-72`) — converting a caller-supplied value (e.g. .NET `TimeSpan`)
|
||||||
|
//! into the `MxValue` variant the wire kind expects. That belongs at the
|
||||||
|
//! consumer boundary in Rust, not in the metadata; F13's `NmxClient::write_*`
|
||||||
|
//! wrappers will handle it.
|
||||||
|
|
||||||
|
use mxaccess_codec::{CodecError, MxDataType, MxReferenceHandle, MxValueKind};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Returned by [`GalaxyTagMetadata::resolve_write_kind`] when the metadata's
|
||||||
|
/// `(mx_data_type, is_array)` combination is not writable on the LMX wire.
|
||||||
|
///
|
||||||
|
/// Mirrors the `ArgumentOutOfRangeException` paths in the .NET reference
|
||||||
|
/// at `NmxWriteMessage.cs:62-65,108` and `GalaxyRepositoryTagResolver.cs:62,70`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
|
#[error("MX data type {mx_data_type} (is_array={is_array}) has no supported MxValueKind mapping")]
|
||||||
|
pub struct UnsupportedDataType {
|
||||||
|
pub mx_data_type: i16,
|
||||||
|
pub is_array: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved Galaxy tag metadata. Field order and types match the .NET
|
||||||
|
/// `GalaxyTagMetadata` record exactly (`cs:6-19`).
|
||||||
|
///
|
||||||
|
/// # Numeric ranges
|
||||||
|
///
|
||||||
|
/// `platform_id`, `engine_id`, `object_id` are stored as `u16` because they
|
||||||
|
/// come from `dbo.instance.mx_*_id` (SQL `smallint` checked-cast to ushort
|
||||||
|
/// in .NET — `cs:155-157`). `primitive_id`, `attribute_id`, `property_id`,
|
||||||
|
/// `mx_data_type`, `security_classification` are `i16` for the same reason
|
||||||
|
/// (signed `smallint`).
|
||||||
|
///
|
||||||
|
/// `property_id` is sourced from `SQL int` and checked-cast to `i16`
|
||||||
|
/// (`cs:160`). The Rust port stores `i16` to match the .NET shape; values
|
||||||
|
/// outside the i16 range are a SQL-side issue, not a Rust-side issue.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct GalaxyTagMetadata {
|
||||||
|
pub object_tag_name: String,
|
||||||
|
pub attribute_name: String,
|
||||||
|
/// `None` for `dynamic` attributes; `Some(name)` for `primitive`.
|
||||||
|
/// Mirrors `string? PrimitiveName` (`cs:9`).
|
||||||
|
pub primitive_name: Option<String>,
|
||||||
|
pub platform_id: u16,
|
||||||
|
pub engine_id: u16,
|
||||||
|
pub object_id: u16,
|
||||||
|
pub primitive_id: i16,
|
||||||
|
pub attribute_id: i16,
|
||||||
|
pub property_id: i16,
|
||||||
|
pub mx_data_type: i16,
|
||||||
|
pub is_array: bool,
|
||||||
|
pub security_classification: i16,
|
||||||
|
/// Provenance tag — either `"dynamic"` or `"primitive"` per the SQL
|
||||||
|
/// `attribute_source` column (`cs:247,276,375,399`).
|
||||||
|
pub attribute_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GalaxyTagMetadata {
|
||||||
|
/// Default `property_id` for ordinary value attributes — `cs:21`.
|
||||||
|
pub const VALUE_PROPERTY_ID: i16 = 10;
|
||||||
|
|
||||||
|
/// `property_id` used for `(buffer)` property references — `cs:22`.
|
||||||
|
pub const BUFFER_PROPERTY_ID: i16 = 50;
|
||||||
|
|
||||||
|
/// `true` when [`Self::property_id`] equals [`Self::BUFFER_PROPERTY_ID`].
|
||||||
|
/// Mirrors `IsBufferProperty` (`cs:24`).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_buffer_property(&self) -> bool {
|
||||||
|
self.property_id == Self::BUFFER_PROPERTY_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the wire-side [`MxValueKind`] this attribute writes as,
|
||||||
|
/// based on `(mx_data_type, is_array)`. Mirrors the .NET
|
||||||
|
/// `GalaxyTagMetadata.ProjectWriteValue` kind selection at
|
||||||
|
/// `GalaxyRepositoryTagResolver.cs:53-72` (delegated to
|
||||||
|
/// [`MxValueKind::for_data_type`] which fuses both
|
||||||
|
/// `NmxWriteMessage.GetValueKind` and the `ProjectWriteValue` scalar
|
||||||
|
/// fallbacks for `ElapsedTime` → `Int32` and `InternationalizedString` →
|
||||||
|
/// `String`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`UnsupportedDataType`] when the `(mx_data_type, is_array)` pair has
|
||||||
|
/// no LMX wire encoding (e.g. arrays of `ElapsedTime`, scalars of
|
||||||
|
/// `ReferenceType` / `StatusType` / `Enum` / etc.).
|
||||||
|
///
|
||||||
|
/// # Note
|
||||||
|
///
|
||||||
|
/// This only resolves the **kind**; converting the caller's value
|
||||||
|
/// payload into the right `MxValue` variant is the caller's job.
|
||||||
|
/// The .NET reference's `TimeSpan` → `int millis` conversion at
|
||||||
|
/// `cs:67-68` happens at the consumer boundary in Rust, not here —
|
||||||
|
/// the Rust port doesn't expose `TimeSpan`-style types in the codec.
|
||||||
|
pub fn resolve_write_kind(&self) -> Result<MxValueKind, UnsupportedDataType> {
|
||||||
|
let data_type = MxDataType::from_i16(self.mx_data_type);
|
||||||
|
MxValueKind::for_data_type(data_type, self.is_array).ok_or(UnsupportedDataType {
|
||||||
|
mx_data_type: self.mx_data_type,
|
||||||
|
is_array: self.is_array,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when [`Self::resolve_write_kind`] would succeed. Useful as a
|
||||||
|
/// pre-flight check in browse UIs.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_writable(&self) -> bool {
|
||||||
|
self.resolve_write_kind().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the wire-form [`MxReferenceHandle`] this metadata describes.
|
||||||
|
/// Mirrors `ToReferenceHandle(byte galaxyId = 1)` (`cs:26-39`).
|
||||||
|
///
|
||||||
|
/// `galaxy_id` defaults to 1 in the .NET signature; the Rust port makes
|
||||||
|
/// it explicit so callers don't accidentally use `0` (which would
|
||||||
|
/// produce a different wire handle).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Propagates [`CodecError::InvalidName`] from
|
||||||
|
/// [`MxReferenceHandle::from_names`] when either name is empty or
|
||||||
|
/// whitespace-only.
|
||||||
|
pub fn to_reference_handle(&self, galaxy_id: u8) -> Result<MxReferenceHandle, CodecError> {
|
||||||
|
MxReferenceHandle::from_names(
|
||||||
|
galaxy_id,
|
||||||
|
self.platform_id,
|
||||||
|
self.engine_id,
|
||||||
|
self.object_id,
|
||||||
|
&self.object_tag_name,
|
||||||
|
self.primitive_id,
|
||||||
|
self.attribute_id,
|
||||||
|
self.property_id,
|
||||||
|
&self.attribute_name,
|
||||||
|
self.is_array,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample(
|
||||||
|
property_id: i16,
|
||||||
|
primitive_id: i16,
|
||||||
|
primitive_name: Option<&str>,
|
||||||
|
) -> GalaxyTagMetadata {
|
||||||
|
GalaxyTagMetadata {
|
||||||
|
object_tag_name: "TestObject_001".to_string(),
|
||||||
|
attribute_name: "TestInt".to_string(),
|
||||||
|
primitive_name: primitive_name.map(str::to_string),
|
||||||
|
platform_id: 5,
|
||||||
|
engine_id: 7,
|
||||||
|
object_id: 42,
|
||||||
|
primitive_id,
|
||||||
|
attribute_id: 99,
|
||||||
|
property_id,
|
||||||
|
mx_data_type: 4,
|
||||||
|
is_array: false,
|
||||||
|
security_classification: 0,
|
||||||
|
attribute_source: if primitive_name.is_some() {
|
||||||
|
"primitive"
|
||||||
|
} else {
|
||||||
|
"dynamic"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn property_id_constants_match_dotnet() {
|
||||||
|
// .NET `GalaxyTagMetadata.ValuePropertyId` and `BufferPropertyId` at cs:21-22.
|
||||||
|
assert_eq!(GalaxyTagMetadata::VALUE_PROPERTY_ID, 10);
|
||||||
|
assert_eq!(GalaxyTagMetadata::BUFFER_PROPERTY_ID, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_buffer_property_true_only_for_50() {
|
||||||
|
assert!(!sample(10, 0, None).is_buffer_property());
|
||||||
|
assert!(sample(50, 0, None).is_buffer_property());
|
||||||
|
assert!(!sample(0, 0, None).is_buffer_property());
|
||||||
|
assert!(!sample(11, 0, None).is_buffer_property());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_reference_handle_builds_wire_handle() {
|
||||||
|
let meta = sample(10, -1, None); // primitive_id = -1, the .NET "no primitive" sentinel
|
||||||
|
let handle = meta.to_reference_handle(1).unwrap();
|
||||||
|
assert_eq!(handle.galaxy_id, 1);
|
||||||
|
assert_eq!(handle.platform_id, 5);
|
||||||
|
assert_eq!(handle.engine_id, 7);
|
||||||
|
assert_eq!(handle.object_id, 42);
|
||||||
|
assert_eq!(handle.attribute_id, 99);
|
||||||
|
assert_eq!(handle.property_id, 10);
|
||||||
|
// is_array = false → attribute_index = 0 per MxReferenceHandle::from_names.
|
||||||
|
assert_eq!(handle.attribute_index, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_reference_handle_array_sets_attribute_index_minus_one() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.is_array = true;
|
||||||
|
let handle = meta.to_reference_handle(1).unwrap();
|
||||||
|
assert_eq!(handle.attribute_index, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_reference_handle_rejects_empty_name() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.object_tag_name = " ".to_string();
|
||||||
|
let err = meta.to_reference_handle(1).unwrap_err();
|
||||||
|
assert!(matches!(err, CodecError::InvalidName));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primitive_name_round_trips() {
|
||||||
|
let meta = sample(10, 0, Some("DelmiaReceiver"));
|
||||||
|
assert_eq!(meta.primitive_name.as_deref(), Some("DelmiaReceiver"));
|
||||||
|
assert_eq!(meta.attribute_source, "primitive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dynamic_metadata_has_no_primitive_name() {
|
||||||
|
let meta = sample(10, 0, None);
|
||||||
|
assert_eq!(meta.primitive_name, None);
|
||||||
|
assert_eq!(meta.attribute_source, "dynamic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_double_scalar_is_float64() {
|
||||||
|
// sample defaults mx_data_type=4 (Double), is_array=false.
|
||||||
|
let meta = sample(10, 0, None);
|
||||||
|
assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::Float64));
|
||||||
|
assert!(meta.is_writable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_boolean_array_is_bool_array() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = 1; // Boolean
|
||||||
|
meta.is_array = true;
|
||||||
|
assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::BoolArray));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_elapsed_time_scalar_is_int32() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = 7; // ElapsedTime
|
||||||
|
meta.is_array = false;
|
||||||
|
assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::Int32));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_array_of_elapsed_time_unsupported() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = 7; // ElapsedTime
|
||||||
|
meta.is_array = true;
|
||||||
|
let err = meta.resolve_write_kind().unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
UnsupportedDataType {
|
||||||
|
mx_data_type: 7,
|
||||||
|
is_array: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!meta.is_writable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_internationalized_string_scalar_is_string() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = 15; // InternationalizedString
|
||||||
|
meta.is_array = false;
|
||||||
|
assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::String));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_reference_type_unsupported() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = 8; // ReferenceType — never writable on the wire
|
||||||
|
meta.is_array = false;
|
||||||
|
assert!(meta.resolve_write_kind().is_err());
|
||||||
|
assert!(!meta.is_writable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_write_kind_unknown_data_type_unsupported() {
|
||||||
|
let mut meta = sample(10, 0, None);
|
||||||
|
meta.mx_data_type = -1; // Unknown sentinel
|
||||||
|
assert!(meta.resolve_write_kind().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! Tag-reference parser.
|
||||||
|
//!
|
||||||
|
//! Direct port of the inner `ParsedTagReference` record + `ParseCandidates`
|
||||||
|
//! / `ParsePropertySuffix` helpers from
|
||||||
|
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:167-206`.
|
||||||
|
//!
|
||||||
|
//! The .NET reference accepts three input shapes for `ResolveAsync`:
|
||||||
|
//!
|
||||||
|
//! 1. `Object.Attribute` — 2 dot-separated parts, no primitive, dynamic only.
|
||||||
|
//! 2. `Object.Primitive.Attribute` — 3+ parts. **Two candidates** are
|
||||||
|
//! produced: one treating part 1 as the primitive (a primitive-attribute
|
||||||
|
//! lookup), one treating the entire `Primitive.Attribute` tail as a
|
||||||
|
//! dotted attribute name on a dynamic attribute (a dynamic lookup). The
|
||||||
|
//! SQL UNION returns the first that matches, with `dynamic` preferred
|
||||||
|
//! when both match.
|
||||||
|
//! 3. Either of the above with the `.property(buffer)` suffix — strips the
|
||||||
|
//! suffix and overrides `property_id` with
|
||||||
|
//! [`GalaxyTagMetadata::BUFFER_PROPERTY_ID`].
|
||||||
|
//!
|
||||||
|
//! Anything else (one part, empty, only whitespace) is rejected with a
|
||||||
|
//! [`ParseError`].
|
||||||
|
|
||||||
|
// Each indexed/sliced access into `parts` is preceded by an explicit
|
||||||
|
// length check via the `match parts.len()` arm, so the indexing is
|
||||||
|
// statically known to be in-bounds. `.get(n).copied().unwrap_or(...)`
|
||||||
|
// would obscure that 1:1 mirror of the .NET `parts[0]`/`parts[1]`/
|
||||||
|
// `parts[2..]` shape at `cs:180-184`.
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::metadata::GalaxyTagMetadata;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors raised by [`ParsedTagReference::parse_candidates`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ParseError {
|
||||||
|
/// Tag reference was empty or whitespace-only. Mirrors
|
||||||
|
/// `ArgumentException.ThrowIfNullOrWhiteSpace` at `cs:175`.
|
||||||
|
#[error("tag reference must not be empty or whitespace-only")]
|
||||||
|
Empty,
|
||||||
|
|
||||||
|
/// Tag reference produced fewer than two dot-separated parts.
|
||||||
|
/// Mirrors the `_ =>` arm at `cs:186`.
|
||||||
|
#[error("tag reference must be Object.Attribute or Object.Primitive.Attribute")]
|
||||||
|
InvalidShape,
|
||||||
|
|
||||||
|
/// `.property(buffer)` suffix with no base reference. Mirrors
|
||||||
|
/// `cs:196-199`.
|
||||||
|
#[error("property reference must include a base tag reference")]
|
||||||
|
EmptyBaseBeforePropertySuffix,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed candidate. The .NET reference returns a list of these
|
||||||
|
/// because a 3-part input is ambiguous (primitive vs dotted attribute).
|
||||||
|
///
|
||||||
|
/// Mirrors the inner record at `cs:167-171`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ParsedTagReference {
|
||||||
|
pub object_tag_name: String,
|
||||||
|
pub primitive_name: Option<String>,
|
||||||
|
pub attribute_name: String,
|
||||||
|
/// When `Some`, the resolver replaces the metadata's `property_id`
|
||||||
|
/// with this value. Mirrors the `with { PropertyId = override }` at
|
||||||
|
/// `cs:108-110`. Currently set only for the `.property(buffer)`
|
||||||
|
/// suffix.
|
||||||
|
pub property_id_override: Option<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPERTY_BUFFER_SUFFIX: &str = ".property(buffer)";
|
||||||
|
|
||||||
|
impl ParsedTagReference {
|
||||||
|
/// Parse a tag reference into one or more candidates. Mirrors
|
||||||
|
/// `ParseCandidates` (`cs:173-188`).
|
||||||
|
///
|
||||||
|
/// Returns at least one candidate; for 3+-part inputs returns 2
|
||||||
|
/// candidates (primitive-attribute first, dotted-attribute second)
|
||||||
|
/// matching the .NET ordering at `cs:181-185`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ParseError::Empty`], [`ParseError::InvalidShape`], or
|
||||||
|
/// [`ParseError::EmptyBaseBeforePropertySuffix`].
|
||||||
|
pub fn parse_candidates(tag_reference: &str) -> Result<Vec<Self>, ParseError> {
|
||||||
|
if tag_reference.trim().is_empty() {
|
||||||
|
return Err(ParseError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (base_reference, property_id_override) = parse_property_suffix(tag_reference)?;
|
||||||
|
|
||||||
|
// Split on `.`, drop empty parts, trim each (mirrors
|
||||||
|
// `StringSplitOptions.RemoveEmptyEntries | TrimEntries` at cs:177).
|
||||||
|
let parts: Vec<&str> = base_reference
|
||||||
|
.split('.')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match parts.len() {
|
||||||
|
0 | 1 => Err(ParseError::InvalidShape),
|
||||||
|
2 => Ok(vec![Self {
|
||||||
|
object_tag_name: parts[0].to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
attribute_name: parts[1].to_string(),
|
||||||
|
property_id_override,
|
||||||
|
}]),
|
||||||
|
_ => {
|
||||||
|
// 3+ parts — produce both candidates per cs:181-185.
|
||||||
|
let object = parts[0].to_string();
|
||||||
|
let primitive_first = parts[1].to_string();
|
||||||
|
let attr_after_primitive = parts[2..].join(".");
|
||||||
|
let attr_after_object = parts[1..].join(".");
|
||||||
|
Ok(vec![
|
||||||
|
Self {
|
||||||
|
object_tag_name: object.clone(),
|
||||||
|
primitive_name: Some(primitive_first),
|
||||||
|
attribute_name: attr_after_primitive,
|
||||||
|
property_id_override,
|
||||||
|
},
|
||||||
|
Self {
|
||||||
|
object_tag_name: object,
|
||||||
|
primitive_name: None,
|
||||||
|
attribute_name: attr_after_object,
|
||||||
|
property_id_override,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply this candidate's `property_id_override` to the resolved
|
||||||
|
/// metadata. The .NET reference does this with `metadata with
|
||||||
|
/// { PropertyId = override }` at `cs:108-110`; the Rust port exposes
|
||||||
|
/// it as a method so resolver impls can stay short.
|
||||||
|
#[must_use]
|
||||||
|
pub fn apply_overrides(&self, metadata: GalaxyTagMetadata) -> GalaxyTagMetadata {
|
||||||
|
let mut out = metadata;
|
||||||
|
if let Some(pid) = self.property_id_override {
|
||||||
|
out.property_id = pid;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `ParsePropertySuffix` (`cs:190-205`). Returns `(base_reference,
|
||||||
|
/// property_id_override)`.
|
||||||
|
///
|
||||||
|
/// Currently only `.property(buffer)` is recognised; the suffix match is
|
||||||
|
/// case-insensitive (`StringComparison.OrdinalIgnoreCase` at `cs:193`).
|
||||||
|
fn parse_property_suffix(tag_reference: &str) -> Result<(&str, Option<i16>), ParseError> {
|
||||||
|
if tag_reference.len() >= PROPERTY_BUFFER_SUFFIX.len() {
|
||||||
|
let suffix_start = tag_reference.len() - PROPERTY_BUFFER_SUFFIX.len();
|
||||||
|
let suffix = &tag_reference[suffix_start..];
|
||||||
|
if suffix.eq_ignore_ascii_case(PROPERTY_BUFFER_SUFFIX) {
|
||||||
|
let base = &tag_reference[..suffix_start];
|
||||||
|
if base.trim().is_empty() {
|
||||||
|
return Err(ParseError::EmptyBaseBeforePropertySuffix);
|
||||||
|
}
|
||||||
|
return Ok((base, Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((tag_reference, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_reference_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
ParsedTagReference::parse_candidates(""),
|
||||||
|
Err(ParseError::Empty)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ParsedTagReference::parse_candidates(" "),
|
||||||
|
Err(ParseError::Empty)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_part_rejected_as_invalid_shape() {
|
||||||
|
assert_eq!(
|
||||||
|
ParsedTagReference::parse_candidates("OnlyOneSegment"),
|
||||||
|
Err(ParseError::InvalidShape)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_part_returns_single_dynamic_candidate() {
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj.Attr").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 1);
|
||||||
|
assert_eq!(candidates[0].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[0].primitive_name, None);
|
||||||
|
assert_eq!(candidates[0].attribute_name, "Attr");
|
||||||
|
assert_eq!(candidates[0].property_id_override, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_part_returns_primitive_then_dynamic_candidates() {
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj.Prim.Attr").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 2);
|
||||||
|
|
||||||
|
// Candidate 1: primitive-attribute lookup.
|
||||||
|
assert_eq!(candidates[0].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[0].primitive_name.as_deref(), Some("Prim"));
|
||||||
|
assert_eq!(candidates[0].attribute_name, "Attr");
|
||||||
|
|
||||||
|
// Candidate 2: dynamic-attribute lookup with dotted attribute name.
|
||||||
|
assert_eq!(candidates[1].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[1].primitive_name, None);
|
||||||
|
assert_eq!(candidates[1].attribute_name, "Prim.Attr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_part_joins_attribute_with_dots() {
|
||||||
|
// Per cs:183-184: `string.Join('.', parts.Skip(2))` and
|
||||||
|
// `string.Join('.', parts.Skip(1))`.
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj.A.B.C").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 2);
|
||||||
|
assert_eq!(candidates[0].primitive_name.as_deref(), Some("A"));
|
||||||
|
assert_eq!(candidates[0].attribute_name, "B.C");
|
||||||
|
assert_eq!(candidates[1].primitive_name, None);
|
||||||
|
assert_eq!(candidates[1].attribute_name, "A.B.C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_around_parts_is_trimmed() {
|
||||||
|
// Mirrors StringSplitOptions.TrimEntries (cs:177).
|
||||||
|
let candidates = ParsedTagReference::parse_candidates(" Obj . Attr ").unwrap();
|
||||||
|
assert_eq!(candidates[0].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[0].attribute_name, "Attr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_segments_dropped() {
|
||||||
|
// Mirrors RemoveEmptyEntries (cs:177). Multiple consecutive dots
|
||||||
|
// yield empty segments which are dropped before the count check.
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj..Attr").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 1);
|
||||||
|
assert_eq!(candidates[0].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[0].attribute_name, "Attr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn property_buffer_suffix_overrides_property_id() {
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj.Attr.property(buffer)").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 1);
|
||||||
|
assert_eq!(candidates[0].object_tag_name, "Obj");
|
||||||
|
assert_eq!(candidates[0].attribute_name, "Attr");
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0].property_id_override,
|
||||||
|
Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn property_buffer_suffix_is_case_insensitive() {
|
||||||
|
let candidates = ParsedTagReference::parse_candidates("Obj.Attr.PROPERTY(BUFFER)").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0].property_id_override,
|
||||||
|
Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn property_buffer_suffix_with_empty_base_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
ParsedTagReference::parse_candidates(".property(buffer)"),
|
||||||
|
Err(ParseError::EmptyBaseBeforePropertySuffix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn property_buffer_suffix_propagates_to_three_part_candidates() {
|
||||||
|
let candidates =
|
||||||
|
ParsedTagReference::parse_candidates("Obj.Prim.Attr.property(buffer)").unwrap();
|
||||||
|
assert_eq!(candidates.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0].property_id_override,
|
||||||
|
Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidates[1].property_id_override,
|
||||||
|
Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_overrides_replaces_property_id_when_set() {
|
||||||
|
use crate::metadata::GalaxyTagMetadata;
|
||||||
|
let candidate = ParsedTagReference {
|
||||||
|
object_tag_name: "Obj".to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
attribute_name: "Attr".to_string(),
|
||||||
|
property_id_override: Some(50),
|
||||||
|
};
|
||||||
|
let metadata = GalaxyTagMetadata {
|
||||||
|
object_tag_name: "Obj".to_string(),
|
||||||
|
attribute_name: "Attr".to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
platform_id: 1,
|
||||||
|
engine_id: 1,
|
||||||
|
object_id: 1,
|
||||||
|
primitive_id: 0,
|
||||||
|
attribute_id: 1,
|
||||||
|
property_id: 10,
|
||||||
|
mx_data_type: 4,
|
||||||
|
is_array: false,
|
||||||
|
security_classification: 0,
|
||||||
|
attribute_source: "dynamic".to_string(),
|
||||||
|
};
|
||||||
|
let updated = candidate.apply_overrides(metadata);
|
||||||
|
assert_eq!(updated.property_id, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_overrides_no_op_when_unset() {
|
||||||
|
use crate::metadata::GalaxyTagMetadata;
|
||||||
|
let candidate = ParsedTagReference {
|
||||||
|
object_tag_name: "Obj".to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
attribute_name: "Attr".to_string(),
|
||||||
|
property_id_override: None,
|
||||||
|
};
|
||||||
|
let metadata = GalaxyTagMetadata {
|
||||||
|
object_tag_name: "Obj".to_string(),
|
||||||
|
attribute_name: "Attr".to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
platform_id: 1,
|
||||||
|
engine_id: 1,
|
||||||
|
object_id: 1,
|
||||||
|
primitive_id: 0,
|
||||||
|
attribute_id: 1,
|
||||||
|
property_id: 10,
|
||||||
|
mx_data_type: 4,
|
||||||
|
is_array: false,
|
||||||
|
security_classification: 0,
|
||||||
|
attribute_source: "dynamic".to_string(),
|
||||||
|
};
|
||||||
|
let updated = candidate.apply_overrides(metadata);
|
||||||
|
assert_eq!(updated.property_id, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! `Resolver` async trait — pluggable backend for tag-name lookup.
|
||||||
|
//!
|
||||||
|
//! The .NET reference's `GalaxyRepositoryTagResolver` is a single concrete
|
||||||
|
//! class with a SQL backend. The Rust port splits the surface into a trait
|
||||||
|
//! plus one provided implementation (`tiberius`-backed, gated by the
|
||||||
|
//! `galaxy-resolver` Cargo feature) so consumers can plug in any other
|
||||||
|
//! backing — an in-memory cache for tests, a JSON snapshot from
|
||||||
|
//! `wwtools/grdb/`, a future REST client, etc.
|
||||||
|
//!
|
||||||
|
//! Both [`Resolver::resolve`] and [`Resolver::browse`] mirror the .NET
|
||||||
|
//! `ResolveAsync` and `BrowseAsync` signatures (`GalaxyRepositoryTagResolver.cs:88,117`).
|
||||||
|
|
||||||
|
use crate::metadata::GalaxyTagMetadata;
|
||||||
|
use crate::parser::ParseError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors raised by [`Resolver`] implementations. Mirrors the
|
||||||
|
/// `InvalidOperationException` raised by .NET when a tag is not found
|
||||||
|
/// (`GalaxyRepositoryTagResolver.cs:114`) plus parser failures from
|
||||||
|
/// [`crate::parser::ParsedTagReference::parse_candidates`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ResolverError {
|
||||||
|
/// The tag-reference string itself failed to parse.
|
||||||
|
#[error("invalid tag reference: {0}")]
|
||||||
|
InvalidTagReference(#[from] ParseError),
|
||||||
|
|
||||||
|
/// No metadata row matched any of the parsed candidates. Mirrors
|
||||||
|
/// `cs:114`.
|
||||||
|
#[error(
|
||||||
|
"Galaxy tag reference '{tag_reference}' was not found in the deployed repository metadata"
|
||||||
|
)]
|
||||||
|
NotFound { tag_reference: String },
|
||||||
|
|
||||||
|
/// Backend-specific failure (SQL connect / query error, transport
|
||||||
|
/// error, etc.). Carries an opaque message so backends don't have to
|
||||||
|
/// expose their concrete error types in the trait.
|
||||||
|
#[error("Galaxy resolver backend error: {message}")]
|
||||||
|
Backend { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluggable async tag-name → metadata resolver.
|
||||||
|
///
|
||||||
|
/// Implementations should be thread-safe (`Send + Sync`) so the Rust
|
||||||
|
/// `NmxClient` can hold one in an `Arc<dyn Resolver>` shared across
|
||||||
|
/// multiple write/advise calls.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Resolver: Send + Sync {
|
||||||
|
/// Resolve a single tag reference (`Object.Attribute`,
|
||||||
|
/// `Object.Primitive.Attribute`, optionally `.property(buffer)`-
|
||||||
|
/// suffixed) to its metadata. Mirrors `ResolveAsync`
|
||||||
|
/// (`GalaxyRepositoryTagResolver.cs:88-115`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ResolverError::InvalidTagReference`], [`ResolverError::NotFound`],
|
||||||
|
/// or [`ResolverError::Backend`].
|
||||||
|
async fn resolve(&self, tag_reference: &str) -> Result<GalaxyTagMetadata, ResolverError>;
|
||||||
|
|
||||||
|
/// Browse multiple matching attributes. The default implementation
|
||||||
|
/// returns [`ResolverError::Backend`] with `"browse not implemented"`
|
||||||
|
/// — backends that support browsing override this. Mirrors
|
||||||
|
/// `BrowseAsync` (`cs:117-147`).
|
||||||
|
///
|
||||||
|
/// `object_tag_like` and `attribute_like` use SQL `LIKE` semantics
|
||||||
|
/// (`%` for any-sequence, `_` for any-single-char).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As for [`Self::resolve`].
|
||||||
|
async fn browse(
|
||||||
|
&self,
|
||||||
|
object_tag_like: &str,
|
||||||
|
attribute_like: &str,
|
||||||
|
max_rows: usize,
|
||||||
|
) -> Result<Vec<GalaxyTagMetadata>, ResolverError> {
|
||||||
|
let _ = (object_tag_like, attribute_like, max_rows);
|
||||||
|
Err(ResolverError::Backend {
|
||||||
|
message: "browse not implemented for this resolver".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Tiny in-memory resolver for tests. Demonstrates that the trait is
|
||||||
|
/// implementable without any SQL machinery, validating the
|
||||||
|
/// "pluggable backend" design.
|
||||||
|
struct InMemoryResolver {
|
||||||
|
rows: HashMap<String, GalaxyTagMetadata>,
|
||||||
|
browse_calls: Mutex<Vec<(String, String, usize)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryResolver {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut rows = HashMap::new();
|
||||||
|
rows.insert(
|
||||||
|
"TestObject.TestAttr".to_string(),
|
||||||
|
GalaxyTagMetadata {
|
||||||
|
object_tag_name: "TestObject".to_string(),
|
||||||
|
attribute_name: "TestAttr".to_string(),
|
||||||
|
primitive_name: None,
|
||||||
|
platform_id: 1,
|
||||||
|
engine_id: 2,
|
||||||
|
object_id: 3,
|
||||||
|
primitive_id: 0,
|
||||||
|
attribute_id: 7,
|
||||||
|
property_id: 10,
|
||||||
|
mx_data_type: 4,
|
||||||
|
is_array: false,
|
||||||
|
security_classification: 0,
|
||||||
|
attribute_source: "dynamic".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
rows,
|
||||||
|
browse_calls: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Resolver for InMemoryResolver {
|
||||||
|
async fn resolve(&self, tag_reference: &str) -> Result<GalaxyTagMetadata, ResolverError> {
|
||||||
|
self.rows
|
||||||
|
.get(tag_reference)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ResolverError::NotFound {
|
||||||
|
tag_reference: tag_reference.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn browse(
|
||||||
|
&self,
|
||||||
|
object_tag_like: &str,
|
||||||
|
attribute_like: &str,
|
||||||
|
max_rows: usize,
|
||||||
|
) -> Result<Vec<GalaxyTagMetadata>, ResolverError> {
|
||||||
|
self.browse_calls.lock().unwrap().push((
|
||||||
|
object_tag_like.to_string(),
|
||||||
|
attribute_like.to_string(),
|
||||||
|
max_rows,
|
||||||
|
));
|
||||||
|
Ok(self.rows.values().take(max_rows).cloned().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_round_trip() {
|
||||||
|
let r = InMemoryResolver::new();
|
||||||
|
let m = r.resolve("TestObject.TestAttr").await.unwrap();
|
||||||
|
assert_eq!(m.object_tag_name, "TestObject");
|
||||||
|
assert_eq!(m.attribute_name, "TestAttr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_not_found() {
|
||||||
|
let r = InMemoryResolver::new();
|
||||||
|
let err = r.resolve("DoesNotExist.X").await.unwrap_err();
|
||||||
|
assert!(matches!(err, ResolverError::NotFound { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn default_browse_returns_backend_error() {
|
||||||
|
// Concrete impl that doesn't override browse picks up the default.
|
||||||
|
struct NoBrowse;
|
||||||
|
#[async_trait]
|
||||||
|
impl Resolver for NoBrowse {
|
||||||
|
async fn resolve(&self, _: &str) -> Result<GalaxyTagMetadata, ResolverError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let err = NoBrowse.browse("%", "%", 10).await.unwrap_err();
|
||||||
|
match err {
|
||||||
|
ResolverError::Backend { message } => {
|
||||||
|
assert!(message.contains("browse not implemented"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Backend, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_error_converts_into_resolver_error() {
|
||||||
|
// ResolverError::from(ParseError::Empty) via #[from].
|
||||||
|
let e: ResolverError = ParseError::Empty.into();
|
||||||
|
assert!(matches!(e, ResolverError::InvalidTagReference(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Parser for the SQL `roles` blob attached to `dbo.user_profile`.
|
||||||
|
//!
|
||||||
|
//! Direct port of `ParseRoleBlob` at
|
||||||
|
//! `src/MxNativeClient/GalaxyRepositoryUserResolver.cs:87-133`.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! The Galaxy DB stores the user-roles set as a `varbinary` column whose
|
||||||
|
//! `CONVERT(nvarchar(max), roles)` projection produces a hex-string of the
|
||||||
|
//! raw bytes (with `0x` prefix). The bytes themselves are a packed
|
||||||
|
//! sequence of UTF-16LE role names separated by `0x00 0x00` terminators
|
||||||
|
//! (the UTF-16 NUL character) followed by another `0x00 0x00` (the role-list
|
||||||
|
//! separator).
|
||||||
|
//!
|
||||||
|
//! There is no length prefix and no count; the .NET reference walks the
|
||||||
|
//! buffer with a sliding window, emitting each printable-ASCII UTF-16LE
|
||||||
|
//! string of length ≥ 2 that ends in a double-null. Sub-windows that
|
||||||
|
//! produce a non-printable code unit (anything outside `0x20..=0x7E`) are
|
||||||
|
//! discarded — this naturally skips garbage between roles.
|
||||||
|
//!
|
||||||
|
//! Roles are deduplicated case-insensitively (`StringComparer.OrdinalIgnoreCase`
|
||||||
|
//! at `cs:124`).
|
||||||
|
//!
|
||||||
|
//! ## Why this is a separate module
|
||||||
|
//!
|
||||||
|
//! The .NET reference inlines the parser as a `private static`. The Rust
|
||||||
|
//! port lifts it because (a) it has interesting failure modes worth
|
||||||
|
//! testing in isolation and (b) future SQL backends (the planned
|
||||||
|
//! `tiberius`-gated `UserResolver` impl, snapshot-replay test harnesses)
|
||||||
|
//! all need to call it the same way.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
/// Parse a hex-encoded role blob. Returns the deduplicated list of role
|
||||||
|
/// names in discovery order. Mirrors `ParseRoleBlob` (`cs:87-133`).
|
||||||
|
///
|
||||||
|
/// Behavior:
|
||||||
|
///
|
||||||
|
/// - Input that doesn't start with `0x`/`0X` (case-insensitive per
|
||||||
|
/// `StringComparison.OrdinalIgnoreCase` at `cs:89`) returns `[]`.
|
||||||
|
/// - Input shorter than `0x` plus 8 hex chars (the smallest payload that
|
||||||
|
/// could encode a 2-char role + terminator) returns `[]`.
|
||||||
|
/// - Hex-decoding failures return `[]` (the .NET reference would throw
|
||||||
|
/// `FormatException` from `Convert.FromHexString`; the Rust port matches
|
||||||
|
/// the .NET behavior of yielding an empty list because every caller
|
||||||
|
/// expects "unknown" to mean "no roles" — there's no way to distinguish
|
||||||
|
/// "user has no roles" from "user has malformed roles" upstream).
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_role_blob(roles_text: &str) -> Vec<String> {
|
||||||
|
if !roles_text.len().checked_sub(2).is_some_and(|_| {
|
||||||
|
roles_text
|
||||||
|
.get(..2)
|
||||||
|
.is_some_and(|p| p.eq_ignore_ascii_case("0x"))
|
||||||
|
}) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hex = &roles_text[2..];
|
||||||
|
let bytes = match hex_decode(hex) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut roles: Vec<String> = Vec::new();
|
||||||
|
let mut offset: usize = 0;
|
||||||
|
while offset + 3 < bytes.len() {
|
||||||
|
// Scan a candidate role starting at `offset`. Mirrors the inner
|
||||||
|
// `while (cursor + 1 < bytes.Length)` loop at cs:100-116. `cursor`
|
||||||
|
// walks in 2-byte steps reading UTF-16LE code units; `chars`
|
||||||
|
// accumulates ASCII chars; non-printable chars discard the
|
||||||
|
// candidate entirely.
|
||||||
|
let mut chars: Vec<char> = Vec::new();
|
||||||
|
let mut cursor = offset;
|
||||||
|
loop {
|
||||||
|
if cursor + 1 >= bytes.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// (bytes[cursor] | (bytes[cursor+1] << 8)) — UTF-16LE u16.
|
||||||
|
let code_unit = u16::from(bytes[cursor]) | (u16::from(bytes[cursor + 1]) << 8);
|
||||||
|
if code_unit == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !(0x20..=0x7e).contains(&code_unit) {
|
||||||
|
chars.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Cast is safe: range above guarantees `code_unit` is a printable
|
||||||
|
// ASCII byte (0x20..=0x7e), all of which are valid `char` scalars.
|
||||||
|
chars.push(char::from_u32(u32::from(code_unit)).unwrap_or('\0'));
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminator check (cs:118-121): role must be ≥2 chars, the cursor
|
||||||
|
// must still be in-bounds for the trailing 0x00 0x00 pair, and
|
||||||
|
// those two bytes must both be 0. The inner loop guarantees this
|
||||||
|
// when it broke on `code_unit == 0`, but the .NET reference
|
||||||
|
// re-asserts it as a defense against malformed input where the
|
||||||
|
// inner loop ran off the end without seeing a null.
|
||||||
|
let role_ok = chars.len() >= 2
|
||||||
|
&& cursor + 1 < bytes.len()
|
||||||
|
&& bytes[cursor] == 0
|
||||||
|
&& bytes[cursor + 1] == 0;
|
||||||
|
if !role_ok {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let role: String = chars.iter().collect();
|
||||||
|
// Deduplicate case-insensitively (`StringComparer.OrdinalIgnoreCase`
|
||||||
|
// at cs:124).
|
||||||
|
if !roles.iter().any(|r| r.eq_ignore_ascii_case(&role)) {
|
||||||
|
roles.push(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump the outer offset past the matched role + the terminator
|
||||||
|
// pair. The .NET reference does `offset = cursor; offset++`
|
||||||
|
// (the `++` is the `for`-loop increment) — net effect: the next
|
||||||
|
// iteration starts at `cursor + 1`, which is the second byte of
|
||||||
|
// the terminator. This deliberately re-scans starting from the
|
||||||
|
// "wrong" alignment so the parser tolerates packed bytes that
|
||||||
|
// happen to look like a partial role on the offset-by-one slot.
|
||||||
|
offset = cursor + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
roles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex-decode `hex` (no `0x` prefix). Returns `None` on odd length, on
|
||||||
|
/// non-hex characters, or on overflow. Mirrors `Convert.FromHexString`
|
||||||
|
/// at `cs:94`. Pure-Rust to avoid pulling `hex` as a dep.
|
||||||
|
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||||
|
if hex.len() % 2 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = hex.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(hex.len() / 2);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let hi = nibble(bytes[i])?;
|
||||||
|
let lo = nibble(bytes[i + 1])?;
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nibble(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Encode a sequence of role strings + a trailing 0x00 0x00 separator
|
||||||
|
/// into the on-wire byte format, then format as a `0x`-prefixed hex
|
||||||
|
/// string. Used to build test inputs.
|
||||||
|
fn encode_roles(roles: &[&str]) -> String {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
for r in roles {
|
||||||
|
for c in r.chars() {
|
||||||
|
let cu = c as u32 as u16;
|
||||||
|
out.push((cu & 0xFF) as u8);
|
||||||
|
out.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
out.push(0);
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
// .NET appears to require the trailing 0x00 0x00 after the last
|
||||||
|
// role to satisfy the cursor+1<bytes.Length check.
|
||||||
|
out.push(0);
|
||||||
|
out.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &out {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_string_returns_empty_list() {
|
||||||
|
assert_eq!(parse_role_blob(""), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_0x_prefix_returns_empty_list() {
|
||||||
|
// Even a syntactically-valid hex string without 0x is treated as
|
||||||
|
// garbage per cs:89.
|
||||||
|
assert_eq!(parse_role_blob("DEADBEEF"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn just_0x_prefix_returns_empty_list() {
|
||||||
|
assert_eq!(parse_role_blob("0x"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upper_and_lower_case_0x_prefix_both_accepted() {
|
||||||
|
// .NET uses StringComparison.OrdinalIgnoreCase at cs:89.
|
||||||
|
let lower = encode_roles(&["Op"]);
|
||||||
|
let upper = lower.replacen("0x", "0X", 1);
|
||||||
|
assert_eq!(parse_role_blob(&lower), parse_role_blob(&upper));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_single_role() {
|
||||||
|
let input = encode_roles(&["Operator"]);
|
||||||
|
assert_eq!(parse_role_blob(&input), vec!["Operator".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_two_distinct_roles() {
|
||||||
|
let input = encode_roles(&["Operator", "Owner"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert!(parsed.contains(&"Operator".to_string()));
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_case_insensitively() {
|
||||||
|
// Both "Operator" and "operator" appear in the buffer; only the
|
||||||
|
// first wins. Mirrors StringComparer.OrdinalIgnoreCase at cs:124.
|
||||||
|
let input = encode_roles(&["Operator", "operator"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert_eq!(parsed, vec!["Operator".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_single_char_candidates() {
|
||||||
|
// chars.Count < 2 fails the role_ok check at cs:118; single-char
|
||||||
|
// role "A" is dropped.
|
||||||
|
let input = encode_roles(&["A", "Owner"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert_eq!(parsed, vec!["Owner".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_role_containing_non_printable() {
|
||||||
|
// Build bytes manually: "Op\x01" + 0x00 0x00 + "Owner" + 0x00 0x00.
|
||||||
|
// The 0x01 in the first role (a control character) trips the
|
||||||
|
// chars.Clear() branch at cs:108-112; the parser then continues
|
||||||
|
// scanning offset+1 forward and eventually finds "Owner".
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Op".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
// \x01 (non-printable u16 = 0x0001).
|
||||||
|
bytes.push(0x01);
|
||||||
|
bytes.push(0x00);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
let parsed = parse_role_blob(&hex);
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
assert!(!parsed.iter().any(|r| r.contains("Op")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_hex_returns_empty_list() {
|
||||||
|
// Odd-length hex.
|
||||||
|
assert_eq!(parse_role_blob("0xABC"), Vec::<String>::new());
|
||||||
|
// Non-hex char.
|
||||||
|
assert_eq!(parse_role_blob("0xAGG"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_decode_helper_round_trip() {
|
||||||
|
assert_eq!(hex_decode("4D454F57"), Some(vec![0x4D, 0x45, 0x4F, 0x57]));
|
||||||
|
assert_eq!(hex_decode("deadbeef"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF]));
|
||||||
|
assert_eq!(hex_decode("DeAdBeEf"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF]));
|
||||||
|
assert_eq!(hex_decode(""), Some(Vec::new()));
|
||||||
|
assert_eq!(hex_decode("ABC"), None); // odd length
|
||||||
|
assert_eq!(hex_decode("ZZ"), None); // non-hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_blob_with_garbage_between_roles_still_parses() {
|
||||||
|
// 4 random bytes of garbage between two valid roles. The parser's
|
||||||
|
// sliding window should skip the garbage and pick up the second role.
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Operator".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
// Garbage (odd number of bytes — still gets scanned but doesn't
|
||||||
|
// produce valid u16 chars in a way that meets the role_ok check).
|
||||||
|
bytes.extend_from_slice(&[0xFF, 0x01, 0x80, 0xAB]);
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
let parsed = parse_role_blob(&hex);
|
||||||
|
assert!(parsed.contains(&"Operator".to_string()));
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
// The `concatcp!` macro below uses fixed-size byte indexing in a `const fn`
|
||||||
|
// where lengths are statically known. `.get(n)?` is not available in `const`
|
||||||
|
// contexts in stable Rust 1.85, so the indexing is the only path. The
|
||||||
|
// resulting `&'static str` constants are evaluated at compile time, so
|
||||||
|
// any out-of-bounds would surface as a compile error rather than a runtime
|
||||||
|
// panic.
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
//! SQL strings used by the future tiberius-backed resolver.
|
||||||
|
//!
|
||||||
|
//! Direct port of the two `private const string` blocks at
|
||||||
|
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:208-432`. Kept as
|
||||||
|
//! `pub const &str` so any future SQL backend (the planned
|
||||||
|
//! `tiberius`-gated implementation, an alternative dapper-style backend,
|
||||||
|
//! or a snapshot-replay test harness) can grab the canonical query without
|
||||||
|
//! re-typing it.
|
||||||
|
//!
|
||||||
|
//! Both queries assume a Galaxy DB that exposes the tables verified in
|
||||||
|
//! `wwtools/grdb/`:
|
||||||
|
//!
|
||||||
|
//! - `dbo.gobject` / `dbo.instance` — object instances + their MX ids.
|
||||||
|
//! - `dbo.package` (recursive `derived_from_package_id` for inheritance).
|
||||||
|
//! - `dbo.dynamic_attribute` — dynamic attributes attached to a package.
|
||||||
|
//! - `dbo.attribute_definition` / `dbo.primitive_instance` — primitive-
|
||||||
|
//! bound attributes.
|
||||||
|
//!
|
||||||
|
//! ## Resolver input contract
|
||||||
|
//!
|
||||||
|
//! Both queries take `tag_name`-form input only (e.g. `DelmiaReceiver_001`),
|
||||||
|
//! NOT `contained_name`-form (`TestMachine_001.DelmiaReceiver`). See
|
||||||
|
//! `wwtools/grdb/README.md` for the schema asymmetry. The Rust resolver
|
||||||
|
//! enforces this at the parser layer ([`crate::parser::ParsedTagReference`])
|
||||||
|
//! before dispatching to SQL.
|
||||||
|
//!
|
||||||
|
//! ## Result columns (in order)
|
||||||
|
//!
|
||||||
|
//! Both queries return the same 13-column shape — keep this list aligned
|
||||||
|
//! with [`crate::metadata::GalaxyTagMetadata`] field order:
|
||||||
|
//!
|
||||||
|
//! 0. `object_tag_name` `nvarchar`
|
||||||
|
//! 1. `attribute_name` `nvarchar`
|
||||||
|
//! 2. `primitive_name` `nvarchar` or `NULL`
|
||||||
|
//! 3. `mx_platform_id` `smallint` → `u16`
|
||||||
|
//! 4. `mx_engine_id` `smallint` → `u16`
|
||||||
|
//! 5. `mx_object_id` `smallint` → `u16`
|
||||||
|
//! 6. `mx_primitive_id` `smallint` → `i16`
|
||||||
|
//! 7. `mx_attribute_id` `smallint` → `i16`
|
||||||
|
//! 8. `property_id` `int` → `i16` (checked-cast)
|
||||||
|
//! 9. `mx_data_type` `smallint` → `i16`
|
||||||
|
//! 10. `is_array` `bit` → `bool`
|
||||||
|
//! 11. `security_classification` `smallint` → `i16`
|
||||||
|
//! 12. `attribute_source` `nvarchar` ("dynamic" or "primitive")
|
||||||
|
//!
|
||||||
|
//! ## Recursive CTE depth
|
||||||
|
//!
|
||||||
|
//! Both queries cap package-derivation depth at 10 (`AND dpc.depth < 10`).
|
||||||
|
//! Galaxy package inheritance chains are typically short (3-5 levels);
|
||||||
|
//! 10 is a defensive cap against malformed package_id loops. If a real
|
||||||
|
//! deployment legitimately exceeds this, the cap should be raised here
|
||||||
|
//! and tracked in `design/70-risks-and-open-questions.md`.
|
||||||
|
|
||||||
|
/// Single-row resolver query — `Resolve(tag_reference)`.
|
||||||
|
///
|
||||||
|
/// Parameters (in order):
|
||||||
|
/// - `@objectTagName` (`nvarchar`) — the leading `Object` segment.
|
||||||
|
/// - `@attributeName` (`nvarchar`) — the trailing `Attribute` segment, or
|
||||||
|
/// `Primitive.Attribute` for the dotted-attribute candidate.
|
||||||
|
/// - `@primitiveName` (`nvarchar` or `NULL`) — the middle segment when
|
||||||
|
/// the input was `Object.Primitive.Attribute`; `NULL` for dynamic-only
|
||||||
|
/// candidates.
|
||||||
|
///
|
||||||
|
/// Direct port of `GalaxyRepositoryTagResolver.cs:208-314` — the `;WITH`
|
||||||
|
/// `deployed_package_chain`, `ranked_dynamic`, `primitive_attributes`
|
||||||
|
/// blocks plus the final UNION + `ORDER BY` that prefers `dynamic` rows
|
||||||
|
/// over `primitive` rows when both match.
|
||||||
|
pub const RESOLVE_SQL: &str = r#";WITH deployed_package_chain AS (
|
||||||
|
SELECT
|
||||||
|
g.gobject_id,
|
||||||
|
p.package_id,
|
||||||
|
p.derived_from_package_id,
|
||||||
|
0 AS depth
|
||||||
|
FROM dbo.gobject g
|
||||||
|
INNER JOIN dbo.package p
|
||||||
|
ON p.package_id = g.deployed_package_id
|
||||||
|
WHERE g.is_template = 0
|
||||||
|
AND g.deployed_package_id <> 0
|
||||||
|
AND g.tag_name = @objectTagName
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id,
|
||||||
|
p.package_id,
|
||||||
|
p.derived_from_package_id,
|
||||||
|
dpc.depth + 1
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dbo.package p
|
||||||
|
ON p.package_id = dpc.derived_from_package_id
|
||||||
|
WHERE dpc.derived_from_package_id <> 0
|
||||||
|
AND dpc.depth < 10
|
||||||
|
),
|
||||||
|
ranked_dynamic AS (
|
||||||
|
SELECT
|
||||||
|
g.tag_name AS object_tag_name,
|
||||||
|
da.attribute_name,
|
||||||
|
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||||
|
i.mx_platform_id,
|
||||||
|
i.mx_engine_id,
|
||||||
|
i.mx_object_id,
|
||||||
|
da.mx_primitive_id,
|
||||||
|
da.mx_attribute_id,
|
||||||
|
CAST(10 AS int) AS property_id,
|
||||||
|
da.mx_data_type,
|
||||||
|
da.is_array,
|
||||||
|
da.security_classification,
|
||||||
|
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||||
|
ORDER BY dpc.depth
|
||||||
|
) AS rn
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dbo.dynamic_attribute da
|
||||||
|
ON da.package_id = dpc.package_id
|
||||||
|
INNER JOIN dbo.gobject g
|
||||||
|
ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN dbo.instance i
|
||||||
|
ON i.gobject_id = g.gobject_id
|
||||||
|
WHERE da.attribute_name = @attributeName
|
||||||
|
AND @primitiveName IS NULL
|
||||||
|
),
|
||||||
|
primitive_attributes AS (
|
||||||
|
SELECT
|
||||||
|
g.tag_name AS object_tag_name,
|
||||||
|
ad.attribute_name,
|
||||||
|
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||||
|
i.mx_platform_id,
|
||||||
|
i.mx_engine_id,
|
||||||
|
i.mx_object_id,
|
||||||
|
pi.mx_primitive_id,
|
||||||
|
ad.mx_attribute_id,
|
||||||
|
CAST(10 AS int) AS property_id,
|
||||||
|
ad.mx_data_type,
|
||||||
|
ad.is_array,
|
||||||
|
ad.security_classification,
|
||||||
|
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||||
|
1 AS rn
|
||||||
|
FROM dbo.gobject g
|
||||||
|
INNER JOIN dbo.instance i
|
||||||
|
ON i.gobject_id = g.gobject_id
|
||||||
|
INNER JOIN dbo.primitive_instance pi
|
||||||
|
ON pi.gobject_id = g.gobject_id
|
||||||
|
AND pi.package_id = g.deployed_package_id
|
||||||
|
AND pi.property_bitmask & 0x10 <> 0x10
|
||||||
|
INNER JOIN dbo.attribute_definition ad
|
||||||
|
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||||
|
WHERE g.tag_name = @objectTagName
|
||||||
|
AND ad.attribute_name = @attributeName
|
||||||
|
AND (
|
||||||
|
(@primitiveName IS NULL AND pi.primitive_name = N'')
|
||||||
|
OR (@primitiveName IS NOT NULL AND pi.primitive_name = @primitiveName)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT TOP (1)
|
||||||
|
object_tag_name,
|
||||||
|
attribute_name,
|
||||||
|
primitive_name,
|
||||||
|
mx_platform_id,
|
||||||
|
mx_engine_id,
|
||||||
|
mx_object_id,
|
||||||
|
mx_primitive_id,
|
||||||
|
mx_attribute_id,
|
||||||
|
property_id,
|
||||||
|
mx_data_type,
|
||||||
|
is_array,
|
||||||
|
security_classification,
|
||||||
|
attribute_source
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM primitive_attributes
|
||||||
|
) resolved
|
||||||
|
ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User resolver SQL — port of GalaxyRepositoryUserResolver.cs:135-148.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Common `SELECT TOP (1) ...` user-profile projection — `cs:135-144`.
|
||||||
|
/// Used as the base for both [`USER_BY_GUID_SQL`] and [`USER_BY_NAME_SQL`].
|
||||||
|
///
|
||||||
|
/// Result columns (in order):
|
||||||
|
///
|
||||||
|
/// 0. `user_profile_id` `int`
|
||||||
|
/// 1. `user_profile_name` `nvarchar`
|
||||||
|
/// 2. `user_guid` `uniqueidentifier`
|
||||||
|
/// 3. `default_security_group` `nvarchar`
|
||||||
|
/// 4. `intouch_access_level` `int` (NULL-able)
|
||||||
|
/// 5. `roles_text` `nvarchar(max)` — `CONVERT(...)` of the
|
||||||
|
/// `roles` `varbinary` column. Decode through
|
||||||
|
/// [`crate::role_blob::parse_role_blob`].
|
||||||
|
pub const USER_SELECT_SQL: &str = r#"SELECT TOP (1)
|
||||||
|
user_profile_id,
|
||||||
|
user_profile_name,
|
||||||
|
user_guid,
|
||||||
|
default_security_group,
|
||||||
|
intouch_access_level,
|
||||||
|
CONVERT(nvarchar(max), roles) AS roles_text
|
||||||
|
FROM dbo.user_profile"#;
|
||||||
|
|
||||||
|
/// `Resolve user_profile by user_guid` — port of `cs:146`.
|
||||||
|
///
|
||||||
|
/// Parameter: `@userGuid` (`uniqueidentifier`).
|
||||||
|
pub const USER_BY_GUID_SQL: &str = concatcp!(
|
||||||
|
USER_SELECT_SQL,
|
||||||
|
"\nWHERE user_guid = @userGuid\nORDER BY user_profile_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// `Resolve user_profile by user_profile_name` — port of `cs:148`.
|
||||||
|
///
|
||||||
|
/// Parameter: `@userName` (`nvarchar`).
|
||||||
|
pub const USER_BY_NAME_SQL: &str = concatcp!(
|
||||||
|
USER_SELECT_SQL,
|
||||||
|
"\nWHERE user_profile_name = @userName\nORDER BY user_profile_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Tiny `concat!`-equivalent for `&'static str` constants, since `concat!`
|
||||||
|
/// only works with literals. Two-arg specialisation; keeps `USER_BY_GUID_SQL`
|
||||||
|
/// and `USER_BY_NAME_SQL` evaluable at compile time without dragging in
|
||||||
|
/// `const_format` as a dep.
|
||||||
|
macro_rules! concatcp {
|
||||||
|
($a:expr, $b:expr) => {{
|
||||||
|
const A: &str = $a;
|
||||||
|
const B: &str = $b;
|
||||||
|
const N: usize = A.len() + B.len();
|
||||||
|
const fn build() -> [u8; N] {
|
||||||
|
let mut out = [0u8; N];
|
||||||
|
let a = A.as_bytes();
|
||||||
|
let b = B.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < a.len() {
|
||||||
|
out[i] = a[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let mut j = 0;
|
||||||
|
while j < b.len() {
|
||||||
|
out[a.len() + j] = b[j];
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
// SAFETY: A and B are valid UTF-8 (both are `&'static str`); the
|
||||||
|
// concatenation of two valid UTF-8 byte sequences is valid UTF-8.
|
||||||
|
const COMBINED: &[u8; N] = &build();
|
||||||
|
match core::str::from_utf8(COMBINED) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => "",
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
pub(crate) use concatcp;
|
||||||
|
|
||||||
|
/// Multi-row browse query — `Browse(object_tag_like, attribute_like, max_rows)`.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - `@objectTagLike` (`nvarchar`) — `LIKE` pattern for `g.tag_name`.
|
||||||
|
/// - `@attributeLike` (`nvarchar`) — `LIKE` pattern for `attribute_name`.
|
||||||
|
/// - `@maxRows` (`int`) — `TOP (...)` cap. The .NET reference clamps to
|
||||||
|
/// 1000 (`cs:137`); the Rust resolver should do the same before binding
|
||||||
|
/// the parameter.
|
||||||
|
///
|
||||||
|
/// Direct port of `GalaxyRepositoryTagResolver.cs:316-432`. Same column
|
||||||
|
/// ordering as [`RESOLVE_SQL`].
|
||||||
|
pub const BROWSE_SQL: &str = r#";WITH deployed_objects AS (
|
||||||
|
SELECT
|
||||||
|
g.gobject_id,
|
||||||
|
g.tag_name,
|
||||||
|
g.deployed_package_id,
|
||||||
|
i.mx_platform_id,
|
||||||
|
i.mx_engine_id,
|
||||||
|
i.mx_object_id
|
||||||
|
FROM dbo.gobject g
|
||||||
|
INNER JOIN dbo.instance i
|
||||||
|
ON i.gobject_id = g.gobject_id
|
||||||
|
WHERE g.is_template = 0
|
||||||
|
AND g.deployed_package_id <> 0
|
||||||
|
AND g.tag_name LIKE @objectTagLike
|
||||||
|
),
|
||||||
|
deployed_package_chain AS (
|
||||||
|
SELECT
|
||||||
|
d.gobject_id,
|
||||||
|
d.tag_name,
|
||||||
|
d.mx_platform_id,
|
||||||
|
d.mx_engine_id,
|
||||||
|
d.mx_object_id,
|
||||||
|
p.package_id,
|
||||||
|
p.derived_from_package_id,
|
||||||
|
0 AS depth
|
||||||
|
FROM deployed_objects d
|
||||||
|
INNER JOIN dbo.package p
|
||||||
|
ON p.package_id = d.deployed_package_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id,
|
||||||
|
dpc.tag_name,
|
||||||
|
dpc.mx_platform_id,
|
||||||
|
dpc.mx_engine_id,
|
||||||
|
dpc.mx_object_id,
|
||||||
|
p.package_id,
|
||||||
|
p.derived_from_package_id,
|
||||||
|
dpc.depth + 1
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dbo.package p
|
||||||
|
ON p.package_id = dpc.derived_from_package_id
|
||||||
|
WHERE dpc.derived_from_package_id <> 0
|
||||||
|
AND dpc.depth < 10
|
||||||
|
),
|
||||||
|
ranked_dynamic AS (
|
||||||
|
SELECT
|
||||||
|
dpc.tag_name AS object_tag_name,
|
||||||
|
da.attribute_name,
|
||||||
|
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||||
|
dpc.mx_platform_id,
|
||||||
|
dpc.mx_engine_id,
|
||||||
|
dpc.mx_object_id,
|
||||||
|
da.mx_primitive_id,
|
||||||
|
da.mx_attribute_id,
|
||||||
|
CAST(10 AS int) AS property_id,
|
||||||
|
da.mx_data_type,
|
||||||
|
da.is_array,
|
||||||
|
da.security_classification,
|
||||||
|
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||||
|
ORDER BY dpc.depth
|
||||||
|
) AS rn
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dbo.dynamic_attribute da
|
||||||
|
ON da.package_id = dpc.package_id
|
||||||
|
WHERE da.attribute_name LIKE @attributeLike
|
||||||
|
),
|
||||||
|
primitive_attributes AS (
|
||||||
|
SELECT
|
||||||
|
d.tag_name AS object_tag_name,
|
||||||
|
ad.attribute_name,
|
||||||
|
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||||
|
d.mx_platform_id,
|
||||||
|
d.mx_engine_id,
|
||||||
|
d.mx_object_id,
|
||||||
|
pi.mx_primitive_id,
|
||||||
|
ad.mx_attribute_id,
|
||||||
|
CAST(10 AS int) AS property_id,
|
||||||
|
ad.mx_data_type,
|
||||||
|
ad.is_array,
|
||||||
|
ad.security_classification,
|
||||||
|
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||||
|
1 AS rn
|
||||||
|
FROM deployed_objects d
|
||||||
|
INNER JOIN dbo.gobject g
|
||||||
|
ON g.gobject_id = d.gobject_id
|
||||||
|
INNER JOIN dbo.primitive_instance pi
|
||||||
|
ON pi.gobject_id = g.gobject_id
|
||||||
|
AND pi.package_id = g.deployed_package_id
|
||||||
|
AND pi.property_bitmask & 0x10 <> 0x10
|
||||||
|
INNER JOIN dbo.attribute_definition ad
|
||||||
|
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||||
|
WHERE ad.attribute_name LIKE @attributeLike
|
||||||
|
)
|
||||||
|
SELECT TOP (@maxRows)
|
||||||
|
object_tag_name,
|
||||||
|
attribute_name,
|
||||||
|
primitive_name,
|
||||||
|
mx_platform_id,
|
||||||
|
mx_engine_id,
|
||||||
|
mx_object_id,
|
||||||
|
mx_primitive_id,
|
||||||
|
mx_attribute_id,
|
||||||
|
property_id,
|
||||||
|
mx_data_type,
|
||||||
|
is_array,
|
||||||
|
security_classification,
|
||||||
|
attribute_source
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM primitive_attributes
|
||||||
|
) resolved
|
||||||
|
ORDER BY object_tag_name, primitive_name, attribute_name
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_sql_references_three_named_parameters() {
|
||||||
|
// Smoke check: the three @-parameters the .NET command binds at
|
||||||
|
// cs:100-102 must appear by name in the query body.
|
||||||
|
assert!(RESOLVE_SQL.contains("@objectTagName"));
|
||||||
|
assert!(RESOLVE_SQL.contains("@attributeName"));
|
||||||
|
assert!(RESOLVE_SQL.contains("@primitiveName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_sql_references_three_named_parameters() {
|
||||||
|
assert!(BROWSE_SQL.contains("@objectTagLike"));
|
||||||
|
assert!(BROWSE_SQL.contains("@attributeLike"));
|
||||||
|
assert!(BROWSE_SQL.contains("@maxRows"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_sql_caps_recursion_at_depth_10() {
|
||||||
|
// Defensive cap — see module doc.
|
||||||
|
assert!(RESOLVE_SQL.contains("dpc.depth < 10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_sql_caps_recursion_at_depth_10() {
|
||||||
|
assert!(BROWSE_SQL.contains("dpc.depth < 10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_sql_orders_dynamic_before_primitive() {
|
||||||
|
// Per cs:313: ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END.
|
||||||
|
assert!(RESOLVE_SQL.contains("WHEN N'dynamic' THEN 0 ELSE 1 END"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_queries_select_thirteen_columns_in_documented_order() {
|
||||||
|
// Spot-check: the SELECT list ends with attribute_source — the
|
||||||
|
// last (13th) column.
|
||||||
|
assert!(RESOLVE_SQL.contains("attribute_source\nFROM ("));
|
||||||
|
assert!(BROWSE_SQL.contains("attribute_source\nFROM ("));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! `GalaxyUserProfile` + async `UserResolver` trait.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/GalaxyRepositoryUserResolver.cs`.
|
||||||
|
//! The .NET reference exposes a single concrete class with a SQL
|
||||||
|
//! backend; the Rust port splits that into a trait + the data type +
|
||||||
|
//! a separate role-blob parser ([`crate::role_blob::parse_role_blob`])
|
||||||
|
//! so consumers can plug in any backend (in-memory cache, JSON snapshot,
|
||||||
|
//! REST client, planned `tiberius`-gated SQL impl).
|
||||||
|
//!
|
||||||
|
//! The user resolver is needed by F13's `WriteSecured*` flows — those
|
||||||
|
//! pass `current_user_id` and `verifier_user_id` to identify who
|
||||||
|
//! authorised a security-classified write. The user IDs are
|
||||||
|
//! `dbo.user_profile.user_profile_id` (`int`), looked up either by
|
||||||
|
//! `user_guid` (`uniqueidentifier`) or by `user_profile_name`.
|
||||||
|
|
||||||
|
use crate::role_blob::parse_role_blob;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Resolved user profile. Field order and types match the .NET
|
||||||
|
/// `GalaxyUserProfile` record exactly (`GalaxyRepositoryUserResolver.cs:5-11`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct GalaxyUserProfile {
|
||||||
|
pub user_profile_id: i32,
|
||||||
|
pub user_profile_name: String,
|
||||||
|
pub user_guid: Uuid,
|
||||||
|
pub default_security_group: String,
|
||||||
|
/// `None` when `dbo.user_profile.intouch_access_level IS NULL`
|
||||||
|
/// (`cs:83`).
|
||||||
|
pub intouch_access_level: Option<i32>,
|
||||||
|
/// Role names parsed from the `roles` `varbinary` column via
|
||||||
|
/// [`parse_role_blob`]. `Vec` (not `HashSet`) because the .NET
|
||||||
|
/// reference returns an `IReadOnlyList<string>` preserving discovery
|
||||||
|
/// order; deduplication is case-insensitive (`cs:124`) and happens
|
||||||
|
/// inside the parser.
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GalaxyUserProfile {
|
||||||
|
/// Build a `GalaxyUserProfile` from raw column values, parsing the
|
||||||
|
/// `roles_text` blob through [`parse_role_blob`]. Mirrors
|
||||||
|
/// `ReadProfile` (`GalaxyRepositoryUserResolver.cs:76-85`).
|
||||||
|
///
|
||||||
|
/// `roles_text = None` corresponds to `reader.IsDBNull(5)` at `cs:84`
|
||||||
|
/// — yields an empty role list.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_columns(
|
||||||
|
user_profile_id: i32,
|
||||||
|
user_profile_name: String,
|
||||||
|
user_guid: Uuid,
|
||||||
|
default_security_group: String,
|
||||||
|
intouch_access_level: Option<i32>,
|
||||||
|
roles_text: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
user_profile_id,
|
||||||
|
user_profile_name,
|
||||||
|
user_guid,
|
||||||
|
default_security_group,
|
||||||
|
intouch_access_level,
|
||||||
|
roles: roles_text.map(parse_role_blob).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised by [`UserResolver`] implementations. Mirrors
|
||||||
|
/// `KeyNotFoundException` at `cs:48,70` and the same `Backend` /
|
||||||
|
/// pluggable-error split as [`crate::resolver::ResolverError`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UserResolverError {
|
||||||
|
/// No user matched the supplied `user_guid` or `user_profile_name`.
|
||||||
|
/// Mirrors the `KeyNotFoundException` at `cs:48` / `:70`.
|
||||||
|
#[error("Galaxy user '{key}' was not found in dbo.user_profile")]
|
||||||
|
NotFound { key: String },
|
||||||
|
|
||||||
|
/// Backend-specific failure (SQL connect / query error, etc.).
|
||||||
|
#[error("Galaxy user resolver backend error: {message}")]
|
||||||
|
Backend { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluggable async user-profile resolver.
|
||||||
|
///
|
||||||
|
/// Implementations should be thread-safe (`Send + Sync`) so a single
|
||||||
|
/// resolver can be shared across the high-level write helpers in
|
||||||
|
/// `mxaccess-nmx` and the M4 `Session` façade.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserResolver: Send + Sync {
|
||||||
|
/// Look up a user profile by GUID. Mirrors `ResolveByGuidAsync`
|
||||||
|
/// (`GalaxyRepositoryUserResolver.cs:34-52`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||||
|
async fn resolve_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||||
|
|
||||||
|
/// Look up a user profile by name. Mirrors `ResolveByNameAsync`
|
||||||
|
/// (`cs:54-74`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||||
|
async fn resolve_by_name(
|
||||||
|
&self,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||||
|
|
||||||
|
/// Convenience: look up the user profile id only. Mirrors
|
||||||
|
/// `ResolveUserProfileIdByGuidAsync` (`cs:26-32`). Default impl
|
||||||
|
/// delegates to [`Self::resolve_by_guid`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As for [`Self::resolve_by_guid`].
|
||||||
|
async fn resolve_user_profile_id_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<i32, UserResolverError> {
|
||||||
|
let profile = self.resolve_by_guid(user_guid).await?;
|
||||||
|
Ok(profile.user_profile_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_columns_handles_null_intouch_and_null_roles() {
|
||||||
|
let p = GalaxyUserProfile::from_columns(
|
||||||
|
42,
|
||||||
|
"TestUser".to_string(),
|
||||||
|
Uuid::nil(),
|
||||||
|
"Default".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(p.user_profile_id, 42);
|
||||||
|
assert_eq!(p.user_profile_name, "TestUser");
|
||||||
|
assert_eq!(p.user_guid, Uuid::nil());
|
||||||
|
assert_eq!(p.default_security_group, "Default");
|
||||||
|
assert_eq!(p.intouch_access_level, None);
|
||||||
|
assert!(p.roles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_columns_parses_roles_via_parse_role_blob() {
|
||||||
|
// Encode "Owner" + 00 00 + tail terminator manually.
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = GalaxyUserProfile::from_columns(
|
||||||
|
7,
|
||||||
|
"OwnerUser".to_string(),
|
||||||
|
Uuid::nil(),
|
||||||
|
"Default".to_string(),
|
||||||
|
Some(9999),
|
||||||
|
Some(&hex),
|
||||||
|
);
|
||||||
|
assert_eq!(p.intouch_access_level, Some(9999));
|
||||||
|
assert_eq!(p.roles, vec!["Owner".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny in-memory implementation for tests — proves the trait is
|
||||||
|
/// implementable without any SQL machinery (mirrors the Resolver
|
||||||
|
/// trait's InMemoryResolver test at resolver.rs).
|
||||||
|
struct InMemoryUserResolver {
|
||||||
|
by_guid: HashMap<Uuid, GalaxyUserProfile>,
|
||||||
|
by_name: HashMap<String, GalaxyUserProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryUserResolver {
|
||||||
|
fn with_one(profile: GalaxyUserProfile) -> Self {
|
||||||
|
let mut by_guid = HashMap::new();
|
||||||
|
by_guid.insert(profile.user_guid, profile.clone());
|
||||||
|
let mut by_name = HashMap::new();
|
||||||
|
by_name.insert(profile.user_profile_name.clone(), profile);
|
||||||
|
Self { by_guid, by_name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserResolver for InMemoryUserResolver {
|
||||||
|
async fn resolve_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||||
|
self.by_guid
|
||||||
|
.get(&user_guid)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| UserResolverError::NotFound {
|
||||||
|
key: user_guid.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_name(
|
||||||
|
&self,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||||
|
self.by_name
|
||||||
|
.get(user_name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| UserResolverError::NotFound {
|
||||||
|
key: user_name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_profile() -> GalaxyUserProfile {
|
||||||
|
GalaxyUserProfile::from_columns(
|
||||||
|
7,
|
||||||
|
"TestUser".to_string(),
|
||||||
|
Uuid::from_bytes([0xCC; 16]),
|
||||||
|
"Default".to_string(),
|
||||||
|
Some(9999),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_by_guid_round_trip() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let p = r
|
||||||
|
.resolve_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.user_profile_id, 7);
|
||||||
|
assert_eq!(p.user_profile_name, "TestUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_by_name_round_trip() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let p = r.resolve_by_name("TestUser").await.unwrap();
|
||||||
|
assert_eq!(p.user_profile_id, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_not_found_by_guid() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let err = r.resolve_by_guid(Uuid::nil()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_not_found_by_name() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let err = r.resolve_by_name("DoesNotExist").await.unwrap_err();
|
||||||
|
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn resolve_user_profile_id_by_guid_default_impl_works() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let id = r
|
||||||
|
.resolve_user_profile_id_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,13 @@ authors.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||||
|
mxaccess-galaxy = { path = "../mxaccess-galaxy" }
|
||||||
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
||||||
mxaccess-callback = { path = "../mxaccess-callback" }
|
mxaccess-callback = { path = "../mxaccess-callback" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
|||||||
//! `mxaccess-nmx` — `INmxService2` client + raw NMX session façade.
|
//! `mxaccess-nmx` — `INmxService2` client + raw NMX session façade.
|
||||||
//!
|
//!
|
||||||
//! M0 stub. Real implementation lands in M3 — see `design/60-roadmap.md`.
|
//! M3 stream B landed: the [`client`] module ports the raw opnum surface
|
||||||
|
//! of `src/MxNativeClient/ManagedNmxService2Client.cs` (the 9
|
||||||
|
//! `INmxService2` procedures over `mxaccess_rpc::transport`). The
|
||||||
|
//! auto-resolving COM-activation factory and the high-level
|
||||||
|
//! `Write*`/`Advise*` wrappers are deferred — see the module-level docs
|
||||||
|
//! for what's deliberately out of scope for this iteration.
|
||||||
//!
|
//!
|
||||||
//! Opnums (verified against `src/MxNativeClient/NmxComContracts.cs:55-73`,
|
//! Opnums (verified against `src/MxNativeClient/NmxComContracts.cs:55-73`,
|
||||||
//! and on the wire — sequential because `INmxService2 : INmxService` continues
|
//! and on the wire — sequential because `INmxService2 : INmxService` continues
|
||||||
@@ -16,3 +21,7 @@
|
|||||||
//! - `11` GetPartnerVersion
|
//! - `11` GetPartnerVersion
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
pub use client::{NmxClient, NmxClientError, WriteValue};
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ rust-version.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
hmac = "0.12"
|
||||||
|
md-5 = "0.10"
|
||||||
|
md4 = "0.10"
|
||||||
|
rc4 = "0.2"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Crate-level RPC error type.
|
||||||
|
//!
|
||||||
|
//! Hoisted from per-module enums in M2 wave 2 — see `design/followups.md` F8.
|
||||||
|
//! Every parser/encoder in `mxaccess-rpc` returns this single shared
|
||||||
|
//! [`RpcError`] so consumers can match on one error surface across PDU
|
||||||
|
//! decode, OBJREF parse, ORPC `ResolveOxid` body decode, and
|
||||||
|
//! `IRemUnknown::RemQueryInterface` response decode.
|
||||||
|
//!
|
||||||
|
//! Variants here are the union of what M1 wave 1 defined locally in
|
||||||
|
//! `pdu.rs` and `objref.rs` (`design/followups.md` F8 source list),
|
||||||
|
//! plus a generic [`RpcError::Decode`] for one-off conditions wave 2's
|
||||||
|
//! ORPC parsers need (referent-id mismatches, conformant-array max-count
|
||||||
|
//! underflow, NDR alignment overrun) without growing the enum further.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors raised by any codec under `mxaccess-rpc`.
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum RpcError {
|
||||||
|
/// Buffer was shorter than required to decode the type.
|
||||||
|
#[error("short read: expected {expected} bytes, got {actual}")]
|
||||||
|
ShortRead { expected: usize, actual: usize },
|
||||||
|
|
||||||
|
/// Packet type byte at offset 2 (`DceRpcPdu.cs:52`) did not match the
|
||||||
|
/// expected `DceRpcPacketType` for the parser invoked.
|
||||||
|
#[error("unexpected packet type {actual}, expected {expected}")]
|
||||||
|
UnexpectedPacketType { expected: u8, actual: u8 },
|
||||||
|
|
||||||
|
/// Packet type byte was not a known [`crate::pdu::PacketType`] value.
|
||||||
|
#[error("unknown packet type byte {0}")]
|
||||||
|
UnknownPacketType(u8),
|
||||||
|
|
||||||
|
/// `header.frag_length` is inconsistent with the supplied buffer or
|
||||||
|
/// `auth_length` (`DceRpcPdu.cs:94,150,188,226,101-104,156-159,195-198`).
|
||||||
|
#[error(
|
||||||
|
"fragment length {frag_length} inconsistent with buffer length {buffer_len} \
|
||||||
|
(auth_length={auth_length})"
|
||||||
|
)]
|
||||||
|
InvalidFragmentLength {
|
||||||
|
frag_length: usize,
|
||||||
|
buffer_len: usize,
|
||||||
|
auth_length: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A bind PDU's per-context list ran past `frag_length`
|
||||||
|
/// (`DceRpcPdu.cs:237`) or a syntax identifier was truncated
|
||||||
|
/// (`DceRpcPdu.cs:354`).
|
||||||
|
#[error("truncated bind body at offset {offset}; need {need} bytes, frag_length={frag_length}")]
|
||||||
|
TruncatedBindBody {
|
||||||
|
offset: usize,
|
||||||
|
need: usize,
|
||||||
|
frag_length: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Auth-trailer offset is below the 16-byte header
|
||||||
|
/// (`DceRpcPdu.cs:341-345`).
|
||||||
|
#[error("invalid auth trailer offset {offset}")]
|
||||||
|
InvalidAuthTrailer { offset: usize },
|
||||||
|
|
||||||
|
/// Tried to extract an auth value from a PDU whose `auth_length` is 0
|
||||||
|
/// (`DceRpcPdu.cs:336-339`).
|
||||||
|
#[error("PDU has no auth value")]
|
||||||
|
MissingAuthValue,
|
||||||
|
|
||||||
|
/// Generic decode failure with a position and reason. Used by ORPC
|
||||||
|
/// body decoders for one-off conditions that don't justify a typed
|
||||||
|
/// variant (e.g. NDR conformant-array max-count underflow per
|
||||||
|
/// `ObjectExporterMessages.cs:66-69`, referent-id of zero with no
|
||||||
|
/// trailing status per `:57-61`, NDR alignment overrun, etc.).
|
||||||
|
#[error("decode at offset {offset} ({reason}); buffer len {buffer_len}")]
|
||||||
|
Decode {
|
||||||
|
offset: usize,
|
||||||
|
reason: &'static str,
|
||||||
|
buffer_len: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! 16-byte GUID with .NET-compatible display.
|
||||||
|
//!
|
||||||
|
//! Hoisted from `objref::Guid` in M2 wave 2 — see `design/followups.md` F7.
|
||||||
|
//! Both `objref` (for `iid`/`ipid`) and `pdu` (for `SyntaxId` IIDs) and the
|
||||||
|
//! M2 wave 2 `orpc::OrpcThis::cid` / `object_exporter::*` / `rem_unknown::*`
|
||||||
|
//! types share this single representation rather than each rolling their own.
|
||||||
|
//!
|
||||||
|
//! Stored as 16 wire bytes. The first three groups on the wire are
|
||||||
|
//! little-endian (`Data1` u32 LE, `Data2` u16 LE, `Data3` u16 LE) followed by
|
||||||
|
//! 8 big-endian `Data4` bytes — the byte layout produced by .NET
|
||||||
|
//! `new Guid(ReadOnlySpan<byte>)` and consumed by `Guid.TryWriteBytes` (used
|
||||||
|
//! across the .NET reference, e.g. `ComObjRef.cs:31,36`,
|
||||||
|
//! `OrpcStructures.cs:48,127`, `RemUnknownMessages.cs:20,30`).
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
/// 16-byte GUID. See module docs for byte layout.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub struct Guid(pub [u8; 16]);
|
||||||
|
|
||||||
|
impl Guid {
|
||||||
|
pub const ZERO: Guid = Guid([0u8; 16]);
|
||||||
|
|
||||||
|
pub const fn new(bytes: [u8; 16]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_bytes(&self) -> &[u8; 16] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `Guid` from a 16-byte little-endian-leading wire slice. Mirrors
|
||||||
|
/// the .NET `new Guid(span)` byte order.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`crate::error::RpcError::ShortRead`] if `bytes.len() < 16`.
|
||||||
|
pub fn parse(bytes: &[u8]) -> Result<Self, crate::error::RpcError> {
|
||||||
|
if bytes.len() < 16 {
|
||||||
|
return Err(crate::error::RpcError::ShortRead {
|
||||||
|
expected: 16,
|
||||||
|
actual: bytes.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&bytes[..16]);
|
||||||
|
Ok(Self(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the 16 wire bytes into `dst[..16]`. Mirrors .NET
|
||||||
|
/// `Guid.TryWriteBytes(span)`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`crate::error::RpcError::ShortRead`] if `dst.len() < 16`.
|
||||||
|
pub fn write_to(&self, dst: &mut [u8]) -> Result<(), crate::error::RpcError> {
|
||||||
|
if dst.len() < 16 {
|
||||||
|
return Err(crate::error::RpcError::ShortRead {
|
||||||
|
expected: 16,
|
||||||
|
actual: dst.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dst[..16].copy_from_slice(&self.0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Guid {
|
||||||
|
/// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g.
|
||||||
|
/// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are
|
||||||
|
/// little-endian on the wire so are byte-swapped on display.
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let b = &self.0;
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||||
|
b[3],
|
||||||
|
b[2],
|
||||||
|
b[1],
|
||||||
|
b[0],
|
||||||
|
b[5],
|
||||||
|
b[4],
|
||||||
|
b[7],
|
||||||
|
b[6],
|
||||||
|
b[8],
|
||||||
|
b[9],
|
||||||
|
b[10],
|
||||||
|
b[11],
|
||||||
|
b[12],
|
||||||
|
b[13],
|
||||||
|
b[14],
|
||||||
|
b[15],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<[u8; 16]> for Guid {
|
||||||
|
fn from(bytes: [u8; 16]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_matches_dotnet_d_format() {
|
||||||
|
// First 3 groups are byte-swapped on display (LE wire → BE display).
|
||||||
|
let g = Guid::new([
|
||||||
|
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
|
||||||
|
0x27, 0x46,
|
||||||
|
]);
|
||||||
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_round_trip() {
|
||||||
|
let bytes = [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
let g = Guid::parse(&bytes).unwrap();
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
g.write_to(&mut out).unwrap();
|
||||||
|
assert_eq!(out, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_short_buffer_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
Guid::parse(&[0u8; 15]),
|
||||||
|
Err(crate::error::RpcError::ShortRead { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_guid() {
|
||||||
|
assert_eq!(
|
||||||
|
Guid::ZERO.to_string(),
|
||||||
|
"00000000-0000-0000-0000-000000000000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,30 @@
|
|||||||
//! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface.
|
//! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface.
|
||||||
//!
|
//!
|
||||||
//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`.
|
//! - M2 wave 1 (landed): `ntlm`, `pdu`, `objref`.
|
||||||
|
//! - M2 wave 2 (landed): `guid` + `error` (shared types — resolves F7+F8),
|
||||||
|
//! `orpc` (ORPC framing), `object_exporter` (OXID resolution body codec),
|
||||||
|
//! `rem_unknown` (`IRemUnknown::RemQueryInterface` body codec).
|
||||||
|
//! - M2 wave 3 (next): callback exporter — see `design/60-roadmap.md` and
|
||||||
|
//! `design/dependencies.md`.
|
||||||
//!
|
//!
|
||||||
//! Internal `unsafe` is permitted only for `windows-rs` COM activation paths
|
//! Internal `unsafe` is permitted only for `windows-rs` COM activation paths
|
||||||
//! (per `design/00-overview.md` principle 3); all such calls must be wrapped
|
//! (per `design/00-overview.md` principle 3); all such calls must be wrapped
|
||||||
//! in safe abstractions at the crate boundary.
|
//! in safe abstractions at the crate boundary. All modules to date are
|
||||||
|
//! pure-Rust and contain no `unsafe`.
|
||||||
|
|
||||||
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
|
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
|
||||||
// windows-rs COM calls). Public API stays safe.
|
// windows-rs COM calls). Public API stays safe.
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod guid;
|
||||||
|
pub mod nmx_callback_messages;
|
||||||
|
pub mod nmx_metadata;
|
||||||
|
pub mod nmx_service2_messages;
|
||||||
|
pub mod ntlm;
|
||||||
|
pub mod object_exporter;
|
||||||
|
pub mod object_exporter_client;
|
||||||
|
pub mod objref;
|
||||||
|
pub mod orpc;
|
||||||
|
pub mod pdu;
|
||||||
|
pub mod rem_unknown;
|
||||||
|
pub mod transport;
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! `INmxSvcCallback` request body parser + response body encoder.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/NmxSvcCallbackMessages.cs`. Decodes the
|
||||||
|
//! single `byte[] buffer` parameter the AVEVA service marshals through
|
||||||
|
//! `INmxSvcCallback::DataReceived` (opnum 3) and `StatusReceived` (opnum 4),
|
||||||
|
//! and produces the matching `HRESULT`-bearing response body the callback
|
||||||
|
//! exporter writes back.
|
||||||
|
//!
|
||||||
|
//! Per `NmxSvcCallbackMessages.cs:14-36`, the inbound body is:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! offset size field
|
||||||
|
//! 0 32 OrpcThis (encoded length without extensions)
|
||||||
|
//! 32 4 size i32 LE byte-array logical length
|
||||||
|
//! 36 4 max_count i32 LE conformant-array max count
|
||||||
|
//! 40 size body raw bytes carried inside the callback
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `size` and `max_count` are NDR-marshalled `int` values; .NET asserts both
|
||||||
|
//! are non-negative and `max_count >= size` (`cs:24`).
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::error::RpcError;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
use crate::nmx_metadata::INMX_SVC_CALLBACK_IID;
|
||||||
|
use crate::orpc::{OrpcThat, OrpcThis};
|
||||||
|
|
||||||
|
/// Convenience re-export so callers can match the .NET `InterfaceId` static
|
||||||
|
/// (`NmxSvcCallbackMessages.cs:9`).
|
||||||
|
pub const INTERFACE_ID: Guid = INMX_SVC_CALLBACK_IID;
|
||||||
|
|
||||||
|
/// Opnum for `INmxSvcCallback::DataReceived` (`cs:11`). Same value as
|
||||||
|
/// [`crate::nmx_metadata::DATA_RECEIVED.opnum`].
|
||||||
|
pub const DATA_RECEIVED_OPNUM: u16 = 3;
|
||||||
|
|
||||||
|
/// Opnum for `INmxSvcCallback::StatusReceived` (`cs:12`). Same value as
|
||||||
|
/// [`crate::nmx_metadata::STATUS_RECEIVED.opnum`].
|
||||||
|
pub const STATUS_RECEIVED_OPNUM: u16 = 4;
|
||||||
|
|
||||||
|
/// Decoded callback request — mirrors `NmxCallbackRequest`
|
||||||
|
/// (`NmxSvcCallbackMessages.cs:5`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NmxCallbackRequest {
|
||||||
|
pub orpc_this: OrpcThis,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header overhead before the `body` bytes: `OrpcThis(32) + size(4) +
|
||||||
|
/// max_count(4) = 40` (`NmxSvcCallbackMessages.cs:16,29`).
|
||||||
|
pub const CALLBACK_REQUEST_HEADER_LEN: usize = OrpcThis::ENCODED_LEN + 8;
|
||||||
|
|
||||||
|
/// Parse an inbound callback request body. Mirrors `ParseCallbackRequest`
|
||||||
|
/// (`NmxSvcCallbackMessages.cs:14-36`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RpcError::ShortRead`] when `buffer.len() < 40` (`cs:16-19`).
|
||||||
|
/// - [`RpcError::Decode`] when `size < 0` or `max_count < size`
|
||||||
|
/// (`cs:24-27`), or when the declared `size` runs past the buffer
|
||||||
|
/// (`cs:30-33`).
|
||||||
|
pub fn parse_callback_request(buffer: &[u8]) -> Result<NmxCallbackRequest, RpcError> {
|
||||||
|
if buffer.len() < CALLBACK_REQUEST_HEADER_LEN {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: CALLBACK_REQUEST_HEADER_LEN,
|
||||||
|
actual: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let orpc_this = OrpcThis::parse(&buffer[..OrpcThis::ENCODED_LEN])?;
|
||||||
|
|
||||||
|
// size and max_count are .NET `int` (i32 LE). Negative values are
|
||||||
|
// explicitly rejected by the .NET reference (`cs:24`).
|
||||||
|
let size_i32 = i32::from_le_bytes([
|
||||||
|
buffer[OrpcThis::ENCODED_LEN],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 1],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 2],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 3],
|
||||||
|
]);
|
||||||
|
let max_count_i32 = i32::from_le_bytes([
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 4],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 5],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 6],
|
||||||
|
buffer[OrpcThis::ENCODED_LEN + 7],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if size_i32 < 0 || max_count_i32 < size_i32 {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset: OrpcThis::ENCODED_LEN,
|
||||||
|
reason: "callback request has invalid array size metadata",
|
||||||
|
buffer_len: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = size_i32 as usize;
|
||||||
|
let body_offset = CALLBACK_REQUEST_HEADER_LEN;
|
||||||
|
if body_offset + size > buffer.len() {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset: body_offset,
|
||||||
|
reason: "callback request byte array is truncated",
|
||||||
|
buffer_len: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(NmxCallbackRequest {
|
||||||
|
orpc_this,
|
||||||
|
body: buffer[body_offset..body_offset + size].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode the callback response body — `OrpcThat(8) + hresult(4) = 12`
|
||||||
|
/// bytes. Mirrors `EncodeCallbackResponse` (`NmxSvcCallbackMessages.cs:38-44`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_callback_response(hresult: i32) -> [u8; 12] {
|
||||||
|
let mut buf = [0u8; 12];
|
||||||
|
let orpc_that = OrpcThat {
|
||||||
|
flags: 0,
|
||||||
|
extensions_referent_id: 0,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
buf[..OrpcThat::ENCODED_LEN].copy_from_slice(&orpc_that);
|
||||||
|
buf[OrpcThat::ENCODED_LEN..].copy_from_slice(&hresult.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_request(
|
||||||
|
body: &[u8],
|
||||||
|
size_override: Option<i32>,
|
||||||
|
max_count_override: Option<i32>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(CALLBACK_REQUEST_HEADER_LEN + body.len());
|
||||||
|
let orpc_this = OrpcThis::create(Guid::new([0x10; 16]), None).encode();
|
||||||
|
buf.extend_from_slice(&orpc_this);
|
||||||
|
let size = size_override.unwrap_or(body.len() as i32);
|
||||||
|
let max_count = max_count_override.unwrap_or(body.len() as i32);
|
||||||
|
buf.extend_from_slice(&size.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&max_count.to_le_bytes());
|
||||||
|
buf.extend_from_slice(body);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opnums_match_dotnet() {
|
||||||
|
assert_eq!(DATA_RECEIVED_OPNUM, 3);
|
||||||
|
assert_eq!(STATUS_RECEIVED_OPNUM, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_id_matches_callback_iid() {
|
||||||
|
assert_eq!(INTERFACE_ID, INMX_SVC_CALLBACK_IID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_round_trip_empty_body() {
|
||||||
|
let bytes = make_request(&[], None, None);
|
||||||
|
let parsed = parse_callback_request(&bytes).unwrap();
|
||||||
|
assert!(parsed.body.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_round_trip_carries_payload() {
|
||||||
|
let body: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||||
|
let bytes = make_request(body, None, None);
|
||||||
|
let parsed = parse_callback_request(&bytes).unwrap();
|
||||||
|
assert_eq!(parsed.body, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_short_buffer_errors() {
|
||||||
|
let err = parse_callback_request(&[0u8; 39]).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RpcError::ShortRead {
|
||||||
|
expected: 40,
|
||||||
|
actual: 39
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_negative_size_rejected() {
|
||||||
|
let bytes = make_request(&[], Some(-1), Some(0));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_callback_request(&bytes),
|
||||||
|
Err(RpcError::Decode { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_max_count_less_than_size_rejected() {
|
||||||
|
let bytes = make_request(&[0xAA; 8], Some(8), Some(4));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_callback_request(&bytes),
|
||||||
|
Err(RpcError::Decode { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_truncated_body_rejected() {
|
||||||
|
// Declare 16 bytes but supply only 4.
|
||||||
|
let mut bytes = make_request(&[0xAA; 4], Some(16), Some(16));
|
||||||
|
// Trim trailing bytes so the buffer is shorter than declared size.
|
||||||
|
bytes.truncate(CALLBACK_REQUEST_HEADER_LEN + 4);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_callback_request(&bytes),
|
||||||
|
Err(RpcError::Decode { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_response_layout() {
|
||||||
|
// Success response — OrpcThat zeros + hresult=0.
|
||||||
|
let r = encode_callback_response(0);
|
||||||
|
assert_eq!(r.len(), 12);
|
||||||
|
assert_eq!(&r[..8], &[0u8; 8]);
|
||||||
|
assert_eq!(&r[8..], &0i32.to_le_bytes());
|
||||||
|
|
||||||
|
// Negative hresult round-trip.
|
||||||
|
let r = encode_callback_response(unchecked_negative());
|
||||||
|
assert_eq!(&r[8..], &unchecked_negative().to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unchecked_negative() -> i32 {
|
||||||
|
// 0x80004005 = E_FAIL, the canonical generic failure HRESULT.
|
||||||
|
// .NET would write `unchecked((int)0x80004005)`; Rust expresses
|
||||||
|
// the same bit pattern as `i32::MIN`-aligned negative.
|
||||||
|
0x80004005u32 as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//! NMX procedure metadata.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/NmxProcedureMetadata.cs`. Defines the
|
||||||
|
//! `INmxService2` and `INmxSvcCallback` interface IIDs and the per-opnum NDR
|
||||||
|
//! procedure descriptors used by the .NET reference's `ManagedCallbackExporter`
|
||||||
|
//! and `ManagedNmxService2Client`.
|
||||||
|
//!
|
||||||
|
//! These values are wire-load-bearing for M2 wave 3 (callback exporter) and
|
||||||
|
//! M3 (NMX session). Each IID is also enforced by the COM `[Guid(...)]`
|
||||||
|
//! attributes on the matching interfaces in `NmxComContracts.cs:7,52,84`.
|
||||||
|
|
||||||
|
use crate::guid::Guid;
|
||||||
|
|
||||||
|
/// `INmxService2` IID `2630A513-A974-4B1A-8025-457A9A7C56B8`
|
||||||
|
/// (`NmxProcedureMetadata.cs:5`, `NmxComContracts.cs:51`).
|
||||||
|
pub const INMX_SERVICE2_IID: Guid = Guid::new([
|
||||||
|
0x13, 0xA5, 0x30, 0x26, 0x74, 0xA9, 0x1A, 0x4B, 0x80, 0x25, 0x45, 0x7A, 0x9A, 0x7C, 0x56, 0xB8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// `INmxSvcCallback` IID `B49F92F7-C748-4169-8ECA-A0670B012746`
|
||||||
|
/// (`NmxProcedureMetadata.cs:6`, `NmxComContracts.cs:84`).
|
||||||
|
pub const INMX_SVC_CALLBACK_IID: Guid = Guid::new([
|
||||||
|
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01, 0x27, 0x46,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// NDR procedure descriptor — mirrors `NdrProcedureDescriptor`
|
||||||
|
/// (`NmxProcedureMetadata.cs:108-115`). Captures the opnum + the x86 stack
|
||||||
|
/// size and client/server buffer sizes the LMX MIDL stub publishes via
|
||||||
|
/// `NMIDL_PROC_INFO`. The Rust port carries these for parity with the .NET
|
||||||
|
/// reference; a future M3 NMX client may use them to size pre-allocated
|
||||||
|
/// buffers.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NdrProcedureDescriptor {
|
||||||
|
pub interface_id: Guid,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub opnum: u16,
|
||||||
|
pub x86_stack_size: u16,
|
||||||
|
pub client_buffer_size: u16,
|
||||||
|
pub server_buffer_size: u16,
|
||||||
|
pub parameter_count_including_return: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NdrProcedureDescriptor {
|
||||||
|
pub const fn new(
|
||||||
|
interface_id: Guid,
|
||||||
|
name: &'static str,
|
||||||
|
opnum: u16,
|
||||||
|
x86_stack_size: u16,
|
||||||
|
client_buffer_size: u16,
|
||||||
|
server_buffer_size: u16,
|
||||||
|
parameter_count_including_return: u8,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
interface_id,
|
||||||
|
name,
|
||||||
|
opnum,
|
||||||
|
x86_stack_size,
|
||||||
|
client_buffer_size,
|
||||||
|
server_buffer_size,
|
||||||
|
parameter_count_including_return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INmxService2 procedures (`NmxProcedureMetadata.cs:8-87`) -----------
|
||||||
|
|
||||||
|
/// `INmxService2::RegisterEngine` — opnum 3 (`cs:8-15`).
|
||||||
|
pub const REGISTER_ENGINE: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RegisterEngine", 3, 20, 8, 8, 4);
|
||||||
|
|
||||||
|
/// `INmxService2::UnRegisterEngine` — opnum 4 (`cs:17-24`).
|
||||||
|
pub const UNREGISTER_ENGINE: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "UnRegisterEngine", 4, 12, 8, 8, 2);
|
||||||
|
|
||||||
|
/// `INmxService2::Connect` — opnum 5 (`cs:26-33`).
|
||||||
|
pub const CONNECT: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "Connect", 5, 24, 32, 8, 5);
|
||||||
|
|
||||||
|
/// `INmxService2::TransferData` — opnum 6 (`cs:35-42`).
|
||||||
|
pub const TRANSFER_DATA: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "TransferData", 6, 28, 32, 8, 6);
|
||||||
|
|
||||||
|
/// `INmxService2::AddSubscriberEngine` — opnum 7 (`cs:44-51`).
|
||||||
|
pub const ADD_SUBSCRIBER_ENGINE: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "AddSubscriberEngine", 7, 24, 32, 8, 5);
|
||||||
|
|
||||||
|
/// `INmxService2::RemoveSubscriberEngine` — opnum 8 (`cs:53-60`).
|
||||||
|
pub const REMOVE_SUBSCRIBER_ENGINE: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RemoveSubscriberEngine", 8, 24, 32, 8, 5);
|
||||||
|
|
||||||
|
/// `INmxService2::SetHeartbeatSendInterval` — opnum 9 (`cs:62-69`).
|
||||||
|
pub const SET_HEARTBEAT_SEND_INTERVAL: NdrProcedureDescriptor = NdrProcedureDescriptor::new(
|
||||||
|
INMX_SERVICE2_IID,
|
||||||
|
"SetHeartbeatSendInterval",
|
||||||
|
9,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
8,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// `INmxService2::RegisterEngine2` — opnum 10 (`cs:71-78`).
|
||||||
|
pub const REGISTER_ENGINE_2: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "RegisterEngine2", 10, 24, 16, 8, 5);
|
||||||
|
|
||||||
|
/// `INmxService2::GetPartnerVersion` — opnum 11 (`cs:80-87`).
|
||||||
|
pub const GET_PARTNER_VERSION: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SERVICE2_IID, "GetPartnerVersion", 11, 24, 24, 36, 5);
|
||||||
|
|
||||||
|
// --- INmxSvcCallback procedures (`NmxProcedureMetadata.cs:89-105`) -------
|
||||||
|
|
||||||
|
/// `INmxSvcCallback::DataReceived` — opnum 3 (`cs:89-96`).
|
||||||
|
pub const DATA_RECEIVED: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SVC_CALLBACK_IID, "DataReceived", 3, 16, 8, 8, 3);
|
||||||
|
|
||||||
|
/// `INmxSvcCallback::StatusReceived` — opnum 4 (`cs:98-105`).
|
||||||
|
pub const STATUS_RECEIVED: NdrProcedureDescriptor =
|
||||||
|
NdrProcedureDescriptor::new(INMX_SVC_CALLBACK_IID, "StatusReceived", 4, 16, 8, 8, 3);
|
||||||
|
|
||||||
|
/// All `INmxService2` procedures in opnum order. Convenience for callers
|
||||||
|
/// that want to iterate the table.
|
||||||
|
pub const INMX_SERVICE2_PROCEDURES: &[NdrProcedureDescriptor] = &[
|
||||||
|
REGISTER_ENGINE,
|
||||||
|
UNREGISTER_ENGINE,
|
||||||
|
CONNECT,
|
||||||
|
TRANSFER_DATA,
|
||||||
|
ADD_SUBSCRIBER_ENGINE,
|
||||||
|
REMOVE_SUBSCRIBER_ENGINE,
|
||||||
|
SET_HEARTBEAT_SEND_INTERVAL,
|
||||||
|
REGISTER_ENGINE_2,
|
||||||
|
GET_PARTNER_VERSION,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All `INmxSvcCallback` procedures in opnum order.
|
||||||
|
pub const INMX_SVC_CALLBACK_PROCEDURES: &[NdrProcedureDescriptor] =
|
||||||
|
&[DATA_RECEIVED, STATUS_RECEIVED];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inmx_service2_iid_matches_dotnet_d_format() {
|
||||||
|
// .NET `new Guid("2630A513-A974-4B1A-8025-457A9A7C56B8").ToString("D")`
|
||||||
|
assert_eq!(
|
||||||
|
INMX_SERVICE2_IID.to_string(),
|
||||||
|
"2630a513-a974-4b1a-8025-457a9a7c56b8"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inmx_svc_callback_iid_matches_dotnet_d_format() {
|
||||||
|
// The exact IID re-asserted in the OBJREF capture
|
||||||
|
// `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6`
|
||||||
|
// (objref bytes 8..24).
|
||||||
|
assert_eq!(
|
||||||
|
INMX_SVC_CALLBACK_IID.to_string(),
|
||||||
|
"b49f92f7-c748-4169-8eca-a0670b012746"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inmx_service2_opnums_are_3_through_11() {
|
||||||
|
let opnums: Vec<u16> = INMX_SERVICE2_PROCEDURES.iter().map(|p| p.opnum).collect();
|
||||||
|
assert_eq!(opnums, vec![3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inmx_svc_callback_opnums_are_3_and_4() {
|
||||||
|
let opnums: Vec<u16> = INMX_SVC_CALLBACK_PROCEDURES
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.opnum)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(opnums, vec![3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn procedure_names_match_dotnet_nameof() {
|
||||||
|
// .NET uses `nameof(...)` so names match the C# method identifier.
|
||||||
|
assert_eq!(REGISTER_ENGINE.name, "RegisterEngine");
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.name, "RegisterEngine2");
|
||||||
|
assert_eq!(GET_PARTNER_VERSION.name, "GetPartnerVersion");
|
||||||
|
assert_eq!(STATUS_RECEIVED.name, "StatusReceived");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_engine_2_metadata() {
|
||||||
|
// Spot-check the parameters most likely to be load-bearing for M3:
|
||||||
|
// opnum 10, 24-byte x86 stack, 16-byte client buffer, 5 params
|
||||||
|
// including the HRESULT return (`cs:71-78`).
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.opnum, 10);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.x86_stack_size, 24);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.client_buffer_size, 16);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.server_buffer_size, 8);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.parameter_count_including_return, 5);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2.interface_id, INMX_SERVICE2_IID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transfer_data_is_largest_x86_stack() {
|
||||||
|
// TransferData (opnum 6) has the largest x86 stack at 28 bytes
|
||||||
|
// because it carries the `ref byte messageBody` payload pointer.
|
||||||
|
let max = INMX_SERVICE2_PROCEDURES
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.x86_stack_size)
|
||||||
|
.max()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(max, TRANSFER_DATA.x86_stack_size);
|
||||||
|
assert_eq!(TRANSFER_DATA.opnum, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_procedures_use_callback_iid() {
|
||||||
|
for p in INMX_SVC_CALLBACK_PROCEDURES {
|
||||||
|
assert_eq!(p.interface_id, INMX_SVC_CALLBACK_IID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service2_procedures_use_service2_iid() {
|
||||||
|
for p in INMX_SERVICE2_PROCEDURES {
|
||||||
|
assert_eq!(p.interface_id, INMX_SERVICE2_IID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
//! `INmxService2` request/response codecs.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/NmxService2Messages.cs`. Provides
|
||||||
|
//! pure-codec encoders/decoders for the 9 procedures the .NET reference
|
||||||
|
//! marshals against `INmxService2` (opnums 3..11) plus the small set of
|
||||||
|
//! NDR helpers used by `RegisterEngine2` (`EncodeBstrUserMarshal`,
|
||||||
|
//! `EncodeNullInterfacePointer`, `EncodeInterfacePointer`).
|
||||||
|
//!
|
||||||
|
//! All wire fields are little-endian. Each encoder returns a `Vec<u8>`
|
||||||
|
//! that the transport (`crate::transport::DceRpcTcpClient::call_bound`)
|
||||||
|
//! sends as the `stub_data` of a `Request` PDU.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::error::RpcError;
|
||||||
|
use crate::nmx_metadata::INMX_SERVICE2_IID;
|
||||||
|
use crate::orpc::{OrpcThat, OrpcThis};
|
||||||
|
|
||||||
|
/// `NmxServiceClass` CLSID `AE24BD51-2E80-44CC-905B-E5446C942BEB`
|
||||||
|
/// (`NmxService2Messages.cs:12`, also `NmxComContracts.cs:7`).
|
||||||
|
pub const NMX_SERVICE_CLSID: crate::guid::Guid = crate::guid::Guid::new([
|
||||||
|
0x51, 0xBD, 0x24, 0xAE, 0x80, 0x2E, 0xCC, 0x44, 0x90, 0x5B, 0xE5, 0x44, 0x6C, 0x94, 0x2B, 0xEB,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// `INmxService2` IID — re-exported for convenience
|
||||||
|
/// (`NmxService2Messages.cs:13`).
|
||||||
|
pub const INTERFACE_ID: crate::guid::Guid = INMX_SERVICE2_IID;
|
||||||
|
|
||||||
|
// --- Opnums (`NmxService2Messages.cs:15-23`) ----------------------------
|
||||||
|
|
||||||
|
pub const REGISTER_ENGINE_OPNUM: u16 = 3;
|
||||||
|
pub const UNREGISTER_ENGINE_OPNUM: u16 = 4;
|
||||||
|
pub const CONNECT_OPNUM: u16 = 5;
|
||||||
|
pub const TRANSFER_DATA_OPNUM: u16 = 6;
|
||||||
|
pub const ADD_SUBSCRIBER_ENGINE_OPNUM: u16 = 7;
|
||||||
|
pub const REMOVE_SUBSCRIBER_ENGINE_OPNUM: u16 = 8;
|
||||||
|
pub const SET_HEARTBEAT_SEND_INTERVAL_OPNUM: u16 = 9;
|
||||||
|
pub const REGISTER_ENGINE_2_OPNUM: u16 = 10;
|
||||||
|
pub const GET_PARTNER_VERSION_OPNUM: u16 = 11;
|
||||||
|
|
||||||
|
// --- Records ------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Decoded `GetPartnerVersion` response — mirrors
|
||||||
|
/// `NmxGetPartnerVersionResult` (`cs:6`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NmxGetPartnerVersionResult {
|
||||||
|
pub orpc_that: OrpcThat,
|
||||||
|
pub partner_version: i32,
|
||||||
|
pub hresult: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded HRESULT-only response (Connect / Register* / Unregister /
|
||||||
|
/// Set / TransferData / Add/Remove subscriber). Mirrors
|
||||||
|
/// `NmxHResultResponse` (`cs:8`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NmxHResultResponse {
|
||||||
|
pub orpc_that: OrpcThat,
|
||||||
|
pub hresult: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encoders -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GetPartnerVersion` request (`cs:25-37`).
|
||||||
|
///
|
||||||
|
/// Layout: `OrpcThis(32) || galaxy_id(4) || platform_id(4) || engine_id(4)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_get_partner_version_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
galaxy_id: i32,
|
||||||
|
platform_id: i32,
|
||||||
|
engine_id: i32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 12];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&galaxy_id.to_le_bytes());
|
||||||
|
buf[36..40].copy_from_slice(&platform_id.to_le_bytes());
|
||||||
|
buf[40..44].copy_from_slice(&engine_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Connect` request (`cs:52-66`).
|
||||||
|
///
|
||||||
|
/// Layout: `OrpcThis(32) || local_engine_id(4) || remote_galaxy_id(4) ||
|
||||||
|
/// remote_platform_id(4) || remote_engine_id(4)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_connect_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
local_engine_id: i32,
|
||||||
|
remote_galaxy_id: i32,
|
||||||
|
remote_platform_id: i32,
|
||||||
|
remote_engine_id: i32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 16];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes());
|
||||||
|
buf[36..40].copy_from_slice(&remote_galaxy_id.to_le_bytes());
|
||||||
|
buf[40..44].copy_from_slice(&remote_platform_id.to_le_bytes());
|
||||||
|
buf[44..48].copy_from_slice(&remote_engine_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AddSubscriberEngine` / `RemoveSubscriberEngine` request shape
|
||||||
|
/// (`cs:68-82`). Both opnums share this layout — the .NET reference
|
||||||
|
/// reuses the same encoder.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_subscriber_engine_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
local_engine_id: i32,
|
||||||
|
subscriber_galaxy_id: i32,
|
||||||
|
subscriber_platform_id: i32,
|
||||||
|
subscriber_engine_id: i32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 16];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes());
|
||||||
|
buf[36..40].copy_from_slice(&subscriber_galaxy_id.to_le_bytes());
|
||||||
|
buf[40..44].copy_from_slice(&subscriber_platform_id.to_le_bytes());
|
||||||
|
buf[44..48].copy_from_slice(&subscriber_engine_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UnRegisterEngine` request (`cs:84-92`).
|
||||||
|
///
|
||||||
|
/// Layout: `OrpcThis(32) || local_engine_id(4)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_unregister_engine_request(orpc_this: OrpcThis, local_engine_id: i32) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 4];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SetHeartbeatSendInterval` request (`cs:94-104`).
|
||||||
|
///
|
||||||
|
/// Layout: `OrpcThis(32) || ticks_per_beat(4) || max_missed_ticks(4)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_set_heartbeat_send_interval_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
ticks_per_beat: i32,
|
||||||
|
max_missed_ticks: i32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; OrpcThis::ENCODED_LEN + 8];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&ticks_per_beat.to_le_bytes());
|
||||||
|
buf[36..40].copy_from_slice(&max_missed_ticks.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TransferData` request (`cs:106-124`).
|
||||||
|
///
|
||||||
|
/// Layout (NDR-aligned to 4 bytes overall):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 32 OrpcThis
|
||||||
|
/// 32 4 remote_galaxy_id i32 LE
|
||||||
|
/// 36 4 remote_platform_id i32 LE
|
||||||
|
/// 40 4 remote_engine_id i32 LE
|
||||||
|
/// 44 4 message_length i32 LE
|
||||||
|
/// 48 4 max_count i32 LE = message_length
|
||||||
|
/// 52..(52+len) len message_body
|
||||||
|
/// (padded to 4-byte alignment)
|
||||||
|
/// ```
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_transfer_data_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
remote_galaxy_id: i32,
|
||||||
|
remote_platform_id: i32,
|
||||||
|
remote_engine_id: i32,
|
||||||
|
message_body: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let body_offset = OrpcThis::ENCODED_LEN + 20;
|
||||||
|
let padded_length = align_up(body_offset + message_body.len(), 4);
|
||||||
|
let mut buf = vec![0u8; padded_length];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&remote_galaxy_id.to_le_bytes());
|
||||||
|
buf[36..40].copy_from_slice(&remote_platform_id.to_le_bytes());
|
||||||
|
buf[40..44].copy_from_slice(&remote_engine_id.to_le_bytes());
|
||||||
|
let body_len = i32::try_from(message_body.len()).unwrap_or(i32::MAX);
|
||||||
|
buf[44..48].copy_from_slice(&body_len.to_le_bytes());
|
||||||
|
buf[48..52].copy_from_slice(&body_len.to_le_bytes());
|
||||||
|
buf[body_offset..body_offset + message_body.len()].copy_from_slice(message_body);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RegisterEngine2` request (`cs:126-154`).
|
||||||
|
///
|
||||||
|
/// Layout (each section 4-byte NDR-aligned):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 0 32 OrpcThis
|
||||||
|
/// 32 4 local_engine_id i32 LE
|
||||||
|
/// 36 4 domain_marker i32 LE = 0x72657355 ("User" little-endian)
|
||||||
|
/// 40 var bstr (12-byte BSTR header + UTF-16 chars, no NUL)
|
||||||
|
/// (aligned to 4) 4 version i32 LE
|
||||||
|
/// (followed by the InterfacePointer structure for the callback OBJREF)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `domain_marker = 0x72657355` is `"Useu"`-style ASCII reversed; the
|
||||||
|
/// .NET reference writes it verbatim at `cs:146` and the LMX server
|
||||||
|
/// parses it back as a string-form domain identity. The Rust port does
|
||||||
|
/// not interpret it; it round-trips the constant per CLAUDE.md
|
||||||
|
/// "preserve unknown bytes" rule.
|
||||||
|
///
|
||||||
|
/// When `callback_obj_ref` is `None` the encoder writes a 4-byte null
|
||||||
|
/// interface pointer (`cs:134-135`); when `Some(bytes)`, it wraps the
|
||||||
|
/// OBJREF in a 12-byte InterfacePointer header per `cs:206-215`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_register_engine_2_request(
|
||||||
|
orpc_this: OrpcThis,
|
||||||
|
local_engine_id: i32,
|
||||||
|
engine_name: &str,
|
||||||
|
version: i32,
|
||||||
|
callback_obj_ref: Option<&[u8]>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let bstr = encode_bstr_user_marshal(engine_name);
|
||||||
|
let callback = match callback_obj_ref {
|
||||||
|
None => encode_null_interface_pointer().to_vec(),
|
||||||
|
Some(bytes) => encode_interface_pointer(bytes),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bstr_offset = OrpcThis::ENCODED_LEN + 8;
|
||||||
|
let version_offset = align_up(bstr_offset + bstr.len(), 4);
|
||||||
|
let length = align_up(version_offset + 4 + callback.len(), 4);
|
||||||
|
let mut buf = vec![0u8; length];
|
||||||
|
buf[..OrpcThis::ENCODED_LEN].copy_from_slice(&orpc_this.encode());
|
||||||
|
buf[32..36].copy_from_slice(&local_engine_id.to_le_bytes());
|
||||||
|
// "Useu" domain marker — `cs:146`.
|
||||||
|
buf[36..40].copy_from_slice(&0x7265_5355i32.to_le_bytes());
|
||||||
|
buf[40..40 + bstr.len()].copy_from_slice(&bstr);
|
||||||
|
buf[version_offset..version_offset + 4].copy_from_slice(&version.to_le_bytes());
|
||||||
|
let cb_off = version_offset + 4;
|
||||||
|
buf[cb_off..cb_off + callback.len()].copy_from_slice(&callback);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a UTF-16LE BSTR as the LMX MIDL stub expects
|
||||||
|
/// (`NmxService2Messages.cs:156-171`):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 0..4 char_count i32 LE number of UTF-16 code units (no NUL)
|
||||||
|
/// 4..8 byte_count i32 LE 2 * char_count
|
||||||
|
/// 8..12 char_count i32 LE repeated (NDR conformant array max count)
|
||||||
|
/// 12.. UTF-16LE chars (no terminator)
|
||||||
|
/// ```
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_bstr_user_marshal(value: &str) -> Vec<u8> {
|
||||||
|
let utf16: Vec<u16> = value.encode_utf16().collect();
|
||||||
|
let char_count = i32::try_from(utf16.len()).unwrap_or(i32::MAX);
|
||||||
|
let byte_count = i32::try_from(utf16.len() * 2).unwrap_or(i32::MAX);
|
||||||
|
let mut buf = vec![0u8; 12 + utf16.len() * 2];
|
||||||
|
buf[0..4].copy_from_slice(&char_count.to_le_bytes());
|
||||||
|
buf[4..8].copy_from_slice(&byte_count.to_le_bytes());
|
||||||
|
buf[8..12].copy_from_slice(&char_count.to_le_bytes());
|
||||||
|
for (i, ch) in utf16.iter().enumerate() {
|
||||||
|
buf[12 + i * 2..12 + i * 2 + 2].copy_from_slice(&ch.to_le_bytes());
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4-byte null interface pointer — `cs:201-204`. The LMX server treats
|
||||||
|
/// a 4-byte zero referent as "no callback registered".
|
||||||
|
#[must_use]
|
||||||
|
pub const fn encode_null_interface_pointer() -> [u8; 4] {
|
||||||
|
[0, 0, 0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an OBJREF in the InterfacePointer NDR layout — `cs:206-215`:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 0..4 referent_id u32 LE = 0x00020000
|
||||||
|
/// 4..8 length i32 LE = obj_ref.len()
|
||||||
|
/// 8..12 max_count i32 LE = obj_ref.len()
|
||||||
|
/// 12.. obj_ref bytes (padded to 4-byte alignment)
|
||||||
|
/// ```
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_interface_pointer(obj_ref: &[u8]) -> Vec<u8> {
|
||||||
|
let length = align_up(12 + obj_ref.len(), 4);
|
||||||
|
let mut buf = vec![0u8; length];
|
||||||
|
buf[0..4].copy_from_slice(&0x0002_0000u32.to_le_bytes());
|
||||||
|
let len_i32 = i32::try_from(obj_ref.len()).unwrap_or(i32::MAX);
|
||||||
|
buf[4..8].copy_from_slice(&len_i32.to_le_bytes());
|
||||||
|
buf[8..12].copy_from_slice(&len_i32.to_le_bytes());
|
||||||
|
buf[12..12 + obj_ref.len()].copy_from_slice(obj_ref);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Decoders -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a `GetPartnerVersion` response (`cs:39-50`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`RpcError::ShortRead`] if the buffer is shorter than 16 bytes.
|
||||||
|
pub fn parse_get_partner_version_response(
|
||||||
|
buffer: &[u8],
|
||||||
|
) -> Result<NmxGetPartnerVersionResult, RpcError> {
|
||||||
|
let need = OrpcThat::ENCODED_LEN + 8;
|
||||||
|
if buffer.len() < need {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: need,
|
||||||
|
actual: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?;
|
||||||
|
Ok(NmxGetPartnerVersionResult {
|
||||||
|
orpc_that,
|
||||||
|
partner_version: i32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]),
|
||||||
|
hresult: i32::from_le_bytes([buffer[12], buffer[13], buffer[14], buffer[15]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a generic HRESULT response (`cs:173-183`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`RpcError::ShortRead`] if the buffer is shorter than 12 bytes.
|
||||||
|
pub fn parse_hresult_response(buffer: &[u8]) -> Result<NmxHResultResponse, RpcError> {
|
||||||
|
let need = OrpcThat::ENCODED_LEN + 4;
|
||||||
|
if buffer.len() < need {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: need,
|
||||||
|
actual: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let orpc_that = OrpcThat::parse(&buffer[..OrpcThat::ENCODED_LEN])?;
|
||||||
|
Ok(NmxHResultResponse {
|
||||||
|
orpc_that,
|
||||||
|
hresult: i32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn align_up(value: usize, alignment: usize) -> usize {
|
||||||
|
let r = value % alignment;
|
||||||
|
if r == 0 { value } else { value + alignment - r }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
|
||||||
|
fn sample_orpc_this() -> OrpcThis {
|
||||||
|
OrpcThis::create(Guid::new([0xAB; 16]), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nmx_service_clsid_matches_dotnet_d_format() {
|
||||||
|
// .NET `new Guid("AE24BD51-2E80-44CC-905B-E5446C942BEB").ToString("D")`.
|
||||||
|
assert_eq!(
|
||||||
|
NMX_SERVICE_CLSID.to_string(),
|
||||||
|
"ae24bd51-2e80-44cc-905b-e5446c942beb"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opnum_constants_match_dotnet() {
|
||||||
|
assert_eq!(REGISTER_ENGINE_OPNUM, 3);
|
||||||
|
assert_eq!(UNREGISTER_ENGINE_OPNUM, 4);
|
||||||
|
assert_eq!(CONNECT_OPNUM, 5);
|
||||||
|
assert_eq!(TRANSFER_DATA_OPNUM, 6);
|
||||||
|
assert_eq!(ADD_SUBSCRIBER_ENGINE_OPNUM, 7);
|
||||||
|
assert_eq!(REMOVE_SUBSCRIBER_ENGINE_OPNUM, 8);
|
||||||
|
assert_eq!(SET_HEARTBEAT_SEND_INTERVAL_OPNUM, 9);
|
||||||
|
assert_eq!(REGISTER_ENGINE_2_OPNUM, 10);
|
||||||
|
assert_eq!(GET_PARTNER_VERSION_OPNUM, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_partner_version_request_layout() {
|
||||||
|
let buf = encode_get_partner_version_request(sample_orpc_this(), 1, 2, 3);
|
||||||
|
// 32 (OrpcThis) + 12 = 44.
|
||||||
|
assert_eq!(buf.len(), 44);
|
||||||
|
assert_eq!(&buf[32..36], &1i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[36..40], &2i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[40..44], &3i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_request_layout() {
|
||||||
|
let buf = encode_connect_request(sample_orpc_this(), 10, 11, 12, 13);
|
||||||
|
assert_eq!(buf.len(), 48);
|
||||||
|
assert_eq!(&buf[32..36], &10i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[44..48], &13i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscriber_engine_request_layout() {
|
||||||
|
let buf = encode_subscriber_engine_request(sample_orpc_this(), 1, 2, 3, 4);
|
||||||
|
assert_eq!(buf.len(), 48);
|
||||||
|
assert_eq!(&buf[44..48], &4i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregister_engine_request_layout() {
|
||||||
|
let buf = encode_unregister_engine_request(sample_orpc_this(), 0xCAFE);
|
||||||
|
assert_eq!(buf.len(), 36);
|
||||||
|
assert_eq!(&buf[32..36], &0xCAFEi32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_heartbeat_send_interval_request_layout() {
|
||||||
|
let buf = encode_set_heartbeat_send_interval_request(sample_orpc_this(), 100, 5);
|
||||||
|
assert_eq!(buf.len(), 40);
|
||||||
|
assert_eq!(&buf[32..36], &100i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[36..40], &5i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transfer_data_request_layout_aligned() {
|
||||||
|
// body length 8 — body offset 52 + 8 = 60, already 4-aligned.
|
||||||
|
let body = [0xAAu8; 8];
|
||||||
|
let buf = encode_transfer_data_request(sample_orpc_this(), 1, 2, 3, &body);
|
||||||
|
assert_eq!(buf.len(), 60);
|
||||||
|
assert_eq!(&buf[44..48], &8i32.to_le_bytes()); // length
|
||||||
|
assert_eq!(&buf[48..52], &8i32.to_le_bytes()); // max_count
|
||||||
|
assert_eq!(&buf[52..60], &body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transfer_data_request_layout_padded() {
|
||||||
|
// body length 5 — body offset 52 + 5 = 57, padded to 60.
|
||||||
|
let body = [0xBBu8; 5];
|
||||||
|
let buf = encode_transfer_data_request(sample_orpc_this(), 1, 2, 3, &body);
|
||||||
|
assert_eq!(buf.len(), 60);
|
||||||
|
assert_eq!(&buf[44..48], &5i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[52..57], &body);
|
||||||
|
// padding bytes 57..60 are zero (default vec! init).
|
||||||
|
assert_eq!(&buf[57..60], &[0u8; 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bstr_user_marshal_layout() {
|
||||||
|
// "AB" (2 chars, 4 UTF-16LE bytes) → header 12 + 4 bytes = 16.
|
||||||
|
let buf = encode_bstr_user_marshal("AB");
|
||||||
|
assert_eq!(buf.len(), 16);
|
||||||
|
assert_eq!(&buf[0..4], &2i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[4..8], &4i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[8..12], &2i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[12..14], &b"A\0"[..]);
|
||||||
|
assert_eq!(&buf[14..16], &b"B\0"[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bstr_empty_string() {
|
||||||
|
let buf = encode_bstr_user_marshal("");
|
||||||
|
assert_eq!(buf.len(), 12);
|
||||||
|
assert_eq!(&buf[0..4], &0i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[4..8], &0i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[8..12], &0i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_interface_pointer_is_4_zero_bytes() {
|
||||||
|
assert_eq!(encode_null_interface_pointer(), [0u8; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_pointer_referent_id_and_aligned_length() {
|
||||||
|
// OBJREF length 6 → 12 + 6 = 18 → align 4 → 20.
|
||||||
|
let obj = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06];
|
||||||
|
let buf = encode_interface_pointer(&obj);
|
||||||
|
assert_eq!(buf.len(), 20);
|
||||||
|
assert_eq!(&buf[0..4], &0x0002_0000u32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[4..8], &6i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[8..12], &6i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[12..18], &obj);
|
||||||
|
assert_eq!(&buf[18..20], &[0u8; 2]); // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_engine_2_request_with_callback_objref() {
|
||||||
|
// Uses a 16-byte OBJREF stub.
|
||||||
|
let obj = [0xCCu8; 16];
|
||||||
|
let buf = encode_register_engine_2_request(sample_orpc_this(), 42, "Engine", 6, Some(&obj));
|
||||||
|
// OrpcThis(32) + local_engine(4) + marker(4) + bstr(24) + version(4) + callback(28+pad)
|
||||||
|
// bstr: 12 + 12 (6 UTF-16 chars) = 24
|
||||||
|
// callback: 12 + 16 = 28, already 4-aligned
|
||||||
|
// Total = 32 + 4 + 4 + 24 + 4 + 28 = 96.
|
||||||
|
assert_eq!(buf.len(), 96);
|
||||||
|
assert_eq!(&buf[32..36], &42i32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[36..40], &0x7265_5355i32.to_le_bytes()); // "Useu"
|
||||||
|
// BSTR header at 40..52.
|
||||||
|
assert_eq!(&buf[40..44], &6i32.to_le_bytes()); // 6 chars
|
||||||
|
// version at 64.
|
||||||
|
assert_eq!(&buf[64..68], &6i32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_engine_2_request_with_null_callback() {
|
||||||
|
let buf = encode_register_engine_2_request(sample_orpc_this(), 7, "X", 1, None);
|
||||||
|
// OrpcThis(32) + 4 + 4 + bstr(14 → align 16) + version(4) + callback(4)
|
||||||
|
// bstr: 12 + 2 = 14 → align to 16
|
||||||
|
// callback: 4 (null), version_offset + 4 + 4 = ?. Let's just check total > 0.
|
||||||
|
assert!(buf.len() >= 32 + 4 + 4 + 14 + 4 + 4);
|
||||||
|
// The null interface-pointer slot is 4 bytes of zero at the end.
|
||||||
|
let len = buf.len();
|
||||||
|
assert_eq!(&buf[len - 4..len], &[0u8; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_get_partner_version_response_happy_path() {
|
||||||
|
let mut buf = vec![0u8; 16];
|
||||||
|
// OrpcThat at 0..8 (zeros).
|
||||||
|
buf[8..12].copy_from_slice(&6i32.to_le_bytes()); // partner_version
|
||||||
|
buf[12..16].copy_from_slice(&0i32.to_le_bytes()); // S_OK
|
||||||
|
let r = parse_get_partner_version_response(&buf).unwrap();
|
||||||
|
assert_eq!(r.partner_version, 6);
|
||||||
|
assert_eq!(r.hresult, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_get_partner_version_short_buffer_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_get_partner_version_response(&[0u8; 15]),
|
||||||
|
Err(RpcError::ShortRead {
|
||||||
|
expected: 16,
|
||||||
|
actual: 15
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hresult_response_happy_path() {
|
||||||
|
let mut buf = vec![0u8; 12];
|
||||||
|
buf[8..12].copy_from_slice(&0x8000_4005u32.to_le_bytes()); // E_FAIL
|
||||||
|
let r = parse_hresult_response(&buf).unwrap();
|
||||||
|
assert_eq!(r.hresult, 0x8000_4005u32 as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hresult_response_short_buffer_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_hresult_response(&[0u8; 11]),
|
||||||
|
Err(RpcError::ShortRead {
|
||||||
|
expected: 12,
|
||||||
|
actual: 11
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
|||||||
|
//! `IObjectExporter` body codec — `ResolveOxid` request/response.
|
||||||
|
//!
|
||||||
|
//! Direct port of the codec-only members of
|
||||||
|
//! `src/MxNativeClient/ObjectExporterMessages.cs`. This module covers:
|
||||||
|
//!
|
||||||
|
//! - The `IObjectExporter` interface IID and opnum constants
|
||||||
|
//! (`ObjectExporterMessages.cs:7-13`).
|
||||||
|
//! - DCE/RPC protocol-sequence ids used by `ResolveOxid`
|
||||||
|
//! (`ObjectExporterMessages.cs:15-16`, `[MS-DCOM]` §2.2.10).
|
||||||
|
//! - [`encode_resolve_oxid_request`] — produces the marshalled request stub
|
||||||
|
//! for `IObjectExporter::ResolveOxid` (opnum 0). Mirrors
|
||||||
|
//! `EncodeResolveOxidRequest` (`ObjectExporterMessages.cs:18-37`).
|
||||||
|
//! - [`parse_resolve_oxid_failure`] — extracts the trailing 4-byte error
|
||||||
|
//! status from a failure response stub. Mirrors
|
||||||
|
//! `ParseResolveOxidFailure` (`ObjectExporterMessages.cs:39-47`).
|
||||||
|
//! - [`parse_resolve_oxid_result`] — decodes the success-shape response
|
||||||
|
//! stub (DUALSTRINGARRAY of bindings + IPID + authn-hint + status).
|
||||||
|
//! Mirrors `ParseResolveOxidResult` (`ObjectExporterMessages.cs:49-90`).
|
||||||
|
//!
|
||||||
|
//! **Not ported here:** `src/MxNativeClient/ObjectExporterClient.cs`. Those
|
||||||
|
//! four methods (`ResolveOxidUnauthenticated`,
|
||||||
|
//! `ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`,
|
||||||
|
//! `ResolveOxidWithManagedNtlmPacketIntegrity`) are transport-layer code
|
||||||
|
//! that depend on a `DceRpcTcpClient` we have not yet ported. They will
|
||||||
|
//! follow once the transport crate exists.
|
||||||
|
//!
|
||||||
|
//! The dual-string-array decode in this module is intentionally **not**
|
||||||
|
//! consolidated with [`crate::objref::ComObjRef`]'s decoder. The two
|
||||||
|
//! shapes differ in three documented ways
|
||||||
|
//! (`ObjectExporterMessages.cs:92-126`):
|
||||||
|
//!
|
||||||
|
//! 1. The loop iterates `entries` u16 code units exactly — **not**
|
||||||
|
//! `min(entries, data.len()/2)` like the OBJREF parser
|
||||||
|
//! (`ComObjRef.cs:59`). The caller is responsible for slicing the input
|
||||||
|
//! to the expected byte length up front.
|
||||||
|
//! 2. Non-printable code units are escaped as a single `'?'` character —
|
||||||
|
//! **not** the `<XXXX>` lowercase-hex form used by `ComObjRef`.
|
||||||
|
//! 3. The protocol label is either `"ncacn_ip_tcp"` (for `0x0007`) or a
|
||||||
|
//! decimal-formatted `"protseq_0x{:04x}"` fallback — there is no other
|
||||||
|
//! tower-id table.
|
||||||
|
|
||||||
|
// Direct byte indexing — every access is guarded by an explicit length check
|
||||||
|
// and the result reads as a 1:1 mirror of the .NET `BinaryPrimitives` calls.
|
||||||
|
// `.get(n)?` would obscure the byte map. Mirrors the rationale documented in
|
||||||
|
// `crates/mxaccess-codec/src/reference_handle.rs:7-11` and `objref.rs:25`.
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::error::RpcError;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
use crate::objref::ComDualStringEntry;
|
||||||
|
|
||||||
|
/// `IObjectExporter` IID `99FCFEC4-5260-101B-BBCB-00AA0021347A`
|
||||||
|
/// (`ObjectExporterMessages.cs:7`, `[MS-DCOM]` §1.9). The wire bytes are
|
||||||
|
/// .NET `Guid.TryWriteBytes(span)` order: first three groups
|
||||||
|
/// little-endian (`Data1` u32 LE, `Data2` u16 LE, `Data3` u16 LE) followed
|
||||||
|
/// by 8 big-endian `Data4` bytes.
|
||||||
|
pub const IOBJECT_EXPORTER_IID: Guid = Guid::new([
|
||||||
|
0xC4, 0xFE, 0xFC, 0x99, 0x60, 0x52, 0x1B, 0x10, 0xBB, 0xCB, 0x00, 0xAA, 0x00, 0x21, 0x34, 0x7A,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// Opnum 0 — `ResolveOxid` (`ObjectExporterMessages.cs:8`,
|
||||||
|
/// `[MS-DCOM]` §3.1.2.5.1.1).
|
||||||
|
pub const RESOLVE_OXID_OPNUM: u16 = 0;
|
||||||
|
/// Opnum 1 — `SimplePing` (`ObjectExporterMessages.cs:9`).
|
||||||
|
pub const SIMPLE_PING_OPNUM: u16 = 1;
|
||||||
|
/// Opnum 2 — `ComplexPing` (`ObjectExporterMessages.cs:10`).
|
||||||
|
pub const COMPLEX_PING_OPNUM: u16 = 2;
|
||||||
|
/// Opnum 3 — `ServerAlive` (`ObjectExporterMessages.cs:11`).
|
||||||
|
pub const SERVER_ALIVE_OPNUM: u16 = 3;
|
||||||
|
/// Opnum 4 — `ResolveOxid2` (`ObjectExporterMessages.cs:12`).
|
||||||
|
pub const RESOLVE_OXID2_OPNUM: u16 = 4;
|
||||||
|
/// Opnum 5 — `ServerAlive2` (`ObjectExporterMessages.cs:13`).
|
||||||
|
pub const SERVER_ALIVE2_OPNUM: u16 = 5;
|
||||||
|
|
||||||
|
/// Protocol sequence `ncacn_ip_tcp` (`ObjectExporterMessages.cs:15`,
|
||||||
|
/// `[MS-DCOM]` §2.2.10).
|
||||||
|
pub const PROTSEQ_NCACN_IP_TCP: u16 = 0x0007;
|
||||||
|
/// Protocol sequence `ncalrpc` (`ObjectExporterMessages.cs:16`).
|
||||||
|
pub const PROTSEQ_NCALRPC: u16 = 0x001f;
|
||||||
|
|
||||||
|
/// 4-byte alignment helper. Mirrors `Align`
|
||||||
|
/// (`ObjectExporterMessages.cs:128-132`).
|
||||||
|
const fn align(value: usize, alignment: usize) -> usize {
|
||||||
|
let remainder = value % alignment;
|
||||||
|
if remainder == 0 {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
value + alignment - remainder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode the `IObjectExporter::ResolveOxid` request stub.
|
||||||
|
///
|
||||||
|
/// Wire layout (`ObjectExporterMessages.cs:18-37`):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 8 oxid u64 LE
|
||||||
|
/// 8 2 count (short) u16 LE (= requested_protseqs.len())
|
||||||
|
/// 10 2 <padding> u16 (zero — implicit from buffer init)
|
||||||
|
/// 12 4 count (max) u32 LE (= requested_protseqs.len())
|
||||||
|
/// 16 N*2 protseqs[] u16 LE each
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The buffer length is then 4-byte aligned per `Align(length, 4)`
|
||||||
|
/// (`:26`); for an odd-length protseq array this adds 2 trailing zero
|
||||||
|
/// bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RpcError::Decode`] if `requested_protseqs` is empty —
|
||||||
|
/// mirrors the .NET `ArgumentException` at
|
||||||
|
/// `ObjectExporterMessages.cs:21-23`.
|
||||||
|
pub fn encode_resolve_oxid_request(
|
||||||
|
oxid: u64,
|
||||||
|
requested_protseqs: &[u16],
|
||||||
|
) -> Result<Vec<u8>, RpcError> {
|
||||||
|
if requested_protseqs.is_empty() {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset: 0,
|
||||||
|
reason: "ResolveOxid request requires at least one protseq",
|
||||||
|
buffer_len: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// u16 protseq array — `len * 2` is identical to .NET's
|
||||||
|
// `requestedProtseqs.Count * sizeof(ushort)` (cs:25).
|
||||||
|
let mut length = 8 + 2 + 2 + 4 + std::mem::size_of_val(requested_protseqs);
|
||||||
|
length = align(length, 4);
|
||||||
|
let mut buffer = vec![0u8; length];
|
||||||
|
|
||||||
|
buffer[0..8].copy_from_slice(&oxid.to_le_bytes());
|
||||||
|
// Truncating cast mirrors the .NET `(ushort)requestedProtseqs.Count`.
|
||||||
|
let count_u16: u16 = (requested_protseqs.len() as u32) as u16;
|
||||||
|
buffer[8..10].copy_from_slice(&count_u16.to_le_bytes());
|
||||||
|
let count_u32: u32 = requested_protseqs.len() as u32;
|
||||||
|
buffer[12..16].copy_from_slice(&count_u32.to_le_bytes());
|
||||||
|
for (i, ps) in requested_protseqs.iter().enumerate() {
|
||||||
|
let off = 16 + i * size_of::<u16>();
|
||||||
|
buffer[off..off + 2].copy_from_slice(&ps.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure-shape response of `IObjectExporter::ResolveOxid` — only the
|
||||||
|
/// trailing 4-byte HRESULT/`error_status` is meaningful.
|
||||||
|
///
|
||||||
|
/// Mirrors `ResolveOxidFailure` (`ObjectExporterMessages.cs:135`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ResolveOxidFailure {
|
||||||
|
pub error_status: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Success-shape response of `IObjectExporter::ResolveOxid` — the
|
||||||
|
/// DUALSTRINGARRAY of server bindings + IPID for `IRemUnknown` +
|
||||||
|
/// authn-svc hint + final status.
|
||||||
|
///
|
||||||
|
/// Mirrors `ResolveOxidResult` (`ObjectExporterMessages.cs:137-141`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ResolveOxidResult {
|
||||||
|
pub bindings: Vec<ComDualStringEntry>,
|
||||||
|
pub rem_unknown_ipid: Guid,
|
||||||
|
pub authn_hint: u32,
|
||||||
|
pub error_status: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a failure-shape `ResolveOxid` response stub. The 4-byte status
|
||||||
|
/// sits at the **end** of the stub (`stub[^4..]`,
|
||||||
|
/// `ObjectExporterMessages.cs:46`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RpcError::ShortRead`] if the stub is shorter than 4 bytes —
|
||||||
|
/// mirrors the .NET `ArgumentException` at
|
||||||
|
/// `ObjectExporterMessages.cs:41-44`.
|
||||||
|
pub fn parse_resolve_oxid_failure(stub: &[u8]) -> Result<ResolveOxidFailure, RpcError> {
|
||||||
|
if stub.len() < 4 {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: 4,
|
||||||
|
actual: stub.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let tail = &stub[stub.len() - 4..];
|
||||||
|
Ok(ResolveOxidFailure {
|
||||||
|
error_status: u32::from_le_bytes([tail[0], tail[1], tail[2], tail[3]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a success-shape `ResolveOxid` response stub.
|
||||||
|
///
|
||||||
|
/// Wire layout (`ObjectExporterMessages.cs:49-90`):
|
||||||
|
///
|
||||||
|
/// ```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 each, length = entries * 2 bytes
|
||||||
|
/// ... .. padding to next 4-byte boundary
|
||||||
|
/// ... 16 rem_unknown_ipid GUID
|
||||||
|
/// ... 4 authn_hint u32 LE
|
||||||
|
/// ... 4 error_status u32 LE
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Notable behaviors mirrored from the .NET source:
|
||||||
|
///
|
||||||
|
/// - If `referent_id == 0` the bindings are empty, IPID is zero, authn
|
||||||
|
/// hint is zero, and the error status is read from the **trailing** 4
|
||||||
|
/// bytes (`ObjectExporterMessages.cs:57-61`).
|
||||||
|
/// - If `max_count < entries` the input is rejected
|
||||||
|
/// (`ObjectExporterMessages.cs:66-69`).
|
||||||
|
/// - `arrayBytes = max_count * sizeof(u16)` is the conformant-array byte
|
||||||
|
/// length; the dual-string decode is sliced to `entries * 2` bytes
|
||||||
|
/// (`:78`). The trailing fields read offset is then 4-byte aligned
|
||||||
|
/// (`:79`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RpcError::ShortRead`] when `stub.len() < 32`
|
||||||
|
/// (`ObjectExporterMessages.cs:51-54`).
|
||||||
|
/// - [`RpcError::Decode`] when `max_count < entries`
|
||||||
|
/// (`:66-69`), the conformant array runs past the buffer (`:73-76`),
|
||||||
|
/// or the trailing 24 bytes are truncated (`:80-83`).
|
||||||
|
pub fn parse_resolve_oxid_result(stub: &[u8]) -> Result<ResolveOxidResult, 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(ResolveOxidResult {
|
||||||
|
bindings: Vec::new(),
|
||||||
|
rem_unknown_ipid: Guid::ZERO,
|
||||||
|
authn_hint: 0,
|
||||||
|
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: "ResolveOxid DUALSTRINGARRAY max count is smaller than entry count",
|
||||||
|
buffer_len: stub.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let array_offset: usize = 12;
|
||||||
|
// `checked((int)maxCount * sizeof(ushort))` (`:72`). max_count fits in
|
||||||
|
// u32; multiplying by 2 fits in u64 with no overflow on any platform.
|
||||||
|
let array_bytes: usize = match (max_count as usize).checked_mul(2) {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset: 4,
|
||||||
|
reason: "ResolveOxid 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: "ResolveOxid 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);
|
||||||
|
if offset.checked_add(24).is_none_or(|end| end > stub.len()) {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset,
|
||||||
|
reason: "ResolveOxid 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 error_status = u32::from_le_bytes([
|
||||||
|
stub[offset + 20],
|
||||||
|
stub[offset + 21],
|
||||||
|
stub[offset + 22],
|
||||||
|
stub[offset + 23],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Ok(ResolveOxidResult {
|
||||||
|
bindings: decoded,
|
||||||
|
rem_unknown_ipid: ipid,
|
||||||
|
authn_hint,
|
||||||
|
error_status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the dual-string-array slice produced by
|
||||||
|
/// `IObjectExporter::ResolveOxid`.
|
||||||
|
///
|
||||||
|
/// Mirrors `DecodeDualStringArray` (`ObjectExporterMessages.cs:92-126`).
|
||||||
|
///
|
||||||
|
/// **This is intentionally a different shape than
|
||||||
|
/// [`crate::objref::ComObjRef`]'s dual-string decoder.** Three differences
|
||||||
|
/// vs. `ComObjRef.cs:57-102`:
|
||||||
|
///
|
||||||
|
/// 1. The loop iterates `entries` u16 code units exactly. The caller is
|
||||||
|
/// expected to have sliced `data` to `entries * 2` bytes already
|
||||||
|
/// (`ObjectExporterMessages.cs:78`).
|
||||||
|
/// 2. Non-printable code units are emitted as **`'?'`** rather than
|
||||||
|
/// `<XXXX>` (`:115`).
|
||||||
|
/// 3. The protocol label is either `"ncacn_ip_tcp"` (for tower id
|
||||||
|
/// `0x0007`) or `format!("protseq_0x{:04x}", tower_id)` — no other
|
||||||
|
/// tower table is consulted (`:120`).
|
||||||
|
///
|
||||||
|
/// `is_security_binding` is set when the entry's start index (in u16
|
||||||
|
/// code units) is at or past `security_offset` (`:122`).
|
||||||
|
pub fn decode_dual_string_array(
|
||||||
|
data: &[u8],
|
||||||
|
entries: u16,
|
||||||
|
security_offset: u16,
|
||||||
|
) -> Vec<ComDualStringEntry> {
|
||||||
|
let entries = entries as usize;
|
||||||
|
let mut strings = Vec::new();
|
||||||
|
|
||||||
|
let mut i: usize = 0;
|
||||||
|
while i < entries {
|
||||||
|
let entry_start = i;
|
||||||
|
// Bound u16 reads to the supplied slice; the .NET source assumes
|
||||||
|
// the caller pre-sliced to `entries * 2` and would otherwise throw
|
||||||
|
// an `ArgumentOutOfRangeException`. Mirror that contract by
|
||||||
|
// stopping early if the data was over-trimmed.
|
||||||
|
if i * 2 + 2 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let tower_id = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
|
||||||
|
i += 1;
|
||||||
|
if tower_id == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = String::new();
|
||||||
|
while i < entries {
|
||||||
|
if i * 2 + 2 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let value = u16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
|
||||||
|
i += 1;
|
||||||
|
if value == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// `value >= 0x20 && value <= 0x7e ? (char)value : '?'` (:115).
|
||||||
|
if (0x20..=0x7e).contains(&value) {
|
||||||
|
text.push(value as u8 as char);
|
||||||
|
} else {
|
||||||
|
text.push('?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canonical `"ncacn_ip_tcp"` label (tower 0x0007) is borrowed
|
||||||
|
// from a `&'static str`; everything else is owned. `ComDualStringEntry::protocol`
|
||||||
|
// is `Cow<'static, str>` — see the type-doc on that struct for why
|
||||||
|
// the OBJREF and OXID parsers emit different protocol labels for
|
||||||
|
// the same tower id.
|
||||||
|
let protocol: std::borrow::Cow<'static, str> = if tower_id == PROTSEQ_NCACN_IP_TCP {
|
||||||
|
std::borrow::Cow::Borrowed("ncacn_ip_tcp")
|
||||||
|
} else {
|
||||||
|
std::borrow::Cow::Owned(format!("protseq_0x{:04x}", tower_id))
|
||||||
|
};
|
||||||
|
|
||||||
|
strings.push(ComDualStringEntry {
|
||||||
|
tower_id,
|
||||||
|
protocol,
|
||||||
|
value: text,
|
||||||
|
is_security_binding: entry_start >= security_offset as usize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
strings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time invariants: opnums and protseq constants match
|
||||||
|
// `ObjectExporterMessages.cs:8-16`.
|
||||||
|
const _: () = assert!(RESOLVE_OXID_OPNUM == 0);
|
||||||
|
const _: () = assert!(SIMPLE_PING_OPNUM == 1);
|
||||||
|
const _: () = assert!(COMPLEX_PING_OPNUM == 2);
|
||||||
|
const _: () = assert!(SERVER_ALIVE_OPNUM == 3);
|
||||||
|
const _: () = assert!(RESOLVE_OXID2_OPNUM == 4);
|
||||||
|
const _: () = assert!(SERVER_ALIVE2_OPNUM == 5);
|
||||||
|
const _: () = assert!(PROTSEQ_NCACN_IP_TCP == 0x0007);
|
||||||
|
const _: () = assert!(PROTSEQ_NCALRPC == 0x001f);
|
||||||
|
// Spot-check the IID wire layout: first byte is `Data1` LSB (0xC4) and
|
||||||
|
// the trailing big-endian half of `Data4` ends in 0x7A.
|
||||||
|
const _: () = assert!(IOBJECT_EXPORTER_IID.0[0] == 0xC4);
|
||||||
|
const _: () = assert!(IOBJECT_EXPORTER_IID.0[15] == 0x7A);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Wire bytes of `99FCFEC4-5260-101B-BBCB-00AA0021347A` as produced
|
||||||
|
/// by .NET `new Guid("...").TryWriteBytes(span)`. First three groups
|
||||||
|
/// are little-endian, last 8 bytes big-endian. Hand-computed:
|
||||||
|
/// `Data1` = 0x99FCFEC4 → [C4 FE FC 99]
|
||||||
|
/// `Data2` = 0x5260 → [60 52]
|
||||||
|
/// `Data3` = 0x101B → [1B 10]
|
||||||
|
/// `Data4` = BB CB 00 AA 00 21 34 7A (already BE)
|
||||||
|
const IID_WIRE_BYTES: [u8; 16] = [
|
||||||
|
0xC4, 0xFE, 0xFC, 0x99, 0x60, 0x52, 0x1B, 0x10, 0xBB, 0xCB, 0x00, 0xAA, 0x00, 0x21, 0x34,
|
||||||
|
0x7A,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iid_constant_matches_dotnet_wire_bytes() {
|
||||||
|
assert_eq!(*IOBJECT_EXPORTER_IID.as_bytes(), IID_WIRE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iid_display_matches_dotnet_d_format() {
|
||||||
|
// .NET `new Guid("99FCFEC4-5260-101B-BBCB-00AA0021347A").ToString("D")`
|
||||||
|
// is lowercase `"99fcfec4-5260-101b-bbcb-00aa0021347a"`.
|
||||||
|
assert_eq!(
|
||||||
|
IOBJECT_EXPORTER_IID.to_string(),
|
||||||
|
"99fcfec4-5260-101b-bbcb-00aa0021347a"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opnum_constants() {
|
||||||
|
// Mirrors ObjectExporterMessages.cs:8-13.
|
||||||
|
assert_eq!(RESOLVE_OXID_OPNUM, 0);
|
||||||
|
assert_eq!(SIMPLE_PING_OPNUM, 1);
|
||||||
|
assert_eq!(COMPLEX_PING_OPNUM, 2);
|
||||||
|
assert_eq!(SERVER_ALIVE_OPNUM, 3);
|
||||||
|
assert_eq!(RESOLVE_OXID2_OPNUM, 4);
|
||||||
|
assert_eq!(SERVER_ALIVE2_OPNUM, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protseq_constants() {
|
||||||
|
// Mirrors ObjectExporterMessages.cs:15-16.
|
||||||
|
assert_eq!(PROTSEQ_NCACN_IP_TCP, 0x0007);
|
||||||
|
assert_eq!(PROTSEQ_NCALRPC, 0x001f);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn align_helper_matches_dotnet() {
|
||||||
|
// ObjectExporterMessages.cs:128-132.
|
||||||
|
assert_eq!(align(0, 4), 0);
|
||||||
|
assert_eq!(align(1, 4), 4);
|
||||||
|
assert_eq!(align(3, 4), 4);
|
||||||
|
assert_eq!(align(4, 4), 4);
|
||||||
|
assert_eq!(align(5, 4), 8);
|
||||||
|
assert_eq!(align(18, 4), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_resolve_oxid_request_one_protseq() {
|
||||||
|
// protseqs = [0x0007] -> body length = 8 + 2 + 2 + 4 + 2 = 18 →
|
||||||
|
// aligned up to 20.
|
||||||
|
let oxid = 0x1122_3344_5566_7788u64;
|
||||||
|
let buf = encode_resolve_oxid_request(oxid, &[PROTSEQ_NCACN_IP_TCP]).unwrap();
|
||||||
|
assert_eq!(buf.len(), 20);
|
||||||
|
// Layout asserts.
|
||||||
|
assert_eq!(&buf[0..8], &oxid.to_le_bytes());
|
||||||
|
assert_eq!(&buf[8..10], &1u16.to_le_bytes());
|
||||||
|
// padding at 10..12 must be zero.
|
||||||
|
assert_eq!(&buf[10..12], &[0u8, 0u8]);
|
||||||
|
assert_eq!(&buf[12..16], &1u32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
|
||||||
|
// 4-byte alignment padding at the tail.
|
||||||
|
assert_eq!(&buf[18..20], &[0u8, 0u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_resolve_oxid_request_two_protseqs() {
|
||||||
|
// [0x0007, 0x001f] → 8 + 2 + 2 + 4 + 4 = 20 (already aligned).
|
||||||
|
let buf = encode_resolve_oxid_request(0, &[PROTSEQ_NCACN_IP_TCP, PROTSEQ_NCALRPC]).unwrap();
|
||||||
|
assert_eq!(buf.len(), 20);
|
||||||
|
assert_eq!(&buf[8..10], &2u16.to_le_bytes());
|
||||||
|
assert_eq!(&buf[12..16], &2u32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
|
||||||
|
assert_eq!(&buf[18..20], &PROTSEQ_NCALRPC.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_resolve_oxid_request_three_protseqs_aligned() {
|
||||||
|
// [0x0007, 0x001f, 0x0007] → 8 + 2 + 2 + 4 + 6 = 22 → aligned to 24.
|
||||||
|
let buf = encode_resolve_oxid_request(
|
||||||
|
0,
|
||||||
|
&[PROTSEQ_NCACN_IP_TCP, PROTSEQ_NCALRPC, PROTSEQ_NCACN_IP_TCP],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(buf.len(), 24);
|
||||||
|
assert_eq!(&buf[8..10], &3u16.to_le_bytes());
|
||||||
|
assert_eq!(&buf[12..16], &3u32.to_le_bytes());
|
||||||
|
assert_eq!(&buf[16..18], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
|
||||||
|
assert_eq!(&buf[18..20], &PROTSEQ_NCALRPC.to_le_bytes());
|
||||||
|
assert_eq!(&buf[20..22], &PROTSEQ_NCACN_IP_TCP.to_le_bytes());
|
||||||
|
// Trailing alignment padding.
|
||||||
|
assert_eq!(&buf[22..24], &[0u8, 0u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_resolve_oxid_request_empty_errors() {
|
||||||
|
let err = encode_resolve_oxid_request(0, &[]).unwrap_err();
|
||||||
|
match err {
|
||||||
|
RpcError::Decode { reason, .. } => {
|
||||||
|
assert!(reason.contains("at least one protseq"));
|
||||||
|
}
|
||||||
|
other => panic!("expected RpcError::Decode, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_failure_4_bytes() {
|
||||||
|
// Single 4-byte status.
|
||||||
|
let stub = 0x8000_4005u32.to_le_bytes();
|
||||||
|
let parsed = parse_resolve_oxid_failure(&stub).unwrap();
|
||||||
|
assert_eq!(parsed.error_status, 0x8000_4005);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_failure_12_bytes_takes_tail_4() {
|
||||||
|
// Last 4 bytes only.
|
||||||
|
let mut stub = vec![0u8; 12];
|
||||||
|
stub[8..12].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
|
||||||
|
let parsed = parse_resolve_oxid_failure(&stub).unwrap();
|
||||||
|
assert_eq!(parsed.error_status, 0xDEAD_BEEF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_failure_short_buffer_errors() {
|
||||||
|
let err = parse_resolve_oxid_failure(&[0u8; 3]).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RpcError::ShortRead {
|
||||||
|
expected: 4,
|
||||||
|
actual: 3
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand-build a success-shape `ResolveOxid` response stub with one
|
||||||
|
/// `ncacn_ip_tcp` binding `"AB"` and a single `0x0000` security
|
||||||
|
/// terminator. Returns `(stub, expected_ipid)`.
|
||||||
|
fn build_success_stub() -> (Vec<u8>, Guid) {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
// referent_id (non-zero).
|
||||||
|
buf.extend_from_slice(&0x0000_0001u32.to_le_bytes());
|
||||||
|
|
||||||
|
// dual-string array u16 code units:
|
||||||
|
// [0] tower_id = 0x0007
|
||||||
|
// [1] 'A' = 0x0041
|
||||||
|
// [2] 'B' = 0x0042
|
||||||
|
// [3] 0x0000 terminator
|
||||||
|
// [4] 0x0000 security-binding terminator
|
||||||
|
// entries = 5; security_offset = 4 (entry-start >= 4 are security).
|
||||||
|
let entries: u16 = 5;
|
||||||
|
let max_count: u32 = entries as u32;
|
||||||
|
let security_offset: u16 = 4;
|
||||||
|
|
||||||
|
buf.extend_from_slice(&max_count.to_le_bytes()); // offset 4..8
|
||||||
|
buf.extend_from_slice(&entries.to_le_bytes()); // offset 8..10
|
||||||
|
buf.extend_from_slice(&security_offset.to_le_bytes()); // offset 10..12
|
||||||
|
|
||||||
|
// dual-string array bytes (entries * 2 = 10 bytes; max_count * 2 = 10 — same here).
|
||||||
|
for unit in [0x0007u16, b'A' as u16, b'B' as u16, 0x0000, 0x0000] {
|
||||||
|
buf.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 12 + 10 = 22 bytes, align to 4 → offset 24. Pad 2 bytes.
|
||||||
|
assert_eq!(buf.len(), 22);
|
||||||
|
buf.extend_from_slice(&[0u8, 0u8]);
|
||||||
|
assert_eq!(buf.len(), 24);
|
||||||
|
|
||||||
|
// Trailing 24 bytes: 16-byte IPID + 4-byte authn_hint + 4-byte status.
|
||||||
|
let ipid_bytes: [u8; 16] = [
|
||||||
|
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad,
|
||||||
|
0xae, 0xaf,
|
||||||
|
];
|
||||||
|
buf.extend_from_slice(&ipid_bytes);
|
||||||
|
buf.extend_from_slice(&0x0000_000Au32.to_le_bytes()); // authn_hint
|
||||||
|
buf.extend_from_slice(&0u32.to_le_bytes()); // status
|
||||||
|
|
||||||
|
(buf, Guid::new(ipid_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_result_happy_path() {
|
||||||
|
let (stub, expected_ipid) = build_success_stub();
|
||||||
|
let parsed = parse_resolve_oxid_result(&stub).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.rem_unknown_ipid, expected_ipid);
|
||||||
|
assert_eq!(parsed.authn_hint, 0xA);
|
||||||
|
assert_eq!(parsed.error_status, 0);
|
||||||
|
|
||||||
|
// One ncacn_ip_tcp string-binding "AB".
|
||||||
|
assert_eq!(parsed.bindings.len(), 1);
|
||||||
|
let entry = &parsed.bindings[0];
|
||||||
|
assert_eq!(entry.tower_id, 0x0007);
|
||||||
|
assert_eq!(entry.protocol, "ncacn_ip_tcp");
|
||||||
|
assert_eq!(entry.value, "AB");
|
||||||
|
// entry_start (0) < security_offset (4) → string binding.
|
||||||
|
assert!(!entry.is_security_binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_result_referent_id_zero() {
|
||||||
|
// referent_id = 0 → empty bindings, IPID zero, authn_hint 0,
|
||||||
|
// status from the trailing 4 bytes (`:57-61`).
|
||||||
|
let mut stub = vec![0u8; 32];
|
||||||
|
// referent_id zero (already).
|
||||||
|
// Put the status at the end.
|
||||||
|
stub[28..32].copy_from_slice(&0x8000_0001u32.to_le_bytes());
|
||||||
|
let parsed = parse_resolve_oxid_result(&stub).unwrap();
|
||||||
|
assert!(parsed.bindings.is_empty());
|
||||||
|
assert_eq!(parsed.rem_unknown_ipid, Guid::ZERO);
|
||||||
|
assert_eq!(parsed.authn_hint, 0);
|
||||||
|
assert_eq!(parsed.error_status, 0x8000_0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_result_max_count_lt_entries_errors() {
|
||||||
|
let mut stub = vec![0u8; 64];
|
||||||
|
stub[0..4].copy_from_slice(&1u32.to_le_bytes()); // referent_id != 0
|
||||||
|
stub[4..8].copy_from_slice(&1u32.to_le_bytes()); // max_count = 1
|
||||||
|
stub[8..10].copy_from_slice(&5u16.to_le_bytes()); // entries = 5
|
||||||
|
stub[10..12].copy_from_slice(&0u16.to_le_bytes());
|
||||||
|
let err = parse_resolve_oxid_result(&stub).unwrap_err();
|
||||||
|
match err {
|
||||||
|
RpcError::Decode { reason, .. } => {
|
||||||
|
assert!(reason.contains("max count"));
|
||||||
|
}
|
||||||
|
other => panic!("expected RpcError::Decode, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_result_truncated_trailing_errors() {
|
||||||
|
// Build a valid header but drop the trailing 24 bytes. The
|
||||||
|
// dual-string array is empty (entries=0, max_count=0), so offset
|
||||||
|
// after alignment = 12 + 0 = 12. Buffer length 32 leaves only 20
|
||||||
|
// bytes of trailing space — but the parser needs 24, so it must
|
||||||
|
// error.
|
||||||
|
let mut stub = vec![0u8; 32];
|
||||||
|
stub[0..4].copy_from_slice(&1u32.to_le_bytes()); // referent_id != 0
|
||||||
|
stub[4..8].copy_from_slice(&0u32.to_le_bytes()); // max_count = 0
|
||||||
|
stub[8..10].copy_from_slice(&0u16.to_le_bytes()); // entries = 0
|
||||||
|
stub[10..12].copy_from_slice(&0u16.to_le_bytes()); // security_offset = 0
|
||||||
|
let err = parse_resolve_oxid_result(&stub).unwrap_err();
|
||||||
|
match err {
|
||||||
|
RpcError::Decode { reason, .. } => {
|
||||||
|
assert!(reason.contains("trailing fields are truncated"));
|
||||||
|
}
|
||||||
|
other => panic!("expected RpcError::Decode, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resolve_oxid_result_short_buffer_errors() {
|
||||||
|
let err = parse_resolve_oxid_result(&[0u8; 31]).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RpcError::ShortRead {
|
||||||
|
expected: 32,
|
||||||
|
actual: 31
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_dual_string_array_question_mark_escape() {
|
||||||
|
// `?` (not `<XXXX>`) is the non-printable escape per
|
||||||
|
// ObjectExporterMessages.cs:115. Build:
|
||||||
|
// [0] tower 0x0007
|
||||||
|
// [1] 0x0100 (non-printable)
|
||||||
|
// [2] 'a' (printable)
|
||||||
|
// [3] 0x0000 terminator
|
||||||
|
// entries = 4, security_offset = 4 → no security binding.
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] {
|
||||||
|
data.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decode_dual_string_array(&data, 4, 4);
|
||||||
|
assert_eq!(decoded.len(), 1);
|
||||||
|
assert_eq!(decoded[0].tower_id, 0x0007);
|
||||||
|
assert_eq!(decoded[0].protocol, "ncacn_ip_tcp");
|
||||||
|
// `?` escape (single character), not `<0100>`.
|
||||||
|
assert_eq!(decoded[0].value, "?a");
|
||||||
|
assert!(!decoded[0].is_security_binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_dual_string_array_unknown_protseq_label() {
|
||||||
|
// Tower 0x0009 (ncacn_np in ComObjRef) gets the
|
||||||
|
// `protseq_0x0009` fallback here, **not** the table lookup.
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for unit in [0x0009u16, b'X' as u16, 0x0000] {
|
||||||
|
data.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decode_dual_string_array(&data, 3, 3);
|
||||||
|
assert_eq!(decoded.len(), 1);
|
||||||
|
assert_eq!(decoded[0].protocol, "protseq_0x0009");
|
||||||
|
assert_eq!(decoded[0].value, "X");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_dual_string_array_security_offset_split() {
|
||||||
|
// Two entries, each `tower=0x0007 / value / 0x0000`, total 6 u16
|
||||||
|
// code units. security_offset = 3 means the second entry (start
|
||||||
|
// index 3) is a security binding.
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] {
|
||||||
|
data.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decode_dual_string_array(&data, 6, 3);
|
||||||
|
assert_eq!(decoded.len(), 2);
|
||||||
|
assert_eq!(decoded[0].value, "A");
|
||||||
|
assert!(!decoded[0].is_security_binding);
|
||||||
|
assert_eq!(decoded[1].value, "B");
|
||||||
|
assert!(decoded[1].is_security_binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
//! `IObjectExporter::ResolveOxid` transport wrappers.
|
||||||
|
//!
|
||||||
|
//! Direct port of the codec-driving methods from
|
||||||
|
//! `src/MxNativeClient/ObjectExporterClient.cs`. Two methods land here:
|
||||||
|
//!
|
||||||
|
//! - [`resolve_oxid_unauthenticated`] — mirrors `cs:14-30`
|
||||||
|
//! (`ResolveOxidUnauthenticated`).
|
||||||
|
//! - [`resolve_oxid_with_managed_ntlm_packet_integrity`] — mirrors
|
||||||
|
//! `cs:66-81` (`ResolveOxidWithManagedNtlmPacketIntegrity`).
|
||||||
|
//!
|
||||||
|
//! The two SSPI flavours (`ResolveOxidWithNtlmConnect` at `cs:32-47` and
|
||||||
|
//! `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) wrap
|
||||||
|
//! `System.Net.Security.SspiClientContext` — explicitly out of scope for
|
||||||
|
//! the Rust port. Resolves `design/followups.md` F9 down to the items
|
||||||
|
//! that are .NET-specific.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::ntlm::NtlmClientContext;
|
||||||
|
use crate::object_exporter::{
|
||||||
|
IOBJECT_EXPORTER_IID, RESOLVE_OXID_OPNUM, ResolveOxidFailure, ResolveOxidResult,
|
||||||
|
encode_resolve_oxid_request, parse_resolve_oxid_failure, parse_resolve_oxid_result,
|
||||||
|
};
|
||||||
|
use crate::transport::{DceRpcTcpClient, TransportError};
|
||||||
|
|
||||||
|
/// Outcome of a `ResolveOxid` call. Either the server returned a typed
|
||||||
|
/// `DUALSTRINGARRAY` (success or empty) or a 4-byte `RPC_C_NS_*` failure
|
||||||
|
/// status word.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ResolveOxidOutcome {
|
||||||
|
/// Decoded `DUALSTRINGARRAY` (per `ResolveOxidResult`).
|
||||||
|
Result(ResolveOxidResult),
|
||||||
|
/// 4-byte trailing status (per `ResolveOxidFailure`). Returned when
|
||||||
|
/// the response stub is too short for a full result but matches the
|
||||||
|
/// failure tail shape.
|
||||||
|
Failure(ResolveOxidFailure),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive a single `ResolveOxid` round-trip without authentication.
|
||||||
|
/// Mirrors `ObjectExporterClient.ResolveOxidUnauthenticated`
|
||||||
|
/// (`ObjectExporterClient.cs:14-30`).
|
||||||
|
///
|
||||||
|
/// Steps (mirroring `cs:16-29`):
|
||||||
|
///
|
||||||
|
/// 1. Open a TCP connection to `(host, port)`.
|
||||||
|
/// 2. Bind to `IObjectExporter` (version 0.0).
|
||||||
|
/// 3. Build a `ResolveOxid` request with the supplied `oxid` + `protseqs`
|
||||||
|
/// (defaults to `[ProtseqNcacnIpTcp]` when empty — per `cs:26`).
|
||||||
|
/// 4. Call opnum 0 on the bound context.
|
||||||
|
/// 5. Try [`parse_resolve_oxid_result`] first; if it fails with a typed
|
||||||
|
/// decode error, fall back to [`parse_resolve_oxid_failure`] over the
|
||||||
|
/// last 4 bytes per the .NET reference's two-shape return type.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// I/O, codec, or fault from the server.
|
||||||
|
pub async fn resolve_oxid_unauthenticated(
|
||||||
|
addr: SocketAddr,
|
||||||
|
oxid: u64,
|
||||||
|
requested_protseqs: &[u16],
|
||||||
|
) -> Result<ResolveOxidOutcome, TransportError> {
|
||||||
|
let mut client = DceRpcTcpClient::connect(addr).await?;
|
||||||
|
let _bind = client.bind(IOBJECT_EXPORTER_IID, 0, 0).await?;
|
||||||
|
let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?;
|
||||||
|
let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?;
|
||||||
|
decode_resolve_oxid_response(&response.stub_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive a single `ResolveOxid` round-trip with NTLMv2 packet-integrity
|
||||||
|
/// authentication. Mirrors `ObjectExporterClient.ResolveOxidWithManagedNtlmPacketIntegrity`
|
||||||
|
/// (`cs:66-81`).
|
||||||
|
///
|
||||||
|
/// Steps mirror the unauthenticated variant but the bind is replaced
|
||||||
|
/// with [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`],
|
||||||
|
/// causing every subsequent call to be NTLM-signed.
|
||||||
|
///
|
||||||
|
/// `ntlm` must be a fresh [`NtlmClientContext`] — it is consumed by the
|
||||||
|
/// transport for the lifetime of the connection.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// I/O, codec, NTLM, or fault from the server.
|
||||||
|
pub async fn resolve_oxid_with_managed_ntlm_packet_integrity(
|
||||||
|
addr: SocketAddr,
|
||||||
|
oxid: u64,
|
||||||
|
requested_protseqs: &[u16],
|
||||||
|
ntlm: NtlmClientContext,
|
||||||
|
) -> Result<ResolveOxidOutcome, TransportError> {
|
||||||
|
let mut client = DceRpcTcpClient::connect(addr).await?;
|
||||||
|
let _bind = client
|
||||||
|
.bind_with_managed_ntlm_packet_integrity(IOBJECT_EXPORTER_IID, 0, 0, ntlm)
|
||||||
|
.await?;
|
||||||
|
let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?;
|
||||||
|
let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?;
|
||||||
|
decode_resolve_oxid_response(&response.stub_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default to `[ProtseqNcacnIpTcp]` when the caller passes an empty
|
||||||
|
/// slice — matches `cs:26` (`requestedProtseqs ?? [..]`).
|
||||||
|
fn default_protseqs(requested: &[u16]) -> &[u16] {
|
||||||
|
if requested.is_empty() {
|
||||||
|
&[crate::object_exporter::PROTSEQ_NCACN_IP_TCP]
|
||||||
|
} else {
|
||||||
|
requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a `ResolveOxid` response stub. The .NET reference exposes two
|
||||||
|
/// parsers (`ParseResolveOxidResult` and `ParseResolveOxidFailure`)
|
||||||
|
/// without a discriminator on the wire — the choice is made by the
|
||||||
|
/// caller based on whether the stub looks like a typed result or just a
|
||||||
|
/// 4-byte status. The Rust port mirrors that: try the result parser
|
||||||
|
/// first; on `RpcError::ShortRead` or `RpcError::Decode` fall back to
|
||||||
|
/// the failure parser.
|
||||||
|
fn decode_resolve_oxid_response(stub: &[u8]) -> Result<ResolveOxidOutcome, TransportError> {
|
||||||
|
match parse_resolve_oxid_result(stub) {
|
||||||
|
Ok(result) => Ok(ResolveOxidOutcome::Result(result)),
|
||||||
|
Err(_) => Ok(ResolveOxidOutcome::Failure(parse_resolve_oxid_failure(
|
||||||
|
stub,
|
||||||
|
)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::object_exporter::{
|
||||||
|
IOBJECT_EXPORTER_IID, PROTSEQ_NCACN_IP_TCP, encode_resolve_oxid_request,
|
||||||
|
};
|
||||||
|
use crate::pdu::{PacketType, PduHeader, ResponsePdu};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
fn local_addr() -> SocketAddr {
|
||||||
|
"127.0.0.1:0".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spin a hand-rolled DCE/RPC server that accepts one connection,
|
||||||
|
/// drains a Bind, replies with a minimal BindAck, drains a Request,
|
||||||
|
/// and replies with a Response carrying `stub_data`.
|
||||||
|
async fn one_shot_server(stub_data: Vec<u8>) -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
||||||
|
let listener = TcpListener::bind(local_addr()).await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
// 1. Drain Bind.
|
||||||
|
let mut hdr = [0u8; 16];
|
||||||
|
sock.read_exact(&mut hdr).await.unwrap();
|
||||||
|
let bind_h = PduHeader::decode(&hdr).unwrap();
|
||||||
|
let mut body = vec![0u8; bind_h.fragment_length as usize - 16];
|
||||||
|
sock.read_exact(&mut body).await.unwrap();
|
||||||
|
// Reply with a 16-byte BindAck shell — DceRpcTcpClient::bind
|
||||||
|
// only inspects the header.
|
||||||
|
let resp_h = PduHeader {
|
||||||
|
version: 5,
|
||||||
|
version_minor: 0,
|
||||||
|
packet_type: PacketType::BindAck,
|
||||||
|
packet_flags: 0x03,
|
||||||
|
data_representation: 0x10,
|
||||||
|
fragment_length: 16,
|
||||||
|
auth_length: 0,
|
||||||
|
call_id: bind_h.call_id,
|
||||||
|
};
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
resp_h.encode(&mut out).unwrap();
|
||||||
|
sock.write_all(&out).await.unwrap();
|
||||||
|
|
||||||
|
// 2. Drain Request.
|
||||||
|
sock.read_exact(&mut hdr).await.unwrap();
|
||||||
|
let req_h = PduHeader::decode(&hdr).unwrap();
|
||||||
|
let mut body = vec![0u8; req_h.fragment_length as usize - 16];
|
||||||
|
sock.read_exact(&mut body).await.unwrap();
|
||||||
|
|
||||||
|
// 3. Reply with Response carrying the supplied stub_data.
|
||||||
|
let response = ResponsePdu {
|
||||||
|
header: PduHeader {
|
||||||
|
version: 5,
|
||||||
|
version_minor: 0,
|
||||||
|
packet_type: PacketType::Response,
|
||||||
|
packet_flags: 0x03,
|
||||||
|
data_representation: 0x10,
|
||||||
|
fragment_length: 0, // overwritten by encode
|
||||||
|
auth_length: 0,
|
||||||
|
call_id: req_h.call_id,
|
||||||
|
},
|
||||||
|
allocation_hint: stub_data.len() as u32,
|
||||||
|
context_id: 0,
|
||||||
|
cancel_count: 0,
|
||||||
|
reserved23: 0,
|
||||||
|
stub_data,
|
||||||
|
};
|
||||||
|
let bytes = response.encode();
|
||||||
|
sock.write_all(&bytes).await.unwrap();
|
||||||
|
});
|
||||||
|
(addr, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_oxid_unauthenticated_round_trip() {
|
||||||
|
// Build a synthetic ResolveOxid result stub: referent=1, max_count=1,
|
||||||
|
// entries=1, security_offset=2, dual-string [0x0007, 0, 0] (8 bytes
|
||||||
|
// padded to 12 with align_up), then 16-byte IPID + authn_hint(4) +
|
||||||
|
// status(4) trailing.
|
||||||
|
let mut stub = Vec::new();
|
||||||
|
// referent_id != 0
|
||||||
|
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 array: 1 u16 (tower=0x0007), then need to align to 4.
|
||||||
|
stub.extend_from_slice(&0x0007u16.to_le_bytes());
|
||||||
|
// Per parse_resolve_oxid_result: array_offset = 12; array_bytes =
|
||||||
|
// max_count * 2 = 2; offset after = align(14, 4) = 16.
|
||||||
|
// We've written 14 bytes so far; pad to 16.
|
||||||
|
stub.push(0);
|
||||||
|
stub.push(0);
|
||||||
|
// Trailing 24 bytes: IPID(16) + authn_hint(4) + status(4)
|
||||||
|
stub.extend_from_slice(&[0xCC; 16]);
|
||||||
|
stub.extend_from_slice(&0x1234u32.to_le_bytes());
|
||||||
|
stub.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
|
||||||
|
let (addr, handle) = one_shot_server(stub).await;
|
||||||
|
let outcome =
|
||||||
|
resolve_oxid_unauthenticated(addr, 0xDEAD_BEEF_CAFE_BABE, &[PROTSEQ_NCACN_IP_TCP])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
ResolveOxidOutcome::Result(r) => {
|
||||||
|
assert_eq!(r.error_status, 0);
|
||||||
|
assert_eq!(r.authn_hint, 0x1234);
|
||||||
|
assert_eq!(r.rem_unknown_ipid.as_bytes(), &[0xCC; 16]);
|
||||||
|
}
|
||||||
|
ResolveOxidOutcome::Failure(_) => panic!("expected Result variant"),
|
||||||
|
}
|
||||||
|
handle.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_oxid_falls_back_to_failure_for_short_stub() {
|
||||||
|
// 4-byte stub with just an error_status — too short for a full
|
||||||
|
// result, must decode as Failure.
|
||||||
|
let stub = 0x8004_0007u32.to_le_bytes().to_vec();
|
||||||
|
let (addr, handle) = one_shot_server(stub).await;
|
||||||
|
let outcome = resolve_oxid_unauthenticated(addr, 0, &[]).await.unwrap();
|
||||||
|
match outcome {
|
||||||
|
ResolveOxidOutcome::Failure(f) => assert_eq!(f.error_status, 0x8004_0007),
|
||||||
|
ResolveOxidOutcome::Result(_) => panic!("expected Failure variant"),
|
||||||
|
}
|
||||||
|
handle.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_protseqs_falls_back_when_empty() {
|
||||||
|
let r = default_protseqs(&[]);
|
||||||
|
assert_eq!(r, &[PROTSEQ_NCACN_IP_TCP]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_protseqs_passes_through_when_provided() {
|
||||||
|
let custom: &[u16] = &[0x0007, 0x001f];
|
||||||
|
let r = default_protseqs(custom);
|
||||||
|
assert_eq!(r, custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile-only check that the IID + opnum constants match the .NET
|
||||||
|
/// reference values used by the wrapper (sanity guard against
|
||||||
|
/// accidental constant drift).
|
||||||
|
#[test]
|
||||||
|
fn iid_and_opnum_constants_present() {
|
||||||
|
// IID first byte is 0xC4 (LE of 0x99FCFEC4 Data1).
|
||||||
|
assert_eq!(IOBJECT_EXPORTER_IID.as_bytes()[0], 0xC4);
|
||||||
|
assert_eq!(RESOLVE_OXID_OPNUM, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the encode helper is callable from this module path
|
||||||
|
/// (catches `pub use` regressions during refactors).
|
||||||
|
#[test]
|
||||||
|
fn encode_resolve_oxid_request_callable() {
|
||||||
|
let buf = encode_resolve_oxid_request(0, &[PROTSEQ_NCACN_IP_TCP]).unwrap();
|
||||||
|
assert!(!buf.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,915 @@
|
|||||||
|
//! `ComObjRef` — DCOM OBJREF parser.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/ComObjRef.cs`. Parses the marshalled
|
||||||
|
//! interface byte stream produced by `CoMarshalInterface` (per `[MS-DCOM]`
|
||||||
|
//! §2.2.18) into a structured form the RPC layer can inspect for OXID/OID/IPID
|
||||||
|
//! and the dual-string-array bindings (string + security towers).
|
||||||
|
//!
|
||||||
|
//! The .NET reference parses 68 fixed bytes followed by the dual-string array,
|
||||||
|
//! which is decoded character-by-character and used purely for diagnostics.
|
||||||
|
//! The Rust port mirrors that parser shape exactly — every field offset,
|
||||||
|
//! the `Math.Min(entries, data.Length / 2)` bound, and the printable-ASCII
|
||||||
|
//! escaping of each UTF-16 code unit are 1:1 with `ComObjRef.cs:18-117`.
|
||||||
|
//!
|
||||||
|
//! `ComObjRefProvider.cs` is **not** ported here — it is a thin wrapper around
|
||||||
|
//! Win32 `CoMarshalInterface` / `IStream` / `GlobalLock` and produces OBJREF
|
||||||
|
//! bytes by calling into ole32. That belongs behind `windows-rs` in a later
|
||||||
|
//! M2/M3 wave; the pure-Rust parser stands alone and is what M2 wave 1 needs
|
||||||
|
//! for OBJREF inspection on inbound activation responses. See followup F1
|
||||||
|
//! in this module's report.
|
||||||
|
|
||||||
|
// Direct byte indexing — every access is guarded by an explicit length check
|
||||||
|
// and the result reads as a 1:1 mirror of the .NET `BinaryPrimitives` calls.
|
||||||
|
// `.get(n)?` would obscure the byte map. Mirrors the rationale documented in
|
||||||
|
// `crates/mxaccess-codec/src/reference_handle.rs:7-11`.
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
// `Guid` and `RpcError` are crate-shared since M2 wave 2 — see
|
||||||
|
// `design/followups.md` F7+F8.
|
||||||
|
pub use crate::error::RpcError;
|
||||||
|
pub use crate::guid::Guid;
|
||||||
|
|
||||||
|
/// Encoded layout per `ComObjRef.cs:25-39`:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
|
||||||
|
/// 4 4 flags u32 LE (1 = OBJREF_STANDARD; only standard parsed)
|
||||||
|
/// 8 16 iid GUID
|
||||||
|
/// 24 4 std_flags u32 LE (STDOBJREF flags)
|
||||||
|
/// 28 4 public_refs u32 LE
|
||||||
|
/// 32 8 oxid u64 LE
|
||||||
|
/// 40 8 oid u64 LE
|
||||||
|
/// 48 16 ipid GUID
|
||||||
|
/// 64 2 dual_string_entries u16 LE (count of u16 code units in the array)
|
||||||
|
/// 66 2 dual_string_security_offset u16 LE (boundary index between string and security bindings)
|
||||||
|
/// 68 .. dual-string array (variable; UTF-16LE code units terminated by 0x0000 per entry)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// **Header length** is 68 bytes; the dual-string array follows starting at
|
||||||
|
/// offset 68. The `dual_string_entries` count is bounded by the actual byte
|
||||||
|
/// length of the trailing bytes via `min(entries, data.len() / 2)`
|
||||||
|
/// (`ComObjRef.cs:59`).
|
||||||
|
pub const OBJREF_HEADER_LEN: usize = 68;
|
||||||
|
|
||||||
|
/// "MEOW" — the OBJREF signature (`ComObjRef.cs:29`, also `[MS-DCOM]` §2.2.18.1).
|
||||||
|
pub const OBJREF_SIGNATURE: u32 = 0x574F_454D;
|
||||||
|
|
||||||
|
const FLAGS_OFFSET: usize = 4;
|
||||||
|
const IID_OFFSET: usize = 8;
|
||||||
|
const STD_FLAGS_OFFSET: usize = 24;
|
||||||
|
const PUBLIC_REFS_OFFSET: usize = 28;
|
||||||
|
const OXID_OFFSET: usize = 32;
|
||||||
|
const OID_OFFSET: usize = 40;
|
||||||
|
const IPID_OFFSET: usize = 48;
|
||||||
|
const DUAL_STRING_ENTRIES_OFFSET: usize = 64;
|
||||||
|
const DUAL_STRING_SECURITY_OFFSET_OFFSET: usize = 66;
|
||||||
|
|
||||||
|
/// One decoded entry of the OBJREF dual-string array. `value` is the
|
||||||
|
/// printable-ASCII escaping of the UTF-16 string per `ComObjRef.cs:82-91` —
|
||||||
|
/// non-printable code units appear as `<XXXX>` lowercase hex. `is_security_binding`
|
||||||
|
/// is set when the entry's start offset (in u16 units) is at or past
|
||||||
|
/// `DualStringSecurityOffset`.
|
||||||
|
///
|
||||||
|
/// Mirrors `ComDualStringEntry` (`ComObjRef.cs:138-145`).
|
||||||
|
///
|
||||||
|
/// `protocol` is `Cow<'static, str>` because the OBJREF parser uses the
|
||||||
|
/// 7-entry static table (`Cow::Borrowed`) while the M2 wave 2 OXID-resolve
|
||||||
|
/// parser uses `format!("protseq_0x{:04x}", tower_id)` (`Cow::Owned`) for
|
||||||
|
/// unknown tower ids (`ObjectExporterMessages.cs:120`). The two parsers
|
||||||
|
/// share the entry type but emit different protocol labels for the same
|
||||||
|
/// tower id — this is intentional and matches the .NET reference.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ComDualStringEntry {
|
||||||
|
pub tower_id: u16,
|
||||||
|
pub protocol: std::borrow::Cow<'static, str>,
|
||||||
|
pub value: String,
|
||||||
|
pub is_security_binding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComDualStringEntry {
|
||||||
|
/// Mirrors `ComDualStringEntry.ToDiagnosticString` (`ComObjRef.cs:140-144`):
|
||||||
|
/// `"<kind>:0x<tower_id_lc>:<protocol>:<value>"`.
|
||||||
|
pub fn to_diagnostic_string(&self) -> String {
|
||||||
|
let kind = if self.is_security_binding {
|
||||||
|
"security"
|
||||||
|
} else {
|
||||||
|
"string"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}:0x{:04x}:{}:{}",
|
||||||
|
kind, self.tower_id, self.protocol, self.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed DCOM OBJREF (standard form).
|
||||||
|
///
|
||||||
|
/// Mirrors `ComObjRef` record (`ComObjRef.cs:5-16`). All eleven fields of the
|
||||||
|
/// .NET record are preserved including `signature`, `flags`, `std_flags`,
|
||||||
|
/// `dual_string_entries`, and `dual_string_security_offset` — even though the
|
||||||
|
/// signature is a known constant, the parser does not validate it (the .NET
|
||||||
|
/// reference doesn't either; bytes are surfaced verbatim per CLAUDE.md
|
||||||
|
/// preserve-unknown-bytes rule).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ComObjRef {
|
||||||
|
pub signature: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub iid: Guid,
|
||||||
|
pub standard_flags: u32,
|
||||||
|
pub public_refs: u32,
|
||||||
|
pub oxid: u64,
|
||||||
|
pub oid: u64,
|
||||||
|
pub ipid: Guid,
|
||||||
|
/// Raw entry-count field — measured in **u16 code units**, not entries.
|
||||||
|
/// Preserved verbatim from the wire even when it overruns the buffer; the
|
||||||
|
/// parse loop bounds itself by `min(entries, data.len() / 2)`
|
||||||
|
/// (`ComObjRef.cs:59`).
|
||||||
|
pub dual_string_entries: u16,
|
||||||
|
/// Boundary (in u16 code-unit indices) between string bindings and
|
||||||
|
/// security bindings within the dual-string array (`ComObjRef.cs:98`).
|
||||||
|
pub dual_string_security_offset: u16,
|
||||||
|
pub dual_string_entries_decoded: Vec<ComDualStringEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComObjRef {
|
||||||
|
/// Header length (68 bytes) before the dual-string array.
|
||||||
|
pub const HEADER_LEN: usize = OBJREF_HEADER_LEN;
|
||||||
|
|
||||||
|
/// Parse an OBJREF buffer. Mirrors `ComObjRef.Parse` (`ComObjRef.cs:18-40`)
|
||||||
|
/// byte-for-byte: 68-byte fixed header followed by a UTF-16LE
|
||||||
|
/// dual-string array bounded by `min(entries, tail.len() / 2)`.
|
||||||
|
///
|
||||||
|
/// The signature field is read but not validated — the .NET reference
|
||||||
|
/// surfaces it verbatim so callers can diff against captures.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RpcError::ShortRead`] if `buffer.len() < 68`
|
||||||
|
/// (matches `ComObjRef.cs:20-23`).
|
||||||
|
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
|
||||||
|
if buffer.len() < Self::HEADER_LEN {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: Self::HEADER_LEN,
|
||||||
|
actual: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let dual_string_entries = read_u16_le(buffer, DUAL_STRING_ENTRIES_OFFSET);
|
||||||
|
let security_offset = read_u16_le(buffer, DUAL_STRING_SECURITY_OFFSET_OFFSET);
|
||||||
|
|
||||||
|
let mut iid_bytes = [0u8; 16];
|
||||||
|
iid_bytes.copy_from_slice(&buffer[IID_OFFSET..IID_OFFSET + 16]);
|
||||||
|
let mut ipid_bytes = [0u8; 16];
|
||||||
|
ipid_bytes.copy_from_slice(&buffer[IPID_OFFSET..IPID_OFFSET + 16]);
|
||||||
|
|
||||||
|
let tail = &buffer[Self::HEADER_LEN..];
|
||||||
|
let decoded = decode_dual_string_array(tail, dual_string_entries, security_offset);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
signature: read_u32_le(buffer, 0),
|
||||||
|
flags: read_u32_le(buffer, FLAGS_OFFSET),
|
||||||
|
iid: Guid(iid_bytes),
|
||||||
|
standard_flags: read_u32_le(buffer, STD_FLAGS_OFFSET),
|
||||||
|
public_refs: read_u32_le(buffer, PUBLIC_REFS_OFFSET),
|
||||||
|
oxid: read_u64_le(buffer, OXID_OFFSET),
|
||||||
|
oid: read_u64_le(buffer, OID_OFFSET),
|
||||||
|
ipid: Guid(ipid_bytes),
|
||||||
|
dual_string_entries,
|
||||||
|
dual_string_security_offset: security_offset,
|
||||||
|
dual_string_entries_decoded: decoded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic line emitter — byte-identical to `ToDiagnosticLines`
|
||||||
|
/// (`ComObjRef.cs:42-55`). The output is intended for matching against
|
||||||
|
/// Frida-captured probe output (`captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt`).
|
||||||
|
pub fn to_diagnostic_lines(&self) -> Vec<String> {
|
||||||
|
let dual_strings = self
|
||||||
|
.dual_string_entries_decoded
|
||||||
|
.iter()
|
||||||
|
.map(ComDualStringEntry::to_diagnostic_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("|");
|
||||||
|
vec![
|
||||||
|
format!("objref_signature=0x{:08X}", self.signature),
|
||||||
|
format!("objref_flags=0x{:08X}", self.flags),
|
||||||
|
format!("objref_iid={}", self.iid),
|
||||||
|
format!("std_flags=0x{:08X}", self.standard_flags),
|
||||||
|
format!("std_public_refs={}", self.public_refs),
|
||||||
|
format!("std_oxid=0x{:016X}", self.oxid),
|
||||||
|
format!("std_oid=0x{:016X}", self.oid),
|
||||||
|
format!("std_ipid={}", self.ipid),
|
||||||
|
format!("dual_string_entries={}", self.dual_string_entries),
|
||||||
|
format!(
|
||||||
|
"dual_string_security_offset={}",
|
||||||
|
self.dual_string_security_offset
|
||||||
|
),
|
||||||
|
format!("dual_strings={}", dual_strings),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the trailing dual-string array. Mirrors
|
||||||
|
/// `DecodeDualStringArray` (`ComObjRef.cs:57-102`).
|
||||||
|
///
|
||||||
|
/// The loop walks `i` in u16 code-unit indices, capped by
|
||||||
|
/// `min(entries, data.len() / 2)`. Each entry begins with a 16-bit
|
||||||
|
/// `tower_id`; if zero, it terminates the string-binding region (the
|
||||||
|
/// `continue` skips to the next index without producing an entry — same as
|
||||||
|
/// the .NET source). Otherwise the following u16 code units up to (but not
|
||||||
|
/// including) the next 0x0000 terminator form the entry's value, escaped
|
||||||
|
/// printable-ASCII per the `0x20..=0x7e` rule.
|
||||||
|
fn decode_dual_string_array(
|
||||||
|
data: &[u8],
|
||||||
|
entries: u16,
|
||||||
|
security_offset: u16,
|
||||||
|
) -> Vec<ComDualStringEntry> {
|
||||||
|
let entries = entries as usize;
|
||||||
|
let count = entries.min(data.len() / 2);
|
||||||
|
let mut strings = Vec::new();
|
||||||
|
|
||||||
|
let mut i: usize = 0;
|
||||||
|
while i < count {
|
||||||
|
let entry_start = i;
|
||||||
|
let tower_id = read_u16_le(data, i * 2);
|
||||||
|
i += 1;
|
||||||
|
if tower_id == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = String::new();
|
||||||
|
while i < count {
|
||||||
|
let value = read_u16_le(data, i * 2);
|
||||||
|
i += 1;
|
||||||
|
if value == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0x20..=0x7e).contains(&value) {
|
||||||
|
// Safe: 0x20..=0x7e is printable ASCII, valid UTF-8.
|
||||||
|
text.push(value as u8 as char);
|
||||||
|
} else {
|
||||||
|
// Non-printable: emit "<XXXX>" lowercase hex (mirrors .NET
|
||||||
|
// `value.ToString("x4", InvariantCulture)`).
|
||||||
|
// write! to a String never fails; ignore the Result.
|
||||||
|
let _ = write!(&mut text, "<{:04x}>", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strings.push(ComDualStringEntry {
|
||||||
|
tower_id,
|
||||||
|
protocol: std::borrow::Cow::Borrowed(protocol_tower_name(tower_id)),
|
||||||
|
value: text,
|
||||||
|
is_security_binding: entry_start >= security_offset as usize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
strings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol-tower name table per `ComObjRef.cs:104-117`. Returns `"unknown"`
|
||||||
|
/// for unrecognised tower ids — mirrors the `_ =>` fall-through.
|
||||||
|
pub const fn protocol_tower_name(tower_id: u16) -> &'static str {
|
||||||
|
match tower_id {
|
||||||
|
0x0007 => "ncacn_ip_tcp",
|
||||||
|
0x0008 => "ncadg_ip_udp",
|
||||||
|
0x0009 => "ncacn_np",
|
||||||
|
0x000f => "ncacn_spx",
|
||||||
|
0x0010 => "ncacn_nb_nb",
|
||||||
|
0x0016 => "ncadg_ip_udp_or_netbios",
|
||||||
|
0x001f => "ncalrpc",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
||||||
|
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
||||||
|
u32::from_le_bytes([
|
||||||
|
bytes[offset],
|
||||||
|
bytes[offset + 1],
|
||||||
|
bytes[offset + 2],
|
||||||
|
bytes[offset + 3],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read_u64_le(bytes: &[u8], offset: usize) -> u64 {
|
||||||
|
u64::from_le_bytes([
|
||||||
|
bytes[offset],
|
||||||
|
bytes[offset + 1],
|
||||||
|
bytes[offset + 2],
|
||||||
|
bytes[offset + 3],
|
||||||
|
bytes[offset + 4],
|
||||||
|
bytes[offset + 5],
|
||||||
|
bytes[offset + 6],
|
||||||
|
bytes[offset + 7],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time invariant: header length matches the documented byte layout.
|
||||||
|
const _: () = assert!(OBJREF_HEADER_LEN == 68);
|
||||||
|
const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ComObjRefBuilder — pure-Rust OBJREF emitter.
|
||||||
|
// Direct port of the second class in
|
||||||
|
// `src/MxNativeClient/ManagedCallbackExporter.cs:337-393`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Auth-service tower IDs the .NET reference advertises in every callback
|
||||||
|
/// OBJREF. Mirrors the hard-coded array at
|
||||||
|
/// `ManagedCallbackExporter.cs:362`. Each id appears in the security-binding
|
||||||
|
/// portion of the dual-string array followed by `0xFFFF` and a terminator.
|
||||||
|
///
|
||||||
|
/// IDs in order: NTLM SSP (0x0009), GSS Negotiate (0x001E), Kerberos (0x0010),
|
||||||
|
/// SSL/TLS (0x000A), Schannel (0x0016), DPA (0x001F), Kerberos extension
|
||||||
|
/// (0x000E). The Rust port carries the same set verbatim — no synthesis.
|
||||||
|
pub const CALLBACK_OBJREF_AUTH_SERVICES: [u16; 7] =
|
||||||
|
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E];
|
||||||
|
|
||||||
|
/// Builds standard OBJREF byte buffers for the callback exporter to publish.
|
||||||
|
///
|
||||||
|
/// Mirrors the static `ComObjRefBuilder` class
|
||||||
|
/// (`src/MxNativeClient/ManagedCallbackExporter.cs:337-393`). The .NET reference
|
||||||
|
/// only ever emits *standard* OBJREFs (`flags = 1`); the Rust port matches.
|
||||||
|
///
|
||||||
|
/// This is the higher-level emitter that builds OBJREF bytes from primitives.
|
||||||
|
/// It is **not** the Win32 `CoMarshalInterface`-based emitter from
|
||||||
|
/// `ComObjRefProvider.cs` — that wrapper around `ole32` is still tracked as
|
||||||
|
/// open follow-up F6 (it requires `windows-rs` and the M2 wave 3 callback
|
||||||
|
/// exporter to register the emitted OBJREF with COM).
|
||||||
|
pub struct ComObjRefBuilder;
|
||||||
|
|
||||||
|
impl ComObjRefBuilder {
|
||||||
|
/// Build a standard-OBJREF buffer for a given IID, OXID/OID/IPID, and one
|
||||||
|
/// or more `ncacn_ip_tcp` string bindings (e.g. `"hostname[5985]"`).
|
||||||
|
/// Mirrors `ComObjRefBuilder.CreateStandardObjRef`
|
||||||
|
/// (`ManagedCallbackExporter.cs:339-392`).
|
||||||
|
///
|
||||||
|
/// # Layout (`cs:348-389`)
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
|
||||||
|
/// 4 4 flags u32 LE = 1
|
||||||
|
/// 8 16 iid GUID
|
||||||
|
/// 24 4 std_flags u32 LE
|
||||||
|
/// 28 4 public_refs u32 LE
|
||||||
|
/// 32 8 oxid u64 LE
|
||||||
|
/// 40 8 oid u64 LE
|
||||||
|
/// 48 16 ipid GUID
|
||||||
|
/// 64 2 entries u16 LE (count of u16 code units below)
|
||||||
|
/// 66 2 security_offset u16 LE (in u16 code units)
|
||||||
|
/// 68 .. dual-string array (variable-length u16 LE words)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # `entries` and `security_offset`
|
||||||
|
///
|
||||||
|
/// `entries` is the **total u16-code-unit count** of the dual-string
|
||||||
|
/// array (string bindings + 0 separator + 7 security entries + final 0).
|
||||||
|
/// `security_offset` is the index (in u16 units) where security bindings
|
||||||
|
/// begin — `cs:348` computes this as
|
||||||
|
/// `sum(1 + binding.len() + 1 for binding in stringBindings) + 1`, i.e.
|
||||||
|
/// per-binding `tower_id` (1 word) + `binding.len()` ASCII chars (one
|
||||||
|
/// word each) + null terminator (1 word), plus the trailing 0 separator
|
||||||
|
/// that ends the string section.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Never panics. All length math saturates: bindings longer than
|
||||||
|
/// `u16::MAX - HEADER_LEN/2 - SECURITY_TAIL_LEN` are not representable
|
||||||
|
/// in the 16-bit `entries` field, and the .NET reference does not guard
|
||||||
|
/// against this either; callers are expected to keep bindings short
|
||||||
|
/// (typical `hostname[port]` is < 100 chars).
|
||||||
|
#[must_use]
|
||||||
|
pub fn create_standard_objref(
|
||||||
|
iid: Guid,
|
||||||
|
std_flags: u32,
|
||||||
|
public_refs: u32,
|
||||||
|
oxid: u64,
|
||||||
|
oid: u64,
|
||||||
|
ipid: Guid,
|
||||||
|
string_bindings: &[&str],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
// security_offset = sum_{b in string_bindings}(1 + b.len() + 1) + 1
|
||||||
|
// (cs:348). u16-truncating cast mirrors `(ushort)`.
|
||||||
|
let security_offset: u16 = string_bindings
|
||||||
|
.iter()
|
||||||
|
.map(|b| 1 + b.len() + 1)
|
||||||
|
.sum::<usize>()
|
||||||
|
.saturating_add(1)
|
||||||
|
.min(u16::MAX as usize) as u16;
|
||||||
|
|
||||||
|
// Build the u16 word array.
|
||||||
|
let mut words: Vec<u16> = Vec::new();
|
||||||
|
|
||||||
|
// String-bindings section: per binding, [0x0007 (ncacn_ip_tcp), each
|
||||||
|
// ASCII char as u16, terminator 0] (cs:350-359).
|
||||||
|
for binding in string_bindings {
|
||||||
|
words.push(0x0007);
|
||||||
|
for ch in binding.chars() {
|
||||||
|
words.push(ch as u16);
|
||||||
|
}
|
||||||
|
words.push(0);
|
||||||
|
}
|
||||||
|
// 0 separator that ends the string section (cs:361).
|
||||||
|
words.push(0);
|
||||||
|
|
||||||
|
// Security-bindings section: 7 hard-coded tower entries, each
|
||||||
|
// [tower_id, 0xFFFF, 0] (cs:362-367).
|
||||||
|
for &auth in &CALLBACK_OBJREF_AUTH_SERVICES {
|
||||||
|
words.push(auth);
|
||||||
|
words.push(0xFFFF);
|
||||||
|
words.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final terminator (cs:369).
|
||||||
|
words.push(0);
|
||||||
|
|
||||||
|
// u16-truncating cast mirrors `(ushort)words.Count` (cs:371).
|
||||||
|
let entries: u16 = words.len().min(u16::MAX as usize) as u16;
|
||||||
|
let mut buffer = vec![0u8; OBJREF_HEADER_LEN + words.len() * 2];
|
||||||
|
|
||||||
|
// Fixed 68-byte header (cs:373-382).
|
||||||
|
buffer[0..4].copy_from_slice(&OBJREF_SIGNATURE.to_le_bytes());
|
||||||
|
buffer[4..8].copy_from_slice(&1u32.to_le_bytes()); // flags = 1 (OBJREF_STANDARD)
|
||||||
|
buffer[8..24].copy_from_slice(iid.as_bytes());
|
||||||
|
buffer[24..28].copy_from_slice(&std_flags.to_le_bytes());
|
||||||
|
buffer[28..32].copy_from_slice(&public_refs.to_le_bytes());
|
||||||
|
buffer[32..40].copy_from_slice(&oxid.to_le_bytes());
|
||||||
|
buffer[40..48].copy_from_slice(&oid.to_le_bytes());
|
||||||
|
buffer[48..64].copy_from_slice(ipid.as_bytes());
|
||||||
|
buffer[64..66].copy_from_slice(&entries.to_le_bytes());
|
||||||
|
buffer[66..68].copy_from_slice(&security_offset.to_le_bytes());
|
||||||
|
|
||||||
|
// Dual-string array body (cs:384-389).
|
||||||
|
let mut offset = OBJREF_HEADER_LEN;
|
||||||
|
for word in &words {
|
||||||
|
buffer[offset..offset + 2].copy_from_slice(&word.to_le_bytes());
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Hand-built OBJREF: signature + flags=1 + sample IID + std_flags +
|
||||||
|
/// public_refs=5 + fake OXID/OID/IPID + dual_string array containing one
|
||||||
|
/// `ncacn_ip_tcp` entry then a `0x0000` terminator. Returns the bytes.
|
||||||
|
fn build_minimal_objref() -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
// signature "MEOW" 0x574F454D LE
|
||||||
|
buf.extend_from_slice(&0x574F_454Du32.to_le_bytes());
|
||||||
|
// flags = 1 (OBJREF_STANDARD)
|
||||||
|
buf.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
// iid (16 bytes; arbitrary)
|
||||||
|
buf.extend_from_slice(&[
|
||||||
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||||
|
0x0f, 0x10,
|
||||||
|
]);
|
||||||
|
// std_flags
|
||||||
|
buf.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
// public_refs = 5
|
||||||
|
buf.extend_from_slice(&5u32.to_le_bytes());
|
||||||
|
// oxid
|
||||||
|
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
|
||||||
|
// oid
|
||||||
|
buf.extend_from_slice(&0xAABB_CCDD_EEFF_0011u64.to_le_bytes());
|
||||||
|
// ipid
|
||||||
|
buf.extend_from_slice(&[
|
||||||
|
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad,
|
||||||
|
0xae, 0xaf,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build the dual-string array:
|
||||||
|
// tower_id 0x0007 (ncacn_ip_tcp)
|
||||||
|
// "AB" as UTF-16LE
|
||||||
|
// 0x0000 terminator
|
||||||
|
// That's 4 u16 code units = 8 bytes.
|
||||||
|
let array_units: [u16; 4] = [0x0007, b'A' as u16, b'B' as u16, 0x0000];
|
||||||
|
let dual_entries: u16 = array_units.len() as u16;
|
||||||
|
let security_offset: u16 = dual_entries; // no security bindings
|
||||||
|
|
||||||
|
// dual_string_entries (count of u16 code units)
|
||||||
|
buf.extend_from_slice(&dual_entries.to_le_bytes());
|
||||||
|
// dual_string_security_offset
|
||||||
|
buf.extend_from_slice(&security_offset.to_le_bytes());
|
||||||
|
|
||||||
|
// header now exactly 68 bytes
|
||||||
|
assert_eq!(buf.len(), 68);
|
||||||
|
|
||||||
|
for unit in array_units {
|
||||||
|
buf.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_minimal_objref() {
|
||||||
|
let bytes = build_minimal_objref();
|
||||||
|
let parsed = ComObjRef::parse(&bytes).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.signature, 0x574F_454D);
|
||||||
|
assert_eq!(parsed.flags, 1);
|
||||||
|
assert_eq!(parsed.standard_flags, 0);
|
||||||
|
assert_eq!(parsed.public_refs, 5);
|
||||||
|
assert_eq!(parsed.oxid, 0x1122_3344_5566_7788);
|
||||||
|
assert_eq!(parsed.oid, 0xAABB_CCDD_EEFF_0011);
|
||||||
|
assert_eq!(parsed.dual_string_entries, 4);
|
||||||
|
assert_eq!(parsed.dual_string_security_offset, 4);
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
|
||||||
|
|
||||||
|
let entry = &parsed.dual_string_entries_decoded[0];
|
||||||
|
assert_eq!(entry.tower_id, 0x0007);
|
||||||
|
assert_eq!(entry.protocol, "ncacn_ip_tcp");
|
||||||
|
assert_eq!(entry.value, "AB");
|
||||||
|
// entry_start (0) < security_offset (4) → string binding.
|
||||||
|
assert!(!entry.is_security_binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_lines_format_minimal() {
|
||||||
|
let bytes = build_minimal_objref();
|
||||||
|
let parsed = ComObjRef::parse(&bytes).unwrap();
|
||||||
|
let lines = parsed.to_diagnostic_lines();
|
||||||
|
// Per ComObjRef.cs:42-55 there are exactly 11 lines.
|
||||||
|
assert_eq!(lines.len(), 11);
|
||||||
|
assert_eq!(lines[0], "objref_signature=0x574F454D");
|
||||||
|
assert_eq!(lines[1], "objref_flags=0x00000001");
|
||||||
|
assert_eq!(lines[3], "std_flags=0x00000000");
|
||||||
|
assert_eq!(lines[4], "std_public_refs=5");
|
||||||
|
assert_eq!(lines[5], "std_oxid=0x1122334455667788");
|
||||||
|
assert_eq!(lines[6], "std_oid=0xAABBCCDDEEFF0011");
|
||||||
|
assert_eq!(lines[8], "dual_string_entries=4");
|
||||||
|
assert_eq!(lines[9], "dual_string_security_offset=4");
|
||||||
|
assert_eq!(lines[10], "dual_strings=string:0x0007:ncacn_ip_tcp:AB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_short_buffer() {
|
||||||
|
// 67-byte buffer (one shy of header) must error, not panic.
|
||||||
|
let err = ComObjRef::parse(&[0u8; 67]).unwrap_err();
|
||||||
|
match err {
|
||||||
|
RpcError::ShortRead { expected, actual } => {
|
||||||
|
assert_eq!(expected, 68);
|
||||||
|
assert_eq!(actual, 67);
|
||||||
|
}
|
||||||
|
other => panic!("expected ShortRead, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_exact_header_no_array() {
|
||||||
|
// 68 bytes with dual_string_entries=0 → no decoded entries.
|
||||||
|
let mut buf = vec![0u8; 68];
|
||||||
|
// signature
|
||||||
|
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
|
||||||
|
let parsed = ComObjRef::parse(&buf).unwrap();
|
||||||
|
assert_eq!(parsed.dual_string_entries, 0);
|
||||||
|
assert_eq!(parsed.dual_string_security_offset, 0);
|
||||||
|
assert!(parsed.dual_string_entries_decoded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_tower_name_table() {
|
||||||
|
// All 7 documented tower ids per ComObjRef.cs:106-117.
|
||||||
|
assert_eq!(protocol_tower_name(0x0007), "ncacn_ip_tcp");
|
||||||
|
assert_eq!(protocol_tower_name(0x0008), "ncadg_ip_udp");
|
||||||
|
assert_eq!(protocol_tower_name(0x0009), "ncacn_np");
|
||||||
|
assert_eq!(protocol_tower_name(0x000f), "ncacn_spx");
|
||||||
|
assert_eq!(protocol_tower_name(0x0010), "ncacn_nb_nb");
|
||||||
|
assert_eq!(protocol_tower_name(0x0016), "ncadg_ip_udp_or_netbios");
|
||||||
|
assert_eq!(protocol_tower_name(0x001f), "ncalrpc");
|
||||||
|
// Fall-through.
|
||||||
|
assert_eq!(protocol_tower_name(0x0000), "unknown");
|
||||||
|
assert_eq!(protocol_tower_name(0xFFFF), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dual_string_array_overrun_bounded() {
|
||||||
|
// Build a header that claims 1000 dual-string code units but only
|
||||||
|
// includes 4 bytes (= 2 code units) of trailing data. The parser
|
||||||
|
// must bound itself via min(entries, data.len()/2) per
|
||||||
|
// ComObjRef.cs:59 and not read past the end.
|
||||||
|
let mut buf = build_minimal_objref();
|
||||||
|
// Truncate the trailing dual-string bytes back to 0 and lie about
|
||||||
|
// entries=1000.
|
||||||
|
buf.truncate(68);
|
||||||
|
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
|
||||||
|
.copy_from_slice(&1000u16.to_le_bytes());
|
||||||
|
let parsed = ComObjRef::parse(&buf).unwrap();
|
||||||
|
// The wire-declared entry count is preserved verbatim per
|
||||||
|
// CLAUDE.md unknown-bytes rule.
|
||||||
|
assert_eq!(parsed.dual_string_entries, 1000);
|
||||||
|
// But the loop bound prevents any decoding.
|
||||||
|
assert!(parsed.dual_string_entries_decoded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_binding_flag_split() {
|
||||||
|
// Build a dual-string array with one string binding then one security
|
||||||
|
// binding. Layout (u16 code units):
|
||||||
|
// [0] tower=0x0007 (string binding starts at index 0)
|
||||||
|
// [1] 'A'
|
||||||
|
// [2] 0x0000 terminator
|
||||||
|
// [3] tower=0x0007 (security binding starts at index 3)
|
||||||
|
// [4] 'B'
|
||||||
|
// [5] 0x0000 terminator
|
||||||
|
// dual_string_entries = 6, security_offset = 3 (entries with start
|
||||||
|
// index >= 3 are security bindings).
|
||||||
|
let mut buf = vec![0u8; 68];
|
||||||
|
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
|
||||||
|
let entries: u16 = 6;
|
||||||
|
let sec_off: u16 = 3;
|
||||||
|
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
|
||||||
|
.copy_from_slice(&entries.to_le_bytes());
|
||||||
|
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
|
||||||
|
.copy_from_slice(&sec_off.to_le_bytes());
|
||||||
|
for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] {
|
||||||
|
buf.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let parsed = ComObjRef::parse(&buf).unwrap();
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded.len(), 2);
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded[0].value, "A");
|
||||||
|
assert!(!parsed.dual_string_entries_decoded[0].is_security_binding);
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded[1].value, "B");
|
||||||
|
assert!(parsed.dual_string_entries_decoded[1].is_security_binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_printable_codeunit_escaped_as_hex() {
|
||||||
|
// tower=0x0007, then a non-printable u16 (0x0100), then 'a', then 0x0000.
|
||||||
|
let mut buf = vec![0u8; 68];
|
||||||
|
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
|
||||||
|
let entries: u16 = 4;
|
||||||
|
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
|
||||||
|
.copy_from_slice(&entries.to_le_bytes());
|
||||||
|
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
|
||||||
|
.copy_from_slice(&entries.to_le_bytes());
|
||||||
|
for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] {
|
||||||
|
buf.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let parsed = ComObjRef::parse(&buf).unwrap();
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
|
||||||
|
// Expect "<0100>a" per the printable-ASCII escape rule
|
||||||
|
// (ComObjRef.cs:82-91).
|
||||||
|
assert_eq!(parsed.dual_string_entries_decoded[0].value, "<0100>a");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured OBJREF from `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6`
|
||||||
|
/// (`managed_callback_objref_hex`). 366 bytes; produced by the .NET
|
||||||
|
/// managed-callback exporter via `MarshalIUnknownObjRef` in the live
|
||||||
|
/// probe. Used to verify that the Rust parser interprets a real-world
|
||||||
|
/// OBJREF identically to the .NET reference.
|
||||||
|
const CAPTURED_OBJREF_HEX: &str = "4D454F5701000000F7929FB448C769418ECAA0670B012746800A000005000000750CC6C8BA9B1EFD3BF71E12FEE5B5C1022C0000DC32FFFF8AC67645E6ED23FF95007F0007004400450053004B0054004F0050002D0036004A004C0033004B004B004F0000000700310030002E003100300030002E0030002E0034003800000007003100370032002E00320039002E003200320034002E0031000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0062006200340031003A0065006500370065003A0035006600640034003A0064006300310038000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0035003000620031003A0038003400360066003A0037006200350031003A006500610034003000000000000900FFFF00001E00FFFF00001000FFFF00000A00FFFF00001600FFFF00001F00FFFF00000E00FFFF00000000";
|
||||||
|
|
||||||
|
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||||
|
let bytes = hex.as_bytes();
|
||||||
|
assert!(bytes.len() % 2 == 0);
|
||||||
|
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||||
|
for chunk in bytes.chunks(2) {
|
||||||
|
let hi = (chunk[0] as char).to_digit(16).unwrap() as u8;
|
||||||
|
let lo = (chunk[1] as char).to_digit(16).unwrap() as u8;
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_objref_parses() {
|
||||||
|
let bytes = hex_decode(CAPTURED_OBJREF_HEX);
|
||||||
|
// probe.stdout.txt:5 reports managed_callback_objref_size=366.
|
||||||
|
assert_eq!(bytes.len(), 366);
|
||||||
|
|
||||||
|
let parsed = ComObjRef::parse(&bytes).unwrap();
|
||||||
|
|
||||||
|
// Signature is the canonical "MEOW".
|
||||||
|
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
|
||||||
|
// OBJREF_STANDARD.
|
||||||
|
assert_eq!(parsed.flags, 1);
|
||||||
|
// public_refs = 5 (per the captured bytes 28..32 = 05 00 00 00).
|
||||||
|
assert_eq!(parsed.public_refs, 5);
|
||||||
|
// Captured bytes at offset 64..68 are `95 00 7F 00`:
|
||||||
|
// dual_string_entries (u16 LE) = 0x0095 = 149
|
||||||
|
// dual_string_security_offset (u16 LE) = 0x007F = 127
|
||||||
|
// 149 u16 units from offset 68 onwards exactly fills the remaining
|
||||||
|
// 366 - 68 = 298 bytes (149 * 2), so the entries count saturates the
|
||||||
|
// buffer — confirming the parser's `min(entries, data.len()/2)`
|
||||||
|
// bound at `ComObjRef.cs:59` produces the same effective walk length.
|
||||||
|
assert_eq!(parsed.dual_string_entries, 0x0095);
|
||||||
|
assert_eq!(parsed.dual_string_security_offset, 0x007F);
|
||||||
|
|
||||||
|
// First decoded string-binding is the hostname over ncacn_ip_tcp.
|
||||||
|
// The probe was run on host DESKTOP-6JL3KKO per the captured UTF-16
|
||||||
|
// bytes immediately following the dual-string header.
|
||||||
|
let first = &parsed.dual_string_entries_decoded[0];
|
||||||
|
assert_eq!(first.tower_id, 0x0007);
|
||||||
|
assert_eq!(first.protocol, "ncacn_ip_tcp");
|
||||||
|
assert_eq!(first.value, "DESKTOP-6JL3KKO");
|
||||||
|
assert!(!first.is_security_binding);
|
||||||
|
|
||||||
|
// Subsequent string-bindings are the IPv4 + IPv6 endpoint addresses.
|
||||||
|
// Confirm we got at least 4 string-bindings (host + v4 + 2x v6) plus
|
||||||
|
// the security-binding entries.
|
||||||
|
assert!(parsed.dual_string_entries_decoded.len() >= 5);
|
||||||
|
|
||||||
|
// At least one entry must be a security binding (entries past
|
||||||
|
// security_offset). The "0900FFFF" sequence in the captured bytes
|
||||||
|
// decodes as tower=0x0009 (ncacn_np) with a single non-printable
|
||||||
|
// u16 0xFFFF — appears in the security-binding tail.
|
||||||
|
assert!(
|
||||||
|
parsed
|
||||||
|
.dual_string_entries_decoded
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.is_security_binding),
|
||||||
|
"expected at least one security binding in captured OBJREF"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The diagnostic emitter must produce exactly 11 lines.
|
||||||
|
let lines = parsed.to_diagnostic_lines();
|
||||||
|
assert_eq!(lines.len(), 11);
|
||||||
|
assert_eq!(lines[0], "objref_signature=0x574F454D");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guid_display_matches_dotnet_d_format() {
|
||||||
|
// .NET Guid("F7929FB4-48C7-6941-8ECA-A0670B012746".replace order):
|
||||||
|
// The 16-byte sequence F7 92 9F B4 48 C7 69 41 8E CA A0 67 0B 01 27 46
|
||||||
|
// displays as "b49f92f7-c748-4169-8eca-a0670b012746" — first three
|
||||||
|
// groups are byte-swapped (LE on wire, BE in display).
|
||||||
|
let g = Guid([
|
||||||
|
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
|
||||||
|
0x27, 0x46,
|
||||||
|
]);
|
||||||
|
assert_eq!(format!("{}", g), "b49f92f7-c748-4169-8eca-a0670b012746");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_length_constant() {
|
||||||
|
assert_eq!(ComObjRef::HEADER_LEN, 68);
|
||||||
|
assert_eq!(OBJREF_HEADER_LEN, 68);
|
||||||
|
assert_eq!(OBJREF_SIGNATURE, 0x574F_454D);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ComObjRefBuilder tests --------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_emits_meow_header_and_flags() {
|
||||||
|
let iid = Guid::new([0xAA; 16]);
|
||||||
|
let ipid = Guid::new([0xBB; 16]);
|
||||||
|
let buf = ComObjRefBuilder::create_standard_objref(
|
||||||
|
iid,
|
||||||
|
0x280,
|
||||||
|
5,
|
||||||
|
0x0123_4567_89AB_CDEF,
|
||||||
|
0xFEDC_BA98_7654_3210,
|
||||||
|
ipid,
|
||||||
|
&["host[5985]"],
|
||||||
|
);
|
||||||
|
assert!(buf.len() >= OBJREF_HEADER_LEN);
|
||||||
|
// Signature
|
||||||
|
assert_eq!(&buf[0..4], &OBJREF_SIGNATURE.to_le_bytes());
|
||||||
|
// flags = 1 (OBJREF_STANDARD)
|
||||||
|
assert_eq!(&buf[4..8], &1u32.to_le_bytes());
|
||||||
|
// IID
|
||||||
|
assert_eq!(&buf[8..24], iid.as_bytes());
|
||||||
|
// std_flags
|
||||||
|
assert_eq!(&buf[24..28], &0x280u32.to_le_bytes());
|
||||||
|
// public_refs
|
||||||
|
assert_eq!(&buf[28..32], &5u32.to_le_bytes());
|
||||||
|
// OXID/OID
|
||||||
|
assert_eq!(&buf[32..40], &0x0123_4567_89AB_CDEFu64.to_le_bytes());
|
||||||
|
assert_eq!(&buf[40..48], &0xFEDC_BA98_7654_3210u64.to_le_bytes());
|
||||||
|
// IPID
|
||||||
|
assert_eq!(&buf[48..64], ipid.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_round_trips_through_parser() {
|
||||||
|
// The emitted OBJREF must parse back through ComObjRef::parse with
|
||||||
|
// the same key fields.
|
||||||
|
let iid = Guid::new([0x11; 16]);
|
||||||
|
let ipid = Guid::new([0x22; 16]);
|
||||||
|
let buf = ComObjRefBuilder::create_standard_objref(
|
||||||
|
iid,
|
||||||
|
0x280,
|
||||||
|
5,
|
||||||
|
0x1111_2222_3333_4444,
|
||||||
|
0x5555_6666_7777_8888,
|
||||||
|
ipid,
|
||||||
|
&["DESKTOP[12345]"],
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed = ComObjRef::parse(&buf).unwrap();
|
||||||
|
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
|
||||||
|
assert_eq!(parsed.flags, 1);
|
||||||
|
assert_eq!(parsed.iid, iid);
|
||||||
|
assert_eq!(parsed.standard_flags, 0x280);
|
||||||
|
assert_eq!(parsed.public_refs, 5);
|
||||||
|
assert_eq!(parsed.oxid, 0x1111_2222_3333_4444);
|
||||||
|
assert_eq!(parsed.oid, 0x5555_6666_7777_8888);
|
||||||
|
assert_eq!(parsed.ipid, ipid);
|
||||||
|
|
||||||
|
// First decoded entry should be the ncacn_ip_tcp string binding,
|
||||||
|
// and at least one security binding (auth-service tail) follows.
|
||||||
|
let first = &parsed.dual_string_entries_decoded[0];
|
||||||
|
assert_eq!(first.tower_id, 0x0007);
|
||||||
|
assert_eq!(first.protocol, "ncacn_ip_tcp");
|
||||||
|
assert_eq!(first.value, "DESKTOP[12345]");
|
||||||
|
assert!(!first.is_security_binding);
|
||||||
|
|
||||||
|
let security_count = parsed
|
||||||
|
.dual_string_entries_decoded
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.is_security_binding)
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
security_count >= 1,
|
||||||
|
"expected at least one security binding, got {security_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_security_offset_matches_dotnet_formula() {
|
||||||
|
// security_offset = sum(1 + binding.len() + 1) + 1 (cs:348).
|
||||||
|
// For one binding "host[12]" (8 chars): 1 + 8 + 1 + 1 = 11.
|
||||||
|
let buf = ComObjRefBuilder::create_standard_objref(
|
||||||
|
Guid::ZERO,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Guid::ZERO,
|
||||||
|
&["host[12]"],
|
||||||
|
);
|
||||||
|
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
|
||||||
|
assert_eq!(security_offset, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_two_bindings_security_offset() {
|
||||||
|
// Two bindings: "a[1]" (4) + "b[2]" (4):
|
||||||
|
// (1+4+1) + (1+4+1) + 1 = 13.
|
||||||
|
let buf = ComObjRefBuilder::create_standard_objref(
|
||||||
|
Guid::ZERO,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Guid::ZERO,
|
||||||
|
&["a[1]", "b[2]"],
|
||||||
|
);
|
||||||
|
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
|
||||||
|
assert_eq!(security_offset, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_emits_seven_security_entries() {
|
||||||
|
// Each security entry contributes 3 u16 words [tower_id, 0xFFFF, 0].
|
||||||
|
// Total security words = 7 * 3 = 21, plus a trailing 0 = 22.
|
||||||
|
// String section for one binding "h[1]" (4 chars): 1+4+1+1 = 7 words.
|
||||||
|
// Total entries = 7 + 22 = 29.
|
||||||
|
let buf =
|
||||||
|
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["h[1]"]);
|
||||||
|
let entries = u16::from_le_bytes([buf[64], buf[65]]);
|
||||||
|
assert_eq!(entries, 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_auth_services_table_matches_dotnet_order() {
|
||||||
|
// The auth-service tower ids in the security tail must appear in the
|
||||||
|
// order the .NET reference writes them (cs:362).
|
||||||
|
assert_eq!(
|
||||||
|
CALLBACK_OBJREF_AUTH_SERVICES,
|
||||||
|
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_total_buffer_length_matches_words_count() {
|
||||||
|
// entries * 2 + HEADER_LEN
|
||||||
|
let buf =
|
||||||
|
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["x[1]"]);
|
||||||
|
let entries = u16::from_le_bytes([buf[64], buf[65]]) as usize;
|
||||||
|
assert_eq!(buf.len(), OBJREF_HEADER_LEN + entries * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! ORPC structures shared by `IObjectExporter` and `IRemUnknown` requests.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/OrpcStructures.cs`. Provides:
|
||||||
|
//!
|
||||||
|
//! - [`ComVersion`] — 4-byte (Major u16, Minor u16) DCOM version pair.
|
||||||
|
//! - [`OrpcThis`] — 32-byte ORPC request header (`OrpcStructures.cs:10-52`).
|
||||||
|
//! - [`OrpcThat`] — 8-byte ORPC response header (`OrpcStructures.cs:54-77`).
|
||||||
|
//! - [`MInterfacePointer`] — length-prefixed OBJREF wrapper
|
||||||
|
//! (`OrpcStructures.cs:79-109`).
|
||||||
|
//! - [`StdObjRef`] — 40-byte STDOBJREF body (`OrpcStructures.cs:111-140`).
|
||||||
|
//!
|
||||||
|
//! All multi-byte fields are little-endian.
|
||||||
|
//!
|
||||||
|
//! These types are M2 wave 2 prerequisites for [`crate::object_exporter`] and
|
||||||
|
//! [`crate::rem_unknown`]; the wave 2 agents import them rather than each
|
||||||
|
//! defining their own ORPC framing.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::error::RpcError;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
|
||||||
|
/// `OrpcStructures.cs:5-8` — DCOM version pair.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ComVersion {
|
||||||
|
pub major: u16,
|
||||||
|
pub minor: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComVersion {
|
||||||
|
/// Default version `5.7` per `OrpcStructures.cs:7`.
|
||||||
|
pub const VERSION_5_7: ComVersion = ComVersion { major: 5, minor: 7 };
|
||||||
|
|
||||||
|
pub const fn new(major: u16, minor: u16) -> Self {
|
||||||
|
Self { major, minor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ComVersion {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::VERSION_5_7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32-byte ORPC request header (without extensions).
|
||||||
|
/// Mirrors `OrpcThis` (`OrpcStructures.cs:10-52`).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 2 version.major u16 LE
|
||||||
|
/// 2 2 version.minor u16 LE
|
||||||
|
/// 4 4 flags u32 LE
|
||||||
|
/// 8 4 reserved1 u32 LE
|
||||||
|
/// 12 16 cid GUID
|
||||||
|
/// 28 4 extensions_referent_id u32 LE
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct OrpcThis {
|
||||||
|
pub version: ComVersion,
|
||||||
|
pub flags: u32,
|
||||||
|
pub reserved1: u32,
|
||||||
|
pub cid: Guid,
|
||||||
|
pub extensions_referent_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrpcThis {
|
||||||
|
/// Encoded length without extensions — `OrpcStructures.cs:17`.
|
||||||
|
pub const ENCODED_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// Construct with default version 5.7 and zeroed flags/extensions.
|
||||||
|
/// Mirrors `OrpcThis.Create(cid, version)` (`OrpcStructures.cs:19-22`).
|
||||||
|
pub fn create(cid: Guid, version: Option<ComVersion>) -> Self {
|
||||||
|
Self {
|
||||||
|
version: version.unwrap_or_default(),
|
||||||
|
flags: 0,
|
||||||
|
reserved1: 0,
|
||||||
|
cid,
|
||||||
|
extensions_referent_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the 32-byte header. Mirrors `OrpcThis.Parse`
|
||||||
|
/// (`OrpcStructures.cs:24-39`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 32`.
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
version: ComVersion::new(
|
||||||
|
u16::from_le_bytes([buffer[0], buffer[1]]),
|
||||||
|
u16::from_le_bytes([buffer[2], buffer[3]]),
|
||||||
|
),
|
||||||
|
flags: u32::from_le_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]),
|
||||||
|
reserved1: u32::from_le_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]),
|
||||||
|
cid: Guid::parse(&buffer[12..28])?,
|
||||||
|
extensions_referent_id: u32::from_le_bytes([
|
||||||
|
buffer[28], buffer[29], buffer[30], buffer[31],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode to 32 bytes. Mirrors `OrpcThis.Encode`
|
||||||
|
/// (`OrpcStructures.cs:41-51`).
|
||||||
|
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
|
||||||
|
let mut buf = [0u8; Self::ENCODED_LEN];
|
||||||
|
buf[0..2].copy_from_slice(&self.version.major.to_le_bytes());
|
||||||
|
buf[2..4].copy_from_slice(&self.version.minor.to_le_bytes());
|
||||||
|
buf[4..8].copy_from_slice(&self.flags.to_le_bytes());
|
||||||
|
buf[8..12].copy_from_slice(&self.reserved1.to_le_bytes());
|
||||||
|
buf[12..28].copy_from_slice(self.cid.as_bytes());
|
||||||
|
buf[28..32].copy_from_slice(&self.extensions_referent_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8-byte ORPC response header (without extensions).
|
||||||
|
/// Mirrors `OrpcThat` (`OrpcStructures.cs:54-77`).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 4 flags u32 LE
|
||||||
|
/// 4 4 extensions_referent_id u32 LE
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub struct OrpcThat {
|
||||||
|
pub flags: u32,
|
||||||
|
pub extensions_referent_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrpcThat {
|
||||||
|
/// Encoded length without extensions — `OrpcStructures.cs:56`.
|
||||||
|
pub const ENCODED_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Decode 8 bytes. Mirrors `OrpcThat.Parse` (`OrpcStructures.cs:58-68`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 8`.
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
flags: u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]),
|
||||||
|
extensions_referent_id: u32::from_le_bytes([
|
||||||
|
buffer[4], buffer[5], buffer[6], buffer[7],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode to 8 bytes. Mirrors `OrpcThat.Encode`
|
||||||
|
/// (`OrpcStructures.cs:70-76`).
|
||||||
|
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
|
||||||
|
let mut buf = [0u8; Self::ENCODED_LEN];
|
||||||
|
buf[0..4].copy_from_slice(&self.flags.to_le_bytes());
|
||||||
|
buf[4..8].copy_from_slice(&self.extensions_referent_id.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length-prefixed OBJREF byte wrapper used to carry interface pointers in
|
||||||
|
/// ORPC bodies. Mirrors `MInterfacePointer` (`OrpcStructures.cs:79-109`).
|
||||||
|
///
|
||||||
|
/// Wire layout: `u32 LE size || size bytes of OBJREF`. The Rust port owns the
|
||||||
|
/// `objref_bytes` `Vec<u8>` (matching the .NET `byte[] ObjRefBytes`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MInterfacePointer {
|
||||||
|
pub objref_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MInterfacePointer {
|
||||||
|
/// Header length (the `u32` size prefix).
|
||||||
|
pub const SIZE_PREFIX_LEN: usize = 4;
|
||||||
|
|
||||||
|
pub fn new(objref_bytes: Vec<u8>) -> Self {
|
||||||
|
Self { objref_bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode as `size_le32 || objref_bytes`. Mirrors `Encode`
|
||||||
|
/// (`OrpcStructures.cs:81-87`).
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let len = self.objref_bytes.len();
|
||||||
|
let mut buf = Vec::with_capacity(Self::SIZE_PREFIX_LEN + len);
|
||||||
|
let len_u32: u32 = len.try_into().unwrap_or(u32::MAX);
|
||||||
|
buf.extend_from_slice(&len_u32.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&self.objref_bytes);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `size_le32 || size bytes` into an owned `MInterfacePointer`.
|
||||||
|
/// Mirrors `Parse` (`OrpcStructures.cs:89-103`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RpcError::ShortRead`] if the buffer is shorter than the
|
||||||
|
/// 4-byte size prefix, or [`RpcError::Decode`] if the declared size
|
||||||
|
/// runs past the buffer.
|
||||||
|
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
|
||||||
|
if buffer.len() < Self::SIZE_PREFIX_LEN {
|
||||||
|
return Err(RpcError::ShortRead {
|
||||||
|
expected: Self::SIZE_PREFIX_LEN,
|
||||||
|
actual: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let size = u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
||||||
|
if size > buffer.len() - Self::SIZE_PREFIX_LEN {
|
||||||
|
return Err(RpcError::Decode {
|
||||||
|
offset: Self::SIZE_PREFIX_LEN,
|
||||||
|
reason: "MInterfacePointer OBJREF payload truncated",
|
||||||
|
buffer_len: buffer.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
objref_bytes: buffer[Self::SIZE_PREFIX_LEN..Self::SIZE_PREFIX_LEN + size].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the inner OBJREF bytes through [`crate::objref::ComObjRef::parse`].
|
||||||
|
/// Mirrors `MInterfacePointer.ParseObjRef` (`OrpcStructures.cs:105-108`).
|
||||||
|
pub fn parse_objref(&self) -> Result<crate::objref::ComObjRef, RpcError> {
|
||||||
|
crate::objref::ComObjRef::parse(&self.objref_bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40-byte STDOBJREF body. Mirrors `StdObjRef` (`OrpcStructures.cs:111-140`).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 4 flags u32 LE
|
||||||
|
/// 4 4 public_refs u32 LE
|
||||||
|
/// 8 8 oxid u64 LE
|
||||||
|
/// 16 8 oid u64 LE
|
||||||
|
/// 24 16 ipid GUID
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub struct StdObjRef {
|
||||||
|
pub flags: u32,
|
||||||
|
pub public_refs: u32,
|
||||||
|
pub oxid: u64,
|
||||||
|
pub oid: u64,
|
||||||
|
pub ipid: Guid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdObjRef {
|
||||||
|
/// Encoded length — `OrpcStructures.cs:113`.
|
||||||
|
pub const ENCODED_LEN: usize = 40;
|
||||||
|
|
||||||
|
/// Decode 40 bytes. Mirrors `StdObjRef.Parse`
|
||||||
|
/// (`OrpcStructures.cs:115-128`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RpcError::ShortRead`] if `buffer.len() < 40`.
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
flags: u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]),
|
||||||
|
public_refs: u32::from_le_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]),
|
||||||
|
oxid: u64::from_le_bytes([
|
||||||
|
buffer[8], buffer[9], buffer[10], buffer[11], buffer[12], buffer[13], buffer[14],
|
||||||
|
buffer[15],
|
||||||
|
]),
|
||||||
|
oid: u64::from_le_bytes([
|
||||||
|
buffer[16], buffer[17], buffer[18], buffer[19], buffer[20], buffer[21], buffer[22],
|
||||||
|
buffer[23],
|
||||||
|
]),
|
||||||
|
ipid: Guid::parse(&buffer[24..40])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode to 40 bytes. Mirrors `StdObjRef.Encode`
|
||||||
|
/// (`OrpcStructures.cs:130-139`).
|
||||||
|
pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
|
||||||
|
let mut buf = [0u8; Self::ENCODED_LEN];
|
||||||
|
buf[0..4].copy_from_slice(&self.flags.to_le_bytes());
|
||||||
|
buf[4..8].copy_from_slice(&self.public_refs.to_le_bytes());
|
||||||
|
buf[8..16].copy_from_slice(&self.oxid.to_le_bytes());
|
||||||
|
buf[16..24].copy_from_slice(&self.oid.to_le_bytes());
|
||||||
|
buf[24..40].copy_from_slice(self.ipid.as_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn com_version_default_is_5_7() {
|
||||||
|
assert_eq!(ComVersion::default(), ComVersion::new(5, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orpc_this_round_trip() {
|
||||||
|
let cid = sample_guid(0x10);
|
||||||
|
let original = OrpcThis::create(cid, None);
|
||||||
|
let encoded = original.encode();
|
||||||
|
assert_eq!(encoded.len(), OrpcThis::ENCODED_LEN);
|
||||||
|
let decoded = OrpcThis::parse(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orpc_this_short_buffer_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
OrpcThis::parse(&[0u8; 31]),
|
||||||
|
Err(RpcError::ShortRead {
|
||||||
|
expected: 32,
|
||||||
|
actual: 31
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orpc_that_round_trip() {
|
||||||
|
let original = OrpcThat {
|
||||||
|
flags: 0xDEAD_BEEF,
|
||||||
|
extensions_referent_id: 0x1234_5678,
|
||||||
|
};
|
||||||
|
let encoded = original.encode();
|
||||||
|
assert_eq!(encoded.len(), OrpcThat::ENCODED_LEN);
|
||||||
|
let decoded = OrpcThat::parse(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m_interface_pointer_round_trip() {
|
||||||
|
let mip = MInterfacePointer::new(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
|
||||||
|
let encoded = mip.encode();
|
||||||
|
assert_eq!(encoded.len(), 4 + 5);
|
||||||
|
// Size prefix is 5.
|
||||||
|
assert_eq!(&encoded[0..4], &5u32.to_le_bytes());
|
||||||
|
let decoded = MInterfacePointer::parse(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded, mip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m_interface_pointer_truncated_payload_errors() {
|
||||||
|
// Declares 16 bytes but only supplies 4 after the prefix.
|
||||||
|
let mut bad = Vec::new();
|
||||||
|
bad.extend_from_slice(&16u32.to_le_bytes());
|
||||||
|
bad.extend_from_slice(&[0u8; 4]);
|
||||||
|
let err = MInterfacePointer::parse(&bad).unwrap_err();
|
||||||
|
assert!(matches!(err, RpcError::Decode { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn std_objref_round_trip() {
|
||||||
|
let original = StdObjRef {
|
||||||
|
flags: 0,
|
||||||
|
public_refs: 5,
|
||||||
|
oxid: 0x1122_3344_5566_7788,
|
||||||
|
oid: 0x99AA_BBCC_DDEE_FF00,
|
||||||
|
ipid: sample_guid(0x55),
|
||||||
|
};
|
||||||
|
let encoded = original.encode();
|
||||||
|
assert_eq!(encoded.len(), StdObjRef::ENCODED_LEN);
|
||||||
|
let decoded = StdObjRef::parse(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn std_objref_short_buffer_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
StdObjRef::parse(&[0u8; 39]),
|
||||||
|
Err(RpcError::ShortRead {
|
||||||
|
expected: 40,
|
||||||
|
actual: 39
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,862 @@
|
|||||||
|
//! DCE/RPC TCP transport — async port of `DceRpcTcpClient.cs`.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/DceRpcTcpClient.cs` over tokio.
|
||||||
|
//! Provides:
|
||||||
|
//!
|
||||||
|
//! - [`DceRpcTcpClient::connect`] — open a TCP connection
|
||||||
|
//! - [`DceRpcTcpClient::bind`] — unauthenticated bind (`cs:33-53`)
|
||||||
|
//! - [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] —
|
||||||
|
//! NTLMv2 packet-integrity bind using [`crate::ntlm::NtlmClientContext`]
|
||||||
|
//! (`cs:65-106`)
|
||||||
|
//! - [`DceRpcTcpClient::call`] / [`DceRpcTcpClient::call_bound`] /
|
||||||
|
//! [`DceRpcTcpClient::call_bound_object`] — request dispatch
|
||||||
|
//! (`cs:151-182,252-282`)
|
||||||
|
//!
|
||||||
|
//! The `BindWithNtlmConnect` / `BindWithNtlmPacketIntegrity` flavours from
|
||||||
|
//! the .NET reference (`cs:55-63,108-149`) wrap `System.Net.Security.SspiClientContext`,
|
||||||
|
//! which is .NET-specific. They're explicitly out of scope for the Rust
|
||||||
|
//! port — the managed-NTLM path is the only one we need (cite
|
||||||
|
//! `design/00-overview.md` principle 3 and `design/40-protocol-invariants.md`).
|
||||||
|
//!
|
||||||
|
//! ## Packet integrity
|
||||||
|
//!
|
||||||
|
//! When [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] is used,
|
||||||
|
//! every subsequent `call*` PDU is wrapped per `cs:201-250`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! pdu_layout = unauthenticated_request
|
||||||
|
//! || pad to 4-byte align (filled with 0xBB, cs:215)
|
||||||
|
//! || DceRpcAuthTrailer (8 bytes)
|
||||||
|
//! || 16-byte NTLM signature
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The header's `frag_length` is rewritten to the new full length and
|
||||||
|
//! `auth_length` is set to 16 (the signature size). `NtlmClientContext::sign`
|
||||||
|
//! is called over `pdu[0..length-16]` and the result is written into the
|
||||||
|
//! trailing 16 bytes. Mirrors `cs:201-250` exactly.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::error::RpcError;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
use crate::ntlm::{NtlmClientContext, NtlmError, SIGNATURE_LEN};
|
||||||
|
use crate::pdu::{
|
||||||
|
AuthLevel, AuthTrailer, AuthType, BindPdu, FaultPdu, PacketType, PduHeader,
|
||||||
|
PresentationContext, ResponsePdu, SyntaxId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Errors raised by the TCP transport. Mirrors the wrap of
|
||||||
|
/// `IOException` / `InvalidOperationException` / `DceRpcFaultException`
|
||||||
|
/// at `DceRpcTcpClient.cs:170-174,205,245,393`.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum TransportError {
|
||||||
|
/// I/O failure on the underlying TCP stream.
|
||||||
|
#[error("transport I/O: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// PDU codec failure — wraps [`RpcError`].
|
||||||
|
#[error("PDU codec: {0}")]
|
||||||
|
Codec(#[from] RpcError),
|
||||||
|
|
||||||
|
/// NTLM signing failed (signing called before authenticate completed
|
||||||
|
/// or buffer length issues). Mirrors `cs:245`.
|
||||||
|
#[error("NTLM signing: {0}")]
|
||||||
|
Ntlm(#[from] NtlmError),
|
||||||
|
|
||||||
|
/// `Connect()` was not called or the socket was closed
|
||||||
|
/// (`cs:402-407`).
|
||||||
|
#[error("DCE/RPC TCP client is not connected")]
|
||||||
|
NotConnected,
|
||||||
|
|
||||||
|
/// A `Call*` arrived with `auth_level == PacketIntegrity` but no
|
||||||
|
/// auth trailer was set up by a prior bind (`cs:203-206,245`).
|
||||||
|
#[error("packet-integrity auth requested without an auth trailer")]
|
||||||
|
AuthContextMissing,
|
||||||
|
|
||||||
|
/// Server returned a `Fault` PDU. Mirrors `DceRpcFaultException`
|
||||||
|
/// (`cs:411-420`).
|
||||||
|
#[error("DCE/RPC fault 0x{status:08x}")]
|
||||||
|
Fault { status: u32 },
|
||||||
|
|
||||||
|
/// Server replied with a packet type the transport doesn't expect at
|
||||||
|
/// that point in the conversation (e.g. a `Request` where a Response
|
||||||
|
/// was expected).
|
||||||
|
#[error("unexpected response packet type: {actual:?}")]
|
||||||
|
UnexpectedResponsePacketType { actual: PacketType },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed `auth_context_id` used by the .NET reference for every
|
||||||
|
/// authenticated PDU (`cs:90,133`). The same value is reused across the
|
||||||
|
/// connection so the server can correlate the trailer back to the
|
||||||
|
/// negotiated NTLM context.
|
||||||
|
pub const NTLM_AUTH_CONTEXT_ID: u32 = 79232;
|
||||||
|
|
||||||
|
/// Fixed PDU header constants used for outbound frames (`cs:336-347`).
|
||||||
|
const FRAME_VERSION: u8 = 5;
|
||||||
|
const FRAME_VERSION_MINOR: u8 = 0;
|
||||||
|
const FRAME_PACKET_FLAGS: u8 = 0x03;
|
||||||
|
const FRAME_DATA_REPRESENTATION: u32 = 0x10;
|
||||||
|
|
||||||
|
/// `DceRpcTcpClient` — a single-connection async DCE/RPC client.
|
||||||
|
///
|
||||||
|
/// Construct with [`connect`](Self::connect), then call one of the
|
||||||
|
/// `bind*` methods, then dispatch one or more `call*` requests, then drop
|
||||||
|
/// to close the socket.
|
||||||
|
///
|
||||||
|
/// Not Clone (the underlying `TcpStream` is single-owner) and not Sync
|
||||||
|
/// (mutable internal state — call id counter, NTLM context, auth trailer).
|
||||||
|
pub struct DceRpcTcpClient {
|
||||||
|
stream: TcpStream,
|
||||||
|
next_call_id: u32,
|
||||||
|
bound_context_id: u16,
|
||||||
|
ntlm: Option<NtlmClientContext>,
|
||||||
|
auth_trailer: Option<AuthTrailer>,
|
||||||
|
auth_level: AuthLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DceRpcTcpClient {
|
||||||
|
/// Open a TCP connection to `addr`. Mirrors `Connect()`
|
||||||
|
/// (`DceRpcTcpClient.cs:26-31`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`std::io::Error`] from [`TcpStream::connect`].
|
||||||
|
pub async fn connect(addr: SocketAddr) -> std::io::Result<Self> {
|
||||||
|
let stream = TcpStream::connect(addr).await?;
|
||||||
|
Ok(Self {
|
||||||
|
stream,
|
||||||
|
next_call_id: 1,
|
||||||
|
bound_context_id: 0,
|
||||||
|
ntlm: None,
|
||||||
|
auth_trailer: None,
|
||||||
|
auth_level: AuthLevel::None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local socket address (for tests / diagnostics).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`std::io::Error`] if the socket is invalid.
|
||||||
|
pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
|
||||||
|
self.stream.local_addr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently negotiated `bound_context_id`. Set by the bind methods.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bound_context_id(&self) -> u16 {
|
||||||
|
self.bound_context_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently negotiated `auth_level`. Tracks `_authLevel` from the
|
||||||
|
/// .NET reference (`cs:18,104,147`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn auth_level(&self) -> AuthLevel {
|
||||||
|
self.auth_level
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unauthenticated bind. Mirrors `Bind`
|
||||||
|
/// (`DceRpcTcpClient.cs:33-53`).
|
||||||
|
///
|
||||||
|
/// On success returns the response PDU header (typically `BindAck`
|
||||||
|
/// per `[C706]` §12.6.4.4); the bound presentation context id is
|
||||||
|
/// always 0 for this transport (the .NET reference only ever
|
||||||
|
/// presents one context at a time).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// I/O, codec, or unexpected packet type.
|
||||||
|
pub async fn bind(
|
||||||
|
&mut self,
|
||||||
|
interface_id: Guid,
|
||||||
|
version_major: u16,
|
||||||
|
version_minor: u16,
|
||||||
|
) -> Result<PduHeader, TransportError> {
|
||||||
|
let call_id = self.next_call_id;
|
||||||
|
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||||
|
|
||||||
|
let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id);
|
||||||
|
let bytes = pdu.encode();
|
||||||
|
|
||||||
|
self.stream.write_all(&bytes).await?;
|
||||||
|
let response = read_pdu(&mut self.stream).await?;
|
||||||
|
let header = PduHeader::decode(&response)?;
|
||||||
|
Ok(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind + Auth3 round-trip using the managed NTLMv2 packet-integrity
|
||||||
|
/// path. Mirrors `BindWithManagedNtlmPacketIntegrity`
|
||||||
|
/// (`cs:65-106`).
|
||||||
|
///
|
||||||
|
/// Takes ownership of the [`NtlmClientContext`] for the lifetime of
|
||||||
|
/// the connection; subsequent `call*` requests are signed with it.
|
||||||
|
/// The .NET reference creates the context via
|
||||||
|
/// `ManagedNtlmClientContext.FromEnvironment()` (cs:70) — that
|
||||||
|
/// helper is open follow-up F1 in the Rust port; for now the caller
|
||||||
|
/// constructs `NtlmClientContext::new(user, password, domain, workstation)`
|
||||||
|
/// explicitly.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// I/O, codec, or NTLM (Type1/Type3 building).
|
||||||
|
pub async fn bind_with_managed_ntlm_packet_integrity(
|
||||||
|
&mut self,
|
||||||
|
interface_id: Guid,
|
||||||
|
version_major: u16,
|
||||||
|
version_minor: u16,
|
||||||
|
mut ntlm: NtlmClientContext,
|
||||||
|
) -> Result<PduHeader, TransportError> {
|
||||||
|
let call_id = self.next_call_id;
|
||||||
|
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||||
|
|
||||||
|
let type1 = ntlm.create_type1();
|
||||||
|
let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id);
|
||||||
|
let trailer = AuthTrailer {
|
||||||
|
auth_type: AuthType::WinNt,
|
||||||
|
auth_level: AuthLevel::PacketIntegrity,
|
||||||
|
auth_pad_length: 0,
|
||||||
|
auth_reserved: 0,
|
||||||
|
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bind_with_auth = pdu.encode_with_auth(trailer, &type1);
|
||||||
|
self.stream.write_all(&bind_with_auth).await?;
|
||||||
|
|
||||||
|
let response = read_pdu(&mut self.stream).await?;
|
||||||
|
let response_header = PduHeader::decode(&response)?;
|
||||||
|
let challenge = BindPdu::read_auth_value(&response)?;
|
||||||
|
let mut inputs = crate::ntlm::OsInputs;
|
||||||
|
let type3 = ntlm.create_type3(&challenge.token, &mut inputs)?;
|
||||||
|
|
||||||
|
let auth3_header = make_request_header(PacketType::Auth3, response_header.call_id);
|
||||||
|
let auth3 = BindPdu::encode_auth3(auth3_header, trailer, &type3);
|
||||||
|
self.stream.write_all(&auth3).await?;
|
||||||
|
|
||||||
|
self.bound_context_id = 0;
|
||||||
|
self.ntlm = Some(ntlm);
|
||||||
|
self.auth_trailer = Some(trailer);
|
||||||
|
self.auth_level = AuthLevel::PacketIntegrity;
|
||||||
|
Ok(response_header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a Request on the explicit context id. Mirrors `Call`
|
||||||
|
/// (`DceRpcTcpClient.cs:151-154`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// I/O, codec, NTLM signing, or `TransportError::Fault` if the server
|
||||||
|
/// returned a Fault PDU.
|
||||||
|
pub async fn call(
|
||||||
|
&mut self,
|
||||||
|
context_id: u16,
|
||||||
|
opnum: u16,
|
||||||
|
stub_data: &[u8],
|
||||||
|
) -> Result<ResponsePdu, TransportError> {
|
||||||
|
self.call_core(context_id, opnum, stub_data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a Request on the bound context with no object UUID.
|
||||||
|
/// Mirrors `CallBound` (`cs:179-182`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As for [`call`](Self::call).
|
||||||
|
pub async fn call_bound(
|
||||||
|
&mut self,
|
||||||
|
opnum: u16,
|
||||||
|
stub_data: &[u8],
|
||||||
|
) -> Result<ResponsePdu, TransportError> {
|
||||||
|
let cid = self.bound_context_id;
|
||||||
|
self.call_core(cid, opnum, stub_data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a Request on the bound context with an object UUID
|
||||||
|
/// (sets `PFC_OBJECT_UUID = 0x80` in the packet flags). Mirrors
|
||||||
|
/// `CallBoundObject` (`cs:156-159`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As for [`call`](Self::call).
|
||||||
|
pub async fn call_bound_object(
|
||||||
|
&mut self,
|
||||||
|
object_uuid: Guid,
|
||||||
|
opnum: u16,
|
||||||
|
stub_data: &[u8],
|
||||||
|
) -> Result<ResponsePdu, TransportError> {
|
||||||
|
let cid = self.bound_context_id;
|
||||||
|
self.call_core(cid, opnum, stub_data, Some(object_uuid))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_core(
|
||||||
|
&mut self,
|
||||||
|
context_id: u16,
|
||||||
|
opnum: u16,
|
||||||
|
stub_data: &[u8],
|
||||||
|
object_uuid: Option<Guid>,
|
||||||
|
) -> Result<ResponsePdu, TransportError> {
|
||||||
|
let call_id = self.next_call_id;
|
||||||
|
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||||
|
|
||||||
|
let header = make_request_header(PacketType::Request, call_id);
|
||||||
|
let request = encode_request_bytes(header, context_id, opnum, stub_data, object_uuid);
|
||||||
|
|
||||||
|
let pdu = if self.auth_level == AuthLevel::PacketIntegrity {
|
||||||
|
let trailer = self
|
||||||
|
.auth_trailer
|
||||||
|
.ok_or(TransportError::AuthContextMissing)?;
|
||||||
|
let ntlm = self
|
||||||
|
.ntlm
|
||||||
|
.as_mut()
|
||||||
|
.ok_or(TransportError::AuthContextMissing)?;
|
||||||
|
encode_packet_integrity_request(&request, trailer, ntlm)?
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
};
|
||||||
|
|
||||||
|
self.stream.write_all(&pdu).await?;
|
||||||
|
let response = read_pdu(&mut self.stream).await?;
|
||||||
|
let response_header = PduHeader::decode(&response)?;
|
||||||
|
match response_header.packet_type {
|
||||||
|
PacketType::Response => Ok(ResponsePdu::decode(&response)?),
|
||||||
|
PacketType::Fault => {
|
||||||
|
let fault = FaultPdu::decode(&response)?;
|
||||||
|
Err(TransportError::Fault {
|
||||||
|
status: fault.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => Err(TransportError::UnexpectedResponsePacketType { actual: other }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the standard outbound bind PDU (`cs:33-48,73-84,116-127`).
|
||||||
|
fn make_bind_pdu(
|
||||||
|
interface_id: Guid,
|
||||||
|
version_major: u16,
|
||||||
|
version_minor: u16,
|
||||||
|
call_id: u32,
|
||||||
|
) -> BindPdu {
|
||||||
|
BindPdu {
|
||||||
|
header: make_request_header(PacketType::Bind, call_id),
|
||||||
|
max_transmit_fragment: 4280,
|
||||||
|
max_receive_fragment: 4280,
|
||||||
|
association_group_id: 0,
|
||||||
|
presentation_contexts: vec![PresentationContext {
|
||||||
|
context_id: 0,
|
||||||
|
abstract_syntax: SyntaxId {
|
||||||
|
uuid_bytes: *interface_id.as_bytes(),
|
||||||
|
version_major,
|
||||||
|
version_minor,
|
||||||
|
},
|
||||||
|
transfer_syntaxes: vec![SyntaxId::NDR20],
|
||||||
|
}],
|
||||||
|
reserved25_28: [0; 3],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a fresh outbound PDU header. Mirrors `CreateHeader`
|
||||||
|
/// (`cs:336-347`). `fragment_length` and `auth_length` are 0; the PDU
|
||||||
|
/// encoder fills `fragment_length` later. `packet_flags = 0x03` matches
|
||||||
|
/// `cs:342`.
|
||||||
|
fn make_request_header(packet_type: PacketType, call_id: u32) -> PduHeader {
|
||||||
|
PduHeader {
|
||||||
|
version: FRAME_VERSION,
|
||||||
|
version_minor: FRAME_VERSION_MINOR,
|
||||||
|
packet_type,
|
||||||
|
packet_flags: FRAME_PACKET_FLAGS,
|
||||||
|
data_representation: FRAME_DATA_REPRESENTATION,
|
||||||
|
fragment_length: 0,
|
||||||
|
auth_length: 0,
|
||||||
|
call_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the unauthenticated `Request` PDU bytes. Mirrors
|
||||||
|
/// `EncodeRequestBytes` (`DceRpcTcpClient.cs:252-282`).
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// 0 16 PduHeader
|
||||||
|
/// 16 4 allocation_hint u32 LE = stub.len()
|
||||||
|
/// 20 2 context_id u16 LE
|
||||||
|
/// 22 2 opnum u16 LE
|
||||||
|
/// 24..(24+16 if object) 16 object_uuid (only when PFC_OBJECT_UUID)
|
||||||
|
/// stub_offset.. var stub_data
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Sets `packet_flags |= 0x80` (`PFC_OBJECT_UUID`) when `object_uuid` is
|
||||||
|
/// `Some`, mirroring `cs:269`.
|
||||||
|
pub(crate) fn encode_request_bytes(
|
||||||
|
header: PduHeader,
|
||||||
|
context_id: u16,
|
||||||
|
opnum: u16,
|
||||||
|
stub_data: &[u8],
|
||||||
|
object_uuid: Option<Guid>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let object_length = if object_uuid.is_some() { 16 } else { 0 };
|
||||||
|
let fixed_offset = PduHeader::LENGTH;
|
||||||
|
let stub_offset = fixed_offset + 8 + object_length;
|
||||||
|
let length = stub_offset + stub_data.len();
|
||||||
|
let mut pdu = vec![0u8; length];
|
||||||
|
|
||||||
|
let request_header = PduHeader {
|
||||||
|
packet_type: PacketType::Request,
|
||||||
|
fragment_length: u16::try_from(length).unwrap_or(u16::MAX),
|
||||||
|
auth_length: 0,
|
||||||
|
packet_flags: {
|
||||||
|
let base = if header.packet_flags == 0 {
|
||||||
|
0x03
|
||||||
|
} else {
|
||||||
|
header.packet_flags
|
||||||
|
};
|
||||||
|
if object_uuid.is_some() {
|
||||||
|
base | 0x80
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
},
|
||||||
|
..header
|
||||||
|
};
|
||||||
|
let _ = request_header.encode(&mut pdu);
|
||||||
|
|
||||||
|
pdu[fixed_offset..fixed_offset + 4].copy_from_slice(
|
||||||
|
&u32::try_from(stub_data.len())
|
||||||
|
.unwrap_or(u32::MAX)
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
pdu[fixed_offset + 4..fixed_offset + 6].copy_from_slice(&context_id.to_le_bytes());
|
||||||
|
pdu[fixed_offset + 6..fixed_offset + 8].copy_from_slice(&opnum.to_le_bytes());
|
||||||
|
if let Some(uuid) = object_uuid {
|
||||||
|
pdu[fixed_offset + 8..fixed_offset + 24].copy_from_slice(uuid.as_bytes());
|
||||||
|
}
|
||||||
|
pdu[stub_offset..stub_offset + stub_data.len()].copy_from_slice(stub_data);
|
||||||
|
pdu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an unauthenticated Request PDU with packet-integrity padding,
|
||||||
|
/// auth trailer, and 16-byte NTLM signature. Mirrors
|
||||||
|
/// `EncodePacketIntegrityRequest` (`DceRpcTcpClient.cs:201-250`).
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 0..N unauthenticated PDU (header + body)
|
||||||
|
/// N..N+pad 0xBB pad bytes to 4-byte boundary (cs:215)
|
||||||
|
/// N+pad.. AuthTrailer (8 bytes; auth_pad_length set to pad)
|
||||||
|
/// last 16 bytes NTLM signature over [0..length-16]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The PDU header inside is rewritten to set `fragment_length = length`
|
||||||
|
/// and `auth_length = 16`.
|
||||||
|
pub(crate) fn encode_packet_integrity_request(
|
||||||
|
unauthenticated: &[u8],
|
||||||
|
trailer: AuthTrailer,
|
||||||
|
ntlm: &mut NtlmClientContext,
|
||||||
|
) -> Result<Vec<u8>, TransportError> {
|
||||||
|
let pad_length = align_up(unauthenticated.len(), 4) - unauthenticated.len();
|
||||||
|
let length = unauthenticated.len() + pad_length + AuthTrailer::LENGTH + SIGNATURE_LEN;
|
||||||
|
let mut pdu = vec![0u8; length];
|
||||||
|
pdu[..unauthenticated.len()].copy_from_slice(unauthenticated);
|
||||||
|
if pad_length > 0 {
|
||||||
|
pdu[unauthenticated.len()..unauthenticated.len() + pad_length].fill(0xBB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the embedded PDU header.
|
||||||
|
let parsed_header = PduHeader::decode(unauthenticated)?;
|
||||||
|
let header = PduHeader {
|
||||||
|
packet_type: PacketType::Request,
|
||||||
|
packet_flags: if parsed_header.packet_flags == 0 {
|
||||||
|
0x03
|
||||||
|
} else {
|
||||||
|
parsed_header.packet_flags
|
||||||
|
},
|
||||||
|
fragment_length: u16::try_from(length).unwrap_or(u16::MAX),
|
||||||
|
auth_length: u16::try_from(SIGNATURE_LEN).unwrap_or(u16::MAX),
|
||||||
|
..parsed_header
|
||||||
|
};
|
||||||
|
let _ = header.encode(&mut pdu);
|
||||||
|
|
||||||
|
// Write the auth trailer (with auth_pad_length reflecting our pad).
|
||||||
|
let trailer = AuthTrailer {
|
||||||
|
auth_pad_length: u8::try_from(pad_length).unwrap_or(u8::MAX),
|
||||||
|
..trailer
|
||||||
|
};
|
||||||
|
let trailer_offset = unauthenticated.len() + pad_length;
|
||||||
|
let mut trailer_buf = [0u8; AuthTrailer::LENGTH];
|
||||||
|
trailer.encode(&mut trailer_buf)?;
|
||||||
|
pdu[trailer_offset..trailer_offset + AuthTrailer::LENGTH].copy_from_slice(&trailer_buf);
|
||||||
|
|
||||||
|
// Sign over [0..length - SIGNATURE_LEN] and write the signature into the
|
||||||
|
// trailing 16 bytes. The .NET reference fills the placeholder with 0x20
|
||||||
|
// before signing (`cs:231`); since `Sign` reads only [0..length-16],
|
||||||
|
// the placeholder doesn't affect the MAC, but we keep the same
|
||||||
|
// initial bytes so any future test that compares full PDUs has a
|
||||||
|
// consistent shape.
|
||||||
|
pdu[length - SIGNATURE_LEN..].fill(0x20);
|
||||||
|
let signature = ntlm.sign(&pdu[..length - SIGNATURE_LEN])?;
|
||||||
|
pdu[length - SIGNATURE_LEN..].copy_from_slice(&signature);
|
||||||
|
|
||||||
|
Ok(pdu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn align_up(value: usize, alignment: usize) -> usize {
|
||||||
|
let r = value % alignment;
|
||||||
|
if r == 0 { value } else { value + alignment - r }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one full PDU from `stream`. Mirrors `ReadPdu` + `ReadExact`
|
||||||
|
/// (`DceRpcTcpClient.cs:372-400`). Returns the full bytes including the
|
||||||
|
/// 16-byte header.
|
||||||
|
async fn read_pdu(stream: &mut TcpStream) -> Result<Vec<u8>, TransportError> {
|
||||||
|
let mut header_bytes = [0u8; PduHeader::LENGTH];
|
||||||
|
stream.read_exact(&mut header_bytes).await?;
|
||||||
|
let header = PduHeader::decode(&header_bytes)?;
|
||||||
|
let frag = header.fragment_length as usize;
|
||||||
|
if frag < PduHeader::LENGTH {
|
||||||
|
return Err(TransportError::Codec(RpcError::InvalidFragmentLength {
|
||||||
|
frag_length: frag,
|
||||||
|
buffer_len: header_bytes.len(),
|
||||||
|
auth_length: header.auth_length as usize,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let mut pdu = vec![0u8; frag];
|
||||||
|
pdu[..PduHeader::LENGTH].copy_from_slice(&header_bytes);
|
||||||
|
if frag > PduHeader::LENGTH {
|
||||||
|
stream.read_exact(&mut pdu[PduHeader::LENGTH..]).await?;
|
||||||
|
}
|
||||||
|
Ok(pdu)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn align_up_matches_dotnet_align() {
|
||||||
|
assert_eq!(align_up(0, 4), 0);
|
||||||
|
assert_eq!(align_up(1, 4), 4);
|
||||||
|
assert_eq!(align_up(28, 4), 28);
|
||||||
|
assert_eq!(align_up(29, 4), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ntlm_auth_context_id_matches_dotnet() {
|
||||||
|
// .NET hard-codes 79232 = 0x13580 at cs:90,133.
|
||||||
|
assert_eq!(NTLM_AUTH_CONTEXT_ID, 79232);
|
||||||
|
assert_eq!(NTLM_AUTH_CONTEXT_ID, 0x0001_3580);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn make_request_header_uses_v5_drep_0x10_flags_03() {
|
||||||
|
let h = make_request_header(PacketType::Bind, 7);
|
||||||
|
assert_eq!(h.version, 5);
|
||||||
|
assert_eq!(h.version_minor, 0);
|
||||||
|
assert_eq!(h.packet_type, PacketType::Bind);
|
||||||
|
assert_eq!(h.packet_flags, 0x03);
|
||||||
|
assert_eq!(h.data_representation, 0x10);
|
||||||
|
assert_eq!(h.fragment_length, 0);
|
||||||
|
assert_eq!(h.auth_length, 0);
|
||||||
|
assert_eq!(h.call_id, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_request_bytes_no_object_uuid_layout() {
|
||||||
|
let header = make_request_header(PacketType::Request, 42);
|
||||||
|
let stub = [0xAAu8, 0xBB, 0xCC, 0xDD];
|
||||||
|
let bytes = encode_request_bytes(header, 0, 6, &stub, None);
|
||||||
|
|
||||||
|
// Total = header(16) + 8 fixed + stub(4) = 28.
|
||||||
|
assert_eq!(bytes.len(), 28);
|
||||||
|
|
||||||
|
// PFC_OBJECT_UUID bit must NOT be set (cs:269).
|
||||||
|
assert_eq!(bytes[3] & 0x80, 0);
|
||||||
|
|
||||||
|
// allocation_hint = 4
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
// context_id = 0
|
||||||
|
assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0);
|
||||||
|
// opnum = 6
|
||||||
|
assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 6);
|
||||||
|
// stub bytes follow at offset 24.
|
||||||
|
assert_eq!(&bytes[24..28], &stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_request_bytes_with_object_uuid_sets_pfc_bit_and_inserts_uuid() {
|
||||||
|
let header = make_request_header(PacketType::Request, 99);
|
||||||
|
let stub = [0x01u8, 0x02];
|
||||||
|
let uuid = Guid::new([0x11; 16]);
|
||||||
|
let bytes = encode_request_bytes(header, 0, 0, &stub, Some(uuid));
|
||||||
|
|
||||||
|
// Total = header(16) + 8 fixed + 16 object UUID + 2 stub = 42.
|
||||||
|
assert_eq!(bytes.len(), 42);
|
||||||
|
|
||||||
|
// PFC_OBJECT_UUID bit must be set (cs:269).
|
||||||
|
assert_eq!(bytes[3] & 0x80, 0x80);
|
||||||
|
|
||||||
|
// Object UUID at offset 24..40.
|
||||||
|
assert_eq!(&bytes[24..40], uuid.as_bytes());
|
||||||
|
|
||||||
|
// Stub at offset 40.
|
||||||
|
assert_eq!(&bytes[40..42], &stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_packet_integrity_request_pad_and_signature_layout() {
|
||||||
|
// Build an unauthenticated request that's NOT 4-byte aligned to
|
||||||
|
// exercise the pad branch (cs:208,213-216).
|
||||||
|
let header = make_request_header(PacketType::Request, 1);
|
||||||
|
let stub = [0xDE, 0xAD, 0xBE]; // 3 bytes -> total 27 (header 16 + 8 fixed + 3 stub)
|
||||||
|
let unauth = encode_request_bytes(header, 0, 0, &stub, None);
|
||||||
|
assert_eq!(unauth.len(), 27);
|
||||||
|
|
||||||
|
// Build an NTLM context that's authenticated enough for sign() to
|
||||||
|
// succeed (Type1 + Type3 with fixed inputs).
|
||||||
|
let mut ntlm = NtlmClientContext::new("U", "P", "D", Some(""));
|
||||||
|
ntlm.create_type1();
|
||||||
|
let challenge = make_dummy_challenge();
|
||||||
|
ntlm.create_type3(
|
||||||
|
&challenge,
|
||||||
|
&mut crate::ntlm::FixedInputs {
|
||||||
|
client_challenge: [0u8; 8],
|
||||||
|
exported_session_key: [0u8; 16],
|
||||||
|
filetime: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let trailer = AuthTrailer {
|
||||||
|
auth_type: AuthType::WinNt,
|
||||||
|
auth_level: AuthLevel::PacketIntegrity,
|
||||||
|
auth_pad_length: 0,
|
||||||
|
auth_reserved: 0,
|
||||||
|
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap();
|
||||||
|
|
||||||
|
// pad = 1 byte (27 -> 28); total = 27 + 1 + 8 + 16 = 52.
|
||||||
|
assert_eq!(pdu.len(), 52);
|
||||||
|
|
||||||
|
// Pad byte at offset 27 must be 0xBB.
|
||||||
|
assert_eq!(pdu[27], 0xBB);
|
||||||
|
|
||||||
|
// Trailer auth_pad_length at offset 28+2 = 30 (per AuthTrailer
|
||||||
|
// encode: auth_type, auth_level, auth_pad_length, ...).
|
||||||
|
assert_eq!(pdu[28], AuthType::WinNt.as_byte());
|
||||||
|
assert_eq!(pdu[29], AuthLevel::PacketIntegrity.as_byte());
|
||||||
|
assert_eq!(pdu[30], 1);
|
||||||
|
|
||||||
|
// Embedded header: fragment_length=52, auth_length=16.
|
||||||
|
let h = PduHeader::decode(&pdu).unwrap();
|
||||||
|
assert_eq!(h.fragment_length as usize, 52);
|
||||||
|
assert_eq!(h.auth_length as usize, SIGNATURE_LEN);
|
||||||
|
|
||||||
|
// The trailing 16 bytes are the NTLM signature; they MUST not be
|
||||||
|
// the 0x20 placeholder fill.
|
||||||
|
assert_ne!(&pdu[36..52], &[0x20u8; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_packet_integrity_request_no_pad_when_already_aligned() {
|
||||||
|
// 28-byte unauth (header 16 + 8 fixed + 4 stub) is already aligned.
|
||||||
|
let header = make_request_header(PacketType::Request, 1);
|
||||||
|
let stub = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||||
|
let unauth = encode_request_bytes(header, 0, 0, &stub, None);
|
||||||
|
assert_eq!(unauth.len(), 28);
|
||||||
|
|
||||||
|
let mut ntlm = NtlmClientContext::new("U", "P", "D", Some(""));
|
||||||
|
ntlm.create_type1();
|
||||||
|
let challenge = make_dummy_challenge();
|
||||||
|
ntlm.create_type3(
|
||||||
|
&challenge,
|
||||||
|
&mut crate::ntlm::FixedInputs {
|
||||||
|
client_challenge: [0u8; 8],
|
||||||
|
exported_session_key: [0u8; 16],
|
||||||
|
filetime: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let trailer = AuthTrailer {
|
||||||
|
auth_type: AuthType::WinNt,
|
||||||
|
auth_level: AuthLevel::PacketIntegrity,
|
||||||
|
auth_pad_length: 0,
|
||||||
|
auth_reserved: 0,
|
||||||
|
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap();
|
||||||
|
// Total = 28 + 0 (no pad) + 8 trailer + 16 sig = 52.
|
||||||
|
assert_eq!(pdu.len(), 52);
|
||||||
|
// auth_pad_length at offset 30 is 0.
|
||||||
|
assert_eq!(pdu[30], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimum-viable Type2 challenge so create_type3 succeeds in
|
||||||
|
/// tests. Mirrors what a real server would send.
|
||||||
|
fn make_dummy_challenge() -> Vec<u8> {
|
||||||
|
// 48-byte minimum Type2: signature(8) + msg_type(4) + target_name_fields(8)
|
||||||
|
// + flags(4) + server_challenge(8) + reserved(8) + target_info_fields(8)
|
||||||
|
// = 56 bytes when including target_info fields. Use the same scaffold
|
||||||
|
// ntlm::tests uses internally.
|
||||||
|
let mut buf = vec![0u8; 56];
|
||||||
|
buf[..8].copy_from_slice(b"NTLMSSP\0");
|
||||||
|
// message_type = 2
|
||||||
|
buf[8..12].copy_from_slice(&2u32.to_le_bytes());
|
||||||
|
// target_name fields zero (no target name)
|
||||||
|
// flags
|
||||||
|
buf[20..24].copy_from_slice(
|
||||||
|
&(crate::ntlm::NEGOTIATE_UNICODE
|
||||||
|
| crate::ntlm::NEGOTIATE_NTLM
|
||||||
|
| crate::ntlm::NEGOTIATE_EXTENDED_SESSION_SECURITY
|
||||||
|
| crate::ntlm::NEGOTIATE_TARGET_INFO
|
||||||
|
| crate::ntlm::NEGOTIATE_KEY_EXCHANGE
|
||||||
|
| crate::ntlm::NEGOTIATE_128
|
||||||
|
| crate::ntlm::NEGOTIATE_56)
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
// server challenge bytes 24..32 left zero is fine
|
||||||
|
// reserved 32..40 zero
|
||||||
|
// target_info fields 40..48: length(2) + max_length(2) + offset(4)
|
||||||
|
// We'll point target_info at offset 48 with length 8 (one EOL pair).
|
||||||
|
buf[40..42].copy_from_slice(&8u16.to_le_bytes());
|
||||||
|
buf[42..44].copy_from_slice(&8u16.to_le_bytes());
|
||||||
|
buf[44..48].copy_from_slice(&48u32.to_le_bytes());
|
||||||
|
// target_info: 4 bytes EOL marker (id=0, len=0) repeated to pad
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trip test using a hand-rolled tokio echo-bind server.
|
||||||
|
/// Verifies the client can `connect` -> `bind` -> read a BindAck.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bind_round_trip_with_local_listener() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
// Spawn a one-shot server that accepts one connection, reads a
|
||||||
|
// Bind PDU, and writes back a minimal BindAck-shaped PDU.
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
// Read the 16-byte header.
|
||||||
|
let mut hdr = [0u8; 16];
|
||||||
|
sock.read_exact(&mut hdr).await.unwrap();
|
||||||
|
let parsed = PduHeader::decode(&hdr).unwrap();
|
||||||
|
// Drain the rest.
|
||||||
|
let mut body = vec![0u8; parsed.fragment_length as usize - 16];
|
||||||
|
sock.read_exact(&mut body).await.unwrap();
|
||||||
|
|
||||||
|
// Send a fake BindAck — header only, length=16, no body.
|
||||||
|
let resp = PduHeader {
|
||||||
|
version: 5,
|
||||||
|
version_minor: 0,
|
||||||
|
packet_type: PacketType::BindAck,
|
||||||
|
packet_flags: 0x03,
|
||||||
|
data_representation: 0x10,
|
||||||
|
fragment_length: 16,
|
||||||
|
auth_length: 0,
|
||||||
|
call_id: parsed.call_id,
|
||||||
|
};
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
resp.encode(&mut out).unwrap();
|
||||||
|
sock.write_all(&out).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = DceRpcTcpClient::connect(addr).await.unwrap();
|
||||||
|
let header = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap();
|
||||||
|
assert_eq!(header.packet_type, PacketType::BindAck);
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `call` over a server that echoes back a Fault must surface as
|
||||||
|
/// `TransportError::Fault`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn call_returns_fault_when_server_responds_with_fault() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
// 1. Drain the Bind, send a minimal BindAck.
|
||||||
|
let mut hdr = [0u8; 16];
|
||||||
|
sock.read_exact(&mut hdr).await.unwrap();
|
||||||
|
let bind = PduHeader::decode(&hdr).unwrap();
|
||||||
|
let mut body = vec![0u8; bind.fragment_length as usize - 16];
|
||||||
|
sock.read_exact(&mut body).await.unwrap();
|
||||||
|
let resp = PduHeader {
|
||||||
|
version: 5,
|
||||||
|
version_minor: 0,
|
||||||
|
packet_type: PacketType::BindAck,
|
||||||
|
packet_flags: 0x03,
|
||||||
|
data_representation: 0x10,
|
||||||
|
fragment_length: 16,
|
||||||
|
auth_length: 0,
|
||||||
|
call_id: bind.call_id,
|
||||||
|
};
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
resp.encode(&mut out).unwrap();
|
||||||
|
sock.write_all(&out).await.unwrap();
|
||||||
|
|
||||||
|
// 2. Drain the Request, reply with a Fault carrying status=0xDEADBEEF.
|
||||||
|
sock.read_exact(&mut hdr).await.unwrap();
|
||||||
|
let req = PduHeader::decode(&hdr).unwrap();
|
||||||
|
let mut body = vec![0u8; req.fragment_length as usize - 16];
|
||||||
|
sock.read_exact(&mut body).await.unwrap();
|
||||||
|
let fault = FaultPdu {
|
||||||
|
header: PduHeader {
|
||||||
|
version: 5,
|
||||||
|
version_minor: 0,
|
||||||
|
packet_type: PacketType::Fault,
|
||||||
|
packet_flags: 0x03,
|
||||||
|
data_representation: 0x10,
|
||||||
|
fragment_length: 0, // overwritten by encode
|
||||||
|
auth_length: 0,
|
||||||
|
call_id: req.call_id,
|
||||||
|
},
|
||||||
|
allocation_hint: 0,
|
||||||
|
context_id: 0,
|
||||||
|
cancel_count: 0,
|
||||||
|
reserved23: 0,
|
||||||
|
status: 0xDEAD_BEEF,
|
||||||
|
stub_data: Vec::new(),
|
||||||
|
};
|
||||||
|
let bytes = fault.encode();
|
||||||
|
sock.write_all(&bytes).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = DceRpcTcpClient::connect(addr).await.unwrap();
|
||||||
|
let _ = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap();
|
||||||
|
let err = client.call(0, 0, &[]).await.unwrap_err();
|
||||||
|
match err {
|
||||||
|
TransportError::Fault { status } => assert_eq!(status, 0xDEAD_BEEF),
|
||||||
|
other => panic!("expected Fault, got {other:?}"),
|
||||||
|
}
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_context_missing_when_packet_integrity_set_without_trailer() {
|
||||||
|
// Direct unit test: forcing auth_level high without setting up the
|
||||||
|
// trailer/ntlm pair should yield `AuthContextMissing` from
|
||||||
|
// call_core via the runtime gate. We simulate that gate inline.
|
||||||
|
// (The full async path would need a server; this test just
|
||||||
|
// confirms the variant exists and matches the documented error.)
|
||||||
|
let err = TransportError::AuthContextMissing;
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("auth trailer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,19 @@ authors.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||||
|
mxaccess-callback = { path = "../mxaccess-callback" }
|
||||||
|
mxaccess-galaxy = { path = "../mxaccess-galaxy" }
|
||||||
|
mxaccess-nmx = { path = "../mxaccess-nmx" }
|
||||||
|
mxaccess-rpc = { path = "../mxaccess-rpc" }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
+254
-33
@@ -21,21 +21,21 @@ pub use mxaccess_codec::{
|
|||||||
|
|
||||||
// ---- Public types --------------------------------------------------------
|
// ---- Public types --------------------------------------------------------
|
||||||
|
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError};
|
||||||
|
pub use mxaccess_nmx::WriteValue;
|
||||||
|
pub use session::Subscription;
|
||||||
|
|
||||||
/// Async session façade. Cheap clones share the inner state; drop of the last
|
/// Async session façade. Cheap clones share the inner state; drop of the last
|
||||||
/// clone fires `UnregisterEngine` best-effort. For deterministic shutdown,
|
/// clone fires `UnregisterEngine` best-effort. For deterministic shutdown,
|
||||||
/// call `Session::shutdown(timeout).await`.
|
/// call `Session::shutdown(timeout).await`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
_inner: Arc<SessionInner>,
|
pub(crate) inner: Arc<session::SessionInner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
// `Subscription` is defined in `session.rs`; re-export below alongside Session.
|
||||||
struct SessionInner;
|
|
||||||
|
|
||||||
/// Stream of `DataChange` items. Drop sends `UnAdvise` via the long-lived
|
|
||||||
/// connection task (no `tokio::spawn` from `Drop`).
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Subscription;
|
|
||||||
|
|
||||||
/// One inbound update. Carries both `quality: u16` (legacy 16-bit OPC quality,
|
/// One inbound update. Carries both `quality: u16` (legacy 16-bit OPC quality,
|
||||||
/// e.g. `0xC0` = "Good") and `status: MxStatus` (the richer category model).
|
/// e.g. `0xC0` = "Good") and `status: MxStatus` (the richer category model).
|
||||||
@@ -86,8 +86,52 @@ pub struct TransportCapabilities {
|
|||||||
pub operation_complete_frame: bool,
|
pub operation_complete_frame: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
/// Reconnect / recover policy. Mirrors `MxNativeRecoveryPolicy`
|
||||||
pub struct RecoveryPolicy;
|
/// (`MxNativeSession.cs:24-43`).
|
||||||
|
///
|
||||||
|
/// `Default` and [`RecoveryPolicy::SINGLE_ATTEMPT`] both produce one
|
||||||
|
/// attempt with zero delay — same as the .NET reference's default
|
||||||
|
/// `SingleAttempt` static (`cs:26`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RecoveryPolicy {
|
||||||
|
/// Total attempts before giving up (≥ 1). The .NET reference defaults
|
||||||
|
/// to 1 (`cs:28`); `validate()` rejects 0 (`cs:33-36`).
|
||||||
|
pub max_attempts: u32,
|
||||||
|
/// Delay between attempts. Must be non-negative — enforced by
|
||||||
|
/// `Duration` (`cs:38-41`).
|
||||||
|
pub delay: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryPolicy {
|
||||||
|
/// Single-attempt policy — `MxNativeRecoveryPolicy.SingleAttempt`
|
||||||
|
/// (`cs:26`).
|
||||||
|
pub const SINGLE_ATTEMPT: RecoveryPolicy = RecoveryPolicy {
|
||||||
|
max_attempts: 1,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Validate the policy. Mirrors `Validate()` (`cs:31-42`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ConfigError::InvalidArgument`] when `max_attempts == 0`. The
|
||||||
|
/// .NET reference also checks `Delay < TimeSpan.Zero`; the Rust
|
||||||
|
/// `Duration` type makes that case unreachable so the check is
|
||||||
|
/// elided.
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.max_attempts < 1 {
|
||||||
|
return Err(ConfigError::InvalidArgument {
|
||||||
|
detail: "max_attempts must be at least 1".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RecoveryPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::SINGLE_ATTEMPT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
|
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
|
||||||
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
|
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
|
||||||
@@ -110,6 +154,77 @@ pub enum RecoveryEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session-level configuration. Mirrors `MxNativeClientOptions`
|
||||||
|
/// (`MxNativeSession.cs:7-22`).
|
||||||
|
///
|
||||||
|
/// All defaults match the .NET reference exactly:
|
||||||
|
///
|
||||||
|
/// - `local_engine_id`: `0x7000 + (process_id & 0x0FFF)` per
|
||||||
|
/// `GenerateDefaultLocalEngineId` (`cs:18-21`). The 12-bit PID slot
|
||||||
|
/// keeps the engine id stable across runs of the same process while
|
||||||
|
/// avoiding collisions with other clients on the box.
|
||||||
|
/// - `engine_name`: `"mxaccess.<pid>"` mirroring
|
||||||
|
/// `"MxNativeClient.{Environment.ProcessId}"` (`cs:10`).
|
||||||
|
/// - `partner_version`: `6` (`cs:11`) — matches the value the live
|
||||||
|
/// probe expects from `INmxService2::GetPartnerVersion` per
|
||||||
|
/// `design/60-roadmap.md:54`.
|
||||||
|
/// - `galaxy_id`: `1` (`cs:12`).
|
||||||
|
/// - `source_platform_id`: `1` (`cs:13`).
|
||||||
|
/// - `heartbeat_max_missed_ticks`: `3` (`cs:16`).
|
||||||
|
///
|
||||||
|
/// `heartbeat_ticks_per_beat` defaults to `None` — the .NET reference
|
||||||
|
/// defaults to `null` (`cs:15`) which means "skip the
|
||||||
|
/// `SetHeartbeatSendInterval` call entirely." When `Some(n)`, M4's
|
||||||
|
/// `Session::connect` will issue a heartbeat-config call after
|
||||||
|
/// `RegisterEngine2`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SessionOptions {
|
||||||
|
pub local_engine_id: i32,
|
||||||
|
pub engine_name: String,
|
||||||
|
pub partner_version: i32,
|
||||||
|
pub galaxy_id: u8,
|
||||||
|
pub source_platform_id: i32,
|
||||||
|
pub heartbeat_ticks_per_beat: Option<i32>,
|
||||||
|
pub heartbeat_max_missed_ticks: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionOptions {
|
||||||
|
/// Build the default `local_engine_id`. Mirrors
|
||||||
|
/// `GenerateDefaultLocalEngineId` (`MxNativeSession.cs:18-21`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Never fails; the platform PID is always representable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_local_engine_id() -> i32 {
|
||||||
|
// Cast through i32 to mirror the .NET `int` width. PIDs below
|
||||||
|
// 2^31 (effectively all real PIDs) fit losslessly.
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
0x7000 + (pid & 0x0FFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the default `engine_name`. Mirrors `MxNativeClient.{ProcessId}`
|
||||||
|
/// (`MxNativeSession.cs:10`) but lowercased to match Rust naming
|
||||||
|
/// conventions for client-side advertised names.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_engine_name() -> String {
|
||||||
|
format!("mxaccess.{}", std::process::id())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
local_engine_id: Self::default_local_engine_id(),
|
||||||
|
engine_name: Self::default_engine_name(),
|
||||||
|
partner_version: 6,
|
||||||
|
galaxy_id: 1,
|
||||||
|
source_platform_id: 1,
|
||||||
|
heartbeat_ticks_per_beat: None,
|
||||||
|
heartbeat_max_missed_ticks: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Error taxonomy ------------------------------------------------------
|
// ---- Error taxonomy ------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -295,22 +410,6 @@ impl Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-as-subscribe per `MxNativeSession.ReadAsync` — requires a positive
|
|
||||||
/// timeout, drop guarantees `UnAdvise`.
|
|
||||||
pub async fn read(&self, _reference: &str, _timeout: Duration) -> Result<DataChange, Error> {
|
|
||||||
Err(Error::Unsupported {
|
|
||||||
operation: Cow::Borrowed("Session::read"),
|
|
||||||
transport: TransportKind::Nmx,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn subscribe(&self, _reference: &str) -> Result<Subscription, Error> {
|
|
||||||
Err(Error::Unsupported {
|
|
||||||
operation: Cow::Borrowed("Session::subscribe"),
|
|
||||||
transport: TransportKind::Nmx,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn subscribe_many(&self, _references: &[&str]) -> Result<Subscription, Error> {
|
pub async fn subscribe_many(&self, _references: &[&str]) -> Result<Subscription, Error> {
|
||||||
Err(Error::Unsupported {
|
Err(Error::Unsupported {
|
||||||
operation: Cow::Borrowed("Session::subscribe_many"),
|
operation: Cow::Borrowed("Session::subscribe_many"),
|
||||||
@@ -329,13 +428,6 @@ impl Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn recover_connection(&self, _policy: RecoveryPolicy) -> Result<(), Error> {
|
|
||||||
Err(Error::Unsupported {
|
|
||||||
operation: Cow::Borrowed("Session::recover_connection"),
|
|
||||||
transport: TransportKind::Nmx,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orderly shutdown — flushes `UnAdvise` for every live subscription,
|
/// Orderly shutdown — flushes `UnAdvise` for every live subscription,
|
||||||
/// then `UnregisterEngine`. Recommended exit path for production code.
|
/// then `UnregisterEngine`. Recommended exit path for production code.
|
||||||
pub async fn shutdown(self, _timeout: Duration) -> Result<(), Error> {
|
pub async fn shutdown(self, _timeout: Duration) -> Result<(), Error> {
|
||||||
@@ -345,3 +437,132 @@ impl Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---- RecoveryPolicy ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_default_is_single_attempt() {
|
||||||
|
let p = RecoveryPolicy::default();
|
||||||
|
assert_eq!(p, RecoveryPolicy::SINGLE_ATTEMPT);
|
||||||
|
assert_eq!(p.max_attempts, 1);
|
||||||
|
assert_eq!(p.delay, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_single_attempt_const_matches_dotnet() {
|
||||||
|
// .NET MxNativeRecoveryPolicy.SingleAttempt = new(): max_attempts=1,
|
||||||
|
// Delay=TimeSpan.Zero. cs:26-29.
|
||||||
|
assert_eq!(RecoveryPolicy::SINGLE_ATTEMPT.max_attempts, 1);
|
||||||
|
assert_eq!(RecoveryPolicy::SINGLE_ATTEMPT.delay, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_validate_accepts_valid() {
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 5,
|
||||||
|
delay: Duration::from_millis(100),
|
||||||
|
};
|
||||||
|
assert!(p.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_validate_rejects_zero_attempts() {
|
||||||
|
// cs:33-36 throws ArgumentOutOfRangeException for MaxAttempts < 1.
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 0,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
let err = p.validate().unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::InvalidArgument { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_accepts_zero_delay() {
|
||||||
|
// The .NET reference allows Delay = TimeSpan.Zero (only negative is
|
||||||
|
// rejected); Rust's Duration is unsigned so this is automatic.
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 3,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
assert!(p.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SessionOptions ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_defaults_match_dotnet() {
|
||||||
|
let o = SessionOptions::default();
|
||||||
|
// local_engine_id = 0x7000 + (pid & 0x0FFF) per cs:18-21.
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
assert_eq!(o.local_engine_id, 0x7000 + (pid & 0x0FFF));
|
||||||
|
assert_eq!(o.engine_name, format!("mxaccess.{pid}"));
|
||||||
|
assert_eq!(o.partner_version, 6);
|
||||||
|
assert_eq!(o.galaxy_id, 1);
|
||||||
|
assert_eq!(o.source_platform_id, 1);
|
||||||
|
assert_eq!(o.heartbeat_ticks_per_beat, None);
|
||||||
|
assert_eq!(o.heartbeat_max_missed_ticks, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_default_local_engine_id_is_in_0x7000_range() {
|
||||||
|
let id = SessionOptions::default_local_engine_id();
|
||||||
|
assert!(
|
||||||
|
(0x7000..=0x7FFF).contains(&id),
|
||||||
|
"default local_engine_id 0x{id:X} not in 0x7000..=0x7FFF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_default_engine_name_starts_with_mxaccess_dot() {
|
||||||
|
let n = SessionOptions::default_engine_name();
|
||||||
|
assert!(n.starts_with("mxaccess."));
|
||||||
|
// Tail must be a valid u32 (the PID).
|
||||||
|
let pid_str = n.trim_start_matches("mxaccess.");
|
||||||
|
assert!(pid_str.parse::<u32>().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_can_be_overridden() {
|
||||||
|
let o = SessionOptions {
|
||||||
|
local_engine_id: 0x5000,
|
||||||
|
engine_name: "test".to_string(),
|
||||||
|
partner_version: 7,
|
||||||
|
galaxy_id: 2,
|
||||||
|
source_platform_id: 99,
|
||||||
|
heartbeat_ticks_per_beat: Some(10),
|
||||||
|
heartbeat_max_missed_ticks: 5,
|
||||||
|
};
|
||||||
|
assert_eq!(o.partner_version, 7);
|
||||||
|
assert_eq!(o.heartbeat_ticks_per_beat, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recovery event smoke tests -----------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_event_started_constructible() {
|
||||||
|
let e = RecoveryEvent::Started { attempt: 1 };
|
||||||
|
match e {
|
||||||
|
RecoveryEvent::Started { attempt } => assert_eq!(attempt, 1),
|
||||||
|
other => panic!("expected Started, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_event_recovered_constructible() {
|
||||||
|
let e = RecoveryEvent::Recovered { attempt: 3 };
|
||||||
|
match e {
|
||||||
|
RecoveryEvent::Recovered { attempt } => assert_eq!(attempt, 3),
|
||||||
|
other => panic!("expected Recovered, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user