7b0b9c7365
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.
176 lines
6.6 KiB
C#
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 _);
|
|
}
|
|
}
|