diff --git a/design/followups.md b/design/followups.md index 6d73d16..1a3a703 100644 --- a/design/followups.md +++ b/design/followups.md @@ -60,6 +60,12 @@ move to `## Resolved` with a date + commit hash. **Why deferred:** `ManagedNmxService2Client.Create()` (`ManagedNmxService2Client.cs:30-64`) auto-discovers `(host, port, service_ipid)` by activating the `NmxSvc.NmxService` COM ProgID, marshalling the resulting `IUnknown` to an OBJREF, calling `IObjectExporter::ResolveOxid` against the OXID inside, then `IRemUnknown::RemQueryInterface` to get the `INmxService2` IPID. This requires `windows-rs` for `CoCreateInstance` / `CLSIDFromProgID` (the same gating dep as F6), plus the `ComObjRefProvider.MarshalIUnknownObjRef` port (also F6). **Resolves when:** F6 lands (windows-rs wired in + `ComObjRefProvider` port). At that point `NmxClient::create()` becomes ~30 lines that chain the existing primitives: COM activation → `MarshalIUnknownObjRef` → `ComObjRef::parse` → `object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity` → `rem_unknown::encode_rem_query_interface_request` over a temporary transport → `NmxClient::connect`. +### 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." + ### F13 — `NmxClient` high-level write/advise/subscribe wrappers **Severity:** P1 **Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 55b64c6..b0fd6a5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -31,6 +31,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -84,6 +90,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -132,6 +162,18 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.186" @@ -221,6 +263,7 @@ dependencies = [ "mxaccess-codec", "thiserror", "tokio", + "uuid", ] [[package]] @@ -327,6 +370,18 @@ dependencies = [ "cipher", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "socket2" version = "0.6.3" @@ -443,6 +498,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -455,6 +520,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/rust/crates/mxaccess-galaxy/Cargo.toml b/rust/crates/mxaccess-galaxy/Cargo.toml index 68cd8d4..1ac94a0 100644 --- a/rust/crates/mxaccess-galaxy/Cargo.toml +++ b/rust/crates/mxaccess-galaxy/Cargo.toml @@ -12,6 +12,7 @@ authors.workspace = true mxaccess-codec = { path = "../mxaccess-codec" } async-trait = { workspace = true } thiserror = { workspace = true } +uuid = "1" [dev-dependencies] tokio = { workspace = true } diff --git a/rust/crates/mxaccess-galaxy/src/lib.rs b/rust/crates/mxaccess-galaxy/src/lib.rs index 7ca7b0c..4f27f28 100644 --- a/rust/crates/mxaccess-galaxy/src/lib.rs +++ b/rust/crates/mxaccess-galaxy/src/lib.rs @@ -29,8 +29,12 @@ pub mod metadata; pub mod parser; pub mod resolver; +pub mod role_blob; pub mod sql; +pub mod user; pub use metadata::GalaxyTagMetadata; pub use parser::{ParseError, ParsedTagReference}; pub use resolver::{Resolver, ResolverError}; +pub use role_blob::parse_role_blob; +pub use user::{GalaxyUserProfile, UserResolver, UserResolverError}; diff --git a/rust/crates/mxaccess-galaxy/src/role_blob.rs b/rust/crates/mxaccess-galaxy/src/role_blob.rs new file mode 100644 index 0000000..1ec8c40 --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/role_blob.rs @@ -0,0 +1,333 @@ +//! Parser for the SQL `roles` blob attached to `dbo.user_profile`. +//! +//! Direct port of `ParseRoleBlob` at +//! `src/MxNativeClient/GalaxyRepositoryUserResolver.cs:87-133`. +//! +//! ## Wire format +//! +//! The Galaxy DB stores the user-roles set as a `varbinary` column whose +//! `CONVERT(nvarchar(max), roles)` projection produces a hex-string of the +//! raw bytes (with `0x` prefix). The bytes themselves are a packed +//! sequence of UTF-16LE role names separated by `0x00 0x00` terminators +//! (the UTF-16 NUL character) followed by another `0x00 0x00` (the role-list +//! separator). +//! +//! There is no length prefix and no count; the .NET reference walks the +//! buffer with a sliding window, emitting each printable-ASCII UTF-16LE +//! string of length ≥ 2 that ends in a double-null. Sub-windows that +//! produce a non-printable code unit (anything outside `0x20..=0x7E`) are +//! discarded — this naturally skips garbage between roles. +//! +//! Roles are deduplicated case-insensitively (`StringComparer.OrdinalIgnoreCase` +//! at `cs:124`). +//! +//! ## Why this is a separate module +//! +//! The .NET reference inlines the parser as a `private static`. The Rust +//! port lifts it because (a) it has interesting failure modes worth +//! testing in isolation and (b) future SQL backends (the planned +//! `tiberius`-gated `UserResolver` impl, snapshot-replay test harnesses) +//! all need to call it the same way. + +#![allow(clippy::indexing_slicing)] + +/// Parse a hex-encoded role blob. Returns the deduplicated list of role +/// names in discovery order. Mirrors `ParseRoleBlob` (`cs:87-133`). +/// +/// Behavior: +/// +/// - Input that doesn't start with `0x`/`0X` (case-insensitive per +/// `StringComparison.OrdinalIgnoreCase` at `cs:89`) returns `[]`. +/// - Input shorter than `0x` plus 8 hex chars (the smallest payload that +/// could encode a 2-char role + terminator) returns `[]`. +/// - Hex-decoding failures return `[]` (the .NET reference would throw +/// `FormatException` from `Convert.FromHexString`; the Rust port matches +/// the .NET behavior of yielding an empty list because every caller +/// expects "unknown" to mean "no roles" — there's no way to distinguish +/// "user has no roles" from "user has malformed roles" upstream). +#[must_use] +pub fn parse_role_blob(roles_text: &str) -> Vec { + if !roles_text.len().checked_sub(2).is_some_and(|_| { + roles_text + .get(..2) + .is_some_and(|p| p.eq_ignore_ascii_case("0x")) + }) { + return Vec::new(); + } + + let hex = &roles_text[2..]; + let bytes = match hex_decode(hex) { + Some(b) => b, + None => return Vec::new(), + }; + + let mut roles: Vec = Vec::new(); + let mut offset: usize = 0; + while offset + 3 < bytes.len() { + // Scan a candidate role starting at `offset`. Mirrors the inner + // `while (cursor + 1 < bytes.Length)` loop at cs:100-116. `cursor` + // walks in 2-byte steps reading UTF-16LE code units; `chars` + // accumulates ASCII chars; non-printable chars discard the + // candidate entirely. + let mut chars: Vec = Vec::new(); + let mut cursor = offset; + loop { + if cursor + 1 >= bytes.len() { + break; + } + // (bytes[cursor] | (bytes[cursor+1] << 8)) — UTF-16LE u16. + let code_unit = u16::from(bytes[cursor]) | (u16::from(bytes[cursor + 1]) << 8); + if code_unit == 0 { + break; + } + if !(0x20..=0x7e).contains(&code_unit) { + chars.clear(); + break; + } + // Cast is safe: range above guarantees `code_unit` is a printable + // ASCII byte (0x20..=0x7e), all of which are valid `char` scalars. + chars.push(char::from_u32(u32::from(code_unit)).unwrap_or('\0')); + cursor += 2; + } + + // Terminator check (cs:118-121): role must be ≥2 chars, the cursor + // must still be in-bounds for the trailing 0x00 0x00 pair, and + // those two bytes must both be 0. The inner loop guarantees this + // when it broke on `code_unit == 0`, but the .NET reference + // re-asserts it as a defense against malformed input where the + // inner loop ran off the end without seeing a null. + let role_ok = chars.len() >= 2 + && cursor + 1 < bytes.len() + && bytes[cursor] == 0 + && bytes[cursor + 1] == 0; + if !role_ok { + offset += 1; + continue; + } + + let role: String = chars.iter().collect(); + // Deduplicate case-insensitively (`StringComparer.OrdinalIgnoreCase` + // at cs:124). + if !roles.iter().any(|r| r.eq_ignore_ascii_case(&role)) { + roles.push(role); + } + + // Jump the outer offset past the matched role + the terminator + // pair. The .NET reference does `offset = cursor; offset++` + // (the `++` is the `for`-loop increment) — net effect: the next + // iteration starts at `cursor + 1`, which is the second byte of + // the terminator. This deliberately re-scans starting from the + // "wrong" alignment so the parser tolerates packed bytes that + // happen to look like a partial role on the offset-by-one slot. + offset = cursor + 1; + } + + roles +} + +/// Hex-decode `hex` (no `0x` prefix). Returns `None` on odd length, on +/// non-hex characters, or on overflow. Mirrors `Convert.FromHexString` +/// at `cs:94`. Pure-Rust to avoid pulling `hex` as a dep. +fn hex_decode(hex: &str) -> Option> { + if hex.len() % 2 != 0 { + return None; + } + let bytes = hex.as_bytes(); + let mut out = Vec::with_capacity(hex.len() / 2); + let mut i = 0; + while i < bytes.len() { + let hi = nibble(bytes[i])?; + let lo = nibble(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Some(out) +} + +fn nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + /// Encode a sequence of role strings + a trailing 0x00 0x00 separator + /// into the on-wire byte format, then format as a `0x`-prefixed hex + /// string. Used to build test inputs. + fn encode_roles(roles: &[&str]) -> String { + let mut out: Vec = Vec::new(); + for r in roles { + for c in r.chars() { + let cu = c as u32 as u16; + out.push((cu & 0xFF) as u8); + out.push((cu >> 8) as u8); + } + out.push(0); + out.push(0); + } + // .NET appears to require the trailing 0x00 0x00 after the last + // role to satisfy the cursor+1::new()); + } + + #[test] + fn missing_0x_prefix_returns_empty_list() { + // Even a syntactically-valid hex string without 0x is treated as + // garbage per cs:89. + assert_eq!(parse_role_blob("DEADBEEF"), Vec::::new()); + } + + #[test] + fn just_0x_prefix_returns_empty_list() { + assert_eq!(parse_role_blob("0x"), Vec::::new()); + } + + #[test] + fn upper_and_lower_case_0x_prefix_both_accepted() { + // .NET uses StringComparison.OrdinalIgnoreCase at cs:89. + let lower = encode_roles(&["Op"]); + let upper = lower.replacen("0x", "0X", 1); + assert_eq!(parse_role_blob(&lower), parse_role_blob(&upper)); + } + + #[test] + fn parses_single_role() { + let input = encode_roles(&["Operator"]); + assert_eq!(parse_role_blob(&input), vec!["Operator".to_string()]); + } + + #[test] + fn parses_two_distinct_roles() { + let input = encode_roles(&["Operator", "Owner"]); + let parsed = parse_role_blob(&input); + assert!(parsed.contains(&"Operator".to_string())); + assert!(parsed.contains(&"Owner".to_string())); + } + + #[test] + fn deduplicates_case_insensitively() { + // Both "Operator" and "operator" appear in the buffer; only the + // first wins. Mirrors StringComparer.OrdinalIgnoreCase at cs:124. + let input = encode_roles(&["Operator", "operator"]); + let parsed = parse_role_blob(&input); + assert_eq!(parsed, vec!["Operator".to_string()]); + } + + #[test] + fn skips_single_char_candidates() { + // chars.Count < 2 fails the role_ok check at cs:118; single-char + // role "A" is dropped. + let input = encode_roles(&["A", "Owner"]); + let parsed = parse_role_blob(&input); + assert_eq!(parsed, vec!["Owner".to_string()]); + } + + #[test] + fn rejects_role_containing_non_printable() { + // Build bytes manually: "Op\x01" + 0x00 0x00 + "Owner" + 0x00 0x00. + // The 0x01 in the first role (a control character) trips the + // chars.Clear() branch at cs:108-112; the parser then continues + // scanning offset+1 forward and eventually finds "Owner". + let mut bytes: Vec = Vec::new(); + for c in "Op".chars() { + let cu = c as u16; + bytes.push((cu & 0xFF) as u8); + bytes.push((cu >> 8) as u8); + } + // \x01 (non-printable u16 = 0x0001). + bytes.push(0x01); + bytes.push(0x00); + bytes.push(0); + bytes.push(0); + for c in "Owner".chars() { + let cu = c as u16; + bytes.push((cu & 0xFF) as u8); + bytes.push((cu >> 8) as u8); + } + bytes.push(0); + bytes.push(0); + bytes.push(0); + bytes.push(0); + let mut hex = String::from("0x"); + for b in &bytes { + hex.push_str(&format!("{b:02X}")); + } + let parsed = parse_role_blob(&hex); + assert!(parsed.contains(&"Owner".to_string())); + assert!(!parsed.iter().any(|r| r.contains("Op"))); + } + + #[test] + fn malformed_hex_returns_empty_list() { + // Odd-length hex. + assert_eq!(parse_role_blob("0xABC"), Vec::::new()); + // Non-hex char. + assert_eq!(parse_role_blob("0xAGG"), Vec::::new()); + } + + #[test] + fn hex_decode_helper_round_trip() { + assert_eq!(hex_decode("4D454F57"), Some(vec![0x4D, 0x45, 0x4F, 0x57])); + assert_eq!(hex_decode("deadbeef"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF])); + assert_eq!(hex_decode("DeAdBeEf"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF])); + assert_eq!(hex_decode(""), Some(Vec::new())); + assert_eq!(hex_decode("ABC"), None); // odd length + assert_eq!(hex_decode("ZZ"), None); // non-hex + } + + #[test] + fn long_blob_with_garbage_between_roles_still_parses() { + // 4 random bytes of garbage between two valid roles. The parser's + // sliding window should skip the garbage and pick up the second role. + let mut bytes: Vec = Vec::new(); + for c in "Operator".chars() { + let cu = c as u16; + bytes.push((cu & 0xFF) as u8); + bytes.push((cu >> 8) as u8); + } + bytes.push(0); + bytes.push(0); + // Garbage (odd number of bytes — still gets scanned but doesn't + // produce valid u16 chars in a way that meets the role_ok check). + bytes.extend_from_slice(&[0xFF, 0x01, 0x80, 0xAB]); + for c in "Owner".chars() { + let cu = c as u16; + bytes.push((cu & 0xFF) as u8); + bytes.push((cu >> 8) as u8); + } + bytes.push(0); + bytes.push(0); + bytes.push(0); + bytes.push(0); + let mut hex = String::from("0x"); + for b in &bytes { + hex.push_str(&format!("{b:02X}")); + } + let parsed = parse_role_blob(&hex); + assert!(parsed.contains(&"Operator".to_string())); + assert!(parsed.contains(&"Owner".to_string())); + } +} diff --git a/rust/crates/mxaccess-galaxy/src/sql.rs b/rust/crates/mxaccess-galaxy/src/sql.rs index c3cedc9..1468ded 100644 --- a/rust/crates/mxaccess-galaxy/src/sql.rs +++ b/rust/crates/mxaccess-galaxy/src/sql.rs @@ -1,3 +1,11 @@ +// The `concatcp!` macro below uses fixed-size byte indexing in a `const fn` +// where lengths are statically known. `.get(n)?` is not available in `const` +// contexts in stable Rust 1.85, so the indexing is the only path. The +// resulting `&'static str` constants are evaluated at compile time, so +// any out-of-bounds would surface as a compile error rather than a runtime +// panic. +#![allow(clippy::indexing_slicing)] + //! SQL strings used by the future tiberius-backed resolver. //! //! Direct port of the two `private const string` blocks at @@ -172,6 +180,84 @@ FROM ( ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END "#; +// --------------------------------------------------------------------------- +// User resolver SQL — port of GalaxyRepositoryUserResolver.cs:135-148. +// --------------------------------------------------------------------------- + +/// Common `SELECT TOP (1) ...` user-profile projection — `cs:135-144`. +/// Used as the base for both [`USER_BY_GUID_SQL`] and [`USER_BY_NAME_SQL`]. +/// +/// Result columns (in order): +/// +/// 0. `user_profile_id` `int` +/// 1. `user_profile_name` `nvarchar` +/// 2. `user_guid` `uniqueidentifier` +/// 3. `default_security_group` `nvarchar` +/// 4. `intouch_access_level` `int` (NULL-able) +/// 5. `roles_text` `nvarchar(max)` — `CONVERT(...)` of the +/// `roles` `varbinary` column. Decode through +/// [`crate::role_blob::parse_role_blob`]. +pub const USER_SELECT_SQL: &str = r#"SELECT TOP (1) + user_profile_id, + user_profile_name, + user_guid, + default_security_group, + intouch_access_level, + CONVERT(nvarchar(max), roles) AS roles_text +FROM dbo.user_profile"#; + +/// `Resolve user_profile by user_guid` — port of `cs:146`. +/// +/// Parameter: `@userGuid` (`uniqueidentifier`). +pub const USER_BY_GUID_SQL: &str = concatcp!( + USER_SELECT_SQL, + "\nWHERE user_guid = @userGuid\nORDER BY user_profile_id" +); + +/// `Resolve user_profile by user_profile_name` — port of `cs:148`. +/// +/// Parameter: `@userName` (`nvarchar`). +pub const USER_BY_NAME_SQL: &str = concatcp!( + USER_SELECT_SQL, + "\nWHERE user_profile_name = @userName\nORDER BY user_profile_id" +); + +/// Tiny `concat!`-equivalent for `&'static str` constants, since `concat!` +/// only works with literals. Two-arg specialisation; keeps `USER_BY_GUID_SQL` +/// and `USER_BY_NAME_SQL` evaluable at compile time without dragging in +/// `const_format` as a dep. +macro_rules! concatcp { + ($a:expr, $b:expr) => {{ + const A: &str = $a; + const B: &str = $b; + const N: usize = A.len() + B.len(); + const fn build() -> [u8; N] { + let mut out = [0u8; N]; + let a = A.as_bytes(); + let b = B.as_bytes(); + let mut i = 0; + while i < a.len() { + out[i] = a[i]; + i += 1; + } + let mut j = 0; + while j < b.len() { + out[a.len() + j] = b[j]; + j += 1; + } + out + } + // SAFETY: A and B are valid UTF-8 (both are `&'static str`); the + // concatenation of two valid UTF-8 byte sequences is valid UTF-8. + const COMBINED: &[u8; N] = &build(); + match core::str::from_utf8(COMBINED) { + Ok(s) => s, + Err(_) => "", + } + }}; +} +pub(crate) use concatcp; + /// Multi-row browse query — `Browse(object_tag_like, attribute_like, max_rows)`. /// /// Parameters: diff --git a/rust/crates/mxaccess-galaxy/src/user.rs b/rust/crates/mxaccess-galaxy/src/user.rs new file mode 100644 index 0000000..d7fb84c --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/user.rs @@ -0,0 +1,278 @@ +//! `GalaxyUserProfile` + async `UserResolver` trait. +//! +//! Direct port of `src/MxNativeClient/GalaxyRepositoryUserResolver.cs`. +//! The .NET reference exposes a single concrete class with a SQL +//! backend; the Rust port splits that into a trait + the data type + +//! a separate role-blob parser ([`crate::role_blob::parse_role_blob`]) +//! so consumers can plug in any backend (in-memory cache, JSON snapshot, +//! REST client, planned `tiberius`-gated SQL impl). +//! +//! The user resolver is needed by F13's `WriteSecured*` flows — those +//! pass `current_user_id` and `verifier_user_id` to identify who +//! authorised a security-classified write. The user IDs are +//! `dbo.user_profile.user_profile_id` (`int`), looked up either by +//! `user_guid` (`uniqueidentifier`) or by `user_profile_name`. + +use crate::role_blob::parse_role_blob; +use async_trait::async_trait; +use thiserror::Error; +use uuid::Uuid; + +/// Resolved user profile. Field order and types match the .NET +/// `GalaxyUserProfile` record exactly (`GalaxyRepositoryUserResolver.cs:5-11`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GalaxyUserProfile { + pub user_profile_id: i32, + pub user_profile_name: String, + pub user_guid: Uuid, + pub default_security_group: String, + /// `None` when `dbo.user_profile.intouch_access_level IS NULL` + /// (`cs:83`). + pub intouch_access_level: Option, + /// Role names parsed from the `roles` `varbinary` column via + /// [`parse_role_blob`]. `Vec` (not `HashSet`) because the .NET + /// reference returns an `IReadOnlyList` preserving discovery + /// order; deduplication is case-insensitive (`cs:124`) and happens + /// inside the parser. + pub roles: Vec, +} + +impl GalaxyUserProfile { + /// Build a `GalaxyUserProfile` from raw column values, parsing the + /// `roles_text` blob through [`parse_role_blob`]. Mirrors + /// `ReadProfile` (`GalaxyRepositoryUserResolver.cs:76-85`). + /// + /// `roles_text = None` corresponds to `reader.IsDBNull(5)` at `cs:84` + /// — yields an empty role list. + #[must_use] + pub fn from_columns( + user_profile_id: i32, + user_profile_name: String, + user_guid: Uuid, + default_security_group: String, + intouch_access_level: Option, + roles_text: Option<&str>, + ) -> Self { + Self { + user_profile_id, + user_profile_name, + user_guid, + default_security_group, + intouch_access_level, + roles: roles_text.map(parse_role_blob).unwrap_or_default(), + } + } +} + +/// Errors raised by [`UserResolver`] implementations. Mirrors +/// `KeyNotFoundException` at `cs:48,70` and the same `Backend` / +/// pluggable-error split as [`crate::resolver::ResolverError`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum UserResolverError { + /// No user matched the supplied `user_guid` or `user_profile_name`. + /// Mirrors the `KeyNotFoundException` at `cs:48` / `:70`. + #[error("Galaxy user '{key}' was not found in dbo.user_profile")] + NotFound { key: String }, + + /// Backend-specific failure (SQL connect / query error, etc.). + #[error("Galaxy user resolver backend error: {message}")] + Backend { message: String }, +} + +/// Pluggable async user-profile resolver. +/// +/// Implementations should be thread-safe (`Send + Sync`) so a single +/// resolver can be shared across the high-level write helpers in +/// `mxaccess-nmx` and the M4 `Session` façade. +#[async_trait] +pub trait UserResolver: Send + Sync { + /// Look up a user profile by GUID. Mirrors `ResolveByGuidAsync` + /// (`GalaxyRepositoryUserResolver.cs:34-52`). + /// + /// # Errors + /// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`]. + async fn resolve_by_guid( + &self, + user_guid: Uuid, + ) -> Result; + + /// Look up a user profile by name. Mirrors `ResolveByNameAsync` + /// (`cs:54-74`). + /// + /// # Errors + /// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`]. + async fn resolve_by_name( + &self, + user_name: &str, + ) -> Result; + + /// Convenience: look up the user profile id only. Mirrors + /// `ResolveUserProfileIdByGuidAsync` (`cs:26-32`). Default impl + /// delegates to [`Self::resolve_by_guid`]. + /// + /// # Errors + /// As for [`Self::resolve_by_guid`]. + async fn resolve_user_profile_id_by_guid( + &self, + user_guid: Uuid, + ) -> Result { + let profile = self.resolve_by_guid(user_guid).await?; + Ok(profile.user_profile_id) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn from_columns_handles_null_intouch_and_null_roles() { + let p = GalaxyUserProfile::from_columns( + 42, + "TestUser".to_string(), + Uuid::nil(), + "Default".to_string(), + None, + None, + ); + assert_eq!(p.user_profile_id, 42); + assert_eq!(p.user_profile_name, "TestUser"); + assert_eq!(p.user_guid, Uuid::nil()); + assert_eq!(p.default_security_group, "Default"); + assert_eq!(p.intouch_access_level, None); + assert!(p.roles.is_empty()); + } + + #[test] + fn from_columns_parses_roles_via_parse_role_blob() { + // Encode "Owner" + 00 00 + tail terminator manually. + let mut bytes: Vec = Vec::new(); + for c in "Owner".chars() { + let cu = c as u16; + bytes.push((cu & 0xFF) as u8); + bytes.push((cu >> 8) as u8); + } + bytes.extend_from_slice(&[0, 0, 0, 0]); + let mut hex = String::from("0x"); + for b in &bytes { + hex.push_str(&format!("{b:02X}")); + } + + let p = GalaxyUserProfile::from_columns( + 7, + "OwnerUser".to_string(), + Uuid::nil(), + "Default".to_string(), + Some(9999), + Some(&hex), + ); + assert_eq!(p.intouch_access_level, Some(9999)); + assert_eq!(p.roles, vec!["Owner".to_string()]); + } + + /// Tiny in-memory implementation for tests — proves the trait is + /// implementable without any SQL machinery (mirrors the Resolver + /// trait's InMemoryResolver test at resolver.rs). + struct InMemoryUserResolver { + by_guid: HashMap, + by_name: HashMap, + } + + impl InMemoryUserResolver { + fn with_one(profile: GalaxyUserProfile) -> Self { + let mut by_guid = HashMap::new(); + by_guid.insert(profile.user_guid, profile.clone()); + let mut by_name = HashMap::new(); + by_name.insert(profile.user_profile_name.clone(), profile); + Self { by_guid, by_name } + } + } + + #[async_trait] + impl UserResolver for InMemoryUserResolver { + async fn resolve_by_guid( + &self, + user_guid: Uuid, + ) -> Result { + self.by_guid + .get(&user_guid) + .cloned() + .ok_or_else(|| UserResolverError::NotFound { + key: user_guid.to_string(), + }) + } + + async fn resolve_by_name( + &self, + user_name: &str, + ) -> Result { + self.by_name + .get(user_name) + .cloned() + .ok_or_else(|| UserResolverError::NotFound { + key: user_name.to_string(), + }) + } + } + + fn sample_profile() -> GalaxyUserProfile { + GalaxyUserProfile::from_columns( + 7, + "TestUser".to_string(), + Uuid::from_bytes([0xCC; 16]), + "Default".to_string(), + Some(9999), + None, + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_by_guid_round_trip() { + let r = InMemoryUserResolver::with_one(sample_profile()); + let p = r + .resolve_by_guid(Uuid::from_bytes([0xCC; 16])) + .await + .unwrap(); + assert_eq!(p.user_profile_id, 7); + assert_eq!(p.user_profile_name, "TestUser"); + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_by_name_round_trip() { + let r = InMemoryUserResolver::with_one(sample_profile()); + let p = r.resolve_by_name("TestUser").await.unwrap(); + assert_eq!(p.user_profile_id, 7); + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_not_found_by_guid() { + let r = InMemoryUserResolver::with_one(sample_profile()); + let err = r.resolve_by_guid(Uuid::nil()).await.unwrap_err(); + assert!(matches!(err, UserResolverError::NotFound { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_not_found_by_name() { + let r = InMemoryUserResolver::with_one(sample_profile()); + let err = r.resolve_by_name("DoesNotExist").await.unwrap_err(); + assert!(matches!(err, UserResolverError::NotFound { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn resolve_user_profile_id_by_guid_default_impl_works() { + let r = InMemoryUserResolver::with_one(sample_profile()); + let id = r + .resolve_user_profile_id_by_guid(Uuid::from_bytes([0xCC; 16])) + .await + .unwrap(); + assert_eq!(id, 7); + } +}