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.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,478 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Compares an incoming bundle DTO against the existing entity in the target
/// database and produces a single <see cref="ImportPreviewItem"/> that
/// classifies the conflict and (for <c>Modified</c>) carries a coarse field
/// diff in JSON.
/// <para>
/// "Coarse" means: each persistent field is compared as a value; differing
/// fields appear in <c>changes</c> with old/new values (or hashes for large
/// blobs like script code). Per-line / Myers-style diff is explicitly out of
/// scope for v1 — the design plan defers it to a follow-up task. Script
/// bodies record only a line-count delta to give the operator a sense of the
/// change without paying the diff cost up front.
/// </para>
/// <para>
/// Entity versions are not yet tracked on the POCOs, so the
/// <see cref="ImportPreviewItem.ExistingVersion"/> / <see cref="ImportPreviewItem.IncomingVersion"/>
/// fields are always <c>null</c> here. They are reserved for a future
/// optimistic-concurrency feature.
/// </para>
/// </summary>
public sealed class ArtifactDiff
{
private static readonly JsonSerializerOptions DiffJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ---- Templates ----
/// <summary>
/// Compares an incoming template against the existing template in the database.
/// </summary>
/// <param name="incoming">The incoming template from the bundle.</param>
/// <param name="existing">The existing template in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null)
{
return New("Template", incoming.Name);
}
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
// Children: compare each child collection by name. We track which names
// were added, which were removed, and which existed on both sides but
// diverged in body. We use coarse value equality / line counts for
// scripts so the diff JSON stays under a few KB per item.
DiffChildren(
existing.Attributes,
incoming.Attributes,
e => e.Name,
i => i.Name,
AttributesEqual,
"Attributes",
changes);
DiffChildren(
existing.Alarms,
incoming.Alarms,
e => e.Name,
i => i.Name,
AlarmsEqual,
"Alarms",
changes);
DiffScriptChildren(existing.Scripts, incoming.Scripts, changes);
// Compositions diff by InstanceName since ComposedTemplateId vs
// ComposedTemplateName aren't directly comparable. The bundle side
// already serializes to the name form, so the comparison reduces to
// (InstanceName, ComposedTemplateName) tuple equality.
DiffChildren(
existing.Compositions,
incoming.Compositions,
e => e.InstanceName,
i => i.InstanceName,
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
"Compositions",
changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming shared script against the existing shared script in the database.
/// </summary>
/// <param name="incoming">The incoming shared script from the bundle.</param>
/// <param name="existing">The existing shared script in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SharedScript", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code);
return BuildItem("SharedScript", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming external system against the existing external system in the database.
/// </summary>
/// <param name="incoming">The incoming external system from the bundle.</param>
/// <param name="existing">The existing external system in the database, or null if new.</param>
/// <param name="existingMethods">The existing external system methods, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList<ExternalSystemMethod>? existingMethods)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ExternalSystem", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl);
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
// Secrets: presence-only comparison (we never echo the value in the diff).
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.AuthConfiguration",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
// Methods are name-keyed children.
var existingForCompare = existingMethods ?? Array.Empty<ExternalSystemMethod>();
DiffChildren(
existingForCompare,
incoming.Methods,
e => e.Name,
i => i.Name,
ExternalSystemMethodsEqual,
"Methods",
changes);
return BuildItem("ExternalSystem", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming database connection against the existing database connection in the database.
/// </summary>
/// <param name="incoming">The incoming database connection from the bundle.</param>
/// <param name="existing">The existing database connection in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("DatabaseConnection", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
// ConnectionString lives in Secrets only — presence-only comparison.
var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.ConnectionString",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("DatabaseConnection", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming notification list against the existing notification list in the database.
/// </summary>
/// <param name="incoming">The incoming notification list from the bundle.</param>
/// <param name="existing">The existing notification list in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("NotificationList", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString());
DiffChildren(
existing.Recipients,
incoming.Recipients,
e => e.Name,
i => i.Name,
(e, i) => e.EmailAddress == i.EmailAddress,
"Recipients",
changes);
return BuildItem("NotificationList", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming SMTP configuration against the existing SMTP configuration in the database.
/// </summary>
/// <param name="incoming">The incoming SMTP configuration from the bundle.</param>
/// <param name="existing">The existing SMTP configuration in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SmtpConfiguration", incoming.Host);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Port", existing.Port, incoming.Port);
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress);
AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode);
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.Credentials",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("SmtpConfiguration", incoming.Host, changes);
}
/// <summary>
/// Compares an incoming API key against the existing API key in the database.
/// </summary>
/// <param name="incoming">The incoming API key from the bundle.</param>
/// <param name="existing">The existing API key in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareApiKey(ApiKeyDto incoming, ApiKey? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ApiKey", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "IsEnabled", existing.IsEnabled, incoming.IsEnabled);
// KeyHash is opaque — record only changed/unchanged, not the value.
if (!string.Equals(existing.KeyHash, incoming.KeyHash, StringComparison.Ordinal))
{
changes.Add(new FieldChange("KeyHash", "<changed>", "<changed>"));
}
return BuildItem("ApiKey", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming API method against the existing API method in the database.
/// </summary>
/// <param name="incoming">The incoming API method from the bundle.</param>
/// <param name="existing">The existing API method in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ApiMethod", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "ApprovedApiKeyIds", existing.ApprovedApiKeyIds, incoming.ApprovedApiKeyIds);
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script);
return BuildItem("ApiMethod", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming template folder against the existing template folder in the database.
/// </summary>
/// <param name="incoming">The incoming template folder from the bundle.</param>
/// <param name="existing">The existing template folder in the database, or null if new.</param>
/// <param name="folderNameById">A mapping of folder IDs to names for resolving parent folder references.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary<int, string> folderNameById)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("TemplateFolder", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder);
var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null;
AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName);
return BuildItem("TemplateFolder", incoming.Name, changes);
}
// ---- Helpers ----
private static ImportPreviewItem New(string entityType, string name) =>
new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null);
private static ImportPreviewItem BuildItem(string entityType, string name, List<FieldChange> changes)
{
if (changes.Count == 0)
{
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null);
}
var diff = new FieldDiff(
Adds: Array.Empty<string>(),
Removes: Array.Empty<string>(),
Changes: changes);
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null);
}
private static void AddIfDifferent<T>(List<FieldChange> changes, string field, T existing, T incoming)
{
if (!Equals(existing, incoming))
{
changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString()));
}
}
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
{
// Script bodies can be large — record a line-count delta + change marker
// instead of inlining the full text so the diff JSON stays compact.
var sameNullness = existing is null == incoming is null;
var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
if (bothPresentAndEqual) return;
var oldLines = existing?.Split('\n').Length ?? 0;
var newLines = incoming?.Split('\n').Length ?? 0;
changes.Add(new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>"));
}
private static void DiffChildren<TExisting, TIncoming>(
IEnumerable<TExisting> existing,
IEnumerable<TIncoming> incoming,
Func<TExisting, string> existingKey,
Func<TIncoming, string> incomingKey,
Func<TExisting, TIncoming, bool> equal,
string childCategory,
List<FieldChange> changes)
{
var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
foreach (var name in added)
{
changes.Add(new FieldChange($"{childCategory}.{name}", null, "<added>"));
}
foreach (var name in removed)
{
changes.Add(new FieldChange($"{childCategory}.{name}", "<present>", null));
}
foreach (var (name, e) in existingByName)
{
if (!incomingByName.TryGetValue(name, out var i)) continue;
if (!equal(e, i))
{
changes.Add(new FieldChange($"{childCategory}.{name}", "<modified>", "<modified>"));
}
}
}
/// <summary>
/// Scripts get a finer-grained per-row line-count delta so the preview UI
/// can show the operator which scripts changed and roughly how much.
/// </summary>
private static void DiffScriptChildren(
IEnumerable<TemplateScript> existing,
IEnumerable<TemplateScriptDto> incoming,
List<FieldChange> changes)
{
var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
{
var inc = incomingByName[name];
var newLines = inc.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", null, $"<added, {newLines} lines>"));
}
foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
{
var ex = existingByName[name];
var oldLines = ex.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", $"<present, {oldLines} lines>", null));
}
foreach (var (name, ex) in existingByName)
{
if (!incomingByName.TryGetValue(name, out var inc)) continue;
if (!ScriptsEqual(ex, inc))
{
var oldLines = ex.Code.Split('\n').Length;
var newLines = inc.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", $"<{oldLines} lines>", $"<{newLines} lines>"));
}
}
}
private static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
e.Value == i.Value
&& e.DataType == i.DataType
&& e.IsLocked == i.IsLocked
&& e.Description == i.Description
&& e.DataSourceReference == i.DataSourceReference;
private static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i) =>
e.Description == i.Description
&& e.PriorityLevel == i.PriorityLevel
&& e.TriggerType == i.TriggerType
&& e.TriggerConfiguration == i.TriggerConfiguration
&& e.IsLocked == i.IsLocked;
private static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
string.Equals(e.Code, i.Code, StringComparison.Ordinal)
&& e.TriggerType == i.TriggerType
&& e.TriggerConfiguration == i.TriggerConfiguration
&& e.ParameterDefinitions == i.ParameterDefinitions
&& e.ReturnDefinition == i.ReturnDefinition
&& e.IsLocked == i.IsLocked;
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
e.HttpMethod == i.HttpMethod
&& e.Path == i.Path
&& e.ParameterDefinitions == i.ParameterDefinitions
&& e.ReturnDefinition == i.ReturnDefinition;
private static string? FolderNameOf(Template t)
{
// Templates carry only a FK to the folder; the EntitySerializer projects
// it to a name. The diff doesn't have access to a folder lookup at
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
// known. The PreviewAsync caller can pass a hydrated Template (via
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
// it — this branch is a deliberate best-effort.
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
}
private static string? BaseTemplateNameOf(Template t)
{
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
}
private static string CompositionTargetNameOf(TemplateComposition comp)
{
return $"<id:{comp.ComposedTemplateId}>";
}
private sealed record FieldChange(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("oldValue")] string? OldValue,
[property: JsonPropertyName("newValue")] string? NewValue);
private sealed record FieldDiff(
[property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
[property: JsonPropertyName("removes")] IReadOnlyList<string> Removes,
[property: JsonPropertyName("changes")] IReadOnlyList<FieldChange> Changes);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// T-003: thrown by <see cref="BundleImporter.LoadAsync"/> when an encrypted bundle has
/// exceeded the configured failed-unlock attempt limit
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>). The lockout is tracked
/// server-side keyed by <c>BundleManifest.ContentHash</c>, so a second tab / CLI caller
/// re-uploading the same bytes hits the same counter and cannot side-step the limit.
/// </summary>
public sealed class BundleLockedException : Exception
{
/// <summary>Number of recorded unlock failures for this bundle.</summary>
public int FailedAttempts { get; }
/// <summary>SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key.</summary>
public string BundleContentHash { get; }
/// <summary>
/// Initializes a new <see cref="BundleLockedException"/>.
/// </summary>
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
/// <param name="failedAttempts">Number of failures recorded against this bundle.</param>
public BundleLockedException(string bundleContentHash, int failedAttempts)
: base(
$"Bundle is locked after {failedAttempts} failed unlock attempts. "
+ "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.")
{
BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash));
FailedAttempts = failedAttempts;
}
}
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// T-007: periodic background sweep that drives <see cref="IBundleSessionStore.EvictExpired"/>
/// so abandoned import sessions clear from memory on their own, without needing a
/// new <see cref="IBundleSessionStore.Get"/> to trigger lazy eviction. Each session
/// owns the decrypted bundle content (potentially up to ~100 MB of secrets — DB
/// connection strings, SMTP credentials, external-system auth configs), and the
/// design contract is "bundles are not retained server-side after ApplyAsync
/// commits". This service keeps abandoned / failed sessions from pinning that
/// plaintext for the full 30-minute TTL when no other traffic flows.
/// </summary>
internal sealed class BundleSessionEvictionService : BackgroundService
{
private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1);
private readonly IBundleSessionStore _sessionStore;
private readonly ILogger<BundleSessionEvictionService> _logger;
public BundleSessionEvictionService(
IBundleSessionStore sessionStore,
ILogger<BundleSessionEvictionService> logger)
{
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(SweepInterval, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
try
{
_sessionStore.EvictExpired();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Bundle session sweep failed; will retry on next interval.");
}
}
}
}
@@ -0,0 +1,175 @@
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 _);
}
}
@@ -0,0 +1,35 @@
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Transport-004: thrown by <see cref="BundleImporter.LoadAsync"/> when the caller
/// has exceeded the configured per-IP-per-hour unlock attempt cap
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerIpPerHour"/>). The 429-equivalent
/// signal: the caller must wait for the trailing-hour window to roll forward before
/// another passphrase attempt is accepted.
/// </summary>
public sealed class BundleUnlockRateLimitedException : Exception
{
/// <summary>
/// Rate-limit key the limiter rejected the attempt against — the caller IP when
/// supplied, or the bundle's content hash as the architectural fallback (the
/// importer has no <c>IHttpContext</c> dependency by design).
/// </summary>
public string ClientKey { get; }
/// <summary>Per-window cap that was reached.</summary>
public int Limit { get; }
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimitedException"/>.
/// </summary>
/// <param name="clientKey">The rate-limit key that exceeded its budget.</param>
/// <param name="limit">The configured per-window cap.</param>
public BundleUnlockRateLimitedException(string clientKey, int limit)
: base(
$"Bundle unlock rate limit reached ({limit} attempts per hour). "
+ "Wait for the trailing-hour window to expire before retrying.")
{
ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
Limit = limit;
}
}
@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// 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.
/// <para>
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
/// than the configured window, then either appends the current timestamp and returns
/// <c>true</c> if the count is still under the threshold, or refuses to append and
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
/// queue length post-prune.
/// </para>
/// <para>
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class BundleUnlockRateLimiter
{
/// <summary>
/// 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.
/// </summary>
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _window;
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
/// 1-hour trailing window and the system clock. Suitable for production DI.
/// </summary>
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
{
}
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
/// (for deterministic tests) and a custom trailing window.
/// </summary>
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
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;
}
/// <summary>
/// Attempts to register a new passphrase try against the configured per-key
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
/// returns <c>false</c> when the key has exhausted its budget for the trailing
/// window — the caller should reject the unlock request with a 429-equivalent.
/// </summary>
/// <param name="clientKey">
/// Opaque caller identifier — typically the remote IP, but any stable per-source
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
/// </param>
/// <param name="maxAttemptsPerWindow">
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
/// default 10). Must be at least 1.
/// </param>
/// <returns>
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
/// trailing window.
/// </returns>
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;
}
}
/// <summary>
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
/// within the trailing window. Primarily for tests / diagnostics; not part of the
/// hot-path.
/// </summary>
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;
}
}
/// <summary>
/// Per-key queue of attempt timestamps. A class (rather than a bare
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
/// races — letting the per-bucket lock guard the queue mutations.
/// </summary>
private sealed class AttemptBucket
{
public Queue<DateTimeOffset> Timestamps { get; } = new();
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Thrown when the post-apply semantic validation pass detects that the merged
/// target configuration would not be deployable — e.g. a template script
/// references a SharedScript or ExternalSystem that exists in neither the
/// bundle nor the (post-merge) target database.
/// <para>
/// The exception is caught inside <see cref="BundleImporter.ApplyAsync"/> to
/// roll back the transaction, emit a <c>BundleImportFailed</c> audit row, and
/// re-throw to the caller so the UI can surface the specific errors. It is
/// deliberately distinct from <see cref="InvalidOperationException"/> so the
/// caller can distinguish "your bundle is bad" from "the import infra is bad".
/// </para>
/// </summary>
public sealed class SemanticValidationException : Exception
{
/// <summary>Gets the list of semantic validation error messages that caused this exception.</summary>
public IReadOnlyList<string> Errors { get; }
/// <summary>
/// Initializes a new <see cref="SemanticValidationException"/> with the given error list.
/// </summary>
/// <param name="errors">The list of validation error messages to include in the exception.</param>
public SemanticValidationException(IReadOnlyList<string> errors)
: base(BuildMessage(errors))
{
Errors = errors ?? throw new ArgumentNullException(nameof(errors));
}
private static string BuildMessage(IReadOnlyList<string> errors)
{
if (errors is null || errors.Count == 0)
{
return "Bundle semantic validation failed.";
}
return "Bundle semantic validation failed: " + string.Join("; ", errors);
}
}