using System.Security.Cryptography; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Options; using ScadaLink.CentralUI.Auth; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.Transport; using ScadaLink.Transport.Import; namespace ScadaLink.CentralUI.Components.Pages.Design; /// /// Code-behind for the TransportImport wizard (Transport feature, Task T22). /// /// Five-step state machine: /// /// — read bundle bytes, attempt /// a passphrase-less ; if the /// bundle is encrypted, advance to Step 2 without yet opening a session. /// — collect the passphrase /// and retry LoadAsync; 3-strike lockout per the configured /// . /// — render /// items, collect per Modified item; Apply /// is blocked while any remains. /// — type-the-environment-name /// guard prevents accidental cross-cluster overwrites. /// — render Apply result + audit /// drill-in link; on , surface /// the error list and allow returning to Step 3. /// /// /// The page is gated on RequireAdmin — Import touches central configuration /// globally and must not be available to Design-only or Deployment-only users. /// /// Cached bundle bytes: because currently /// peeks the manifest by attempting decryption, encrypted bundles require two /// LoadAsync invocations. We cache the raw bytes in _bundleBytes after the /// first read so the user does not need to re-select the file before entering the /// passphrase. The bytes are cleared on Done / Back-to-Upload. /// public partial class TransportImport : ComponentBase { public enum ImportWizardStep { Upload = 1, Passphrase = 2, Diff = 3, Confirm = 4, Result = 5, } // ---- Injected services ---- [Inject] private IBundleImporter BundleImporter { get; set; } = default!; [Inject] private NavigationManager Nav { get; set; } = default!; [Inject] private AuthenticationStateProvider Auth { get; set; } = default!; [Inject] private IOptions Options { get; set; } = default!; [Inject] private IAuditService AuditService { get; set; } = default!; [Inject] private ScadaLinkDbContext DbContext { get; set; } = default!; // ---- Wizard state ---- private ImportWizardStep _step = ImportWizardStep.Upload; private string? _errorMessage; // ---- Session + cached bytes ---- // Bundle bytes are cached so the same file can be re-attempted with a // passphrase without forcing the user to re-pick it. Cleared in ResetAll. private byte[]? _bundleBytes; private BundleSession? _session; private bool _uploadInProgress; // ---- Step 2: passphrase ---- private string _passphrase = string.Empty; private int _failedUnlockAttempts; // ---- Step 3: preview + resolutions ---- private ImportPreview? _preview; // Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup. private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions; // ---- Step 4: confirm ---- private string _confirmEnvironmentText = string.Empty; // ---- Step 5: apply result ---- private bool _applyInProgress; private ImportResult? _result; private IReadOnlyList? _validationErrors; // ============================================================ // Step 1 — Upload // ============================================================ /// /// Buffers the selected file, enforces the configured size cap, then calls /// with no passphrase to peek the /// manifest. Encrypted bundles surface as , /// which we catch and use to advance to Step 2 — the session is opened on /// the second LoadAsync call once the passphrase is provided. /// private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) { _errorMessage = null; _uploadInProgress = true; _session = null; _bundleBytes = null; try { var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L; if (e.File.Size > maxBytes) { _errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB."; return; } // OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump // it to the configured cap so the read doesn't throw before we get // to the importer's own length check. using var fileStream = e.File.OpenReadStream(maxBytes); using var ms = new MemoryStream(); await fileStream.CopyToAsync(ms); _bundleBytes = ms.ToArray(); await TryLoadAsync(passphrase: null); } catch (ArgumentException) { // Encrypted bundle, no passphrase yet — expected. The wizard // advances to the passphrase step when the user clicks Next. } catch (Exception ex) { _errorMessage = $"Failed to read bundle: {ex.Message}"; } finally { _uploadInProgress = false; } } /// /// Attempts to open a from the cached bytes with /// the given passphrase. On (encrypted bundle /// with no passphrase) leaves the wizard's step caller to advance to the /// passphrase step. Wrong-passphrase failures surface as /// and are counted by the caller. /// private async Task TryLoadAsync(string? passphrase) { if (_bundleBytes is null) { _errorMessage = "No bundle bytes cached — please re-select the file."; return; } try { using var stream = new MemoryStream(_bundleBytes); _session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None); _errorMessage = null; } catch (ArgumentException) { // Encrypted bundle, no passphrase supplied — caller advances to Step 2. // We deliberately do NOT set _errorMessage here; the page surfaces // an empty Step-2 prompt instead. _session = null; throw; } catch (CryptographicException) { // Wrong passphrase — bubble so the caller can increment the counter. _session = null; throw; } catch (InvalidDataException ex) { _session = null; _errorMessage = $"Bundle is invalid: {ex.Message}"; } catch (NotSupportedException ex) { _session = null; _errorMessage = $"Bundle format unsupported: {ex.Message}"; } catch (InvalidOperationException ex) { _session = null; _errorMessage = ex.Message; } } /// /// Advances from Step 1 to either the passphrase step (encrypted bundle) or /// straight to the diff step (unencrypted bundle). For encrypted bundles /// LoadAsync was already attempted with null and threw /// , so _session is null and we move /// to Step 2. For unencrypted bundles _session is already populated; /// jump directly to Step 3. /// private async Task GoFromUploadAsync() { if (_session is null) { // Peek the manifest to find out if it's encrypted. We re-call LoadAsync // with null passphrase; for encrypted bundles this throws // ArgumentException → advance to Step 2. try { await TryLoadAsync(passphrase: null); } catch (ArgumentException) { _step = ImportWizardStep.Passphrase; return; } catch (CryptographicException) { _errorMessage = "Bundle could not be decrypted."; return; } } if (_session is null) { // Some other error already surfaced via _errorMessage. return; } if (_session.Manifest.Encryption is not null) { _step = ImportWizardStep.Passphrase; } else { await LoadPreviewAndAdvanceAsync(); } } // ============================================================ // Step 2 — Passphrase // ============================================================ /// /// Submits the entered passphrase. /// /// T-003: lockout enforcement is now server-side and keyed by the bundle's /// content hash. means "wrong passphrase, /// try again"; a means the importer has /// observed enough failures against this bundle to lock it (the count is /// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for /// display ("3 of N attempts used") — it is no longer the source of truth. /// /// private async Task SubmitPassphraseAsync() { if (string.IsNullOrEmpty(_passphrase)) { return; } _uploadInProgress = true; try { await TryLoadAsync(_passphrase); if (_session is not null) { _failedUnlockAttempts = 0; _passphrase = string.Empty; await LoadPreviewAndAdvanceAsync(); } } catch (BundleLockedException ex) { // T-003: server-side lockout reached. Emit a final audit row so the // lockout is visible in the audit log, reset the wizard, and surface // the typed message verbatim. _passphrase = string.Empty; _failedUnlockAttempts = ex.FailedAttempts; await EmitUnlockFailedAuditRowAsync(ex.BundleContentHash, ex.FailedAttempts, ex.Message); _errorMessage = ex.Message; ResetSessionState(); _step = ImportWizardStep.Upload; } catch (CryptographicException ex) { _failedUnlockAttempts++; _passphrase = string.Empty; var entityId = _session?.Manifest.ContentHash ?? ""; await EmitUnlockFailedAuditRowAsync(entityId, _failedUnlockAttempts, ex.Message); // The server tracks the authoritative counter; the local count is // kept in sync for the Razor display only. if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession) { _errorMessage = $"Too many failed unlock attempts ({_failedUnlockAttempts}). " + "Please re-upload the bundle."; ResetSessionState(); _step = ImportWizardStep.Upload; } else { _errorMessage = "Wrong passphrase. Please try again."; } } catch (ArgumentException) { _errorMessage = "Passphrase required."; } finally { _uploadInProgress = false; } } /// /// T-003: best-effort audit row for a wrong-passphrase attempt. Audit failure /// must never abort the user-facing action — same defensive pattern as the /// original page used. /// private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason) { try { var user = await Auth.GetCurrentUsernameAsync(); var entityName = _session?.Manifest.SourceEnvironment ?? ""; await AuditService.LogAsync( user: user, action: "BundleImportUnlockFailed", entityType: "Bundle", entityId: entityId, entityName: entityName, afterState: new { AttemptNumber = attemptNumber, Reason = reason, }, cancellationToken: CancellationToken.None); await DbContext.SaveChangesAsync(); } catch { // Audit failure is non-fatal — swallow and continue. } } private void BackToUpload() { _step = ImportWizardStep.Upload; _errorMessage = null; } // ============================================================ // Step 3 — Diff & resolve conflicts // ============================================================ private async Task LoadPreviewAndAdvanceAsync() { if (_session is null) return; try { _preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None); _resolutions = BuildDefaultResolutions(_preview); _step = ImportWizardStep.Diff; } catch (Exception ex) { _errorMessage = $"Failed to build import preview: {ex.Message}"; } } /// /// Builds the default resolution per preview item: /// /// /// /// /// (UI disables Apply anyway) /// /// Visible to tests via internal so the default-mapping contract is unit-pinned. /// /// The import preview containing all conflict items to map. /// A dictionary keyed by (EntityType, Name) with default resolution actions populated. internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions( ImportPreview preview) { var map = new Dictionary<(string, string), ImportResolution>(); foreach (var item in preview.Items) { var action = item.Kind switch { ConflictKind.Identical => ResolutionAction.Skip, ConflictKind.New => ResolutionAction.Add, ConflictKind.Modified => ResolutionAction.Overwrite, ConflictKind.Blocker => ResolutionAction.Skip, _ => ResolutionAction.Skip, }; map[(item.EntityType, item.Name)] = new ImportResolution( item.EntityType, item.Name, action, RenameTo: null); } return map; } private void SetResolution((string EntityType, string Name) key, ResolutionAction action) { if (_resolutions is null) return; var existing = _resolutions[key]; _resolutions[key] = existing with { Action = action }; } private void SetRenameTo((string EntityType, string Name) key, string? renameTo) { if (_resolutions is null) return; var existing = _resolutions[key]; _resolutions[key] = existing with { RenameTo = renameTo }; } private void BulkSet(ResolutionAction action) { if (_resolutions is null || _preview is null) return; foreach (var item in _preview.Items) { if (item.Kind != ConflictKind.Modified) continue; var key = (item.EntityType, item.Name); _resolutions[key] = _resolutions[key] with { Action = action }; } } private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions() { if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0); var adds = 0; var overs = 0; var skips = 0; var renames = 0; var blockers = 0; foreach (var item in _preview.Items) { if (item.Kind == ConflictKind.Blocker) { blockers++; continue; } var action = _resolutions[(item.EntityType, item.Name)].Action; switch (action) { case ResolutionAction.Add: adds++; break; case ResolutionAction.Overwrite: overs++; break; case ResolutionAction.Skip: skips++; break; case ResolutionAction.Rename: renames++; break; } } return (adds, overs, skips, renames, blockers); } private void GoToConfirm() { if (_preview is null) return; if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker)) { _errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first."; return; } _confirmEnvironmentText = string.Empty; _step = ImportWizardStep.Confirm; } private void BackToDiff() { _step = ImportWizardStep.Diff; _errorMessage = null; _validationErrors = null; _result = null; } // ============================================================ // Step 4 + 5 — Confirm & Apply // ============================================================ /// /// Invokes with the collected /// resolutions and the authenticated user identity. Distinguishes /// (recoverable — surface the /// error list and let the operator return to Step 3) from generic /// exceptions (display generic error + force re-upload). /// private async Task ApplyAsync() { if (_session is null || _resolutions is null) return; if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return; _applyInProgress = true; _errorMessage = null; _validationErrors = null; _result = null; try { var user = await Auth.GetCurrentUsernameAsync(); _result = await BundleImporter.ApplyAsync( _session.SessionId, _resolutions.Values.ToList(), user, CancellationToken.None); _step = ImportWizardStep.Result; } catch (SemanticValidationException ex) { _validationErrors = ex.Errors; _step = ImportWizardStep.Result; } catch (Exception ex) { _errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle."; _step = ImportWizardStep.Result; } finally { _applyInProgress = false; } } // ============================================================ // Reset helpers // ============================================================ private void ResetSessionState() { _session = null; _bundleBytes = null; _preview = null; _resolutions = null; _passphrase = string.Empty; _confirmEnvironmentText = string.Empty; _result = null; _validationErrors = null; } }