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; /// /// Three-phase bundle importer: validates the /// bundle envelope (manifest + content hash + decryption) and opens a /// session; diffs the bundle's DTOs against the /// current target database; 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. /// 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 _options; private readonly TimeProvider _timeProvider; public BundleImporter( BundleSerializer bundleSerializer, ManifestValidator manifestValidator, BundleSecretEncryptor encryptor, EntitySerializer entitySerializer, IBundleSessionStore sessionStore, IOptions 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)); } /// /// Validates the bundle envelope and opens a session keyed by a fresh GUID. /// Wrong-passphrase failures surface as /// so the caller (UI / API endpoint) can increment the lockout counter on /// the returned session — this method does not mutate FailedUnlockAttempts /// itself because the session does not exist yet at the point of failure. /// public async Task 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); } /// /// 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 /// 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. /// public async Task 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(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(); // ---- 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(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? 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); } /// /// 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 DependencyResolver; 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. /// private async Task> DetectBlockersAsync(BundleContentDto content, CancellationToken ct) { var blockers = new List(); // 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(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(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(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 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 ApplyAsync( Guid sessionId, IReadOnlyList resolutions, string user, CancellationToken ct = default) { // Filled in by T17. throw new NotImplementedException("ApplyAsync is implemented by task T17."); } }