From cd9c127acf534bc45d687ebf6394aa7ea986d402 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:12:50 +0700 Subject: [PATCH 01/17] feat: add deterministic SV evidence bundle exporter --- Services/SmvSnapshotEvidenceExporter.cs | 418 ++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 Services/SmvSnapshotEvidenceExporter.cs diff --git a/Services/SmvSnapshotEvidenceExporter.cs b/Services/SmvSnapshotEvidenceExporter.cs new file mode 100644 index 000000000..32313318c --- /dev/null +++ b/Services/SmvSnapshotEvidenceExporter.cs @@ -0,0 +1,418 @@ +using System.Globalization; +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace ArIED61850Tester.Services; + +public sealed record SmvSnapshotEvidenceProvenance +{ + public string ApplicationName { get; init; } = "ARSAS"; + public string ApplicationVersion { get; init; } = string.Empty; + public string ApplicationInformationalVersion { get; init; } = string.Empty; + public string ApplicationRepository { get; init; } = "masarray/arsas"; + public string EngineRepository { get; init; } = string.Empty; + public string EngineRef { get; init; } = string.Empty; + public string EngineCommit { get; init; } = string.Empty; + public int? EnginePullRequest { get; init; } + + public static SmvSnapshotEvidenceProvenance LoadCurrent(string? baseDirectory = null) + { + var assembly = typeof(SmvSnapshotEvidenceProvenance).Assembly; + var version = assembly.GetName().Version?.ToString(3) ?? string.Empty; + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion ?? version; + + var provenance = new SmvSnapshotEvidenceProvenance + { + ApplicationVersion = version, + ApplicationInformationalVersion = informationalVersion + }; + + var root = string.IsNullOrWhiteSpace(baseDirectory) ? AppContext.BaseDirectory : baseDirectory; + var candidates = new[] + { + Path.Combine(root, "engines", "ARIEC61850.lock.json"), + Path.Combine(Directory.GetCurrentDirectory(), "engines", "ARIEC61850.lock.json") + }; + + foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!File.Exists(candidate)) + continue; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(candidate, Encoding.UTF8)); + var rootElement = document.RootElement; + return provenance with + { + EngineRepository = ReadString(rootElement, "repository"), + EngineRef = ReadString(rootElement, "ref"), + EngineCommit = ReadString(rootElement, "commit"), + EnginePullRequest = rootElement.TryGetProperty("pairedPullRequest", out var pullRequest) && + pullRequest.TryGetInt32(out var pullRequestNumber) + ? pullRequestNumber + : null + }; + } + catch (JsonException) + { + // Export remains available, but the manifest will clearly show missing engine provenance. + } + } + + return provenance; + } + + private static string ReadString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : string.Empty; +} + +public sealed record SmvSnapshotEvidenceContext +{ + public required DateTimeOffset GeneratedAtUtc { get; init; } + public required string DeviceName { get; init; } + public required string EndpointText { get; init; } + public required string AdapterDisplayText { get; init; } + public required string ControlReference { get; init; } + public required string SelectedStreamId { get; init; } + public required string SelectedDataSetReference { get; init; } + public required string SelectedAppId { get; init; } + public required string SelectedDestinationMac { get; init; } + public required double ExplicitNominalFrequencyHz { get; init; } + public required SmvSnapshotEvidenceProvenance Provenance { get; init; } +} + +public sealed record SmvSnapshotEvidenceBundleResult +{ + public required string BundlePath { get; init; } + public required string BundleSha256 { get; init; } + public required IReadOnlyList Entries { get; init; } +} + +/// +/// Produces one portable, deterministic and reviewable evidence package for a bounded SV snapshot. +/// The package deliberately preserves raw values and safety boundaries; it never invents current, +/// voltage, phase or engineering-unit semantics without trusted ordered SCL mapping. +/// +public static class SmvSnapshotEvidenceExporter +{ + public const string SchemaVersion = "arsas.sv-evidence.v1"; + private static readonly DateTimeOffset FixedZipTimestamp = + new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static async Task ExportAsync( + string destinationZipPath, + SmvSnapshotResult snapshot, + SmvSnapshotEvidenceContext context, + ReadOnlyMemory waveformPng, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationZipPath); + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(context); + if (waveformPng.IsEmpty) + throw new ArgumentException("A rendered waveform PNG is required for the SV evidence bundle.", nameof(waveformPng)); + + var fullDestinationPath = Path.GetFullPath(destinationZipPath); + var destinationDirectory = Path.GetDirectoryName(fullDestinationPath) + ?? throw new InvalidOperationException("The evidence bundle destination has no parent directory."); + Directory.CreateDirectory(destinationDirectory); + + var files = new SortedDictionary(StringComparer.Ordinal) + { + ["README.txt"] = Utf8(BuildReadme(snapshot, context)), + ["diagnostics.txt"] = Utf8(BuildDiagnostics(snapshot)), + ["manifest.json"] = Json(BuildManifest(snapshot, context)), + ["provenance.json"] = Json(context.Provenance), + ["samples.csv"] = Utf8(BuildSamplesCsv(snapshot)), + ["waveform.png"] = waveformPng.ToArray() + }; + files["SHA256SUMS.txt"] = Utf8(BuildChecksums(files)); + + var temporaryPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var output = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 131072, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: false, Encoding.UTF8)) + { + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = archive.CreateEntry(file.Key, CompressionLevel.Optimal); + entry.LastWriteTime = FixedZipTimestamp; + await using var stream = entry.Open(); + await stream.WriteAsync(file.Value, cancellationToken).ConfigureAwait(false); + } + } + + File.Move(temporaryPath, fullDestinationPath, overwrite: true); + return new SmvSnapshotEvidenceBundleResult + { + BundlePath = fullDestinationPath, + BundleSha256 = await ComputeFileSha256Async(fullDestinationPath, cancellationToken).ConfigureAwait(false), + Entries = files.Keys.ToArray() + }; + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + public static string BuildSuggestedFileName( + SmvSnapshotEvidenceContext context, + SmvSnapshotResult snapshot) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(snapshot); + + var identity = FirstNonEmpty( + context.SelectedStreamId, + snapshot.StreamId, + context.ControlReference, + $"APPID-{snapshot.AppId:X4}"); + var safeIdentity = SanitizeFileName(identity, 56); + return $"ARSAS-SV-Evidence-{safeIdentity}-{context.GeneratedAtUtc:yyyyMMdd-HHmmss}Z.zip"; + } + + private static object BuildManifest(SmvSnapshotResult snapshot, SmvSnapshotEvidenceContext context) + => new + { + schemaVersion = SchemaVersion, + generatedAtUtc = context.GeneratedAtUtc.ToUniversalTime(), + verdict = snapshot.IsCleanProof ? "PASS" : "REVIEW", + proofBoundary = new + { + proves = "Bounded reception, IEC 61850-9-2 parsing, stable seqOfData shape and observable sample-counter continuity for the selected window.", + doesNotProve = "Calibrated current/voltage accuracy, formal conformance, universal interoperability or channel semantics without trusted ordered SCL mapping and reviewed scaling evidence." + }, + operatorSelection = new + { + context.DeviceName, + context.EndpointText, + context.AdapterDisplayText, + context.ControlReference, + streamId = context.SelectedStreamId, + dataSetReference = context.SelectedDataSetReference, + appId = context.SelectedAppId, + destinationMac = context.SelectedDestinationMac, + context.ExplicitNominalFrequencyHz + }, + observedStream = new + { + appId = $"0x{snapshot.AppId:X4}", + snapshot.SourceMac, + snapshot.DestinationMac, + vlan = snapshot.VlanText, + streamId = snapshot.StreamId, + dataSetReference = snapshot.DataSetReference, + snapshot.ConfigurationRevision, + snapshot.SampleSynchronization, + snapshot.DeclaredSampleRate, + snapshot.DeclaredSampleMode + }, + timebase = new + { + snapshot.NominalFrequencyHz, + snapshot.SamplesPerCycle, + snapshot.CycleCount, + snapshot.TargetSamples, + snapshot.CapturedSamples, + captureDurationMilliseconds = snapshot.CaptureDuration.TotalMilliseconds, + reason = snapshot.TimebaseReason + }, + transport = new + { + snapshot.CapturedFrames, + snapshot.ParsedAsdus, + firstSampleCount = snapshot.FirstSampleCount, + lastSampleCount = snapshot.LastSampleCount + }, + continuity = new + { + snapshot.ContinuousTransitions, + snapshot.NormalWraps, + snapshot.GapTransitions, + snapshot.MissingSamples, + snapshot.DuplicateTransitions, + snapshot.OutOfOrderTransitions, + snapshot.RestartTransitions, + snapshot.HasCounterAnomaly + }, + payload = new + { + shape = snapshot.PayloadShape, + plottedLaneCount = snapshot.Channels.Count, + lanes = snapshot.Channels.Select(channel => new + { + channel.ChannelIndex, + channel.PayloadWordIndex, + channel.Label, + channel.Interpretation, + sampleCount = channel.Samples.Count, + channel.Minimum, + channel.Maximum, + channel.PeakToPeak + }) + }, + diagnostics = snapshot.Diagnostics, + files = new + { + rawSamples = "samples.csv", + waveform = "waveform.png", + diagnostics = "diagnostics.txt", + provenance = "provenance.json", + checksums = "SHA256SUMS.txt" + } + }; + + private static string BuildSamplesCsv(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(32768); + builder.Append("sampleIndex,cyclePosition"); + foreach (var channel in snapshot.Channels) + builder.Append(',').Append(CsvCell($"{channel.Label} [raw INT32]")); + builder.AppendLine(); + + var rowCount = snapshot.Channels.Count == 0 + ? 0 + : snapshot.Channels.Max(channel => channel.Samples.Count); + for (var sampleIndex = 0; sampleIndex < rowCount; sampleIndex++) + { + builder.Append(sampleIndex.ToString(CultureInfo.InvariantCulture)); + builder.Append(',').Append( + (sampleIndex / (double)Math.Max(1, snapshot.SamplesPerCycle)) + .ToString("0.########", CultureInfo.InvariantCulture)); + + foreach (var channel in snapshot.Channels) + { + builder.Append(','); + if (sampleIndex < channel.Samples.Count) + builder.Append(channel.Samples[sampleIndex].ToString("R", CultureInfo.InvariantCulture)); + } + builder.AppendLine(); + } + + return builder.ToString(); + } + + private static string BuildDiagnostics(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(); + builder.AppendLine($"Verdict: {(snapshot.IsCleanProof ? "PASS" : "REVIEW")}"); + builder.AppendLine($"Continuity: gaps={snapshot.GapTransitions}, missing={snapshot.MissingSamples}, duplicates={snapshot.DuplicateTransitions}, outOfOrder={snapshot.OutOfOrderTransitions}, restarts={snapshot.RestartTransitions}"); + builder.AppendLine($"Timebase: {snapshot.TimebaseReason}"); + builder.AppendLine($"Payload: {snapshot.PayloadShape}"); + builder.AppendLine(); + builder.AppendLine("Engine diagnostics:"); + if (snapshot.Diagnostics.Count == 0) + builder.AppendLine("- none"); + else + foreach (var diagnostic in snapshot.Diagnostics) + builder.Append("- ").AppendLine(diagnostic); + return builder.ToString(); + } + + private static string BuildReadme(SmvSnapshotResult snapshot, SmvSnapshotEvidenceContext context) + => $""" + ARSAS Sampled Values Evidence Bundle + ==================================== + + Schema: {SchemaVersion} + Generated UTC: {context.GeneratedAtUtc.ToUniversalTime():O} + Verdict: {(snapshot.IsCleanProof ? "PASS" : "REVIEW")} + + This package records one bounded IEC 61850-9-2 observation window. + Raw lanes are exported exactly as signed numeric payload representations. + + IMPORTANT BOUNDARY + ------------------ + This package proves bounded reception, protocol parsing, stable payload shape and + sample-counter observability for the selected stream. It does not by itself prove + calibrated current/voltage accuracy, formal conformance, universal interoperability, + or IA/IB/IC/IN/VA/VB/VC/VN semantics. Those claims require trusted ordered SCL mapping, + reviewed scaling evidence, known injection and controlled field acceptance. + + Files + ----- + manifest.json Structured capture identity, verdict, counters and payload metadata. + provenance.json ARSAS and ARIEC61850 source provenance. + samples.csv Raw lane samples with invariant-culture numeric formatting. + waveform.png Static visual proof rendered by ARSAS. + diagnostics.txt Continuity and engine diagnostics. + SHA256SUMS.txt SHA-256 digests for every evidence payload file. + """; + + private static string BuildChecksums(IReadOnlyDictionary files) + { + var builder = new StringBuilder(); + foreach (var file in files.OrderBy(item => item.Key, StringComparer.Ordinal)) + builder.Append(Convert.ToHexStringLower(SHA256.HashData(file.Value))) + .Append(" ") + .AppendLine(file.Key); + return builder.ToString(); + } + + private static byte[] Json(object value) + => JsonSerializer.SerializeToUtf8Bytes(value, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }); + + private static byte[] Utf8(string value) + => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(value.Replace("\r\n", "\n", StringComparison.Ordinal)); + + private static string CsvCell(string value) + => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + + private static string SanitizeFileName(string value, int maximumLength) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value + .Trim() + .Select(character => invalid.Contains(character) || char.IsControl(character) ? '-' : character) + .ToArray()); + while (sanitized.Contains("--", StringComparison.Ordinal)) + sanitized = sanitized.Replace("--", "-", StringComparison.Ordinal); + sanitized = sanitized.Trim(' ', '.', '-'); + if (sanitized.Length > maximumLength) + sanitized = sanitized[..maximumLength].TrimEnd(' ', '.', '-'); + return string.IsNullOrWhiteSpace(sanitized) ? "SV-stream" : sanitized; + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "SV-stream"; + + private static async Task ComputeFileSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 131072, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[131072]; + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + hash.AppendData(buffer, 0, read); + } + return Convert.ToHexStringLower(hash.GetHashAndReset()); + } +} \ No newline at end of file From 1a54c69d77ff73b82b3b115d0c51dee83c83cc07 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:13:44 +0700 Subject: [PATCH 02/17] feat: add SV evidence bundle export workflow --- SmvViewerWindow.P1Evidence.cs | 151 ++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 SmvViewerWindow.P1Evidence.cs diff --git a/SmvViewerWindow.P1Evidence.cs b/SmvViewerWindow.P1Evidence.cs new file mode 100644 index 000000000..04a65e77f --- /dev/null +++ b/SmvViewerWindow.P1Evidence.cs @@ -0,0 +1,151 @@ +using System.ComponentModel; +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ArIED61850Tester.Services; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class SmvViewerWindow +{ + private bool _isEvidenceExportBusy; + private string _evidenceExportStatusText = + "Capture a snapshot to enable the portable engineering evidence bundle."; + + public bool IsEvidenceExportBusy + { + get => _isEvidenceExportBusy; + private set + { + if (_isEvidenceExportBusy == value) + return; + _isEvidenceExportBusy = value; + Raise(); + } + } + + public string EvidenceExportStatusText + { + get => _evidenceExportStatusText; + private set + { + if (_evidenceExportStatusText == value) + return; + _evidenceExportStatusText = value; + Raise(); + } + } + + private async void ExportEvidence_Click(object sender, RoutedEventArgs e) + { + var snapshot = _snapshot; + if (snapshot is null) + { + MessageBox.Show( + this, + "Capture and accept one bounded SV snapshot before exporting evidence.", + "SV Evidence Bundle", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var stream = SelectedStream; + var context = new SmvSnapshotEvidenceContext + { + GeneratedAtUtc = DateTimeOffset.UtcNow, + DeviceName = DeviceName, + EndpointText = EndpointText, + AdapterDisplayText = SelectedAdapter?.DisplayText ?? "Unrecorded adapter", + ControlReference = stream?.ControlReference ?? string.Empty, + SelectedStreamId = stream?.StreamId ?? snapshot.StreamId, + SelectedDataSetReference = stream?.DataSetReference ?? snapshot.DataSetReference, + SelectedAppId = stream?.AppId ?? $"0x{snapshot.AppId:X4}", + SelectedDestinationMac = stream?.DestinationMac ?? snapshot.DestinationMac, + ExplicitNominalFrequencyHz = ReadSelectedFrequency(), + Provenance = SmvSnapshotEvidenceProvenance.LoadCurrent() + }; + + var dialog = new SaveFileDialog + { + Title = "Export ARSAS SV evidence bundle", + Filter = "ARSAS SV evidence bundle (*.zip)|*.zip", + DefaultExt = ".zip", + AddExtension = true, + OverwritePrompt = true, + FileName = SmvSnapshotEvidenceExporter.BuildSuggestedFileName(context, snapshot) + }; + if (dialog.ShowDialog(this) != true) + return; + + IsEvidenceExportBusy = true; + ExportEvidenceButton.IsEnabled = false; + EvidenceExportStatusText = "Rendering waveform and building evidence package…"; + + try + { + await Dispatcher.InvokeAsync(RenderWaveform); + var waveformPng = RenderWaveformPng(); + var result = await SmvSnapshotEvidenceExporter.ExportAsync( + dialog.FileName, + snapshot, + context, + waveformPng); + + EvidenceExportStatusText = + $"Evidence exported: {Path.GetFileName(result.BundlePath)} · SHA-256 {result.BundleSha256[..12]}…"; + StatusText = + $"SV evidence bundle exported with {result.Entries.Count:N0} auditable entries. The raw-value and calibration boundaries remain explicit."; + + MessageBox.Show( + this, + $"Evidence bundle created successfully.\n\n{result.BundlePath}\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) + { + EvidenceExportStatusText = $"Evidence export failed: {ex.Message}"; + StatusText = "No partial evidence package was accepted. Choose a writable destination and retry."; + MessageBox.Show( + this, + ex.Message, + "SV Evidence Export Failed", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + finally + { + IsEvidenceExportBusy = false; + ExportEvidenceButton.IsEnabled = _snapshot is not null && !CancelButton.IsEnabled; + } + } + + private byte[] RenderWaveformPng() + { + WaveformCanvas.UpdateLayout(); + var width = Math.Max(1, (int)Math.Ceiling(Math.Max(WaveformCanvas.ActualWidth, WaveformCanvas.Width))); + var height = Math.Max(1, (int)Math.Ceiling(Math.Max(WaveformCanvas.ActualHeight, WaveformCanvas.Height))); + var bounds = new Rect(0, 0, width, height); + + var visual = new DrawingVisual(); + using (var drawing = visual.RenderOpen()) + { + drawing.DrawRectangle(Brushes.White, null, bounds); + drawing.DrawRectangle(new VisualBrush(WaveformCanvas), null, bounds); + } + + var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + bitmap.Freeze(); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var output = new MemoryStream(); + encoder.Save(output); + return output.ToArray(); + } +} \ No newline at end of file From ad1756d25bc7e8a10ea7eb0e829f00da750dd9b3 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 28 Jul 2026 12:14:40 +0700 Subject: [PATCH 03/17] feat: expose auditable SV evidence export in viewer --- SmvViewerWindow.xaml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/SmvViewerWindow.xaml b/SmvViewerWindow.xaml index a5dfd6e8c..7475bda66 100644 --- a/SmvViewerWindow.xaml +++ b/SmvViewerWindow.xaml @@ -208,15 +208,37 @@ + + - +