From 57e051c18b69e4e76fbac615b5a8db764d94df2a Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:57:29 +0700 Subject: [PATCH 1/7] feat: add deterministic SV evidence bundle exporter --- Services/SmvEvidenceBundleExporter.cs | 240 ++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 Services/SmvEvidenceBundleExporter.cs diff --git a/Services/SmvEvidenceBundleExporter.cs b/Services/SmvEvidenceBundleExporter.cs new file mode 100644 index 000000000..be88d7ed3 --- /dev/null +++ b/Services/SmvEvidenceBundleExporter.cs @@ -0,0 +1,240 @@ +using System.Globalization; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace ArIED61850Tester.Services; + +public sealed record SmvEvidenceBundleRequest +{ + public required string OutputPath { get; init; } + public required SmvSnapshotResult Snapshot { get; init; } + public required byte[] WaveformPng { get; init; } + public required SmvSnapshotSelectionIdentity Selection { get; init; } + public required string ApplicationVersion { get; init; } + public required string ApplicationCommit { get; init; } + public required string EngineRepository { get; init; } + public required string EngineReference { get; init; } + public required string EngineCommit { get; init; } + public DateTimeOffset ExportedAtUtc { get; init; } = DateTimeOffset.UtcNow; +} + +public sealed record SmvEvidenceBundleResult +{ + public required string OutputPath { get; init; } + public required string BundleSha256 { get; init; } + public required IReadOnlyDictionary EntrySha256 { get; init; } +} + +public sealed class SmvEvidenceBundleExporter +{ + private static readonly UTF8Encoding Utf8NoBom = new(false); + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public async Task ExportAsync( + SmvEvidenceBundleRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Snapshot); + ArgumentNullException.ThrowIfNull(request.Selection); + ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputPath); + if (request.WaveformPng.Length == 0) + throw new ArgumentException("Waveform PNG is empty.", nameof(request)); + + var outputPath = Path.GetFullPath(request.OutputPath); + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var entries = BuildEntries(request); + var hashes = entries.ToDictionary( + pair => pair.Key, + pair => ComputeSha256(pair.Value), + StringComparer.Ordinal); + entries["SHA256SUMS.txt"] = Utf8NoBom.GetBytes(BuildChecksums(hashes)); + + var temporaryPath = outputPath + ".partial"; + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + + try + { + await using (var file = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 64 * 1024, + useAsync: true)) + { + using var archive = new ZipArchive(file, ZipArchiveMode.Create, leaveOpen: true); + foreach (var pair in entries.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = archive.CreateEntry(pair.Key, CompressionLevel.Optimal); + entry.LastWriteTime = request.ExportedAtUtc; + await using var stream = entry.Open(); + await stream.WriteAsync(pair.Value, cancellationToken); + } + } + + File.Move(temporaryPath, outputPath, overwrite: true); + var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken); + return new SmvEvidenceBundleResult + { + OutputPath = outputPath, + BundleSha256 = ComputeSha256(bundleBytes), + EntrySha256 = hashes + }; + } + catch + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + throw; + } + } + + internal static Dictionary BuildEntries(SmvEvidenceBundleRequest request) + { + var snapshot = request.Snapshot; + var manifest = new + { + schema = "arsas.sv-evidence-bundle.v1", + exportedAtUtc = request.ExportedAtUtc, + verdict = snapshot.IsCleanProof ? "PASS" : "REVIEW", + boundary = "Bounded passive IEC 61850-9-2 reception evidence; not calibrated measurement or formal conformance evidence.", + application = new + { + name = "ARSAS", + version = request.ApplicationVersion, + commit = request.ApplicationCommit + }, + engine = new + { + repository = request.EngineRepository, + reference = request.EngineReference, + commit = request.EngineCommit + }, + selection = request.Selection, + stream = new + { + appId = $"0x{snapshot.AppId:X4}", + snapshot.SourceMac, + snapshot.DestinationMac, + vlan = snapshot.VlanText, + svId = snapshot.StreamId, + dataSetReference = snapshot.DataSetReference, + snapshot.ConfigurationRevision, + snapshot.SampleSynchronization + }, + capture = new + { + snapshot.NominalFrequencyHz, + snapshot.SamplesPerCycle, + snapshot.CycleCount, + snapshot.TargetSamples, + snapshot.CapturedSamples, + snapshot.CapturedFrames, + snapshot.ParsedAsdus, + snapshot.FirstSampleCount, + snapshot.LastSampleCount, + snapshot.ContinuousTransitions, + snapshot.NormalWraps, + snapshot.GapTransitions, + snapshot.MissingSamples, + snapshot.DuplicateTransitions, + snapshot.OutOfOrderTransitions, + snapshot.RestartTransitions, + durationMilliseconds = snapshot.CaptureDuration.TotalMilliseconds, + snapshot.TimebaseReason, + snapshot.PayloadShape + }, + channels = snapshot.Channels.Select(channel => new + { + channel.ChannelIndex, + channel.PayloadWordIndex, + channel.Label, + channel.Interpretation, + sampleCount = channel.Samples.Count, + channel.Minimum, + channel.Maximum, + channel.PeakToPeak + }) + }; + + return new Dictionary(StringComparer.Ordinal) + { + ["waveform.png"] = request.WaveformPng, + ["samples.csv"] = Utf8NoBom.GetBytes(BuildCsv(snapshot)), + ["manifest.json"] = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions), + ["diagnostics.txt"] = Utf8NoBom.GetBytes(BuildDiagnostics(snapshot)) + }; + } + + internal static string BuildCsv(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(); + builder.Append("sample_index,smp_cnt"); + foreach (var channel in snapshot.Channels) + builder.Append(',').Append(EscapeCsv(channel.Label)); + builder.AppendLine(); + + var sampleCount = snapshot.Channels.Count == 0 + ? 0 + : snapshot.Channels.Min(channel => channel.Samples.Count); + var wrap = Math.Max(1, snapshot.SamplesPerCycle * snapshot.CycleCount); + for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) + { + builder.Append(sampleIndex.ToString(CultureInfo.InvariantCulture)); + var smpCnt = (snapshot.FirstSampleCount + sampleIndex) % wrap; + builder.Append(',').Append(smpCnt.ToString(CultureInfo.InvariantCulture)); + foreach (var channel in snapshot.Channels) + { + builder.Append(',').Append(channel.Samples[sampleIndex].ToString("R", CultureInfo.InvariantCulture)); + } + builder.AppendLine(); + } + + return builder.ToString(); + } + + internal static string BuildDiagnostics(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(); + builder.AppendLine($"Verdict: {(snapshot.IsCleanProof ? "PASS" : "REVIEW")}"); + builder.AppendLine(SmvSnapshotSafetyAssessment.BuildContinuityEvidence(snapshot)); + builder.AppendLine($"Timebase: {snapshot.TimebaseReason}"); + builder.AppendLine($"Payload: {snapshot.PayloadShape}"); + builder.AppendLine("Boundary: raw lanes remain semantically unresolved until ordered SCL mapping and reviewed scaling evidence are bound."); + if (snapshot.Diagnostics.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("Parser and continuity diagnostics:"); + foreach (var diagnostic in snapshot.Diagnostics.Distinct(StringComparer.Ordinal)) + builder.AppendLine($"- {diagnostic}"); + } + return builder.ToString(); + } + + private static string BuildChecksums(IReadOnlyDictionary hashes) + => string.Join("\n", hashes.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Value} {pair.Key}")) + "\n"; + + private static string ComputeSha256(byte[] bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static string EscapeCsv(string value) + { + var text = value ?? string.Empty; + if (!text.Contains(',') && !text.Contains('"') && !text.Contains('\n') && !text.Contains('\r')) + return text; + return $"\"{text.Replace("\"", "\"\"")}\""; + } +} From 877c8b3b0ee742bd5ebcccac740c24010d2fbe94 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:58:25 +0700 Subject: [PATCH 2/7] feat: initialize P1 SV evidence controls --- SmvViewerWindow.P0Hardening.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SmvViewerWindow.P0Hardening.cs b/SmvViewerWindow.P0Hardening.cs index 78e6ee511..66a7a9010 100644 --- a/SmvViewerWindow.P0Hardening.cs +++ b/SmvViewerWindow.P0Hardening.cs @@ -20,6 +20,7 @@ protected override void OnInitialized(EventArgs e) base.OnInitialized(e); CaptureButton.AddHandler(Button.ClickEvent, new RoutedEventHandler(P0CaptureButton_Click), handledEventsToo: true); SnapshotChannels.CollectionChanged += P0SnapshotChannels_CollectionChanged; + InitializeP1EvidenceBundle(); } private void P0CaptureButton_Click(object sender, RoutedEventArgs e) From f9544178216faa8f0cffd4df67ff6389fc81fa45 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:59:09 +0700 Subject: [PATCH 3/7] feat: add SV evidence bundle export workflow --- SmvViewerWindow.P1EvidenceBundle.cs | 208 ++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 SmvViewerWindow.P1EvidenceBundle.cs diff --git a/SmvViewerWindow.P1EvidenceBundle.cs b/SmvViewerWindow.P1EvidenceBundle.cs new file mode 100644 index 000000000..24ea28d3a --- /dev/null +++ b/SmvViewerWindow.P1EvidenceBundle.cs @@ -0,0 +1,208 @@ +using System.Reflection; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.Win32; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class SmvViewerWindow +{ + private readonly SmvEvidenceBundleExporter _evidenceBundleExporter = new(); + private Button? _exportEvidenceButton; + + private void InitializeP1EvidenceBundle() + { + Loaded += (_, _) => InstallEvidenceBundleButton(); + } + + private void InstallEvidenceBundleButton() + { + if (_exportEvidenceButton is not null) + return; + + var closeButton = FindButtonByContent(this, "Close"); + if (closeButton is null || VisualTreeHelper.GetParent(closeButton) is not Grid footerGrid) + return; + + if (footerGrid.ColumnDefinitions.Count < 2) + return; + + footerGrid.ColumnDefinitions.Insert(1, new ColumnDefinition { Width = GridLength.Auto }); + Grid.SetColumn(closeButton, 2); + + _exportEvidenceButton = new Button + { + Content = "Export evidence bundle", + Width = 160, + Margin = new Thickness(12, 0, 0, 0), + IsEnabled = _snapshot is not null, + ToolTip = "Export PNG waveform, raw CSV, JSON manifest, diagnostics, SHA-256 checksums, and app/engine provenance." + }; + if (TryFindResource("SoftButton") is Style style) + _exportEvidenceButton.Style = style; + _exportEvidenceButton.Click += ExportEvidenceBundle_Click; + Grid.SetColumn(_exportEvidenceButton, 1); + footerGrid.Children.Add(_exportEvidenceButton); + + SnapshotChannels.CollectionChanged += (_, _) => RefreshEvidenceBundleButton(); + RefreshEvidenceBundleButton(); + } + + private void RefreshEvidenceBundleButton() + { + if (_exportEvidenceButton is not null) + _exportEvidenceButton.IsEnabled = _snapshot is not null && !CancelButton.IsEnabled; + } + + private async void ExportEvidenceBundle_Click(object sender, RoutedEventArgs e) + { + var snapshot = _snapshot; + var selection = _p0CaptureSelection; + if (snapshot is null || selection is null) + { + MessageBox.Show(this, "Capture and accept an SV snapshot before exporting evidence.", "SV Evidence Bundle", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var timestamp = DateTimeOffset.UtcNow; + var safeStream = SanitizeFileName(string.IsNullOrWhiteSpace(snapshot.StreamId) ? $"appid-{snapshot.AppId:X4}" : snapshot.StreamId); + var dialog = new SaveFileDialog + { + Title = "Export SV evidence bundle", + Filter = "ARSAS SV evidence bundle (*.zip)|*.zip", + DefaultExt = ".zip", + AddExtension = true, + FileName = $"ARSAS-SV-{safeStream}-{timestamp:yyyyMMdd-HHmmss}Z.zip" + }; + if (dialog.ShowDialog(this) != true) + return; + + try + { + _exportEvidenceButton!.IsEnabled = false; + CaptureStatusText = "Exporting auditable SV evidence bundle…"; + StatusText = "ARSAS is writing waveform, raw samples, manifest, diagnostics, integrity hashes, and provenance without modifying the captured stream."; + + var provenance = ReadBuildProvenance(); + var result = await _evidenceBundleExporter.ExportAsync(new SmvEvidenceBundleRequest + { + OutputPath = dialog.FileName, + Snapshot = snapshot, + WaveformPng = RenderWaveformPng(), + Selection = selection, + ApplicationVersion = provenance.ApplicationVersion, + ApplicationCommit = provenance.ApplicationCommit, + EngineRepository = provenance.EngineRepository, + EngineReference = provenance.EngineReference, + EngineCommit = provenance.EngineCommit, + ExportedAtUtc = timestamp + }); + + CaptureStatusText = "SV evidence bundle exported."; + StatusText = $"Evidence bundle saved to {result.OutputPath}. Bundle SHA-256: {result.BundleSha256}."; + MessageBox.Show( + this, + $"Evidence bundle exported successfully.\n\n{result.OutputPath}\n\nSHA-256\n{result.BundleSha256}", + "SV Evidence Bundle", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + CaptureStatusText = "Evidence bundle export failed."; + StatusText = $"SV evidence export failed: {ex.Message}"; + MessageBox.Show(this, ex.Message, "SV Evidence Bundle", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + RefreshEvidenceBundleButton(); + } + } + + private byte[] RenderWaveformPng() + { + UpdateLayout(); + RenderWaveform(); + WaveformCanvas.UpdateLayout(); + + var width = Math.Max(1, (int)Math.Ceiling(Math.Max(WaveformCanvas.ActualWidth, 760))); + var height = Math.Max(1, (int)Math.Ceiling(Math.Max(WaveformCanvas.ActualHeight, WaveformCanvas.Height))); + var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + + var background = new DrawingVisual(); + using (var context = background.RenderOpen()) + { + context.DrawRectangle(Brushes.White, null, new Rect(0, 0, width, height)); + var brush = new VisualBrush(WaveformCanvas) + { + Stretch = Stretch.None, + AlignmentX = AlignmentX.Left, + AlignmentY = AlignmentY.Top + }; + context.DrawRectangle(brush, null, new Rect(0, 0, width, height)); + } + bitmap.Render(background); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var stream = new MemoryStream(); + encoder.Save(stream); + return stream.ToArray(); + } + + private static BuildProvenance ReadBuildProvenance() + { + var assembly = typeof(SmvViewerWindow).Assembly; + var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + var version = informationalVersion.Split('+')[0]; + var applicationCommit = informationalVersion.Contains('+') + ? informationalVersion[(informationalVersion.IndexOf('+') + 1)..] + : "not-embedded"; + + using var stream = assembly.GetManifestResourceStream("ARSAS.ARIEC61850.lock.json") + ?? throw new InvalidOperationException("Embedded ARIEC61850 provenance lock was not found."); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + return new BuildProvenance( + version, + applicationCommit, + root.GetProperty("repository").GetString() ?? "unknown", + root.GetProperty("ref").GetString() ?? "unknown", + root.GetProperty("commit").GetString() ?? "unknown"); + } + + private static Button? FindButtonByContent(DependencyObject root, string content) + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is Button button && string.Equals(button.Content?.ToString(), content, StringComparison.Ordinal)) + return button; + var nested = FindButtonByContent(child, content); + if (nested is not null) + return nested; + } + return null; + } + + private static string SanitizeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(character => invalid.Contains(character) ? '-' : character).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "stream" : sanitized.Trim(); + } + + private sealed record BuildProvenance( + string ApplicationVersion, + string ApplicationCommit, + string EngineRepository, + string EngineReference, + string EngineCommit); +} From 916f003297047eaa40b8c84fb923f2b7a61b2d70 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:59:45 +0700 Subject: [PATCH 4/7] build: embed immutable engine provenance in ARSAS --- ArIED61850Tester.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index d57b561bc..68e71b540 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -40,6 +40,7 @@ + From 30cef566cba1471704d563f22ecbddfe292efc9c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:00:28 +0700 Subject: [PATCH 5/7] test: validate complete SV evidence bundle contents --- .../SmvEvidenceBundleExporterTests.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs diff --git a/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs b/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs new file mode 100644 index 000000000..58448a31b --- /dev/null +++ b/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs @@ -0,0 +1,134 @@ +using System.IO.Compression; +using System.Text.Json; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SmvEvidenceBundleExporterTests +{ + [Fact] + public async Task ExportAsync_WritesAuditableBundleWithIntegrityEvidence() + { + var outputPath = Path.Combine(Path.GetTempPath(), $"arsas-sv-evidence-{Guid.NewGuid():N}.zip"); + try + { + var snapshot = CreateSnapshot(); + var exporter = new SmvEvidenceBundleExporter(); + var result = await exporter.ExportAsync(new SmvEvidenceBundleRequest + { + OutputPath = outputPath, + Snapshot = snapshot, + WaveformPng = [137, 80, 78, 71, 13, 10, 26, 10], + Selection = SmvSnapshotSelectionIdentity.Create( + "IED1LD0/LLN0$MSVCB01", + "MU01", + "IED1LD0/LLN0$Dataset01", + "0x4000", + "01-0C-CD-04-00-01"), + ApplicationVersion = "1.6.19", + ApplicationCommit = "abc123", + EngineRepository = "masarray/ARIEC61850", + EngineReference = "main", + EngineCommit = "0f8453182957900bc6d91287fb8177c8d9762188", + ExportedAtUtc = new DateTimeOffset(2026, 7, 28, 0, 0, 0, TimeSpan.Zero) + }); + + Assert.True(File.Exists(outputPath)); + Assert.Matches("^[0-9a-f]{64}$", result.BundleSha256); + + using var archive = ZipFile.OpenRead(outputPath); + var names = archive.Entries.Select(entry => entry.FullName).OrderBy(name => name).ToArray(); + Assert.Equal( + ["SHA256SUMS.txt", "diagnostics.txt", "manifest.json", "samples.csv", "waveform.png"], + names); + + var manifestText = await ReadEntryAsync(archive, "manifest.json"); + using var manifest = JsonDocument.Parse(manifestText); + Assert.Equal("arsas.sv-evidence-bundle.v1", manifest.RootElement.GetProperty("schema").GetString()); + Assert.Equal("REVIEW", manifest.RootElement.GetProperty("verdict").GetString()); + Assert.Equal("1.6.19", manifest.RootElement.GetProperty("application").GetProperty("version").GetString()); + Assert.Equal( + "0f8453182957900bc6d91287fb8177c8d9762188", + manifest.RootElement.GetProperty("engine").GetProperty("commit").GetString()); + + var csv = await ReadEntryAsync(archive, "samples.csv"); + Assert.Contains("sample_index,smp_cnt,Raw lane 1,Raw lane 2", csv, StringComparison.Ordinal); + Assert.Contains("0,10,100,-100", csv, StringComparison.Ordinal); + Assert.Contains("2,12,120,-120", csv, StringComparison.Ordinal); + + var diagnostics = await ReadEntryAsync(archive, "diagnostics.txt"); + Assert.Contains("Verdict: REVIEW", diagnostics, StringComparison.Ordinal); + Assert.Contains("restart 1", diagnostics, StringComparison.Ordinal); + + var checksums = await ReadEntryAsync(archive, "SHA256SUMS.txt"); + Assert.Contains(" manifest.json", checksums, StringComparison.Ordinal); + Assert.Contains(" samples.csv", checksums, StringComparison.Ordinal); + Assert.Contains(" waveform.png", checksums, StringComparison.Ordinal); + } + finally + { + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + private static SmvSnapshotResult CreateSnapshot() + => new() + { + StartedAt = new DateTimeOffset(2026, 7, 28, 0, 0, 0, TimeSpan.Zero), + CompletedAt = new DateTimeOffset(2026, 7, 28, 0, 0, 0, 40, TimeSpan.Zero), + AppId = 0x4000, + SourceMac = "00-11-22-33-44-55", + DestinationMac = "01-0C-CD-04-00-01", + VlanText = "100", + StreamId = "MU01", + DataSetReference = "IED1LD0/LLN0$Dataset01", + ConfigurationRevision = 7, + SampleSynchronization = 2, + NominalFrequencyHz = 50, + SamplesPerCycle = 80, + CycleCount = 2, + TargetSamples = 3, + CapturedSamples = 3, + CapturedFrames = 3, + ParsedAsdus = 3, + FirstSampleCount = 10, + LastSampleCount = 12, + ContinuousTransitions = 1, + RestartTransitions = 1, + TimebaseReason = "explicit test evidence", + PayloadShape = "16 bytes · 2 raw 32-bit words", + Diagnostics = ["Publisher restart observed."], + Channels = + [ + new SmvSnapshotChannel + { + ChannelIndex = 1, + PayloadWordIndex = 0, + Label = "Raw lane 1", + Interpretation = "Signed INT32", + Samples = [100, 110, 120], + Minimum = 100, + Maximum = 120 + }, + new SmvSnapshotChannel + { + ChannelIndex = 2, + PayloadWordIndex = 1, + Label = "Raw lane 2", + Interpretation = "Signed INT32", + Samples = [-100, -110, -120], + Minimum = -120, + Maximum = -100 + } + ] + }; + + private static async Task ReadEntryAsync(ZipArchive archive, string name) + { + var entry = archive.GetEntry(name) ?? throw new InvalidDataException($"Missing ZIP entry: {name}"); + await using var stream = entry.Open(); + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); + } +} From 427e9e640e9fa57144a68ffc8a75f99aed053f0a Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:01:00 +0700 Subject: [PATCH 6/7] docs: record P1 SV evidence bundle --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afcd257e..6c330036c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,13 @@ Notable public changes to ARSAS are recorded here. Application releases must ide - Regression coverage for clean and incomplete SV windows, gap/duplicate/out-of-order/restart handling, continuity evidence, and immutable stream-selection identity. - Canonical `Directory.Build.props` version metadata aligned with the project, `VERSION`, packaging, and CI. - Test-result artifacts in the Windows build workflow. +- A single auditable SV Evidence Bundle export containing the rendered waveform PNG, raw-sample CSV, structured manifest JSON, parser/continuity diagnostics, per-entry SHA-256 integrity file, and application/engine provenance. +- Regression coverage that opens the generated ZIP and validates its required evidence files, verdict, provenance, raw samples, diagnostics, and checksum listing. ### Changed - Windows CI restores and builds the complete solution, runs application regression tests, and only then publishes the portable package. +- The SMV Snapshot Viewer enables evidence export only after a snapshot is accepted and keeps the export disabled during active capture. - Development version advanced to `1.6.19`. The currently published stable release remains `1.6.18` until a separately validated and tagged release is produced. ## 1.6.18 From 6407ece17426806f181893f37d1de0aac3bc63e6 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 13:05:46 +0700 Subject: [PATCH 7/7] test: use deterministic ordinal ZIP entry ordering --- tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs b/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs index 58448a31b..e0b1ea8bb 100644 --- a/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs +++ b/tests/ARSAS.Tests/SmvEvidenceBundleExporterTests.cs @@ -37,7 +37,10 @@ public async Task ExportAsync_WritesAuditableBundleWithIntegrityEvidence() Assert.Matches("^[0-9a-f]{64}$", result.BundleSha256); using var archive = ZipFile.OpenRead(outputPath); - var names = archive.Entries.Select(entry => entry.FullName).OrderBy(name => name).ToArray(); + var names = archive.Entries + .Select(entry => entry.FullName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); Assert.Equal( ["SHA256SUMS.txt", "diagnostics.txt", "manifest.json", "samples.csv", "waveform.png"], names);