refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
using System.Security.Cryptography;
|
||||
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.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;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
|
||||
///
|
||||
/// Five-step state machine:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
|
||||
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
|
||||
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
|
||||
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
|
||||
/// and retry LoadAsync; 3-strike lockout per the configured
|
||||
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
|
||||
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
|
||||
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
|
||||
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
|
||||
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
|
||||
/// guard prevents accidental cross-cluster overwrites.</item>
|
||||
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
|
||||
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
|
||||
/// the error list and allow returning to Step 3.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
|
||||
/// globally and must not be available to Design-only or Deployment-only users.
|
||||
///
|
||||
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
|
||||
/// peeks the manifest by attempting decryption, encrypted bundles require two
|
||||
/// LoadAsync invocations. CentralUI-031: we previously cached the raw bytes in a
|
||||
/// <c>byte[] _bundleBytes</c> 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
|
||||
/// <c>Path.GetTempPath()/scadabridge-transport-staging/</c> 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.
|
||||
/// </summary>
|
||||
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<TransportOptions> Options { get; set; } = default!;
|
||||
[Inject] private IAuditService AuditService { get; set; } = default!;
|
||||
[Inject] private ScadaBridgeDbContext DbContext { 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 4: confirm ----
|
||||
private string _confirmEnvironmentText = string.Empty;
|
||||
|
||||
// ---- Step 5: apply result ----
|
||||
private bool _applyInProgress;
|
||||
private ImportResult? _result;
|
||||
private IReadOnlyList<string>? _validationErrors;
|
||||
|
||||
// ============================================================
|
||||
// Step 1 — Upload
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the selected file, enforces the configured size cap, then calls
|
||||
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
|
||||
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
|
||||
/// which we catch and use to advance to Step 2 — the session is opened on
|
||||
/// the second LoadAsync call once the passphrase is provided.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
|
||||
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
|
||||
/// with no passphrase) leaves the wizard's step caller to advance to the
|
||||
/// passphrase step. Wrong-passphrase failures surface as
|
||||
/// <see cref="CryptographicException"/> and are counted by the caller.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>null</c> and threw
|
||||
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
|
||||
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
|
||||
/// jump directly to Step 3.
|
||||
/// </summary>
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Submits the entered passphrase.
|
||||
/// <para>
|
||||
/// T-003: lockout enforcement is now server-side and keyed by the bundle's
|
||||
/// content hash. <see cref="CryptographicException"/> means "wrong passphrase,
|
||||
/// try again"; a <see cref="BundleLockedException"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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 ?? "<no-session>";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the default resolution per preview item:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
|
||||
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
|
||||
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
|
||||
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
|
||||
/// </list>
|
||||
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
|
||||
/// </summary>
|
||||
/// <param name="preview">The import preview containing all conflict items to map.</param>
|
||||
/// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
|
||||
/// resolutions and the authenticated user identity. Distinguishes
|
||||
/// <see cref="SemanticValidationException"/> (recoverable — surface the
|
||||
/// error list and let the operator return to Step 3) from generic
|
||||
/// exceptions (display generic error + force re-upload).
|
||||
/// </summary>
|
||||
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;
|
||||
DeleteBundleTempFile();
|
||||
_preview = null;
|
||||
_resolutions = null;
|
||||
_passphrase = string.Empty;
|
||||
_confirmEnvironmentText = string.Empty;
|
||||
_result = null;
|
||||
_validationErrors = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteBundleTempFile();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user