diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3183adef..e537c2703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,10 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 + - name: Verify source-clean rejection fixtures + shell: powershell + run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Verify premium UX, GOOSE, SMV, SAS and release invariants shell: powershell run: | diff --git a/scripts/test-source-clean-guard.ps1 b/scripts/test-source-clean-guard.ps1 new file mode 100644 index 000000000..ed4e98f5c --- /dev/null +++ b/scripts/test-source-clean-guard.ps1 @@ -0,0 +1,84 @@ +# Copyright 2026 Ari Sulistiono +# SPDX-License-Identifier: GPL-3.0-or-later +<# +Tests the complete source-clean scanner against temporary Git-tracked fixtures. +Construct the known test identifier from code points so this test file itself +does not need an exemption from the same clean-room gate. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$scanner = Join-Path $PSScriptRoot "verify-source-clean.ps1" +$identifier = -join (@(73, 69, 68, 83, 99, 111, 117, 116) | ForEach-Object { [char]$_ }) +$cases = @( + @{ Path = "Services/Fixture.cs"; Text = "public sealed class ${identifier}Fixture {}"; Expected = "text" }, + @{ Path = "docs/reference.md"; Text = "# $identifier"; Expected = "text" }, + @{ Path = "evidence/fixture.json"; Text = "{`"reference`": `"$identifier`"}"; Expected = "text" }, + @{ Path = ".github/workflows/smart-discovery-post-merge-production.yml"; Text = "name: $identifier"; Expected = "text" }, + @{ Path = "tests/ARSAS.Tests/SyntheticFixture.cs"; Text = "// $identifier"; Expected = "text" }, + @{ Path = "docs/${identifier}-fixture.md"; Text = "# independently generated fixture"; Expected = "path" } +) + +function Invoke-Case { + param( + [Parameter(Mandatory=$true)][string]$RelativePath, + [Parameter(Mandatory=$true)][string]$Content, + [Parameter(Mandatory=$true)][bool]$MustReject, + [string]$Expected = "text" + ) + $root = Join-Path ([IO.Path]::GetTempPath()) ("arsas-clean-room-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root -Force | Out-Null + try { + & git -C $root init --quiet + if ($LASTEXITCODE -ne 0) { throw "Fixture Git initialization failed." } + $file = Join-Path $root ($RelativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)) + New-Item -ItemType Directory -Path (Split-Path $file) -Force | Out-Null + [IO.File]::WriteAllText($file, $Content, [Text.UTF8Encoding]::new($false)) + & git -C $root add --all + if ($LASTEXITCODE -ne 0) { throw "Fixture Git staging failed." } + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = "powershell.exe" + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $quotedScanner = '"' + $scanner.Replace('"', '\"') + '"' + $quotedRoot = '"' + $root.Replace('"', '\"') + '"' + $startInfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File $quotedScanner -RepositoryRoot $quotedRoot -ScanOnly" + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Source-clean fixture scanner failed to start." + } + + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $output = @($stdout, $stderr) -join [Environment]::NewLine + $process.Dispose() + if ($MustReject) { + if ($exitCode -eq 0 -or $output -notmatch ("Forbidden external identifier in " + $Expected)) { + throw "Source-clean unexpectedly accepted a forbidden $Expected fixture: $RelativePath; exit=$exitCode; output=$output" + } + } + elseif ($exitCode -ne 0) { + throw "Source-clean rejected a neutral fixture: $RelativePath; exit=$exitCode; output=$output" + } + } + finally { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } +} + +foreach ($case in $cases) { + Invoke-Case -RelativePath $case.Path -Content $case.Text -MustReject $true -Expected $case.Expected +} +Invoke-Case -RelativePath "docs/synthetic-reference.md" -Content "# ARSAS independent IEC 61850 synthetic evidence" -MustReject $false +Write-Host "Source-clean negative and positive fixture tests PASS." -ForegroundColor Green diff --git a/scripts/verify-source-clean.ps1 b/scripts/verify-source-clean.ps1 index 3c0fd91e3..f03c91ad5 100644 --- a/scripts/verify-source-clean.ps1 +++ b/scripts/verify-source-clean.ps1 @@ -12,10 +12,17 @@ publish or repeat unrelated product and company names. #> [CmdletBinding()] -param() +param( + [string]$RepositoryRoot, + [switch]$ScanOnly +) $ErrorActionPreference = "Stop" -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$RepoRoot = if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} else { + (Resolve-Path -LiteralPath $RepositoryRoot).Path +} $ForbiddenFilePatterns = @( "LICENSE-APACHE-2.0", @@ -56,16 +63,8 @@ $TextExtensions = @( ".props", ".targets", ".sln", ".slnx", ".txt" ) -# These are first-party convergence authorities. They intentionally contain the -# external interoperability label so the acceptance contract remains discoverable. -$ApprovedConvergenceIdentifierPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) -@( - ".github/workflows/smart-discovery-post-merge-production.yml", - ".github/workflows/smart-discovery-mainline-readiness.yml", - ".github/workflows/scl-interoperability-r7.yml", - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" -) | ForEach-Object { [void]$ApprovedConvergenceIdentifierPaths.Add($_) } - +# No tracked path receives a whole-file external-identifier exemption. Historical +# comparison evidence is linked by immutable commit rather than copied into active files. $Problems = New-Object System.Collections.Generic.List[string] function Normalize-RelativePath { @@ -73,17 +72,22 @@ function Normalize-RelativePath { return $Path.Replace('\', '/').TrimStart('/') } -function Get-Sha256Hex { +$Sha256 = [System.Security.Cryptography.SHA256]::Create() +$IdentifierCandidateCache = [System.Collections.Generic.Dictionary[string,bool]]::new([System.StringComparer]::Ordinal) + +function Test-ForbiddenIdentifierCandidate { param([Parameter(Mandatory=$true)][string]$Value) - $algorithm = [System.Security.Cryptography.SHA256]::Create() - try { - $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) - return -join ($algorithm.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) - } - finally { - $algorithm.Dispose() + if (-not $CandidateLengths.Contains($Value.Length)) { return $false } + if ($IdentifierCandidateCache.ContainsKey($Value)) { + return $IdentifierCandidateCache[$Value] } + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) + $hash = -join ($Sha256.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) + $isForbidden = $ForbiddenIdentifierHashes.Contains($hash) + $IdentifierCandidateCache[$Value] = $isForbidden + return $isForbidden } function Test-ContainsForbiddenIdentifier { @@ -93,11 +97,26 @@ function Test-ContainsForbiddenIdentifier { $words = @([regex]::Matches($Text.ToLowerInvariant(), '[a-z0-9]+') | ForEach-Object { $_.Value }) for ($index = 0; $index -lt $words.Count; $index++) { + $word = $words[$index] + + # Detect identifiers embedded in source/path tokens such as TypeNameSuffix. + # This closes the common case where a prohibited product name is attached + # to a class, fixture, job, or filename rather than separated by punctuation. + foreach ($length in $CandidateLengths) { + if ($word.Length -lt $length) { continue } + for ($offset = 0; $offset -le ($word.Length - $length); $offset++) { + $fragment = $word.Substring($offset, $length) + if (Test-ForbiddenIdentifierCandidate $fragment) { + return $true + } + } + } + $candidate = "" for ($count = 1; $count -le 4 -and ($index + $count - 1) -lt $words.Count; $count++) { $candidate += $words[$index + $count - 1] if ($candidate.Length -gt 22) { break } - if ($CandidateLengths.Contains($candidate.Length) -and $ForbiddenIdentifierHashes.Contains((Get-Sha256Hex $candidate))) { + if (Test-ForbiddenIdentifierCandidate $candidate) { return $true } } @@ -135,8 +154,7 @@ foreach ($relative in (Get-TrackedRelativePaths)) { } } - $identifierScanExempt = $ApprovedConvergenceIdentifierPaths.Contains($relative) - if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $relative)) { + if (Test-ContainsForbiddenIdentifier $relative) { $Problems.Add("Forbidden external identifier in path: $relative") } @@ -144,7 +162,7 @@ foreach ($relative in (Get-TrackedRelativePaths)) { if ($TextExtensions -notcontains [IO.Path]::GetExtension($relative).ToLowerInvariant()) { continue } $content = Get-Content -LiteralPath $fullPath -Raw -ErrorAction SilentlyContinue - if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $content)) { + if (Test-ContainsForbiddenIdentifier $content) { $Problems.Add("Forbidden external identifier in text: $relative") } @@ -162,7 +180,10 @@ if ($Problems.Count -gt 0) { throw "ARSAS source tree failed clean-room validation with $($Problems.Count) problem(s)." } -& (Join-Path $PSScriptRoot "verify-fault-record-bindings.ps1") -& (Join-Path $PSScriptRoot "verify-auto-update.ps1") +if (-not $ScanOnly) { + & (Join-Path $PSScriptRoot "verify-fault-record-bindings.ps1") + & (Join-Path $PSScriptRoot "verify-auto-update.ps1") +} -Write-Host "All Git-tracked ARSAS content passed source, website, external-IP, current-license, binding, and updater checks." -ForegroundColor Green +$Sha256.Dispose() +Write-Host "All Git-tracked ARSAS content passed source and external-identifier checks." -ForegroundColor Green diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index b5d7c170d..745cf9c02 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -385,15 +385,18 @@ public void InteroperabilityReferenceContract_PhysicalRetestPassedAndMergeReady( } [Fact] - public void SourceClean_DoesNotExemptNeutralReferenceEvidence() + public void SourceClean_GuardsEveryTrackedFileWithoutWholeFileExceptions() { var source = File.ReadAllText(FindRepoFile("scripts/verify-source-clean.ps1")); - - Assert.Contains("$ApprovedConvergenceIdentifierPaths", source, StringComparison.Ordinal); - Assert.DoesNotContain("docs/INTEROPERABILITY_REFERENCE_CONTRACT.md", source, StringComparison.Ordinal); - Assert.DoesNotContain("evidence/interoperability-reference-target.json", source, StringComparison.Ordinal); - Assert.Contains("CanonicalLiveSclExportRegressionTests.cs", source, StringComparison.Ordinal); - Assert.Contains("identifierScanExempt", source, StringComparison.Ordinal); + var build = File.ReadAllText(FindRepoFile(".github/workflows/build.yml")); + + Assert.DoesNotContain("$ApprovedConvergenceIdentifierPaths", source, StringComparison.Ordinal); + Assert.DoesNotContain("identifierScanExempt", source, StringComparison.Ordinal); + Assert.Contains("if (Test-ContainsForbiddenIdentifier $relative)", source, StringComparison.Ordinal); + Assert.Contains("if (Test-ContainsForbiddenIdentifier $content)", source, StringComparison.Ordinal); + Assert.Contains("test-source-clean-guard.ps1", build, StringComparison.Ordinal); + Assert.Contains("RepositoryRoot", source, StringComparison.Ordinal); + Assert.Contains("ScanOnly", source, StringComparison.Ordinal); } diff --git a/tests/ARSAS.Tests/InteroperabilityReferenceEvidenceRegressionTests.cs b/tests/ARSAS.Tests/InteroperabilityReferenceEvidenceRegressionTests.cs index 58443173f..35698c5b5 100644 --- a/tests/ARSAS.Tests/InteroperabilityReferenceEvidenceRegressionTests.cs +++ b/tests/ARSAS.Tests/InteroperabilityReferenceEvidenceRegressionTests.cs @@ -55,7 +55,8 @@ public void ActiveReferenceAndOriginalHistoricalProvenance_AreBothDiscoverable() Assert.Contains("externalReferenceCapture", workflow, StringComparison.Ordinal); Assert.Contains("evidence/interoperability-reference-target.json", workflow, StringComparison.Ordinal); Assert.Contains("evidence/interoperability-reference-target.json", documentation, StringComparison.Ordinal); - Assert.Contains("ApprovedConvergenceIdentifierPaths", sourceClean, StringComparison.Ordinal); + Assert.DoesNotContain("ApprovedConvergenceIdentifierPaths", sourceClean, StringComparison.Ordinal); + Assert.Contains("No tracked path receives a whole-file external-identifier exemption", sourceClean, StringComparison.Ordinal); } private static string FindRepositoryFile(string path)