Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AR.Iec61850.Mms.MmsFileDirectoryEntry> 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) ||
Expand All @@ -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)
{
Expand Down
109 changes: 109 additions & 0 deletions Models/IedBrowserFileDiscoveryPlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using AR.Iec61850.FaultRecords;
using AR.Iec61850.Mms;

namespace ArIED61850Tester.Models;

/// <summary>
/// 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.
/// </summary>
public static class IedBrowserFileDiscoveryPlanner
{
public sealed record Result(
IedBrowserFileCatalog Files,
string? RootWarning,
string? CatalogWarning,
IReadOnlyList<string> CatalogDiagnostics);

public static async Task<Result> DiscoverAsync(
Func<CancellationToken, Task<IReadOnlyList<MmsFileDirectoryEntry>>> readRoot,
Func<CancellationToken, Task<Iec61850FaultRecordCatalog>> scanFaultRecords,
CancellationToken cancellationToken,
Func<IedBrowserFileCatalog, Task>? 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<MmsFileDirectoryEntry> root = Array.Empty<MmsFileDirectoryEntry>();
string? rootWarning = null;
try
{
root = await readRoot(cancellationToken).ConfigureAwait(false)
?? Array.Empty<MmsFileDirectoryEntry>();
}
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);
}
}
134 changes: 134 additions & 0 deletions tests/ARSAS.Tests/M214RootFirstFileDirectoryTests.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<MmsFileDirectoryEntry>>(
[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<IReadOnlyList<MmsFileDirectoryEntry>>(
[File("/notes.txt", 10), new MmsFileDirectoryEntry
{
Path = "/RECORDS/", Name = "RECORDS/", SizeBytes = 0
}]),
ct => Task.FromCanceled<Iec61850FaultRecordCatalog>(
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<OperationCanceledException>(() =>
IedBrowserFileDiscoveryPlanner.DiscoverAsync(
_ => Task.FromResult<IReadOnlyList<MmsFileDirectoryEntry>>(
[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<IReadOnlyList<MmsFileDirectoryEntry>>(
[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<string>();
await IedBrowserFileDiscoveryPlanner.DiscoverAsync(
_ =>
{
order.Add("root");
return Task.FromResult<IReadOnlyList<MmsFileDirectoryEntry>>(
[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);
}
}
Loading