diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 7ebc3c4b9..3269c0cd1 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -43,6 +43,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private string _sclIedName = string.Empty; private string _sclAccessPointName = string.Empty; private string _sclEndpointOrigin = "Unbound"; + private Iec61850ReportContinuitySnapshot? _reportContinuityEvidence; public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); public BulkObservableCollection Signals { get; } = new(); @@ -53,6 +54,15 @@ public sealed class Iec61850MonitorDevice : ObservableObject [JsonIgnore] public StaticAcquisitionParitySnapshot StaticAcquisitionParity { get; set; } = new(); + // Snapshot publication is atomic across runtime/diagnostic threads, never a + // mutable live ReportStreams dictionary. Association reset clears its scope. + [JsonIgnore] + public Iec61850ReportContinuitySnapshot? ReportContinuityEvidence + { + get => System.Threading.Volatile.Read(ref _reportContinuityEvidence); + set => System.Threading.Volatile.Write(ref _reportContinuityEvidence, value); + } + public SclIedWorkspace? SclWorkspace { get => _sclWorkspace; diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index f7e01dbca..aaf8d73c3 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -171,6 +171,7 @@ public static async Task BuildAsync( builder.AppendLine($"Detail : {device.Detail}"); builder.AppendLine($"Acquisition : {device.AcquisitionMode}"); AppendStaticIngressParity(builder, device.StaticAcquisitionParity); + AppendReportContinuity(builder, device.ReportContinuityEvidence); if (device.IsMonitoring && Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)) AppendStaticInitialImage(builder, device.Points); builder.AppendLine($"Saved model : {device.HasDiscoveryCache} ({device.SignalCount:N0} signal(s))"); @@ -271,6 +272,48 @@ private static void AppendStaticInitialImage( builder.AppendLine($" Additional pending: {image.PendingPoints.Count - 12} (showing first 12; never inferred unavailable)"); } + internal static void AppendReportContinuity( + StringBuilder builder, Iec61850ReportContinuitySnapshot? snapshot) + { + if (snapshot is null) + { + builder.AppendLine("Report continuity : NOT CAPTURED • no monitor evidence in current association"); + return; + } + + var verdict = snapshot.Frames == 0 ? "NO FRAME METADATA" : + snapshot.UntrackedStreamCount > 0 ? "INCOMPLETE • metadata stream limit reached" : + snapshot.Findings > 0 ? "ANOMALY OBSERVED" : + snapshot.Sequenced < snapshot.Frames ? "PARTIAL SqNum EVIDENCE" : + "OBSERVED WITHOUT SEQUENCE ALERTS"; + builder.AppendLine($"Report continuity : {verdict} • frames={snapshot.Frames}, " + + $"sequenced={snapshot.Sequenced}, processUpdates={snapshot.ProcessUpdatesSeen}, " + + $"streams={snapshot.StreamCount}, findings={snapshot.Findings}, " + + $"BufOvfl={snapshot.Overflows}, untracked={snapshot.UntrackedStreamCount}"); + builder.AppendLine(" Qualification : association-local decoded metadata only; no alerts " + + "does not prove SOE/event continuity, GI causality or reconnect/replay completeness."); + + foreach (var (stream, index) in snapshot.Streams.Select((stream, index) => (stream, index))) + { + static string Safe(string value) + { + var text = (value ?? string.Empty).Replace("\r", @"\r", StringComparison.Ordinal) + .Replace("\n", @"\n", StringComparison.Ordinal); + return text.Length > 240 ? text[..240] + "…[TRUNCATED]" : text; + } + builder.AppendLine($" stream[{index}] : {(stream.Buffered ? "BRCB" : "URCB")}, " + + $"rcb={Safe(stream.Rcb)}, dataset={Safe(stream.DataSet)}, " + + $"rptId={Safe(stream.ReportId)}, frames={stream.Frames}, " + + $"SqNum={stream.Sequenced}, missingSqNum={stream.SequenceMissing}, " + + $"segmented={stream.Segmented}, findings={stream.Findings}, " + + $"BufOvfl={stream.Overflow}, ConfRevChanges={stream.ConfRevChanges}, " + + $"EntryIDPresent={stream.EntryIdPresent}, firstSqNum={stream.FirstSqNum?.ToString() ?? "-"}, " + + $"lastSqNum={stream.LastSqNum?.ToString() ?? "-"}"); + } + if (snapshot.StreamCount > snapshot.Streams.Count) + builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}"); + } + internal static void AppendStaticIngressParity( StringBuilder builder, StaticAcquisitionParitySnapshot parity) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 613aa6a48..6df290c5b 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -50,6 +50,8 @@ private sealed class DeviceSession public Dictionary ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase); public PriorityQueue PollQueue { get; } = new(); public Dictionary ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase); + public long ContinuityProcessUpdatesSeen { get; set; } + public int ContinuityUntrackedStreams { get; set; } public int LastUnroutedReportCount { get; set; } public int ReportPlanCursor { get; set; } public IReadOnlyList PendingReportPlans { get; set; } = Array.Empty(); @@ -489,6 +491,9 @@ public async Task> StartMonitoringAsync( session.ReportReferenceIndex.Clear(); session.PollQueue.Clear(); session.ReportStreams.Clear(); + session.ContinuityProcessUpdatesSeen = 0; + session.ContinuityUntrackedStreams = 0; + session.Device.ReportContinuityEvidence = null; session.LastUnroutedReportCount = 0; session.ReportPlanCursor = 0; session.PendingReportPlans = Array.Empty(); @@ -1314,12 +1319,39 @@ private void ProcessReportHealth( $"{delta} IEC 61850 InformationReport frame(s) were not routed because RptID/DataSet identity was ambiguous. The engine intentionally refused unsafe DataSet projection."); } + if (slice.Updates.Count > 0) + session.ContinuityProcessUpdatesSeen = + Math.Min(long.MaxValue - slice.Updates.Count, session.ContinuityProcessUpdatesSeen) + + slice.Updates.Count; + + // Bound metadata cardinality even if an IED emits pathological RptID + // variations. Report acceptance and value delivery remain untouched. + const int maxTrackedStreams = 64; foreach (var frame in slice.ReportFrames) { var streamKey = BuildReportStreamKey(plan, frame); if (!session.ReportStreams.TryGetValue(streamKey, out var state)) { - state = new Iec61850ReportContinuityState(); + if (session.ReportStreams.Count >= maxTrackedStreams) + { + if (session.ContinuityUntrackedStreams < int.MaxValue) + session.ContinuityUntrackedStreams++; + if (session.ContinuityUntrackedStreams == 1) + Log("WARN", session.Device.Name, + "Report continuity evidence reached the 64-stream diagnostic limit; " + + "additional metadata identities remain untracked; report processing continues."); + continue; + } + + state = new Iec61850ReportContinuityState + { + ReportControlReference = string.IsNullOrWhiteSpace(frame.ReportControlReference) + ? plan.ReportControlReference : frame.ReportControlReference, + DataSetReference = string.IsNullOrWhiteSpace(frame.DataSetReference) + ? plan.DataSetReference : frame.DataSetReference, + ReportId = frame.ReportId, + Buffered = plan.Buffered + }; session.ReportStreams[streamKey] = state; } @@ -1333,6 +1365,15 @@ private void ProcessReportHealth( $"{ReportName(frame.ReportControlReference, plan.ReportControlReference)}: {finding}"); } } + + // One immutable publication per non-empty slice, never per value/frame. + // The GUI may concurrently Copy Diagnostic without locking the MMS loop. + if (slice.Updates.Count > 0 || slice.ReportFrames.Count > 0) + session.Device.ReportContinuityEvidence = + Iec61850ReportContinuityInspector.Snapshot( + session.ReportStreams, + session.ContinuityProcessUpdatesSeen, + session.ContinuityUntrackedStreams); } private static string BuildReportStreamKey(ReportControlPlan plan, NativeReportFrameMetadata frame) @@ -1760,6 +1801,9 @@ await DisposeClientForReconnectAsync( session.ActiveReportPlanOrder.Clear(); session.PointPlanIds.Clear(); session.ReportStreams.Clear(); + session.ContinuityProcessUpdatesSeen = 0; + session.ContinuityUntrackedStreams = 0; + session.Device.ReportContinuityEvidence = null; session.LastUnroutedReportCount = 0; ResetAssociationReportEvidence(session); @@ -2064,6 +2108,9 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.StaticReportProjection.Reset(); session.PollQueue.Clear(); session.ReportStreams.Clear(); + session.ContinuityProcessUpdatesSeen = 0; + session.ContinuityUntrackedStreams = 0; + session.Device.ReportContinuityEvidence = null; session.LastUnroutedReportCount = 0; session.ReportPlanCursor = 0; session.PendingReportPlans = Array.Empty(); diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 37819f75c..db84d8024 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -16,8 +16,60 @@ internal sealed class Iec61850ReportContinuityState public bool AwaitingMoreSegments { get; set; } public ulong? ConfigurationRevision { get; set; } public string LastEntryIdHex { get; set; } = string.Empty; + + // All counts belong to this exact report stream within the current + // association. No static state, no process/control-plane side effects. + public string ReportControlReference { get; set; } = string.Empty; + public string DataSetReference { get; set; } = string.Empty; + public string ReportId { get; set; } = string.Empty; + public bool Buffered { get; set; } + public long FramesSeen { get; set; } + public long SequencedFrames { get; set; } + public long MissingSequenceFrames { get; set; } + public long SegmentedFrames { get; set; } + public long AnomalyFindings { get; set; } + public long BufferOverflows { get; set; } + public long ConfRevChanges { get; set; } + public long EntryIdPresentFrames { get; set; } + public ulong? FirstSequenceNumber { get; set; } +} + +/// +/// Immutable copyable diagnostic evidence. Counts describe decoded metadata, +/// NOT completeness of SOE delivery or absence of lost events. +/// +public sealed record Iec61850ReportContinuityStreamSnapshot +{ + public string Rcb { get; init; } = string.Empty; + public string DataSet { get; init; } = string.Empty; + public string ReportId { get; init; } = string.Empty; + public bool Buffered { get; init; } + public long Frames { get; init; } + public long Sequenced { get; init; } + public long SequenceMissing { get; init; } + public long Segmented { get; init; } + public long Findings { get; init; } + public long Overflow { get; init; } + public long ConfRevChanges { get; init; } + public long EntryIdPresent { get; init; } + public ulong? FirstSqNum { get; init; } + public ulong? LastSqNum { get; init; } } +public sealed record Iec61850ReportContinuitySnapshot +{ + public long ProcessUpdatesSeen { get; init; } + public int StreamCount { get; init; } + public int UntrackedStreamCount { get; init; } + public long Frames { get; init; } + public long Sequenced { get; init; } + public long Findings { get; init; } + public long Overflows { get; init; } + public IReadOnlyList Streams { get; init; } = + Array.Empty(); +} + + internal static class Iec61850ReportContinuityInspector { private const ulong MaxSequenceNumber = ushort.MaxValue; @@ -35,8 +87,25 @@ internal static IReadOnlyList Observe( ArgumentNullException.ThrowIfNull(state); ArgumentNullException.ThrowIfNull(frame); + if (state.FramesSeen < long.MaxValue) state.FramesSeen++; + if (frame.SequenceNumber.HasValue) + { + if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; + if (frame.SequenceNumber.Value <= MaxSequenceNumber) + state.FirstSequenceNumber ??= frame.SequenceNumber; + } + else if (state.MissingSequenceFrames < long.MaxValue) state.MissingSequenceFrames++; + if (frame.SubSequenceNumber.HasValue && state.SegmentedFrames < long.MaxValue) + state.SegmentedFrames++; + if (!string.IsNullOrEmpty(frame.EntryIdHex) && state.EntryIdPresentFrames < long.MaxValue) + state.EntryIdPresentFrames++; + List? findings = null; - void Warn(string message) => (findings ??= new List(2)).Add(message); + void Warn(string message) + { + (findings ??= new List(2)).Add(message); + if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; + } var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); var entryIdPresent = !string.IsNullOrEmpty(frame.EntryIdHex); @@ -50,6 +119,7 @@ string EntryContext() => buffered if (frame.BufferOverflow == true) { + if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++; Warn(buffered ? "BRCB BufOvfl=true: possible loss of buffered entries; continuity cannot be certified from SqNum alone" : "URCB carried unexpected BufOvfl=true; inspect report OptionFields/decoder attribution"); @@ -60,6 +130,7 @@ string EntryContext() => buffered if (state.ConfigurationRevision.HasValue && state.ConfigurationRevision.Value != frame.ConfRev.Value) { + if (state.ConfRevChanges < long.MaxValue) state.ConfRevChanges++; Warn($"Report ConfRev changed: previous={state.ConfigurationRevision.Value}, " + $"current={frame.ConfRev.Value}; DataSet schema and member bindings require revalidation"); } @@ -139,6 +210,49 @@ string EntryContext() => buffered return findings is null ? Array.Empty() : findings; } + internal static Iec61850ReportContinuitySnapshot Snapshot( + IReadOnlyDictionary streams, + long processUpdatesSeen, + int untrackedStreamCount, + int maxShown = 12) + { + ArgumentNullException.ThrowIfNull(streams); + var ordered = streams.Values + .OrderBy(state => state.ReportControlReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.ReportId, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var shown = ordered.Take(Math.Clamp(maxShown, 0, 32)).Select(state => + new Iec61850ReportContinuityStreamSnapshot + { + Rcb = state.ReportControlReference, + DataSet = state.DataSetReference, + ReportId = state.ReportId, + Buffered = state.Buffered, + Frames = state.FramesSeen, + Sequenced = state.SequencedFrames, + SequenceMissing = state.MissingSequenceFrames, + Segmented = state.SegmentedFrames, + Findings = state.AnomalyFindings, + Overflow = state.BufferOverflows, + ConfRevChanges = state.ConfRevChanges, + EntryIdPresent = state.EntryIdPresentFrames, + FirstSqNum = state.FirstSequenceNumber, + LastSqNum = state.LastSequenceNumber + }).ToArray(); + return new Iec61850ReportContinuitySnapshot + { + ProcessUpdatesSeen = processUpdatesSeen, + StreamCount = ordered.Length, + UntrackedStreamCount = untrackedStreamCount, + Frames = ordered.Sum(state => state.FramesSeen), + Sequenced = ordered.Sum(state => state.SequencedFrames), + Findings = ordered.Sum(state => state.AnomalyFindings), + Overflows = ordered.Sum(state => state.BufferOverflows), + Streams = shown + }; + } + private static string? DescribeSequenceAnomaly(ulong? previous, ulong current) { if (!previous.HasValue) diff --git a/tests/ARSAS.Tests/ReportContinuitySnapshotP76CTests.cs b/tests/ARSAS.Tests/ReportContinuitySnapshotP76CTests.cs new file mode 100644 index 000000000..b297dca64 --- /dev/null +++ b/tests/ARSAS.Tests/ReportContinuitySnapshotP76CTests.cs @@ -0,0 +1,168 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ReportContinuitySnapshotP76CTests +{ + [Fact] + public void Snapshot_MakesMissingMetadataDistinctFromAnomalyFreeObservation() + { + var noFrameMetadata = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary(), 13, 0); + var emptyReport = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(emptyReport, noFrameMetadata); + Assert.Contains("NO FRAME METADATA", emptyReport.ToString(), StringComparison.Ordinal); + Assert.Contains("processUpdates=13", emptyReport.ToString(), StringComparison.Ordinal); + + var healthy = new Iec61850ReportContinuityState + { + ReportControlReference = "IED/LLN0.BR.rpt01", + DataSetReference = "IED/LLN0.Indications", + ReportId = "IED_RPT", + Buffered = true + }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(healthy, + new NativeReportFrameMetadata { SequenceNumber = 12, EntryIdHex = "AA" }, true)); + Assert.Empty(Iec61850ReportContinuityInspector.Observe(healthy, + new NativeReportFrameMetadata { SequenceNumber = 13, EntryIdHex = "BB" }, true)); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["stream1"] = healthy }, 13, 0); + var text = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(text, snapshot); + Assert.Contains("OBSERVED WITHOUT SEQUENCE ALERTS", text.ToString(), StringComparison.Ordinal); + Assert.Contains("frames=2", text.ToString(), StringComparison.Ordinal); + Assert.Contains("SqNum=2", text.ToString(), StringComparison.Ordinal); + Assert.Contains("EntryIDPresent=2", text.ToString(), StringComparison.Ordinal); + Assert.Contains("firstSqNum=12", text.ToString(), StringComparison.Ordinal); + Assert.Contains("lastSqNum=13", text.ToString(), StringComparison.Ordinal); + Assert.Contains("does not prove SOE/event continuity", text.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void MissingSqNumMetadata_IsNeverClassifiedAsFullContinuityProof() + { + var state = new Iec61850ReportContinuityState { ReportControlReference = "IED/LLN0.BR.rpt01" }; + Iec61850ReportContinuityInspector.Observe(state, new NativeReportFrameMetadata(), true); + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 3 }, true); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["k"] = state }, 2, 0); + var text = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(text, snapshot); + Assert.Contains("PARTIAL SqNum EVIDENCE", text.ToString(), StringComparison.Ordinal); + Assert.Equal(2, snapshot.Frames); + Assert.Equal(1, snapshot.Sequenced); + Assert.Equal(1, snapshot.Streams[0].SequenceMissing); + } + + [Fact] + public void RealFieldBackwardGap_IsCountedWithoutConcludingEventLoss() + { + var state = new Iec61850ReportContinuityState + { + ReportControlReference = "IED/LLN0.BR.rpt02", + Buffered = true + }; + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 4 }, true); + var findings = Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 1 }, true); + Assert.Single(findings); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["rpt"] = state }, 7, 0); + Assert.Equal(1, snapshot.Findings); + var text = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(text, snapshot); + Assert.Contains("ANOMALY OBSERVED", text.ToString(), StringComparison.Ordinal); + Assert.Contains("findings=1", text.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void ConfRevAndBufOvfl_CountIndependentlyEvenWithSequentialSqNum() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 0, ConfRev = 1 }, true)); + var findings = Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 1, ConfRev = 2, BufferOverflow = true }, true); + Assert.Equal(2, findings.Count); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["rpt"] = state }, 2, 0); + Assert.Equal(2, snapshot.Findings); + Assert.Equal(1, snapshot.Overflows); + Assert.Equal(1, snapshot.Streams[0].ConfRevChanges); + } + + [Fact] + public void Snapshot_IsAnImmutableCopyOfCurrentAssociationEvidence() + { + var state = new Iec61850ReportContinuityState { ReportControlReference = "IED/LLN0.BR.rpt01" }; + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 1 }, true); + var streamStates = new Dictionary { ["rpt"] = state }; + var captured = Iec61850ReportContinuityInspector.Snapshot(streamStates, 1, 0); + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata { SequenceNumber = 2 }, true); + streamStates.Clear(); + Assert.Equal(1, captured.Frames); + Assert.Equal(1, captured.Streams[0].Frames); + Assert.Equal(1UL, captured.Streams[0].LastSqNum); + } + + [Fact] + public void MetadataCardinalityAndUntrustedLabels_AreBoundedInCopiedReport() + { + var dict = new Dictionary(); + for (var i = 0; i < 20; i++) + { + dict["key" + i] = new Iec61850ReportContinuityState + { + ReportControlReference = "RCB\r\nStatic parity : INJECTED" + new string('R', 550), + ReportId = "report", + Buffered = true, + FramesSeen = 1, + SequencedFrames = 1 + }; + } + var snap = Iec61850ReportContinuityInspector.Snapshot(dict, 20, 2); + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, snap); + var report = sb.ToString(); + Assert.Contains("INCOMPLETE", report, StringComparison.Ordinal); + Assert.Contains("TRUNCATED", report, StringComparison.Ordinal); + Assert.Contains(@"\r\nStatic parity : INJECTED", report, StringComparison.Ordinal); + Assert.DoesNotContain("\r\nStatic parity : INJECTED", report, StringComparison.Ordinal); + Assert.DoesNotContain("stream[12]", report, StringComparison.Ordinal); + Assert.True(report.Length < 10000, $"Diagnostic became unbounded: {report.Length}"); + } + + [Fact] + public void Runtime_ClearsAssociationEvidenceOnEveryMonitoringOrReconnectReset() + { + var text = Read("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("session.Device.ReportContinuityEvidence = null;", text, StringComparison.Ordinal); + Assert.Contains("Iec61850ReportContinuityInspector.Snapshot", text, StringComparison.Ordinal); + Assert.Contains("session.ContinuityUntrackedStreams", text, StringComparison.Ordinal); + Assert.Contains("maxTrackedStreams = 64", text, StringComparison.Ordinal); + Assert.Equal(3, text.Split("session.Device.ReportContinuityEvidence = null;", + StringSplitOptions.None).Length - 1); + var copy = Read("Services/DiagnosticReportBuilder.cs"); + Assert.Contains("AppendReportContinuity(builder, device.ReportContinuityEvidence);", + copy, StringComparison.Ordinal); + } + + private static string Read(string path) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var full = Path.Combine(directory.FullName, path); + if (File.Exists(full)) + return File.ReadAllText(full); + directory = directory.Parent; + } + throw new FileNotFoundException(path); + } +}