Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/CommonLib/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
using SharpHoundCommonLib.Processors;
using Microsoft.Win32;
using System.Threading.Tasks;
using System.Threading;

namespace SharpHoundCommonLib {
public static class Helpers {
private static readonly HashSet<string> Groups = new() { "268435456", "268435457", "536870912", "536870913" };
private static readonly HashSet<string> Computers = new() { "805306369" };
private static readonly HashSet<string> Users = new() { "805306368", "805306370" };
private static readonly double MaxTimeSpanTicks = (double)TimeSpan.MaxValue.Ticks - 1_000;

private static readonly Regex DCReplaceRegex = new("DC=", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SPNRegex = new(@".*\/.*", RegexOptions.Compiled);
Expand Down Expand Up @@ -318,15 +320,28 @@ public static string DumpDirectoryObject(this IDirectoryObject directoryObject)
return builder.ToString();
}

public static TimeSpan BackoffWithDecorrelatedJitter(int attempt, TimeSpan baseDelay, TimeSpan maxDelay) {
// Decorrelated Jitter Backoff - see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
var temp = Math.Min(maxDelay.Ticks, baseDelay.Ticks * Math.Pow(2, attempt));
temp = temp / 2 + RandomUtils.Between(0, temp / 2);
var ticksToDelay = Math.Min(maxDelay.Ticks, RandomUtils.Between(baseDelay.Ticks, temp * 3));

// This ensures that a TimeSpan can be created with the ticks amount as TimeSpan uses a long.
return double.IsInfinity(ticksToDelay) ? TimeSpan.FromTicks((long)MaxTimeSpanTicks) :
TimeSpan.FromTicks((long)Math.Min(MaxTimeSpanTicks, ticksToDelay));
}

/// <summary>
/// Attempt an action a number of times, quietly eating a specific exception until the last attempt if it throws.
/// </summary>
/// <param name="action"></param>
/// <param name="retryCount"></param>
/// <param name="logger"></param>
public static async Task RetryOnException<T>(Func<Task> action, int retryCount, ILogger logger = null) where T : Exception {
public static async Task RetryOnException<T>(Func<Task> action, int retryCount, TimeSpan? baseDelay = null, TimeSpan? maxDelay = null, ILogger logger = null) where T : Exception {
int attempt = 0;
bool success = false;
baseDelay ??= TimeSpan.FromSeconds(1);
maxDelay ??= TimeSpan.FromSeconds(30);
do {
try {
await action();
Expand All @@ -337,9 +352,33 @@ public static async Task RetryOnException<T>(Func<Task> action, int retryCount,
logger?.LogDebug(e, "Exception caught, retrying attempt {Attempt}", attempt);
if (attempt >= retryCount)
throw;

var delay = BackoffWithDecorrelatedJitter(attempt, baseDelay.Value, maxDelay.Value);
await Task.Delay(delay);
}
} while (!success && attempt < retryCount);
}

public static async Task<U> RetryOnException<T, U>(Func<U> action, int retryCount, TimeSpan? baseDelay = null, TimeSpan? maxDelay = null, ILogger logger = null) where T : Exception {
int attempt = 0;
baseDelay ??= TimeSpan.FromSeconds(1);
maxDelay ??= TimeSpan.FromSeconds(30);
do {
try {
return action();
}
catch (T e) {
attempt++;
logger?.LogDebug(e, "Exception caught, retrying attempt {Attempt}", attempt);
if (attempt >= retryCount)
throw;
var delay = BackoffWithDecorrelatedJitter(attempt, baseDelay.Value, maxDelay.Value);
await Task.Delay(delay);
}
} while (attempt < retryCount);

throw new InvalidOperationException($"You really shouldn't be here, {nameof(RetryOnException)} isn't working as intended.");
}
}

public class ParsedGPLink {
Expand Down
11 changes: 4 additions & 7 deletions src/CommonLib/LdapConnectionPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ internal class LdapConnectionPool : IDisposable {
private const int MaxRetries = 3;
private static readonly ConcurrentDictionary<string, NetAPIStructs.DomainControllerInfo?> DCInfoCache = new();

// Tracks domains we know we've determined we shouldn't try to connect to
private static readonly ConcurrentHashSet _excludedDomains = new();

public LdapConnectionPool(string identifier, string poolIdentifier, LdapConfig config,
IPortScanner scanner = null, NativeMethods nativeMethods = null, ILogger log = null) {
_connections = new ConcurrentBag<LdapConnectionWrapper>();
Expand Down Expand Up @@ -693,7 +690,7 @@ private bool CallDsGetDcName(string domainName, out NetAPIStructs.DomainControll

public async Task<(bool Success, LdapConnectionWrapper ConnectionWrapper, string Message)>
GetConnectionAsync() {
if (_excludedDomains.Contains(_identifier)) {
if (LdapUtils.IsExcludedDomain(_identifier)) {
return (false, null, $"Identifier {_identifier} excluded for connection attempt");
}

Expand Down Expand Up @@ -727,7 +724,7 @@ private bool CallDsGetDcName(string domainName, out NetAPIStructs.DomainControll

public async Task<(bool Success, LdapConnectionWrapper ConnectionWrapper, string Message)>
GetGlobalCatalogConnectionAsync() {
if (_excludedDomains.Contains(_identifier)) {
if (LdapUtils.IsExcludedDomain(_identifier)) {
return (false, null, $"Identifier {_identifier} excluded for connection attempt");
}

Expand Down Expand Up @@ -813,7 +810,7 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti
_log.LogDebug(
"Could not get domain object from GetDomain, unable to create ldap connection for domain {Domain}",
_identifier);
_excludedDomains.Add(_identifier);
LdapUtils.AddExcludedDomain(_identifier);
return (false, null, "Unable to get domain object for further strategies");
}

Expand Down Expand Up @@ -852,7 +849,7 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti
catch (Exception e) {
_log.LogInformation(e, "We will not be able to connect to domain {Domain} by any strategy, leaving it.",
_identifier);
_excludedDomains.Add(_identifier);
LdapUtils.AddExcludedDomain(_identifier);
}
Comment on lines +852 to 853
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don’t blanket‑exclude the domain on any exception at the end of CreateNewConnection.

Catching Exception and permanently excluding can mask auth/config issues and reduce resiliency. Restrict exclusion to specific transient/network failures or remove it here and rely on upstream retry/strategy logic.

Apply this change:

-                LdapUtils.AddExcludedDomain(_identifier);
+                // Avoid permanent exclusion on generic exceptions; rely on retries/strategies.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LdapUtils.AddExcludedDomain(_identifier);
}
// Avoid permanent exclusion on generic exceptions; rely on retries/strategies.
}
🤖 Prompt for AI Agents
In src/CommonLib/LdapConnectionPool.cs around lines 852-853, the code currently
catches all Exception and calls LdapUtils.AddExcludedDomain(_identifier), which
can hide auth/configuration errors and permanently blacklist domains; change
this to not blanket-exclude on any exception by replacing the broad catch with
targeted handling: catch only transient/network-related exceptions (e.g.,
LdapException with transient/error codes, SocketException, TimeoutException or
whatever concrete exceptions your LDAP client throws) and call
LdapUtils.AddExcludedDomain(_identifier) only in those catches; for other
exceptions rethrow after logging (or log and let upstream retry/strategy logic
handle it) and avoid swallowing Exception; ensure logging includes the exception
details and update any unit/integration tests to reflect the new behavior.


return (false, null, "All attempted connections failed");
Expand Down
26 changes: 22 additions & 4 deletions src/CommonLib/LdapUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public class LdapUtils : ILdapUtils {
private static ConcurrentDictionary<string, Domain> _domainCache = new();
private static ConcurrentHashSet _domainControllers = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentHashSet _unresolvablePrincipals = new(StringComparer.OrdinalIgnoreCase);

// Tracks Domains we know we've determined we shouldn't try to connect to
private static ConcurrentHashSet _excludedDomains = new(StringComparer.OrdinalIgnoreCase);

private static readonly ConcurrentDictionary<string, string> DomainToForestCache =
new(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -506,12 +509,14 @@ public bool GetDomain(string domainName, out Domain domain) {
: new DirectoryContext(DirectoryContextType.Domain);

// Blocking External Call
domain = Domain.GetDomain(context);
domain = Helpers.RetryOnException<ActiveDirectoryObjectNotFoundException, Domain>(() => Domain.GetDomain(context), 2).GetAwaiter().GetResult();
if (domain == null) return false;
_domainCache.TryAdd(cacheKey, domain);
return true;
}
catch (Exception e) {
// The Static GetDomain Function ran into an issue requiring to exclude a domain as it would continuously
// try to connect to a domain that it could not connect to. This method may also need the same logic.
_log.LogDebug(e, "GetDomain call failed for domain name {Name}", domainName);
domain = null;
return false;
Expand All @@ -520,6 +525,10 @@ public bool GetDomain(string domainName, out Domain domain) {

public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domain domain) {
if (_domainCache.TryGetValue(domainName, out domain)) return true;
if (IsExcludedDomain(domainName)) {
Logging.Logger.LogDebug("Domain: {DomainName} has been excluded for collection. Skipping", domainName);
return false;
}
Comment on lines 529 to 533
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Nice: early exit on excluded domains.

Once null-safety and out-param assignment are applied, this will prevent thrashing unreachable domains.

To confirm no call sites pass null to the static overload (which would otherwise bypass exclusion semantics), run:


🏁 Script executed:

#!/bin/bash
# Find static calls where the first argument is null
rg -nP --type=cs -C2 '\bLdapUtils\.GetDomain\s*\(\s*null\s*,\s*'

Length of output: 0


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) All occurrences of 'GetDomain(' in C# files:"
rg -n --type=cs '\bGetDomain\s*\(' -S -C2 || true

echo
echo "2) Occurrences of 'LdapUtils.GetDomain(' in C# files:"
rg -nP --type=cs '\bLdapUtils\.GetDomain\s*\(' -S -C2 || true

echo
echo "3) Calls to GetDomain(...) with a literal null as the first argument:"
rg -nP --type=cs '\b(?:LdapUtils\.)?GetDomain\s*\(\s*null\b' -S -C2 || true

echo
echo "4) Show src/CommonLib/LdapUtils.cs around the reported lines (480-560):"
if [ -f src/CommonLib/LdapUtils.cs ]; then
  sed -n '480,560p' src/CommonLib/LdapUtils.cs || true
else
  echo "src/CommonLib/LdapUtils.cs not found"
fi

Length of output: 15200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Find references/definitions of _domainCache and _nullCacheKey"
rg -n --type=cs '_domainCache' -S -n -C3 || true
echo
rg -n --type=cs '_nullCacheKey' -S -n -C3 || true

echo
echo "2) Find IsExcludedDomain / AddExcludedDomain"
rg -n --type=cs 'IsExcludedDomain\(' -S -n -C3 || true
echo
rg -n --type=cs 'AddExcludedDomain\(' -S -n -C3 || true

echo
echo "3) Print top of src/CommonLib/LdapUtils.cs (fields / static members)"
if [ -f src/CommonLib/LdapUtils.cs ]; then
  sed -n '1,240p' src/CommonLib/LdapUtils.cs || true
else
  echo "src/CommonLib/LdapUtils.cs not found"
fi

echo
echo "4) Show the IsExcludedDomain and AddExcludedDomain function bodies (if found)"
files=$(rg -n --type=cs 'IsExcludedDomain\(|AddExcludedDomain\(' -S --hidden --no-line-number --line-number | cut -d: -f1 | sort -u || true)
for f in $files; do
  echo "---- $f ----"
  rg -n --type=cs 'IsExcludedDomain\(|AddExcludedDomain\(' -n -C6 "$f" || true
  sed -n '1,400p' "$f" | sed -n '1,400p' || true
done

Length of output: 65751


Make the static GetDomain null-safe and consistent with the instance overload

Static overload (src/CommonLib/LdapUtils.cs:526–531) calls _domainCache.TryGetValue(domainName, ...) and IsExcludedDomain(domainName) without guarding domainName; passing null will throw (ConcurrentDictionary disallows null keys) and also diverges from the instance GetDomain's use of _nullCacheKey. Normalize null handling (e.g. guard domainName, map null to a consistent null-cache key or delegate to the instance semantics) and ensure the out parameter is assigned on every return path.

Call sites to review: src/CommonLib/LdapConnectionPool.cs:635, 808; src/CommonLib/ConnectionPoolManager.cs:152.


try {
Comment on lines 526 to 535
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix out parameter assignment and null-safe caching/exclusion in static GetDomain.

  • Out param domain isn’t assigned on the early-return path, which will not compile.
  • Null domainName will currently throw in _domainCache.TryGetValue and in exclusion check.
  • Cache key should mirror the instance overload by using _nullCacheKey when domainName is null.

Apply this diff:

-        public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domain domain) {
-            if (_domainCache.TryGetValue(domainName, out domain)) return true;
-            if (IsExcludedDomain(domainName)) {
-                Logging.Logger.LogDebug("Domain: {DomainName} has been excluded for collection. Skipping", domainName);
-                return false;
-            }
+        public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domain domain) {
+            var cacheKey = domainName ?? _nullCacheKey;
+            if (_domainCache.TryGetValue(cacheKey, out domain)) return true;
+            if (IsExcludedDomain(domainName)) {
+                Logging.Logger.LogDebug("Domain: {DomainName} has been excluded for collection. Skipping", domainName ?? "<null>");
+                domain = null;
+                return false;
+            }
             ...
-                if (domain == null) return false;
-                _domainCache.TryAdd(domainName, domain);
+                if (domain == null) return false;
+                _domainCache.TryAdd(cacheKey, domain);
                 return true;

Also applies to: 549-550

🤖 Prompt for AI Agents
In src/CommonLib/LdapUtils.cs around lines 526-533 (and also apply same fix at
lines 549-550): create a local cacheKey = domainName ?? _nullCacheKey and use
that for _domainCache.TryGetValue so null won’t throw; assign the out parameter
domain = null before any early returns (e.g., when returning after TryGetValue
miss or when IsExcludedDomain indicates exclusion) and call IsExcludedDomain
with domainName in a null-safe way (or check for null before calling); ensure
the exclusion branch logs and then returns false with domain set to null so the
out param is always assigned.

DirectoryContext context;
Expand All @@ -535,14 +544,17 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
: new DirectoryContext(DirectoryContextType.Domain);

// Blocking External Call
domain = Domain.GetDomain(context);
domain = Helpers.RetryOnException<ActiveDirectoryObjectNotFoundException, Domain>(() => Domain.GetDomain(context), 2).GetAwaiter().GetResult();
if (domain == null) return false;
_domainCache.TryAdd(domainName, domain);
return true;
}
catch (Exception e) {
Logging.Logger.LogDebug("Static GetDomain call failed for domain {DomainName}: {Error}", domainName,
Logging.Logger.LogDebug("Static GetDomain call failed, adding to exclusion, for domain {DomainName}: {Error}", domainName,
e.Message);
// If a domain cannot be contacted, this will exclude the domain so that it does not continuously try to connect, and
// cause more timeouts.
AddExcludedDomain(domainName);
domain = null;
return false;
}
Expand All @@ -565,11 +577,13 @@ public bool GetDomain(out Domain domain) {
: new DirectoryContext(DirectoryContextType.Domain);

// Blocking External Call
domain = Domain.GetDomain(context);
domain = Helpers.RetryOnException<ActiveDirectoryObjectNotFoundException, Domain>(() => Domain.GetDomain(context), 2).GetAwaiter().GetResult();
_domainCache.TryAdd(_nullCacheKey, domain);
return true;
}
catch (Exception e) {
// The Static GetDomain Function ran into an issue requiring to exclude a domain as it would continuously
// try to connect to a domain that it could not connect to. This method may also need the same logic.
_log.LogDebug(e, "GetDomain call failed for blank domain");
domain = null;
return false;
Expand Down Expand Up @@ -1129,6 +1143,7 @@ public void ResetUtils() {
_domainControllers = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase);
_connectionPool?.Dispose();
_connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner);
_excludedDomains = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase);
}

private IDirectoryObject CreateDirectoryEntry(string path) {
Expand All @@ -1143,6 +1158,9 @@ public void Dispose() {
_connectionPool?.Dispose();
}

public static bool IsExcludedDomain(string domain) => _excludedDomains.Contains(domain);
public static void AddExcludedDomain(string domain) => _excludedDomains.Add(domain);

Comment on lines +1163 to +1165
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard exclusion helpers against null/empty inputs.

Current code will throw ArgumentNullException on null domain. Also avoid polluting the set with empty strings.

Apply this diff:

-public static bool IsExcludedDomain(string domain) => _excludedDomains.Contains(domain);
-public static void AddExcludedDomain(string domain) => _excludedDomains.Add(domain);
+public static bool IsExcludedDomain(string domain) =>
+    !string.IsNullOrWhiteSpace(domain) && _excludedDomains.Contains(domain);
+public static void AddExcludedDomain(string domain) {
+    if (!string.IsNullOrWhiteSpace(domain))
+        _excludedDomains.Add(domain);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static bool IsExcludedDomain(string domain) => _excludedDomains.Contains(domain);
public static void AddExcludedDomain(string domain) => _excludedDomains.Add(domain);
public static bool IsExcludedDomain(string domain) => !string.IsNullOrWhiteSpace(domain) && _excludedDomains.Contains(domain);
public static void AddExcludedDomain(string domain)
{
if (!string.IsNullOrWhiteSpace(domain))
_excludedDomains.Add(domain);
}
🤖 Prompt for AI Agents
In src/CommonLib/LdapUtils.cs around lines 1161 to 1163, the helpers don’t guard
against null or empty domain values which causes ArgumentNullException and
allows empty strings into the set; update IsExcludedDomain to return false when
domain is null or empty (optionally trim) and update AddExcludedDomain to no-op
when domain is null or empty (optionally trim and normalize case before adding)
so the set is never polluted and calls are safe.

internal static bool ResolveLabel(string objectIdentifier, string distinguishedName, string samAccountType,
string[] objectClasses, int flags, out Label type) {
type = Label.Base;
Expand Down
11 changes: 11 additions & 0 deletions src/CommonLib/RandomUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System;
using System.Threading;

namespace SharpHoundCommonLib;

public static class RandomUtils {
private static readonly ThreadLocal<Random> Random = new(() => new Random());

public static double NextDouble() => Random.Value.NextDouble();
public static double Between(double minValue, double maxValue) => Random.Value.NextDouble() * (maxValue - minValue) + minValue;
}
Comment on lines 7 to 20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Seed ThreadLocal securely and avoid symbol shadowing.

Multiple threads can get identical sequences with new Random(); also the field name Random shadows the type.

Apply this diff:

+using System.Security.Cryptography;
 
 public static class RandomUtils {
-    private static readonly ThreadLocal<Random> Random = new(() => new Random());
-    
-    public static double NextDouble() => Random.Value.NextDouble();
-    public static double Between(double minValue, double maxValue) => Random.Value.NextDouble() * (maxValue - minValue) + minValue;
+    private static readonly ThreadLocal<Random> ThreadRandom = new(() => {
+        // Per‑thread, cryptographically seeded RNG to avoid duplicate sequences across threads.
+        Span<byte> buf = stackalloc byte[8];
+        RandomNumberGenerator.Fill(buf);
+        var seed = BitConverter.ToInt32(buf);
+        return new Random(seed);
+    });
+
+    public static double NextDouble() => ThreadRandom.Value.NextDouble();
+    public static double Between(double minValue, double maxValue) {
+        if (maxValue < minValue) (minValue, maxValue) = (maxValue, minValue);
+        return ThreadRandom.Value.NextDouble() * (maxValue - minValue) + minValue;
+    }
 }
🤖 Prompt for AI Agents
In src/CommonLib/RandomUtils.cs around lines 7 to 11, the ThreadLocal field is
named "Random" (shadowing the System.Random type) and each Random is constructed
with the default constructor which can produce identical sequences across
threads; change the field name to something like s_threadRandom to avoid
shadowing and seed each Random instance using a cryptographically-secure seed
(e.g., obtain an int seed from
System.Security.Cryptography.RandomNumberGenerator or
RandomNumberGenerator.GetInt32) when creating the ThreadLocal Random so each
thread gets a unique, secure seed; keep the API (NextDouble, Between) but have
them use s_threadRandom.Value.

25 changes: 1 addition & 24 deletions test/unit/LdapConnectionPoolTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,16 @@

public class LdapConnectionPoolTest
{
private static void AddExclusionDomain(string identifier) {
var excludedDomainsField = typeof(LdapConnectionPool)
.GetField("_excludedDomains", BindingFlags.Static | BindingFlags.NonPublic);

var excludedDomains = (ConcurrentHashSet)excludedDomainsField.GetValue(null);

excludedDomains.Add(identifier);
}

[Fact]
public async Task LdapConnectionPool_ExcludedDomains_ShouldExitEarly()
public async Task LdapConnectionPool_Static_GetDomain_Add_To_ExcludedDomains_ShouldExitEarly()
{
var mockLogger = new Mock<ILogger>();
var ldapConfig = new LdapConfig();
var connectionPool = new ConnectionPoolManager(ldapConfig, mockLogger.Object);

AddExclusionDomain("excludedDomain.com");
var connectAttempt = await connectionPool.TestDomainConnection("excludedDomain.com", false);

Assert.False(connectAttempt.Success);
Assert.Contains("excluded for connection attempt", connectAttempt.Message);
}

[Fact]
public async Task LdapConnectionPool_ExcludedDomains_NonExcludedShouldntExit()
{
var mockLogger = new Mock<ILogger>();
var ldapConfig = new LdapConfig();
var connectionPool = new ConnectionPoolManager(ldapConfig, mockLogger.Object);

AddExclusionDomain("excludedDomain.com");
var connectAttempt = await connectionPool.TestDomainConnection("perfectlyValidDomain.com", false);

Assert.DoesNotContain("excluded for connection attempt", connectAttempt.Message);
}
}
17 changes: 17 additions & 0 deletions test/unit/TimeoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,21 @@ public async Task ExecuteWithTimeout_Task_T_ParentTokenCancel() {
Assert.False(result.IsSuccess);
Assert.Equal("Cancellation requested", result.Error);
}

[Theory]
[InlineData(0, 2, 30, 2, 6)]
[InlineData(5, 2, 200, 1, 192)]
[InlineData(5, 5, 500, 5, 480)]
[InlineData(0, 2, 1, 1, 1)]
[InlineData(5, 2, 1, 1, 1)]
[InlineData(5, 2, 2, 2, 2)]
[InlineData(5, 30, 30, 30, 30)]
public void DecorrelatedTimeSpan_BetweenExpected(int attempt, int baseDelayValue, int maxDelayValue, double expectedLowerBound, double expectedUpperBound) {
var baseDelay = TimeSpan.FromTicks(baseDelayValue);
var maxDelay = TimeSpan.FromTicks(maxDelayValue);
for (var trials = 0; trials < 500; trials++) {
var delay = SharpHoundCommonLib.Helpers.BackoffWithDecorrelatedJitter(attempt, baseDelay, maxDelay);
Assert.InRange(delay.Ticks, expectedLowerBound, expectedUpperBound);
}
}
}
Loading