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
1 change: 1 addition & 0 deletions ArIED61850Tester.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<Resource Include="Assets\app-icon.ico" />
<Resource Include="Assets\app-icon-256.png" />
<Resource Include="Assets\gateway-hero.png" />
<EmbeddedResource Include="engines\ARIEC61850.lock.json" LogicalName="ARSAS.ARIEC61850.lock.json" />
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
240 changes: 240 additions & 0 deletions Services/SmvEvidenceBundleExporter.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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<SmvEvidenceBundleResult> 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<string, byte[]> 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,
Comment on lines +137 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the capture timestamps in the manifest

When a snapshot is exported later or exported more than once, exportedAtUtc identifies only when the ZIP was written; the available SmvSnapshotResult.StartedAt and CompletedAt values are omitted from the capture evidence. Consequently, a reviewer cannot establish when the represented network observation occurred or distinguish otherwise identical captures, which undermines the bundle's audit trail.

Useful? React with 👍 / 👎.

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<string, byte[]>(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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the actual captured smpCnt values in the CSV

When FirstSampleCount is outside the two-cycle window size, this immediately writes a false counter value—for example, a snapshot starting at 1000 with 80 samples/cycle emits 40 because it takes modulo 160, contradicting the manifest's firstSampleCount. It also invents a continuous sequence for REVIEW captures containing gaps, duplicates, out-of-order samples, or restarts. Since the snapshot currently retains only the first and last counters, preserve each ASDU's actual smpCnt during capture and export that sequence alongside its raw sample.

Useful? React with 👍 / 👎.

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<string, string> 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("\"", "\"\"")}\"";
}
}
1 change: 1 addition & 0 deletions SmvViewerWindow.P0Hardening.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading