using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; 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; using ZB.MOM.WW.ScadaBridge.Transport.Import; namespace ZB.MOM.WW.ScadaBridge.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. CentralUI-031: we previously cached the raw bytes in a /// byte[] _bundleBytes field, which buffered the full upload (default cap /// 100 MB) in the component's per-circuit state — multiplied across concurrent /// operator sessions, that produced real central-node memory pressure. The /// bytes are now streamed once to a per-session temp file under /// Path.GetTempPath()/scadabridge-transport-staging/ and only the path is /// retained on the component. The file is deleted on Back-to-Upload / Reset / /// successful Apply / component Dispose, so an abandoned wizard does not leak /// staged bundle plaintext beyond circuit teardown. /// public partial class TransportImport : ComponentBase, IDisposable { 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 ScadaBridgeDbContext DbContext { get; set; } = default!; // M8 E2: the Map step needs the destination environment's sites + each // site's connections to populate the "map to existing target" dropdowns. [Inject] private ISiteRepository SiteRepo { get; set; } = default!; // ---- Wizard state ---- private ImportWizardStep _step = ImportWizardStep.Upload; private string? _errorMessage; // ---- Session + cached bundle path ---- // CentralUI-031: the upload is streamed to a per-session temp file and only // the path is retained on the component, so we don't hold an entire bundle // (up to MaxBundleSizeMb, default 100 MB) in per-circuit memory across the // wizard's lifetime. The file is deleted on every wizard reset path and on // component disposal so an abandoned wizard cannot leak staged plaintext // beyond circuit teardown. private string? _bundleTempPath; private BundleSession? _session; private bool _uploadInProgress; // Staging directory for in-flight bundle uploads. Lives under the system // temp directory rather than wwwroot/ because the file is never served to // a browser — it is only read by the in-process IBundleImporter. private static readonly string StagingDir = Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging"); // ---- 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 3 (Map sub-section, M8 E2): name mapping ---- // The sentinel dropdown value for "Create new" — empty string can't collide // with a real SiteIdentifier / connection Name (both are non-empty). private const string CreateNewValue = ""; // Destination environment's sites, loaded once when the preview carries any // required mappings. private IReadOnlyList _targetSites = Array.Empty(); // Per-target-site connections, loaded lazily and cached by SiteIdentifier. private readonly Dictionary> _targetConnections = new(); // Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen // target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from // each RequiredSiteMapping.AutoMatchTargetIdentifier. private readonly Dictionary _siteChoices = new(); // Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName). // Value is the chosen target connection Name, or CreateNewValue for "Create new". private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new(); // ---- 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; DeleteBundleTempFile(); 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; } // CentralUI-031: stream the upload directly to a per-session temp // file so the central node's working set is bounded by the // FileStream buffer (~80 KB) rather than the full bundle bytes. // 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. Directory.CreateDirectory(StagingDir); _bundleTempPath = Path.Combine(StagingDir, $"{Guid.NewGuid():N}.scadabundle"); using (var fileStream = e.File.OpenReadStream(maxBytes)) await using (var dest = new FileStream( _bundleTempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true)) { await fileStream.CopyToAsync(dest); } 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 (_bundleTempPath is null || !File.Exists(_bundleTempPath)) { _errorMessage = "No bundle staged — please re-select the file."; return; } try { // CentralUI-031: read the staged bundle straight off disk; the // importer's LoadAsync only walks the stream forward, so a plain // FileStream is sufficient (no need to buffer it back into memory). using var stream = new FileStream( _bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true); _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() { // Reset all wizard + Map state so a subsequent re-upload starts from a // clean slate and cannot observe stale session / preview / name-map data // from the previous flow. ResetSessionState also clears _preview, // _resolutions, _siteChoices, _connectionChoices, etc. ResetSessionState(); _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); await InitNameMappingStateAsync(_preview); _step = ImportWizardStep.Diff; } catch (Exception ex) { _errorMessage = $"Failed to build import preview: {ex.Message}"; } } /// /// M8 E2: prepares the Map sub-section state for a freshly loaded preview. /// When the preview carries no required site/connection mappings this is a /// no-op (the Map section is hidden) and we skip the site/connection reads /// entirely — central-config-only bundles never touch the destination's /// site catalogue. /// /// Default choices follow the reference precedence: each site/connection /// is seeded to its AutoMatch* value when the importer found a match, /// otherwise to "Create new". The operator's explicit dropdown choice then /// wins over the auto-match default. /// private async Task InitNameMappingStateAsync(ImportPreview preview) { _targetSites = Array.Empty(); _targetConnections.Clear(); _siteChoices.Clear(); _connectionChoices.Clear(); if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0) { return; } _targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None); foreach (var rsm in preview.RequiredSiteMappings) { // Seed to the auto-matched target identifier when present AND that // target still exists in the destination; otherwise "Create new". var seed = rsm.AutoMatchTargetIdentifier is not null && _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier) ? rsm.AutoMatchTargetIdentifier : CreateNewValue; _siteChoices[rsm.SourceSiteIdentifier] = seed; // Eagerly load the seeded target's connections so the connection-row // dropdowns beneath this site can render their option sets without an // async hop on first render. if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed)) { var target = _targetSites.First(s => s.SiteIdentifier == seed); _targetConnections[seed] = await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None); } } foreach (var rcm in preview.RequiredConnectionMappings) { // Default the connection to its auto-match. We don't pre-validate it // against the chosen target's connection list here — the dropdown's // option set (driven by the chosen site) governs what's selectable, // and the seed simply renders as the initial selection. _connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] = rcm.AutoMatchTargetName ?? CreateNewValue; } } /// /// Returns the connections of the destination site the operator chose for /// (used to populate a connection /// row's dropdown). Reads the _targetConnections cache, which is /// populated for every chosen target site by /// (seeded auto-matches) and (operator /// changes). Returns an empty list when the source site maps to "Create new" /// (no existing target connections to bind to). /// private IReadOnlyList ConnectionsForChosenTarget(string sourceSiteIdentifier) { if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier) || string.IsNullOrEmpty(targetSiteIdentifier)) { return Array.Empty(); } return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached) ? cached : Array.Empty(); } /// /// Handles a site-mapping dropdown change. Records the choice, eagerly loads + /// caches the new target's connections, then resets the per-connection choices /// for every under this source site so /// that stale selections from the previous target cannot survive into /// . Each connection is re-seeded: if the new target /// has a same-named connection the choice defaults to that name (matching the /// auto-match logic in ); otherwise it /// falls back to the sentinel ("Create new"). /// private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value) { var chosen = value ?? CreateNewValue; _siteChoices[sourceSiteIdentifier] = chosen; if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen)) { var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen); if (target is not null) { _targetConnections[chosen] = await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None); } } // Reset connection choices for every RequiredConnectionMapping belonging // to this source site. The previously-chosen connection name may not exist // on the newly-chosen target site, and leaving it would cause BuildNameMap // to emit a MapToExisting for a connection that does not exist there. if (_preview is not null) { foreach (var rcm in _preview.RequiredConnectionMappings .Where(m => m.SourceSiteIdentifier == sourceSiteIdentifier)) { // Default to the new target's same-named connection when present; // otherwise fall back to Create-new. _connectionChoices[(sourceSiteIdentifier, rcm.SourceConnectionName)] = (!string.IsNullOrEmpty(chosen) && _targetConnections.TryGetValue(chosen, out var conns) && conns.Any(c => c.Name == rcm.SourceConnectionName)) ? rcm.SourceConnectionName : CreateNewValue; } } } private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value) { _connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue; } /// /// M8 E2: folds the operator's Map-step choices into a . /// A concrete chosen target → with that /// target identifier/name; the "Create new" sentinel → /// with a null target. Iterates the preview's required mappings so the map's shape /// always matches what the bundle references (a missing choice falls back to its /// auto-match default, mirroring the seed). Returns /// when the preview carries no required mappings. /// private BundleNameMap BuildNameMap() { if (_preview is null || (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0)) { return BundleNameMap.Empty; } var sites = new List(_preview.RequiredSiteMappings.Count); foreach (var rsm in _preview.RequiredSiteMappings) { var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : (rsm.AutoMatchTargetIdentifier ?? CreateNewValue); sites.Add(string.IsNullOrEmpty(chosen) ? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null) : new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen)); } var connections = new List(_preview.RequiredConnectionMappings.Count); foreach (var rcm in _preview.RequiredConnectionMappings) { var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName); var chosen = _connectionChoices.TryGetValue(key, out var c) ? c : (rcm.AutoMatchTargetName ?? CreateNewValue); connections.Add(string.IsNullOrEmpty(chosen) ? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null) : new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen)); } return new BundleNameMap(sites, connections); } /// /// M8 E2: parses a Modified item's FieldDiffJson and returns the /// lineDiff object for the first code field that carries one, or null /// when the diff has no line-level payload (ordinary fields render as a coarse /// summary instead). Tolerant of malformed/absent JSON — a parse failure /// simply yields null so the row degrades to the coarse field-diff view. /// internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson) { if (string.IsNullOrWhiteSpace(fieldDiffJson)) { return null; } try { using var doc = JsonDocument.Parse(fieldDiffJson); if (!doc.RootElement.TryGetProperty("changes", out var changes) || changes.ValueKind != JsonValueKind.Array) { return null; } foreach (var change in changes.EnumerateArray()) { if (change.ValueKind == JsonValueKind.Object && change.TryGetProperty("lineDiff", out var lineDiff) && lineDiff.ValueKind == JsonValueKind.Object) { // Clone so the element outlives the JsonDocument we dispose. return lineDiff.Clone(); } } return null; } catch (JsonException) { return null; } } /// /// 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(); // M8 E2: fold the operator's Map-step choices into the name map. For // central-config-only bundles (no required mappings) this is // BundleNameMap.Empty, which the importer normalises away. var nameMap = BuildNameMap(); _result = await BundleImporter.ApplyAsync( _session.SessionId, _resolutions.Values.ToList(), user, CancellationToken.None, nameMap: nameMap); _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; DeleteBundleTempFile(); _preview = null; _resolutions = null; _passphrase = string.Empty; _confirmEnvironmentText = string.Empty; _result = null; _validationErrors = null; // M8 E2: clear the Map sub-section state too. _targetSites = Array.Empty(); _targetConnections.Clear(); _siteChoices.Clear(); _connectionChoices.Clear(); } /// /// CentralUI-031: deletes the staged bundle temp file if any. Swallows IO /// failures — an undeletable temp file is best-effort cleanup and must not /// block the wizard. /// private void DeleteBundleTempFile() { var path = _bundleTempPath; _bundleTempPath = null; if (path is null) return; try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Another handle may still be open (e.g. an in-flight LoadAsync // read). Leave the file behind; the OS temp dir is reaped on its // own schedule. Audit-failure-style: never block the user-facing // action. } catch (UnauthorizedAccessException) { // Same rationale. } } /// /// CentralUI-031: ensures the staged temp file does not survive circuit /// teardown. Blazor invokes Dispose when the user navigates away or the /// circuit ends, so an abandoned wizard cleans up automatically. /// public void Dispose() { DeleteBundleTempFile(); } }