diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index b6b42ab05..f3c107b81 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -99,29 +99,33 @@ private async Task BrowseIedBrowserFilesAsync(Iec61850MonitorDevice device) IedBrowserDetailStatus.Text = "Reading IEC 61850 FileDirectory…"; try { - // Reuse the exact service in Fault Records / COMTRADE. - // The service owns its own association and bounded traversal. + // Read root FIRST so a slow recursive fault-record scan cannot + // hide valid MMS files. The existing ARIEC session stays the only + // wire authority; COMTRADE discovery is an optional enrichment. var files = await Task.Run(async () => { await using var transfer = new ArIED61850Tester.Services.FaultRecordTransferClient(); await transfer.ConnectAsync(source.IpAddress, source.Port, cts.Token); - var catalog = await transfer.DiscoverAsync(remoteDirectory: null, cts.Token); - // FaultRecordCatalog filters ordinary files by extension. - // Ask the same ARIEC session for real root FileDirectory - // entries instead of claiming that zero records means no files. - IReadOnlyList root = []; - string? rootWarning = null; - try - { - root = await transfer.ReadRootFileDirectoryAsync(cts.Token); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - rootWarning = ex.Message; - } - return (Files: new IedBrowserFileCatalog(catalog, root), - RootWarning: rootWarning, - CatalogDiagnostics: catalog.Diagnostics); + return await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + ct => transfer.ReadRootFileDirectoryAsync(ct), + ct => transfer.DiscoverAsync(null, ct), + cts.Token, + publishRoot: early => Dispatcher.InvokeAsync(() => + { + if (cts.IsCancellationRequested || + !ReferenceEquals(_iedBrowserFileReadOperation, cts) || + !ReferenceEquals(source, SelectedDevice) || + !ReferenceEquals(source, _iedBrowserFileOwner) || + !source.IsConnected) + return; + _iedBrowserFileCatalog = early; + _iedBrowserRemoteFiles = early.Files; + _iedBrowserFilesLoaded = true; + _iedBrowserForceRefresh = true; + RefreshIedBrowser(); + IedBrowserDetailStatus.Text = + $"FileDirectory · {early.Files.Count:N0} root entries · checking verified records…"; + }).Task).ConfigureAwait(false); }, cts.Token).ConfigureAwait(true); if (cts.IsCancellationRequested || !ReferenceEquals(source, SelectedDevice) || @@ -132,10 +136,16 @@ private async Task BrowseIedBrowserFilesAsync(Iec61850MonitorDevice device) _iedBrowserFilesLoaded = true; _iedBrowserForceRefresh = true; RefreshIedBrowser(); + var notes = new[] { files.RootWarning, files.CatalogWarning } + .Where(note => !string.IsNullOrWhiteSpace(note)) + .Concat(files.CatalogDiagnostics).Take(4).ToArray(); IedBrowserDetailStatus.Text = files.Files.Files.Count > 0 - ? $"FileDirectory · {files.Files.Files.Count:N0} browsable entries (root files/folders + verified fault records)" + ? $"FileDirectory · {files.Files.Files.Count:N0} entries" + + (notes.Length > 0 ? " · partial inventory (details in tooltip)" : " · verified root/records") : "FileDirectory returned no browsable entries. " + - (files.RootWarning ?? string.Join(" | ", files.CatalogDiagnostics.Take(3))); + (notes.Length == 0 ? "IED directory is empty." : notes[0]); + IedBrowserDetailStatus.ToolTip = notes.Length == 0 + ? null : string.Join(Environment.NewLine, notes); } catch (OperationCanceledException) { diff --git a/Models/IedBrowserFileDiscoveryPlanner.cs b/Models/IedBrowserFileDiscoveryPlanner.cs new file mode 100644 index 000000000..bdf53ac19 --- /dev/null +++ b/Models/IedBrowserFileDiscoveryPlanner.cs @@ -0,0 +1,109 @@ +using AR.Iec61850.FaultRecords; +using AR.Iec61850.Mms; + +namespace ArIED61850Tester.Models; + +/// +/// Single-session MMS file inventory, optimized for a usable root listing +/// before bounded optional fault-record traversal. Actual wire operations are +/// supplied by the pinned ARIEC-backed transfer client. No duplicate decoder. +/// +public static class IedBrowserFileDiscoveryPlanner +{ + public sealed record Result( + IedBrowserFileCatalog Files, + string? RootWarning, + string? CatalogWarning, + IReadOnlyList CatalogDiagnostics); + + public static async Task DiscoverAsync( + Func>> readRoot, + Func> scanFaultRecords, + CancellationToken cancellationToken, + Func? publishRoot = null, + TimeSpan? scanBudget = null) + { + ArgumentNullException.ThrowIfNull(readRoot); + ArgumentNullException.ThrowIfNull(scanFaultRecords); + var budget = scanBudget ?? TimeSpan.FromSeconds(30); + if (budget <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(scanBudget)); + cancellationToken.ThrowIfCancellationRequested(); + + IReadOnlyList root = Array.Empty(); + string? rootWarning = null; + try + { + root = await readRoot(cancellationToken).ConfigureAwait(false) + ?? Array.Empty(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + rootWarning = "Root MMS FileDirectory: " + ex.Message; + } + + cancellationToken.ThrowIfCancellationRequested(); + // Reuse ARIEC's exact extension/package authority even for root files. + // One invalid remote path must not hide other actual MMS inventory. + // On failure, show paths only; none becomes download-authorized. + Iec61850FaultRecordCatalog rootCatalog; + try + { + rootCatalog = Iec61850FaultRecordCatalogBuilder.Build(root); + } + catch (ArgumentException ex) + { + rootCatalog = new Iec61850FaultRecordCatalog + { + Diagnostics = ["Untrusted FileDirectory record path rejected: " + ex.Message] + }; + } + var early = new IedBrowserFileCatalog(rootCatalog, root); + if (root.Count > 0 && publishRoot is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + await publishRoot(early).ConfigureAwait(false); + } + + // No real directory candidates: the verified root list is complete. + // Scan when root failed/was empty, or it actually advertises folders. + if (root.Count > 0 && !root.Any(entry => entry.IsLikelyDirectory)) + return new Result(early, rootWarning, null, rootCatalog.Diagnostics); + + Iec61850FaultRecordCatalog? scanned = null; + string? scanWarning = null; + using (var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + deadline.CancelAfter(budget); + try + { + scanned = await scanFaultRecords(deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + scanWarning = "Fault-record subdirectory discovery timed out; showing verified root files."; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + scanWarning = "Fault-record subdirectory discovery: " + ex.Message; + } + } + cancellationToken.ThrowIfCancellationRequested(); + + if (scanned is null) + return new Result(early, rootWarning, scanWarning, rootCatalog.Diagnostics); + + // Prefer recursively verified complete packages when present, while + // retaining root-only complete packages if the IED's later pages fail. + var merged = new Iec61850FaultRecordCatalog + { + Records = scanned.Records.Concat(rootCatalog.Records) + .DistinctBy(record => record.RecordId, StringComparer.Ordinal) + .ToArray(), + DirectoriesVisited = scanned.DirectoriesVisited, + Diagnostics = scanned.Diagnostics + }; + return new Result(new IedBrowserFileCatalog(merged, root), + rootWarning, scanWarning, merged.Diagnostics); + } +} diff --git a/tests/ARSAS.Tests/M214RootFirstFileDirectoryTests.cs b/tests/ARSAS.Tests/M214RootFirstFileDirectoryTests.cs new file mode 100644 index 000000000..1a9ea238c --- /dev/null +++ b/tests/ARSAS.Tests/M214RootFirstFileDirectoryTests.cs @@ -0,0 +1,134 @@ +using AR.Iec61850.FaultRecords; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214RootFirstFileDirectoryTests +{ + private static MmsFileDirectoryEntry File(string path, uint size) + => new() { Path = path, Name = Path.GetFileName(path), SizeBytes = size }; + + [Fact] + public async Task RootFilesAppearWithoutWastingMmsScanWhenNoDirectoriesExist() + { + var scans = 0; + var published = 0; + var result = await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => Task.FromResult>( + [File("config.xml", 128), File("IED.CFG", 128), + File("IED.DAT", 1024)]), + _ => { scans++; return Task.FromResult(new Iec61850FaultRecordCatalog()); }, + CancellationToken.None, + publishRoot: catalog => + { + published++; + Assert.Equal(3, catalog.Files.Count); + return Task.CompletedTask; + }); + Assert.Equal(0, scans); + Assert.Equal(1, published); + Assert.Equal(3, result.Files.Files.Count); + Assert.Null(result.CatalogWarning); + Assert.True(result.Files.TryGetRecord("IED.CFG", out _, out _)); + } + + [Fact] + public async Task RootEvidenceSurvivesSlowOrRejectedRecursiveCatalog() + { + var published = 0; + var result = await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => Task.FromResult>( + [File("/notes.txt", 10), new MmsFileDirectoryEntry + { + Path = "/RECORDS/", Name = "RECORDS/", SizeBytes = 0 + }]), + ct => Task.FromCanceled( + new CancellationToken(true)), + CancellationToken.None, + publishRoot: root => + { + published++; + Assert.Equal(2, root.Files.Count); + return Task.CompletedTask; + }, + scanBudget: TimeSpan.FromMilliseconds(50)); + Assert.Equal(1, published); + Assert.NotNull(result.CatalogWarning); + Assert.Equal(2, result.Files.Files.Count); + Assert.False(result.Files.TryGetRecord("/notes.txt", out _, out _)); + } + + [Fact] + public async Task FailureOfRootStillAllowsExistingFaultRecordDiscoveryFallback() + { + var scanned = false; + var result = await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => throw new InvalidOperationException("MMS AccessDenied"), + _ => { scanned = true; return Task.FromResult(new Iec61850FaultRecordCatalog()); }, + CancellationToken.None); + Assert.True(scanned); + Assert.Contains("AccessDenied", result.RootWarning); + Assert.Empty(result.Files.Files); + } + + [Fact] + public async Task UserCancellationNeverPublishesRootOrTriggersRecordScan() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var scanned = false; + var callback = false; + await Assert.ThrowsAnyAsync(() => + IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => Task.FromResult>( + [File("/foo.bin", 1)]), + _ => { scanned = true; return Task.FromResult(new Iec61850FaultRecordCatalog()); }, + cancelled.Token, + _ => { callback = true; return Task.CompletedTask; })); + Assert.False(scanned); + Assert.False(callback); + } + + [Fact] + public async Task UnsafeRootRecordPathCannotHideSafeInventoryOrAuthorizeDownload() + { + var discovery = await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => Task.FromResult>( + [File("../escape.CFG", 5), File("readme.txt", 12)]), + _ => throw new InvalidOperationException( + "No recursive scan should occur without directories."), + CancellationToken.None); + Assert.Equal(2, discovery.Files.Files.Count); + Assert.NotEmpty(discovery.CatalogDiagnostics); + Assert.Contains("rejected", discovery.CatalogDiagnostics[0], + StringComparison.OrdinalIgnoreCase); + Assert.False(discovery.Files.TryGetRecord("../escape.CFG", out _, out _)); + Assert.False(discovery.Files.TryGetRecord("readme.txt", out _, out _)); + } + + [Fact] + public async Task RootIsPublishedBeforeDeeperMmsTraversal() + { + var order = new List(); + await IedBrowserFileDiscoveryPlanner.DiscoverAsync( + _ => + { + order.Add("root"); + return Task.FromResult>( + [new MmsFileDirectoryEntry { Path = "/dir/", Name = "dir/" }]); + }, + _ => + { + order.Add("catalog"); + return Task.FromResult(new Iec61850FaultRecordCatalog()); + }, + CancellationToken.None, + _ => + { + order.Add("publish"); + return Task.CompletedTask; + }); + Assert.Equal(new[] { "root", "publish", "catalog" }, order); + } +}