diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml
new file mode 100644
index 000000000..b05239cab
--- /dev/null
+++ b/.github/workflows/validate-sv-evidence.yml
@@ -0,0 +1,94 @@
+name: Validate SV evidence bundles
+
+on:
+ pull_request:
+ paths:
+ - "SmvViewerWindow.xaml"
+ - "Services/SmvViewerWindow.P1Evidence.cs"
+ - "Services/SmvSnapshotEvidenceExporter.cs"
+ - "Services/SmvSnapshotCaptureService.cs"
+ - "tests/ARSAS.Tests/**"
+ - "engines/ARIEC61850.lock.json"
+ - ".github/workflows/validate-sv-evidence.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate-evidence:
+ name: Build and test deterministic SV evidence
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout ARSAS application
+ uses: actions/checkout@v4
+ with:
+ path: ARSAS
+
+ - name: Resolve immutable ARIEC61850 engine lock
+ shell: powershell
+ run: |
+ $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json
+ if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') {
+ throw "ARIEC61850 lock metadata is invalid."
+ }
+ "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Checkout immutable ARIEC61850 engine revision
+ shell: powershell
+ run: |
+ git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850
+ git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT
+ git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT
+ $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim()
+ if ($actual -ne $env:ARIEC61850_COMMIT) {
+ throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual."
+ }
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore focused test project
+ run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj
+
+ - name: Build focused test project
+ shell: powershell
+ run: |
+ $log = ".\ARSAS\sv-evidence-build.log"
+ dotnet build .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-restore *> $log
+ $buildExitCode = $LASTEXITCODE
+ if ($buildExitCode -ne 0) {
+ Write-Host "=== Focused compiler errors ===" -ForegroundColor Red
+ $errors = @(Select-String -Path $log -Pattern ': error ')
+ if ($errors.Count -eq 0) {
+ Get-Content $log -Tail 80 | ForEach-Object { Write-Host $_ -ForegroundColor Red }
+ }
+ else {
+ $errors | ForEach-Object { Write-Host $_.Line -ForegroundColor Red }
+ }
+ exit $buildExitCode
+ }
+ Get-Content $log -Tail 12
+
+ - name: Upload focused compiler diagnostics
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ARSAS-sv-evidence-compiler-log
+ path: ARSAS/sv-evidence-build.log
+ if-no-files-found: error
+
+ - name: Run SV evidence regression tests
+ run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~SmvSnapshotEvidenceExporterTests" --logger "trx;LogFileName=sv-evidence-tests.trx" --results-directory .\ARSAS\TestResults
+
+ - name: Upload focused evidence test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ARSAS-sv-evidence-test-results
+ path: ARSAS/TestResults/*.trx
+ if-no-files-found: ignore
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index d57b561bc..e3efb5d96 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -13,7 +13,7 @@
Copyright (C) 2026 Ari Sulistiono
ARSAS
ARSAS - IEC 61850 Engineering Workstation
- Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering, SCL workflows, diagnostics, sequence of events, and guarded control validation.
+ Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation.
1.6.19
1.6.19.0
1.6.19.0
@@ -21,7 +21,7 @@
https://github.com/masarray/arsas
git
README.md
- iec61850;iec-61850;mms;goose;sampled-values;smv;iec-61850-file-transfer;comtrade;scl;scl-generator;ied-explorer;multi-ied-monitoring;relay-testing;substation-automation;digital-substation;report-control-block;sequence-of-events;smart-control;wpf;dotnet
+ iec61850;iec-61850;mms;goose;sampled-values;smv;sv-evidence;iec-61850-file-transfer;comtrade;scl;scl-generator;ied-explorer;multi-ied-monitoring;relay-testing;substation-automation;digital-substation;report-control-block;sequence-of-events;smart-control;wpf;dotnet
GPL-3.0-or-later
$(ARIEC61850_PROJECT)
..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj
@@ -41,6 +41,9 @@
+
@@ -56,4 +59,4 @@
-
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2afcd257e..f9a200be4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,14 +14,23 @@ Notable public changes to ARSAS are recorded here. Application releases must ide
### Added
- `ARSAS.Tests`, the first application-layer regression-test project.
-- Regression coverage for clean and incomplete SV windows, gap/duplicate/out-of-order/restart handling, continuity evidence, and immutable stream-selection identity.
+- Regression coverage for clean and incomplete SV windows, gap/duplicate/out-of-order/restart handling, continuity evidence, immutable stream-selection identity, and deterministic evidence export.
- Canonical `Directory.Build.props` version metadata aligned with the project, `VERSION`, packaging, and CI.
- Test-result artifacts in the Windows build workflow.
+- Portable Sampled Values engineering evidence bundles containing:
+ - structured `manifest.json` capture identity and verdict;
+ - separate application/engine `provenance.json`;
+ - invariant-culture raw `samples.csv`;
+ - rendered `waveform.png`;
+ - explicit `diagnostics.txt` and evidence boundary;
+ - deterministic `SHA256SUMS.txt` integrity records.
+- Runtime publication of the immutable `ARIEC61850.lock.json` provenance used by exported evidence.
### Changed
- Windows CI restores and builds the complete solution, runs application regression tests, and only then publishes the portable package.
-- 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.
+- The SV viewer can export a reviewed evidence package after a bounded snapshot without claiming current, voltage, engineering units, calibration, formal conformance, or universal interoperability.
+- Development version remains `1.6.19`. The currently published stable release remains `1.6.18` until a separately validated and tagged release is produced.
## 1.6.18
@@ -55,4 +64,4 @@ Notable public changes to ARSAS are recorded here. Application releases must ide
## Historical licensing boundary
-Revisions through `0df1007d9538b978edba67218136bc5c4f8019ad` remain available under their original terms on branch `archive/apache-2.0-final`. Current `main` and current community release packages are GPL-3.0-or-later only. See `docs/LICENSING.md`.
+Revisions through `0df1007d9538b978edba67218136bc5c4f8019ad` remain available under their original terms on branch `archive/apache-2.0-final`. Current `main` and current community release packages are GPL-3.0-or-later only. See `docs/LICENSING.md`.
\ No newline at end of file
diff --git a/Services/SmvSnapshotEvidenceExporter.cs b/Services/SmvSnapshotEvidenceExporter.cs
new file mode 100644
index 000000000..5cbbcc81b
--- /dev/null
+++ b/Services/SmvSnapshotEvidenceExporter.cs
@@ -0,0 +1,428 @@
+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 = ReadInt32(rootElement, "sourcePullRequest") ??
+ ReadInt32(rootElement, "pairedPullRequest")
+ };
+ }
+ 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;
+
+ private static int? ReadInt32(JsonElement element, string propertyName)
+ => element.TryGetProperty(propertyName, out var value) && value.TryGetInt32(out var number)
+ ? number
+ : null;
+}
+
+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
+ },
+ captureInterval = new
+ {
+ startedAtUtc = snapshot.StartedAt.ToUniversalTime(),
+ completedAtUtc = snapshot.CompletedAt.ToUniversalTime(),
+ durationMilliseconds = snapshot.CaptureDuration.TotalMilliseconds
+ },
+ timebase = new
+ {
+ snapshot.NominalFrequencyHz,
+ snapshot.SamplesPerCycle,
+ snapshot.CycleCount,
+ snapshot.TargetSamples,
+ snapshot.CapturedSamples,
+ 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}
+ Capture started UTC: {snapshot.StartedAt.ToUniversalTime():O}
+ Capture completed UTC: {snapshot.CompletedAt.ToUniversalTime():O}
+ Bundle 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.ToHexString(SHA256.HashData(file.Value)).ToLowerInvariant())
+ .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.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
+ }
+}
\ No newline at end of file
diff --git a/Services/SmvViewerWindow.P1Evidence.cs b/Services/SmvViewerWindow.P1Evidence.cs
new file mode 100644
index 000000000..3f70fb779
--- /dev/null
+++ b/Services/SmvViewerWindow.P1Evidence.cs
@@ -0,0 +1,161 @@
+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 _p1CaptureAdapterDisplayText = "Unrecorded adapter";
+ private double _p1CaptureNominalFrequencyHz = 50.0;
+ 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 void CaptureP1EvidenceSelection()
+ {
+ _p1CaptureAdapterDisplayText = SelectedAdapter?.DisplayText ?? "Unrecorded adapter";
+ _p1CaptureNominalFrequencyHz = ReadSelectedFrequency();
+ }
+
+ 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 context = new SmvSnapshotEvidenceContext
+ {
+ GeneratedAtUtc = DateTimeOffset.UtcNow,
+ DeviceName = DeviceName,
+ EndpointText = EndpointText,
+ AdapterDisplayText = _p1CaptureAdapterDisplayText,
+ ControlReference = _p0CaptureSelection?.ControlReference ?? string.Empty,
+ SelectedStreamId = _p0CaptureSelection?.StreamId ?? snapshot.StreamId,
+ SelectedDataSetReference = _p0CaptureSelection?.DataSetReference ?? snapshot.DataSetReference,
+ SelectedAppId = _p0CaptureSelection?.AppId ?? $"0x{snapshot.AppId:X4}",
+ SelectedDestinationMac = _p0CaptureSelection?.DestinationMac ?? snapshot.DestinationMac,
+ ExplicitNominalFrequencyHz = _p1CaptureNominalFrequencyHz,
+ 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 renderWidth = WaveformCanvas.ActualWidth > 1 ? WaveformCanvas.ActualWidth : 1000;
+ var configuredHeight = double.IsNaN(WaveformCanvas.Height) ? 390 : WaveformCanvas.Height;
+ var renderHeight = WaveformCanvas.ActualHeight > 1 ? WaveformCanvas.ActualHeight : configuredHeight;
+ var width = Math.Max(1, (int)Math.Ceiling(renderWidth));
+ var height = Math.Max(1, (int)Math.Ceiling(renderHeight));
+ 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) { Stretch = Stretch.Fill }, 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
diff --git a/SmvViewerWindow.P0Hardening.cs b/SmvViewerWindow.P0Hardening.cs
index 78e6ee511..df3e2a086 100644
--- a/SmvViewerWindow.P0Hardening.cs
+++ b/SmvViewerWindow.P0Hardening.cs
@@ -33,6 +33,8 @@ private void P0CaptureButton_Click(object sender, RoutedEventArgs e)
stream.DataSetReference,
stream.AppId,
stream.DestinationMac);
+
+ CaptureP1EvidenceSelection();
}
private void P0StreamGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
diff --git a/SmvViewerWindow.xaml b/SmvViewerWindow.xaml
index a5dfd6e8c..7475bda66 100644
--- a/SmvViewerWindow.xaml
+++ b/SmvViewerWindow.xaml
@@ -208,15 +208,37 @@
+
+
-
+
-
+
\ No newline at end of file
diff --git a/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterReviewTests.cs b/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterReviewTests.cs
new file mode 100644
index 000000000..3dc170c17
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterReviewTests.cs
@@ -0,0 +1,144 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvSnapshotEvidenceExporterTestsReview
+{
+ [Fact]
+ public async Task Manifest_SeparatesCaptureIntervalFromBundleGenerationTime()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var startedAt = new DateTimeOffset(2026, 7, 28, 5, 0, 0, TimeSpan.Zero);
+ var completedAt = startedAt.AddMilliseconds(40);
+ var generatedAt = completedAt.AddMinutes(5);
+ var path = Path.Combine(directory, "capture-time.zip");
+
+ await SmvSnapshotEvidenceExporter.ExportAsync(
+ path,
+ CreateSnapshot(startedAt, completedAt),
+ CreateContext(generatedAt),
+ new byte[] { 0x89, 0x50, 0x4E, 0x47 });
+
+ using var archive = ZipFile.OpenRead(path);
+ using var manifest = JsonDocument.Parse(ReadEntryText(archive, "manifest.json"));
+ var root = manifest.RootElement;
+ var interval = root.GetProperty("captureInterval");
+
+ Assert.Equal(startedAt, interval.GetProperty("startedAtUtc").GetDateTimeOffset());
+ Assert.Equal(completedAt, interval.GetProperty("completedAtUtc").GetDateTimeOffset());
+ Assert.Equal(40, interval.GetProperty("durationMilliseconds").GetDouble());
+ Assert.Equal(generatedAt, root.GetProperty("generatedAtUtc").GetDateTimeOffset());
+
+ var readme = ReadEntryText(archive, "README.txt");
+ Assert.Contains($"Capture started UTC: {startedAt:O}", readme, StringComparison.Ordinal);
+ Assert.Contains($"Capture completed UTC: {completedAt:O}", readme, StringComparison.Ordinal);
+ Assert.Contains($"Bundle generated UTC: {generatedAt:O}", readme, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ProvenanceLoader_ReadsSourcePullRequestFromShippedLockSchema()
+ {
+ var directory = CreateTemporaryDirectory();
+ try
+ {
+ var engineDirectory = Path.Combine(directory, "engines");
+ Directory.CreateDirectory(engineDirectory);
+ File.WriteAllText(
+ Path.Combine(engineDirectory, "ARIEC61850.lock.json"),
+ """
+ {
+ "repository": "masarray/ARIEC61850",
+ "ref": "main",
+ "commit": "0f8453182957900bc6d91287fb8177c8d9762188",
+ "sourcePullRequest": 45
+ }
+ """,
+ Encoding.UTF8);
+
+ var provenance = SmvSnapshotEvidenceProvenance.LoadCurrent(directory);
+
+ Assert.Equal("masarray/ARIEC61850", provenance.EngineRepository);
+ Assert.Equal("main", provenance.EngineRef);
+ Assert.Equal("0f8453182957900bc6d91287fb8177c8d9762188", provenance.EngineCommit);
+ Assert.Equal(45, provenance.EnginePullRequest);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private static SmvSnapshotResult CreateSnapshot(DateTimeOffset startedAt, DateTimeOffset completedAt)
+ => new()
+ {
+ StartedAt = startedAt,
+ CompletedAt = completedAt,
+ AppId = 0x4000,
+ DestinationMac = "01-0C-CD-04-00-01",
+ StreamId = "MU01",
+ NominalFrequencyHz = 50,
+ SamplesPerCycle = 80,
+ CycleCount = 2,
+ TargetSamples = 160,
+ CapturedSamples = 160,
+ CapturedFrames = 80,
+ ParsedAsdus = 160,
+ LastSampleCount = 159,
+ ContinuousTransitions = 159,
+ TimebaseReason = "Deterministic review test.",
+ PayloadShape = "4 bytes · 1 raw 32-bit word",
+ Channels = new[]
+ {
+ new SmvSnapshotChannel
+ {
+ ChannelIndex = 1,
+ PayloadWordIndex = 0,
+ Label = "Raw word 1",
+ Interpretation = "Signed INT32 representation; channel/unit semantics unresolved",
+ Samples = new double[] { 1, 2 },
+ Minimum = 1,
+ Maximum = 2
+ }
+ }
+ };
+
+ private static SmvSnapshotEvidenceContext CreateContext(DateTimeOffset generatedAt)
+ => new()
+ {
+ GeneratedAtUtc = generatedAt,
+ DeviceName = "Review IED",
+ EndpointText = "192.0.2.20:102",
+ AdapterDisplayText = "Captured mirror adapter",
+ ControlReference = "IED1LD0/LLN0$MSVCB01",
+ SelectedStreamId = "MU01",
+ SelectedDataSetReference = "IED1LD0/LLN0$Dataset01",
+ SelectedAppId = "0x4000",
+ SelectedDestinationMac = "01-0C-CD-04-00-01",
+ ExplicitNominalFrequencyHz = 50,
+ Provenance = new SmvSnapshotEvidenceProvenance()
+ };
+
+ private static string ReadEntryText(ZipArchive archive, string name)
+ {
+ var entry = archive.GetEntry(name) ?? throw new InvalidDataException($"Missing ZIP entry: {name}");
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+ return reader.ReadToEnd();
+ }
+
+ private static string CreateTemporaryDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+}
\ No newline at end of file
diff --git a/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterTests.cs b/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterTests.cs
new file mode 100644
index 000000000..29ca12117
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvSnapshotEvidenceExporterTests.cs
@@ -0,0 +1,240 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvSnapshotEvidenceExporterTests
+{
+ private static readonly DateTimeOffset FixedGeneratedAt =
+ new(2026, 7, 28, 4, 30, 0, TimeSpan.Zero);
+
+ [Fact]
+ public async Task ExportBundle_ContainsSevenAuditableEntriesAndRawSamples()
+ {
+ var temporaryDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var path = Path.Combine(temporaryDirectory, "evidence.zip");
+ var result = await SmvSnapshotEvidenceExporter.ExportAsync(
+ path,
+ CreateSnapshot(),
+ CreateContext(),
+ CreatePngBytes());
+
+ Assert.Equal(7, result.Entries.Count);
+ Assert.Equal(64, result.BundleSha256.Length);
+
+ using var archive = ZipFile.OpenRead(path);
+ var names = archive.Entries.Select(entry => entry.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray();
+ Assert.Equal(
+ new[]
+ {
+ "README.txt",
+ "SHA256SUMS.txt",
+ "diagnostics.txt",
+ "manifest.json",
+ "provenance.json",
+ "samples.csv",
+ "waveform.png"
+ },
+ names);
+
+ var samples = ReadEntryText(archive, "samples.csv");
+ Assert.Contains("sampleIndex,cyclePosition", samples, StringComparison.Ordinal);
+ Assert.Contains("0,0,100,-100", samples, StringComparison.Ordinal);
+ Assert.Contains("3,0.0375,400,-400", samples, StringComparison.Ordinal);
+
+ using var manifest = JsonDocument.Parse(ReadEntryText(archive, "manifest.json"));
+ Assert.Equal(SmvSnapshotEvidenceExporter.SchemaVersion, manifest.RootElement.GetProperty("schemaVersion").GetString());
+ Assert.Equal("PASS", manifest.RootElement.GetProperty("verdict").GetString());
+ Assert.Equal("0x4000", manifest.RootElement.GetProperty("observedStream").GetProperty("appId").GetString());
+ Assert.Equal(2, manifest.RootElement.GetProperty("payload").GetProperty("plottedLaneCount").GetInt32());
+
+ ValidateChecksums(archive);
+ }
+ finally
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task RestartVerdict_IsPersistedAsReview()
+ {
+ var temporaryDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var path = Path.Combine(temporaryDirectory, "restart.zip");
+ await SmvSnapshotEvidenceExporter.ExportAsync(
+ path,
+ CreateSnapshot() with { RestartTransitions = 1 },
+ CreateContext(),
+ CreatePngBytes());
+
+ using var archive = ZipFile.OpenRead(path);
+ using var manifest = JsonDocument.Parse(ReadEntryText(archive, "manifest.json"));
+ Assert.Equal("REVIEW", manifest.RootElement.GetProperty("verdict").GetString());
+ Assert.Equal(1, manifest.RootElement.GetProperty("continuity").GetProperty("restartTransitions").GetInt32());
+ Assert.Contains("Verdict: REVIEW", ReadEntryText(archive, "diagnostics.txt"), StringComparison.Ordinal);
+ }
+ finally
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task FixedEvidence_IsByteDeterministic()
+ {
+ var temporaryDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var firstPath = Path.Combine(temporaryDirectory, "first.zip");
+ var secondPath = Path.Combine(temporaryDirectory, "second.zip");
+ var snapshot = CreateSnapshot();
+ var context = CreateContext();
+ var png = CreatePngBytes();
+
+ var first = await SmvSnapshotEvidenceExporter.ExportAsync(firstPath, snapshot, context, png);
+ var second = await SmvSnapshotEvidenceExporter.ExportAsync(secondPath, snapshot, context, png);
+
+ Assert.Equal(first.BundleSha256, second.BundleSha256);
+ Assert.Equal(await File.ReadAllBytesAsync(firstPath), await File.ReadAllBytesAsync(secondPath));
+ }
+ finally
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void SuggestedFileName_IsPortableAndTraceable()
+ {
+ var name = SmvSnapshotEvidenceExporter.BuildSuggestedFileName(
+ CreateContext() with { SelectedStreamId = "MU/01:*?" },
+ CreateSnapshot());
+
+ Assert.StartsWith("ARSAS-SV-Evidence-", name, StringComparison.Ordinal);
+ Assert.EndsWith("-20260728-043000Z.zip", name, StringComparison.Ordinal);
+ Assert.DoesNotContain('/', name);
+ Assert.DoesNotContain(':', name);
+ Assert.DoesNotContain('*', name);
+ Assert.DoesNotContain('?', name);
+ }
+
+ private static SmvSnapshotResult CreateSnapshot()
+ => new()
+ {
+ StartedAt = FixedGeneratedAt.AddMilliseconds(-40),
+ CompletedAt = FixedGeneratedAt,
+ 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,
+ DeclaredSampleRate = 4000,
+ DeclaredSampleMode = 1,
+ NominalFrequencyHz = 50,
+ SamplesPerCycle = 80,
+ CycleCount = 2,
+ TargetSamples = 160,
+ CapturedSamples = 160,
+ CapturedFrames = 80,
+ ParsedAsdus = 160,
+ FirstSampleCount = 0,
+ LastSampleCount = 159,
+ ContinuousTransitions = 159,
+ TimebaseReason = "Declared 4000 samples/s at explicit 50 Hz.",
+ PayloadShape = "16 bytes · 2 raw 32-bit words",
+ Channels = new[]
+ {
+ new SmvSnapshotChannel
+ {
+ ChannelIndex = 1,
+ PayloadWordIndex = 0,
+ Label = "Raw word 1",
+ Interpretation = "Signed INT32 representation; channel/unit semantics unresolved",
+ Samples = new double[] { 100, 200, 300, 400 },
+ Minimum = 100,
+ Maximum = 400
+ },
+ new SmvSnapshotChannel
+ {
+ ChannelIndex = 2,
+ PayloadWordIndex = 1,
+ Label = "Raw word 2",
+ Interpretation = "Signed INT32 representation; channel/unit semantics unresolved",
+ Samples = new double[] { -100, -200, -300, -400 },
+ Minimum = -400,
+ Maximum = -100
+ }
+ },
+ Diagnostics = new[] { "Synthetic deterministic test evidence." }
+ };
+
+ private static SmvSnapshotEvidenceContext CreateContext()
+ => new()
+ {
+ GeneratedAtUtc = FixedGeneratedAt,
+ DeviceName = "Test IED",
+ EndpointText = "192.0.2.10:102",
+ AdapterDisplayText = "Test mirror adapter",
+ ControlReference = "IED1LD0/LLN0$MSVCB01",
+ SelectedStreamId = "MU01",
+ SelectedDataSetReference = "IED1LD0/LLN0$Dataset01",
+ SelectedAppId = "0x4000",
+ SelectedDestinationMac = "01-0C-CD-04-00-01",
+ ExplicitNominalFrequencyHz = 50,
+ Provenance = new SmvSnapshotEvidenceProvenance
+ {
+ ApplicationVersion = "1.6.20",
+ ApplicationInformationalVersion = "1.6.20+test",
+ EngineRepository = "masarray/ARIEC61850",
+ EngineRef = "main",
+ EngineCommit = "0f8453182957900bc6d91287fb8177c8d9762188",
+ EnginePullRequest = 45
+ }
+ };
+
+ private static byte[] CreatePngBytes()
+ => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x00 };
+
+ private static string ReadEntryText(ZipArchive archive, string name)
+ {
+ var entry = archive.GetEntry(name) ?? throw new InvalidDataException($"Missing ZIP entry: {name}");
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+ return reader.ReadToEnd();
+ }
+
+ private static void ValidateChecksums(ZipArchive archive)
+ {
+ var checksumLines = ReadEntryText(archive, "SHA256SUMS.txt")
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ Assert.Equal(6, checksumLines.Length);
+
+ foreach (var line in checksumLines)
+ {
+ var separator = line.IndexOf(" ", StringComparison.Ordinal);
+ Assert.True(separator > 0, $"Invalid checksum line: {line}");
+ var expected = line[..separator];
+ var name = line[(separator + 2)..];
+ var entry = archive.GetEntry(name) ?? throw new InvalidDataException($"Missing checksummed entry: {name}");
+ using var stream = entry.Open();
+ var actual = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
+ Assert.Equal(expected, actual);
+ }
+ }
+
+ private static string CreateTemporaryDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+}
\ No newline at end of file