using System.Collections.Concurrent; using Microsoft.Extensions.Options; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; namespace ScadaLink.Transport.Import; /// /// In-memory implementation of backed by a /// . Sessions are evicted lazily /// at read time () and on-demand via ; /// there is no background timer. /// /// Thread-safety: backed by of /// to . All store operations /// ( / / / /// ) use the concurrent dictionary's safe primitives /// (TryGetValue, indexer assignment, TryRemove) and are safe /// under concurrent callers. The instance itself /// is NOT thread-safe — callers that share a session reference (e.g. two /// importers mutating FailedUnlockAttempts on the same session) MUST /// serialize their mutations on that shared reference. /// /// /// TTL is supplied by the importer via ; /// this store does not impose its own. The injected /// is used purely to determine now when checking ExpiresAt, which /// keeps unit tests deterministic. /// /// /// The 3-strike unlock lockout is owned by /// (FailedUnlockAttempts / Locked); the store just hands out the /// shared session reference so the importer can mutate the counter in place. /// /// public sealed class BundleSessionStore : IBundleSessionStore { private readonly ConcurrentDictionary _sessions = new(); /// /// T-003: per-bundle unlock-failure counters, keyed by /// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on /// — so retries against the same bundle bytes from a /// second tab / CLI caller share the counter and cannot side-step the lockout. Entries /// expire on the same TTL as a session. /// private readonly ConcurrentDictionary _unlockFailures = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; private readonly IOptions _options; /// T-003: per-bundle unlock-failure entry with expiry. private sealed class UnlockFailureRecord { public int Count; public DateTimeOffset ExpiresAt; } /// /// Initializes a new . /// /// Transport options. is also used as the TTL for the T-003 per-bundle unlock-failure tracker. /// Time provider used to evaluate session expiry. public BundleSessionStore(IOptions options, TimeProvider timeProvider) { ArgumentNullException.ThrowIfNull(options); _options = options; _ = options.Value; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } /// public BundleSession Open(BundleSession session) { ArgumentNullException.ThrowIfNull(session); // Overwrite on collision is defensive: GUIDs are random so practical // collisions don't happen, but if a caller reuses an id we always // honor the latest Open call. _sessions[session.SessionId] = session; return session; } /// public BundleSession? Get(Guid sessionId) { if (!_sessions.TryGetValue(sessionId, out var session)) return null; if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session; _sessions.TryRemove(sessionId, out _); return null; } /// public void Remove(Guid sessionId) { _sessions.TryRemove(sessionId, out _); } /// public void EvictExpired() { var now = _timeProvider.GetUtcNow(); foreach (var kv in _sessions) { if (kv.Value.ExpiresAt <= now) { _sessions.TryRemove(kv.Key, out _); } } // T-003: also expire stale per-bundle unlock-failure entries so a bundle // that was previously locked clears once the lockout window passes. foreach (var kv in _unlockFailures) { if (kv.Value.ExpiresAt <= now) { _unlockFailures.TryRemove(kv.Key, out _); } } } /// public int GetUnlockFailureCount(string bundleContentHash) { ArgumentException.ThrowIfNullOrEmpty(bundleContentHash); if (!_unlockFailures.TryGetValue(bundleContentHash, out var record)) { return 0; } // Lazy expiry — if the entry has aged past its window treat it as cleared. if (record.ExpiresAt <= _timeProvider.GetUtcNow()) { _unlockFailures.TryRemove(bundleContentHash, out _); return 0; } return record.Count; } /// public int IncrementUnlockFailureCount(string bundleContentHash) { ArgumentException.ThrowIfNullOrEmpty(bundleContentHash); var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes); var now = _timeProvider.GetUtcNow(); var record = _unlockFailures.AddOrUpdate( bundleContentHash, _ => new UnlockFailureRecord { Count = 1, ExpiresAt = now + ttl }, (_, existing) => { // Treat an expired record as a fresh start so a legitimate operator // returning hours later does not face a stale lockout. if (existing.ExpiresAt <= now) { existing.Count = 1; } else { existing.Count++; } existing.ExpiresAt = now + ttl; return existing; }); return record.Count; } /// public void ClearUnlockFailures(string bundleContentHash) { ArgumentException.ThrowIfNullOrEmpty(bundleContentHash); _unlockFailures.TryRemove(bundleContentHash, out _); } }