Compare commits

...

21 Commits

Author SHA1 Message Date
Joseph Doherty 33edc91234 rustfmt: collapse short multi-line expressions in ntlm tests
rust / build / test / clippy / fmt (push) Has been cancelled
Pure whitespace cleanup from running `cargo fmt --all` between
iterations; no semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:01:45 -04:00
Joseph Doherty 4863c6dc1f [M4] mxaccess: Session::recover_connection + RecoveryEvent broadcast
Wires the recovery API surface and event channel. Recovery is
currently a no-op (validates policy + emits Started/Recovered
events); the real teardown + re-bind + re-advise loop is wave-3
work tracked as F16.

New
- Session::recover_connection(policy) — port of
  MxNativeSession.RecoverConnectionAsync (cs:399-440). Validates
  policy.max_attempts >= 1 (mirrors cs:33-36 via
  RecoveryPolicy::validate). Emits RecoveryEvent::Started + Recovered
  through the broadcast channel. Returns Ok(()) immediately — actual
  reconnect work is F16.
- Session::recovery_events() -> broadcast::Receiver<Arc<RecoveryEvent>>
  — typed observable for consumers that want to wire monitoring or
  state-machine handling. Same Arc-broadcast pattern as
  Session::callbacks(). Multi-subscriber safe (Arc::ptr_eq verified
  in tests).
- SessionInner.recovery_tx: broadcast::Sender<Arc<RecoveryEvent>>
  initialized in connect_nmx + connect_test_session.

Removed lib.rs stub (was Err(Unsupported)).

design/followups.md: F16 added (P1) covering the actual reconnect
loop. Resolves when R15's long-lived connection task lands and
SessionInner gains a subscription registry — at that point the
recover loop becomes ~50 lines slotting RecoverConnectionCore-style
work between the Started and Recovered events.

Tests (4 new in mxaccess; total 48)
- recover_connection emits Started + Recovered for the default
  single-attempt policy.
- recover_connection rejects max_attempts == 0 with InvalidArgument.
- recover_connection after shutdown returns EngineNotRegistered.
- recovery_events supports multiple subscribers (Arc::ptr_eq
  verifies the same allocation reaches both).

Test count delta: 520 -> 524 (+4). All four DoD gates green.
Open followups: 9 -> 10 (added F16).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:59:25 -04:00
Joseph Doherty 2dc091d0be [M4] mxaccess: Session::read (read-as-subscribe pattern)
Now that Subscription impls Stream<Item = Result<DataChange, Error>>,
the read-as-subscribe pattern is a thin wrapper over subscribe +
timeout + best-effort unsubscribe.

New
- Session::read(reference, timeout) -> Result<DataChange, Error> —
  port of MxNativeSession.ReadAsync (cs:312-359). Validates timeout
  > 0, subscribes, awaits the first DataChange under
  tokio::time::timeout, then issues UnAdvise (best-effort, mirrors
  the .NET finally block at cs:351-358 which discards the
  unsubscribe return).

