diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6e5233..07c3c22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,14 +36,24 @@ jobs:
- name: Restore tests
run: dotnet restore tests/ARSVIN.Tests/ARSVIN.Tests.csproj
- - name: Build Publisher
- run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore
+ - name: Build Publisher with warnings as errors
+ run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror
- - name: Build Subscriber
- run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore
+ - name: Build Subscriber with warnings as errors
+ run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror
- - name: Test
- run: dotnet test tests/ARSVIN.Tests/ARSVIN.Tests.csproj -c Release --no-restore --logger trx
+ - name: Test with coverage gate
+ shell: pwsh
+ run: .\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50 -NoRestore
+
+ - name: Upload test and coverage evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: arsvin-test-evidence
+ path: artifacts/test-results
+ if-no-files-found: warn
+ retention-days: 14
- name: Publisher dependency vulnerability report
run: dotnet list src/ARSVIN/ARSVIN.csproj package --vulnerable --include-transitive
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index ad81454..13692c5 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -34,8 +34,8 @@ jobs:
- name: Restore solution
run: dotnet restore ARSVIN.sln
- - name: Build solution
- run: dotnet build ARSVIN.sln -c Release --no-restore
+ - name: Build solution with warnings as errors
+ run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8e5e9a6..447c021 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,6 +10,7 @@ on:
- '.github/workflows/release.yml'
- 'installer/**'
- 'scripts/publish-release.ps1'
+ - 'scripts/generate-sbom.ps1'
- 'publish-win-x64.ps1'
- 'build.ps1'
- 'src/**'
@@ -35,6 +36,7 @@ concurrency:
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
+ INNO_SETUP_VERSION: '6.7.1'
jobs:
package:
@@ -106,7 +108,7 @@ jobs:
throw "Tag ${{ github.ref_name }} does not point to a commit contained in main. Merge the release commit before tagging."
}
- - name: Build and test
+ - name: Build and test with warnings as errors
shell: pwsh
run: .\build.ps1
@@ -114,9 +116,22 @@ jobs:
shell: pwsh
run: .\scripts\publish-release.ps1 -Version '${{ steps.version.outputs.value }}' -SkipTests
- - name: Install Inno Setup
+ - name: Install pinned Inno Setup
shell: pwsh
- run: choco install innosetup --no-progress -y
+ run: |
+ choco install innosetup --version=$env:INNO_SETUP_VERSION --allow-downgrade --no-progress -y
+ if ($LASTEXITCODE -ne 0) {
+ throw "Chocolatey could not install Inno Setup $env:INNO_SETUP_VERSION."
+ }
+
+ $installed = choco list --local-only --exact innosetup --limit-output
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Could not query the installed Inno Setup package.'
+ }
+
+ if ($installed -notcontains "innosetup|$env:INNO_SETUP_VERSION") {
+ throw "Expected Inno Setup $env:INNO_SETUP_VERSION, found: $($installed -join ', ')"
+ }
- name: Build Windows installer
shell: pwsh
@@ -130,6 +145,12 @@ jobs:
throw 'Inno Setup compiler (ISCC.exe) was not found.'
}
+ $versionInfo = (Get-Item $iscc).VersionInfo
+ Write-Host "Validated Chocolatey package: innosetup|$env:INNO_SETUP_VERSION"
+ Write-Host "ISCC path: $iscc"
+ Write-Host "ISCC product version metadata: $($versionInfo.ProductVersion)"
+ Write-Host "ISCC file version metadata: $($versionInfo.FileVersion)"
+
$sourceDir = (Resolve-Path '.\artifacts\installer-input').Path
$outputDir = (Resolve-Path '.\artifacts\release').Path
$version = '${{ steps.version.outputs.value }}'
@@ -144,6 +165,10 @@ jobs:
throw "Inno Setup failed with exit code $LASTEXITCODE."
}
+ - name: Generate CycloneDX SBOM
+ shell: pwsh
+ run: .\scripts\generate-sbom.ps1 -Version '${{ steps.version.outputs.value }}'
+
- name: Validate release artifacts
shell: pwsh
run: |
@@ -152,7 +177,8 @@ jobs:
'ARSVIN-Publisher-win-x64.exe',
'ArSubsv-Subscriber-win-x64.exe',
'ARSVIN-Suite-Setup-win-x64.exe',
- 'ARSVIN-win-x64-portable.zip'
+ 'ARSVIN-win-x64-portable.zip',
+ 'ARSVIN-SBOM.cdx.json'
)
foreach ($name in $expected) {
@@ -166,6 +192,14 @@ jobs:
}
}
+ $sbom = Get-Content (Join-Path $releaseRoot 'ARSVIN-SBOM.cdx.json') -Raw | ConvertFrom-Json
+ if ($sbom.bomFormat -ne 'CycloneDX' -or $sbom.specVersion -ne '1.5') {
+ throw 'Generated SBOM is not CycloneDX 1.5.'
+ }
+ if (@($sbom.components).Count -lt 1) {
+ throw 'Generated SBOM contains no dependency components.'
+ }
+
Get-ChildItem $releaseRoot -File |
Select-Object Name, Length |
Sort-Object Name |
@@ -259,8 +293,8 @@ jobs:
[System.Text.UTF8Encoding]::new($false)
)
- if ($lines.Count -ne 4) {
- throw "Expected four checksummed release artifacts, found $($lines.Count)."
+ if ($lines.Count -ne 5) {
+ throw "Expected five checksummed release artifacts, found $($lines.Count)."
}
Get-Content (Join-Path $releaseRoot 'SHA256SUMS.txt')
@@ -272,6 +306,7 @@ jobs:
path: |
artifacts/release/*.exe
artifacts/release/*.zip
+ artifacts/release/*.json
artifacts/release/SHA256SUMS.txt
if-no-files-found: error
retention-days: 30
@@ -283,6 +318,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
steps:
- name: Download validated release artifact
@@ -291,6 +329,23 @@ jobs:
name: ${{ needs.package.outputs.artifact_name }}
path: artifacts/release
+ - name: Attest release build provenance
+ uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
+ with:
+ subject-path: |
+ artifacts/release/*.exe
+ artifacts/release/*.zip
+ artifacts/release/*.json
+ artifacts/release/SHA256SUMS.txt
+
+ - name: Attest release SBOM
+ uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
+ with:
+ subject-path: |
+ artifacts/release/*.exe
+ artifacts/release/*.zip
+ sbom-path: artifacts/release/ARSVIN-SBOM.cdx.json
+
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
@@ -306,4 +361,5 @@ jobs:
artifacts/release/ArSubsv-Subscriber-win-x64.exe
artifacts/release/ARSVIN-Suite-Setup-win-x64.exe
artifacts/release/ARSVIN-win-x64-portable.zip
+ artifacts/release/ARSVIN-SBOM.cdx.json
artifacts/release/SHA256SUMS.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf43120..2258f8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,17 +4,27 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change
## Unreleased
+### Added
+
+- Added pinned Coverlet MSBuild instrumentation, TRX/Cobertura/log evidence upload, and a verified 50% line-coverage regression floor for the linked production engine surface.
+- Established a measured baseline of 57.85% line coverage across 1,535 instrumented production lines, with 888 lines covered and all 26 tests passing.
+- Added a repository-owned CycloneDX 1.5 SBOM generator for direct and transitive Publisher/Subscriber NuGet dependencies while excluding test-only packages.
+- Added `ARSVIN-SBOM.cdx.json` to validated workflow artifacts, release downloads, and SHA-256 checksums.
+- Added signed GitHub build-provenance and SBOM attestations for tagged release artifacts.
+
### Security
- Pinned all GitHub Actions used by CI, CodeQL, Pages, and release workflows to immutable commit SHAs while retaining version comments for maintainability and Dependabot updates.
+- Pinned automated installer compilation to the exact Inno Setup 6.7.1 Chocolatey package and retain resolved compiler metadata in workflow evidence.
+- Treat compiler warnings as errors in validated Publisher, Subscriber, test, release, and CodeQL build paths.
+- Stabilized SBOM component ordering, source commit, and metadata timestamp for repeatable review from the same commit.
### Planned
-- Exact-version and integrity pinning for installer tooling.
- Shared `ARSVIN.Engine` class library extraction.
-- Code coverage and analyzer quality gates.
+- Higher coverage thresholds and expanded protocol regression tests.
- Search-indexable HTML engineering documentation.
-- SBOM and release provenance/attestation.
+- Windows Authenticode signing when a trusted certificate becomes available.
## 0.3.0 — 2026-07-11
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 180cba4..181a38b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,5 +8,6 @@
+
diff --git a/README.md b/README.md
index 1bc17b4..b07a375 100644
--- a/README.md
+++ b/README.md
@@ -73,8 +73,15 @@ Every tagged release builds validated Windows x64 artifacts with stable filename
| `ArSubsv-Subscriber-win-x64.exe` | Self-contained, single-file portable Subscriber. |
| `ARSVIN-Suite-Setup-win-x64.exe` | Installer containing both applications, Start Menu shortcuts, documentation, and uninstaller. |
| `ARSVIN-win-x64-portable.zip` | Portable suite folder containing both applications and essential documentation. |
+| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 software bill of materials for resolved NuGet dependencies. |
| `SHA256SUMS.txt` | SHA-256 checksums for release verification. |
+Stable and prerelease tag builds publish signed GitHub artifact attestations for provenance. Verify a downloaded file with GitHub CLI:
+
+```powershell
+gh attestation verify .\ARSVIN-Suite-Setup-win-x64.exe --repo masarray/arsvin
+```
+
The binaries are currently **unsigned**. Windows SmartScreen may show an unknown-publisher warning. Releases do not silently install Npcap; download Npcap from its official website when live capture or transmission is required.
## Quick start
@@ -121,7 +128,7 @@ For developers:
- .NET 8 SDK.
- PowerShell 7+ recommended.
- Visual Studio 2022, JetBrains Rider, or VS Code with C# tooling.
-- Inno Setup 6 only when building the installer locally.
+- Inno Setup 6.7.1 when reproducing the automated installer build locally.
## Build from source
@@ -134,19 +141,27 @@ cd arsvin
Build all release artifacts except the installer:
```powershell
-.\scripts\publish-release.ps1 -Version 0.1.0
+.\scripts\publish-release.ps1 -Version 0.3.0
```
Compatibility wrapper:
```powershell
-.\publish-win-x64.ps1 -Version 0.1.0
+.\publish-win-x64.ps1 -Version 0.3.0
```
-Run tests directly:
+Run tests with the repository coverage gate and retain TRX/Cobertura evidence:
+
+```powershell
+.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50
+```
+
+The current linked engine surface measures 57.85% line coverage across 1,535 instrumented production lines. CI enforces a 50% regression floor while the shared engine project and broader protocol tests are developed.
+
+Generate a CycloneDX SBOM after restoring the solution:
```powershell
-dotnet test .\tests\ARSVIN.Tests\ARSVIN.Tests.csproj -c Release
+.\scripts\generate-sbom.ps1 -Version 0.3.0
```
## Repository structure
@@ -156,7 +171,7 @@ src/ARSVIN/ Publisher application and SV generation engine
src/ARSVIN.Subscriber/ ArSubsv subscriber and visualization companion
tests/ARSVIN.Tests/ Protocol and publisher helper tests
installer/ Inno Setup definition for the Windows suite
-scripts/ Repeatable release packaging scripts
+scripts/ Repeatable release packaging and validation scripts
docs/ Engineering, safety, and contributor documentation
samples/ SCL, COMTRADE, scenario, and evidence samples
site/ Static, SEO-ready GitHub Pages product site
diff --git a/build.ps1 b/build.ps1
index 9a020ba..eea6880 100644
--- a/build.ps1
+++ b/build.ps1
@@ -24,12 +24,12 @@ Invoke-DotNet -Arguments @('restore', $subscriberProject)
Invoke-DotNet -Arguments @('restore', $testProject)
Write-Host '==> Building ARSVIN Publisher'
-Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore')
+Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore', '-warnaserror')
Write-Host '==> Building ArSubsv Subscriber'
-Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore')
+Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore', '-warnaserror')
Write-Host '==> Running tests'
-Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore')
+Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true')
Write-Host '==> Build completed successfully'
diff --git a/docs/build-and-release.md b/docs/build-and-release.md
index ffc6538..6c5a720 100644
--- a/docs/build-and-release.md
+++ b/docs/build-and-release.md
@@ -1,6 +1,6 @@
# Build and Release
-ARSVIN uses a tag-driven GitHub Actions workflow to build two self-contained portable applications, one suite installer, one portable ZIP, and SHA-256 checksums.
+ARSVIN uses a tag-driven GitHub Actions workflow to build two self-contained portable applications, one suite installer, one portable ZIP, a CycloneDX software bill of materials, SHA-256 checksums, and signed GitHub artifact attestations.
## Prerequisites
@@ -13,7 +13,7 @@ For local source builds:
For local installer builds:
- All requirements above
-- Inno Setup 6
+- Inno Setup 6.7.1
Npcap is required only for live capture/transmission testing. It is not silently bundled or installed by the ARSVIN release process.
@@ -29,7 +29,23 @@ The script restores, builds, and tests:
- `src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj`
- `tests/ARSVIN.Tests/ARSVIN.Tests.csproj`
-External command exit codes are checked, so CI and local builds stop immediately when a `dotnet` command fails.
+External command exit codes are checked, and compiler warnings are treated as errors for the validated build path.
+
+### Coverage evidence
+
+```powershell
+.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50
+```
+
+The script:
+
+1. runs the xUnit suite using pinned Coverlet MSBuild instrumentation,
+2. explicitly instruments the linked production engine source in `ARSVIN.Tests`,
+3. excludes test and generated source files from the reported metric,
+4. writes TRX, the complete `dotnet test` log, and Cobertura evidence under `artifacts/test-results`,
+5. fails when no production lines are instrumented or line coverage is below the configured threshold.
+
+The verified baseline is 57.85% line coverage over 1,535 instrumented production lines, with 888 lines covered. CI enforces a 50% regression floor. This is not a claim that the complete WPF UI or every live-network path is covered; raise the threshold as the shared engine project and protocol tests expand.
## Validate the public site
@@ -70,6 +86,24 @@ artifacts/installer-input/
The two direct `.exe` files are self-contained .NET 8 single-file applications. The ZIP includes both applications, essential documentation, license notices, and sample files.
+## Generate the CycloneDX SBOM
+
+After restoring the application projects, run:
+
+```powershell
+.\scripts\generate-sbom.ps1 -Version 0.3.0
+```
+
+Output:
+
+```text
+artifacts/release/ARSVIN-SBOM.cdx.json
+```
+
+The generator reads the resolved NuGet graphs for the Publisher and Subscriber projects, deduplicates direct and transitive application packages, records which application uses each package, and writes CycloneDX 1.5 JSON. Test-only packages such as xUnit and Coverlet are intentionally excluded from the release SBOM.
+
+Component order and metadata timestamp are stabilized from the source commit so repeated generation from the same commit and version is reviewable. The SBOM covers managed application dependencies; it does not claim to inventory Windows, Npcap, GitHub-hosted runner contents, or every tool used by the build service.
+
## Build the installer locally
After running the publish script:
@@ -93,6 +127,8 @@ Output:
artifacts/release/ARSVIN-Suite-Setup-win-x64.exe
```
+The automated workflow installs and verifies the exact Chocolatey package version declared in `INNO_SETUP_VERSION`. It records the resolved `ISCC.exe` path and file metadata for evidence, while the exact package version remains the authoritative toolchain pin.
+
The installer:
- installs per-user under `%LOCALAPPDATA%\Programs\ARSVIN`,
@@ -112,17 +148,19 @@ Workflow: `.github/workflows/release.yml`
When release tooling, installer definitions, project files, or build configuration change, the workflow runs on the pull request and:
-1. restores, builds, and tests the solution,
+1. restores, builds, and tests the solution with warnings treated as errors,
2. publishes both portable single-file applications,
3. creates the portable suite ZIP,
-4. compiles the Inno Setup installer,
-5. checks all expected artifact names and non-empty files,
-6. silently installs the suite into a temporary directory,
-7. verifies Publisher, Subscriber, documentation, version file, and uninstaller,
-8. silently uninstalls the temporary installation,
-9. generates checksums and uploads a private workflow artifact.
+4. installs and verifies the pinned Inno Setup package,
+5. compiles the installer,
+6. generates and structurally validates the application-only CycloneDX SBOM,
+7. checks all expected artifact names and non-empty files,
+8. silently installs the suite into a temporary directory,
+9. verifies Publisher, Subscriber, documentation, version file, and uninstaller,
+10. silently uninstalls the temporary installation,
+11. generates checksums and uploads a private workflow artifact.
-A pull-request run never creates a public GitHub Release.
+A pull-request run never creates a public GitHub Release or public attestation.
### Stable tagged release
@@ -135,7 +173,7 @@ git tag -a v0.4.0 -m "ARSVIN v0.4.0"
git push origin v0.4.0
```
-The workflow repeats the validated packaging path, downloads the validated workflow artifact in a separate least-privilege release job, and publishes all public files to GitHub Releases. Stable tags are eligible to become the repository's latest release.
+The workflow repeats the validated packaging path, downloads the validated workflow artifact in a separate least-privilege release job, creates signed provenance and SBOM attestations, and publishes all public files to GitHub Releases. Stable tags are eligible to become the repository's latest release.
### Prerelease tag
@@ -164,16 +202,27 @@ Manual runs upload private workflow artifacts only. They never create or replace
| `ArSubsv-Subscriber-win-x64.exe` | Portable Subscriber/analysis companion. |
| `ARSVIN-Suite-Setup-win-x64.exe` | Installer for both applications. |
| `ARSVIN-win-x64-portable.zip` | Portable suite package. |
+| `ARSVIN-SBOM.cdx.json` | CycloneDX 1.5 managed application-dependency SBOM. |
| `SHA256SUMS.txt` | Integrity hashes for all release assets. |
## Verify a download
+Verify the local hash:
+
```powershell
Get-FileHash .\ARSVIN-Suite-Setup-win-x64.exe -Algorithm SHA256
```
Compare the result with `SHA256SUMS.txt` from the same GitHub Release.
+Verify signed GitHub build provenance:
+
+```powershell
+gh attestation verify .\ARSVIN-Suite-Setup-win-x64.exe --repo masarray/arsvin
+```
+
+GitHub artifact attestations use a short-lived signing identity issued during the tagged workflow. This validates repository/workflow provenance; it is separate from Windows Authenticode signing.
+
## Code signing status
Release binaries are currently unsigned. Windows SmartScreen may display an unknown-publisher warning. The workflow intentionally does not contain placeholder signing steps or require paid signing secrets.
@@ -187,6 +236,7 @@ Before tagging a public release:
- Confirm the intended release commit is already on `main`.
- Confirm `main` CI, release validation, and CodeQL are green.
- Update `VersionPrefix` and `CHANGELOG.md`.
+- Review the generated application SBOM and checksums.
- Test Publisher dry run.
- Test live publishing only on an isolated lab link.
- Test Subscriber live capture and PCAP import.
diff --git a/docs/public-release-checklist.md b/docs/public-release-checklist.md
index 25ac69a..54b52fd 100644
--- a/docs/public-release-checklist.md
+++ b/docs/public-release-checklist.md
@@ -15,23 +15,34 @@ Use this checklist before making a public GitHub release.
## Engineering quality
- [ ] `dotnet restore` succeeds.
-- [ ] `dotnet build -c Release` succeeds on Windows.
-- [ ] `dotnet test -c Release` succeeds.
+- [ ] warning-free `dotnet build -c Release -warnaserror` succeeds on Windows.
+- [ ] unit tests succeed.
+- [ ] the configured line-coverage regression floor succeeds.
+- [ ] TRX and Cobertura evidence are retained by CI.
- [ ] CodeQL workflow passes.
+- [ ] dependency vulnerability reports pass.
- [ ] Dependabot is enabled for NuGet and GitHub Actions.
-- [ ] No secrets, real station files, private captures, or confidential SCL files are committed.
+- [ ] no secrets, real station files, private captures, or confidential SCL files are committed.
## Safety
- [ ] README warns that ARSVIN can transmit raw Ethernet frames.
-- [ ] Live mode safety docs are up to date.
-- [ ] Known limitations are honest and visible.
-- [ ] Release notes state that the app is not a certified protection test set.
-- [ ] Testers use isolated lab networks or point-to-point links.
+- [ ] live mode safety docs are up to date.
+- [ ] known limitations are honest and visible.
+- [ ] release notes state that the app is not a certified protection test set.
+- [ ] testers use isolated lab networks or point-to-point links.
## Release package
-- [ ] Tag format is `vX.Y.Z`.
-- [ ] Portable ZIP includes executable, README, LICENSE, NOTICE, and relevant docs.
-- [ ] Release notes include highlights, fixes, known limitations, and safety reminders.
-- [ ] Downloaded ZIP runs on a clean Windows machine with Npcap installed.
+- [ ] tag format is `vX.Y.Z` or a valid semantic-version prerelease.
+- [ ] tagged commit is already contained in protected `main`.
+- [ ] Publisher portable EXE exists and is non-empty.
+- [ ] Subscriber portable EXE exists and is non-empty.
+- [ ] suite installer exists and passes silent install/uninstall smoke testing.
+- [ ] portable ZIP includes executables, README, LICENSE, notices, and relevant docs.
+- [ ] CycloneDX SBOM exists, parses, and contains resolved dependency components.
+- [ ] SHA-256 checksum file covers all distributed release assets.
+- [ ] GitHub build-provenance and SBOM attestations are created for tagged assets.
+- [ ] `gh attestation verify` succeeds for a downloaded release file.
+- [ ] release notes include highlights, fixes, known limitations, and safety reminders.
+- [ ] downloaded artifacts run on a clean Windows machine with Npcap installed for live-network testing.
diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1
new file mode 100644
index 0000000..cc75f5e
--- /dev/null
+++ b/scripts/generate-sbom.ps1
@@ -0,0 +1,218 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$')]
+ [string] $Version,
+
+ [string] $OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$root = Split-Path -Parent $PSScriptRoot
+$applicationProjects = @(
+ [ordered]@{
+ Name = 'ARSVIN Publisher'
+ Path = Join-Path $root 'src\ARSVIN\ARSVIN.csproj'
+ },
+ [ordered]@{
+ Name = 'ArSubsv Subscriber'
+ Path = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj'
+ }
+)
+
+if ([string]::IsNullOrWhiteSpace($OutputPath)) {
+ $OutputPath = Join-Path $root 'artifacts\release\ARSVIN-SBOM.cdx.json'
+}
+elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
+ $OutputPath = Join-Path $root $OutputPath
+}
+
+$outputDirectory = Split-Path -Parent $OutputPath
+New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
+
+$sourceCommitOutput = & git -C $root rev-parse HEAD 2>&1
+if ($LASTEXITCODE -ne 0) {
+ throw "Could not resolve the source commit.`n$($sourceCommitOutput -join [Environment]::NewLine)"
+}
+$sourceCommit = ($sourceCommitOutput -join '').Trim()
+
+$sourceTimestampOutput = & git -C $root show -s --format=%cI HEAD 2>&1
+if ($LASTEXITCODE -ne 0) {
+ throw "Could not resolve the source commit timestamp.`n$($sourceTimestampOutput -join [Environment]::NewLine)"
+}
+$sourceTimestamp = [DateTimeOffset]::Parse(
+ ($sourceTimestampOutput -join '').Trim(),
+ [System.Globalization.CultureInfo]::InvariantCulture
+).ToUniversalTime().ToString('o')
+
+$packages = @{}
+
+foreach ($applicationProject in $applicationProjects) {
+ $projectName = [string] $applicationProject.Name
+ $projectPath = [string] $applicationProject.Path
+
+ Write-Host "==> Resolving NuGet dependencies: $projectName"
+ $commandOutput = & dotnet list $projectPath package --include-transitive --format json --output-version 1 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "dotnet list package failed for $projectName with exit code $LASTEXITCODE.`n$($commandOutput -join [Environment]::NewLine)"
+ }
+
+ $jsonText = $commandOutput -join [Environment]::NewLine
+ $jsonStart = $jsonText.IndexOf('{')
+ $jsonEnd = $jsonText.LastIndexOf('}')
+ if ($jsonStart -lt 0 -or $jsonEnd -le $jsonStart) {
+ throw "Could not locate the JSON dependency graph for $projectName."
+ }
+
+ $dependencyGraph = $jsonText.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json
+
+ foreach ($project in @($dependencyGraph.projects)) {
+ foreach ($framework in @($project.frameworks)) {
+ foreach ($scopeName in @('topLevelPackages', 'transitivePackages')) {
+ $property = $framework.PSObject.Properties[$scopeName]
+ if (-not $property) {
+ continue
+ }
+
+ foreach ($package in @($property.Value)) {
+ $id = [string] $package.id
+ $resolvedVersion = [string] $package.resolvedVersion
+ if ([string]::IsNullOrWhiteSpace($resolvedVersion)) {
+ $resolvedVersion = [string] $package.version
+ }
+
+ if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($resolvedVersion)) {
+ continue
+ }
+
+ $key = "$($id.ToLowerInvariant())|$resolvedVersion"
+ $isTopLevel = $scopeName -eq 'topLevelPackages'
+
+ if (-not $packages.ContainsKey($key)) {
+ $packages[$key] = [pscustomobject]@{
+ Id = $id
+ Version = $resolvedVersion
+ Scope = if ($isTopLevel) { 'direct' } else { 'transitive' }
+ UsedBy = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ }
+ }
+ elseif ($isTopLevel) {
+ $packages[$key].Scope = 'direct'
+ }
+
+ $null = $packages[$key].UsedBy.Add($projectName)
+ }
+ }
+ }
+ }
+}
+
+if ($packages.Count -eq 0) {
+ throw 'The application dependency graphs did not contain any resolved NuGet packages.'
+}
+
+$sortedPackages = @(
+ $packages.Values |
+ Sort-Object `
+ @{ Expression = { ([string] $_.Id).ToLowerInvariant() } }, `
+ @{ Expression = { [string] $_.Version } }
+)
+
+$testOnlyPackagePatterns = @(
+ '^coverlet(?:\.|$)',
+ '^xunit(?:\.|$)',
+ '^Microsoft\.NET\.Test\.Sdk$'
+)
+$testOnlyPackages = @(
+ $sortedPackages | Where-Object {
+ $packageId = [string] $_.Id
+ $testOnlyPackagePatterns | Where-Object { $packageId -match $_ }
+ }
+)
+if ($testOnlyPackages.Count -gt 0) {
+ $names = $testOnlyPackages.Id -join ', '
+ throw "Release SBOM unexpectedly contains test-only packages: $names"
+}
+
+$components = foreach ($package in $sortedPackages) {
+ $escapedId = [Uri]::EscapeDataString([string] $package.Id)
+ $escapedVersion = [Uri]::EscapeDataString([string] $package.Version)
+ $purl = "pkg:nuget/$escapedId@$escapedVersion"
+ $usedBy = @($package.UsedBy | Sort-Object) -join ', '
+
+ [ordered]@{
+ type = 'library'
+ 'bom-ref' = $purl
+ name = [string] $package.Id
+ version = [string] $package.Version
+ purl = $purl
+ properties = @(
+ [ordered]@{
+ name = 'arsvin:dependency-scope'
+ value = [string] $package.Scope
+ },
+ [ordered]@{
+ name = 'arsvin:used-by'
+ value = $usedBy
+ }
+ )
+ }
+}
+
+$sbom = [ordered]@{
+ bomFormat = 'CycloneDX'
+ specVersion = '1.5'
+ version = 1
+ metadata = [ordered]@{
+ timestamp = $sourceTimestamp
+ tools = [ordered]@{
+ components = @(
+ [ordered]@{
+ type = 'application'
+ author = 'ARSVIN project'
+ name = 'generate-sbom.ps1'
+ version = '1.1.0'
+ }
+ )
+ }
+ component = [ordered]@{
+ type = 'application'
+ 'bom-ref' = "pkg:generic/arsvin@$Version"
+ name = 'ARSVIN Windows Suite'
+ version = $Version
+ licenses = @(
+ [ordered]@{
+ license = [ordered]@{
+ id = 'Apache-2.0'
+ }
+ }
+ )
+ properties = @(
+ [ordered]@{
+ name = 'arsvin:source-commit'
+ value = $sourceCommit
+ },
+ [ordered]@{
+ name = 'arsvin:included-applications'
+ value = ($applicationProjects.Name -join ', ')
+ }
+ )
+ }
+ }
+ components = @($components)
+}
+
+$json = $sbom | ConvertTo-Json -Depth 20
+[System.IO.File]::WriteAllText(
+ $OutputPath,
+ $json + [Environment]::NewLine,
+ [System.Text.UTF8Encoding]::new($false)
+)
+
+$written = Get-Item $OutputPath
+Write-Host "==> CycloneDX SBOM written: $($written.FullName)"
+Write-Host " Source commit: $sourceCommit"
+Write-Host " Components: $($components.Count)"
+Write-Host " Size: $($written.Length) bytes"
diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1
new file mode 100644
index 0000000..450d49c
--- /dev/null
+++ b/scripts/test-with-coverage.ps1
@@ -0,0 +1,100 @@
+[CmdletBinding()]
+param(
+ [ValidateRange(0, 100)]
+ [double] $MinimumLineCoverage = 50,
+
+ [switch] $NoRestore
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$root = Split-Path -Parent $PSScriptRoot
+$testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj'
+$resultsRoot = Join-Path $root 'artifacts\test-results'
+$coveragePrefix = Join-Path $resultsRoot 'coverage'
+$testLog = Join-Path $resultsRoot 'dotnet-test.log'
+
+if (Test-Path $resultsRoot) {
+ Remove-Item $resultsRoot -Recurse -Force
+}
+New-Item -ItemType Directory -Path $resultsRoot -Force | Out-Null
+
+$arguments = @(
+ 'test',
+ $testProject,
+ '-c', 'Release'
+)
+
+if ($NoRestore) {
+ $arguments += '--no-restore'
+}
+
+# Production engine source is currently linked into ARSVIN.Tests. Force that
+# assembly into the instrumentation set, keep source matching permissive for
+# linked documents, and exclude test files from the resulting metric.
+$arguments += @(
+ '/p:TreatWarningsAsErrors=true',
+ '/p:CollectCoverage=true',
+ '/p:IncludeTestAssembly=true',
+ '/p:Include=[ARSVIN.Tests]*',
+ '/p:ExcludeAssembliesWithoutSources=None',
+ '/p:CoverletOutputFormat=cobertura',
+ "/p:CoverletOutput=$coveragePrefix",
+ '/p:DeterministicReport=true',
+ '/p:ExcludeByFile=**/tests/**%2c**/*Tests.cs%2c**/*.g.cs%2c**/*.g.i.cs%2c**/obj/**',
+ '--logger', 'trx;LogFileName=ARSVIN.Tests.trx',
+ '--results-directory', $resultsRoot
+)
+
+Write-Host "dotnet $($arguments -join ' ')"
+& dotnet @arguments 2>&1 | Tee-Object -FilePath $testLog
+$testExitCode = $LASTEXITCODE
+if ($testExitCode -ne 0) {
+ throw "dotnet test with coverage failed with exit code $testExitCode."
+}
+
+$coverageFile = Join-Path $resultsRoot 'coverage.cobertura.xml'
+if (-not (Test-Path $coverageFile -PathType Leaf)) {
+ throw "Coverlet MSBuild integration did not produce $coverageFile."
+}
+
+[xml] $coverage = Get-Content $coverageFile -Raw
+$lineRateText = [string] $coverage.coverage.'line-rate'
+$linesValidText = [string] $coverage.coverage.'lines-valid'
+if ([string]::IsNullOrWhiteSpace($lineRateText)) {
+ throw 'Cobertura report does not contain a root line-rate value.'
+}
+
+$linesValid = 0
+if (-not [int]::TryParse($linesValidText, [ref] $linesValid) -or $linesValid -le 0) {
+ throw 'Coverage report contains no instrumented production source lines.'
+}
+
+$lineRate = [double]::Parse(
+ $lineRateText,
+ [System.Globalization.CultureInfo]::InvariantCulture
+)
+$lineCoverage = [Math]::Round($lineRate * 100, 2)
+
+Write-Host "Instrumented lines: $linesValid"
+Write-Host "Line coverage: $lineCoverage%"
+Write-Host "Minimum required: $MinimumLineCoverage%"
+Write-Host "Coverage report: $coverageFile"
+
+if ($env:GITHUB_STEP_SUMMARY) {
+ @"
+## Test coverage
+
+| Metric | Result |
+|---|---:|
+| Instrumented production lines | **$linesValid** |
+| Line coverage | **$lineCoverage%** |
+| Required minimum | **$MinimumLineCoverage%** |
+| Report | `artifacts/test-results/coverage.cobertura.xml` |
+"@ | Add-Content $env:GITHUB_STEP_SUMMARY
+}
+
+if ($lineCoverage -lt $MinimumLineCoverage) {
+ throw "Line coverage $lineCoverage% is below the required $MinimumLineCoverage%."
+}
diff --git a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj
index f78111e..2161e4f 100644
--- a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj
+++ b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj
@@ -15,6 +15,10 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+