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
7 changes: 5 additions & 2 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -607,12 +607,15 @@
</Border>
</Grid>

<DataGrid Grid.Row="2" ItemsSource="{Binding SelectedDevice.Points}" IsReadOnly="True"
<DataGrid x:Name="ExplorerLiveValuesGrid" Grid.Row="2" ItemsSource="{Binding SelectedDevice.Points}" IsReadOnly="True"
Loaded="ExplorerLiveGrid_Loaded"
Style="{StaticResource ModernDataGrid}" FrozenColumnCount="2"
MinHeight="0" VerticalAlignment="Stretch"
EnableRowVirtualization="True" EnableColumnVirtualization="True"
VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto">
ScrollViewer.CanContentScroll="True"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
<Style.Triggers>
Expand Down
88 changes: 86 additions & 2 deletions Models/IedBrowserReadEvidenceBundle.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

Expand All @@ -20,8 +21,10 @@ public static byte[] Build(
if (Encoding.UTF8.GetByteCount(csv) > 4_194_304)
throw new ArgumentOutOfRangeException(nameof(csv),
"Selected-page evidence exceeded the 4 MiB diagnostic boundary.");
var rowCount = Math.Max(0, csv.Split('\n').Length -
(csv.EndsWith('\n') ? 2 : 1));
var rowCount = CountSelectedTargetRows(csv);
// Hash the exact UTF-8 bytes written to the CSV ZIP entry, not a normalized copy.
var csvBytes = new UTF8Encoding(false).GetBytes(csv);
var csvSha256 = Convert.ToHexString(SHA256.HashData(csvBytes)).ToLowerInvariant();
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
Expand All @@ -41,6 +44,7 @@ static void Write(ZipArchive archive, string name, string body)
EngineVersion = engineVersion,
Association = association,
SelectedTargetRows = rowCount,
ReadEvidenceCsvSha256 = csvSha256,
WireGvaStatus = "NOT_CAPTURED",
WireInvokeStatus = "NOT_CAPTURED",
UiProjectionStatus = "NOT_MEASURED",
Expand All @@ -53,4 +57,84 @@ static void Write(ZipArchive archive, string name, string body)
}
return buffer.ToArray();
}

/// <summary>
/// Count logical CSV records, not physical lines: quoted diagnostic values
/// may legally contain newlines. Refuse malformed quoting and more than
/// 4096 selected targets rather than publishing a misleading manifest.
/// The first CSV record is the header, not an observation.
/// </summary>
private static int CountSelectedTargetRows(string csv)
{
if (csv.Length == 0) return 0;
var records = 0;
var inQuotes = false;
var afterQuote = false;
var atFieldStart = true;
var recordHasContent = false;

for (var i = 0; i < csv.Length; i++)
{
var c = csv[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < csv.Length && csv[i + 1] == '"')
i++; // Escaped CSV quote belongs to the same field.
else
{
inQuotes = false;
afterQuote = true;
}
}
continue;
}

if (c is '\r' or '\n')
{
if (!recordHasContent)
throw new FormatException("Evidence CSV contains an empty record.");
records++;
if (records > 4097)
throw new ArgumentOutOfRangeException(nameof(csv),
"Selected-page evidence exceeds 4096 CSV target records.");
if (c == '\r' && i + 1 < csv.Length && csv[i + 1] == '\n')
i++;
atFieldStart = true;
afterQuote = false;
recordHasContent = false;
continue;
}

if (afterQuote)
{
if (c != ',')
throw new FormatException("Evidence CSV has unexpected data after a quoted field.");
atFieldStart = true;
afterQuote = false;
recordHasContent = true;
continue;
}

if (c == '"')
{
if (!atFieldStart)
throw new FormatException("Evidence CSV has an unexpected quote in an unquoted field.");
inQuotes = true;
atFieldStart = false;
}
else
atFieldStart = c == ',';
recordHasContent = true;
}

