From 1bc98e10a15f64f86b8f00ba0e6b3266a12dde07 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 03:43:18 -0400 Subject: [PATCH] docs(plans): add Transport (Component #24) implementation plan 30-task plan covering ScadaLink.Transport project, AES-256-GCM bundle encryption, IAuditCorrelationContext for BundleImportId threading, TreeView checkbox-selection mode + TemplateFolderTree wrapper, two Central UI wizard pages, EF migration, integration tests, README + cross-reference updates. Single shipping slice, no feature flag. --- docs/plans/2026-05-24-transport.md | 1665 +++++++++++++++++ docs/plans/2026-05-24-transport.md.tasks.json | 36 + 2 files changed, 1701 insertions(+) create mode 100644 docs/plans/2026-05-24-transport.md create mode 100644 docs/plans/2026-05-24-transport.md.tasks.json diff --git a/docs/plans/2026-05-24-transport.md b/docs/plans/2026-05-24-transport.md new file mode 100644 index 0000000..cc0aacf --- /dev/null +++ b/docs/plans/2026-05-24-transport.md @@ -0,0 +1,1665 @@ +# Transport (Component #24) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Ship Component #24 (Transport) — file-based, encrypted bundle export/import of templates, system artifacts, and central-only configuration via the Central UI — as a single shipping slice. + +**Architecture:** New `ScadaLink.Transport` project peer to Template Engine + Deployment Manager. Two new Central UI pages under the Design nav group. Persistence flows through existing audited repositories with a new scoped `IAuditCorrelationContext` carrying `BundleImportId`. AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) for content encryption. No site changes. + +**Tech Stack:** .NET 10, EF Core, ASP.NET Core / Blazor Server, xUnit + NSubstitute + FluentAssertions, in-memory EF for integration tests, existing `TreeView.razor` component extended with a checkbox-selection mode. + +**Source design doc:** `docs/plans/2026-05-24-transport-design.md` (already committed). + +**Key codebase facts that shape this plan:** +- Audit entity is `AuditLogEntry` (`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), DbSet `_context.AuditLogEntries`. +- `IAuditService.LogAsync(user, action, entityType, entityId, entityName, afterState, ct)` — no existing correlation parameter; we add a scoped `IAuditCorrelationContext` rather than changing every repository signature. +- **No persisted `DeployedRevisionHash` on Instance.** `DeploymentService.CompareAsync` computes stale-vs-fresh live by comparing `snapshot.RevisionHash` to the current flattened-config hash. Overwriting a template naturally changes the hash, so the Deployments page surfaces affected instances automatically — **no explicit stale-mark write is required.** +- `TreeView.razor` exists at `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` with single-select navigation. We extend it with a checkbox / multi-select mode rather than building a parallel widget. +- DI extension convention: `AddTransport()` in `ServiceCollectionExtensions.cs`, registered in `src/ScadaLink.Host/Program.cs` under "Central-only components". +- Test integration pattern: `ScadaLinkWebApplicationFactory : WebApplicationFactory` with in-memory EF. + +**No feature flag** — backward compatible additive change; users who never open the new pages see no behavior difference. + +--- + +## Task Index + +| # | Task | Class | Time | +|---|---|---|---| +| 0 | Bundle DTOs in Commons | small | ~3 min | +| 1 | Transport interfaces | small | ~3 min | +| 2 | `BundleImportId` on `AuditLogEntry` + EF Fluent config | small | ~3 min | +| 3 | EF migration `AddBundleImportIdToAuditLog` | trivial | ~2 min | +| 4 | `IAuditCorrelationContext` scoped service | small | ~3 min | +| 5 | `AuditService` reads correlation context | small | ~3 min | +| 6 | `ScadaLink.Transport` project skeleton + slnx + DI | small | ~3 min | +| 7 | `TransportOptions` | trivial | ~2 min | +| 8 | `BundleSecretEncryptor` (AES-256-GCM + PBKDF2) + tests | standard | ~4 min | +| 9 | `ManifestBuilder` + manifest schema validation | standard | ~4 min | +| 10 | `EntitySerializer` — DTOs + secret carving | standard | ~5 min | +| 11 | `BundleSerializer` (ZIP packer + reader + content hash) | standard | ~4 min | +| 12 | `DependencyResolver` + topology tests | standard | ~5 min | +| 13 | `BundleSessionStore` (TTL + 3-strike lockout) | standard | ~4 min | +| 14 | `BundleExporter.ExportAsync` + audit + tests | standard | ~5 min | +| 15 | `BundleImporter.LoadAsync` | standard | ~4 min | +| 16 | `BundleImporter.PreviewAsync` (diff engine) | standard | ~5 min | +| 17 | `BundleImporter.ApplyAsync` (transaction + validation + audit) | high-risk | ~5 min | +| 18 | Host registration + transport options binding | trivial | ~2 min | +| 19 | `TreeView` checkbox-selection mode + tri-state propagation | standard | ~5 min | +| 20 | `TemplateFolderTree.razor` wrapper + Templates.razor refactor | standard | ~4 min | +| 21 | `TransportExport.razor` wizard | high-risk | ~5 min | +| 22 | `TransportImport.razor` wizard | high-risk | ~5 min | +| 23 | NavMenu entries + UI auth attributes verified | small | ~3 min | +| 24 | Audit log "Bundle Import" filter on existing page | small | ~3 min | +| 25 | Integration: export → wipe → import round-trip | standard | ~5 min | +| 26 | Integration: conflict resolutions + rollback on validation fail | standard | ~5 min | +| 27 | `Component-Transport.md` design doc | small | ~3 min | +| 28 | README + cross-reference updates | small | ~3 min | +| 29 | Manual cluster verification checklist | trivial | ~3 min | + +--- + +## Task 0: Bundle DTOs in Commons + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 2, Task 4, Task 19 + +**Files:** +- Create: `src/ScadaLink.Commons/Types/Transport/BundleManifest.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/BundleSummary.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/ManifestContentEntry.cs` + +**Step 1: Write the DTOs** + +`BundleManifest.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record BundleManifest( + int BundleFormatVersion, + string SchemaVersion, + DateTimeOffset CreatedAtUtc, + string SourceEnvironment, + string ExportedBy, + string ScadaLinkVersion, + string ContentHash, + EncryptionMetadata? Encryption, + BundleSummary Summary, + IReadOnlyList Contents); +``` + +`EncryptionMetadata.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record EncryptionMetadata( + string Algorithm, // "AES-256-GCM" + string Kdf, // "PBKDF2-SHA256" + int Iterations, + string SaltB64, + string IvB64); +``` + +`BundleSummary.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record BundleSummary( + int Templates, + int TemplateFolders, + int SharedScripts, + int ExternalSystems, + int DbConnections, + int NotificationLists, + int SmtpConfigs, + int ApiKeys, + int ApiMethods); +``` + +`ManifestContentEntry.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record ManifestContentEntry( + string Type, + string Name, + int Version, + IReadOnlyList DependsOn); +``` + +**Step 2: Build** + +Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj` +Expected: build succeeds. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.Commons/Types/Transport/ +git commit -m "feat(transport): add bundle manifest DTOs in Commons" +``` + +--- + +## Task 1: Transport interfaces + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 0, Task 2, Task 4, Task 19 + +**Files:** +- Create: `src/ScadaLink.Commons/Types/Transport/ExportSelection.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/ImportPreview.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/ImportResolution.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/ImportResult.cs` +- Create: `src/ScadaLink.Commons/Types/Transport/BundleSession.cs` +- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleExporter.cs` +- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleImporter.cs` +- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleSessionStore.cs` +- Create: `src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs` + +**Step 1: Write the types and interfaces** + +`ExportSelection.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record ExportSelection( + IReadOnlyList TemplateIds, + IReadOnlyList SharedScriptIds, + IReadOnlyList ExternalSystemIds, + IReadOnlyList DatabaseConnectionIds, + IReadOnlyList NotificationListIds, + IReadOnlyList SmtpConfigurationIds, + IReadOnlyList ApiKeyIds, + IReadOnlyList ApiMethodIds, + bool IncludeDependencies); +``` + +`ImportPreview.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public enum ConflictKind { Identical, Modified, New, Blocker } + +public sealed record ImportPreviewItem( + string EntityType, + string Name, + int? ExistingVersion, + int? IncomingVersion, + ConflictKind Kind, + string? FieldDiffJson, + string? BlockerReason); + +public sealed record ImportPreview( + Guid SessionId, + IReadOnlyList Items); +``` + +`ImportResolution.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public enum ResolutionAction { Add, Overwrite, Skip, Rename } + +public sealed record ImportResolution( + string EntityType, + string Name, + ResolutionAction Action, + string? RenameTo); +``` + +`ImportResult.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed record ImportResult( + Guid BundleImportId, + int Added, + int Overwritten, + int Skipped, + int Renamed, + IReadOnlyList StaleInstanceIds, + string AuditEventCorrelation); +``` + +`BundleSession.cs`: +```csharp +namespace ScadaLink.Commons.Types.Transport; + +public sealed class BundleSession +{ + public Guid SessionId { get; init; } + public BundleManifest Manifest { get; init; } = null!; + public byte[] DecryptedContent { get; init; } = Array.Empty(); + public DateTimeOffset ExpiresAt { get; init; } + public int FailedUnlockAttempts { get; set; } + public bool Locked => FailedUnlockAttempts >= 3; +} +``` + +`IBundleExporter.cs`: +```csharp +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Commons.Interfaces.Transport; + +public interface IBundleExporter +{ + Task ExportAsync( + ExportSelection selection, + string user, + string sourceEnvironment, + string? passphrase, + CancellationToken cancellationToken = default); +} +``` + +`IBundleImporter.cs`: +```csharp +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Commons.Interfaces.Transport; + +public interface IBundleImporter +{ + Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default); + Task PreviewAsync(Guid sessionId, CancellationToken ct = default); + Task ApplyAsync( + Guid sessionId, + IReadOnlyList resolutions, + string user, + CancellationToken ct = default); +} +``` + +`IBundleSessionStore.cs`: +```csharp +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Commons.Interfaces.Transport; + +public interface IBundleSessionStore +{ + BundleSession Open(BundleSession session); + BundleSession? Get(Guid sessionId); + void Remove(Guid sessionId); + void EvictExpired(); +} +``` + +`IAuditCorrelationContext.cs`: +```csharp +namespace ScadaLink.Commons.Interfaces.Transport; + +/// +/// Scoped service the bundle importer sets to thread a BundleImportId through to +/// the audit log entries emitted by the audited repository methods invoked during +/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes. +/// +public interface IAuditCorrelationContext +{ + Guid? BundleImportId { get; set; } +} +``` + +**Step 2: Build** + +Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj` +Expected: build succeeds. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.Commons/Types/Transport/ src/ScadaLink.Commons/Interfaces/Transport/ +git commit -m "feat(transport): add IBundleExporter / IBundleImporter interfaces" +``` + +--- + +## Task 2: `BundleImportId` on `AuditLogEntry` + EF Fluent config + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 0, Task 1, Task 19 + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs` (add nullable `BundleImportId`) +- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` (add Fluent config in `OnModelCreating` near the existing `AuditLogEntry` mapping; add `IX_AuditLogEntries_BundleImportId` non-clustered index) + +**Step 1: Add property** + +In `AuditLogEntry.cs`, after `Timestamp`: +```csharp +public Guid? BundleImportId { get; set; } +``` + +**Step 2: Add Fluent config + index** + +In `ScadaLinkDbContext.OnModelCreating`, find the existing `modelBuilder.Entity(...)` block and add: +```csharp +e.HasIndex(x => x.BundleImportId).HasDatabaseName("IX_AuditLogEntries_BundleImportId"); +``` + +If no existing block, add one near other audit configs. + +**Step 3: Build** + +Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj` +Expected: build succeeds. + +**Step 4: Commit** + +```bash +git add src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs \ + src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +git commit -m "feat(transport): add BundleImportId column on AuditLogEntry" +``` + +--- + +## Task 3: EF migration `AddBundleImportIdToAuditLog` + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** none (depends on Task 2) + +**Files:** +- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddBundleImportIdToAuditLog.cs` (generated) +- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddBundleImportIdToAuditLog.Designer.cs` (generated) + +**Step 1: Generate** + +Run: +```bash +dotnet ef migrations add AddBundleImportIdToAuditLog \ + -p src/ScadaLink.ConfigurationDatabase \ + -s src/ScadaLink.Host +``` +Expected: two files generated under `Migrations/` with naming pattern `yyyyMMddHHmmss_AddBundleImportIdToAuditLog.cs`. + +**Step 2: Sanity-check the migration** + +Open the generated `.cs` file. It must: +- Call `AddColumn(...)` for `BundleImportId` with `nullable: true`. +- Call `CreateIndex(...)` for `IX_AuditLogEntries_BundleImportId`. + +If the generated migration includes anything else (drift from other entity changes), abort and surface it — that's a plan defect. + +**Step 3: Build** + +Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj` +Expected: build succeeds. + +**Step 4: Commit** + +```bash +git add src/ScadaLink.ConfigurationDatabase/Migrations/ +git commit -m "feat(transport): EF migration AddBundleImportIdToAuditLog" +``` + +--- + +## Task 4: `IAuditCorrelationContext` scoped service + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 0, Task 1, Task 2, Task 19 + +**Files:** +- Create: `src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs` +- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (add scoped registration; if no file, create alongside existing convention used by audit service) + +**Step 1: Write the implementation** + +`AuditCorrelationContext.cs`: +```csharp +using ScadaLink.Commons.Interfaces.Transport; + +namespace ScadaLink.ConfigurationDatabase.Services; + +/// +/// Per-scope mutable holder for the active bundle import id. AuditService reads it +/// while writing AuditLogEntry rows. Registered as Scoped so each Blazor circuit / +/// request gets its own value; ApplyAsync explicitly creates a service scope and +/// sets the id at the top of the transaction. +/// +public sealed class AuditCorrelationContext : IAuditCorrelationContext +{ + public Guid? BundleImportId { get; set; } +} +``` + +**Step 2: Register** + +In `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (the `AddConfigurationDatabase()` method), add: +```csharp +services.AddScoped(); +``` +If a `ServiceCollectionExtensions.cs` doesn't exist in `ScadaLink.ConfigurationDatabase`, look at where `IAuditService` is currently registered (search the repo for `AddScoped` in alphabetical position) +- Create: `tests/ScadaLink.Transport.Tests/ScadaLink.Transport.Tests.csproj` +- Create: `tests/ScadaLink.Transport.IntegrationTests/ScadaLink.Transport.IntegrationTests.csproj` +- Modify: `ScadaLink.slnx` (add the two test projects) + +**Step 1: Write the csproj** + +`ScadaLink.Transport.csproj`: +```xml + + + net10.0 + enable + enable + true + + + + + + + + + + + +``` + +`ServiceCollectionExtensions.cs`: +```csharp +using Microsoft.Extensions.DependencyInjection; + +namespace ScadaLink.Transport; + +public static class ServiceCollectionExtensions +{ + public const string OptionsSection = "ScadaLink:Transport"; + + public static IServiceCollection AddTransport(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddOptions().BindConfiguration(OptionsSection); + // Concrete services added in later tasks. + return services; + } +} +``` + +Test csprojs follow the existing test pattern (look at `tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj` for shape — xUnit + NSubstitute + FluentAssertions + ProjectReference back to ScadaLink.Transport). + +**Step 2: Add to slnx** + +Insert in alphabetical order under `/src/` and `/tests/` folder sections. + +**Step 3: Build** + +Run: `dotnet build ScadaLink.slnx` +Expected: build succeeds (Transport empty + tests empty). + +**Step 4: Commit** + +```bash +git add src/ScadaLink.Transport/ tests/ScadaLink.Transport.Tests/ tests/ScadaLink.Transport.IntegrationTests/ ScadaLink.slnx +git commit -m "feat(transport): scaffold ScadaLink.Transport project + test projects" +``` + +--- + +## Task 7: `TransportOptions` + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Task 8 (different file) + +**Files:** +- Create: `src/ScadaLink.Transport/TransportOptions.cs` + +**Step 1: Write the options class** + +```csharp +namespace ScadaLink.Transport; + +public sealed class TransportOptions +{ + public int BundleSessionTtlMinutes { get; set; } = 30; + public int MaxBundleSizeMb { get; set; } = 100; + public int MaxUnlockAttemptsPerSession { get; set; } = 3; + public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10; + public int Pbkdf2Iterations { get; set; } = 600_000; + public int SchemaVersionMajor { get; set; } = 1; +} +``` + +**Step 2: Commit** + +```bash +git add src/ScadaLink.Transport/TransportOptions.cs +git commit -m "feat(transport): add TransportOptions" +``` + +--- + +## Task 8: `BundleSecretEncryptor` (AES-256-GCM + PBKDF2) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 7, Task 9, Task 10, Task 12, Task 13, Task 19 + +**Files:** +- Create: `src/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs` +- Create: `tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs` + +**Step 1: Write failing tests first** + +Tests to write (all in `BundleSecretEncryptorTests.cs`): +1. `Encrypt_then_Decrypt_roundtrips_arbitrary_bytes` +2. `Decrypt_with_wrong_passphrase_throws_CryptographicException` +3. `Decrypt_with_tampered_ciphertext_throws_CryptographicException` (flip one byte in ciphertext) +4. `Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv` +5. `Encrypt_emits_metadata_matching_decryption_inputs` (salt, iv, iterations, algorithm) + +**Step 2: Run tests to verify failure** + +Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests` +Expected: FAIL (class not yet defined). + +**Step 3: Implement** + +```csharp +using System.Security.Cryptography; +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Transport.Encryption; + +public sealed class BundleSecretEncryptor +{ + private const int KeyBytes = 32; // AES-256 + private const int SaltBytes = 16; + private const int NonceBytes = 12; // GCM standard + private const int TagBytes = 16; + + public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt( + ReadOnlySpan plaintext, + string passphrase, + int iterations) + { + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var nonce = RandomNumberGenerator.GetBytes(NonceBytes); + var key = DeriveKey(passphrase, salt, iterations); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagBytes]; + using var aes = new AesGcm(key, TagBytes); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + // Format: ciphertext || tag + var output = new byte[ciphertext.Length + TagBytes]; + Buffer.BlockCopy(ciphertext, 0, output, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, output, ciphertext.Length, TagBytes); + + return (output, new EncryptionMetadata( + "AES-256-GCM", "PBKDF2-SHA256", iterations, + Convert.ToBase64String(salt), + Convert.ToBase64String(nonce))); + } + + public byte[] Decrypt(ReadOnlySpan payload, EncryptionMetadata metadata, string passphrase) + { + if (metadata.Algorithm != "AES-256-GCM" || metadata.Kdf != "PBKDF2-SHA256") + throw new CryptographicException("Unsupported bundle encryption parameters."); + + var salt = Convert.FromBase64String(metadata.SaltB64); + var nonce = Convert.FromBase64String(metadata.IvB64); + var key = DeriveKey(passphrase, salt, metadata.Iterations); + + if (payload.Length < TagBytes) throw new CryptographicException("Bundle payload too short."); + var ctLen = payload.Length - TagBytes; + var ciphertext = payload[..ctLen]; + var tag = payload[ctLen..]; + + var plaintext = new byte[ctLen]; + using var aes = new AesGcm(key, TagBytes); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return plaintext; + } + + private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations) + { + using var pbkdf2 = new Rfc2898DeriveBytes(passphrase, salt, iterations, HashAlgorithmName.SHA256); + return pbkdf2.GetBytes(KeyBytes); + } +} +``` + +**Step 4: Run tests** + +Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.Transport/Encryption/ tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs +git commit -m "feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor" +``` + +--- + +## Task 9: `ManifestBuilder` + schema validation + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 7, Task 8, Task 10, Task 12, Task 13, Task 19 + +**Files:** +- Create: `src/ScadaLink.Transport/Serialization/ManifestBuilder.cs` +- Create: `src/ScadaLink.Transport/Serialization/ManifestValidator.cs` +- Create: `tests/ScadaLink.Transport.Tests/ManifestBuilderTests.cs` + +**Step 1: Write failing tests** + +Tests: +1. `Build_populates_summary_from_contents` +2. `Build_serializes_to_valid_json` +3. `Validate_rejects_unsupported_bundleFormatVersion` +4. `Validate_rejects_when_contentHash_mismatch` +5. `Validate_accepts_well_formed_v1_manifest` + +**Step 2-5:** Run-fail → implement → run-pass → commit. + +`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaLinkVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`. + +`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`). + +**Commit:** +```bash +git commit -m "feat(transport): ManifestBuilder + ManifestValidator with schema-version gating" +``` + +--- + +## Task 10: `EntitySerializer` — DTOs + secret carving + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7, Task 8, Task 9, Task 12, Task 13, Task 19 + +**Files:** +- Create: `src/ScadaLink.Transport/Serialization/EntityDtos.cs` (all bundle DTOs: `TemplateDto`, `TemplateFolderDto`, `SharedScriptDto`, `ExternalSystemDto`, `DatabaseConnectionDto`, `NotificationListDto`, `SmtpConfigDto`, `ApiKeyDto`, `ApiMethodDto`, each with a nested `SecretsBlock` where applicable) +- Create: `src/ScadaLink.Transport/Serialization/EntitySerializer.cs` +- Create: `tests/ScadaLink.Transport.Tests/EntitySerializerTests.cs` + +**Step 1: Write failing tests** + +Tests: +1. `ToDto_carves_external_system_credentials_into_secrets_block` +2. `ToDto_carves_smtp_password_into_secrets_block` +3. `ToDto_carves_api_key_hash_into_secrets_block` +4. `Roundtrip_template_preserves_attributes_alarms_scripts_composition` +5. `Roundtrip_template_folder_preserves_hierarchy` + +**Step 3: Implement** + +DTOs are flat records mirroring the Commons POCOs minus EF-only navigation properties. Secrets fields move into a nested `SecretsBlock` record on the DTO: +```csharp +public sealed record ExternalSystemDto( + string Name, + string BaseUrl, + string AuthType, + IReadOnlyList Methods, + SecretsBlock? Secrets); + +public sealed record SecretsBlock(IReadOnlyDictionary Values); +``` + +`EntitySerializer` exposes: +- `BundleContentDto ToBundleContent(EntityAggregate aggregate)` — converts in-memory aggregate to top-level DTO grouped by type. +- `EntityAggregate FromBundleContent(BundleContentDto content)` — inverse. + +`BundleContentDto`: +```csharp +public sealed record BundleContentDto( + IReadOnlyList TemplateFolders, + IReadOnlyList Templates, + IReadOnlyList SharedScripts, + IReadOnlyList ExternalSystems, + IReadOnlyList DatabaseConnections, + IReadOnlyList NotificationLists, + IReadOnlyList SmtpConfigs, + IReadOnlyList ApiKeys, + IReadOnlyList ApiMethods); +``` + +**Step 5: Commit** +```bash +git commit -m "feat(transport): bundle entity DTOs + secret carving in EntitySerializer" +``` + +--- + +## Task 11: `BundleSerializer` (ZIP packer + reader + content hash) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (depends on Tasks 8, 9, 10) + +**Files:** +- Create: `src/ScadaLink.Transport/Serialization/BundleSerializer.cs` +- Create: `tests/ScadaLink.Transport.Tests/BundleSerializerTests.cs` + +**Step 1: Write failing tests** + +Tests: +1. `Pack_emits_manifest_and_content_entries` +2. `Pack_encrypts_content_when_passphrase_supplied` +3. `Pack_leaves_content_plaintext_when_no_passphrase` +4. `Roundtrip_through_temp_stream_recovers_identical_content` +5. `Read_rejects_zip_without_manifest` + +**Step 3: Implement** + +`BundleSerializer` uses `System.IO.Compression.ZipArchive`: +- `Stream Pack(BundleContentDto content, ManifestContext ctx, string? passphrase)` — serializes `content` → JSON bytes → optional encrypt → writes `manifest.json` + (`content.json` OR `content.enc`) to a `MemoryStream`-backed ZIP. +- `(BundleManifest manifest, byte[] contentBytes) ReadHeader(Stream zipStream)` — opens, reads `manifest.json` only. +- `BundleContentDto Unpack(Stream zipStream, BundleManifest manifest, string? passphrase)` — full unpack + decrypt. + +**Step 5: Commit** +```bash +git commit -m "feat(transport): BundleSerializer ZIP packer/reader" +``` + +--- + +## Task 12: `DependencyResolver` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 13, Task 19 + +**Files:** +- Create: `src/ScadaLink.Transport/Export/DependencyResolver.cs` +- Create: `tests/ScadaLink.Transport.Tests/DependencyResolverTests.cs` + +**Step 1: Write failing tests** + +Tests: +1. `Resolve_includes_base_template_for_composed_template` +2. `Resolve_includes_shared_script_referenced_by_template` +3. `Resolve_includes_external_system_referenced_by_template` +4. `Resolve_includes_api_method_shared_script_dependency` +5. `Resolve_handles_diamond_dependency_without_duplication` +6. `Resolve_includes_template_folder_for_each_selected_template` +7. `Resolve_returns_topological_order_base_before_derived` + +**Step 3: Implement** + +`DependencyResolver` takes `ExportSelection` + repository readers, expands the selection through the edges in design §6.1, and returns a `ResolvedExport` aggregate: +```csharp +public sealed record ResolvedExport( + IReadOnlyList TemplateFolders, + IReadOnlyList