feat(transport): BundleImporter.LoadAsync with manifest validation

This commit is contained in:
Joseph Doherty
2026-05-24 04:37:02 -04:00
parent 7c70ce0dbf
commit 5fc6790c36
3 changed files with 496 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
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 readonly BundleSerializer _bundleSerializer;
private readonly ManifestValidator _manifestValidator;
private readonly BundleSecretEncryptor _encryptor;
#pragma warning disable IDE0052 // wired-in dependencies for T16/T17.
private readonly EntitySerializer _entitySerializer;
private readonly ITemplateEngineRepository _templateRepo;
private readonly IExternalSystemRepository _externalRepo;
private readonly INotificationRepository _notificationRepo;
private readonly IInboundApiRepository _inboundApiRepo;
private readonly IAuditService _auditService;
private readonly IAuditCorrelationContext _correlationContext;
private readonly ScadaLinkDbContext _dbContext;
#pragma warning restore IDE0052
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);
}
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
{
// Filled in by T16. Throwing NotImplementedException here keeps the
// interface contract honest while letting LoadAsync ship in isolation.
throw new NotImplementedException("PreviewAsync is implemented by task T16.");
}
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.");
}
}

View File

@@ -30,6 +30,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<DependencyResolver>();
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
services.AddScoped<IBundleImporter, BundleImporter>();
// Remaining concrete services added in later tasks.
return services;
}