if (inQuotes)
throw new FormatException("Evidence CSV has an unterminated quoted field.");
if (recordHasContent) records++;
if (records > 4097)
throw new ArgumentOutOfRangeException(nameof(csv),
"Selected-page evidence exceeds 4096 CSV target records.");
return Math.Max(0, records - 1);
}
}
74 changes: 74 additions & 0 deletions tests/ARSAS.Tests/P0DLiveSignalViewportContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Xml.Linq;

namespace ARSAS.Tests;

/// <summary>
/// Structural WPF contract only. Actual DPI and window geometry remain
/// a separate Windows rendered-workflow acceptance gate.
/// </summary>
public sealed class P0DLiveSignalViewportContractTests
{
private static readonly XNamespace Xaml =
"http://schemas.microsoft.com/winfx/2006/xaml";

[Fact]
public void LiveSignalValues_StayInFiniteStarViewport_WithVirtualizedIndependentScrolling()
{
var xaml = XDocument.Load(Path.Combine(FindRepositoryRoot(), "MainWindow.xaml"));
var viewport = xaml.Descendants().Single(e =>
e.Name.LocalName == "DataGrid" &&
(string?)e.Attribute(Xaml + "Name") == "ExplorerLiveValuesGrid");

Assert.Equal("2", (string?)viewport.Attribute("Grid.Row"));
Assert.Equal("0", (string?)viewport.Attribute("MinHeight"));
Assert.Equal("Stretch", (string?)viewport.Attribute("VerticalAlignment"));
Assert.Equal("Auto", (string?)viewport.Attribute("ScrollViewer.VerticalScrollBarVisibility"));
Assert.Equal("Auto", (string?)viewport.Attribute("ScrollViewer.HorizontalScrollBarVisibility"));
Assert.Equal("True", (string?)viewport.Attribute("ScrollViewer.CanContentScroll"));
Assert.Equal("True", (string?)viewport.Attribute("EnableRowVirtualization"));
Assert.Equal("True", (string?)viewport.Attribute("VirtualizingPanel.IsVirtualizing"));
Assert.Equal("Recycling", (string?)viewport.Attribute("VirtualizingPanel.VirtualizationMode"));
Assert.Equal("2", (string?)viewport.Attribute("FrozenColumnCount"));

var host = viewport.Parent;
Assert.NotNull(host);
Assert.Equal("Grid", host!.Name.LocalName);
var rows = host.Elements().Single(e => e.Name.LocalName == "Grid.RowDefinitions")
.Elements().ToArray();
Assert.Equal(3, rows.Length);
Assert.Equal("*", (string?)rows[2].Attribute("Height"));
}

[Fact]
public void CommandDock_RemainsSeparateFromValuesStarRegion()
{
var xaml = XDocument.Load(Path.Combine(FindRepositoryRoot(), "MainWindow.xaml"));
var viewport = xaml.Descendants().Single(e =>
(string?)e.Attribute(Xaml + "Name") == "ExplorerLiveValuesGrid");
var card = viewport.Parent?.Parent;
var shell = card?.Parent;
Assert.NotNull(shell);
Assert.Equal("Grid", shell!.Name.LocalName);
var shellRows = shell.Elements()
.Single(e => e.Name.LocalName == "Grid.RowDefinitions")
.Elements().ToArray();
Assert.Equal(2, shellRows.Length);
Assert.Equal("*", (string?)shellRows[0].Attribute("Height"));
Assert.Equal("Auto", (string?)shellRows[1].Attribute("Height"));

Assert.Equal("0", (string?)card!.Attribute("Grid.Row"));
var dock = shell.Elements().Single(e =>
(string?)e.Attribute(Xaml + "Name") == "CommandPanelExpander");
Assert.Equal("1", (string?)dock.Attribute("Grid.Row"));
}

private static string FindRepositoryRoot()
{
var folder = new DirectoryInfo(AppContext.BaseDirectory);
while (folder is not null &&
!File.Exists(Path.Combine(folder.FullName, "MainWindow.xaml")))
folder = folder.Parent;
return folder?.FullName ?? throw new InvalidOperationException(
"Test requires the tracked MainWindow.xaml.");
}
}
51 changes: 51 additions & 0 deletions tests/ARSAS.Tests/P0DReadEvidenceBundleTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Security.Cryptography;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;
Expand Down Expand Up @@ -31,6 +32,8 @@ public void OptInBundle_IsBounded_SelfIdentifying_AndDoesNotInventMmsProof()
var root = doc.RootElement;
Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32());
Assert.Equal(2, root.GetProperty("SelectedTargetRows").GetInt32());
Assert.Equal(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(csv))).ToLowerInvariant(),
root.GetProperty("ReadEvidenceCsvSha256").GetString());
Assert.Equal(4, root.GetProperty("Association").GetInt64());
Assert.Equal("1.6.40+appsha", root.GetProperty("AppVersion").GetString());
Assert.Equal("1.6.40+enginesha", root.GetProperty("EngineVersion").GetString());
Expand All @@ -41,6 +44,54 @@ public void OptInBundle_IsBounded_SelfIdentifying_AndDoesNotInventMmsProof()
Assert.Null(archive.GetEntry("network.pcap"));
}

