Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
2026-05-28 09:37:45 -04:00

176 lines
6.6 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// In-memory implementation of <see cref="IBundleSessionStore"/> backed by a
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>. Sessions are evicted lazily
/// at read time (<see cref="Get"/>) and on-demand via <see cref="EvictExpired"/>;
/// there is no background timer.
/// <para>
/// Thread-safety: backed by <see cref="ConcurrentDictionary{TKey,TValue}"/> of
/// <see cref="Guid"/> to <see cref="BundleSession"/>. All store operations
/// (<see cref="Get"/> / <see cref="Open"/> / <see cref="Remove"/> /
/// <see cref="EvictExpired"/>) use the concurrent dictionary's safe primitives
/// (<c>TryGetValue</c>, indexer assignment, <c>TryRemove</c>) and are safe
/// under concurrent callers. The <see cref="BundleSession"/> instance itself
/// is NOT thread-safe — callers that share a session reference (e.g. two
/// importers mutating <c>FailedUnlockAttempts</c> on the same session) MUST
/// serialize their mutations on that shared reference.
/// </para>
/// <para>
/// TTL is supplied by the importer via <see cref="BundleSession.ExpiresAt"/>;
/// this store does not impose its own. The injected <see cref="TimeProvider"/>
/// is used purely to determine <c>now</c> when checking <c>ExpiresAt</c>, which
/// keeps unit tests deterministic.
/// </para>
/// <para>
/// The 3-strike unlock lockout is owned by <see cref="BundleSession"/>
/// (<c>FailedUnlockAttempts</c> / <c>Locked</c>); the store just hands out the
/// shared session reference so the importer can mutate the counter in place.
/// </para>
/// </summary>
public sealed class BundleSessionStore : IBundleSessionStore
{
private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();
/// <summary>
/// T-003: per-bundle unlock-failure counters, keyed by <see cref="BundleManifest.ContentHash"/>
/// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
/// <see cref="BundleSession"/> — 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.
/// </summary>
private readonly ConcurrentDictionary<string, UnlockFailureRecord> _unlockFailures = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IOptions<TransportOptions> _options;
/// <summary>T-003: per-bundle unlock-failure entry with expiry.</summary>
private sealed class UnlockFailureRecord
{
public int Count;
public DateTimeOffset ExpiresAt;
}
/// <summary>
/// Initializes a new <see cref="BundleSessionStore"/>.
/// </summary>
/// <param name="options">Transport options. <see cref="TransportOptions.BundleSessionTtlMinutes"/> is also used as the TTL for the T-003 per-bundle unlock-failure tracker.</param>
/// <param name="timeProvider">Time provider used to evaluate session expiry.</param>
public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_ = options.Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public void Remove(Guid sessionId)
{
_sessions.TryRemove(sessionId, out _);
}
/// <inheritdoc />
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 _);
}
}
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public void ClearUnlockFailures(string bundleContentHash)
{
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
_unlockFailures.TryRemove(bundleContentHash, out _);
}
}