435 lines
20 KiB
C#
435 lines
20 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using Microsoft.Extensions.Options;
|
||
using ScadaLink.Commons.Entities.Templates;
|
||
using ScadaLink.Commons.Interfaces.Repositories;
|
||
using ScadaLink.Commons.Interfaces.Services;
|
||
using ScadaLink.Commons.Interfaces.Transport;
|
||
using ScadaLink.Commons.Types.Transport;
|
||
using ScadaLink.ConfigurationDatabase;
|
||
using ScadaLink.Transport.Encryption;
|
||
using ScadaLink.Transport.Serialization;
|
||
|
||
namespace ScadaLink.Transport.Import;
|
||
|
||
/// <summary>
|
||
/// Three-phase bundle importer: <see cref="LoadAsync"/> validates the
|
||
/// bundle envelope (manifest + content hash + decryption) and opens a
|
||
/// session; <see cref="PreviewAsync"/> diffs the bundle's DTOs against the
|
||
/// current target database; <see cref="ApplyAsync"/> writes the chosen
|
||
/// resolutions through the audited repositories. Only LoadAsync is
|
||
/// implemented in this slice — the other two are wired into DI now so
|
||
/// follow-up tasks can fill them in without churning the constructor.
|
||
/// </summary>
|
||
public sealed class BundleImporter : IBundleImporter
|
||
{
|
||
private static readonly JsonSerializerOptions ContentJsonOptions = new()
|
||
{
|
||
WriteIndented = false,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||
Converters = { new JsonStringEnumConverter() },
|
||
};
|
||
|
||
private readonly BundleSerializer _bundleSerializer;
|
||
private readonly ManifestValidator _manifestValidator;
|
||
private readonly BundleSecretEncryptor _encryptor;
|
||
private readonly ArtifactDiff _diff = new();
|
||
#pragma warning disable IDE0052 // wired-in dependencies for T17.
|
||
private readonly EntitySerializer _entitySerializer;
|
||
private readonly IAuditService _auditService;
|
||
private readonly IAuditCorrelationContext _correlationContext;
|
||
private readonly ScadaLinkDbContext _dbContext;
|
||
#pragma warning restore IDE0052
|
||
private readonly ITemplateEngineRepository _templateRepo;
|
||
private readonly IExternalSystemRepository _externalRepo;
|
||
private readonly INotificationRepository _notificationRepo;
|
||
private readonly IInboundApiRepository _inboundApiRepo;
|
||
private readonly IBundleSessionStore _sessionStore;
|
||
private readonly IOptions<TransportOptions> _options;
|
||
private readonly TimeProvider _timeProvider;
|
||
|
||
public BundleImporter(
|
||
BundleSerializer bundleSerializer,
|
||
ManifestValidator manifestValidator,
|
||
BundleSecretEncryptor encryptor,
|
||
EntitySerializer entitySerializer,
|
||
IBundleSessionStore sessionStore,
|
||
IOptions<TransportOptions> options,
|
||
TimeProvider timeProvider,
|
||
ITemplateEngineRepository templateRepo,
|
||
IExternalSystemRepository externalRepo,
|
||
INotificationRepository notificationRepo,
|
||
IInboundApiRepository inboundApiRepo,
|
||
IAuditService auditService,
|
||
IAuditCorrelationContext correlationContext,
|
||
ScadaLinkDbContext dbContext)
|
||
{
|
||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
|
||
_encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
|
||
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
|
||
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
|
||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||
_templateRepo = templateRepo ?? throw new ArgumentNullException(nameof(templateRepo));
|
||
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
|
||
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
|
||
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
|
||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the bundle envelope and opens a session keyed by a fresh GUID.
|
||
/// Wrong-passphrase failures surface as <see cref="CryptographicException"/>
|
||
/// so the caller (UI / API endpoint) can increment the lockout counter on
|
||
/// the returned session — this method does not mutate <c>FailedUnlockAttempts</c>
|
||
/// itself because the session does not exist yet at the point of failure.
|
||
/// </summary>
|
||
public async Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(bundleStream);
|
||
|
||
// Copy to a seekable buffer — manifest + content readers each open a
|
||
// fresh ZipArchive over the same bytes, so the upstream stream needs to
|
||
// be seekable. A caller-supplied FileStream is seekable but a Kestrel
|
||
// request stream is not, so we always normalise to MemoryStream.
|
||
var ms = new MemoryStream();
|
||
await bundleStream.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||
ms.Position = 0;
|
||
|
||
// Size cap is in MB; multiply in long arithmetic so the comparison
|
||
// doesn't overflow at the int boundary for large MaxBundleSizeMb.
|
||
var maxBytes = _options.Value.MaxBundleSizeMb * 1024L * 1024L;
|
||
if (ms.Length > maxBytes)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Bundle exceeds maximum allowed size of {_options.Value.MaxBundleSizeMb} MB.");
|
||
}
|
||
|
||
BundleManifest manifest;
|
||
try
|
||
{
|
||
ms.Position = 0;
|
||
manifest = _bundleSerializer.ReadManifest(ms);
|
||
}
|
||
catch (InvalidDataException)
|
||
{
|
||
// Preserve the serializer's specific "manifest missing/null" message
|
||
// — the caller wants to surface a precise diagnostic to the operator.
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw new InvalidDataException("Bundle is missing or has a malformed manifest.json.", ex);
|
||
}
|
||
|
||
ms.Position = 0;
|
||
var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest);
|
||
|
||
// Validate format version + content-hash + manifest shape. Reject paths
|
||
// surface as distinct exceptions so the UI can disambiguate the cause.
|
||
var validation = _manifestValidator.Validate(manifest, contentBytes);
|
||
switch (validation)
|
||
{
|
||
case ManifestValidationResult.UnsupportedFormatVersion:
|
||
throw new NotSupportedException(
|
||
$"Bundle format version {manifest.BundleFormatVersion} is not supported by this cluster.");
|
||
case ManifestValidationResult.ContentHashMismatch:
|
||
throw new InvalidDataException(
|
||
"Bundle content hash does not match manifest — file may be corrupt.");
|
||
case ManifestValidationResult.MalformedManifest:
|
||
throw new InvalidDataException("Bundle manifest is malformed.");
|
||
case ManifestValidationResult.Ok:
|
||
break;
|
||
default:
|
||
throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
|
||
}
|
||
|
||
// Decrypt when the manifest carries EncryptionMetadata. AES-GCM tag
|
||
// mismatch surfaces as a CryptographicException (or its
|
||
// AuthenticationTagMismatchException subclass on .NET 10+) — bubble it
|
||
// unchanged so the caller can detect wrong-passphrase via type check
|
||
// and increment the lockout counter on the (about-to-be-rejected)
|
||
// session reference. The session is not opened on the failure path.
|
||
byte[] decryptedContent;
|
||
if (manifest.Encryption is not null)
|
||
{
|
||
if (string.IsNullOrEmpty(passphrase))
|
||
{
|
||
throw new ArgumentException(
|
||
"Passphrase required for encrypted bundle.", nameof(passphrase));
|
||
}
|
||
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase);
|
||
}
|
||
else
|
||
{
|
||
decryptedContent = contentBytes;
|
||
}
|
||
|
||
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
|
||
var session = new BundleSession
|
||
{
|
||
SessionId = Guid.NewGuid(),
|
||
Manifest = manifest,
|
||
DecryptedContent = decryptedContent,
|
||
ExpiresAt = _timeProvider.GetUtcNow() + ttl,
|
||
};
|
||
return _sessionStore.Open(session);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Diffs every artifact in the loaded bundle against the current target
|
||
/// database. Lookups are name-keyed (the bundle is portable across
|
||
/// environments so FK ids never line up). Emits <see cref="ConflictKind.Blocker"/>
|
||
/// items when a bundled template references a SharedScript or ExternalSystem
|
||
/// that is in neither the bundle nor the target — that import would crash at
|
||
/// runtime, so we surface it in the preview UI before Apply.
|
||
/// </summary>
|
||
public async Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
|
||
{
|
||
var session = _sessionStore.Get(sessionId)
|
||
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
|
||
if (session.Locked)
|
||
{
|
||
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
|
||
}
|
||
|
||
BundleContentDto content;
|
||
try
|
||
{
|
||
content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
|
||
?? throw new InvalidDataException("Session content deserialized to null.");
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
|
||
}
|
||
|
||
var items = new List<ImportPreviewItem>();
|
||
|
||
// ---- TemplateFolders ----
|
||
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||
var folderByName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
|
||
var folderNameById = allFolders.ToDictionary(f => f.Id, f => f.Name);
|
||
foreach (var fDto in content.TemplateFolders)
|
||
{
|
||
folderByName.TryGetValue(fDto.Name, out var existing);
|
||
items.Add(_diff.CompareTemplateFolder(fDto, existing, folderNameById));
|
||
}
|
||
|
||
// ---- Templates ----
|
||
// Repos only expose GetTemplateByIdAsync / GetAllTemplatesAsync — no
|
||
// by-name lookup. Pull all once and index by name for the diff loop.
|
||
var allTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
var hydratedByName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||
foreach (var stub in allTemplates)
|
||
{
|
||
// GetAllTemplatesAsync may not eager-load children — fetch the
|
||
// children-loaded variant for any name that matches an incoming DTO
|
||
// so the per-child diff loop sees the full collection.
|
||
if (content.Templates.Any(t => string.Equals(t.Name, stub.Name, StringComparison.Ordinal)))
|
||
{
|
||
var hydrated = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
|
||
if (hydrated is not null)
|
||
{
|
||
hydratedByName[stub.Name] = hydrated;
|
||
}
|
||
}
|
||
}
|
||
foreach (var tDto in content.Templates)
|
||
{
|
||
hydratedByName.TryGetValue(tDto.Name, out var existing);
|
||
items.Add(_diff.CompareTemplate(tDto, existing));
|
||
}
|
||
|
||
// ---- SharedScripts ----
|
||
foreach (var s in content.SharedScripts)
|
||
{
|
||
var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareSharedScript(s, existing));
|
||
}
|
||
|
||
// ---- ExternalSystems (+ their methods) ----
|
||
foreach (var es in content.ExternalSystems)
|
||
{
|
||
var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
|
||
IReadOnlyList<Commons.Entities.ExternalSystems.ExternalSystemMethod>? methods = null;
|
||
if (existing is not null)
|
||
{
|
||
methods = await _externalRepo.GetMethodsByExternalSystemIdAsync(existing.Id, ct).ConfigureAwait(false);
|
||
}
|
||
items.Add(_diff.CompareExternalSystem(es, existing, methods));
|
||
}
|
||
|
||
// ---- DatabaseConnections ----
|
||
foreach (var db in content.DatabaseConnections)
|
||
{
|
||
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareDatabaseConnection(db, existing));
|
||
}
|
||
|
||
// ---- NotificationLists ----
|
||
foreach (var nl in content.NotificationLists)
|
||
{
|
||
var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareNotificationList(nl, existing));
|
||
}
|
||
|
||
// ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
|
||
var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
|
||
var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
|
||
foreach (var sm in content.SmtpConfigs)
|
||
{
|
||
smtpByHost.TryGetValue(sm.Host, out var existing);
|
||
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
|
||
}
|
||
|
||
// ---- ApiKeys (no by-name lookup — scan GetAll) ----
|
||
var allApiKeys = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
|
||
var apiKeyByName = allApiKeys.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
|
||
foreach (var k in content.ApiKeys)
|
||
{
|
||
apiKeyByName.TryGetValue(k.Name, out var existing);
|
||
items.Add(_diff.CompareApiKey(k, existing));
|
||
}
|
||
|
||
// ---- ApiMethods ----
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareApiMethod(m, existing));
|
||
}
|
||
|
||
// ---- Blocker detection ----
|
||
items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false));
|
||
|
||
return new ImportPreview(sessionId, items);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Surfaces unresolved cross-entity references: a TemplateScript or
|
||
/// ApiMethod body that name-mentions a SharedScript or ExternalSystem that
|
||
/// is in neither the bundle nor the target database. We reuse the same
|
||
/// substring-with-word-boundary scan as <c>DependencyResolver</c>; the
|
||
/// implementations are kept in lockstep but not factored out yet because
|
||
/// the resolver's scan operates on entity Code while the importer's scan
|
||
/// operates on DTO Code — same algorithm, different inputs.
|
||
/// </summary>
|
||
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(BundleContentDto content, CancellationToken ct)
|
||
{
|
||
var blockers = new List<ImportPreviewItem>();
|
||
|
||
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
|
||
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||
|
||
var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
|
||
foreach (var s in allSharedScripts) sharedScriptNames.Add(s.Name);
|
||
|
||
var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var e in content.ExternalSystems) externalSystemNames.Add(e.Name);
|
||
foreach (var e in allExternalSystems) externalSystemNames.Add(e.Name);
|
||
|
||
// Heuristic: collect a small candidate vocabulary of identifiers used
|
||
// by the bundle's scripts that are NOT one of the known-good names, and
|
||
// check whether each one was previously a SharedScript or ExternalSystem
|
||
// (i.e. matches the naming-convention shape of an identifier reference).
|
||
// For v1, we walk the SharedScripts the bundle *expects* — by scanning
|
||
// bodies and reporting any identifier-shaped token that resolves to a
|
||
// SharedScript by historical record... but that's overreach.
|
||
//
|
||
// Simpler + sufficient v1: scan every script body in the bundle's
|
||
// templates + ApiMethods, and for each occurrence of "Name(" where
|
||
// Name is a valid identifier, if Name appears in NEITHER set, surface
|
||
// it as a Blocker. This catches the documented use-case
|
||
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
|
||
var referencedFromBundle = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var t in content.Templates)
|
||
{
|
||
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
|
||
foreach (var a in t.Attributes)
|
||
{
|
||
CollectCallIdentifiers(a.Value, referencedFromBundle);
|
||
CollectCallIdentifiers(a.DataSourceReference, referencedFromBundle);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
CollectCallIdentifiers(m.Script, referencedFromBundle);
|
||
}
|
||
|
||
// For each candidate, only report it as a blocker if it looks like a
|
||
// resource reference (PascalCase, length > 1) AND it's not present
|
||
// anywhere we can satisfy it. We deliberately do not look at language
|
||
// keywords or stdlib helpers — the test surface only ever uses
|
||
// well-named identifiers.
|
||
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
|
||
{
|
||
if (!LooksLikeResourceName(candidate)) continue;
|
||
var isShared = sharedScriptNames.Contains(candidate);
|
||
var isExternal = externalSystemNames.Contains(candidate);
|
||
if (isShared || isExternal) continue;
|
||
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: "Reference",
|
||
Name: candidate,
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: ConflictKind.Blocker,
|
||
FieldDiffJson: null,
|
||
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
|
||
}
|
||
|
||
return blockers;
|
||
}
|
||
|
||
private static void CollectCallIdentifiers(string? body, HashSet<string> sink)
|
||
{
|
||
if (string.IsNullOrEmpty(body)) return;
|
||
// Find every "Identifier(" or "Identifier." occurrence. The boundary
|
||
// before the identifier must NOT be an identifier char so we don't
|
||
// match the trailing portion of a longer token.
|
||
for (var i = 0; i < body.Length; i++)
|
||
{
|
||
if (!IsIdentifierStart(body[i])) continue;
|
||
if (i > 0 && IsIdentifierChar(body[i - 1])) continue;
|
||
var start = i;
|
||
while (i < body.Length && IsIdentifierChar(body[i])) i++;
|
||
if (i >= body.Length) break;
|
||
var trailing = body[i];
|
||
if (trailing == '(' || trailing == '.')
|
||
{
|
||
sink.Add(body[start..i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool LooksLikeResourceName(string name)
|
||
{
|
||
if (name.Length < 2) return false;
|
||
if (!char.IsUpper(name[0])) return false;
|
||
for (var i = 1; i < name.Length; i++)
|
||
{
|
||
if (!IsIdentifierChar(name[i])) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
|
||
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
|
||
|
||
public Task<ImportResult> ApplyAsync(
|
||
Guid sessionId,
|
||
IReadOnlyList<ImportResolution> resolutions,
|
||
string user,
|
||
CancellationToken ct = default)
|
||
{
|
||
// Filled in by T17.
|
||
throw new NotImplementedException("ApplyAsync is implemented by task T17.");
|
||
}
|
||
}
|