[M3] mxaccess-galaxy: GalaxyUserProfile + UserResolver trait + role-blob
Lands the user-resolver half of M3 stream A. Pure-Rust foundation — the tiberius-backed SQL impl is logged as F14 and stays gated behind the existing galaxy-resolver Cargo feature. New - role_blob.rs (~270 LoC, 12 tests including a garbage-between-roles edge case) — port of ParseRoleBlob (cs:87-133). Sliding-window scan over hex-decoded UTF-16LE bytes; rejects non-printable code units; case-insensitive dedup. Pure function, no I/O. - user.rs (~290 LoC, 8 tests including 4 tokio-driven InMemoryUserResolver cases) — GalaxyUserProfile (port of cs:5-11) + from_columns helper bridging into role_blob + UserResolver async trait + UserResolverError with NotFound / Backend variants. - sql.rs additions: USER_SELECT_SQL + USER_BY_GUID_SQL + USER_BY_NAME_SQL constants (port of cs:135-148). Inline concatcp! macro composes the base SELECT with each WHERE clause at compile time without pulling const_format. Cargo.toml: added uuid (Galaxy user_guid is a uniqueidentifier). design/followups.md: added F14 (P2) for the tiberius-backed SQL impl behind the galaxy-resolver feature. Test count delta: 427 -> 446 (+19; mxaccess-galaxy 30 -> 49). All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
**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`.
|
**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
|
### F13 — `NmxClient` high-level write/advise/subscribe wrappers
|
||||||
**Severity:** P1
|
**Severity:** P1
|
||||||
**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs`
|
**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs`
|
||||||
|
|||||||
Generated
+110
@@ -31,6 +31,12 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -84,6 +90,30 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -132,6 +162,18 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
@@ -221,6 +263,7 @@ dependencies = [
|
|||||||
"mxaccess-codec",
|
"mxaccess-codec",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -327,6 +370,18 @@ dependencies = [
|
|||||||
"cipher",
|
"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]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -443,6 +498,16 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ authors.workspace = true
|
|||||||
mxaccess-codec = { path = "../mxaccess-codec" }
|
mxaccess-codec = { path = "../mxaccess-codec" }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
uuid = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -29,8 +29,12 @@
|
|||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod resolver;
|
pub mod resolver;
|
||||||
|
pub mod role_blob;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
pub use metadata::GalaxyTagMetadata;
|
pub use metadata::GalaxyTagMetadata;
|
||||||
pub use parser::{ParseError, ParsedTagReference};
|
pub use parser::{ParseError, ParsedTagReference};
|
||||||
pub use resolver::{Resolver, ResolverError};
|
pub use resolver::{Resolver, ResolverError};
|
||||||
|
pub use role_blob::parse_role_blob;
|
||||||
|
pub use user::{GalaxyUserProfile, UserResolver, UserResolverError};
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Parser for the SQL `roles` blob attached to `dbo.user_profile`.
|
||||||
|
//!
|
||||||
|
//! Direct port of `ParseRoleBlob` at
|
||||||
|
//! `src/MxNativeClient/GalaxyRepositoryUserResolver.cs:87-133`.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! The Galaxy DB stores the user-roles set as a `varbinary` column whose
|
||||||
|
//! `CONVERT(nvarchar(max), roles)` projection produces a hex-string of the
|
||||||
|
//! raw bytes (with `0x` prefix). The bytes themselves are a packed
|
||||||
|
//! sequence of UTF-16LE role names separated by `0x00 0x00` terminators
|
||||||
|
//! (the UTF-16 NUL character) followed by another `0x00 0x00` (the role-list
|
||||||
|
//! separator).
|
||||||
|
//!
|
||||||
|
//! There is no length prefix and no count; the .NET reference walks the
|
||||||
|
//! buffer with a sliding window, emitting each printable-ASCII UTF-16LE
|
||||||
|
//! string of length ≥ 2 that ends in a double-null. Sub-windows that
|
||||||
|
//! produce a non-printable code unit (anything outside `0x20..=0x7E`) are
|
||||||
|
//! discarded — this naturally skips garbage between roles.
|
||||||
|
//!
|
||||||
|
//! Roles are deduplicated case-insensitively (`StringComparer.OrdinalIgnoreCase`
|
||||||
|
//! at `cs:124`).
|
||||||
|
//!
|
||||||
|
//! ## Why this is a separate module
|
||||||
|
//!
|
||||||
|
//! The .NET reference inlines the parser as a `private static`. The Rust
|
||||||
|
//! port lifts it because (a) it has interesting failure modes worth
|
||||||
|
//! testing in isolation and (b) future SQL backends (the planned
|
||||||
|
//! `tiberius`-gated `UserResolver` impl, snapshot-replay test harnesses)
|
||||||
|
//! all need to call it the same way.
|
||||||
|
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
/// Parse a hex-encoded role blob. Returns the deduplicated list of role
|
||||||
|
/// names in discovery order. Mirrors `ParseRoleBlob` (`cs:87-133`).
|
||||||
|
///
|
||||||
|
/// Behavior:
|
||||||
|
///
|
||||||
|
/// - Input that doesn't start with `0x`/`0X` (case-insensitive per
|
||||||
|
/// `StringComparison.OrdinalIgnoreCase` at `cs:89`) returns `[]`.
|
||||||
|
/// - Input shorter than `0x` plus 8 hex chars (the smallest payload that
|
||||||
|
/// could encode a 2-char role + terminator) returns `[]`.
|
||||||
|
/// - Hex-decoding failures return `[]` (the .NET reference would throw
|
||||||
|
/// `FormatException` from `Convert.FromHexString`; the Rust port matches
|
||||||
|
/// the .NET behavior of yielding an empty list because every caller
|
||||||
|
/// expects "unknown" to mean "no roles" — there's no way to distinguish
|
||||||
|
/// "user has no roles" from "user has malformed roles" upstream).
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_role_blob(roles_text: &str) -> Vec<String> {
|
||||||
|
if !roles_text.len().checked_sub(2).is_some_and(|_| {
|
||||||
|
roles_text
|
||||||
|
.get(..2)
|
||||||
|
.is_some_and(|p| p.eq_ignore_ascii_case("0x"))
|
||||||
|
}) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hex = &roles_text[2..];
|
||||||
|
let bytes = match hex_decode(hex) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut roles: Vec<String> = Vec::new();
|
||||||
|
let mut offset: usize = 0;
|
||||||
|
while offset + 3 < bytes.len() {
|
||||||
|
// Scan a candidate role starting at `offset`. Mirrors the inner
|
||||||
|
// `while (cursor + 1 < bytes.Length)` loop at cs:100-116. `cursor`
|
||||||
|
// walks in 2-byte steps reading UTF-16LE code units; `chars`
|
||||||
|
// accumulates ASCII chars; non-printable chars discard the
|
||||||
|
// candidate entirely.
|
||||||
|
let mut chars: Vec<char> = Vec::new();
|
||||||
|
let mut cursor = offset;
|
||||||
|
loop {
|
||||||
|
if cursor + 1 >= bytes.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// (bytes[cursor] | (bytes[cursor+1] << 8)) — UTF-16LE u16.
|
||||||
|
let code_unit = u16::from(bytes[cursor]) | (u16::from(bytes[cursor + 1]) << 8);
|
||||||
|
if code_unit == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !(0x20..=0x7e).contains(&code_unit) {
|
||||||
|
chars.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Cast is safe: range above guarantees `code_unit` is a printable
|
||||||
|
// ASCII byte (0x20..=0x7e), all of which are valid `char` scalars.
|
||||||
|
chars.push(char::from_u32(u32::from(code_unit)).unwrap_or('\0'));
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminator check (cs:118-121): role must be ≥2 chars, the cursor
|
||||||
|
// must still be in-bounds for the trailing 0x00 0x00 pair, and
|
||||||
|
// those two bytes must both be 0. The inner loop guarantees this
|
||||||
|
// when it broke on `code_unit == 0`, but the .NET reference
|
||||||
|
// re-asserts it as a defense against malformed input where the
|
||||||
|
// inner loop ran off the end without seeing a null.
|
||||||
|
let role_ok = chars.len() >= 2
|
||||||
|
&& cursor + 1 < bytes.len()
|
||||||
|
&& bytes[cursor] == 0
|
||||||
|
&& bytes[cursor + 1] == 0;
|
||||||
|
if !role_ok {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let role: String = chars.iter().collect();
|
||||||
|
// Deduplicate case-insensitively (`StringComparer.OrdinalIgnoreCase`
|
||||||
|
// at cs:124).
|
||||||
|
if !roles.iter().any(|r| r.eq_ignore_ascii_case(&role)) {
|
||||||
|
roles.push(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump the outer offset past the matched role + the terminator
|
||||||
|
// pair. The .NET reference does `offset = cursor; offset++`
|
||||||
|
// (the `++` is the `for`-loop increment) — net effect: the next
|
||||||
|
// iteration starts at `cursor + 1`, which is the second byte of
|
||||||
|
// the terminator. This deliberately re-scans starting from the
|
||||||
|
// "wrong" alignment so the parser tolerates packed bytes that
|
||||||
|
// happen to look like a partial role on the offset-by-one slot.
|
||||||
|
offset = cursor + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
roles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex-decode `hex` (no `0x` prefix). Returns `None` on odd length, on
|
||||||
|
/// non-hex characters, or on overflow. Mirrors `Convert.FromHexString`
|
||||||
|
/// at `cs:94`. Pure-Rust to avoid pulling `hex` as a dep.
|
||||||
|
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||||
|
if hex.len() % 2 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = hex.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(hex.len() / 2);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let hi = nibble(bytes[i])?;
|
||||||
|
let lo = nibble(bytes[i + 1])?;
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nibble(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Encode a sequence of role strings + a trailing 0x00 0x00 separator
|
||||||
|
/// into the on-wire byte format, then format as a `0x`-prefixed hex
|
||||||
|
/// string. Used to build test inputs.
|
||||||
|
fn encode_roles(roles: &[&str]) -> String {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
for r in roles {
|
||||||
|
for c in r.chars() {
|
||||||
|
let cu = c as u32 as u16;
|
||||||
|
out.push((cu & 0xFF) as u8);
|
||||||
|
out.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
out.push(0);
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
// .NET appears to require the trailing 0x00 0x00 after the last
|
||||||
|
// role to satisfy the cursor+1<bytes.Length check.
|
||||||
|
out.push(0);
|
||||||
|
out.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &out {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_string_returns_empty_list() {
|
||||||
|
assert_eq!(parse_role_blob(""), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_0x_prefix_returns_empty_list() {
|
||||||
|
// Even a syntactically-valid hex string without 0x is treated as
|
||||||
|
// garbage per cs:89.
|
||||||
|
assert_eq!(parse_role_blob("DEADBEEF"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn just_0x_prefix_returns_empty_list() {
|
||||||
|
assert_eq!(parse_role_blob("0x"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upper_and_lower_case_0x_prefix_both_accepted() {
|
||||||
|
// .NET uses StringComparison.OrdinalIgnoreCase at cs:89.
|
||||||
|
let lower = encode_roles(&["Op"]);
|
||||||
|
let upper = lower.replacen("0x", "0X", 1);
|
||||||
|
assert_eq!(parse_role_blob(&lower), parse_role_blob(&upper));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_single_role() {
|
||||||
|
let input = encode_roles(&["Operator"]);
|
||||||
|
assert_eq!(parse_role_blob(&input), vec!["Operator".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_two_distinct_roles() {
|
||||||
|
let input = encode_roles(&["Operator", "Owner"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert!(parsed.contains(&"Operator".to_string()));
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_case_insensitively() {
|
||||||
|
// Both "Operator" and "operator" appear in the buffer; only the
|
||||||
|
// first wins. Mirrors StringComparer.OrdinalIgnoreCase at cs:124.
|
||||||
|
let input = encode_roles(&["Operator", "operator"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert_eq!(parsed, vec!["Operator".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_single_char_candidates() {
|
||||||
|
// chars.Count < 2 fails the role_ok check at cs:118; single-char
|
||||||
|
// role "A" is dropped.
|
||||||
|
let input = encode_roles(&["A", "Owner"]);
|
||||||
|
let parsed = parse_role_blob(&input);
|
||||||
|
assert_eq!(parsed, vec!["Owner".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_role_containing_non_printable() {
|
||||||
|
// Build bytes manually: "Op\x01" + 0x00 0x00 + "Owner" + 0x00 0x00.
|
||||||
|
// The 0x01 in the first role (a control character) trips the
|
||||||
|
// chars.Clear() branch at cs:108-112; the parser then continues
|
||||||
|
// scanning offset+1 forward and eventually finds "Owner".
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Op".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
// \x01 (non-printable u16 = 0x0001).
|
||||||
|
bytes.push(0x01);
|
||||||
|
bytes.push(0x00);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
let parsed = parse_role_blob(&hex);
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
assert!(!parsed.iter().any(|r| r.contains("Op")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_hex_returns_empty_list() {
|
||||||
|
// Odd-length hex.
|
||||||
|
assert_eq!(parse_role_blob("0xABC"), Vec::<String>::new());
|
||||||
|
// Non-hex char.
|
||||||
|
assert_eq!(parse_role_blob("0xAGG"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_decode_helper_round_trip() {
|
||||||
|
assert_eq!(hex_decode("4D454F57"), Some(vec![0x4D, 0x45, 0x4F, 0x57]));
|
||||||
|
assert_eq!(hex_decode("deadbeef"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF]));
|
||||||
|
assert_eq!(hex_decode("DeAdBeEf"), Some(vec![0xDE, 0xAD, 0xBE, 0xEF]));
|
||||||
|
assert_eq!(hex_decode(""), Some(Vec::new()));
|
||||||
|
assert_eq!(hex_decode("ABC"), None); // odd length
|
||||||
|
assert_eq!(hex_decode("ZZ"), None); // non-hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_blob_with_garbage_between_roles_still_parses() {
|
||||||
|
// 4 random bytes of garbage between two valid roles. The parser's
|
||||||
|
// sliding window should skip the garbage and pick up the second role.
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Operator".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
// Garbage (odd number of bytes — still gets scanned but doesn't
|
||||||
|
// produce valid u16 chars in a way that meets the role_ok check).
|
||||||
|
bytes.extend_from_slice(&[0xFF, 0x01, 0x80, 0xAB]);
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.push(0);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
let parsed = parse_role_blob(&hex);
|
||||||
|
assert!(parsed.contains(&"Operator".to_string()));
|
||||||
|
assert!(parsed.contains(&"Owner".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
//! SQL strings used by the future tiberius-backed resolver.
|
||||||
//!
|
//!
|
||||||
//! Direct port of the two `private const string` blocks at
|
//! 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
|
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)`.
|
/// Multi-row browse query — `Browse(object_tag_like, attribute_like, max_rows)`.
|
||||||
///
|
///
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! `GalaxyUserProfile` + async `UserResolver` trait.
|
||||||
|
//!
|
||||||
|
//! Direct port of `src/MxNativeClient/GalaxyRepositoryUserResolver.cs`.
|
||||||
|
//! The .NET reference exposes a single concrete class with a SQL
|
||||||
|
//! backend; the Rust port splits that into a trait + the data type +
|
||||||
|
//! a separate role-blob parser ([`crate::role_blob::parse_role_blob`])
|
||||||
|
//! so consumers can plug in any backend (in-memory cache, JSON snapshot,
|
||||||
|
//! REST client, planned `tiberius`-gated SQL impl).
|
||||||
|
//!
|
||||||
|
//! The user resolver is needed by F13's `WriteSecured*` flows — those
|
||||||
|
//! pass `current_user_id` and `verifier_user_id` to identify who
|
||||||
|
//! authorised a security-classified write. The user IDs are
|
||||||
|
//! `dbo.user_profile.user_profile_id` (`int`), looked up either by
|
||||||
|
//! `user_guid` (`uniqueidentifier`) or by `user_profile_name`.
|
||||||
|
|
||||||
|
use crate::role_blob::parse_role_blob;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Resolved user profile. Field order and types match the .NET
|
||||||
|
/// `GalaxyUserProfile` record exactly (`GalaxyRepositoryUserResolver.cs:5-11`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct GalaxyUserProfile {
|
||||||
|
pub user_profile_id: i32,
|
||||||
|
pub user_profile_name: String,
|
||||||
|
pub user_guid: Uuid,
|
||||||
|
pub default_security_group: String,
|
||||||
|
/// `None` when `dbo.user_profile.intouch_access_level IS NULL`
|
||||||
|
/// (`cs:83`).
|
||||||
|
pub intouch_access_level: Option<i32>,
|
||||||
|
/// Role names parsed from the `roles` `varbinary` column via
|
||||||
|
/// [`parse_role_blob`]. `Vec` (not `HashSet`) because the .NET
|
||||||
|
/// reference returns an `IReadOnlyList<string>` preserving discovery
|
||||||
|
/// order; deduplication is case-insensitive (`cs:124`) and happens
|
||||||
|
/// inside the parser.
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GalaxyUserProfile {
|
||||||
|
/// Build a `GalaxyUserProfile` from raw column values, parsing the
|
||||||
|
/// `roles_text` blob through [`parse_role_blob`]. Mirrors
|
||||||
|
/// `ReadProfile` (`GalaxyRepositoryUserResolver.cs:76-85`).
|
||||||
|
///
|
||||||
|
/// `roles_text = None` corresponds to `reader.IsDBNull(5)` at `cs:84`
|
||||||
|
/// — yields an empty role list.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_columns(
|
||||||
|
user_profile_id: i32,
|
||||||
|
user_profile_name: String,
|
||||||
|
user_guid: Uuid,
|
||||||
|
default_security_group: String,
|
||||||
|
intouch_access_level: Option<i32>,
|
||||||
|
roles_text: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
user_profile_id,
|
||||||
|
user_profile_name,
|
||||||
|
user_guid,
|
||||||
|
default_security_group,
|
||||||
|
intouch_access_level,
|
||||||
|
roles: roles_text.map(parse_role_blob).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised by [`UserResolver`] implementations. Mirrors
|
||||||
|
/// `KeyNotFoundException` at `cs:48,70` and the same `Backend` /
|
||||||
|
/// pluggable-error split as [`crate::resolver::ResolverError`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UserResolverError {
|
||||||
|
/// No user matched the supplied `user_guid` or `user_profile_name`.
|
||||||
|
/// Mirrors the `KeyNotFoundException` at `cs:48` / `:70`.
|
||||||
|
#[error("Galaxy user '{key}' was not found in dbo.user_profile")]
|
||||||
|
NotFound { key: String },
|
||||||
|
|
||||||
|
/// Backend-specific failure (SQL connect / query error, etc.).
|
||||||
|
#[error("Galaxy user resolver backend error: {message}")]
|
||||||
|
Backend { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluggable async user-profile resolver.
|
||||||
|
///
|
||||||
|
/// Implementations should be thread-safe (`Send + Sync`) so a single
|
||||||
|
/// resolver can be shared across the high-level write helpers in
|
||||||
|
/// `mxaccess-nmx` and the M4 `Session` façade.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserResolver: Send + Sync {
|
||||||
|
/// Look up a user profile by GUID. Mirrors `ResolveByGuidAsync`
|
||||||
|
/// (`GalaxyRepositoryUserResolver.cs:34-52`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||||
|
async fn resolve_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||||
|
|
||||||
|
/// Look up a user profile by name. Mirrors `ResolveByNameAsync`
|
||||||
|
/// (`cs:54-74`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||||
|
async fn resolve_by_name(
|
||||||
|
&self,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||||
|
|
||||||
|
/// Convenience: look up the user profile id only. Mirrors
|
||||||
|
/// `ResolveUserProfileIdByGuidAsync` (`cs:26-32`). Default impl
|
||||||
|
/// delegates to [`Self::resolve_by_guid`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As for [`Self::resolve_by_guid`].
|
||||||
|
async fn resolve_user_profile_id_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<i32, UserResolverError> {
|
||||||
|
let profile = self.resolve_by_guid(user_guid).await?;
|
||||||
|
Ok(profile.user_profile_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_columns_handles_null_intouch_and_null_roles() {
|
||||||
|
let p = GalaxyUserProfile::from_columns(
|
||||||
|
42,
|
||||||
|
"TestUser".to_string(),
|
||||||
|
Uuid::nil(),
|
||||||
|
"Default".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(p.user_profile_id, 42);
|
||||||
|
assert_eq!(p.user_profile_name, "TestUser");
|
||||||
|
assert_eq!(p.user_guid, Uuid::nil());
|
||||||
|
assert_eq!(p.default_security_group, "Default");
|
||||||
|
assert_eq!(p.intouch_access_level, None);
|
||||||
|
assert!(p.roles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_columns_parses_roles_via_parse_role_blob() {
|
||||||
|
// Encode "Owner" + 00 00 + tail terminator manually.
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
for c in "Owner".chars() {
|
||||||
|
let cu = c as u16;
|
||||||
|
bytes.push((cu & 0xFF) as u8);
|
||||||
|
bytes.push((cu >> 8) as u8);
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
let mut hex = String::from("0x");
|
||||||
|
for b in &bytes {
|
||||||
|
hex.push_str(&format!("{b:02X}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = GalaxyUserProfile::from_columns(
|
||||||
|
7,
|
||||||
|
"OwnerUser".to_string(),
|
||||||
|
Uuid::nil(),
|
||||||
|
"Default".to_string(),
|
||||||
|
Some(9999),
|
||||||
|
Some(&hex),
|
||||||
|
);
|
||||||
|
assert_eq!(p.intouch_access_level, Some(9999));
|
||||||
|
assert_eq!(p.roles, vec!["Owner".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny in-memory implementation for tests — proves the trait is
|
||||||
|
/// implementable without any SQL machinery (mirrors the Resolver
|
||||||
|
/// trait's InMemoryResolver test at resolver.rs).
|
||||||
|
struct InMemoryUserResolver {
|
||||||
|
by_guid: HashMap<Uuid, GalaxyUserProfile>,
|
||||||
|
by_name: HashMap<String, GalaxyUserProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryUserResolver {
|
||||||
|
fn with_one(profile: GalaxyUserProfile) -> Self {
|
||||||
|
let mut by_guid = HashMap::new();
|
||||||
|
by_guid.insert(profile.user_guid, profile.clone());
|
||||||
|
let mut by_name = HashMap::new();
|
||||||
|
by_name.insert(profile.user_profile_name.clone(), profile);
|
||||||
|
Self { by_guid, by_name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserResolver for InMemoryUserResolver {
|
||||||
|
async fn resolve_by_guid(
|
||||||
|
&self,
|
||||||
|
user_guid: Uuid,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||||
|
self.by_guid
|
||||||
|
.get(&user_guid)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| UserResolverError::NotFound {
|
||||||
|
key: user_guid.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_name(
|
||||||
|
&self,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||||
|
self.by_name
|
||||||
|
.get(user_name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| UserResolverError::NotFound {
|
||||||
|
key: user_name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_profile() -> GalaxyUserProfile {
|
||||||
|
GalaxyUserProfile::from_columns(
|
||||||
|
7,
|
||||||
|
"TestUser".to_string(),
|
||||||
|
Uuid::from_bytes([0xCC; 16]),
|
||||||
|
"Default".to_string(),
|
||||||
|
Some(9999),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_by_guid_round_trip() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let p = r
|
||||||
|
.resolve_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.user_profile_id, 7);
|
||||||
|
assert_eq!(p.user_profile_name, "TestUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_by_name_round_trip() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let p = r.resolve_by_name("TestUser").await.unwrap();
|
||||||
|
assert_eq!(p.user_profile_id, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_not_found_by_guid() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let err = r.resolve_by_guid(Uuid::nil()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn in_memory_resolver_not_found_by_name() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let err = r.resolve_by_name("DoesNotExist").await.unwrap_err();
|
||||||
|
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn resolve_user_profile_id_by_guid_default_impl_works() {
|
||||||
|
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||||
|
let id = r
|
||||||
|
.resolve_user_profile_id_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user