diff --git a/src/CommonLib/Cache.cs b/src/CommonLib/Cache.cs
index 3ff4d563..f94a9bfa 100644
--- a/src/CommonLib/Cache.cs
+++ b/src/CommonLib/Cache.cs
@@ -46,10 +46,12 @@ private Cache()
[IgnoreDataMember] private static Cache CacheInstance { get; set; }
///
- /// Add a SID/Domain-name pair to the cache. Writes both directions (SID→Name and
- /// Name→SID) so a successful resolution by any tier benefits subsequent lookups in
- /// either direction. Existing entries are preserved (TryAdd semantics) — first
- /// resolver wins.
+ /// Add a SID/Domain-name pair to the cache. The Name→SID direction is always written
+ /// (NetBIOS aliases are valid lookup keys). The SID→Name direction is only written
+ /// when the name is a DNS-shaped FQDN: a NetBIOS-keyed reverse write would poison
+ /// the slot for downstream consumers that depend on the SID→Name lookup yielding a
+ /// DNS name (LDAP base DN construction, server selection, GetDomainInfoAsync hints).
+ /// Existing entries are preserved (TryAdd semantics) — first resolver wins.
///
/// A SID or a domain name.
/// The corresponding domain name or SID.
@@ -57,11 +59,36 @@ internal static void AddDomainSidMapping(string key, string value)
{
if (CacheInstance == null) return;
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) return;
- CacheInstance.SIDToDomainCache.TryAdd(key, value);
- // Bidirectional: SIDs (S-…) and DNS domain names cannot collide as keys, so writing
- // the reverse mapping makes a successful resolution by any tier visible to lookups
- // in either direction.
- CacheInstance.SIDToDomainCache.TryAdd(value, key);
+
+ var keyIsSid = LooksLikeDomainSid(key);
+ var valueIsSid = LooksLikeDomainSid(value);
+
+ if (keyIsSid == valueIsSid)
+ {
+ // Both look like SIDs or neither does — caller misuse or an unexpected input
+ // shape. Throw this data out
+ return;
+ }
+
+ var sid = keyIsSid ? key : value;
+ var name = keyIsSid ? value : key;
+
+ CacheInstance.SIDToDomainCache.TryAdd(name, sid);
+
+ if (LooksLikeDnsDomainName(name))
+ {
+ CacheInstance.SIDToDomainCache.TryAdd(sid, name);
+ }
+ }
+
+ private static bool LooksLikeDomainSid(string value)
+ {
+ return value != null && value.StartsWith("S-1-", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool LooksLikeDnsDomainName(string value)
+ {
+ return value != null && value.IndexOf('.') >= 0;
}
///
@@ -177,19 +204,33 @@ private static void NormalizeCaseInsensitiveCaches()
if (CacheInstance == null) return;
if (CacheInstance.SIDToDomainCache != null)
{
- CacheInstance.SIDToDomainCache = new ConcurrentDictionary(
- CacheInstance.SIDToDomainCache, StringComparer.OrdinalIgnoreCase);
+ CacheInstance.SIDToDomainCache = CopyCaseInsensitive(CacheInstance.SIDToDomainCache);
}
if (CacheInstance.GlobalCatalogCache != null)
{
- CacheInstance.GlobalCatalogCache = new ConcurrentDictionary(
- CacheInstance.GlobalCatalogCache, StringComparer.OrdinalIgnoreCase);
+ CacheInstance.GlobalCatalogCache = CopyCaseInsensitive(CacheInstance.GlobalCatalogCache);
}
if (CacheInstance.ValueToIdCache != null)
{
- CacheInstance.ValueToIdCache = new ConcurrentDictionary(
- CacheInstance.ValueToIdCache, StringComparer.OrdinalIgnoreCase);
+ CacheInstance.ValueToIdCache = CopyCaseInsensitive(CacheInstance.ValueToIdCache);
+ }
+ }
+
+ ///
+ /// Copies into a new keyed
+ /// by . Entries are added with TryAdd so keys that
+ /// collide only by case (introduced before the case-insensitive invariant was reapplied) are
+ /// silently dropped — first writer wins — rather than throwing from the constructor.
+ ///
+ private static ConcurrentDictionary CopyCaseInsensitive(
+ ConcurrentDictionary source)
+ {
+ var copy = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var kvp in source)
+ {
+ copy.TryAdd(kvp.Key, kvp.Value);
}
+ return copy;
}
///
diff --git a/src/CommonLib/ConnectionPoolManager.cs b/src/CommonLib/ConnectionPoolManager.cs
index 19d92909..9de2cca2 100644
--- a/src/CommonLib/ConnectionPoolManager.cs
+++ b/src/CommonLib/ConnectionPoolManager.cs
@@ -146,6 +146,13 @@ private string ResolveIdentifier(string identifier) {
.GetAwaiter().GetResult();
if (infoOk && !string.IsNullOrEmpty(info?.DomainSid)) {
Cache.AddDomainSidMapping(domainName, info.DomainSid);
+ // Also seed the canonical FQDN keyed write so the SID->Name slot is populated
+ // even when the caller passed a NetBIOS alias. AddDomainSidMapping gates the
+ // SID->Name direction on the name being DNS-shaped.
+ if (!string.IsNullOrEmpty(info.Name) &&
+ !string.Equals(domainName, info.Name, StringComparison.OrdinalIgnoreCase)) {
+ Cache.AddDomainSidMapping(info.Name, info.DomainSid);
+ }
return (true, info.DomainSid);
}
diff --git a/src/CommonLib/LdapConnectionPool.cs b/src/CommonLib/LdapConnectionPool.cs
index 6b933709..b12ffe95 100644
--- a/src/CommonLib/LdapConnectionPool.cs
+++ b/src/CommonLib/LdapConnectionPool.cs
@@ -36,14 +36,18 @@ internal class LdapConnectionPool : IDisposable {
private static readonly TimeSpan MaxBackoffDelay = TimeSpan.FromSeconds(20);
private const int BackoffDelayMultiplier = 2;
private const int MaxRetries = 3;
- private static readonly ConcurrentDictionary DCInfoCache = new();
+ private static readonly ConcurrentDictionary DCInfoCache = new(StringComparer.OrdinalIgnoreCase);
// Metrics
private readonly IMetricRouter _metric;
- // Tracks domains we know we've determined we shouldn't try to connect to
+ // Tracks domains we know we've determined we shouldn't try to connect to.
private static readonly ConcurrentHashSet ExcludedDomains = new(StringComparer.OrdinalIgnoreCase);
+ // Drops every exclusion record. Called from LdapUtils.ResetUtils so a fresh enumeration
+ // pass after a configuration change isn't shadowed by stale exclusion state.
+ internal static void ClearExclusions() => ExcludedDomains.Clear();
+
public LdapConnectionPool(string identifier, string poolIdentifier, LdapConfig config,
IPortScanner scanner = null, NativeMethods nativeMethods = null, ILogger log = null, IMetricRouter metric = null) {
_connections = [];
@@ -693,7 +697,7 @@ private static TimeSpan GetNextBackoff(int retryCount) {
string tempPath;
if (CallDsGetDcName(queryParameters.DomainName, out var info) && info != null) {
tempPath = Helpers.DomainNameToDistinguishedName(info.Value.DomainName);
- connectionWrapper.SaveContext(queryParameters.NamingContext, basePath);
+ connectionWrapper.SaveContext(queryParameters.NamingContext, tempPath);
}
else {
// Controlled replacement for LdapUtils.GetDomain + DomainNameToDistinguishedName.
diff --git a/src/CommonLib/LdapQueryParameters.cs b/src/CommonLib/LdapQueryParameters.cs
index 354bf730..2f2f09f6 100644
--- a/src/CommonLib/LdapQueryParameters.cs
+++ b/src/CommonLib/LdapQueryParameters.cs
@@ -3,46 +3,47 @@
using System.Threading;
using SharpHoundCommonLib.Enums;
-namespace SharpHoundCommonLib {
- public class LdapQueryParameters {
- private static int _queryIDIndex;
- private string _searchBase;
- private string _relativeSearchBase;
- public string LDAPFilter { get; set; }
- public SearchScope SearchScope { get; set; } = SearchScope.Subtree;
- public string[] Attributes { get; set; } = Array.Empty();
- public string DomainName { get; set; }
- public bool GlobalCatalog { get; set; }
- public bool IncludeSecurityDescriptor { get; set; } = false;
- public bool IncludeDeleted { get; set; } = false;
- private int QueryID { get; }
-
- public LdapQueryParameters() {
- QueryID = _queryIDIndex;
- Interlocked.Increment(ref _queryIDIndex);
- }
+namespace SharpHoundCommonLib;
+
+public class LdapQueryParameters {
+ private static int _queryIDIndex;
+ private string _relativeSearchBase;
+ private string _searchBase;
+
+ public LdapQueryParameters() {
+ QueryID = _queryIDIndex;
+ Interlocked.Increment(ref _queryIDIndex);
+ }
- public string SearchBase {
- get => _searchBase;
- set {
- _relativeSearchBase = null;
- _searchBase = value;
- }
+ public string LDAPFilter { get; set; }
+ public SearchScope SearchScope { get; set; } = SearchScope.Subtree;
+ public string[] Attributes { get; set; } = Array.Empty();
+ public string DomainName { get; set; }
+ public bool GlobalCatalog { get; set; }
+ public bool IncludeSecurityDescriptor { get; set; } = false;
+ public bool IncludeDeleted { get; set; } = false;
+ private int QueryID { get; }
+
+ public string SearchBase {
+ get => _searchBase;
+ set {
+ _relativeSearchBase = null;
+ _searchBase = value;
}
+ }
- public string RelativeSearchBase {
- get => _relativeSearchBase;
- set {
- _relativeSearchBase = value;
- _searchBase = null;
- }
+ public string RelativeSearchBase {
+ get => _relativeSearchBase;
+ set {
+ _relativeSearchBase = value;
+ _searchBase = null;
}
+ }
- public NamingContext NamingContext { get; set; } = NamingContext.Default;
+ public NamingContext NamingContext { get; set; } = NamingContext.Default;
- public string GetQueryInfo()
- {
- return $"Query Information - Filter: {LDAPFilter}, Domain: {DomainName}, GlobalCatalog: {GlobalCatalog}, ADSPath: {SearchBase}, ID: {QueryID}";
- }
+ public string GetQueryInfo() {
+ return
+ $"Query Information - Filter: {LDAPFilter}, Domain: {DomainName}, GlobalCatalog: {GlobalCatalog}, ADSPath: {SearchBase}, ID: {QueryID}";
}
}
\ No newline at end of file
diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs
index 5e376912..1038fce9 100644
--- a/src/CommonLib/LdapUtils.cs
+++ b/src/CommonLib/LdapUtils.cs
@@ -30,23 +30,26 @@
namespace SharpHoundCommonLib {
public class LdapUtils : ILdapUtils {
- //This cache is indexed by domain sid
- private static ConcurrentDictionary _domainCache =
- new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary _domainInfoCache =
new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentHashSet _domainControllers = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentHashSet _unresolvablePrincipals = new(StringComparer.OrdinalIgnoreCase);
- private static readonly ConcurrentDictionary DomainToForestCache =
- new(StringComparer.OrdinalIgnoreCase);
-
- // Single-shot cache for the uncontrolled Domain.GetDomain() hint tier in
- // ResolveEffectiveDomainHint. null = not attempted; "" = attempted and failed; otherwise the
- // resolved domain name. Guarded by _uncontrolledGetDomainHintLock because the underlying RPC
- // is slow and we only want it to fire once per process. Reset from ResetUtils.
- private static string _uncontrolledGetDomainHint;
- private static readonly object _uncontrolledGetDomainHintLock = new();
+ // Coalesces concurrent first-time domain resolutions so that N callers asking for the
+ // same domain trigger one tier walk, not N. The first caller's pool/config/log is captured
+ // by the lazy; subsequent callers await the same task and see the same result. Entries
+ // are removed once the task settles, so a later cache miss for the same domain (e.g.,
+ // post-ResetUtils) starts a fresh resolution rather than reusing a completed task.
+ private static readonly ConcurrentDictionary>>
+ _inFlightDomainResolutions = new(StringComparer.OrdinalIgnoreCase);
+
+ // Tracks which domains are currently being resolved on the calling logical execution
+ // context. Reentrant resolution is by design: the pool tier's GetLdapConnection path
+ // calls back into GetDomainInfoStaticAsync to resolve the domain SID before binding.
+ // Without this guard the inner call would await the outer lazy that hasn't published
+ // yet, deadlocking on Lazy's self-recursion detection. AsyncLocal flows through async
+ // continuations, so it captures the recursion chain across any number of awaits.
+ private static readonly AsyncLocal> _resolvingDomains = new();
private static readonly ConcurrentDictionary
SeenWellKnownPrincipals = new();
@@ -67,12 +70,11 @@ private readonly ConcurrentDictionary
private readonly ILogger _log;
private readonly IPortScanner _portScanner;
private readonly NativeMethods _nativeMethods;
- private readonly string _nullCacheKey = Guid.NewGuid().ToString();
// Per-instance cache for the no-hint Domain resolution. The OS-side resolution depends
// on the calling thread's auth context and/or _ldapConfig credentials, neither of which
- // is a usable cache key, so this state cannot live in the static _domainCache. Failures
- // are not cached; the next call retries.
+ // is a usable cache key for a process-wide store. Failures are not cached; the next call
+ // retries.
private Domain _currentDomain;
private readonly object _currentDomainLock = new();
private static readonly Regex SIDRegex = new(@"^(S-\d+-\d+-\d+-\d+-\d+-\d+)(-\d+)?$");
@@ -316,23 +318,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
}
public virtual async Task<(bool Success, string ForestName)> GetForest(string domain) {
- if (DomainToForestCache.TryGetValue(domain, out var cachedForest)) {
- return (true, cachedForest);
- }
-
+ // DomainInfo.ForestName is already memoized in _domainInfoCache, so a separate
+ // domain-to-forest dictionary would be a duplicate cache over the same key space.
if (await GetDomainInfoAsync(domain) is (true, var domainInfo) &&
!string.IsNullOrEmpty(domainInfo?.ForestName)) {
- DomainToForestCache.TryAdd(domain, domainInfo.ForestName);
return (true, domainInfo.ForestName);
}
- var (success, forest) = await GetForestFromLdap(domain);
- if (success) {
- DomainToForestCache.TryAdd(domain, forest);
- return (true, forest);
- }
-
- return (false, null);
+ return await GetForestFromLdap(domain);
}
private async Task<(bool Success, string ForestName)> GetForestFromLdap(string domain) {
@@ -472,6 +465,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
if (await GetDomainInfoAsync(domainName) is (true, var domainInfo) &&
!string.IsNullOrEmpty(domainInfo?.DomainSid)) {
Cache.AddDomainSidMapping(domainName, domainInfo.DomainSid);
+ // Also seed the canonical FQDN keyed write so the SID->Name slot is populated
+ // even when the caller passed a NetBIOS alias. AddDomainSidMapping gates the
+ // SID->Name direction on the name being DNS-shaped, so the NetBIOS-keyed call
+ // above only writes Name->SID; this second call fills in the FQDN side.
+ if (!string.IsNullOrEmpty(domainInfo.Name) &&
+ !string.Equals(domainName, domainInfo.Name, StringComparison.OrdinalIgnoreCase)) {
+ Cache.AddDomainSidMapping(domainInfo.Name, domainInfo.DomainSid);
+ }
return (true, domainInfo.DomainSid);
}
@@ -527,14 +528,11 @@ public bool GetDomain(string domainName, out Domain domain) {
}
// A blank/whitespace name is the no-target form. Delegate so the per-instance
- // _currentDomain handles it without a per-instance GUID sentinel against the
- // process-wide static cache.
+ // _currentDomain handles it.
if (string.IsNullOrWhiteSpace(domainName)) {
return GetDomain(out domain);
}
- if (_domainCache.TryGetValue(domainName, out domain)) return true;
-
try {
var context = _ldapConfig.Username != null
? new DirectoryContext(DirectoryContextType.Domain, domainName, _ldapConfig.Username,
@@ -543,17 +541,7 @@ public bool GetDomain(string domainName, out Domain domain) {
// Blocking External Call
domain = Domain.GetDomain(context);
- if (domain == null) return false;
-
- // Cache under the caller's input AND the canonical name returned by SDS. The
- // two may differ (e.g. NetBIOS alias vs DNS canonical), so writing both keys
- // ensures alias-form callers don't miss while future canonical-form callers
- // still benefit.
- _domainCache.TryAdd(domainName, domain);
- if (!string.IsNullOrWhiteSpace(domain.Name)) {
- _domainCache.TryAdd(domain.Name, domain);
- }
- return true;
+ return domain != null;
}
catch (Exception e) {
_log.LogDebug(e, "GetDomain call failed for domain name {Name}", domainName);
@@ -579,9 +567,8 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
return false;
}
- // The static overload has no per-instance state to cache a no-hint resolution
- // against, and ConcurrentDictionary throws on a null key. Reject up front rather
- // than proceeding with an unkeyable resolution.
+ // The static overload has no per-instance state to anchor a no-hint resolution to.
+ // Reject up front rather than proceeding with a resolution we can't attribute.
if (string.IsNullOrWhiteSpace(domainName)) {
Logging.Logger.LogDebug(
"Static GetDomain short-circuited: domainName is null or whitespace");
@@ -589,8 +576,6 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
return false;
}
- if (_domainCache.TryGetValue(domainName, out domain)) return true;
-
try {
var context = ldapConfig.Username != null
? new DirectoryContext(DirectoryContextType.Domain, domainName, ldapConfig.Username,
@@ -599,13 +584,7 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
// Blocking External Call
domain = Domain.GetDomain(context);
- if (domain == null) return false;
-
- _domainCache.TryAdd(domainName, domain);
- if (!string.IsNullOrWhiteSpace(domain.Name)) {
- _domainCache.TryAdd(domain.Name, domain);
- }
- return true;
+ return domain != null;
}
catch (Exception e) {
Logging.Logger.LogDebug("Static GetDomain call failed for domain {DomainName}: {Error}", domainName,
@@ -618,9 +597,7 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
///
/// Attempts to get the Domain object representing the user's current domain. The
/// resolution depends on the calling thread's auth context and configured credentials,
- /// so the result is cached per instance rather than in the
- /// shared static cache. Successful resolutions also seed the static cache under the
- /// canonical DNS name so that future explicit-name callers benefit.
+ /// so the result is cached per instance.
///
public bool GetDomain(out Domain domain) {
if (!_ldapConfig.AllowFallbackToUncontrolledLdap) {
@@ -660,16 +637,8 @@ public bool GetDomain(out Domain domain) {
// Blocking External Call
var resolved = Domain.GetDomain(context);
- if (resolved == null) {
- domain = null;
- return false;
- }
_currentDomain = resolved;
- if (!string.IsNullOrWhiteSpace(resolved.Name)) {
- _domainCache.TryAdd(resolved.Name, resolved);
- }
-
domain = resolved;
return true;
}
@@ -1167,6 +1136,12 @@ await GetDomainSidFromDomainName(forestName) is (true, var forestDomainSid)) {
public void SetLdapConfig(LdapConfig config) {
_ldapConfig = config;
_log.LogInformation("New LDAP Config Set:\n {ConfigString}", config.ToString());
+ // _currentDomain was resolved under the previous credentials/server, both of which
+ // can have just changed. Drop it so the next GetDomain(out _) re-resolves against the
+ // new auth context instead of returning a stale Domain bound to the old config.
+ lock (_currentDomainLock) {
+ _currentDomain = null;
+ }
_connectionPool.Dispose();
_connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner);
}
@@ -1252,17 +1227,47 @@ internal static int CompletenessScore(DomainInfo info) {
/// CN=Partitions for . Because the static helper
/// is invoked re-entrantly from
/// during pool acquisition, the sparser one-shot direct-LDAP record reaches the cache first;
- /// without this helper the subsequent pool-derived record would be silently discarded by
+ /// without the score guard the subsequent pool-derived record would be silently discarded by
/// TryAdd and every later cache hit would observe the partial record.
+ ///
+ /// The cross-domain guard () prevents cache poisoning when
+ /// a misconfigured pin or a cross-forest leak causes a tier
+ /// to return a describing a different domain than the one the
+ /// caller asked about. On every successful write the entry is also mirrored under
+ /// when that differs from , so
+ /// subsequent lookups by a different alias form (NetBIOS short name vs. DNS FQDN) hit the
+ /// same richest-available record.
+ ///
///
internal static void CacheDomainInfo(string key, DomainInfo candidate) {
if (key == null || candidate == null) return;
- _domainInfoCache.AddOrUpdate(
- key,
- candidate,
- (_, existing) => CompletenessScore(candidate) > CompletenessScore(existing)
- ? candidate
- : existing);
+ if (!KeyMatchesCandidate(key, candidate)) return;
+
+ DomainInfo PickRicher(string _, DomainInfo existing)
+ => CompletenessScore(candidate) > CompletenessScore(existing) ? candidate : existing;
+
+ _domainInfoCache.AddOrUpdate(key, candidate, PickRicher);
+
+ if (!string.IsNullOrWhiteSpace(candidate.Name)
+ && !string.Equals(key, candidate.Name, StringComparison.OrdinalIgnoreCase)) {
+ _domainInfoCache.AddOrUpdate(candidate.Name, candidate, PickRicher);
+ }
+ }
+
+ ///
+ /// Returns true when identifies the same domain that
+ /// describes, comparing case-insensitively against
+ /// and . A candidate
+ /// with no is treated as unverifiable and accepted; the
+ /// resolution tiers always populate Name on success, so this branch only matters for
+ /// hand-built records in tests.
+ ///
+ private static bool KeyMatchesCandidate(string key, DomainInfo candidate) {
+ if (string.IsNullOrEmpty(candidate.Name)) return true;
+ if (string.Equals(key, candidate.Name, StringComparison.OrdinalIgnoreCase)) return true;
+ if (!string.IsNullOrEmpty(candidate.NetBiosName)
+ && string.Equals(key, candidate.NetBiosName, StringComparison.OrdinalIgnoreCase)) return true;
+ return false;
}
///
@@ -1297,87 +1302,15 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en
/// AuthType, signing, cert verification, credentials).
///
///
- /// Resolution order:
- ///
- /// - Static cache lookup keyed by the effective domain hint (the explicit
- /// when supplied, otherwise the resolved current-user
- /// domain via ). Falls back to
- /// only when no hint can be resolved.
- /// - Controlled LDAP resolution via the connection pool (see ).
- /// - Uncontrolled fallback via System.DirectoryServices.ActiveDirectory.Domain.GetDomain,
- /// gated on (see ).
- ///
- /// Successful results from either path are cached for the lifetime of the process (until
- /// is invoked).
+ /// Walks with this instance's connection pool and
+ /// config, after substituting a current-user domain hint via
+ /// when is empty.
+ /// Successful results are cached in the static for the
+ /// lifetime of the process (until is invoked).
///
- public async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) {
- // Canonicalize the cache key BEFORE the lookup. Without this, a null domainName was
- // stored under a per-instance GUID sentinel, fragmenting the static _domainInfoCache:
- // N LdapUtils instances each wrote a separate entry for the same effective domain,
- // and a later call with the explicit DNS name missed the entry written under null.
- // ResolveEffectiveDomainHint returns explicit arguments verbatim and single-shot-caches
- // the uncontrolled tier in _uncontrolledGetDomainHint, so this is essentially free.
- var effectiveDomain = ResolveEffectiveDomainHint(domainName);
- var cacheKey = !string.IsNullOrWhiteSpace(effectiveDomain) ? effectiveDomain : _nullCacheKey;
- if (_domainInfoCache.TryGetValue(cacheKey, out var cached)) {
- return (true, cached);
- }
-
- // Preferred path: every LDAP call made here flows through LdapConnectionPool and
- // therefore honors every flag on LdapConfig. Pass the already-resolved hint so the
- // controlled adapter does not redo ResolveEffectiveDomainHint internally.
- var (controlledOk, controlledInfo) = await ResolveDomainInfoControlledAsync(effectiveDomain);
- if (controlledOk) {
- CacheDomainInfo(cacheKey, controlledInfo);
- return (true, controlledInfo);
- }
-
- // Secondary controlled path: bind directly to the domain name with a one-shot
- // LdapConnection honoring LdapConfig. Tried before ADSI because this tier is the
- // only one that can express fine-grained LdapConfig.AuthType and
- // LdapConfig.DisableCertVerification - running ADSI first would silently ignore
- // those flags whenever the ADSI bind happened to succeed.
- if (!string.IsNullOrWhiteSpace(effectiveDomain)) {
- var (directOk, directInfo) =
- await TryResolveDomainInfoViaDirectLdapAsync(effectiveDomain, _ldapConfig, _log);
- if (directOk) {
- CacheDomainInfo(cacheKey, directInfo);
- return (true, directInfo);
- }
- }
-
- // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding invokes
- // DsGetDcName internally, so NetBIOS short names and domains without a direct DNS
- // A record still bind here after the raw-LDAP tier above could not resolve them.
- if (!string.IsNullOrWhiteSpace(effectiveDomain)) {
- var (adsiOk, adsiInfo) =
- await TryResolveDomainInfoViaDirectoryEntryAsync(effectiveDomain, _ldapConfig, _log);
- if (adsiOk) {
- // ADSI populates PrimaryDomainController via DsGetDcName. Retrying the
- // direct-LDAP path against that discovered DC closes the score gap when the
- // original raw-LDAP attempt failed only because the domain name itself had
- // no resolvable A/SRV record from this host.
- adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync(
- effectiveDomain, adsiInfo, _ldapConfig, _log);
- CacheDomainInfo(cacheKey, adsiInfo);
- return (true, adsiInfo);
- }
- }
-
- // Opt-in fallback. TryGetDomainInfoViaUncontrolledFallback short-circuits when
- // AllowFallbackToUncontrolledLdap is disabled, so when the flag is off this call is a no-op.
- if (TryGetDomainInfoViaUncontrolledFallback(effectiveDomain, _ldapConfig, _log, out var fallbackInfo)) {
- // Same enrichment opportunity as the ADSI tier - the uncontrolled fallback
- // populates both PrimaryDomainController and DomainControllers but never
- // NetBiosName, so the discovered DC name is enough to reach a score-7 record
- // via a controlled retry.
- fallbackInfo = await TryEnrichDomainInfoViaDirectLdapAsync(
- effectiveDomain, fallbackInfo, _ldapConfig, _log);
- CacheDomainInfo(cacheKey, fallbackInfo);
- return (true, fallbackInfo);
- }
-
- return (false, null);
+ public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) {
+ var hint = ResolveEffectiveDomainHint(domainName);
+ return ResolveDomainInfoAsync(hint, _connectionPool, _ldapConfig, _log);
}
///
@@ -1398,41 +1331,118 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en
/// Config used to honor the gate and to build fallback credentials.
/// Logger used for debug-level diagnostics on failure paths.
///
- /// Results from both paths are stored in the same static
- /// used by the instance overload, so a successful lookup here also benefits later
- /// calls on the same process.
+ /// Results are stored in the same static used by the
+ /// instance overload, so a successful lookup here also benefits later
+ /// calls on the same process. The pool tier is
+ /// skipped here because no is available; the remaining
+ /// three tiers (one-shot direct LDAP, ADSI, uncontrolled fallback) still run.
///
- internal static async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoStaticAsync(
+ internal static Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoStaticAsync(
string domainName, LdapConfig config, ILogger log = null) {
- // Unlike the instance overload, the static entry point does not fabricate a current-domain
- // hint - its callers always know which domain they are asking about.
+ return ResolveDomainInfoAsync(domainName, pool: null, config, log);
+ }
+
+ ///
+ /// Shared resolution entry point that drives the four-tier walk for both
+ /// and .
+ /// Tier 1 (pool-driven controlled LDAP) is skipped when is null.
+ /// Tiers 2-4 (one-shot direct LDAP, ADSI, uncontrolled fallback) run unconditionally.
+ ///
+ ///
+ /// Concurrent first-time resolutions for the same domain are coalesced through
+ /// so duplicate callers observe the same
+ /// richest-tier-published record instead of each issuing the full tier walk independently.
+ /// The first caller's , , and
+ /// are captured by the lazy; subsequent awaiters inherit those.
+ /// In practice all instances in a process share the same effective
+ /// config, so this matches the ambient assumption already made by the static
+ /// .
+ ///
+ /// A pool-equipped caller treats a cached record produced by a non-pool tier
+ /// (signaled by an absent , since only the pool tier
+ /// reads CN=Partitions) as still re-resolvable. Otherwise an earlier sparse write
+ /// from a re-entrant call would permanently shadow
+ /// the richer record this caller's pool could produce.
+ ///
+ ///
+ private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoAsync(
+ string domainName, ConnectionPoolManager pool, LdapConfig config, ILogger log) {
if (string.IsNullOrWhiteSpace(domainName)) {
return (false, null);
}
- if (_domainInfoCache.TryGetValue(domainName, out var cached)) {
+ if (_domainInfoCache.TryGetValue(domainName, out var cached)
+ && (pool == null || !string.IsNullOrEmpty(cached.NetBiosName))) {
return (true, cached);
}
- // Secondary controlled path: a one-shot LdapConnection bound directly to the domain
- // name, honoring LdapConfig. Tried before ADSI because this tier is the only one
- // that can express fine-grained LdapConfig.AuthType and LdapConfig.DisableCertVerification.
+ // Reentrant resolution on the same async chain (pool tier -> GetDomainSidFromDomainName ->
+ // GetDomainInfoStaticAsync -> back here) bypasses the lazy: the outer lazy hasn't published
+ // a Task yet, so awaiting it would self-deadlock. The recursion is bounded because the
+ // inner call always runs with pool=null and so cannot re-enter the pool tier.
+ var resolving = _resolvingDomains.Value ??= new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (!resolving.Add(domainName)) {
+ return await ResolveDomainInfoCoreAsync(domainName, pool, config, log);
+ }
+
+ try {
+ var lazy = _inFlightDomainResolutions.GetOrAdd(domainName,
+ key => new Lazy>(
+ () => ResolveDomainInfoCoreAsync(key, pool, config, log),
+ LazyThreadSafetyMode.ExecutionAndPublication));
+
+ try {
+ return await lazy.Value.ConfigureAwait(false);
+ }
+ finally {
+ _inFlightDomainResolutions.TryRemove(domainName, out _);
+ }
+ }
+ finally {
+ resolving.Remove(domainName);
+ }
+ }
+
+ ///
+ /// Drives the actual four-tier walk for . Wrapped by a
+ /// per-domain so concurrent first-time callers share the result.
+ ///
+ ///
+ /// Tier ordering rationale: the pool tier honors every flag and
+ /// is preferred when available. The one-shot direct-LDAP tier is tried before ADSI because
+ /// it is the only tier outside the pool that can express fine-grained
+ /// and -
+ /// running ADSI first would silently ignore those flags whenever its serverless bind
+ /// happened to succeed. The ADSI and uncontrolled-fallback tiers are followed by a
+ /// direct-LDAP enrichment pass against the discovered PDC so the cached record reaches the
+ /// same shape as the pool and one-shot tiers.
+ ///
+ private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoCoreAsync(
+ string domainName, ConnectionPoolManager pool, LdapConfig config, ILogger log) {
+ // Re-check inside the lazy: another caller may have published a satisfying record
+ // between our outer cache miss and this lazy's first execution.
+ if (_domainInfoCache.TryGetValue(domainName, out var cached)
+ && (pool == null || !string.IsNullOrEmpty(cached.NetBiosName))) {
+ return (true, cached);
+ }
+
+ if (pool != null) {
+ var (poolOk, poolInfo) = await ResolveDomainInfoControlledAsyncCore(domainName, pool, log);
+ if (poolOk) {
+ CacheDomainInfo(domainName, poolInfo);
+ return (true, poolInfo);
+ }
+ }
+
var (directOk, directInfo) = await TryResolveDomainInfoViaDirectLdapAsync(domainName, config, log);
if (directOk) {
CacheDomainInfo(domainName, directInfo);
return (true, directInfo);
}
- // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding handles
- // NetBIOS short names and domains whose DNS layout does not expose an A record on
- // the domain name itself, at the cost of not honoring every LdapConfig flag.
var (adsiOk, adsiInfo) = await TryResolveDomainInfoViaDirectoryEntryAsync(domainName, config, log);
if (adsiOk) {
- // Retry the direct-LDAP path against the DC discovered by ADSI so the cached
- // record reaches the same shape as the pool/one-shot tiers. Mirrors the matching
- // enrichment in the instance overload.
- adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync(
- domainName, adsiInfo, config, log);
+ adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync(domainName, adsiInfo, config, log);
CacheDomainInfo(domainName, adsiInfo);
return (true, adsiInfo);
}
@@ -1447,28 +1457,6 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en
return (false, null);
}
- ///
- /// Instance-level adapter over that supplies
- /// this utils' connection pool and logger, and substitutes a current-domain hint when
- /// is null/empty.
- ///
- ///
- /// The hint is taken from when set, otherwise
- /// from . The config override is the only workable
- /// hint in netonly contexts (runas /netonly) and on workgroup machines, where the
- /// OS API reports the local machine name rather than the target AD domain. If neither is
- /// usable the connection pool will fail to resolve the hint and this method returns
- /// (false, null) so the orchestrator can fall through to the uncontrolled fallback.
- ///
- private Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoControlledAsync(string domainName) {
- var hint = ResolveEffectiveDomainHint(domainName);
- if (string.IsNullOrWhiteSpace(hint)) {
- return Task.FromResult<(bool, DomainInfo)>((false, null));
- }
-
- return ResolveDomainInfoControlledAsyncCore(hint, _connectionPool, _log);
- }
-
///
/// Resolves the domain name to use when the caller did not supply one. Walks a five-step
/// preference order designed to maximize resolution success without paying an RPC on the
@@ -1494,7 +1482,9 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en
/// target, letting the SDS/DsGetDcName stack resolve the current user's domain from the
/// thread's outbound authentication context. In runas /netonly that context is the
/// alt credential (not the local primary token that step 3 failed on), so this recovers
- /// the real target domain. Result cached process-wide after the first successful attempt.
+ /// the real target domain. The RPC is issued on every call that reaches this tier; the
+ /// resolved hint becomes the key on success so subsequent
+ /// resolutions for the same domain short-circuit at the controlled-LDAP tier.
/// - Last-resort even when it equals the
/// machine name. Downstream tiers will almost certainly fail to bind against this, but
/// returning the env var here keeps behavior identical to the pre-change code for users who
@@ -1551,12 +1541,6 @@ internal string ResolveEffectiveDomainHint(string domainName) {
/// supplying it to could misdirect the locator; the
/// credential-less form instead lets Windows use whatever authentication context is already
/// bound to the thread.
- ///
- /// Result is cached process-wide in under
- /// . The lock serializes the RPC so it fires at
- /// most once per cycle even under concurrent callers. A stored empty
- /// string is a "tried and failed" sentinel so repeat callers short-circuit without retrying.
- ///
///
private static bool TryResolveHintViaUncontrolledGetDomain(
LdapConfig config, ILogger log, out string domainName) {
@@ -1564,43 +1548,31 @@ private static bool TryResolveHintViaUncontrolledGetDomain(
// Hard gate: uncontrolled calls require explicit opt-in.
if (config is not { AllowFallbackToUncontrolledLdap: true }) return false;
-
+
if (!string.IsNullOrWhiteSpace(config.Server)) {
- log.LogDebug(
+ log?.LogDebug(
"TryResolveHintViaUncontrolledGetDomain(\"{Name}\", out string DomainName) short-circuited: Specific Server is set",
domainName);
return false;
}
- lock (_uncontrolledGetDomainHintLock) {
- // Previously resolved or previously failed - either way, no new RPC.
- if (_uncontrolledGetDomainHint != null) {
- if (_uncontrolledGetDomainHint.Length == 0) return false;
- domainName = _uncontrolledGetDomainHint;
+ try {
+ // No name, no credentials: SDS resolves via the thread's outbound auth context,
+ // which is what surfaces the alt-creds domain under runas /netonly.
+ var ctx = new DirectoryContext(DirectoryContextType.Domain);
+ var domain = Domain.GetDomain(ctx);
+ var name = domain?.Name;
+ if (!string.IsNullOrEmpty(name)) {
+ domainName = name;
return true;
}
-
- try {
- // No name, no credentials: SDS resolves via the thread's outbound auth context,
- // which is what surfaces the alt-creds domain under runas /netonly.
- var ctx = new DirectoryContext(DirectoryContextType.Domain);
- var domain = Domain.GetDomain(ctx);
- var name = domain?.Name;
- if (!string.IsNullOrEmpty(name)) {
- _uncontrolledGetDomainHint = name;
- domainName = name;
- return true;
- }
- }
- catch (Exception e) {
- log?.LogDebug(e,
- "TryResolveHintViaUncontrolledGetDomain: Domain.GetDomain failed");
- }
-
- // Negative-cache the failure so repeat cache-miss paths don't re-issue the RPC.
- _uncontrolledGetDomainHint = string.Empty;
- return false;
}
+ catch (Exception e) {
+ log?.LogDebug(e,
+ "TryResolveHintViaUncontrolledGetDomain: Domain.GetDomain failed");
+ }
+
+ return false;
}
///
@@ -2369,15 +2341,12 @@ private static (bool Success, DomainInfo DomainInfo) ResolveDomainInfoFromConnec
public void ResetUtils() {
_unresolvablePrincipals.Clear();
- _domainCache.Clear();
_domainInfoCache.Clear();
_domainControllers.Clear();
lock (_currentDomainLock) {
_currentDomain = null;
}
- lock (_uncontrolledGetDomainHintLock) {
- _uncontrolledGetDomainHint = null;
- }
+ LdapConnectionPool.ClearExclusions();
_connectionPool?.Dispose();
_connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner);
diff --git a/test/unit/CacheTest.cs b/test/unit/CacheTest.cs
index 97814ada..324cd4d8 100644
--- a/test/unit/CacheTest.cs
+++ b/test/unit/CacheTest.cs
@@ -128,6 +128,71 @@ public void AddDomainSidMapping_NullOrEmptyArgumentsAreIgnored(string key, strin
Cache.SetCacheInstance(null);
}
+ [Fact]
+ public void AddDomainSidMapping_NetBiosName_DoesNotPoisonSidToNameSlot()
+ {
+ // A NetBIOS-only write must not populate the SID->Name slot, because consumers of
+ // that slot expect a DNS-shaped FQDN (LDAP base DN construction, server selection,
+ // GetDomainInfoAsync hint resolution). Leaving the slot empty lets a later
+ // FQDN-keyed write populate it correctly.
+ Cache.SetCacheInstance(Cache.CreateNewCache());
+
+ const string sid = "S-1-5-21-1212121212-3434343434-5656565656";
+ const string netbios = "CONTOSO";
+
+ Cache.AddDomainSidMapping(sid, netbios);
+
+ Assert.False(Cache.GetDomainSidMapping(sid, out _));
+ Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedSid));
+ Assert.Equal(sid, resolvedSid);
+
+ Cache.SetCacheInstance(null);
+ }
+
+ [Fact]
+ public void AddDomainSidMapping_NetBiosName_ReverseArgumentOrder_DoesNotPoisonSidToNameSlot()
+ {
+ // Same poisoning vector with arguments reversed: AddDomainSidMapping(netbios, sid).
+ Cache.SetCacheInstance(Cache.CreateNewCache());
+
+ const string sid = "S-1-5-21-7878787878-9090909090-1212121212";
+ const string netbios = "FABRIKAM";
+
+ Cache.AddDomainSidMapping(netbios, sid);
+
+ Assert.False(Cache.GetDomainSidMapping(sid, out _));
+ Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedSid));
+ Assert.Equal(sid, resolvedSid);
+
+ Cache.SetCacheInstance(null);
+ }
+
+ [Fact]
+ public void AddDomainSidMapping_NetBiosThenFqdn_FqdnPopulatesSidToNameSlot()
+ {
+ // After a NetBIOS-keyed write leaves the SID->Name slot empty, a subsequent
+ // FQDN-keyed write must fill it with the canonical DNS name. This is the key
+ // behavior that makes the gating safe: the slot stays available for the richer
+ // resolver to populate.
+ Cache.SetCacheInstance(Cache.CreateNewCache());
+
+ const string sid = "S-1-5-21-3434343434-5656565656-7878787878";
+ const string netbios = "CONTOSO";
+ const string fqdn = "CONTOSO.LOCAL";
+
+ Cache.AddDomainSidMapping(netbios, sid);
+ Cache.AddDomainSidMapping(fqdn, sid);
+
+ Assert.True(Cache.GetDomainSidMapping(sid, out var resolvedName));
+ Assert.Equal(fqdn, resolvedName);
+ Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedFromNetbios));
+ Assert.Equal(sid, resolvedFromNetbios);
+ Assert.True(Cache.GetDomainSidMapping(fqdn, out var resolvedFromFqdn));
+ Assert.Equal(sid, resolvedFromFqdn);
+
+ Cache.SetCacheInstance(null);
+ }
+
[Fact]
public void SetCacheInstance_AfterDeserialization_RestoresCaseInsensitiveSidToDomain()
{
@@ -230,5 +295,68 @@ public void SetCacheInstance_AfterDeserialization_RestoresCaseInsensitiveValueTo
Cache.SetCacheInstance(null);
}
+
+ [Fact]
+ public void SetCacheInstance_AfterDeserialization_HealsCaseCollidingKeys()
+ {
+ // A persisted cache produced before the case-insensitive invariant was applied
+ // (or hand-edited / corrupted on disk) can contain two keys that differ only by
+ // case in the same dictionary. Deserialization rebuilds a case-sensitive
+ // ConcurrentDictionary, so both keys survive the round trip. The rewrap must
+ // tolerate the collision (first writer wins) instead of throwing from the
+ // ConcurrentDictionary(IEnumerable, IEqualityComparer) constructor.
+ // Each dictionary contains a pair of keys differing only by case. In a
+ // case-sensitive ConcurrentDictionary (what the deserializer rebuilds) both keys
+ // survive; rewrapping with OrdinalIgnoreCase against that source is the path that
+ // previously threw ArgumentException.
+ const string json = @"{
+ ""SIDToDomainCache"": {
+ ""CONTOSO.LOCAL"": ""S-1-5-21-1-2-3"",
+ ""contoso.local"": ""S-1-5-21-1-2-3""
+ },
+ ""GlobalCatalogCache"": {
+ ""CONTOSO.LOCAL"": [""gc1.contoso.local""],
+ ""contoso.local"": [""gc1.contoso.local""]
+ },
+ ""ValueToIdCache"": {
+ ""Administrator|CONTOSO.LOCAL"": ""S-1-5-21-1-2-3-500"",
+ ""administrator|contoso.local"": ""S-1-5-21-1-2-3-500""
+ },
+ ""IdToTypeCache"": {},
+ ""MachineSidCache"": {},
+ ""CacheCreationDate"": ""0001-01-01T00:00:00"",
+ ""CacheCreationVersion"": ""1.0.0""
+ }";
+ var settings = new JsonSerializerSettings
+ {
+ ObjectCreationHandling = ObjectCreationHandling.Replace
+ };
+ var deserialized = JsonConvert.DeserializeObject(json, settings);
+
+ // Sanity: the deserialized dictionaries actually contain the colliding keys.
+ // Without this, the test would not exercise the constructor's duplicate-key path.
+ Assert.Equal(2, deserialized.SIDToDomainCache.Count);
+ Assert.Equal(2, deserialized.GlobalCatalogCache.Count);
+ Assert.Equal(2, deserialized.ValueToIdCache.Count);
+
+ // Pre-fix this call threw ArgumentException from the rewrap constructor.
+ var ex = Record.Exception(() => Cache.SetCacheInstance(deserialized));
+ Assert.Null(ex);
+
+ // Each cache collapses to a single entry per case-insensitive key (first wins).
+ Assert.Single(deserialized.SIDToDomainCache);
+ Assert.Single(deserialized.GlobalCatalogCache);
+ Assert.Single(deserialized.ValueToIdCache);
+
+ // Lookups succeed under either casing after the rewrap.
+ Assert.True(Cache.GetDomainSidMapping("contoso.local", out var resolvedSid));
+ Assert.Equal("S-1-5-21-1-2-3", resolvedSid);
+ Assert.True(Cache.GetGCCache("contoso.local", out var gcs));
+ Assert.Single(gcs);
+ Assert.True(Cache.GetPrefixedValue("administrator", "contoso.local", out var resolvedId));
+ Assert.Equal("S-1-5-21-1-2-3-500", resolvedId);
+
+ Cache.SetCacheInstance(null);
+ }
}
}
diff --git a/test/unit/LDAPUtilsTest.cs b/test/unit/LDAPUtilsTest.cs
index 6f2321c3..b9f56391 100644
--- a/test/unit/LDAPUtilsTest.cs
+++ b/test/unit/LDAPUtilsTest.cs
@@ -946,6 +946,121 @@ public async Task CacheDomainInfo_NullKeyOrCandidate_NoOp() {
Assert.Null(cached);
}
+ [Fact]
+ public async Task CacheDomainInfo_KeyMismatchesCandidateName_NoOp() {
+ new LdapUtils().ResetUtils();
+ const string key = "h2-contoso.test";
+
+ // Simulates a misconfigured LdapConfig.Server pin that lands on a DC outside the
+ // requested domain - the candidate's Name describes fabrikam, but the caller asked
+ // about contoso. The guard must reject the write to prevent cache poisoning.
+ var fabrikam = new DomainInfo(
+ name: "H2-FABRIKAM.TEST",
+ distinguishedName: "DC=h2-fabrikam,DC=test",
+ domainSid: "S-1-5-21-9-9-9",
+ netBiosName: "FABRIKAM");
+ LdapUtils.CacheDomainInfo(key, fabrikam);
+
+ var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(key, new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+ Assert.False(ok);
+ Assert.Null(cached);
+ }
+
+ [Fact]
+ public async Task CacheDomainInfo_KeyMatchesNetBiosName_Cached() {
+ new LdapUtils().ResetUtils();
+ const string netBiosKey = "H2NETBIOS";
+
+ // NetBIOS short-name keys are a legitimate alias form - the guard must accept the
+ // write when key matches candidate.NetBiosName even though it differs from Name.
+ var info = new DomainInfo(
+ name: "H2-NETBIOS-MATCH.TEST",
+ distinguishedName: "DC=h2-netbios-match,DC=test",
+ domainSid: "S-1-5-21-1-1-1",
+ netBiosName: "H2NETBIOS");
+ LdapUtils.CacheDomainInfo(netBiosKey, info);
+
+ var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(netBiosKey, new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+ Assert.True(ok);
+ Assert.Equal("H2-NETBIOS-MATCH.TEST", cached.Name);
+ }
+
+ [Fact]
+ public async Task CacheDomainInfo_KeyMatchesCandidateNameCaseInsensitive_Cached() {
+ new LdapUtils().ResetUtils();
+ const string lowerKey = "h2-case.test";
+
+ var info = new DomainInfo(name: "H2-CASE.TEST", domainSid: "S-1-5-21-2-2-2");
+ LdapUtils.CacheDomainInfo(lowerKey, info);
+
+ var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(lowerKey, new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+ Assert.True(ok);
+ Assert.Equal("S-1-5-21-2-2-2", cached.DomainSid);
+ }
+
+ [Fact]
+ public async Task CacheDomainInfo_MirrorsEntryUnderCandidateName() {
+ new LdapUtils().ResetUtils();
+ const string netBiosKey = "H1MIRROR";
+
+ // Write under the NetBIOS alias - the helper must mirror the entry under the
+ // canonical DNS Name so a later lookup by FQDN hits the same record without redoing
+ // resolution.
+ var info = new DomainInfo(
+ name: "H1-MIRROR.TEST",
+ distinguishedName: "DC=h1-mirror,DC=test",
+ domainSid: "S-1-5-21-3-3-3",
+ netBiosName: "H1MIRROR");
+ LdapUtils.CacheDomainInfo(netBiosKey, info);
+
+ var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync("H1-MIRROR.TEST", new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+ Assert.True(ok);
+ Assert.Equal("H1MIRROR", cached.NetBiosName);
+ Assert.Equal("S-1-5-21-3-3-3", cached.DomainSid);
+ }
+
+ [Fact]
+ public async Task CacheDomainInfo_RicherAliasWriteUpgradesNameKeyEntry() {
+ new LdapUtils().ResetUtils();
+ const string fqdnKey = "h1-upgrade.test";
+ const string netBiosKey = "H1UPGRADE";
+
+ // Pre-seed the canonical-Name entry with a sparse record (e.g. an earlier ADSI tier
+ // result). A later richer write under the NetBIOS alias must propagate through the
+ // mirror so a FQDN lookup observes the upgrade rather than the stale sparse entry.
+ var sparse = new DomainInfo(name: "H1-UPGRADE.TEST", domainSid: "S-1-5-21-4-4-4");
+ LdapUtils.CacheDomainInfo(fqdnKey, sparse);
+
+ var rich = new DomainInfo(
+ name: "H1-UPGRADE.TEST",
+ distinguishedName: "DC=h1-upgrade,DC=test",
+ domainSid: "S-1-5-21-4-4-4",
+ netBiosName: "H1UPGRADE",
+ primaryDomainController: "dc01.h1-upgrade.test",
+ domainControllers: new[] { "dc01.h1-upgrade.test" });
+ LdapUtils.CacheDomainInfo(netBiosKey, rich);
+
+ var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(fqdnKey, new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+ Assert.True(ok);
+ Assert.Equal("H1UPGRADE", cached.NetBiosName);
+ Assert.Equal("dc01.h1-upgrade.test", cached.PrimaryDomainController);
+ }
+
// ---------------------------------------------------------------------------
// SelectRicherDomainInfo / TryEnrichDomainInfoViaDirectLdapAsync coverage
// ---------------------------------------------------------------------------