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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user