[F14] mxaccess-galaxy: tiberius-backed SQL Resolver + UserResolver
rust / build / test / clippy / fmt (push) Has been cancelled

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<Compat<TcpStream>> 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<String> (@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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 21:54:43 -04:00
parent 9501080170
commit 41f2d4c0f2
5 changed files with 1197 additions and 31 deletions
+3 -7
View File
@@ -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 `<this 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<Compat<TcpStream>>` 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<String>` (`@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 `<this 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).
+506 -23
View File
@@ -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"
+16 -1
View File
@@ -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]
+4
View File
@@ -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};
@@ -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<Compat<TcpStream>>;
// ---------------------------------------------------------------------------
// 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<Self, ResolverError> {
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<SqlClient, ResolverError> {
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<GalaxyTagMetadata, ResolverError> {
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<Vec<GalaxyTagMetadata>, 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<Self, UserResolverError> {
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<SqlClient, UserResolverError> {
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<GalaxyUserProfile, UserResolverError> {
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<GalaxyUserProfile, UserResolverError> {
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<SqlClient, String> {
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<GalaxyTagMetadata, String> {
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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i32, _>(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::<i16, _>(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::<bool, _>(10)
.map_err(|e| format!("col 10 is_array: {e}"))?
.ok_or("col 10 is_array: NULL")?;
let security_classification: i16 = row
.try_get::<i16, _>(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<GalaxyUserProfile, String> {
let user_profile_id: i32 = row
.try_get::<i32, _>(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::<Uuid, _>(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<i32> = row
.try_get::<i32, _>(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<String> = 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<String> = 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<String> = 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<String> = 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"
);
}
}