using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
///
/// 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.
///
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
/// of attempt timestamps. first prunes entries older
/// than the configured window, then either appends the current timestamp and returns
/// true if the count is still under the threshold, or refuses to append and
/// returns false if appending would cross it. The trailing-hour count is the
/// queue length post-prune.
///
///
/// Storage is a process-local . 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.
///
///
/// 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.
///
///
public sealed class BundleUnlockRateLimiter
{
///
/// 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.
///
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary _buckets = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _window;
///
/// Initializes a new using the documented
/// 1-hour trailing window and the system clock. Suitable for production DI.
///
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
{
}
///
/// Initializes a new with an injected clock
/// (for deterministic tests) and a custom trailing window.
///
/// Clock used for both timestamping new attempts and pruning expired ones.
/// Trailing window over which attempts are counted (typically 1 hour).
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;
}
///
/// Attempts to register a new passphrase try against the configured per-key
/// limit. Returns true when the attempt is permitted (and recorded);
/// returns false when the key has exhausted its budget for the trailing
/// window — the caller should reject the unlock request with a 429-equivalent.
///
///
/// Opaque caller identifier — typically the remote IP, but any stable per-source
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
///
///
/// The trailing-window cap (e.g. TransportOptions.MaxUnlockAttemptsPerIpPerHour,
/// default 10). Must be at least 1.
///
///
/// true if the attempt was registered (within budget); false if the
/// caller has already used within the
/// trailing window.
///
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;
}
}
///
/// Returns the number of recorded attempts for still
/// within the trailing window. Primarily for tests / diagnostics; not part of the
/// hot-path.
///
/// Opaque caller identifier — typically the remote IP.
/// The number of recorded attempts for the key that still fall within the trailing window.
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;
}
}
///
/// Per-key queue of attempt timestamps. A class (rather than a bare
/// ) so the dictionary value identity is stable across
/// concurrent
/// races — letting the per-bucket lock guard the queue mutations.
///
private sealed class AttemptBucket
{
/// Ordered queue of attempt timestamps within the current trailing window.
public Queue Timestamps { get; } = new();
}
}