Error mapping
- timeout=0: Configuration::InvalidArgument ("Read timeout must be
  positive") matching ArgumentOutOfRangeException at cs:318-321.
- timeout elapsed: Error::Timeout(timeout).
- subscribe failure (resolver / transport): propagated unchanged.
- stream ends before any value: Connection::EngineNotRegistered
  (broadcast sender dropped during shutdown).
- unsubscribe failure: tracing::warn! with the error; doesn't
  override the read result.

Removed the placeholder stub in lib.rs that returned
Error::Unsupported.

Tests (4 new in mxaccess; total 44)
- read_returns_first_data_change_within_timeout: spawn read,
  inject a 0x33 DataUpdate via test_inject_sender (which fans out
  to all subscriptions), assert the DataChange comes back with the
  right value.
- read_returns_timeout_when_no_data_arrives: read times out cleanly
  with Error::Timeout when no callback fires.
- read_zero_timeout_returns_invalid_argument_without_subscribing:
  validates the early-reject path before any RPC is issued.
- read_propagates_resolver_not_found: subscribe-side error
  surfaces through read unchanged.

Test count delta: 516 -> 520 (+4). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:52:14 -04:00
Joseph Doherty a31237d1d0 [M4] mxaccess: Subscription impls Stream<Item = DataChange> (resolves F15)
F15 step 2/2 lands the per-Subscription routing on top of step 1's
broadcast layer. Subscription is now a working data-change stream.

Subscription type
- Now impls futures_util::Stream<Item = Result<DataChange, Error>>
  via tokio_stream::wrappers::BroadcastStream + a per-message filter.
- No longer Clone (broadcast::Receiver isn't Clone). Consumers that
  want fanout subscribe twice or share via Arc<Mutex<...>>.
- Holds the broadcast::Receiver subscribed BEFORE AdviseSupervisory
  fires — guarantees no updates between advise and stream-creation
  are dropped.
- pending VecDeque buffers records from the current message so each
  poll_next yields at most one DataChange (Stream contract).

Filter logic (records_to_data_changes, mirrors cs:333-343)
- 0x32 SubscriptionStatus: keep when
  msg.item_correlation_id == subscription.correlation_id; drop
  otherwise.
- 0x33 DataUpdate: keep ALL — codec exposes no per-record correlation
  field, and the .NET filter only checks item_correlation_id (which
  0x33 doesn't carry), so DataUpdates fan out to every active
  subscription. Matches .NET behavior verbatim.
- Records with value: None drop silently (mirrors evt.Record.Value
  is null filter at cs:337).
- BroadcastStream Lagged(n) maps to Error::Configuration with the
  lag count in the detail string.

Helpers
- filetime_to_system_time(i64) -> SystemTime: inverse of
  system_time_to_filetime; saturates at Unix epoch for FILETIMEs
  before 1970 since SystemTime can't portably represent pre-epoch.
- record_to_data_change(record, reference) -> Option<DataChange>:
  builds DataChange from one record, returns None for unparseable
  value (the codec couldn't decode the wire kind).
- Status currently hardcoded to MxStatus::DATA_CHANGE_OK (mirrors
  NmxSubscriptionRecord.ToDataChangeStatus at NmxSubscriptionMessage.cs:22-25
  which the .NET reference itself stubs to the OK constant).

Cargo.toml additions: futures-util (workspace) + tokio-stream (0.1
with sync feature for BroadcastStream).

Tests (5 new in mxaccess; total 40)
- subscription_stream_yields_data_change_for_matching_correlation:
  build a 0x32 SubscriptionStatus with one Int32 record and the
  subscription's correlation id, inject through test_inject_sender,
  observe the DataChange (reference, value, quality match) on the
  Stream.
- subscription_stream_filters_out_mismatched_correlation_for_status:
  inject 0x32 with wrong correlation id, assert the stream stays
  pending (timeout-as-success).
- subscription_stream_keeps_data_update_regardless_of_correlation:
  inject 0x33 DataUpdate with one Int32 record (no correlation
  field on the message); stream still yields the DataChange.
- filetime_to_system_time_round_trip: build a SystemTime with .005s
  precision, round-trip through both helpers, assert equality.
- filetime_to_system_time_pre_unix_epoch_saturates: FILETIME 0 (year
  1601) → SystemTime::UNIX_EPOCH (saturating clamp).

design/followups.md: F15 moved to Resolved with both step commits
referenced. Open list: 9 items (was 10).

Test count delta: 511 -> 516 (+5). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:45:16 -04:00
Joseph Doherty 2b849aed7a [M4] mxaccess: wire CallbackExporter + spawn callback router (F15 step 1/2)
Lands the broadcast layer of F15. Session::connect_nmx now starts a
local CallbackExporter on an OS-assigned ephemeral port, builds a
callback OBJREF advertising it (using local_hostname() with a
127.0.0.1 fallback), and registers that OBJREF with NmxClient::register_engine_2
(was register_engine_2_without_callback). A router task drains the
exporter's CallbackEvent stream, decodes each CallbackInvoked body as
NmxSubscriptionMessage, and broadcasts parsed messages on a
tokio::sync::broadcast channel.

Per-subscription correlation routing — turning these raw messages
into per-Subscription DataChange streams — is the next iteration's
work. F15 stays open until that lands.

New Session API
- Session::callbacks() -> broadcast::Receiver<Arc<NmxSubscriptionMessage>>:
  raw observable of every parsed callback message. Test seam +
  escape hatch for consumers that need raw access today.
- Session::callback_exporter_addr() -> Option<SocketAddr>: returns the
  exporter's local addr (Some until shutdown_nmx, None after).

SessionInner additions
- callback_exporter: Mutex<Option<CallbackExporter>> — taken in shutdown.
- callback_tx: broadcast::Sender<Arc<NmxSubscriptionMessage>>.
- router_handle: std::sync::Mutex<Option<JoinHandle<()>>>.

shutdown_nmx now performs the full cleanup chain:
1. UnregisterEngine over the live NMX transport.
2. CallbackExporter::shutdown (cancels accept loop).
3. Wait for router task — exits naturally once exporter's mpsc
   sender side closes. Std::sync::Mutex guard taken-out-then-dropped
   before await to avoid clippy::await_holding_lock.

Routing rationale (callback_router fn)
- CallbackEvent::CallbackInvoked → parse via
  NmxSubscriptionMessage::parse_inner → broadcast Arc<msg>.
- Other event variants (Bind / Auth3Ignored / ProtocolError / etc.)
  silently dropped at this layer; consumers needing them can listen
  to a future diagnostic-channel hook (no followup yet).
- Parse failures silent — the .NET reference fires a separate
  UnparsedCallbackReceived event we don't model yet.

Cargo.toml: added mxaccess-callback as a direct dep on mxaccess.

Tests (5 new in mxaccess; total 35)
- callbacks receiver observes injected NmxSubscriptionMessage.
- multi-subscriber broadcast hands out the same Arc to each receiver.
- callback_exporter_addr is Some before shutdown, None after.
- router_task end-to-end: feed a hand-built CallbackInvoked event
  with a 39-byte 0x32 SubscriptionStatus body, observe the parsed
  message on the broadcast.
- router silently drops non-CallbackInvoked events (e.g. Bind).

Test count delta: 506 -> 511 (+5). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:35:41 -04:00
Joseph Doherty f7139f1118 [M2/M4] mxaccess-rpc: NtlmClientContext::from_env + local_hostname (resolves F1)
Reduces open followups from 11 → 10 (back at the soft threshold).
Step 0 triage flagged F1 as resolvable now: M4's connect-path
example will need a from_env constructor anyway, and the hostname
lookup is portable enough not to need a native-libc dep.

New
- NtlmClientContext::from_env() -> Result<Self, NtlmError>: reads
  MX_RPC_USER / MX_RPC_PASSWORD / MX_RPC_DOMAIN env vars. Empty
  MX_RPC_DOMAIN is permitted (workgroup auth). Mirrors the .NET
  ManagedNtlmClientContext.FromEnvironment() at cs:41-49.
- local_hostname() -> String public helper: checks COMPUTERNAME
  (Windows) then HOSTNAME (POSIX) and returns the empty string when
  neither is set — same "unavailable" semantics as
  Environment.MachineName returning null. No gethostname(2) call,
  no unsafe, no native-libc dep. Callers needing reliable POSIX
  hostnames can pass workstation explicitly.
- NtlmError::MissingEnvVar { name: &'static str } variant.

Tests (8 new in ntlm; total 27)
- from_env three-var happy path
- from_env missing each of the three vars (3 tests)
- from_env accepts empty MX_RPC_DOMAIN
- local_hostname prefers COMPUTERNAME over HOSTNAME
- local_hostname falls back to HOSTNAME
- local_hostname returns empty when neither set
- All env-mutating tests serialize via a static ENV_LOCK Mutex inside
  EnvScope, since std::env::set_var touches process-global state and
  cargo runs #[test]s in parallel by default.

design/followups.md: F1 moved to Resolved.
Open followups: 11 → 10 (back at soft threshold).

Test count delta: 498 -> 506 (+8). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:24:26 -04:00
Joseph Doherty 70feb63ea5 [M4] mxaccess: Session::subscribe + unsubscribe + Subscription handle
Lands the subscribe-path lifecycle: AdviseSupervisory + UnAdvise
round-trip via a Subscription handle. The actual DataChange stream
routing is deferred to F15.

New
- Session::subscribe(reference) -> Result<Subscription, Error> —
  resolves the tag, generates a 16-byte correlation_id via
  rand::random(), calls NmxClient::advise_supervisory. Mirrors
  MxNativeSession.SubscribeAsync (cs:250-270) minus the publisher
  Connect dance (will land alongside F15's callback routing).
- Session::unsubscribe(subscription) -> Result<(), Error> — consumes
  the handle and calls NmxClient::un_advise. Mirrors
  MxNativeSession.Unsubscribe (cs:361-381).
- Subscription { correlation_id, reference, metadata } public type
  with accessor methods. Currently a pure lifecycle handle — no
  Stream impl yet; the Stream<Item=DataChange> shape lands when F15
  wires CallbackExporter routing.
- Removed the old subscribe stub from lib.rs (was Err(Unsupported)).

Drop hazard note
- Subscription deliberately does NOT impl Drop to fire UnAdvise. The
  spawn-from-Drop pattern is the R15 hazard tracked in
  design/70-risks-and-open-questions.md. Callers must call
  Session::unsubscribe(sub).await explicitly. F15's wave-2 long-lived
  connection task will support best-effort drop-time cleanup without
  the spawn-from-Drop hazard.

Cargo.toml: added rand (for correlation_id generation).

design/followups.md: F15 added (P1, M4 wave 2 callback router).
Open followups now at 11 — slightly over the soft 10-item threshold
but no drift (F13 just resolved last iteration). Next iteration's
Step 0 triage will check whether F15 is actionable.

Tests (4 new in mxaccess; total 30)
- subscribe_then_unsubscribe round-trip via in-memory resolver +
  hand-rolled server (2 RPCs: AdviseSupervisory + UnAdvise).
- subscribe propagates non-zero AdviseSupervisory HRESULT.
- subscribe after shutdown returns EngineNotRegistered.
- two_subscribes_produce_distinct_correlation_ids — verifies the
  rand::random() correlation id generation differentiates handles.

Test count delta: 494 -> 498 (+4). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:16:47 -04:00
Joseph Doherty bf95995573 [M4] mxaccess: Session::write_value_at + write_value_secured_at
Adds the timestamped + verified-write paths on top of the wave 1
write path. Plus a SystemTime → FILETIME helper so callers don't have
to do the 1970→1601 epoch arithmetic by hand.

New
- Session::write_value_at(reference, value, timestamp_filetime) —
  port of MxNativeSession.Write2Async (cs:187-209). Delegates to
  NmxClient::write2 with the same routing as write_value.
- Session::write_value_secured_at(reference, value, ts, security) —
  port of MxNativeSession.WriteSecured2Async (cs:223-248). Uses the
  session's options.engine_name as the client name (matches cs:239's
  _options.EngineName convention). Single-user secured writes pass
  current_user_id == verifier_user_id per R6 verification.
- system_time_to_filetime(SystemTime) -> Result<i64, Error>: converts
  via the canonical 11_644_473_600s offset between 1970-01-01 and
  1601-01-01. Pre-1970 values map to Configuration::InvalidArgument.

Tests (7 new in mxaccess; total 26)
- write_value_at round-trip via in-memory resolver + hand-rolled server.
- write_value_secured_at round-trip with single-user (same id twice).
- write_value_at propagates non-zero HRESULT as InvalidArgument.
- system_time_to_filetime: Unix-epoch known value
  (11_644_473_600 * 10_000_000), +1s offset, +500ms subsecond
  conversion, pre-1970 rejection.

One targeted fix: rewrote a doc comment that started a continuation
line with `+ verifier user pair` — clippy parsed `+` as a markdown
list bullet (clippy::doc_lazy_continuation).

Test count delta: 487 -> 494 (+7). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:08:22 -04:00
Joseph Doherty 12cb10c3a1 [M4] mxaccess: Session::connect_nmx + write_value + shutdown (wave 1 main)
First working M4 wave 1 slice. Adds session.rs with the connect /
write / shutdown path on top of NmxClient + Resolver, plus a tokio
test that exercises a full round-trip against a hand-rolled server.
Read, subscribe, recovery, and the long-lived connection task land
in wave 2.

Architecture
- Session holds Arc<SessionInner>; SessionInner wraps NmxClient
  behind a tokio::sync::Mutex. All RPC ops serialize on that mutex.
  Wave 2 will replace it with an mpsc::channel<Op> + dispatcher task
  per design/70-risks-and-open-questions.md R15 (drop-time async
  cleanup hazards).
- ensure_connected gate stops post-shutdown ops with
  Connection::EngineNotRegistered. Shutdown is idempotent via
  AtomicBool::swap.
- Manual Debug impl on SessionInner — neither dyn Resolver nor
  NmxClient impl Debug.

Public API
- Session::connect_nmx(addr, options, ntlm, service_ipid, resolver,
  recovery): validates the policy, opens NmxClient, runs
  RegisterEngine2 (no callback yet — wave 2), optionally configures
  heartbeat. Returns Error::Connection on non-zero HRESULT.
- Session::write_value(reference, value: WriteValue): resolves the
  tag through the configured Resolver, dispatches NmxClient::write.
- Session::resolve_write_kind / resolve_tag: convenience accessors.
- Session::shutdown_nmx: calls UnregisterEngine, idempotent.

Error mapping
- map_nmx / map_transport / map_resolver bridge the inner crate
  errors into the public Error enum. NonZeroHresult → InvalidArgument
  with the hex code; transport Fault → Status-shaped error;
  ResolverError::NotFound → Galaxy { reason: "tag not found: ..." }.
- All three matchers handle their #[non_exhaustive] sources with a
  generic catch-all so future variants don't silently break the map.

Tests (8 new in mxaccess; total mxaccess: 19)
- write_value round-trip via in-memory StaticResolver + hand-rolled
  unauthenticated DCE/RPC server.
- write_value propagates resolver not-found → Galaxy error.
- write_value propagates non-zero HRESULT → InvalidArgument.
- shutdown is idempotent (second call is a no-op).
- write after shutdown returns EngineNotRegistered.
- resolve_tag and resolve_write_kind work without RPC.
- envelope-kind constants used by Session match codec exports
  (sanity guard against codec rename).

mxaccess-nmx: WriteValue now re-exported at crate root.
mxaccess: deps gained mxaccess-nmx/galaxy/rpc + tokio + tracing,
plus async-trait as a dev-dep for the test resolver impl.

Test count delta: 479 -> 487 (+8). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:01:44 -04:00
Joseph Doherty 5cbc330f82 [M4] mxaccess: RecoveryPolicy fields + SessionOptions config
M4 wave 1 prep — the design-pivotal small types per dependencies.md
("(b) is small but design-pivotal — agree the event shape before
consumers depend on it"). The actual Session implementation lands
next iteration as wave 1 main (the .NET MxNativeSession.cs is ~24 KB).

RecoveryPolicy
- Was a unit struct; now carries max_attempts: u32 + delay: Duration
  (port of MxNativeRecoveryPolicy at MxNativeSession.cs:24-43).
- SINGLE_ATTEMPT associated const matches the .NET static at cs:26.
- validate() rejects max_attempts == 0 (cs:33-36); the negative-Delay
  branch (cs:38-41) is unreachable in Rust because Duration is
  unsigned, so it's elided with a doc note.
- Default impl now returns SINGLE_ATTEMPT (was derive Default which
  zero-initialised).

SessionOptions (new — port of MxNativeClientOptions at cs:7-22)
- local_engine_id, engine_name, partner_version, galaxy_id,
  source_platform_id, heartbeat_ticks_per_beat: Option<i32>,
  heartbeat_max_missed_ticks.
- default_local_engine_id() constructor: 0x7000 + (process_id & 0x0FFF)
  per GenerateDefaultLocalEngineId at cs:18-21.
- default_engine_name(): "mxaccess.<pid>" mirroring the .NET
  "MxNativeClient.{ProcessId}" at cs:10.
- partner_version=6 default matches design/60-roadmap.md:54 DoD #1.

Test count delta: 468 -> 479 (+11). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:52:01 -04:00
Joseph Doherty d59ce3571c [M3] mxaccess-nmx: high-level write/advise/un_advise wrappers (resolves F13)
Seven new high-level methods on NmxClient (port of cs:303-466). Each
takes a GalaxyTagMetadata + typed WriteValue (re-exported from
mxaccess-codec), builds the inner NMX body, wraps in NmxTransferEnvelope,
and dispatches via the existing transfer_data opnum.

Methods landed
- write (cs:303-324)
- write2 (cs:326-349, with explicit FILETIME timestamp)
- write_secured2 (cs:351-380, dual user tokens via
  secured_write::resolve_observed_user_token; single-user secured = same id)
- advise_supervisory (cs:382-399, ItemControl envelope)
- send_observed_pre_advise_metadata (cs:401-420, hardcoded target
  platform/engine = (1, 1) per the .NET reference)
- register_reference (cs:422-441, accepts caller-built
  NmxReferenceRegistrationMessage)
- un_advise (cs:443-466, deliberately uses
  NmxTransferMessageKind::Write per cs:457 — the .NET reference's
  divergence from AdviseSupervisory's ItemControl envelope, preserved
  verbatim per CLAUDE.md unknown-bytes rule)

Internal encode_*_transfer_body helpers extracted as pub(crate) fn for
testability — mirrors the .NET reference's `internal static` shape.

NmxClientError gained two new variants: Codec(CodecError) for
metadata->reference-handle and value-encode failures, and
UnsupportedDataType for the kind-resolution path.

Cargo.toml: added mxaccess-galaxy as a dep on mxaccess-nmx.

design/followups.md: F13 moved to Resolved.

Test count delta: 459 -> 468 (+9 in mxaccess-nmx; 8 -> 17). Tests cover
each encode helper standalone (envelope-kind + length checks) plus
real-socket round-trip tests for write / advise_supervisory /
send_observed_pre_advise_metadata.

All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:45:03 -04:00
Joseph Doherty 68aa2e30ab [M3] codec+galaxy: MxValueKind::for_data_type + GalaxyTagMetadata::resolve_write_kind
Last codec-side prerequisite before F13 (NmxClient high-level write
wrappers) can land. Two small additions, both wire-byte-direct ports
of the .NET reference's MxDataType → MxValueKind lookup logic.

mxaccess-codec
- MxValueKind::for_data_type(MxDataType, is_array) -> Option<MxValueKind>:
  fuses NmxWriteMessage.cs:58-86 (TryGetValueKind's 12 base mappings
  for data types 1..=6 scalar+array) with the two scalar fallbacks the
  .NET GalaxyTagMetadata.ProjectWriteValue layers on top
  (GalaxyRepositoryTagResolver.cs:65-69): ElapsedTime → Int32,
  InternationalizedString → String. Returns None for any other
  combination — including arrays of those two types and unsupported
  scalars (ReferenceType, StatusType, Enum, etc.).
- 6 new tests covering the base table, both fallbacks, the array-of-
  unsupported rejection, and the no-mapping branch for ReferenceType /
  StatusType / Enum / DataQualityType / BigString / Unknown / NoData /
  End sentinels.

mxaccess-galaxy
- GalaxyTagMetadata::resolve_write_kind() -> Result<MxValueKind,
  UnsupportedDataType>: pure delegation to MxValueKind::for_data_type
  + a typed error carrying (mx_data_type, is_array) for diagnostics.
- GalaxyTagMetadata::is_writable() — Ok-side accessor for browse UIs.
- UnsupportedDataType public error type (re-exported from lib.rs).
- 7 new tests: Double scalar → Float64, Boolean array → BoolArray,
  ElapsedTime scalar → Int32 (the fallback path), array-of-ElapsedTime
  rejected, InternationalizedString → String, ReferenceType rejected,
  Unknown sentinel rejected.

Test count delta: 446 -> 459 (+13; codec 215 -> 221, galaxy 49 -> 56).
All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:33:42 -04:00
Joseph Doherty baea6eaa41 [M3] mxaccess-galaxy: GalaxyUserProfile + UserResolver trait + role-blob
Lands the user-resolver half of M3 stream A. Pure-Rust foundation —
the tiberius-backed SQL impl is logged as F14 and stays gated behind
the existing galaxy-resolver Cargo feature.

New
- role_blob.rs (~270 LoC, 12 tests including a garbage-between-roles
  edge case) — port of ParseRoleBlob (cs:87-133). Sliding-window scan
  over hex-decoded UTF-16LE bytes; rejects non-printable code units;
  case-insensitive dedup. Pure function, no I/O.
- user.rs (~290 LoC, 8 tests including 4 tokio-driven InMemoryUserResolver
  cases) — GalaxyUserProfile (port of cs:5-11) + from_columns helper
  bridging into role_blob + UserResolver async trait + UserResolverError
  with NotFound / Backend variants.
- sql.rs additions: USER_SELECT_SQL + USER_BY_GUID_SQL + USER_BY_NAME_SQL
  constants (port of cs:135-148). Inline concatcp! macro composes the
  base SELECT with each WHERE clause at compile time without pulling
  const_format.

Cargo.toml: added uuid (Galaxy user_guid is a uniqueidentifier).

design/followups.md: added F14 (P2) for the tiberius-backed SQL impl
behind the galaxy-resolver feature.

Test count delta: 427 -> 446 (+19; mxaccess-galaxy 30 -> 49). All four
DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:26:24 -04:00
Joseph Doherty d84b066c62 [M3] mxaccess-galaxy: GalaxyTagMetadata + parser + Resolver trait + SQL
Lands M3 stream A — the pure-Rust foundation of the Galaxy resolver:
the data type, the tag-reference parser, the async trait, and the
canonical SQL strings. Unblocks F13 (NmxClient::write_* wrappers depend
on GalaxyTagMetadata) without pulling in tiberius yet.

New
- metadata.rs (~195 LoC, 7 tests) — GalaxyTagMetadata record (port of
  cs:6-73). Includes is_buffer_property + to_reference_handle(galaxy_id)
  bridging into mxaccess-codec::MxReferenceHandle::from_names.
- parser.rs (~330 LoC, 12 tests) — ParsedTagReference parser. Handles
  Object.Attribute (1 candidate), Object.Primitive.Attribute (2
  candidates: primitive-attr first, dotted-attr second per cs:181-185),
  and the case-insensitive .property(buffer) suffix. Pure-Rust, no I/O.
- resolver.rs (~200 LoC, 5 tests including a tokio-driven InMemoryResolver
  proving the trait is implementable without SQL) — async Resolver trait
  + ResolverError. Default browse returns Backend("not implemented") so
  read-only backends don't need to override it.
- sql.rs (~280 LoC, 5 smoke tests) — RESOLVE_SQL + BROWSE_SQL constants
  ported byte-for-byte from cs:208-432. Available publicly so any
  backend (the planned tiberius impl, a wwtools/grdb snapshot replay,
  etc.) can grab the canonical query.

Cargo.toml: added mxaccess-codec (path), async-trait, thiserror;
tokio added as dev-dependency for the resolver-trait async tests.

Deliberately deferred to a later iteration:
- The tiberius-backed Resolver impl behind the galaxy-resolver feature.
- ToValueKind / TryGetValueKind / ProjectWriteValue helpers on
  GalaxyTagMetadata (cs:41-72) — these need a MxDataType -> MxValueKind
  lookup that the codec doesn't currently expose; landing them with
  F13's write-helper iteration keeps the iteration coherent.

Test count delta: 397 -> 427 (+30). All four DoD gates green.
Open followups touched: F13 prerequisite (GalaxyTagMetadata) now in
place; F13 itself stays open until the write helpers wire it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:17:16 -04:00
Joseph Doherty 0c772d273d [M3] mxaccess-nmx: NmxClient — 9 raw INmxService2 opnums (stream B)
Lands M3 stream B raw opnum surface: an async NmxClient over the
mxaccess-rpc transport that dispatches all 9 INmxService2 procedures
(GetPartnerVersion, RegisterEngine2 + WithoutCallback, UnregisterEngine,
Connect, AddSubscriberEngine, RemoveSubscriberEngine,
SetHeartbeatSendInterval, TransferData) plus a NonZeroHresult error
variant that mirrors ThrowIfFailed (cs:563-574).

New
- crates/mxaccess-nmx/src/client.rs (~580 LoC, 8 tests including 5
  real-socket tokio tests against a hand-rolled DCE/RPC server) — port
  of the raw opnum surface from ManagedNmxService2Client.cs.
- NmxClient::connect builds the NTLM-packet-integrity bind path; for
  tests, NmxClient::from_bound_transport accepts a transport bound any
  way the caller likes (the test server doesn't validate signatures).
- fresh_orpc_this generates a per-call Cid via rand::random(), mirroring
  the .NET reference's Guid.NewGuid() at every call site.
- NmxClientError::NonZeroHresult unifies the .NET reference's
  Marshal.ThrowExceptionForHR + InvalidOperationException branches so
  callers see one typed surface for "transport-OK + LMX rejected".

Cargo.toml: added tokio, tracing, thiserror, rand to mxaccess-nmx.

Two layers of the .NET reference are deliberately out of scope this
iteration; both logged as new followups in design/followups.md:

- F12 (P1): the auto-resolving Create() factory, which needs windows-rs
  COM activation (gated by F6) + ComObjRefProvider port.
- F13 (P1): the high-level Write*/Advise*/UnAdvise/RegisterReference
  helpers, which depend on GalaxyTagMetadata from M3 stream A (the
  Galaxy SQL resolver crate, not yet started).

Test count delta: 389 -> 397 (+8). All four DoD gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:06:15 -04:00
Joseph Doherty ecfcc3f429 [M3] mxaccess-rpc: NmxService2 codec + F9 ResolveOxid wrappers
Two units of work in one commit:

1. nmx_service2_messages.rs (~470 LoC, 18 tests) — port of
   NmxService2Messages.cs. Encoders for all 9 INmxService2 opnums
   (RegisterEngine, UnRegisterEngine, Connect, TransferData,
   AddSubscriberEngine, RemoveSubscriberEngine, SetHeartbeatSendInterval,
   RegisterEngine2, GetPartnerVersion) plus BSTR + InterfacePointer NDR
   helpers used by RegisterEngine2 marshalling. Decoders for the
   GetPartnerVersion result and the generic HRESULT response. M3 stream
   B (NmxClient) will be a thin layer over these + the transport.

2. object_exporter_client.rs (~290 LoC, 6 tests including 2 real-socket
   tokio tests) — resolves followup F9. Implements:
   - resolve_oxid_unauthenticated (cs:14-30)
   - resolve_oxid_with_managed_ntlm_packet_integrity (cs:66-81)
   ResolveOxidOutcome enum disambiguates the two response shapes the
   .NET reference parses (typed result vs 4-byte failure). The two SSPI
   flavours (cs:32-47, cs:49-64) are permanently skipped — they wrap
   .NET-only System.Net.Security.SspiClientContext.

design/followups.md: F9 moved to Resolved with this commit's hash.

Test count delta: 364 -> 389 (+25; mxaccess-rpc 137 -> 162; +18 from
nmx_service2_messages, +7 from object_exporter_client which includes
the +2 fall-through tests for the dual-shape response decoder).
Open followups touched: F9 resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:56:11 -04:00
Joseph Doherty 432f1102b7 [M2/M3] mxaccess-rpc: tokio DCE/RPC TCP transport (DceRpcTcpClient port)
Lands the async DCE/RPC TCP client — the transport that bridges the M2
PDU codec to a real socket. Unblocks M3 stream B (mxaccess-nmx, the
NmxClient) and brings F9 (ResolveOxid wrappers) within reach.

New
- transport.rs (~700 LoC, 10 tests including 2 real-socket tokio tests)
  — port of src/MxNativeClient/DceRpcTcpClient.cs.
  - DceRpcTcpClient::connect/bind/bind_with_managed_ntlm_packet_integrity/
    call/call_bound/call_bound_object — async over tokio::net::TcpStream.
  - encode_packet_integrity_request: 4-byte 0xBB pad + 8-byte AuthTrailer
    + 16-byte NtlmClientContext::sign signature, frag_length and
    auth_length rewritten in the embedded header per cs:201-250.
  - encode_request_bytes: PFC_OBJECT_UUID flag (0x80) and inserted
    16-byte object UUID slot per cs:269-278.
  - TransportError enum unifies io / codec / NTLM / fault / not-connected
    surfaces. Mirrors DceRpcFaultException as the typed Fault variant.
  - NTLM_AUTH_CONTEXT_ID = 79232 = 0x13580 (cs:90,133) exposed publicly.

Deliberately skipped: BindWithNtlmConnect / BindWithNtlmPacketIntegrity
(SSPI flavours at cs:55-63,108-149) — those wrap .NET's
System.Net.Security.SspiClientContext, which has no portable analogue.
Managed-NTLM path covers what the production Rust client needs.

mxaccess-rpc/Cargo.toml: added tokio (workspace-pinned).

design/followups.md: F9 downgraded P1 → P2 (transport landed; only the
two pure-codec ResolveOxid wrappers remain).

Test count delta: 354 -> 364 (+10).
Open followups touched: F9 partially advanced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:47:42 -04:00
Joseph Doherty b0954b2672 [M2] mxaccess-callback: tokio TCP exporter (wave 3 main)
Lands the M2 wave 3 main course — the INmxSvcCallback callback exporter.
Pure-tokio TCP server that mirrors src/MxNativeClient/ManagedCallbackExporter.cs
and lets a Rust client receive callbacks from NmxSvc.exe.

New
- exporter.rs (~700 LoC, 10 tests) — port of ManagedCallbackExporter.cs.
  CallbackExporter::bind starts a TcpListener + accept loop; per-connection
  serve task walks Bind / AlterContext / Request / Auth3 PDUs and dispatches
  IRemUnknown (opnums 3/4/5) and INmxSvcCallback (opnums 3/4) requests.
  Hand-rolled BindAck encoder mirroring cs:226-254 (single acceptance entry,
  NDR20 transfer syntax).
- ExporterIdentities { oxid, oid, callback_ipid, rem_unknown_ipid } — exposes
  both `random()` (production) and `fixed()` (tests). Mirrors the .NET
  RandomUInt64 + Guid.NewGuid pattern at cs:14-20.
- CallbackEvent enum — typed diagnostic stream replacing .NET's
  List<string> log (cs:12,33-42,315-321). Variants: ClientConnected,
  AcceptError, Bind, Auth3Ignored, Request, RemQueryInterface,
  CallbackInvoked, UnhandledRequest, ClientDisconnected, ProtocolError.
- IUNKNOWN_IID const re-exported alongside the other IIDs.

Tests cover real-socket round-trips: Bind+RemQueryInterface (with IUNKNOWN
returning S_OK), Bind+unknown opnum -> Fault, Bind+DataReceived ->
CallbackInvoked event + 12-byte success response, and graceful shutdown.

Test count delta: 344 -> 354 (+10).
Open followups touched: none new. F2 (verify_signature path) still
gated on a live status-frame fixture under tests/fixtures/m2-status-frame/.
F6 / F9 still need the windows-rs and DceRpcTcpClient ports respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:36:41 -04:00
Joseph Doherty ecbf282f6d [M2] mxaccess-rpc: NMX metadata + callback messages + OBJREF builder
Lands the codec-only prerequisites for M2 wave 3 (callback exporter).
The TCP server itself (port of ManagedCallbackExporter.cs's TcpListener
+ accept loop) follows next iteration in the mxaccess-callback crate.

New modules
- nmx_metadata.rs (9 tests) — port of NmxProcedureMetadata.cs.
  INmxService2 + INmxSvcCallback IIDs, NdrProcedureDescriptor with
  per-opnum metadata for the 9 INmxService2 procedures (opnums 3..11)
  and 2 INmxSvcCallback procedures (opnums 3, 4).
- nmx_callback_messages.rs (8 tests) — port of NmxSvcCallbackMessages.cs.
  parse_callback_request decodes OrpcThis + i32 size + i32 max_count +
  body bytes; encode_callback_response produces the 12-byte OrpcThat +
  HRESULT response.

objref.rs additions
- ComObjRefBuilder::create_standard_objref (8 tests) — port of the
  second class in ManagedCallbackExporter.cs:337-393. Pure-Rust OBJREF
  emitter that builds 68-byte header + dual-string array. Note this is
  *not* the Win32 CoMarshalInterface-based ComObjRefProvider.cs (still
  open as F6); it's the higher-level emitter the callback exporter
  uses to build OBJREF bytes from primitives.
- CALLBACK_OBJREF_AUTH_SERVICES const exposes the 7-entry auth-service
  tower-id table (NTLM SSP through Kerberos extension) the .NET
  reference advertises in every callback OBJREF.

Test count delta: 319 -> 344 (+25; mxaccess-rpc 102 -> 127, codec
unchanged at 215, parity unchanged at 2). All four DoD gates green.
Open followups touched: none new; F6 advances toward resolution but
the windows-rs Win32 wrapper part stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:23:44 -04:00
Joseph Doherty 30138629d3 [M2] mxaccess-rpc: OXID + RemQI body codecs (wave 2)
Lands M2 wave 2 — two pure-Rust body-codec modules under
crates/mxaccess-rpc, plus a small inline ORPC framing port and a
crate-level type consolidation. Resolves F7+F8 from wave 1.

New modules
- guid.rs (4 tests) — hoisted from objref::Guid; shared by all of
  mxaccess-rpc. Resolves F7.
- error.rs — hoisted RpcError union (ShortRead, UnexpectedPacketType,
  UnknownPacketType, InvalidFragmentLength, TruncatedBindBody,
  InvalidAuthTrailer, MissingAuthValue, Decode). Resolves F8.
- orpc.rs (8 tests) — port of OrpcStructures.cs:1-141. ComVersion,
  OrpcThis (32-byte header), OrpcThat (8-byte header),
  MInterfacePointer (length-prefixed OBJREF), StdObjRef (40 bytes).
- object_exporter.rs (~530 LoC, 20 tests) — port of
  ObjectExporterMessages.cs:1-141. IObjectExporter IID, opnums,
  ResolveOxid request encoder + ResolveOxidResult/Failure parsers.
  Owned-string protocol labels cleaned up via Cow upgrade rather than
  Box::leak (ComDualStringEntry::protocol is now Cow<'static, str>).
- rem_unknown.rs (~340 LoC, 11 tests) — port of RemUnknownMessages.cs.
  IRemUnknown IID, RemQueryInterface request/response, RemQiResult.
  4-byte NDR pad in REMQIRESULT preserved as pad_after_hresult per
  CLAUDE.md unknown-bytes rule.

Test count delta: 277 -> 319 (+42; codec 215 unchanged, mxaccess-rpc
60 -> 102, codec parity 2 unchanged).
Open followups touched: F7 + F8 resolved; F9, F10, F11 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:14:29 -04:00
Joseph Doherty 95bd218183 [M2] mxaccess-rpc: NTLMv2 + DCE/RPC PDU + OBJREF parser (wave 1)
Lands M2 wave 1 — three pure-Rust modules under crates/mxaccess-rpc with
60 unit tests. Each is a 1:1 port of one .NET reference file:

- ntlm.rs (1137 LoC, 19 tests) — `ManagedNtlmClientContext.cs`. NTLMv2
  challenge/response, Type1/Type3 builders, sign() with RC4-sealed checksum
  and per-call sequence advance. Manual `Debug` impl that hides credentials;
  not Clone (rc4 0.2 cipher state is non-Clone). Pure-Rust crypto via
  hmac/md-5/md4/rc4 v0.2/rand v0.8 (rc4 0.2 chosen per design/review.md:78).
- pdu.rs (1573 LoC, 33 tests) — `DceRpcPdu.cs` + auth-trailer types from
  `DceRpcAuthentication.cs`. Bind/AlterContext/Auth3/Request/Response/Fault
  PDUs, NDR20 transfer syntax, auth_value with 4-byte alignment padding,
  preserved-byte fields per CLAUDE.md unknown-bytes rule.
- objref.rs (~470 LoC, 11 tests including a 366-byte captured OBJREF
  round-trip) — `ComObjRef.cs`. MEOW signature, OXID/OID/IPID, dual-string
  array with printable-ASCII escaping and security-binding boundary.
  ComObjRefProvider.cs deferred (windows-rs Win32 wrapper — see F6).

Every wire-byte claim cites src/MxNativeClient/<file>.cs:LINE per
CLAUDE.md "no fabricated protocol behaviour" rule.

Test count delta: 217 → 277 (+60)
Open followups touched: F1–F8 (new — see design/followups.md)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:54:39 -04:00
35 changed files with 15462 additions and 49 deletions
+90
View File
@@ -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.
+538
View File
@@ -2,12 +2,248 @@
# It is not intended for manual editing.
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]]
name = "mxaccess"
version = "0.0.0"
dependencies = [
"async-trait",
"futures-util",
"mxaccess-callback",
"mxaccess-codec",
"mxaccess-galaxy",
"mxaccess-nmx",
"mxaccess-rpc",
"rand",
"thiserror",
"tokio",
"tokio-stream",
"tracing",
]
[[package]]
@@ -28,6 +264,9 @@ version = "0.0.0"
dependencies = [
"mxaccess-codec",
"mxaccess-rpc",
"rand",
"tokio",
"tracing",
]
[[package]]
@@ -47,6 +286,13 @@ dependencies = [
[[package]]
name = "mxaccess-galaxy"
version = "0.0.0"
dependencies = [
"async-trait",
"mxaccess-codec",
"thiserror",
"tokio",
"uuid",
]
[[package]]
name = "mxaccess-nmx"
@@ -54,12 +300,47 @@ version = "0.0.0"
dependencies = [
"mxaccess-callback",
"mxaccess-codec",
"mxaccess-galaxy",
"mxaccess-rpc",
"rand",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "mxaccess-rpc"
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]]
name = "proc-macro2"
@@ -79,6 +360,73 @@ dependencies = [
"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]]
name = "syn"
version = "2.0.117"
@@ -110,8 +458,198 @@ dependencies = [
"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]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
]
+3
View File
@@ -11,6 +11,9 @@ authors.workspace = true
[dependencies]
mxaccess-rpc = { path = "../mxaccess-rpc" }
mxaccess-codec = { path = "../mxaccess-codec" }
tokio = { workspace = true }
tracing = { workspace = true }
rand = "0.8"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,6 +1,9 @@
//! `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`):
//! - `3` `DataReceived(bufferSize: i32, dataBuffer: sbyte[bufferSize]) -> hresult`
@@ -10,3 +13,7 @@
//! server-side handshake against our exported OBJREF (DoD condition for M2).
#![forbid(unsafe_code)]
pub mod exporter;
pub use exporter::{CallbackEvent, CallbackExporter, ExporterIdentities, IUNKNOWN_IID};
+177
View File
@@ -116,6 +116,61 @@ impl MxValueKind {
pub fn to_u8(self) -> 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`.
@@ -468,4 +523,126 @@ mod tests {
assert_eq!(MxDataType::default(), MxDataType::Unknown);
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);
}
}
+7
View File
@@ -9,6 +9,13 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = "1"
[dev-dependencies]
tokio = { workspace = true }
[features]
default = []
+36 -10
View File
@@ -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`.
//! Replicates the recursive CTE from
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:209-293`
//! (`deployed_package_chain`) against the verified table set
//! `dbo.gobject` / `dbo.instance` / `dbo.dynamic_attribute` /
//! `dbo.attribute_definition` / `dbo.primitive_instance` / `dbo.package`.
//! M3 stream A landed: the trait + metadata + parser + canonical SQL
//! constants. The actual `tiberius`-backed implementation behind the
//! `galaxy-resolver` Cargo feature is a follow-up (see
//! `design/followups.md`).
//!
//! **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.
//! Modules:
//!
//! - [`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)]
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};
+316
View File
@@ -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());
}
}
+348
View File
@@ -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);
}
}
+197
View File
@@ -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()));
}
}
+439
View File
@@ -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 ("));
}
}
+278
View File
@@ -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);
}
}
+5
View File
@@ -10,8 +10,13 @@ authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
mxaccess-galaxy = { path = "../mxaccess-galaxy" }
mxaccess-rpc = { path = "../mxaccess-rpc" }
mxaccess-callback = { path = "../mxaccess-callback" }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
rand = "0.8"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,6 +1,11 @@
//! `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`,
//! and on the wire — sequential because `INmxService2 : INmxService` continues
@@ -16,3 +21,7 @@
//! - `11` GetPartnerVersion
#![forbid(unsafe_code)]
pub mod client;
pub use client::{NmxClient, NmxClientError, WriteValue};
+7
View File
@@ -9,6 +9,13 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
thiserror = { workspace = true }
tokio = { workspace = true }
hmac = "0.12"
md-5 = "0.10"
md4 = "0.10"
rc4 = "0.2"
rand = "0.8"
[features]
default = []
+77
View File
@@ -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,
},
}
+145
View File
@@ -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"
);
}
}
+22 -2
View File
@@ -1,10 +1,30 @@
//! `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
//! (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
// 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());
}
}
+915
View File
@@ -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);
}
}
+397
View File
@@ -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
+469
View File
@@ -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);
}
}
+862
View File
@@ -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"));
}
}
+14 -2
View File
@@ -9,8 +9,20 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
thiserror = { workspace = true }
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 }
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]
default = []
+254 -33
View File
@@ -21,21 +21,21 @@ pub use mxaccess_codec::{
// ---- 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
/// clone fires `UnregisterEngine` best-effort. For deterministic shutdown,
/// call `Session::shutdown(timeout).await`.
#[derive(Debug, Clone)]
pub struct Session {
_inner: Arc<SessionInner>,
pub(crate) inner: Arc<session::SessionInner>,
}
#[derive(Debug)]
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;
// `Subscription` is defined in `session.rs`; re-export below alongside Session.
/// One inbound update. Carries both `quality: u16` (legacy 16-bit OPC quality,
/// e.g. `0xC0` = "Good") and `status: MxStatus` (the richer category model).
@@ -86,8 +86,52 @@ pub struct TransportCapabilities {
pub operation_complete_frame: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RecoveryPolicy;
/// Reconnect / recover policy. Mirrors `MxNativeRecoveryPolicy`
/// (`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
/// `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 ------------------------------------------------------
#[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> {
Err(Error::Unsupported {
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,
/// then `UnregisterEngine`. Recommended exit path for production code.
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