Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimiter.cs
T
Joseph Doherty eabf270d71 docs: complete XML doc coverage (returns, summaries, inheritdoc)
Resolve all 622 issues flagged by the enhanced CommentChecker: add missing
<returns> tags (incl. the standard phrasing on non-generic Task methods),
add missing <summary> tags, and replace misused/redundant <inheritdoc/> on
members that override or implement nothing with real documentation.
Documentation-only — no behavior change; solution builds clean.
2026-06-03 11:39:32 -04:00

159 lines
6.6 KiB
C#

using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
/// minimal server-side implementation.
/// <para>
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
/// than the configured window, then either appends the current timestamp and returns
/// <c>true</c> if the count is still under the threshold, or refuses to append and
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
/// queue length post-prune.
/// </para>
/// <para>
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
/// counters do not survive a host restart — that is by design: a restart resets the
/// brute-force window in favour of legitimate operators after an outage. Persisting
/// the counters would require a multi-node consensus story the simple in-memory
/// design avoids.
/// </para>
/// <para>
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
/// from multiple threads / circuits without external coordination.
/// </para>
/// </summary>
public sealed class BundleUnlockRateLimiter
{
/// <summary>
/// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
/// at 60 minutes; a constructor overload accepts a different window for tests.
/// </summary>
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _window;
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
/// 1-hour trailing window and the system clock. Suitable for production DI.
/// </summary>
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
{
}
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
/// (for deterministic tests) and a custom trailing window.
/// </summary>
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
{
ArgumentNullException.ThrowIfNull(timeProvider);
if (window <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
}
_timeProvider = timeProvider;
_window = window;
}
/// <summary>
/// Attempts to register a new passphrase try against the configured per-key
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
/// returns <c>false</c> when the key has exhausted its budget for the trailing
/// window — the caller should reject the unlock request with a 429-equivalent.
/// </summary>
/// <param name="clientKey">
/// Opaque caller identifier — typically the remote IP, but any stable per-source
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
/// </param>
/// <param name="maxAttemptsPerWindow">
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
/// default 10). Must be at least 1.
/// </param>
/// <returns>
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
/// trailing window.
/// </returns>
public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
if (maxAttemptsPerWindow < 1)
{
throw new ArgumentOutOfRangeException(
nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
}
var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
var now = _timeProvider.GetUtcNow();
var cutoff = now - _window;
lock (bucket)
{
// Prune expired entries first so a caller that paused longer than the
// window starts the next round at zero — not penalised by stale rows.
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
{
bucket.Timestamps.Dequeue();
}
if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
{
return false;
}
bucket.Timestamps.Enqueue(now);
return true;
}
}
/// <summary>
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
/// within the trailing window. Primarily for tests / diagnostics; not part of the
/// hot-path.
/// </summary>
/// <param name="clientKey">Opaque caller identifier — typically the remote IP.</param>
/// <returns>The number of recorded attempts for the key that still fall within the trailing window.</returns>
public int GetAttemptCount(string clientKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
{
return 0;
}
var cutoff = _timeProvider.GetUtcNow() - _window;
lock (bucket)
{
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
{
bucket.Timestamps.Dequeue();
}
return bucket.Timestamps.Count;
}
}
/// <summary>
/// Per-key queue of attempt timestamps. A class (rather than a bare
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
/// races — letting the per-bucket lock guard the queue mutations.
/// </summary>
private sealed class AttemptBucket
{
/// <summary>Ordered queue of attempt timestamps within the current trailing window.</summary>
public Queue<DateTimeOffset> Timestamps { get; } = new();
}
}