diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index c20e5cc6..1ad4602f 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -1,5 +1,9 @@ name: .NET CI +# Always attest the immutable triggering source, not the mutable PR branch tip. +env: + ARIEC_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + on: push: branches: [ main ] @@ -22,7 +26,7 @@ on: pull_request: # The live ARSAS product pins PR #169 until targeted engine changes pass CI. # Stacked engine PRs must run the exact same Windows build/test gate. - branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019 ] + branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019, fix/p0-c-typed-read-trace-20261019 ] workflow_dispatch: permissions: @@ -38,7 +42,21 @@ jobs: shell: pwsh run: | $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + $sourceSha = $env:ARIEC_SOURCE_SHA + if ($sourceSha -notmatch '^[0-9a-f]{40}$') { + throw "Missing immutable source commit SHA for this workflow." + } git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" source + if ($LASTEXITCODE -ne 0) { throw "Engine source checkout failed." } + git -C source fetch --quiet --depth 1 origin $sourceSha + if ($LASTEXITCODE -ne 0) { throw "Exact source SHA fetch failed." } + git -C source checkout --quiet --detach $sourceSha + if ($LASTEXITCODE -ne 0) { throw "Exact source SHA checkout failed." } + $verifiedSha = (git -C source rev-parse HEAD).Trim() + if ($verifiedSha -ne $sourceSha) { + throw "Source SHA drift: expected $sourceSha, got $verifiedSha." + } + Write-Host "Verified exact ARIEC source commit $verifiedSha" - name: Verify source, provenance, wording and license boundaries shell: pwsh @@ -94,7 +112,7 @@ jobs: @( "ARIEC61850 PR #134 smart-discovery test build", - "Commit: $env:GITHUB_SHA", + "Commit: $env:ARIEC_SOURCE_SHA", "Branch: $env:GITHUB_HEAD_REF", "Run: $env:GITHUB_RUN_NUMBER", "TargetFramework: net8.0", diff --git a/src/AR.Iec61850/Scl/SclModels.cs b/src/AR.Iec61850/Scl/SclModels.cs index 227570a8..569c8626 100644 --- a/src/AR.Iec61850/Scl/SclModels.cs +++ b/src/AR.Iec61850/Scl/SclModels.cs @@ -24,7 +24,10 @@ public enum SclEdition Unknown, Edition1, Edition2, - Edition21 + Edition21, + // IEC 61850-6 Edition 2.2 (SCL namespace identification 2007/C/5). + // Append to preserve the existing public enum numeric identities. + Edition22 } public sealed class SclIed diff --git a/src/AR.Iec61850/Scl/SclParser.cs b/src/AR.Iec61850/Scl/SclParser.cs index 9ef0e92e..b876ee4f 100644 --- a/src/AR.Iec61850/Scl/SclParser.cs +++ b/src/AR.Iec61850/Scl/SclParser.cs @@ -435,39 +435,60 @@ private static SclEdition DetectEdition(XElement root) var release = Attr(root, "release"); var ns = root.Name.NamespaceName.ToLowerInvariant(); - // IEC 61850 SCL keeps the historical namespace URI http://www.iec.ch/61850/2003/SCL - // even for Edition 2 family files. Do not classify by namespace alone. - if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase) && - string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase) && - (string.Equals(release, "4", StringComparison.OrdinalIgnoreCase) || string.Equals(release, "4A", StringComparison.OrdinalIgnoreCase))) - return SclEdition.Edition21; + // IEC 61850-6 uses the SAME historical XML namespace URI for + // Editions 1/2/2.1/2.2. Root version/revision/release, not an invented + // namespace URI or vendor signature, identifies the SCL edition. + if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(revision, "C", StringComparison.OrdinalIgnoreCase)) + return string.Equals(release, "5", StringComparison.OrdinalIgnoreCase) + ? SclEdition.Edition22 : SclEdition.Unknown; - if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase) || - string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) - return SclEdition.Edition2; + if (string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(release, "4", StringComparison.OrdinalIgnoreCase) || + string.Equals(release, "4A", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition21; + // Edition 2 legacy files may omit release or use an earlier + // integer release. Future/contradictory releases stay Unknown. + if (string.IsNullOrWhiteSpace(release) || + int.TryParse(release, NumberStyles.None, CultureInfo.InvariantCulture, + out var olderRelease) && olderRelease <= 3) + return SclEdition.Edition2; + return SclEdition.Unknown; + } - if (string.Equals(version, "2003", StringComparison.OrdinalIgnoreCase) || - string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase)) - return SclEdition.Edition1; + return string.IsNullOrWhiteSpace(revision) && + string.IsNullOrWhiteSpace(release) + ? SclEdition.Edition2 : SclEdition.Unknown; + } - // Edition 1 IED capability files commonly use the historical SCL namespace - // without root version/revision attributes. Edition 2 identifies itself with - // version="2007" and revision="B" while retaining the same namespace URI. - if (string.IsNullOrWhiteSpace(version) && - string.IsNullOrWhiteSpace(revision) && - string.IsNullOrWhiteSpace(release) && - ns.Contains("/2003/scl", StringComparison.Ordinal)) - return SclEdition.Edition1; + if (string.Equals(version, "2003", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(revision) || + string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase) + ? SclEdition.Edition1 : SclEdition.Unknown; + // An explicit unsupported release/version is not safe to downgrade to + // Edition 2 using the shared 2003 namespace. + if (!string.IsNullOrWhiteSpace(version) || !string.IsNullOrWhiteSpace(release)) + return SclEdition.Unknown; + + // Retain the limited historical no-version classification for older + // IED capability files; never infer Edition 2.1/2.2 without root proof. + if (string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition2; + if (string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition1; + if (!string.IsNullOrWhiteSpace(revision)) + return SclEdition.Unknown; + if (ns.Contains("/2003/scl", StringComparison.Ordinal)) + return SclEdition.Edition1; if (ns.Contains("2007b4", StringComparison.Ordinal) || ns.Contains("ed2.1", StringComparison.Ordinal) || ns.Contains("edition2.1", StringComparison.Ordinal)) return SclEdition.Edition21; - - if (ns.Contains("scl", StringComparison.Ordinal)) - return SclEdition.Edition2; - - return SclEdition.Unknown; + return ns.Contains("scl", StringComparison.Ordinal) + ? SclEdition.Edition2 : SclEdition.Unknown; } private static string DataSetKey(string iedName, string ldInst, string lnPath, string dataSetName) diff --git a/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs b/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs index 4d9cffb8..4447b0be 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs @@ -95,6 +95,45 @@ public void Parser_Detects_Edition1_When_Historical_Namespace_Has_No_Root_Versio Assert.Equal(SclEdition.Edition1, document.Edition); } + [Theory] + [InlineData("2003", null, null, SclEdition.Edition1)] + [InlineData("2007", "B", null, SclEdition.Edition2)] + [InlineData("2007", "B", "3", SclEdition.Edition2)] + [InlineData("2007", "B", "4", SclEdition.Edition21)] + [InlineData("2007", "B", "4A", SclEdition.Edition21)] + [InlineData("2007", "C", "5", SclEdition.Edition22)] + [InlineData("2007", "C", "4", SclEdition.Unknown)] + [InlineData("2007", "C", null, SclEdition.Unknown)] + [InlineData("2007", "B", "5", SclEdition.Unknown)] + [InlineData("2007", "Z", "5", SclEdition.Unknown)] + [InlineData("2003", "C", "5", SclEdition.Unknown)] + [InlineData("2099", "C", "5", SclEdition.Unknown)] + public void Parser_DetectsExactSclEditionWithoutNamespaceGuessOrDowngrade( + string? version, string? revision, string? release, SclEdition expected) + { + // Source-independent minimal synthetic SCL, not a vendor/customer IID. + static string Attr(string name, string? value) + => value is null ? string.Empty : " " + name + "=\"" + value + "\""; + var xml = "
"; + var parsed = new SclParser().Parse(xml, "edition-matrix.iid"); + + Assert.Equal(expected, parsed.Edition); + Assert.Equal("http://www.iec.ch/61850/2003/SCL", parsed.NamespaceUri); + } + + [Fact] + public void AddingEdition22_DoesNotRenumberPreviouslyPublishedEditionEnumValues() + { + Assert.Equal(0, (int)SclEdition.Unknown); + Assert.Equal(1, (int)SclEdition.Edition1); + Assert.Equal(2, (int)SclEdition.Edition2); + Assert.Equal(3, (int)SclEdition.Edition21); + Assert.Equal(4, (int)SclEdition.Edition22); + } + internal static SclDocument LoadMinimalStation() => new SclParser().Load(MinimalStationPath());