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:
@@ -0,0 +1,56 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||
|
||||
/// <summary>
|
||||
/// T-005: computes the AES-GCM Associated Authenticated Data (AAD) for a bundle's
|
||||
/// encrypted payload. AAD is the SHA-256 of the manifest after normalising the two
|
||||
/// derivative fields (<c>ContentHash</c>, <c>Encryption</c>) to known sentinel
|
||||
/// values — those depend on the ciphertext and the IV, so they cannot themselves
|
||||
/// be authenticated, but every OTHER manifest field (<c>SourceEnvironment</c>,
|
||||
/// <c>ExportedBy</c>, <c>ScadaBridgeVersion</c>, <c>Summary</c>, <c>Contents</c>,
|
||||
/// <c>CreatedAtUtc</c>, …) participates in the GCM tag.
|
||||
/// <para>
|
||||
/// Threading this byte array through <c>AesGcm.Encrypt</c> / <c>AesGcm.Decrypt</c>
|
||||
/// makes the Step-4 "type the source environment name to confirm" gate
|
||||
/// tamper-evident: a flipped <c>SourceEnvironment</c> on a stolen bundle yields
|
||||
/// an <c>AuthenticationTagMismatchException</c> on decrypt instead of producing
|
||||
/// a valid plaintext with a forged origin label.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class BundleManifestAad
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON options matching <c>BundleSerializer.JsonOptions</c> so the AAD bytes
|
||||
/// are stable across the encrypt + decrypt side.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the AAD bytes for the supplied manifest. The two derivative
|
||||
/// fields are normalised to fixed sentinels so the AAD is independent of the
|
||||
/// ciphertext / IV that will eventually populate them in the on-disk
|
||||
/// manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The manifest whose non-derivative fields should be authenticated.</param>
|
||||
/// <returns>SHA-256 digest of the canonicalised manifest bytes.</returns>
|
||||
public static byte[] Compute(BundleManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
var canonical = manifest with
|
||||
{
|
||||
ContentHash = string.Empty,
|
||||
Encryption = null,
|
||||
};
|
||||
var canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions);
|
||||
return SHA256.HashData(canonicalBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Security.Cryptography;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM authenticated encryption with a PBKDF2-SHA256 derived key.
|
||||
/// Output format is <c>ciphertext || tag</c> (tag is the GCM authentication tag).
|
||||
/// Each encrypt call produces a fresh random salt + nonce so re-encrypting the
|
||||
/// same plaintext yields a different ciphertext.
|
||||
/// </summary>
|
||||
public sealed class BundleSecretEncryptor
|
||||
{
|
||||
private const int KeyBytes = 32; // AES-256.
|
||||
private const int SaltBytes = 16;
|
||||
private const int NonceBytes = 12; // GCM standard.
|
||||
private const int TagBytes = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts plaintext using AES-256-GCM with a PBKDF2-derived key and a fresh random salt and nonce.
|
||||
/// </summary>
|
||||
/// <param name="plaintext">The data to encrypt.</param>
|
||||
/// <param name="passphrase">The passphrase used to derive the encryption key.</param>
|
||||
/// <param name="iterations">PBKDF2 iteration count for key derivation.</param>
|
||||
/// <param name="associatedData">
|
||||
/// T-005: optional AES-GCM Associated Authenticated Data — typically
|
||||
/// <see cref="BundleManifestAad.Compute"/>(manifest). Binds the manifest's
|
||||
/// non-derivative fields (source environment, exporter, summary, …) to the
|
||||
/// ciphertext so any tampering of those fields yields an authentication-tag
|
||||
/// mismatch on decrypt. Pass <see cref="ReadOnlySpan{Byte}.Empty"/> for the
|
||||
/// legacy no-AAD format; the decrypter must mirror the choice.
|
||||
/// </param>
|
||||
/// <returns>The ciphertext (with appended GCM tag) and the encryption metadata needed to decrypt.</returns>
|
||||
public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt(
|
||||
ReadOnlySpan<byte> plaintext,
|
||||
string passphrase,
|
||||
int iterations,
|
||||
ReadOnlySpan<byte> associatedData = default)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
|
||||
var key = DeriveKey(passphrase, salt, iterations);
|
||||
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagBytes];
|
||||
using var aes = new AesGcm(key, TagBytes);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
|
||||
|
||||
// Format: ciphertext || tag.
|
||||
var output = new byte[ciphertext.Length + TagBytes];
|
||||
Buffer.BlockCopy(ciphertext, 0, output, 0, ciphertext.Length);
|
||||
Buffer.BlockCopy(tag, 0, output, ciphertext.Length, TagBytes);
|
||||
|
||||
return (output, new EncryptionMetadata(
|
||||
"AES-256-GCM", "PBKDF2-SHA256", iterations,
|
||||
Convert.ToBase64String(salt),
|
||||
Convert.ToBase64String(nonce)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a payload produced by <see cref="Encrypt"/> using the supplied metadata and passphrase.
|
||||
/// </summary>
|
||||
/// <param name="payload">The ciphertext with appended GCM tag.</param>
|
||||
/// <param name="metadata">Encryption metadata carrying the algorithm, KDF, salt, nonce, and iterations.</param>
|
||||
/// <param name="passphrase">The passphrase used to derive the decryption key.</param>
|
||||
/// <param name="associatedData">
|
||||
/// T-005: AAD that was passed to <see cref="Encrypt"/>. Must match exactly —
|
||||
/// any tampering of the manifest used to derive AAD yields an authentication-
|
||||
/// tag mismatch (surfaces as <see cref="CryptographicException"/>, specifically
|
||||
/// <c>AuthenticationTagMismatchException</c> on .NET 10).
|
||||
/// </param>
|
||||
/// <returns>The decrypted plaintext bytes.</returns>
|
||||
public byte[] Decrypt(
|
||||
ReadOnlySpan<byte> payload,
|
||||
EncryptionMetadata metadata,
|
||||
string passphrase,
|
||||
ReadOnlySpan<byte> associatedData = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
if (metadata.Algorithm != "AES-256-GCM" || metadata.Kdf != "PBKDF2-SHA256")
|
||||
{
|
||||
throw new CryptographicException("Unsupported bundle encryption parameters.");
|
||||
}
|
||||
|
||||
var salt = Convert.FromBase64String(metadata.SaltB64);
|
||||
var nonce = Convert.FromBase64String(metadata.IvB64);
|
||||
var key = DeriveKey(passphrase, salt, metadata.Iterations);
|
||||
|
||||
if (payload.Length < TagBytes)
|
||||
{
|
||||
throw new CryptographicException("Bundle payload too short.");
|
||||
}
|
||||
|
||||
var ctLen = payload.Length - TagBytes;
|
||||
var ciphertext = payload[..ctLen];
|
||||
var tag = payload[ctLen..];
|
||||
|
||||
var plaintext = new byte[ctLen];
|
||||
using var aes = new AesGcm(key, TagBytes);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(passphrase, salt, iterations, HashAlgorithmName.SHA256, KeyBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Ties together the resolver, serializer, encryptor, manifest builder, and audit
|
||||
/// service into a single export pipeline. Produces a ZIP-formatted bundle stream
|
||||
/// and emits one append-only audit row per export — distinguishing encrypted vs
|
||||
/// unencrypted runs via the <c>Action</c> column. The returned stream is positioned
|
||||
/// at <c>0</c> so callers can immediately copy to a response/file.
|
||||
/// </summary>
|
||||
public sealed class BundleExporter : IBundleExporter
|
||||
{
|
||||
private readonly DependencyResolver _resolver;
|
||||
private readonly EntitySerializer _entitySerializer;
|
||||
private readonly ManifestBuilder _manifestBuilder;
|
||||
private readonly BundleSecretEncryptor _encryptor;
|
||||
private readonly BundleSerializer _bundleSerializer;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ScadaBridgeDbContext _dbContext;
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
|
||||
/// <summary>Initializes the exporter with all pipeline collaborators.</summary>
|
||||
/// <param name="resolver">Resolves template dependency graphs and transitive entities.</param>
|
||||
/// <param name="entitySerializer">Converts EF entities to wire-shaped bundle DTOs.</param>
|
||||
/// <param name="manifestBuilder">Builds the bundle manifest including hash and summary.</param>
|
||||
/// <param name="encryptor">Encrypts bundle secrets when a passphrase is provided.</param>
|
||||
/// <param name="bundleSerializer">Serializes and compresses the bundle into a ZIP stream.</param>
|
||||
/// <param name="auditService">Records one audit row per export operation.</param>
|
||||
/// <param name="dbContext">Database context for reading export-time entity state.</param>
|
||||
/// <param name="options">Transport options (e.g., format version).</param>
|
||||
public BundleExporter(
|
||||
DependencyResolver resolver,
|
||||
EntitySerializer entitySerializer,
|
||||
ManifestBuilder manifestBuilder,
|
||||
BundleSecretEncryptor encryptor,
|
||||
BundleSerializer bundleSerializer,
|
||||
IAuditService auditService,
|
||||
ScadaBridgeDbContext dbContext,
|
||||
IOptions<TransportOptions> options)
|
||||
{
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
|
||||
_manifestBuilder = manifestBuilder ?? throw new ArgumentNullException(nameof(manifestBuilder));
|
||||
_encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
|
||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ExportAsync(
|
||||
ExportSelection selection,
|
||||
string user,
|
||||
string sourceEnvironment,
|
||||
string? passphrase,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selection);
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(sourceEnvironment);
|
||||
|
||||
// 1. Resolve dependencies → ResolvedExport (topologically sorted templates,
|
||||
// folder chain, transitive shared-scripts/external-systems if requested).
|
||||
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
|
||||
var aggregate = new EntityAggregate(
|
||||
TemplateFolders: resolved.TemplateFolders,
|
||||
Templates: resolved.Templates,
|
||||
SharedScripts: resolved.SharedScripts,
|
||||
ExternalSystems: resolved.ExternalSystems,
|
||||
ExternalSystemMethods: resolved.ExternalSystemMethods,
|
||||
DatabaseConnections: resolved.DatabaseConnections,
|
||||
NotificationLists: resolved.NotificationLists,
|
||||
SmtpConfigurations: resolved.SmtpConfigs,
|
||||
ApiKeys: resolved.ApiKeys,
|
||||
ApiMethods: resolved.ApiMethods);
|
||||
var contentDto = _entitySerializer.ToBundleContent(aggregate);
|
||||
|
||||
// 3. Per-category summary counts — used for fast humanly-readable preview at import.
|
||||
var summary = new BundleSummary(
|
||||
Templates: resolved.Templates.Count,
|
||||
TemplateFolders: resolved.TemplateFolders.Count,
|
||||
SharedScripts: resolved.SharedScripts.Count,
|
||||
ExternalSystems: resolved.ExternalSystems.Count,
|
||||
DbConnections: resolved.DatabaseConnections.Count,
|
||||
NotificationLists: resolved.NotificationLists.Count,
|
||||
SmtpConfigs: resolved.SmtpConfigs.Count,
|
||||
ApiKeys: resolved.ApiKeys.Count,
|
||||
ApiMethods: resolved.ApiMethods.Count);
|
||||
|
||||
// 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both
|
||||
// ContentHash and EncryptionMetadata against the bytes it actually
|
||||
// writes, so we only need to seed EncryptionMetadata.Iterations when
|
||||
// a passphrase is supplied — Pack rebuilds salt/iv/hash internally.
|
||||
var assemblyVersion = typeof(BundleExporter).Assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
EncryptionMetadata? encryptionSeed = passphrase is null
|
||||
? null
|
||||
: new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: _options.Value.Pbkdf2Iterations,
|
||||
SaltB64: string.Empty,
|
||||
IvB64: string.Empty);
|
||||
|
||||
var templateManifest = _manifestBuilder.Build(
|
||||
sourceEnvironment: sourceEnvironment,
|
||||
exportedBy: user,
|
||||
scadaLinkVersion: assemblyVersion,
|
||||
encryption: encryptionSeed,
|
||||
summary: summary,
|
||||
contents: resolved.ContentManifest,
|
||||
// Pack re-stamps ContentHash against the bytes it actually writes
|
||||
// (either fresh ciphertext or the JSON it just serialized), so the
|
||||
// upstream hash here only matters for the plaintext path — Pack
|
||||
// trusts the supplied hash and the serializer is deterministic.
|
||||
contentBytes: _bundleSerializer.SerializeContentBytes(contentDto));
|
||||
|
||||
// 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps
|
||||
// EncryptionMetadata (fresh salt/iv) + ContentHash.
|
||||
var zipStream = _bundleSerializer.Pack(contentDto, templateManifest, passphrase, _encryptor);
|
||||
|
||||
// 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes
|
||||
// (opaque to caller but uniquely identifies this export). Action
|
||||
// discriminates encrypted vs unencrypted runs.
|
||||
var bundleHash = ComputeStreamSha256(zipStream);
|
||||
await _auditService.LogAsync(
|
||||
user: user,
|
||||
action: passphrase is null ? "UnencryptedBundleExport" : "BundleExported",
|
||||
entityType: "Bundle",
|
||||
entityId: bundleHash,
|
||||
entityName: sourceEnvironment,
|
||||
afterState: new
|
||||
{
|
||||
SourceEnvironment = sourceEnvironment,
|
||||
Encrypted = passphrase is not null,
|
||||
ArtifactCount = resolved.ContentManifest.Count,
|
||||
Summary = summary,
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
zipStream.Position = 0;
|
||||
return zipStream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hashes the zip stream's bytes (resets position before + after) so the audit
|
||||
/// row's <c>EntityId</c> uniquely identifies this exact bundle without leaking
|
||||
/// any of the manifest's internal hashes (which describe content, not the zip).
|
||||
/// </summary>
|
||||
private static string ComputeStreamSha256(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
stream.Position = 0;
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
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.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Expands an <see cref="ExportSelection"/> into the full set of entities that
|
||||
/// must be in the bundle for the selection to be self-consistent. Walks the
|
||||
/// dependency edges documented in §6.1 of the Transport design doc:
|
||||
/// <list type="bullet">
|
||||
/// <item>Template composes Template (via TemplateComposition.ComposedTemplateId).</item>
|
||||
/// <item>Template references SharedScript (by name, scanned in script bodies).</item>
|
||||
/// <item>Template references ExternalSystem (by name, scanned in script bodies + attribute DataSourceReference).</item>
|
||||
/// <item>ApiMethod references SharedScript (by name, scanned in ApiMethod.Script).</item>
|
||||
/// <item>Selected Template's folder chain (ancestor folders included).</item>
|
||||
/// <item>ExternalSystemDefinition pulls its ExternalSystemMethods.</item>
|
||||
/// </list>
|
||||
/// Templates are returned topologically sorted (base-before-derived) via Kahn's
|
||||
/// algorithm so importers can apply them in order.
|
||||
/// </summary>
|
||||
public sealed class DependencyResolver
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templates;
|
||||
private readonly IExternalSystemRepository _externalSystems;
|
||||
private readonly INotificationRepository _notifications;
|
||||
private readonly IInboundApiRepository _inboundApi;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="DependencyResolver"/>.</summary>
|
||||
/// <param name="templates">Repository for template and shared script access.</param>
|
||||
/// <param name="externalSystems">Repository for external system definitions and methods.</param>
|
||||
/// <param name="notifications">Repository for notification lists and SMTP configurations.</param>
|
||||
/// <param name="inboundApi">Repository for inbound API keys and methods.</param>
|
||||
public DependencyResolver(
|
||||
ITemplateEngineRepository templates,
|
||||
IExternalSystemRepository externalSystems,
|
||||
INotificationRepository notifications,
|
||||
IInboundApiRepository inboundApi)
|
||||
{
|
||||
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
|
||||
_externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems));
|
||||
_notifications = notifications ?? throw new ArgumentNullException(nameof(notifications));
|
||||
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
||||
}
|
||||
|
||||
/// <summary>Expands the selection into a fully self-consistent set of entities for bundling.</summary>
|
||||
/// <param name="selection">The user's export selection specifying which entities to include.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A <see cref="ResolvedExport"/> with all transitively required entities.</returns>
|
||||
public async Task<ResolvedExport> ResolveAsync(ExportSelection selection, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selection);
|
||||
|
||||
// ---- Seed: fetch the directly-selected entities ----
|
||||
var templates = new Dictionary<int, Template>();
|
||||
foreach (var id in selection.TemplateIds.Distinct())
|
||||
{
|
||||
var t = await _templates.GetTemplateWithChildrenAsync(id, ct).ConfigureAwait(false);
|
||||
if (t is not null) templates[t.Id] = t;
|
||||
}
|
||||
|
||||
var sharedScripts = new Dictionary<int, SharedScript>();
|
||||
foreach (var id in selection.SharedScriptIds.Distinct())
|
||||
{
|
||||
var s = await _templates.GetSharedScriptByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (s is not null) sharedScripts[s.Id] = s;
|
||||
}
|
||||
|
||||
var externalSystems = new Dictionary<int, ExternalSystemDefinition>();
|
||||
foreach (var id in selection.ExternalSystemIds.Distinct())
|
||||
{
|
||||
var es = await _externalSystems.GetExternalSystemByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (es is not null) externalSystems[es.Id] = es;
|
||||
}
|
||||
|
||||
var dbConnections = new Dictionary<int, DatabaseConnectionDefinition>();
|
||||
foreach (var id in selection.DatabaseConnectionIds.Distinct())
|
||||
{
|
||||
var db = await _externalSystems.GetDatabaseConnectionByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (db is not null) dbConnections[db.Id] = db;
|
||||
}
|
||||
|
||||
var notificationLists = new Dictionary<int, NotificationList>();
|
||||
foreach (var id in selection.NotificationListIds.Distinct())
|
||||
{
|
||||
var nl = await _notifications.GetNotificationListByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (nl is not null) notificationLists[nl.Id] = nl;
|
||||
}
|
||||
|
||||
var smtpConfigs = new Dictionary<int, SmtpConfiguration>();
|
||||
foreach (var id in selection.SmtpConfigurationIds.Distinct())
|
||||
{
|
||||
var sm = await _notifications.GetSmtpConfigurationByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (sm is not null) smtpConfigs[sm.Id] = sm;
|
||||
}
|
||||
|
||||
var apiKeys = new Dictionary<int, ApiKey>();
|
||||
foreach (var id in selection.ApiKeyIds.Distinct())
|
||||
{
|
||||
var k = await _inboundApi.GetApiKeyByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (k is not null) apiKeys[k.Id] = k;
|
||||
}
|
||||
|
||||
var apiMethods = new Dictionary<int, ApiMethod>();
|
||||
foreach (var id in selection.ApiMethodIds.Distinct())
|
||||
{
|
||||
var m = await _inboundApi.GetApiMethodByIdAsync(id, ct).ConfigureAwait(false);
|
||||
if (m is not null) apiMethods[m.Id] = m;
|
||||
}
|
||||
|
||||
// ---- Dependency expansion ----
|
||||
if (selection.IncludeDependencies)
|
||||
{
|
||||
await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false);
|
||||
await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false);
|
||||
await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false);
|
||||
await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ExternalSystemMethods always travel with their parent ExternalSystem.
|
||||
var externalSystemMethods = new List<ExternalSystemMethod>();
|
||||
foreach (var es in externalSystems.Values.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var methods = await _externalSystems
|
||||
.GetMethodsByExternalSystemIdAsync(es.Id, ct)
|
||||
.ConfigureAwait(false);
|
||||
externalSystemMethods.AddRange(methods);
|
||||
}
|
||||
|
||||
// Folder ancestor chain — always pull, regardless of IncludeDependencies,
|
||||
// so the imported tree never references missing parents.
|
||||
var folders = await ResolveFolderChainAsync(templates.Values, ct).ConfigureAwait(false);
|
||||
|
||||
// ---- Topological sort of templates (base-before-derived) ----
|
||||
var orderedTemplates = TopologicallySortTemplates(templates.Values);
|
||||
|
||||
// ---- Build deterministic content manifest ----
|
||||
var manifest = BuildContentManifest(
|
||||
folders,
|
||||
orderedTemplates,
|
||||
sharedScripts.Values,
|
||||
externalSystems.Values,
|
||||
externalSystemMethods,
|
||||
dbConnections.Values,
|
||||
notificationLists.Values,
|
||||
smtpConfigs.Values,
|
||||
apiKeys.Values,
|
||||
apiMethods.Values);
|
||||
|
||||
return new ResolvedExport(
|
||||
TemplateFolders: folders,
|
||||
Templates: orderedTemplates,
|
||||
SharedScripts: sharedScripts.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(),
|
||||
ExternalSystems: externalSystems.Values.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(),
|
||||
ExternalSystemMethods: externalSystemMethods,
|
||||
DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(),
|
||||
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
|
||||
SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
|
||||
ApiKeys: apiKeys.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
|
||||
ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
|
||||
ContentManifest: manifest);
|
||||
}
|
||||
|
||||
// ---- Template composition closure ----
|
||||
private async Task ExpandTemplateClosureAsync(Dictionary<int, Template> templates, CancellationToken ct)
|
||||
{
|
||||
var queue = new Queue<Template>(templates.Values);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var comp in current.Compositions)
|
||||
{
|
||||
if (templates.ContainsKey(comp.ComposedTemplateId)) continue;
|
||||
var composed = await _templates
|
||||
.GetTemplateWithChildrenAsync(comp.ComposedTemplateId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (composed is null) continue;
|
||||
templates[composed.Id] = composed;
|
||||
queue.Enqueue(composed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SharedScript pulls from template script bodies + attributes ----
|
||||
private async Task ExpandSharedScriptsFromTemplatesAsync(
|
||||
Dictionary<int, Template> templates,
|
||||
Dictionary<int, SharedScript> sharedScripts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||
foreach (var shared in allShared)
|
||||
{
|
||||
if (sharedScripts.ContainsKey(shared.Id)) continue;
|
||||
if (TemplatesReferenceName(templates.Values, shared.Name))
|
||||
{
|
||||
sharedScripts[shared.Id] = shared;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SharedScript pulls from ApiMethod.Script bodies ----
|
||||
private async Task ExpandSharedScriptsFromApiMethodsAsync(
|
||||
Dictionary<int, ApiMethod> apiMethods,
|
||||
Dictionary<int, SharedScript> sharedScripts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (apiMethods.Count == 0) return;
|
||||
var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||
foreach (var shared in allShared)
|
||||
{
|
||||
if (sharedScripts.ContainsKey(shared.Id)) continue;
|
||||
if (apiMethods.Values.Any(m => ContainsIdentifier(m.Script, shared.Name)))
|
||||
{
|
||||
sharedScripts[shared.Id] = shared;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ExternalSystem pulls from template script/attribute bodies ----
|
||||
private async Task ExpandExternalSystemsFromTemplatesAsync(
|
||||
Dictionary<int, Template> templates,
|
||||
Dictionary<int, ExternalSystemDefinition> externalSystems,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allSystems = await _externalSystems.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||||
foreach (var es in allSystems)
|
||||
{
|
||||
if (externalSystems.ContainsKey(es.Id)) continue;
|
||||
if (TemplatesReferenceName(templates.Values, es.Name))
|
||||
{
|
||||
externalSystems[es.Id] = es;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TemplatesReferenceName(IEnumerable<Template> templates, string name)
|
||||
{
|
||||
foreach (var t in templates)
|
||||
{
|
||||
foreach (var s in t.Scripts)
|
||||
{
|
||||
if (ContainsIdentifier(s.Code, name)) return true;
|
||||
}
|
||||
foreach (var a in t.Attributes)
|
||||
{
|
||||
if (a.DataSourceReference is not null && ContainsIdentifier(a.DataSourceReference, name)) return true;
|
||||
if (a.Value is not null && ContainsIdentifier(a.Value, name)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Substring scan with word-boundary guarding: the name must not be a partial
|
||||
/// match of a longer identifier. Cheap, dependency-free, sufficient for the
|
||||
/// v1 export-time scan (callers can always over-include with explicit ids).
|
||||
/// </summary>
|
||||
private static bool ContainsIdentifier(string? haystack, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name)) return false;
|
||||
var idx = 0;
|
||||
while (true)
|
||||
{
|
||||
var hit = haystack.IndexOf(name, idx, StringComparison.Ordinal);
|
||||
if (hit < 0) return false;
|
||||
var before = hit == 0 ? '\0' : haystack[hit - 1];
|
||||
var afterIdx = hit + name.Length;
|
||||
var after = afterIdx >= haystack.Length ? '\0' : haystack[afterIdx];
|
||||
if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) return true;
|
||||
idx = hit + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
|
||||
|
||||
// ---- Folder ancestor chain ----
|
||||
private async Task<IReadOnlyList<TemplateFolder>> ResolveFolderChainAsync(
|
||||
IEnumerable<Template> templates,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allFolders = await _templates.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||||
var byId = allFolders.ToDictionary(f => f.Id);
|
||||
|
||||
var needed = new Dictionary<int, TemplateFolder>();
|
||||
foreach (var t in templates)
|
||||
{
|
||||
var fid = t.FolderId;
|
||||
while (fid is not null && byId.TryGetValue(fid.Value, out var folder))
|
||||
{
|
||||
if (!needed.TryAdd(folder.Id, folder)) break; // already walked this chain
|
||||
fid = folder.ParentFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
// Root-first ordering so importers create parents before children.
|
||||
return needed.Values
|
||||
.OrderBy(f => DepthOf(f, byId))
|
||||
.ThenBy(f => f.SortOrder)
|
||||
.ThenBy(f => f.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int DepthOf(TemplateFolder folder, IReadOnlyDictionary<int, TemplateFolder> byId)
|
||||
{
|
||||
var depth = 0;
|
||||
var current = folder;
|
||||
while (current.ParentFolderId is int pid && byId.TryGetValue(pid, out var parent))
|
||||
{
|
||||
depth++;
|
||||
current = parent;
|
||||
if (depth > 1024) break; // defensive: folder graph is acyclic by schema
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// ---- Kahn's algorithm: order templates base-before-derived ----
|
||||
/// <summary>Sorts templates in base-before-derived order using Kahn's algorithm.</summary>
|
||||
/// <param name="templates">The templates to sort; must form an acyclic composition graph.</param>
|
||||
/// <returns>Templates sorted so composed (base) templates appear before their composing (derived) templates.</returns>
|
||||
internal static List<Template> TopologicallySortTemplates(IEnumerable<Template> templates)
|
||||
{
|
||||
// Edge: composed --> composing (composed must appear first).
|
||||
var nodes = templates.ToDictionary(t => t.Id);
|
||||
var inDegree = nodes.ToDictionary(kv => kv.Key, _ => 0);
|
||||
var outEdges = nodes.ToDictionary(kv => kv.Key, _ => new List<int>());
|
||||
|
||||
foreach (var t in nodes.Values)
|
||||
{
|
||||
foreach (var comp in t.Compositions)
|
||||
{
|
||||
if (!nodes.ContainsKey(comp.ComposedTemplateId)) continue;
|
||||
outEdges[comp.ComposedTemplateId].Add(t.Id);
|
||||
inDegree[t.Id]++;
|
||||
}
|
||||
}
|
||||
|
||||
var queue = new Queue<int>(
|
||||
inDegree.Where(kv => kv.Value == 0)
|
||||
.OrderBy(kv => nodes[kv.Key].Name, StringComparer.Ordinal)
|
||||
.Select(kv => kv.Key));
|
||||
|
||||
var result = new List<Template>(nodes.Count);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
result.Add(nodes[id]);
|
||||
foreach (var next in outEdges[id].OrderBy(x => nodes[x].Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (--inDegree[next] == 0) queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count != nodes.Count)
|
||||
throw new InvalidOperationException("Template composition graph is cyclic");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ManifestContentEntry> BuildContentManifest(
|
||||
IReadOnlyList<TemplateFolder> folders,
|
||||
IReadOnlyList<Template> templates,
|
||||
IEnumerable<SharedScript> sharedScripts,
|
||||
IEnumerable<ExternalSystemDefinition> externalSystems,
|
||||
IEnumerable<ExternalSystemMethod> externalSystemMethods,
|
||||
IEnumerable<DatabaseConnectionDefinition> dbConnections,
|
||||
IEnumerable<NotificationList> notificationLists,
|
||||
IEnumerable<SmtpConfiguration> smtpConfigs,
|
||||
IEnumerable<ApiKey> apiKeys,
|
||||
IEnumerable<ApiMethod> apiMethods)
|
||||
{
|
||||
var entries = new List<ManifestContentEntry>();
|
||||
|
||||
foreach (var f in folders)
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("TemplateFolder", f.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var t in templates)
|
||||
{
|
||||
var deps = t.Compositions
|
||||
.Select(c => $"Template:{(templates.FirstOrDefault(x => x.Id == c.ComposedTemplateId)?.Name ?? c.ComposedTemplateId.ToString())}")
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
entries.Add(new ManifestContentEntry("Template", t.Name, 1, deps));
|
||||
}
|
||||
foreach (var s in sharedScripts.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("SharedScript", s.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var es in externalSystems.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("ExternalSystem", es.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
var methodsBySystem = externalSystemMethods.GroupBy(m => m.ExternalSystemDefinitionId);
|
||||
var systemById = externalSystems.ToDictionary(x => x.Id);
|
||||
foreach (var grp in methodsBySystem.OrderBy(g => systemById.TryGetValue(g.Key, out var es) ? es.Name : string.Empty, StringComparer.Ordinal))
|
||||
{
|
||||
var systemName = systemById.TryGetValue(grp.Key, out var es) ? es.Name : grp.Key.ToString();
|
||||
foreach (var m in grp.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry(
|
||||
"ExternalSystemMethod",
|
||||
$"{systemName}.{m.Name}",
|
||||
1,
|
||||
new[] { $"ExternalSystem:{systemName}" }));
|
||||
}
|
||||
}
|
||||
foreach (var d in dbConnections.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("DatabaseConnection", d.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var n in notificationLists.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("NotificationList", n.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var s in smtpConfigs.OrderBy(x => x.Host, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var k in apiKeys.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("ApiKey", k.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Output of <see cref="DependencyResolver.ResolveAsync"/> — the full closure of
|
||||
/// entities that need to land in a bundle for the given <see cref="ExportSelection"/>,
|
||||
/// along with a stable, manifest-ready <c>ContentManifest</c>. Templates are
|
||||
/// topologically ordered (base before derived) so the importer can apply them
|
||||
/// in-order without further sorting.
|
||||
/// </summary>
|
||||
public sealed record ResolvedExport(
|
||||
IReadOnlyList<TemplateFolder> TemplateFolders,
|
||||
IReadOnlyList<Template> Templates,
|
||||
IReadOnlyList<SharedScript> SharedScripts,
|
||||
IReadOnlyList<ExternalSystemDefinition> ExternalSystems,
|
||||
IReadOnlyList<ExternalSystemMethod> ExternalSystemMethods,
|
||||
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
|
||||
IReadOnlyList<NotificationList> NotificationLists,
|
||||
IReadOnlyList<SmtpConfiguration> SmtpConfigs,
|
||||
IReadOnlyList<ApiKey> ApiKeys,
|
||||
IReadOnlyList<ApiMethod> ApiMethods,
|
||||
IReadOnlyList<ManifestContentEntry> ContentManifest);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Packs <see cref="BundleContentDto"/> + <see cref="BundleManifest"/> into a
|
||||
/// ZIP archive (<c>manifest.json</c> always, plus either <c>content.json</c>
|
||||
/// or <c>content.enc</c>) and reverses the operation on read.
|
||||
///
|
||||
/// When packing with a passphrase, the caller must have already encrypted the
|
||||
/// content bytes upstream and built a manifest whose <see cref="EncryptionMetadata"/>
|
||||
/// + <c>ContentHash</c> describe those exact bytes. <see cref="Pack"/> uses the
|
||||
/// manifest as source of truth: it re-serializes the content (or re-encrypts
|
||||
/// with the manifest's salt/iv when needed) so the zip stays internally
|
||||
/// consistent. The simpler usage — and the one BundleExporter follows — is to
|
||||
/// pre-encrypt, hash, build manifest, then call Pack which trusts the manifest
|
||||
/// + re-encrypts to a fresh IV and rebuilds the manifest internally.
|
||||
/// </summary>
|
||||
public sealed class BundleSerializer
|
||||
{
|
||||
private const string ManifestEntryName = "manifest.json";
|
||||
private const string ContentJsonEntryName = "content.json";
|
||||
private const string ContentEncEntryName = "content.enc";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the bundle content to its canonical JSON byte form. Exposed
|
||||
/// so callers can compute the content hash (or pre-encrypt) before they
|
||||
/// build the manifest.
|
||||
/// </summary>
|
||||
/// <param name="content">The bundle content DTO to serialize.</param>
|
||||
public byte[] SerializeContentBytes(BundleContentDto content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(content, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs bundle content and manifest into a ZIP stream, optionally encrypting the content with the supplied passphrase.
|
||||
/// </summary>
|
||||
/// <param name="content">The bundle content to pack.</param>
|
||||
/// <param name="manifest">The bundle manifest describing the content and encryption metadata.</param>
|
||||
/// <param name="passphrase">Passphrase used for encryption; null produces a plaintext bundle.</param>
|
||||
/// <param name="encryptor">Encryptor required when <paramref name="passphrase"/> is supplied.</param>
|
||||
public Stream Pack(
|
||||
BundleContentDto content,
|
||||
BundleManifest manifest,
|
||||
string? passphrase,
|
||||
BundleSecretEncryptor? encryptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
if (passphrase is not null && encryptor is null)
|
||||
{
|
||||
throw new ArgumentException("Encryptor is required when a passphrase is supplied.", nameof(encryptor));
|
||||
}
|
||||
if (passphrase is not null && manifest.Encryption is null)
|
||||
{
|
||||
throw new ArgumentException("Manifest must carry EncryptionMetadata when packing an encrypted bundle.", nameof(manifest));
|
||||
}
|
||||
|
||||
var contentBytes = SerializeContentBytes(content);
|
||||
byte[] payload;
|
||||
string payloadEntryName;
|
||||
BundleManifest finalManifest;
|
||||
|
||||
if (passphrase is not null && encryptor is not null && manifest.Encryption is not null)
|
||||
{
|
||||
// Fresh encryption — re-derives salt/iv. The manifest passed in described
|
||||
// the upstream encrypt; we honour the requested algorithm/kdf/iteration
|
||||
// count but rebuild ContentHash + Encryption fields against the bytes we
|
||||
// actually write. The non-encryption manifest fields (source env, exported
|
||||
// by, summary, contents, version) are preserved verbatim.
|
||||
//
|
||||
// T-005: bind the manifest's non-derivative fields into the AES-GCM AAD
|
||||
// so a tampered SourceEnvironment / ExportedBy / etc. on a stolen bundle
|
||||
// yields an authentication-tag mismatch on decrypt instead of a forged
|
||||
// origin label slipping past the Step-4 confirmation gate. AAD is
|
||||
// computed over a manifest normalised to empty ContentHash + null
|
||||
// Encryption (those fields are derivative of the ciphertext / IV and
|
||||
// cannot themselves be authenticated).
|
||||
var aad = BundleManifestAad.Compute(manifest);
|
||||
var (cipher, freshMeta) = encryptor.Encrypt(
|
||||
contentBytes, passphrase, manifest.Encryption.Iterations, aad);
|
||||
payload = cipher;
|
||||
payloadEntryName = ContentEncEntryName;
|
||||
finalManifest = manifest with
|
||||
{
|
||||
Encryption = freshMeta,
|
||||
ContentHash = "sha256:" + Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(cipher)).ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
payload = contentBytes;
|
||||
payloadEntryName = ContentJsonEntryName;
|
||||
// Plaintext path: trust the supplied manifest's ContentHash. The caller
|
||||
// built it via ManifestBuilder against the same SerializeContentBytes
|
||||
// output (JSON is deterministic for a given DTO + JsonOptions).
|
||||
finalManifest = manifest;
|
||||
}
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(finalManifest, JsonOptions);
|
||||
var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
WriteEntry(archive, ManifestEntryName, manifestBytes);
|
||||
WriteEntry(archive, payloadEntryName, payload);
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and deserializes the manifest entry from a bundle ZIP stream.
|
||||
/// </summary>
|
||||
/// <param name="zipStream">The ZIP stream containing the bundle.</param>
|
||||
public BundleManifest ReadManifest(Stream zipStream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zipStream);
|
||||
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.GetEntry(ManifestEntryName)
|
||||
?? throw new InvalidDataException("Bundle is missing manifest.json.");
|
||||
|
||||
using var es = entry.Open();
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(es, JsonOptions)
|
||||
?? throw new InvalidDataException("manifest.json deserialized to null.");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw bytes from <c>content.enc</c> if present, otherwise from
|
||||
/// <c>content.json</c>. The caller decrypts + JSON-deserializes via
|
||||
/// <see cref="UnpackContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="zipStream">The ZIP stream containing the bundle.</param>
|
||||
/// <param name="manifest">The bundle manifest used to determine which content entry to read.</param>
|
||||
public byte[] ReadContentBytes(Stream zipStream, BundleManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zipStream);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
// Prefer the encrypted variant when both are somehow present.
|
||||
var entry = archive.GetEntry(ContentEncEntryName) ?? archive.GetEntry(ContentJsonEntryName)
|
||||
?? throw new InvalidDataException("Bundle is missing both content.enc and content.json.");
|
||||
|
||||
using var es = entry.Open();
|
||||
using var ms = new MemoryStream();
|
||||
es.CopyTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts (if encrypted) and deserializes raw content bytes into a <see cref="BundleContentDto"/>.
|
||||
/// </summary>
|
||||
/// <param name="contentBytes">Raw bytes read from the bundle ZIP entry.</param>
|
||||
/// <param name="manifest">The bundle manifest providing encryption metadata.</param>
|
||||
/// <param name="passphrase">Passphrase for decryption; required when the manifest indicates encryption.</param>
|
||||
/// <param name="encryptor">Encryptor used for decryption; required when the manifest indicates encryption.</param>
|
||||
public BundleContentDto UnpackContent(
|
||||
byte[] contentBytes,
|
||||
BundleManifest manifest,
|
||||
string? passphrase,
|
||||
BundleSecretEncryptor? encryptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contentBytes);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
byte[] plaintext;
|
||||
if (manifest.Encryption is not null)
|
||||
{
|
||||
if (passphrase is null || encryptor is null)
|
||||
{
|
||||
throw new ArgumentException("Encrypted bundle requires both passphrase and encryptor.");
|
||||
}
|
||||
// T-005: mirror the encrypt side — AAD is derived from the manifest's
|
||||
// non-derivative fields. Tampering yields an authentication-tag mismatch.
|
||||
var aad = BundleManifestAad.Compute(manifest);
|
||||
plaintext = encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
|
||||
}
|
||||
else
|
||||
{
|
||||
plaintext = contentBytes;
|
||||
}
|
||||
|
||||
var content = JsonSerializer.Deserialize<BundleContentDto>(plaintext, JsonOptions)
|
||||
?? throw new InvalidDataException("content payload deserialized to null.");
|
||||
return content;
|
||||
}
|
||||
|
||||
private static void WriteEntry(ZipArchive archive, string entryName, byte[] payload)
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
using var es = entry.Open();
|
||||
es.Write(payload, 0, payload.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory aggregate of all bundle-eligible Commons entities. Matches the
|
||||
/// shape of <see cref="BundleContentDto"/> but uses the real persistence-
|
||||
/// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces
|
||||
/// on the application side of the bundle boundary.
|
||||
/// </summary>
|
||||
public sealed record EntityAggregate(
|
||||
IReadOnlyList<TemplateFolder> TemplateFolders,
|
||||
IReadOnlyList<Template> Templates,
|
||||
IReadOnlyList<SharedScript> SharedScripts,
|
||||
IReadOnlyList<ExternalSystemDefinition> ExternalSystems,
|
||||
IReadOnlyList<ExternalSystemMethod> ExternalSystemMethods,
|
||||
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
|
||||
IReadOnlyList<NotificationList> NotificationLists,
|
||||
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
|
||||
IReadOnlyList<ApiKey> ApiKeys,
|
||||
IReadOnlyList<ApiMethod> ApiMethods);
|
||||
|
||||
/// <summary>
|
||||
/// Top-level serializable bundle payload. Lists are sequenced in dependency
|
||||
/// order so importers can apply them inline. Lists are never null on the wire
|
||||
/// — empty arrays are preferred over nulls so JSON consumers can rely on each
|
||||
/// property being present.
|
||||
/// </summary>
|
||||
public sealed record BundleContentDto(
|
||||
IReadOnlyList<TemplateFolderDto> TemplateFolders,
|
||||
IReadOnlyList<TemplateDto> Templates,
|
||||
IReadOnlyList<SharedScriptDto> SharedScripts,
|
||||
IReadOnlyList<ExternalSystemDto> ExternalSystems,
|
||||
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
|
||||
IReadOnlyList<NotificationListDto> NotificationLists,
|
||||
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
|
||||
IReadOnlyList<ApiKeyDto> ApiKeys,
|
||||
IReadOnlyList<ApiMethodDto> ApiMethods);
|
||||
|
||||
/// <summary>
|
||||
/// Carved-off secret values for an entity. The outer DTO carries all non-
|
||||
/// sensitive fields; secrets land here so a future "share without secrets"
|
||||
/// export mode can drop this block without touching anything else.
|
||||
/// </summary>
|
||||
public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
|
||||
|
||||
public sealed record TemplateFolderDto(
|
||||
string Name,
|
||||
string? ParentName,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record TemplateDto(
|
||||
string Name,
|
||||
string? FolderName,
|
||||
string? BaseTemplateName,
|
||||
string? Description,
|
||||
IReadOnlyList<TemplateAttributeDto> Attributes,
|
||||
IReadOnlyList<TemplateAlarmDto> Alarms,
|
||||
IReadOnlyList<TemplateScriptDto> Scripts,
|
||||
IReadOnlyList<TemplateCompositionDto> Compositions);
|
||||
|
||||
public sealed record TemplateAttributeDto(
|
||||
string Name,
|
||||
string? Value,
|
||||
DataType DataType,
|
||||
bool IsLocked,
|
||||
string? Description,
|
||||
string? DataSourceReference);
|
||||
|
||||
public sealed record TemplateAlarmDto(
|
||||
string Name,
|
||||
string? Description,
|
||||
int PriorityLevel,
|
||||
AlarmTriggerType TriggerType,
|
||||
string? TriggerConfiguration,
|
||||
bool IsLocked,
|
||||
string? OnTriggerScriptName);
|
||||
|
||||
public sealed record TemplateScriptDto(
|
||||
string Name,
|
||||
string Code,
|
||||
string? TriggerType,
|
||||
string? TriggerConfiguration,
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition,
|
||||
bool IsLocked,
|
||||
TimeSpan? MinTimeBetweenRuns);
|
||||
|
||||
public sealed record TemplateCompositionDto(
|
||||
string InstanceName,
|
||||
string ComposedTemplateName);
|
||||
|
||||
public sealed record SharedScriptDto(
|
||||
string Name,
|
||||
string Code,
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition);
|
||||
|
||||
public sealed record ExternalSystemDto(
|
||||
string Name,
|
||||
string BaseUrl,
|
||||
string AuthType,
|
||||
int MaxRetries,
|
||||
TimeSpan RetryDelay,
|
||||
IReadOnlyList<ExternalSystemMethodDto> Methods,
|
||||
SecretsBlock? Secrets);
|
||||
|
||||
public sealed record ExternalSystemMethodDto(
|
||||
string Name,
|
||||
string HttpMethod,
|
||||
string Path,
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition);
|
||||
|
||||
public sealed record DatabaseConnectionDto(
|
||||
string Name,
|
||||
int MaxRetries,
|
||||
TimeSpan RetryDelay,
|
||||
SecretsBlock? Secrets);
|
||||
|
||||
public sealed record NotificationListDto(
|
||||
string Name,
|
||||
NotificationType Type,
|
||||
IReadOnlyList<NotificationRecipientDto> Recipients);
|
||||
|
||||
public sealed record NotificationRecipientDto(
|
||||
string Name,
|
||||
string EmailAddress);
|
||||
|
||||
public sealed record SmtpConfigDto(
|
||||
string Host,
|
||||
int Port,
|
||||
string AuthType,
|
||||
string FromAddress,
|
||||
string? TlsMode,
|
||||
int ConnectionTimeoutSeconds,
|
||||
int MaxConcurrentConnections,
|
||||
int MaxRetries,
|
||||
TimeSpan RetryDelay,
|
||||
SecretsBlock? Secrets);
|
||||
|
||||
public sealed record ApiKeyDto(
|
||||
string Name,
|
||||
string KeyHash,
|
||||
bool IsEnabled,
|
||||
SecretsBlock? Secrets);
|
||||
|
||||
public sealed record ApiMethodDto(
|
||||
string Name,
|
||||
string Script,
|
||||
string? ApprovedApiKeyIds,
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition,
|
||||
int TimeoutSeconds);
|
||||
@@ -0,0 +1,372 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between the persistence-shaped <see cref="EntityAggregate"/> and
|
||||
/// the wire-shaped <see cref="BundleContentDto"/>. The conversion strips EF
|
||||
/// identity (FK ints) and references inter-entity links by name so bundles
|
||||
/// remain portable across environments. Secrets are carved into a per-entity
|
||||
/// <see cref="SecretsBlock"/> so the bundle's secret-free public surface can
|
||||
/// be inspected (or, in future, exported without secrets) without touching the
|
||||
/// rest of the payload.
|
||||
/// </summary>
|
||||
public sealed class EntitySerializer
|
||||
{
|
||||
/// <summary>Converts a persistence-shaped <see cref="EntityAggregate"/> to the portable wire-shaped <see cref="BundleContentDto"/>.</summary>
|
||||
/// <param name="aggregate">The in-memory aggregate to serialize.</param>
|
||||
/// <returns>A <see cref="BundleContentDto"/> with identity-stripped, name-linked entity representations.</returns>
|
||||
public BundleContentDto ToBundleContent(EntityAggregate aggregate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(aggregate);
|
||||
|
||||
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
|
||||
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
|
||||
|
||||
return new BundleContentDto(
|
||||
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
||||
Name: f.Name,
|
||||
ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
|
||||
SortOrder: f.SortOrder)).ToList(),
|
||||
Templates: aggregate.Templates.Select(t =>
|
||||
{
|
||||
// Build per-template script-id → name lookup once so the alarm
|
||||
// projection below resolves OnTriggerScriptId by name in O(1).
|
||||
// Scripts can only target sibling scripts on the same template
|
||||
// (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
|
||||
// so we don't need a global script index.
|
||||
var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
|
||||
return new TemplateDto(
|
||||
Name: t.Name,
|
||||
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
|
||||
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
|
||||
Description: t.Description,
|
||||
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
|
||||
Name: a.Name,
|
||||
Value: a.Value,
|
||||
DataType: a.DataType,
|
||||
IsLocked: a.IsLocked,
|
||||
Description: a.Description,
|
||||
DataSourceReference: a.DataSourceReference)).ToList(),
|
||||
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
PriorityLevel: a.PriorityLevel,
|
||||
TriggerType: a.TriggerType,
|
||||
TriggerConfiguration: a.TriggerConfiguration,
|
||||
IsLocked: a.IsLocked,
|
||||
// Carry the on-trigger script by NAME — the importer resolves
|
||||
// this back to a script id once the parent template's scripts
|
||||
// have been persisted and assigned ids. If the FK doesn't
|
||||
// resolve in this aggregate (e.g. corrupt/orphan row), the
|
||||
// name comes through as null and the importer leaves the
|
||||
// FK null on the imported alarm.
|
||||
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null)).ToList(),
|
||||
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
TriggerType: s.TriggerType,
|
||||
TriggerConfiguration: s.TriggerConfiguration,
|
||||
ParameterDefinitions: s.ParameterDefinitions,
|
||||
ReturnDefinition: s.ReturnDefinition,
|
||||
IsLocked: s.IsLocked,
|
||||
MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(),
|
||||
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
||||
InstanceName: c.InstanceName,
|
||||
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
|
||||
}).ToList(),
|
||||
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
ParameterDefinitions: s.ParameterDefinitions,
|
||||
ReturnDefinition: s.ReturnDefinition)).ToList(),
|
||||
ExternalSystems: aggregate.ExternalSystems.Select(sys =>
|
||||
{
|
||||
var methods = aggregate.ExternalSystemMethods
|
||||
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
|
||||
.Select(m => new ExternalSystemMethodDto(
|
||||
Name: m.Name,
|
||||
HttpMethod: m.HttpMethod,
|
||||
Path: m.Path,
|
||||
ParameterDefinitions: m.ParameterDefinitions,
|
||||
ReturnDefinition: m.ReturnDefinition))
|
||||
.ToList();
|
||||
|
||||
SecretsBlock? secrets = null;
|
||||
if (!string.IsNullOrEmpty(sys.AuthConfiguration))
|
||||
{
|
||||
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["AuthConfiguration"] = sys.AuthConfiguration,
|
||||
});
|
||||
}
|
||||
|
||||
return new ExternalSystemDto(
|
||||
Name: sys.Name,
|
||||
BaseUrl: sys.EndpointUrl,
|
||||
AuthType: sys.AuthType,
|
||||
MaxRetries: sys.MaxRetries,
|
||||
RetryDelay: sys.RetryDelay,
|
||||
Methods: methods,
|
||||
Secrets: secrets);
|
||||
}).ToList(),
|
||||
DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
|
||||
Name: dc.Name,
|
||||
MaxRetries: dc.MaxRetries,
|
||||
RetryDelay: dc.RetryDelay,
|
||||
// Connection strings typically embed credentials inline; treat the entire
|
||||
// string as secret rather than try to parse out the password fragment.
|
||||
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["ConnectionString"] = dc.ConnectionString,
|
||||
}))).ToList(),
|
||||
NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
|
||||
Name: nl.Name,
|
||||
Type: nl.Type,
|
||||
Recipients: nl.Recipients.Select(r => new NotificationRecipientDto(
|
||||
Name: r.Name,
|
||||
EmailAddress: r.EmailAddress)).ToList())).ToList(),
|
||||
SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
|
||||
{
|
||||
SecretsBlock? secrets = null;
|
||||
if (!string.IsNullOrEmpty(smtp.Credentials))
|
||||
{
|
||||
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Credentials"] = smtp.Credentials,
|
||||
});
|
||||
}
|
||||
|
||||
return new SmtpConfigDto(
|
||||
Host: smtp.Host,
|
||||
Port: smtp.Port,
|
||||
AuthType: smtp.AuthType,
|
||||
FromAddress: smtp.FromAddress,
|
||||
TlsMode: smtp.TlsMode,
|
||||
ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
|
||||
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
||||
MaxRetries: smtp.MaxRetries,
|
||||
RetryDelay: smtp.RetryDelay,
|
||||
Secrets: secrets);
|
||||
}).ToList(),
|
||||
// ApiKey stores only KeyHash already; no plaintext to carve. SecretsBlock
|
||||
// stays null per design — KeyHash is on the public DTO.
|
||||
ApiKeys: aggregate.ApiKeys.Select(k => new ApiKeyDto(
|
||||
Name: k.Name,
|
||||
KeyHash: k.KeyHash,
|
||||
IsEnabled: k.IsEnabled,
|
||||
Secrets: null)).ToList(),
|
||||
ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
|
||||
Name: m.Name,
|
||||
Script: m.Script,
|
||||
ApprovedApiKeyIds: m.ApprovedApiKeyIds,
|
||||
ParameterDefinitions: m.ParameterDefinitions,
|
||||
ReturnDefinition: m.ReturnDefinition,
|
||||
TimeoutSeconds: m.TimeoutSeconds)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
|
||||
/// <param name="content">The bundle content DTO to deserialize.</param>
|
||||
/// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
|
||||
public EntityAggregate FromBundleContent(BundleContentDto content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
|
||||
var folders = content.TemplateFolders
|
||||
.Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
|
||||
.ToList();
|
||||
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
|
||||
for (var i = 0; i < folders.Count; i++)
|
||||
{
|
||||
var parentName = content.TemplateFolders[i].ParentName;
|
||||
if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
|
||||
{
|
||||
folders[i].ParentFolderId = parentId;
|
||||
}
|
||||
}
|
||||
|
||||
// Templates: same pattern — assign ids, second pass resolves base + folder.
|
||||
var templates = content.Templates
|
||||
.Select((dto, ix) =>
|
||||
{
|
||||
var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
|
||||
foreach (var a in dto.Attributes)
|
||||
{
|
||||
t.Attributes.Add(new TemplateAttribute(a.Name)
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType,
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
});
|
||||
}
|
||||
foreach (var al in dto.Alarms)
|
||||
{
|
||||
t.Alarms.Add(new TemplateAlarm(al.Name)
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
Description = al.Description,
|
||||
PriorityLevel = al.PriorityLevel,
|
||||
TriggerType = al.TriggerType,
|
||||
TriggerConfiguration = al.TriggerConfiguration,
|
||||
IsLocked = al.IsLocked,
|
||||
});
|
||||
}
|
||||
foreach (var s in dto.Scripts)
|
||||
{
|
||||
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
TriggerType = s.TriggerType,
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
IsLocked = s.IsLocked,
|
||||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||||
});
|
||||
}
|
||||
return t;
|
||||
})
|
||||
.ToList();
|
||||
var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
|
||||
for (var i = 0; i < templates.Count; i++)
|
||||
{
|
||||
var dto = content.Templates[i];
|
||||
if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
|
||||
{
|
||||
templates[i].FolderId = fid;
|
||||
}
|
||||
if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
|
||||
{
|
||||
templates[i].ParentTemplateId = btid;
|
||||
}
|
||||
foreach (var compDto in dto.Compositions)
|
||||
{
|
||||
var comp = new TemplateComposition(compDto.InstanceName)
|
||||
{
|
||||
TemplateId = templates[i].Id,
|
||||
};
|
||||
if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
|
||||
{
|
||||
comp.ComposedTemplateId = ctid;
|
||||
}
|
||||
templates[i].Compositions.Add(comp);
|
||||
}
|
||||
}
|
||||
|
||||
var sharedScripts = content.SharedScripts
|
||||
.Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
|
||||
{
|
||||
Id = ix + 1,
|
||||
ParameterDefinitions = dto.ParameterDefinitions,
|
||||
ReturnDefinition = dto.ReturnDefinition,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var externalSystems = new List<ExternalSystemDefinition>();
|
||||
var externalSystemMethods = new List<ExternalSystemMethod>();
|
||||
for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
|
||||
{
|
||||
var dto = content.ExternalSystems[ix];
|
||||
var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
|
||||
{
|
||||
Id = ix + 1,
|
||||
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
||||
MaxRetries = dto.MaxRetries,
|
||||
RetryDelay = dto.RetryDelay,
|
||||
};
|
||||
externalSystems.Add(sys);
|
||||
foreach (var m in dto.Methods)
|
||||
{
|
||||
externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
|
||||
{
|
||||
ExternalSystemDefinitionId = sys.Id,
|
||||
ParameterDefinitions = m.ParameterDefinitions,
|
||||
ReturnDefinition = m.ReturnDefinition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var databaseConnections = content.DatabaseConnections
|
||||
.Select((dto, ix) =>
|
||||
{
|
||||
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
|
||||
return new DatabaseConnectionDefinition(dto.Name, connStr)
|
||||
{
|
||||
Id = ix + 1,
|
||||
MaxRetries = dto.MaxRetries,
|
||||
RetryDelay = dto.RetryDelay,
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var notificationLists = content.NotificationLists
|
||||
.Select((dto, ix) =>
|
||||
{
|
||||
var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
|
||||
foreach (var r in dto.Recipients)
|
||||
{
|
||||
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)
|
||||
{
|
||||
NotificationListId = list.Id,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var smtpConfigurations = content.SmtpConfigs
|
||||
.Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
|
||||
{
|
||||
Id = ix + 1,
|
||||
Port = dto.Port,
|
||||
Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
|
||||
TlsMode = dto.TlsMode,
|
||||
ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
|
||||
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
||||
MaxRetries = dto.MaxRetries,
|
||||
RetryDelay = dto.RetryDelay,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var apiKeys = content.ApiKeys
|
||||
.Select((dto, ix) =>
|
||||
{
|
||||
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
|
||||
key.Id = ix + 1;
|
||||
key.IsEnabled = dto.IsEnabled;
|
||||
return key;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var apiMethods = content.ApiMethods
|
||||
.Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
|
||||
{
|
||||
Id = ix + 1,
|
||||
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
|
||||
ParameterDefinitions = dto.ParameterDefinitions,
|
||||
ReturnDefinition = dto.ReturnDefinition,
|
||||
TimeoutSeconds = dto.TimeoutSeconds,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new EntityAggregate(
|
||||
TemplateFolders: folders,
|
||||
Templates: templates,
|
||||
SharedScripts: sharedScripts,
|
||||
ExternalSystems: externalSystems,
|
||||
ExternalSystemMethods: externalSystemMethods,
|
||||
DatabaseConnections: databaseConnections,
|
||||
NotificationLists: notificationLists,
|
||||
SmtpConfigurations: smtpConfigurations,
|
||||
ApiKeys: apiKeys,
|
||||
ApiMethods: apiMethods);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="BundleManifest"/> for a freshly serialized bundle.
|
||||
/// Stamps the current format/schema version, captures the SHA-256 hash of the
|
||||
/// raw (post-encryption) content bytes, and copies through the supplied summary
|
||||
/// and content entries verbatim.
|
||||
/// </summary>
|
||||
public sealed class ManifestBuilder
|
||||
{
|
||||
public const int CurrentBundleFormatVersion = 1;
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="BundleManifest"/> with the current format version, a SHA-256 content hash,
|
||||
/// and all supplied metadata.
|
||||
/// </summary>
|
||||
/// <param name="sourceEnvironment">Environment label identifying where the bundle was exported from.</param>
|
||||
/// <param name="exportedBy">Username of the operator who performed the export.</param>
|
||||
/// <param name="scadaLinkVersion">ScadaBridge version string stamped in the manifest.</param>
|
||||
/// <param name="encryption">Encryption metadata when the content is encrypted; null for plain bundles.</param>
|
||||
/// <param name="summary">High-level summary of artifact counts.</param>
|
||||
/// <param name="contents">Per-entry content table describing each artifact in the bundle.</param>
|
||||
/// <param name="contentBytes">Raw content bytes whose SHA-256 hash is computed and stamped.</param>
|
||||
/// <returns>A fully populated <see cref="BundleManifest"/>.</returns>
|
||||
public BundleManifest Build(
|
||||
string sourceEnvironment,
|
||||
string exportedBy,
|
||||
string scadaLinkVersion,
|
||||
EncryptionMetadata? encryption,
|
||||
BundleSummary summary,
|
||||
IReadOnlyList<ManifestContentEntry> contents,
|
||||
byte[] contentBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceEnvironment);
|
||||
ArgumentNullException.ThrowIfNull(exportedBy);
|
||||
ArgumentNullException.ThrowIfNull(scadaLinkVersion);
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
ArgumentNullException.ThrowIfNull(contents);
|
||||
ArgumentNullException.ThrowIfNull(contentBytes);
|
||||
|
||||
var contentHash = "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
|
||||
|
||||
return new BundleManifest(
|
||||
BundleFormatVersion: CurrentBundleFormatVersion,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow,
|
||||
SourceEnvironment: sourceEnvironment,
|
||||
ExportedBy: exportedBy,
|
||||
ScadaBridgeVersion: scadaLinkVersion,
|
||||
ContentHash: contentHash,
|
||||
Encryption: encryption,
|
||||
Summary: summary,
|
||||
Contents: contents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Security.Cryptography;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of validating a <see cref="BundleManifest"/> against the supplied
|
||||
/// raw content bytes. Distinct values let the importer surface a precise
|
||||
/// rejection reason to the operator.
|
||||
/// </summary>
|
||||
public enum ManifestValidationResult
|
||||
{
|
||||
Ok,
|
||||
UnsupportedFormatVersion,
|
||||
ContentHashMismatch,
|
||||
MalformedManifest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a deserialized manifest plus the raw content bytes recovered from
|
||||
/// the bundle ZIP and reports the first integrity failure (or <see cref="ManifestValidationResult.Ok"/>).
|
||||
/// </summary>
|
||||
public sealed class ManifestValidator
|
||||
{
|
||||
/// <summary>Validates a deserialized bundle manifest against the raw content bytes and returns the first integrity failure, or <see cref="ManifestValidationResult.Ok"/>.</summary>
|
||||
/// <param name="manifest">The deserialized manifest to validate.</param>
|
||||
/// <param name="contentBytes">The raw content bytes extracted from the bundle ZIP.</param>
|
||||
/// <returns>A <see cref="ManifestValidationResult"/> indicating success or the specific failure kind.</returns>
|
||||
public ManifestValidationResult Validate(BundleManifest manifest, byte[] contentBytes)
|
||||
{
|
||||
if (manifest is null || contentBytes is null)
|
||||
{
|
||||
return ManifestValidationResult.MalformedManifest;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(manifest.SourceEnvironment) || manifest.Contents is null)
|
||||
{
|
||||
return ManifestValidationResult.MalformedManifest;
|
||||
}
|
||||
|
||||
if (manifest.BundleFormatVersion != ManifestBuilder.CurrentBundleFormatVersion)
|
||||
{
|
||||
return ManifestValidationResult.UnsupportedFormatVersion;
|
||||
}
|
||||
|
||||
var expected = "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
|
||||
if (!string.Equals(expected, manifest.ContentHash, StringComparison.Ordinal))
|
||||
{
|
||||
return ManifestValidationResult.ContentHashMismatch;
|
||||
}
|
||||
|
||||
return ManifestValidationResult.Ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public const string OptionsSection = "ScadaBridge:Transport";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Transport component services: encryptor, manifest builder/validator, serializers, resolver, exporter, importer, and session store.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddTransport(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Pipeline building blocks: stateless services live as singletons; the
|
||||
// resolver and exporter are scoped because they reach into per-request
|
||||
// repository scopes and the scoped DbContext.
|
||||
services.AddSingleton<BundleSecretEncryptor>();
|
||||
services.AddSingleton<ManifestBuilder>();
|
||||
services.AddSingleton<ManifestValidator>();
|
||||
services.AddSingleton<BundleSerializer>();
|
||||
services.AddSingleton<EntitySerializer>();
|
||||
services.AddScoped<DependencyResolver>();
|
||||
services.AddScoped<IBundleExporter, BundleExporter>();
|
||||
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
|
||||
// T-004: per-IP-per-hour unlock rate limiter — design doc §11. Singleton
|
||||
// so the trailing-hour window is shared across every importer scope; the
|
||||
// counters live in-memory and reset on host restart (by design).
|
||||
services.AddSingleton<BundleUnlockRateLimiter>();
|
||||
// T-007: periodic eviction sweep so abandoned sessions clear without
|
||||
// needing a fresh Get() to trigger lazy eviction.
|
||||
services.AddHostedService<BundleSessionEvictionService>();
|
||||
// SemanticValidator is a stateless utility used by ApplyAsync; use
|
||||
// TryAdd so a host that already calls AddTemplateEngine() (which
|
||||
// registers the same type as Transient) wins. Either registration
|
||||
// satisfies the BundleImporter constructor.
|
||||
services.TryAddTransient<SemanticValidator>();
|
||||
services.AddScoped<IBundleImporter, BundleImporter>();
|
||||
// Remaining concrete services added in later tasks.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport;
|
||||
|
||||
public sealed class TransportOptions
|
||||
{
|
||||
/// <summary>Gets or sets the TTL in minutes for an in-progress import session.</summary>
|
||||
public int BundleSessionTtlMinutes { get; set; } = 30;
|
||||
/// <summary>Gets or sets the maximum allowed bundle size in megabytes.</summary>
|
||||
public int MaxBundleSizeMb { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// T-006: maximum allowed decompressed size of any single zip entry in megabytes.
|
||||
/// A 100 MB DEFLATE-compressed bundle can decompress to gigabytes; this cap
|
||||
/// stops a malicious bundle from OOM-ing the central node before its entries
|
||||
/// are decompressed.
|
||||
/// </summary>
|
||||
public int MaxBundleEntryDecompressedMb { get; set; } = 200;
|
||||
/// <summary>
|
||||
/// T-006: maximum permitted number of entries inside a bundle zip. A well-formed
|
||||
/// bundle has exactly two (<c>manifest.json</c> plus <c>content.json</c> or
|
||||
/// <c>content.enc</c>); a small upper bound limits the surface a zip-bomb can
|
||||
/// exploit without rejecting future schema additions out of hand.
|
||||
/// </summary>
|
||||
public int MaxBundleEntryCount { get; set; } = 4;
|
||||
/// <summary>
|
||||
/// T-006: maximum permitted compression ratio (uncompressed length / compressed
|
||||
/// length) per zip entry. Defence-in-depth against decompression bombs whose
|
||||
/// declared <see cref="System.IO.Compression.ZipArchiveEntry.Length"/> is
|
||||
/// trustworthy on read; legitimate JSON compresses around 5–10x, so 50x has
|
||||
/// generous headroom for unusually compressible bundles.
|
||||
/// </summary>
|
||||
public int MaxBundleEntryCompressionRatio { get; set; } = 50;
|
||||
/// <summary>Gets or sets the maximum number of failed passphrase unlock attempts before a session is locked.</summary>
|
||||
public int MaxUnlockAttemptsPerSession { get; set; } = 3;
|
||||
/// <summary>Gets or sets the maximum number of unlock attempts allowed per IP address per hour.</summary>
|
||||
public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10;
|
||||
/// <summary>Gets or sets the PBKDF2 iteration count used for passphrase key derivation.</summary>
|
||||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||||
/// <summary>Gets or sets the major version of the bundle schema this instance produces and accepts.</summary>
|
||||
public int SchemaVersionMajor { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name of the cluster/environment producing bundles. Stamped
|
||||
/// into <c>BundleManifest.SourceEnvironment</c> and surfaced in the export
|
||||
/// filename (<c>scadabundle-{SourceEnvironment}-{yyyy-MM-dd-HHmmss}.scadabundle</c>).
|
||||
/// Bound from <c>Transport:SourceEnvironment</c> in <c>appsettings*.json</c>;
|
||||
/// the default placeholder is fine for single-cluster deployments.
|
||||
/// </summary>
|
||||
public string SourceEnvironment { get; set; } = "scadabridge";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Transport.Tests" />
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user