using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; 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; /// /// 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 Action column. The returned stream is positioned /// at 0 so callers can immediately copy to a response/file. /// 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 _options; /// Initializes the exporter with all pipeline collaborators. /// Resolves template dependency graphs and transitive entities. /// Converts EF entities to wire-shaped bundle DTOs. /// Builds the bundle manifest including hash and summary. /// Encrypts bundle secrets when a passphrase is provided. /// Serializes and compresses the bundle into a ZIP stream. /// Records one audit row per export operation. /// Database context for reading export-time entity state. /// Transport options (e.g., format version). public BundleExporter( DependencyResolver resolver, EntitySerializer entitySerializer, ManifestBuilder manifestBuilder, BundleSecretEncryptor encryptor, BundleSerializer bundleSerializer, IAuditService auditService, ScadaBridgeDbContext dbContext, IOptions 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)); } /// public async Task 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); // 1b. Areas travel by NAME (the importer resolves-or-creates them under the // target site), so we only need an id→name lookup for the AreaIds the // exported instances actually reference — a single query over Areas keyed // to those FKs. Without this the serializer would emit AreaName: null and // the imported instance would lose its area assignment. var referencedAreaIds = resolved.Instances .Select(i => i.AreaId) .Where(id => id is not null) .Select(id => id!.Value) .Distinct() .ToList(); var areaNameById = referencedAreaIds.Count == 0 ? new Dictionary() : await _dbContext.Areas .Where(a => referencedAreaIds.Contains(a.Id)) .ToDictionaryAsync(a => a.Id, a => a.Name, cancellationToken) .ConfigureAwait(false); // 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name). // The resolver's site/dataConnection/instance closure is wired // through via object-initializer — without this the aggregate would // carry the empty defaults and the bundle would ship empty site/ // instance arrays even when those were selected (review item I3). 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, // Inbound API keys are not transported between environments. ApiMethods: resolved.ApiMethods) { Sites = resolved.Sites, DataConnections = resolved.DataConnections, Instances = resolved.Instances, // Wire the resolver's SMS config closure through the // aggregate. Without this the aggregate carries the empty default and the // bundle ships an empty smsConfigs array even when SMS configs were // selected — mirrors the site/instance fix (review item I3). SmsConfigurations = resolved.SmsConfigs, // Area id→name lookup for the exported instances — feeds the serializer's // AreaName projection so each instance's area travels by name. AreaNameById = areaNameById, }; 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, ApiMethods: resolved.ApiMethods.Count, // Additive site/dataConnection/instance counts. Sourced from // the serialized content DTO — the same arrays the importer reads — // so the manifest summary always matches the packed payload. These // arrays default to empty until B1/B2 populate them, so the counts // are 0 today and become correct once those waves land. Sites: contentDto.Sites.Count, DataConnections: contentDto.DataConnections.Count, Instances: contentDto.Instances.Count, // Additive count sourced from the serialized content DTO — the // same array the importer reads — so the manifest summary always matches // the packed payload. SmsConfigs: contentDto.SmsConfigs.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); // Review 2026-10-21 04/P8a: serialise the content graph ONCE — the same bytes // seed the manifest hash and are what Pack writes (or encrypts). var contentBytes = _bundleSerializer.SerializeContentBytes(contentDto); var templateManifest = _manifestBuilder.Build( sourceEnvironment: sourceEnvironment, exportedBy: user, scadaBridgeVersion: 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: contentBytes); // 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps // EncryptionMetadata (fresh salt/iv) + ContentHash. var zipStream = _bundleSerializer.PackContentBytes(contentBytes, 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; } /// /// Hashes the zip stream's bytes (resets position before + after) so the audit /// row's EntityId uniquely identifies this exact bundle without leaking /// any of the manifest's internal hashes (which describe content, not the zip). /// 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(); } }