[Fact]
public void ManifestHashMatchesArchivedPayload_AndDetectsModifiedCsv()
{
const string csv = "CanonicalReference,FC,LedgerState\r\n" +
"LAB/LLN0.Mod.origin.orIdent,ST,OBSERVED\r\n";
var archiveBytes = IedBrowserReadEvidenceBundle.Build(
csv, "app", "engine", 7, DateTimeOffset.UnixEpoch);
using var archive = new ZipArchive(new MemoryStream(archiveBytes), ZipArchiveMode.Read);
using var csvStream = archive.GetEntry("read-evidence.csv")!.Open();
using var payload = new MemoryStream();
csvStream.CopyTo(payload);
var actualHash = Convert.ToHexString(SHA256.HashData(payload.ToArray())).ToLowerInvariant();
using var manifestStream = archive.GetEntry("manifest.json")!.Open();
using var manifest = JsonDocument.Parse(manifestStream);
var declaredHash = manifest.RootElement.GetProperty("ReadEvidenceCsvSha256").GetString();
Assert.Equal(declaredHash, actualHash);
Assert.NotEqual(declaredHash,
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(csv + "tampered"))).ToLowerInvariant());
Assert.Equal(1, manifest.RootElement.GetProperty("SelectedTargetRows").GetInt32());
}

[Fact]
public void ManifestCountsCsvRecords_NotEmbeddedQuotedNewlinesOrEscapedQuotes()
{
const string csv = "CanonicalReference,FC,Reason\r\n" +
"\"TESTLD/LLN0.Mod.origin.orIdent\",\"ST\",\"line 1\r\nline 2 with \"\"quotes\"\"\"\r\n" +
"\"TESTLD/LLN0.NamPlt.swRev\",\"DC\",\"Not scheduled\"\r\n";
var bundle = IedBrowserReadEvidenceBundle.Build(
csv, "app", "engine", 9, DateTimeOffset.UnixEpoch);
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
using var manifestStream = zip.GetEntry("manifest.json")!.Open();
using var manifest = JsonDocument.Parse(manifestStream);
Assert.Equal(2, manifest.RootElement.GetProperty("SelectedTargetRows").GetInt32());
}

[Fact]
public void MalformedCsvOrUnboundedTargetCount_FailsClosedWithoutEvidencePacket()
{
static void Package(string csv) => IedBrowserReadEvidenceBundle.Build(
csv, "app", "engine", 9, DateTimeOffset.UnixEpoch);

Assert.Throws<FormatException>(() => Package("Header,Value\n\"unterminated"));
Assert.Throws<FormatException>(() => Package("Header,Value\n\"quoted\"invalid\n"));
Assert.Throws<FormatException>(() => Package("Header,Value\n\nvalue,3\n"));
Assert.Throws<ArgumentOutOfRangeException>(() =>
Package("Header\n" + string.Concat(Enumerable.Repeat("target\n", 4097))));
}

[Fact]
public void OversizedOrNullEvidenceCannotProduceDiagnosticBundle()
{
Expand Down
Loading