diff --git a/src/ScadaLink.Transport/Export/BundleExporter.cs b/src/ScadaLink.Transport/Export/BundleExporter.cs
new file mode 100644
index 0000000..8658d7d
--- /dev/null
+++ b/src/ScadaLink.Transport/Export/BundleExporter.cs
@@ -0,0 +1,160 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Options;
+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.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 ScadaLinkDbContext _dbContext;
+ private readonly IOptions _options;
+
+ public BundleExporter(
+ DependencyResolver resolver,
+ EntitySerializer entitySerializer,
+ ManifestBuilder manifestBuilder,
+ BundleSecretEncryptor encryptor,
+ BundleSerializer bundleSerializer,
+ IAuditService auditService,
+ ScadaLinkDbContext 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);
+
+ // 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;
+ }
+
+ ///
+ /// 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();
+ }
+}
diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
index bf0f7be..50fee11 100644
--- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
@@ -2,8 +2,10 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Transport;
+using ScadaLink.Transport.Encryption;
using ScadaLink.Transport.Export;
using ScadaLink.Transport.Import;
+using ScadaLink.Transport.Serialization;
namespace ScadaLink.Transport;
@@ -16,7 +18,17 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
services.AddOptions().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();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton();
// Remaining concrete services added in later tasks.
return services;
diff --git a/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs b/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs
new file mode 100644
index 0000000..8361a8d
--- /dev/null
+++ b/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs
@@ -0,0 +1,282 @@
+using System.Security.Cryptography;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.Commons.Entities.ExternalSystems;
+using ScadaLink.Commons.Entities.Notifications;
+using ScadaLink.Commons.Entities.Scripts;
+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.ConfigurationDatabase.Repositories;
+using ScadaLink.ConfigurationDatabase.Services;
+using ScadaLink.Transport;
+using ScadaLink.Transport.Encryption;
+using ScadaLink.Transport.Serialization;
+
+namespace ScadaLink.Transport.IntegrationTests.Export;
+
+///
+/// Integration tests for .
+/// Builds a self-contained DI container (in-memory EF + the repositories +
+/// AddTransport's pipeline) and asserts the exporter round-trips a real bundle
+/// + writes the matching audit row. Using
+/// directly rather than WebApplicationFactory keeps the test focused on
+/// the exporter — no HTTP, no Akka, no LDAP.
+///
+public sealed class BundleExporterTests : IDisposable
+{
+ private readonly ServiceProvider _provider;
+
+ public BundleExporterTests()
+ {
+ var services = new ServiceCollection();
+
+ // AddTransport's BindConfiguration call needs an IConfiguration in the
+ // container — an empty one is fine for these tests because the defaults
+ // baked into TransportOptions are exactly what we want.
+ services.AddSingleton(
+ new ConfigurationBuilder().AddInMemoryCollection().Build());
+
+ // In-memory EF — fresh database per test instance so seeded ids are
+ // predictable and the audit assertions can scan the whole table. The
+ // db name has to be resolved ONCE (outside the lambda) so every scope
+ // resolves the same underlying InMemoryStore — otherwise each scope's
+ // DbContext gets a new GUID and the seeded rows vanish between scopes.
+ var dbName = $"BundleExporterTests_{Guid.NewGuid()}";
+ services.AddDbContext(opts =>
+ opts.UseInMemoryDatabase(dbName));
+
+ // Repositories the resolver pulls from.
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Audit pipeline — AuditService writes via the EF context + reads the
+ // bundle-import correlation id from the scoped context (null here, since
+ // export is not part of an import session).
+ services.AddScoped();
+ services.AddScoped();
+
+ // Add the transport pipeline itself.
+ services.AddTransport();
+
+ _provider = services.BuildServiceProvider();
+ }
+
+ public void Dispose() => _provider.Dispose();
+
+ [Fact]
+ public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
+ {
+ // Arrange: seed two templates (one composing the other), a shared
+ // script referenced from the parent template, one external system, one
+ // notification list. Names live in the script bodies / DataSourceReference
+ // so the dependency resolver's substring scan picks them up.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+
+ var helper = new SharedScript("HelperFn", "return 1;");
+ ctx.SharedScripts.Add(helper);
+
+ var erp = new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey");
+ ctx.ExternalSystemDefinitions.Add(erp);
+
+ var list = new NotificationList("OnCall");
+ ctx.NotificationLists.Add(list);
+ await ctx.SaveChangesAsync();
+
+ var baseTemplate = new Template("BaseDevice")
+ {
+ Description = "shared base",
+ };
+ // Attribute body references the helper + external system, so
+ // IncludeDependencies=true pulls them in automatically.
+ baseTemplate.Scripts.Add(new TemplateScript("init",
+ "var x = HelperFn(); ErpSystem.Call(\"x\");"));
+ ctx.Templates.Add(baseTemplate);
+ await ctx.SaveChangesAsync();
+
+ var composing = new Template("Pump")
+ {
+ Description = "composes BaseDevice",
+ };
+ ctx.Templates.Add(composing);
+ await ctx.SaveChangesAsync();
+
+ composing.Compositions.Add(new TemplateComposition("base")
+ {
+ TemplateId = composing.Id,
+ ComposedTemplateId = baseTemplate.Id,
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ // Act
+ Stream bundleStream;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var exporter = scope.ServiceProvider.GetRequiredService();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
+
+ var selection = new ExportSelection(
+ TemplateIds: templateIds,
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiKeyIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: true);
+
+ bundleStream = await exporter.ExportAsync(
+ selection, user: "alice", sourceEnvironment: "dev",
+ passphrase: null, cancellationToken: CancellationToken.None);
+ }
+
+ // Copy to byte[] so we can re-open the zip multiple times below.
+ byte[] bundleBytes;
+ using (var ms = new MemoryStream())
+ {
+ await bundleStream.CopyToAsync(ms);
+ bundleBytes = ms.ToArray();
+ }
+
+ // Assert: non-empty, valid zip with the expected manifest + content.
+ Assert.NotEmpty(bundleBytes);
+
+ var serializer = _provider.GetRequiredService();
+ BundleManifest manifest;
+ using (var ms = new MemoryStream(bundleBytes, writable: false))
+ {
+ manifest = serializer.ReadManifest(ms);
+ }
+ Assert.Equal("dev", manifest.SourceEnvironment);
+ Assert.Equal("alice", manifest.ExportedBy);
+ Assert.Null(manifest.Encryption);
+ Assert.Equal(2, manifest.Summary.Templates);
+ Assert.Equal(1, manifest.Summary.SharedScripts);
+ Assert.Equal(1, manifest.Summary.ExternalSystems);
+
+ byte[] rawContent;
+ using (var ms = new MemoryStream(bundleBytes, writable: false))
+ {
+ rawContent = serializer.ReadContentBytes(ms, manifest);
+ }
+ var content = serializer.UnpackContent(rawContent, manifest, passphrase: null, encryptor: null);
+ Assert.Equal(2, content.Templates.Count);
+ Assert.Contains(content.Templates, t => t.Name == "BaseDevice");
+ Assert.Contains(content.Templates, t => t.Name == "Pump");
+ Assert.Single(content.SharedScripts);
+ Assert.Equal("HelperFn", content.SharedScripts[0].Name);
+ Assert.Single(content.ExternalSystems);
+ Assert.Equal("ErpSystem", content.ExternalSystems[0].Name);
+
+ // Audit row landed with the expected shape — Action discriminates the
+ // unencrypted export path, EntityId is the SHA-256 of the zip bytes.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var entry = await ctx.AuditLogEntries.SingleAsync();
+ Assert.Equal("UnencryptedBundleExport", entry.Action);
+ Assert.Equal("alice", entry.User);
+ Assert.Equal("Bundle", entry.EntityType);
+ Assert.Equal("dev", entry.EntityName);
+
+ var expectedHash = "sha256:" + Convert.ToHexString(SHA256.HashData(bundleBytes))
+ .ToLowerInvariant();
+ Assert.Equal(expectedHash, entry.EntityId);
+ }
+ }
+
+ [Fact]
+ public async Task ExportAsync_with_passphrase_produces_encrypted_bundle()
+ {
+ // Arrange: minimal template only — we want to exercise the encryption
+ // path, not the dependency resolver.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ ctx.Templates.Add(new Template("Solo") { Description = "alone" });
+ await ctx.SaveChangesAsync();
+ }
+
+ // Act
+ Stream bundleStream;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var exporter = scope.ServiceProvider.GetRequiredService();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
+ var selection = new ExportSelection(
+ TemplateIds: ids,
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiKeyIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: true);
+
+ bundleStream = await exporter.ExportAsync(
+ selection, user: "bob", sourceEnvironment: "stg",
+ passphrase: "correct horse battery staple",
+ cancellationToken: CancellationToken.None);
+ }
+
+ byte[] bundleBytes;
+ using (var ms = new MemoryStream())
+ {
+ await bundleStream.CopyToAsync(ms);
+ bundleBytes = ms.ToArray();
+ }
+
+ // Assert: manifest carries fresh encryption metadata; wrong passphrase
+ // throws (AES-GCM tag mismatch); audit action is "BundleExported".
+ var serializer = _provider.GetRequiredService();
+ var encryptor = _provider.GetRequiredService();
+ BundleManifest manifest;
+ byte[] rawContent;
+ using (var ms = new MemoryStream(bundleBytes, writable: false))
+ {
+ manifest = serializer.ReadManifest(ms);
+ }
+ using (var ms = new MemoryStream(bundleBytes, writable: false))
+ {
+ rawContent = serializer.ReadContentBytes(ms, manifest);
+ }
+ Assert.NotNull(manifest.Encryption);
+ Assert.Equal("AES-256-GCM", manifest.Encryption!.Algorithm);
+ Assert.False(string.IsNullOrEmpty(manifest.Encryption.SaltB64));
+ Assert.False(string.IsNullOrEmpty(manifest.Encryption.IvB64));
+
+ // Wrong passphrase fails AES-GCM tag verification — the runtime throws
+ // the more specific AuthenticationTagMismatchException (a CryptographicException
+ // subclass on .NET 10), so ThrowsAny is correct here.
+ Assert.ThrowsAny(() =>
+ serializer.UnpackContent(rawContent, manifest, "wrong", encryptor));
+
+ // Right passphrase unpacks back to the seeded template.
+ var content = serializer.UnpackContent(
+ rawContent, manifest, "correct horse battery staple", encryptor);
+ Assert.Single(content.Templates);
+ Assert.Equal("Solo", content.Templates[0].Name);
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var entry = await ctx.AuditLogEntries.SingleAsync();
+ Assert.Equal("BundleExported", entry.Action);
+ Assert.Equal("bob", entry.User);
+ Assert.Equal("stg", entry.EntityName);
+ }
+ }
+}