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(); } }