From 41f2d4c0f20c56f3d78413eeef492d9eed871718 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 21:54:43 -0400 Subject: [PATCH] [F14] mxaccess-galaxy: tiberius-backed SQL Resolver + UserResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module crates/mxaccess-galaxy/src/sql_resolver.rs (~480 LoC) gated behind the existing galaxy-resolver Cargo feature. Adds SqlTagResolver + SqlUserResolver, both constructed via from_ado_string(&str) accepting the same connection-string shape the .NET reference uses by default (Server=localhost;Database=ZB;Integrated Security=True; Encrypt=False;TrustServerCertificate=True). Integrated Security=True resolves to Windows auth via tiberius's winauth feature. Each top-level call (resolve / browse / resolve_by_guid / resolve_by_name) opens a fresh Client> and drops it on return — matches the .NET `await using` lifecycle at GalaxyRepositoryTagResolver.cs:93-95. tiberius's Client::query only accepts positional @P1..@PN placeholders (delegates to sp_executesql); the canonical RESOLVE_SQL / BROWSE_SQL / USER_BY_GUID_SQL / USER_BY_NAME_SQL constants are rewritten once-per-process via OnceLock (@objectTagName → @P1, etc.). The unrewritten constants stay byte-identical with the .NET reference for ad-hoc diagnostic copy/paste. read_metadata mirrors ReadMetadata (cs:149-165) byte-by-byte: signed smallint → i16 widened to u16 for platform/engine/object IDs (matches the .NET checked((ushort)reader.GetInt16(N)) shape), int → i32 checked-cast to i16 for property_id, nullable nvarchar for primitive_name. read_user_profile mirrors ReadProfile (cs:76-85) including the roles_text blob → parse_role_blob round-trip. Deps added (gated): tiberius 0.12 (default-features = false; tds73 + rustls + winauth — no chrono / rust_decimal pulled), tokio-util's compat feature for the futures-rs ↔ tokio AsyncRead bridge, futures-util for TryStreamExt::try_next. Default-feature build still pulls only mxaccess-codec + async-trait + thiserror + uuid (slim foot-print preserved per the design doc's intent). New `live` feature on this crate (`live = ["galaxy-resolver"]`) for parity with the workspace pattern. 11 offline unit tests pin: SQL named→positional rewriting (no @named left, @P1/@P2/@P3 present), line-count preserved, ado-string acceptance (default Galaxy shape parses, garbage rejected), input validation (max_rows=0 rejected, empty LIKE rejected, empty user_name rejected, all checked before connect attempt). Two #[cfg(feature = "live")] #[ignore]'d tests round-trip against a real Galaxy DB (gated on MX_LIVE + MX_GALAXY_DB env vars per tools/Setup-LiveProbeEnv.ps1). Live verification on this host: live_resolve_test_child_object_test_int and live_browse_test_child_object both pass against the local AVEVA install — TestChildObject.TestInt resolves with mx_data_type=2 (Int32), is_array=false. Closes F14 in design/followups.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 10 +- rust/Cargo.lock | 529 +++++++++++++- rust/crates/mxaccess-galaxy/Cargo.toml | 17 +- rust/crates/mxaccess-galaxy/src/lib.rs | 4 + .../mxaccess-galaxy/src/sql_resolver.rs | 668 ++++++++++++++++++ 5 files changed, 1197 insertions(+), 31 deletions(-) create mode 100644 rust/crates/mxaccess-galaxy/src/sql_resolver.rs diff --git a/design/followups.md b/design/followups.md index 40c0e2e..17adb12 100644 --- a/design/followups.md +++ b/design/followups.md @@ -236,15 +236,11 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M **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 +### F14 — `tiberius`-backed SQL implementation of `Resolver` + `UserResolver` +**Resolved:** 2026-05-05 (commit ``). New module `crates/mxaccess-galaxy/src/sql_resolver.rs` (~480 LoC) gated behind the existing `galaxy-resolver` Cargo feature; adds `SqlTagResolver` + `SqlUserResolver`, both constructed via `from_ado_string(&str)` accepting the same shape the .NET reference uses by default (`Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). `Integrated Security=True` resolves to Windows authentication via tiberius's `winauth` feature. Each top-level call opens a fresh `Client>` and drops it on return — matches the .NET `await using` shape. `tiberius`'s `Client::query` only accepts positional `@P1..@PN` placeholders (delegates to `sp_executesql`); the canonical `RESOLVE_SQL` / `BROWSE_SQL` / `USER_BY_GUID_SQL` / `USER_BY_NAME_SQL` constants are rewritten once-per-process via `OnceLock` (`@objectTagName` → `@P1`, etc.). `read_metadata` mirrors `ReadMetadata` (`cs:149-165`) byte-by-byte: signed `smallint` → `i16` widened to `u16` for platform/engine/object IDs (matches the .NET `checked((ushort)...)`), `int` → `i32` checked-cast to `i16` for `property_id`, nullable `nvarchar` for `primitive_name`. `read_user_profile` mirrors `ReadProfile` (`cs:76-85`) including the `roles_text` blob → `parse_role_blob` round-trip. New deps: `tiberius 0.12` (`tds73`/`rustls`/`winauth` features, no `chrono` / `rust_decimal`), `tokio-util` `compat` feature for the futures-rs ↔ tokio AsyncRead bridge, `futures-util` for `TryStreamExt::try_next`. New `live` feature in the crate for parity with the workspace pattern (`live = ["galaxy-resolver"]`). 11 offline unit tests pin: SQL named→positional rewriting (no `@named` left, `@P1`/`@P2`/`@P3` present), line-count preserved by rewriting, ado-string acceptance (default Galaxy shape parses; garbage rejected), input validation (`max_rows=0` rejected, empty `LIKE` rejected, empty user_name rejected). Two `#[cfg(feature = "live")]` `#[ignore]`'d tests round-trip against a real Galaxy DB (gated on `MX_LIVE` + `MX_GALAXY_DB` env vars per `tools/Setup-LiveProbeEnv.ps1`): `live_resolve_test_child_object_test_int` (TestChildObject.TestInt → mx_data_type=2 Int32, is_array=false) and `live_browse_test_child_object` (browse returns ≥1 attribute on TestChildObject). Both pass against the local AVEVA install. + ### F4 + F5 — BindAck body parser + captured-bytes round-trip **Resolved:** 2026-05-05 (commit ``). Single change closes both: new `BindAckPdu` struct + `BindAckResult` per-result type + `decode`/`encode` impl in `crates/mxaccess-rpc/src/pdu.rs`. Body layout per `[C706]` §12.6.3.4: `port_any_t` secondary address (u16-length + bytes including NUL) + alignment to 4-byte boundary + `n_results` u8 + 3 reserved + array of `p_result_t` (u16 result + u16 reason + 20-byte SyntaxId). Accepts both `PacketType::BindAck` and `PacketType::AlterContextResponse` (same body shape). New regression test `bind_ack_round_trips_live_capture` decodes the first 84 bytes of `captures/013-loopback-subscribe-scalars/tcp-stream-__1_49704-to-__1_55690.bin` (the server's response to the client's first Bind), asserts the shape (sec_addr=`"49704\0"`, n_results=2, NDR accepted + DCOM negotiate_ack reason 3), then re-encodes and asserts byte-identical against the original frame. Stronger live-wire parity than the prior synthetic-frame tests. F4 + F5 collapsed into one commit because they share scope (parser + round-trip-test). diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8ed0787..0864fd9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -30,12 +30,43 @@ dependencies = [ "syn", ] +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -69,6 +100,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -84,6 +121,16 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -111,6 +158,28 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -159,6 +228,41 @@ dependencies = [ "subtle", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -175,6 +279,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -205,8 +315,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -221,6 +334,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -229,7 +353,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -293,6 +417,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "md-5" version = "0.10.6" @@ -312,6 +442,18 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6bcd6433cff03a4bfc3d9834d504467db1f1cf6d0ea765d37d330249ed629d" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -329,8 +471,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", - "windows-sys", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", ] [[package]] @@ -346,8 +488,8 @@ dependencies = [ "mxaccess-galaxy", "mxaccess-nmx", "mxaccess-rpc", - "rand", - "thiserror", + "rand 0.8.6", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -359,8 +501,8 @@ version = "0.0.0" dependencies = [ "mxaccess-asb-nettcp", "mxaccess-codec", - "rand", - "thiserror", + "rand 0.8.6", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -380,10 +522,10 @@ dependencies = [ "num-integer", "num-traits", "pbkdf2", - "rand", + "rand 0.8.6", "sha1", "sha2", - "thiserror", + "thiserror 2.0.18", "tracing", "zeroize", ] @@ -394,7 +536,7 @@ version = "0.0.0" dependencies = [ "mxaccess-codec", "mxaccess-rpc", - "rand", + "rand 0.8.6", "tokio", "tracing", ] @@ -403,7 +545,7 @@ dependencies = [ name = "mxaccess-codec" version = "0.0.0" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -418,9 +560,12 @@ name = "mxaccess-galaxy" version = "0.0.0" dependencies = [ "async-trait", + "futures-util", "mxaccess-codec", - "thiserror", + "thiserror 2.0.18", + "tiberius", "tokio", + "tokio-util", "uuid", ] @@ -432,8 +577,8 @@ dependencies = [ "mxaccess-codec", "mxaccess-galaxy", "mxaccess-rpc", - "rand", - "thiserror", + "rand 0.8.6", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -445,9 +590,9 @@ dependencies = [ "hmac", "md-5", "md4", - "rand", + "rand 0.8.6", "rc4", - "thiserror", + "thiserror 2.0.18", "tokio", ] @@ -485,6 +630,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -510,6 +661,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -528,6 +685,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.6" @@ -535,8 +705,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -546,7 +726,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -555,7 +744,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -567,12 +765,111 @@ dependencies = [ "cipher 0.5.1", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "sha1" version = "0.10.6" @@ -595,6 +892,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -614,7 +917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -634,13 +937,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -654,6 +977,34 @@ dependencies = [ "syn", ] +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio-rustls", + "tokio-util", + "tracing", + "uuid", + "winauth", +] + [[package]] name = "tokio" version = "1.52.2" @@ -666,7 +1017,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -680,6 +1031,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -700,6 +1061,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -711,6 +1073,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -748,6 +1111,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.23.1" @@ -764,6 +1133,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -815,12 +1190,56 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winauth" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f820cd208ce9c6b050812dc2d724ba98c6c1e9db5ce9b3f58d925ae5723a5e6" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "md5", + "rand 0.7.3", + "winapi", +] + [[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -830,6 +1249,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/rust/crates/mxaccess-galaxy/Cargo.toml b/rust/crates/mxaccess-galaxy/Cargo.toml index 1ac94a0..5747b54 100644 --- a/rust/crates/mxaccess-galaxy/Cargo.toml +++ b/rust/crates/mxaccess-galaxy/Cargo.toml @@ -14,6 +14,17 @@ async-trait = { workspace = true } thiserror = { workspace = true } uuid = "1" +# F14 — tiberius-backed SQL resolver. Optional, gated by `galaxy-resolver` +# so the default footprint stays slim (no TDS / TLS / winauth pulled when +# consumers plug their own Resolver impl). tokio-util's `compat` feature +# bridges `tokio::net::TcpStream` to the futures-rs AsyncRead/Write that +# tiberius expects. `futures-util::TryStreamExt` is needed to drain the +# QueryStream rows. +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "winauth"], optional = true } +tokio = { workspace = true, optional = true } +tokio-util = { version = "0.7", features = ["compat"], optional = true } +futures-util = { workspace = true, optional = true } + [dev-dependencies] tokio = { workspace = true } @@ -22,7 +33,11 @@ default = [] # `galaxy-resolver` (off by default in M0) pulls `tiberius`. Consumers using a # custom `Resolver` impl leave it off and avoid pulling TDS / native-tls / # winauth. Per design/30-crate-topology.md `mxaccess-galaxy` section. -galaxy-resolver = [] +galaxy-resolver = ["dep:tiberius", "dep:tokio", "dep:tokio-util", "dep:futures-util"] +# Enables live integration tests that hit a real SQL Server (typically the +# Galaxy DB on the local AVEVA install). Driven by `MX_LIVE` + `MX_GALAXY_DB` +# env vars, populated by `tools/Setup-LiveProbeEnv.ps1`. +live = ["galaxy-resolver"] auth-windows = [] [lints] diff --git a/rust/crates/mxaccess-galaxy/src/lib.rs b/rust/crates/mxaccess-galaxy/src/lib.rs index ecf7d47..46521f9 100644 --- a/rust/crates/mxaccess-galaxy/src/lib.rs +++ b/rust/crates/mxaccess-galaxy/src/lib.rs @@ -31,10 +31,14 @@ pub mod parser; pub mod resolver; pub mod role_blob; pub mod sql; +#[cfg(feature = "galaxy-resolver")] +pub mod sql_resolver; 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; +#[cfg(feature = "galaxy-resolver")] +pub use sql_resolver::{SqlTagResolver, SqlUserResolver}; pub use user::{GalaxyUserProfile, UserResolver, UserResolverError}; diff --git a/rust/crates/mxaccess-galaxy/src/sql_resolver.rs b/rust/crates/mxaccess-galaxy/src/sql_resolver.rs new file mode 100644 index 0000000..1db566c --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/sql_resolver.rs @@ -0,0 +1,668 @@ +//! `tiberius`-backed implementations of [`crate::Resolver`] and +//! [`crate::UserResolver`]. Gated by the `galaxy-resolver` Cargo feature. +//! +//! Direct port of `GalaxyRepositoryTagResolver.cs` and +//! `GalaxyRepositoryUserResolver.cs`. The pure-Rust foundation +//! (parser, metadata, SQL constants) was already in place; this module +//! is the "fill in the backend" piece tracked as F14 in +//! `design/followups.md`. +//! +//! ## Connection-string parsing +//! +//! Both resolvers accept an `ADO.NET`-style connection string via +//! [`SqlTagResolver::from_ado_string`] / [`SqlUserResolver::from_ado_string`]. +//! The string is parsed by `tiberius::Config::from_ado_string`, which +//! accepts the same shape the .NET reference uses by default +//! (`Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). +//! `Integrated Security=True` resolves to Windows authentication on +//! Windows hosts via the `winauth` feature. +//! +//! ## Named-parameter rewriting +//! +//! `tiberius` only accepts positional `@P1..@PN` placeholders (it +//! delegates to `sp_executesql` internally). The canonical SQL constants +//! in [`crate::sql`] use named parameters (`@objectTagName`, +//! `@attributeName`, `@primitiveName`, `@objectTagLike`, `@attributeLike`, +//! `@maxRows`, `@userGuid`, `@userName`) to stay byte-identical with the +//! .NET reference. Each query string is rewritten once at module-init +//! time via [`std::sync::OnceLock`]. +//! +//! ## Connection lifetime +//! +//! Each top-level call (`resolve`, `browse`, `resolve_by_guid`, +//! `resolve_by_name`) opens a fresh `tiberius::Client` and drops it on +//! return. This matches the `await using` pattern in the .NET reference +//! (`GalaxyRepositoryTagResolver.cs:93-95`). The Galaxy DB is not +//! request-pooled in the .NET shape either — tag resolution happens once +//! per session bring-up, not on the data-plane hot path. + +#![cfg(feature = "galaxy-resolver")] + +use std::sync::OnceLock; + +use async_trait::async_trait; +use futures_util::TryStreamExt; +use tiberius::{Client, Config, QueryItem, Row}; +use tokio::net::TcpStream; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; +use uuid::Uuid; + +use crate::metadata::GalaxyTagMetadata; +use crate::parser::ParsedTagReference; +use crate::resolver::{Resolver, ResolverError}; +use crate::sql; +use crate::user::{GalaxyUserProfile, UserResolver, UserResolverError}; + +/// Shorthand for the tiberius client we hold per call. +type SqlClient = Client>; + +// --------------------------------------------------------------------------- +// Tag resolver +// --------------------------------------------------------------------------- + +/// Tiberius-backed [`Resolver`] hitting a Galaxy Repository SQL Server. +/// +/// Construct with [`SqlTagResolver::from_ado_string`]. The same +/// connection string the .NET reference uses works verbatim +/// (`Server=...;Database=...;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). +#[derive(Debug)] +pub struct SqlTagResolver { + config: Config, +} + +impl SqlTagResolver { + /// Build a resolver from an ADO.NET connection string. Mirrors + /// the .NET reference's default-construction path + /// (`GalaxyRepositoryTagResolver.cs:77-86`). + /// + /// # Errors + /// + /// [`ResolverError::Backend`] when `tiberius::Config::from_ado_string` + /// rejects the string (unparseable key/value, unsupported auth, + /// etc.). + pub fn from_ado_string(connection_string: &str) -> Result { + let config = Config::from_ado_string(connection_string).map_err(|e| { + ResolverError::Backend { + message: format!("invalid ADO.NET connection string: {e}"), + } + })?; + Ok(Self { config }) + } + + async fn open(&self) -> Result { + open_client(&self.config) + .await + .map_err(|message| ResolverError::Backend { message }) + } +} + +#[async_trait] +impl Resolver for SqlTagResolver { + async fn resolve(&self, tag_reference: &str) -> Result { + let candidates = ParsedTagReference::parse_candidates(tag_reference)?; + let mut client = self.open().await?; + + for parsed in &candidates { + let primitive = parsed.primitive_name.as_deref(); + let object_tag = parsed.object_tag_name.as_str(); + let attribute = parsed.attribute_name.as_str(); + + let mut stream = client + .query( + resolve_sql_pos(), + &[&object_tag, &attribute, &primitive], + ) + .await + .map_err(|e| ResolverError::Backend { + message: format!("RESOLVE_SQL execute: {e}"), + })?; + + while let Some(item) = stream + .try_next() + .await + .map_err(|e| ResolverError::Backend { + message: format!("RESOLVE_SQL fetch: {e}"), + })? + { + if let QueryItem::Row(row) = item { + let metadata = read_metadata(&row).map_err(|e| ResolverError::Backend { + message: format!("RESOLVE_SQL row decode: {e}"), + })?; + return Ok(parsed.apply_overrides(metadata)); + } + } + } + + Err(ResolverError::NotFound { + tag_reference: tag_reference.to_string(), + }) + } + + async fn browse( + &self, + object_tag_like: &str, + attribute_like: &str, + max_rows: usize, + ) -> Result, ResolverError> { + if object_tag_like.trim().is_empty() { + return Err(ResolverError::Backend { + message: "object_tag_like must not be empty".to_string(), + }); + } + if attribute_like.trim().is_empty() { + return Err(ResolverError::Backend { + message: "attribute_like must not be empty".to_string(), + }); + } + if max_rows == 0 { + return Err(ResolverError::Backend { + message: "max_rows must be positive".to_string(), + }); + } + + // Mirror the .NET clamp at GalaxyRepositoryTagResolver.cs:137. + let clamped = i32::try_from(max_rows.min(1000)).unwrap_or(1000); + + let mut client = self.open().await?; + let mut stream = client + .query( + browse_sql_pos(), + &[&object_tag_like, &attribute_like, &clamped], + ) + .await + .map_err(|e| ResolverError::Backend { + message: format!("BROWSE_SQL execute: {e}"), + })?; + + let mut out = Vec::new(); + while let Some(item) = stream + .try_next() + .await + .map_err(|e| ResolverError::Backend { + message: format!("BROWSE_SQL fetch: {e}"), + })? + { + if let QueryItem::Row(row) = item { + out.push(read_metadata(&row).map_err(|e| ResolverError::Backend { + message: format!("BROWSE_SQL row decode: {e}"), + })?); + } + } + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// User resolver +// --------------------------------------------------------------------------- + +/// Tiberius-backed [`UserResolver`]. +/// +/// Mirrors `GalaxyRepositoryUserResolver` (`cs:13-149`). +#[derive(Debug)] +pub struct SqlUserResolver { + config: Config, +} + +impl SqlUserResolver { + /// Build a user resolver from an ADO.NET connection string. + /// + /// # Errors + /// + /// [`UserResolverError::Backend`] when the connection string is + /// rejected by `tiberius::Config::from_ado_string`. + pub fn from_ado_string(connection_string: &str) -> Result { + let config = Config::from_ado_string(connection_string).map_err(|e| { + UserResolverError::Backend { + message: format!("invalid ADO.NET connection string: {e}"), + } + })?; + Ok(Self { config }) + } + + async fn open(&self) -> Result { + open_client(&self.config) + .await + .map_err(|message| UserResolverError::Backend { message }) + } +} + +#[async_trait] +impl UserResolver for SqlUserResolver { + async fn resolve_by_guid( + &self, + user_guid: Uuid, + ) -> Result { + let mut client = self.open().await?; + // tiberius's `Uuid` parameter binds to `uniqueidentifier` directly. + let mut stream = client + .query(user_by_guid_sql_pos(), &[&user_guid]) + .await + .map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_GUID_SQL execute: {e}"), + })?; + + while let Some(item) = stream + .try_next() + .await + .map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_GUID_SQL fetch: {e}"), + })? + { + if let QueryItem::Row(row) = item { + return read_user_profile(&row).map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_GUID_SQL row decode: {e}"), + }); + } + } + + Err(UserResolverError::NotFound { + key: user_guid.to_string(), + }) + } + + async fn resolve_by_name( + &self, + user_name: &str, + ) -> Result { + if user_name.trim().is_empty() { + return Err(UserResolverError::Backend { + message: "user_name must not be empty".to_string(), + }); + } + let mut client = self.open().await?; + let mut stream = client + .query(user_by_name_sql_pos(), &[&user_name]) + .await + .map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_NAME_SQL execute: {e}"), + })?; + + while let Some(item) = stream + .try_next() + .await + .map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_NAME_SQL fetch: {e}"), + })? + { + if let QueryItem::Row(row) = item { + return read_user_profile(&row).map_err(|e| UserResolverError::Backend { + message: format!("USER_BY_NAME_SQL row decode: {e}"), + }); + } + } + + Err(UserResolverError::NotFound { + key: user_name.to_string(), + }) + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/// Open a fresh tiberius client to the configured server. Returns a +/// `String` error so each caller can wrap into its preferred error +/// taxonomy. +async fn open_client(config: &Config) -> Result { + let stream = TcpStream::connect(config.get_addr()) + .await + .map_err(|e| format!("TCP connect to {}: {e}", config.get_addr()))?; + // NODELAY mirrors what tiberius's own examples set; latency-sensitive + // for short query/response cycles. + let _ = stream.set_nodelay(true); + Client::connect(config.clone(), stream.compat_write()) + .await + .map_err(|e| format!("tiberius connect: {e}")) +} + +/// Decode one resolver row per `ReadMetadata` (`cs:149-165`). +/// +/// SQL Server smallint → tiberius `i16`; bit → `bool`; nvarchar → +/// `&str`. The platform/engine/object IDs are signed `smallint` on the +/// wire but the .NET reference checked-casts to `ushort`; we widen to +/// `u16` the same way. +fn read_metadata(row: &Row) -> Result { + let object_tag_name: &str = row + .try_get::<&str, _>(0) + .map_err(|e| format!("col 0 object_tag_name: {e}"))? + .ok_or("col 0 object_tag_name: NULL")?; + let attribute_name: &str = row + .try_get::<&str, _>(1) + .map_err(|e| format!("col 1 attribute_name: {e}"))? + .ok_or("col 1 attribute_name: NULL")?; + let primitive_name: Option<&str> = row + .try_get::<&str, _>(2) + .map_err(|e| format!("col 2 primitive_name: {e}"))?; + let platform_id_i16: i16 = row + .try_get::(3) + .map_err(|e| format!("col 3 mx_platform_id: {e}"))? + .ok_or("col 3 mx_platform_id: NULL")?; + let engine_id_i16: i16 = row + .try_get::(4) + .map_err(|e| format!("col 4 mx_engine_id: {e}"))? + .ok_or("col 4 mx_engine_id: NULL")?; + let object_id_i16: i16 = row + .try_get::(5) + .map_err(|e| format!("col 5 mx_object_id: {e}"))? + .ok_or("col 5 mx_object_id: NULL")?; + let primitive_id: i16 = row + .try_get::(6) + .map_err(|e| format!("col 6 mx_primitive_id: {e}"))? + .ok_or("col 6 mx_primitive_id: NULL")?; + let attribute_id: i16 = row + .try_get::(7) + .map_err(|e| format!("col 7 mx_attribute_id: {e}"))? + .ok_or("col 7 mx_attribute_id: NULL")?; + let property_id_i32: i32 = row + .try_get::(8) + .map_err(|e| format!("col 8 property_id: {e}"))? + .ok_or("col 8 property_id: NULL")?; + let mx_data_type: i16 = row + .try_get::(9) + .map_err(|e| format!("col 9 mx_data_type: {e}"))? + .ok_or("col 9 mx_data_type: NULL")?; + let is_array: bool = row + .try_get::(10) + .map_err(|e| format!("col 10 is_array: {e}"))? + .ok_or("col 10 is_array: NULL")?; + let security_classification: i16 = row + .try_get::(11) + .map_err(|e| format!("col 11 security_classification: {e}"))? + .ok_or("col 11 security_classification: NULL")?; + let attribute_source: &str = row + .try_get::<&str, _>(12) + .map_err(|e| format!("col 12 attribute_source: {e}"))? + .ok_or("col 12 attribute_source: NULL")?; + + let property_id = i16::try_from(property_id_i32) + .map_err(|_| format!("property_id {property_id_i32} out of i16 range"))?; + + Ok(GalaxyTagMetadata { + object_tag_name: object_tag_name.to_string(), + attribute_name: attribute_name.to_string(), + primitive_name: primitive_name.map(str::to_string), + platform_id: u16::try_from(platform_id_i16) + .map_err(|_| format!("platform_id {platform_id_i16} negative"))?, + engine_id: u16::try_from(engine_id_i16) + .map_err(|_| format!("engine_id {engine_id_i16} negative"))?, + object_id: u16::try_from(object_id_i16) + .map_err(|_| format!("object_id {object_id_i16} negative"))?, + primitive_id, + attribute_id, + property_id, + mx_data_type, + is_array, + security_classification, + attribute_source: attribute_source.to_string(), + }) +} + +/// Decode one user-profile row per `ReadProfile` (`cs:76-85`). +fn read_user_profile(row: &Row) -> Result { + let user_profile_id: i32 = row + .try_get::(0) + .map_err(|e| format!("col 0 user_profile_id: {e}"))? + .ok_or("col 0 user_profile_id: NULL")?; + let user_profile_name: &str = row + .try_get::<&str, _>(1) + .map_err(|e| format!("col 1 user_profile_name: {e}"))? + .ok_or("col 1 user_profile_name: NULL")?; + let user_guid: Uuid = row + .try_get::(2) + .map_err(|e| format!("col 2 user_guid: {e}"))? + .ok_or("col 2 user_guid: NULL")?; + let default_security_group: &str = row + .try_get::<&str, _>(3) + .map_err(|e| format!("col 3 default_security_group: {e}"))? + .ok_or("col 3 default_security_group: NULL")?; + let intouch_access_level: Option = row + .try_get::(4) + .map_err(|e| format!("col 4 intouch_access_level: {e}"))?; + let roles_text: Option<&str> = row + .try_get::<&str, _>(5) + .map_err(|e| format!("col 5 roles_text: {e}"))?; + + Ok(GalaxyUserProfile::from_columns( + user_profile_id, + user_profile_name.to_string(), + user_guid, + default_security_group.to_string(), + intouch_access_level, + roles_text, + )) +} + +// --------------------------------------------------------------------------- +// Named-parameter rewriting +// --------------------------------------------------------------------------- +// +// tiberius accepts only positional `@P1..@PN` placeholders. Each canonical +// SQL constant in `crate::sql` uses named parameters (matching the .NET +// reference verbatim); rewrite to `@PN` once per process start. + +fn resolve_sql_pos() -> &'static str { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + sql::RESOLVE_SQL + .replace("@objectTagName", "@P1") + .replace("@attributeName", "@P2") + .replace("@primitiveName", "@P3") + }) +} + +fn browse_sql_pos() -> &'static str { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + sql::BROWSE_SQL + .replace("@objectTagLike", "@P1") + .replace("@attributeLike", "@P2") + .replace("@maxRows", "@P3") + }) +} + +fn user_by_guid_sql_pos() -> &'static str { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| sql::USER_BY_GUID_SQL.replace("@userGuid", "@P1")) +} + +fn user_by_name_sql_pos() -> &'static str { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| sql::USER_BY_NAME_SQL.replace("@userName", "@P1")) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + // ----- offline tests (no SQL Server required) ------------------------- + + #[test] + fn resolve_sql_rewrites_three_named_to_positional() { + let sql = resolve_sql_pos(); + assert!(sql.contains("@P1")); + assert!(sql.contains("@P2")); + assert!(sql.contains("@P3")); + assert!(!sql.contains("@objectTagName")); + assert!(!sql.contains("@attributeName")); + assert!(!sql.contains("@primitiveName")); + } + + #[test] + fn browse_sql_rewrites_three_named_to_positional() { + let sql = browse_sql_pos(); + assert!(sql.contains("@P1")); + assert!(sql.contains("@P2")); + assert!(sql.contains("@P3")); + assert!(!sql.contains("@objectTagLike")); + assert!(!sql.contains("@attributeLike")); + assert!(!sql.contains("@maxRows")); + } + + #[test] + fn user_by_guid_rewrites_named_to_positional() { + let sql = user_by_guid_sql_pos(); + assert!(sql.contains("@P1")); + assert!(!sql.contains("@userGuid")); + } + + #[test] + fn user_by_name_rewrites_named_to_positional() { + let sql = user_by_name_sql_pos(); + assert!(sql.contains("@P1")); + assert!(!sql.contains("@userName")); + } + + #[test] + fn rewriting_preserves_line_count() { + // Sanity — replacing named params shouldn't add or remove lines. + assert_eq!( + sql::RESOLVE_SQL.lines().count(), + resolve_sql_pos().lines().count() + ); + assert_eq!( + sql::BROWSE_SQL.lines().count(), + browse_sql_pos().lines().count() + ); + } + + #[test] + fn from_ado_string_rejects_garbage() { + let err = SqlTagResolver::from_ado_string("this is not a valid ADO string").unwrap_err(); + assert!(matches!(err, ResolverError::Backend { .. })); + + let err = SqlUserResolver::from_ado_string("=;=;=").unwrap_err(); + assert!(matches!(err, UserResolverError::Backend { .. })); + } + + #[test] + fn from_ado_string_accepts_default_galaxy_shape() { + // The default shape used by the .NET reference at + // GalaxyRepositoryTagResolver.cs:78. + let s = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True"; + SqlTagResolver::from_ado_string(s).expect("default Galaxy connection shape parses"); + SqlUserResolver::from_ado_string(s) + .expect("default Galaxy connection shape parses for user resolver"); + } + + #[test] + fn browse_rejects_zero_max_rows() { + // We can exercise the input-validation arm without a live DB — + // it's checked before the connect attempt. + let resolver = SqlTagResolver::from_ado_string( + "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", + ) + .unwrap(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt + .block_on(resolver.browse("%", "%", 0)) + .expect_err("max_rows=0 must be rejected"); + match err { + ResolverError::Backend { message } => assert!(message.contains("max_rows")), + other => panic!("expected Backend error, got {other:?}"), + } + } + + #[test] + fn browse_rejects_empty_like_patterns() { + let resolver = SqlTagResolver::from_ado_string( + "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", + ) + .unwrap(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt + .block_on(resolver.browse(" ", "%", 10)) + .expect_err("empty object_tag_like must be rejected"); + assert!(matches!(err, ResolverError::Backend { .. })); + let err = rt + .block_on(resolver.browse("%", "", 10)) + .expect_err("empty attribute_like must be rejected"); + assert!(matches!(err, ResolverError::Backend { .. })); + } + + #[test] + fn resolve_by_name_rejects_empty_user_name() { + let resolver = SqlUserResolver::from_ado_string( + "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", + ) + .unwrap(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt + .block_on(resolver.resolve_by_name(" ")) + .expect_err("empty user_name must be rejected"); + assert!(matches!(err, UserResolverError::Backend { .. })); + } + + // ----- live tests (require MX_LIVE + MX_GALAXY_DB) -------------------- + + /// Live integration test — gated on the workspace's `live` feature + /// AND `MX_LIVE` env var being non-empty AND `MX_GALAXY_DB` being + /// set to a parseable ADO connection string. Populated by + /// `tools/Setup-LiveProbeEnv.ps1`. + #[cfg(feature = "live")] + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires a live Galaxy DB; gated on MX_LIVE + MX_GALAXY_DB"] + async fn live_resolve_test_child_object_test_int() { + if std::env::var_os("MX_LIVE").is_none() { + eprintln!("MX_LIVE not set; skipping"); + return; + } + let conn = match std::env::var("MX_GALAXY_DB") { + Ok(s) if !s.is_empty() => s, + _ => { + eprintln!("MX_GALAXY_DB not set; skipping"); + return; + } + }; + let resolver = SqlTagResolver::from_ado_string(&conn).unwrap(); + let m = resolver + .resolve("TestChildObject.TestInt") + .await + .expect("resolve live tag"); + assert_eq!(m.object_tag_name, "TestChildObject"); + assert_eq!(m.attribute_name, "TestInt"); + // mx_data_type 2 = Int32 per the Galaxy attribute table. + assert_eq!(m.mx_data_type, 2); + assert!(!m.is_array); + } + + #[cfg(feature = "live")] + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires a live Galaxy DB; gated on MX_LIVE + MX_GALAXY_DB"] + async fn live_browse_test_child_object() { + if std::env::var_os("MX_LIVE").is_none() { + return; + } + let conn = match std::env::var("MX_GALAXY_DB") { + Ok(s) if !s.is_empty() => s, + _ => return, + }; + let resolver = SqlTagResolver::from_ado_string(&conn).unwrap(); + let rows = resolver + .browse("TestChildObject", "%", 50) + .await + .expect("browse live tag"); + // The TestChildObject template ships with at least TestInt + + // TestString + a few framework attributes; assert non-empty. + assert!( + !rows.is_empty(), + "expected at least one attribute on TestChildObject" + ); + } +}