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
22 changes: 20 additions & 2 deletions .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
@@ -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 ]
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/AR.Iec61850/Scl/SclModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 46 additions & 25 deletions src/AR.Iec61850/Scl/SclParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/SclParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<SCL xmlns=\"http://www.iec.ch/61850/2003/SCL\"" +
Attr("version", version) + Attr("revision", revision) +
Attr("release", release) +
"><Header id=\"SYNTHETIC\" /></SCL>";
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());

Expand Down