diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 798b339a..68a6e12f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -88,6 +88,12 @@ jobs:
.\tools\generate-dotnet-notices.ps1
-PublishDirectory artifacts/win-x64
+ - name: Generate exact component notice coverage
+ shell: pwsh
+ run: >-
+ .\tools\generate-dotnet-component-coverage.ps1
+ -PublishDirectory artifacts/win-x64
+
- name: Generate reviewed .NET redistribution notice
shell: pwsh
run: >-
@@ -100,7 +106,7 @@ jobs:
.\tools\generate-sbom.ps1
-PublishDirectory artifacts/win-x64
- - name: Verify incomplete and stale packages are rejected
+ - name: Verify incomplete, stale and unmapped packages are rejected
shell: pwsh
run: >-
.\tools\test-release-compliance-negative.ps1
@@ -121,6 +127,8 @@ jobs:
-DirectoryPath artifacts/win-x64 `
-ArchivePath $archivePath `
-ReportPath $reportPath
+ .\tools\verify-dotnet-component-coverage.ps1 `
+ -ArchivePath $archivePath
- name: Upload verified Windows alpha and compliance report
uses: actions/upload-artifact@v4
diff --git a/docs/BUILD.md b/docs/BUILD.md
index c77d0eca..66e17ef4 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -6,29 +6,19 @@ These steps create a self-contained Windows x64 executable and verified ZIP.
Use Windows 10/11 x64 and the exact SDK pinned in `global.json`.
-Current release inputs:
-
```text
.NET SDK 8.0.423
.NET Runtime 8.0.29
Windows Desktop Runtime 8.0.29
```
-Verify:
-
-```powershell
-dotnet --version
-```
-
-SDK roll-forward is disabled.
-
-## 1. Verify release metadata and maintainer decision
+## 1. Verify metadata
```powershell
.\tools\verify-release-metadata.ps1
```
-`Directory.Build.props` is the canonical source for product, SDK, runtime, RID, publish-mode and artifact metadata. The check compares it with the project, `global.json`, the reviewed redistribution-template SHA-256 and the maintainer decision in `release/dotnet-redistribution-review.json`. A mismatched configuration or `blocked` decision stops the build.
+`Directory.Build.props` is the canonical source for product, SDK, runtime, RID, publish mode and artifact metadata. A mismatched reviewed configuration or blocked maintainer decision stops the build.
## 2. Restore and test
@@ -41,9 +31,7 @@ dotnet test .\tests\SightAdapt.Tests\SightAdapt.Tests.csproj `
--no-restore
```
-The application restore graph is the authority for runtime-pack inventory.
-
-## 3. Publish
+## 3. Publish and capture bundle inputs
```powershell
dotnet publish .\src\SightAdapt\SightAdapt.csproj `
@@ -52,69 +40,58 @@ dotnet publish .\src\SightAdapt\SightAdapt.csproj `
--output .\artifacts\win-x64
```
-The static legal baseline is copied to the publish directory. The Microsoft redistribution notice is generated later from reviewed metadata.
+The project captures the SDK-provided `FilesToBundle` list in `artifacts\dotnet-files-to-bundle.tsv`. This temporary file identifies exact single-file inputs; absolute paths are not copied into the release ZIP.
-## 4. Generate exact-version Microsoft notices
+## 4. Import exact official .NET notices
```powershell
.\tools\generate-dotnet-notices.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-This verifies the restored runtime packs, downloads the matching official SDK ZIP, verifies SHA-512 and writes:
-
-- `THIRD-PARTY-NOTICES.txt`;
-- `DOTNET-LICENSE-NOTICE.txt`;
-- `DOTNET-NOTICE-METADATA.json`.
+This verifies the runtime release train and official SDK ZIP SHA-512, then writes the exact Microsoft license, third-party notices and base metadata.
-## 5. Generate the maintainer-reviewed redistribution notice
+## 5. Generate component-level notice coverage
```powershell
-.\tools\generate-dotnet-redistribution-notice.ps1 `
+.\tools\generate-dotnet-component-coverage.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-This verifies the reviewed configuration, template checksum, maintainer decision and exact-version metadata, then writes `MICROSOFT-DOTNET-REDISTRIBUTION.txt`.
+This maps every embedded package asset and every loose runtime binary to:
-No external legal audit is required by the project plan. The generated file remains an internal project notice, not legal advice or clearance.
+- exact package and version;
+- package SHA-512;
+- package-relative asset path;
+- component SHA-256;
+- reviewed notice mapping.
-## 6. Generate SBOM and license report
+The step also adds exact package notice sections for components not covered by the official .NET release bundle. Unreviewed, non-shipped or unmapped package components fail the build.
+
+## 6. Generate redistribution summary
```powershell
-.\tools\generate-sbom.ps1 `
+.\tools\generate-dotnet-redistribution-notice.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-This creates `DEPENDENCIES.md`, `SBOM.spdx.json` and `LICENSE-REPORT.json`.
+## 7. Generate SBOM and license report
-## 7. Inspect the staged directory
-
-At minimum:
-
-```text
-SightAdapt.exe
-LICENSE.txt
-THIRD-PARTY-NOTICES.txt
-DOTNET-LICENSE-NOTICE.txt
-DOTNET-NOTICE-METADATA.json
-MICROSOFT-DOTNET-REDISTRIBUTION.txt
-THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt
-DEPENDENCIES.md
-SBOM.spdx.json
-LICENSE-REPORT.json
-PRIVACY.md
+```powershell
+.\tools\generate-sbom.ps1 `
+ -PublishDirectory .\artifacts\win-x64
```
-## 8. Run negative package checks
+## 8. Run negative checks
```powershell
.\tools\test-release-compliance-negative.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-The validator must reject an incomplete package and a package with stale redistribution metadata.
+The validators must reject incomplete, stale and deliberately unmapped packages.
-## 9. Create and verify the final archive
+## 9. Create and verify the final ZIP
```powershell
$archive = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64.zip'
@@ -129,26 +106,38 @@ Compress-Archive `
-DirectoryPath .\artifacts\win-x64 `
-ArchivePath $archive `
-ReportPath $report
-```
-Retain the compliance report with the archive.
+.\tools\verify-dotnet-component-coverage.ps1 `
+ -ArchivePath $archive
+```
-## 10. Run and inspect the version
+Retain the verified archive and compliance report together.
-```powershell
-.\artifacts\win-x64\SightAdapt.exe
+## Expected package root
-$process = Get-Process SightAdapt
-(Get-Item $process.Path).VersionInfo |
- Format-List ProductVersion, FileVersion
+```text
+SightAdapt.exe
+LICENSE.txt
+THIRD-PARTY-NOTICES.txt
+DOTNET-LICENSE-NOTICE.txt
+DOTNET-NOTICE-METADATA.json
+MICROSOFT-DOTNET-REDISTRIBUTION.txt
+THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt
+DEPENDENCIES.md
+SBOM.spdx.json
+LICENSE-REPORT.json
+PRIVACY.md
```
+Additional loose native runtime DLLs are allowed only when schema-2 metadata maps each file by exact output path and SHA-256.
+
## Clean rebuild
```powershell
Remove-Item .\artifacts\win-x64 -Recurse -Force -ErrorAction SilentlyContinue
+Remove-Item .\artifacts\dotnet-files-to-bundle.tsv -Force -ErrorAction SilentlyContinue
Remove-Item .\artifacts\SightAdapt-*-win-x64.zip -Force -ErrorAction SilentlyContinue
Remove-Item .\artifacts\SightAdapt-*-win-x64-compliance.json -Force -ErrorAction SilentlyContinue
```
-A release must not be published if metadata review, notice generation, the maintainer decision, negative checks or final-package validation fail.
+Do not publish when metadata review, notice import, component coverage, SBOM/license review, negative checks or final-package validation fails.
diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md
index d1e68c05..13829402 100644
--- a/docs/PACKAGING.md
+++ b/docs/PACKAGING.md
@@ -2,60 +2,55 @@
This document defines the minimum contents and verification rules for every binary distribution of SightAdapt.
-## Canonical required-file manifest
+## Canonical required files
The machine-readable source of truth is `release/required-files.txt`.
-Every package must place these files at the package root:
-
| File | Purpose |
|---|---|
-| `SightAdapt.exe` | Application executable |
+| `SightAdapt.exe` | Application executable and single-file container |
| `LICENSE.txt` | MIT License for SightAdapt project code |
-| `THIRD-PARTY-NOTICES.txt` | Exact-version Microsoft/.NET third-party notices |
-| `DOTNET-LICENSE-NOTICE.txt` | Exact-version Microsoft .NET license text and source metadata |
-| `DOTNET-NOTICE-METADATA.json` | SDK, runtime, RID, runtime-pack, source and checksum evidence |
-| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed Microsoft redistribution notice |
+| `THIRD-PARTY-NOTICES.txt` | Exact .NET notices and package-specific notice sections |
+| `DOTNET-LICENSE-NOTICE.txt` | Exact Microsoft .NET license text and source metadata |
+| `DOTNET-NOTICE-METADATA.json` | Exact package, component, notice and checksum evidence |
+| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed redistribution notice |
| `THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt` | Mark ownership, no-endorsement and DRM/access-control boundaries |
| `DEPENDENCIES.md` | Human-readable dependency inventory |
| `SBOM.spdx.json` | SPDX 2.3 component and shipped-file inventory |
| `LICENSE-REPORT.json` | Dependency-license policy result |
| `PRIVACY.md` | Application privacy and local-data notice |
-A package is incomplete if a required file is missing, empty, unreadable, stale or inconsistent with the pinned build inputs.
+A package is incomplete if a required file is missing, unreadable, stale, inconsistent with pinned inputs or contains a binary without exact component notice evidence.
-## Distribution formats
+## Publish sequence
-The same bundle is required for Actions artifacts, manual ZIPs, installers, store packages, portable builds and mirrors. Platform metadata does not replace readable files in the installed or unpacked application directory.
+1. `SightAdapt.csproj` copies the static legal baseline and captures SDK `FilesToBundle` inputs.
+2. `generate-dotnet-notices.ps1` imports exact official .NET legal text.
+3. `generate-dotnet-component-coverage.ps1` maps all embedded and loose package components and adds package-specific notices.
+4. `generate-dotnet-redistribution-notice.ps1` renders the reviewed redistribution summary.
+5. `generate-sbom.ps1` generates the dependency inventory, SPDX SBOM and license report.
-Mirrors must publish the same verified bytes and compliance report without stripping or replacing notices.
+## Exact component coverage
-## Publish behavior
+Schema-2 `DOTNET-NOTICE-METADATA.json` records:
-1. `SightAdapt.csproj` copies the repository legal baseline.
-2. `generate-dotnet-notices.ps1` imports exact-version Microsoft license/notice material.
-3. `generate-dotnet-redistribution-notice.ps1` validates the reviewed configuration, template checksum and maintainer decision, then generates the package notice.
-4. `generate-sbom.ps1` creates `DEPENDENCIES.md`, `SBOM.spdx.json` and `LICENSE-REPORT.json`.
+- SHA-256 of the temporary `FilesToBundle` manifest;
+- exact package identities and NuGet SHA-512 values;
+- repository and license metadata;
+- notice mappings for exact .NET release text and package-specific terms;
+- one sanitized record per embedded or loose component;
+- package-relative asset path and component SHA-256;
+- zero unmapped external components.
-Expected package root:
+The reviewed alpha contains 452 mapped package components across:
-```text
-SightAdapt.exe
-LICENSE.txt
-THIRD-PARTY-NOTICES.txt
-DOTNET-LICENSE-NOTICE.txt
-DOTNET-NOTICE-METADATA.json
-MICROSOFT-DOTNET-REDISTRIBUTION.txt
-THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt
-DEPENDENCIES.md
-SBOM.spdx.json
-LICENSE-REPORT.json
-PRIVACY.md
-```
+- `Microsoft.NETCore.App.Runtime.win-x64/8.0.29`;
+- `Microsoft.WindowsDesktop.App.Runtime.win-x64/8.0.29`;
+- `Microsoft.Windows.SDK.NET.Ref/10.0.19041.56`.
-## Final package compliance gate
+`Microsoft.Windows.SDK.NET.Ref` is classified as `shipped-embedded`, because `Microsoft.Windows.SDK.NET.dll` and `WinRT.Runtime.dll` are present in `FilesToBundle`.
-Create the archive from the publish directory and validate both representations:
+## Final package validation
```powershell
$archive = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64.zip'
@@ -70,54 +65,43 @@ Compress-Archive `
-DirectoryPath '.\artifacts\win-x64' `
-ArchivePath $archive `
-ReportPath $report
+
+.\tools\verify-dotnet-component-coverage.ps1 `
+ -ArchivePath $archive
```
-The gate validates the manifest, staged files, final ZIP, release identity, exact .NET metadata, maintainer redistribution decision, license-policy result and SBOM coverage. It writes the final archive SHA-256 and decision evidence to the compliance report.
+The general compliance gate validates package, metadata, license-report and SBOM invariants. The focused component validator independently verifies package evidence, notice mappings, exact loose-binary hashes and absence of unmapped binaries.
-The negative check must also pass:
+## Negative checks
```powershell
.\tools\test-release-compliance-negative.ps1 `
-PublishDirectory '.\artifacts\win-x64'
```
-It proves that incomplete packages and stale redistribution notices are rejected.
-
-## Microsoft .NET evidence
-
-`global.json` pins the SDK, `Directory.Build.props` records the expected runtime/RID/publish inputs and `project.assets.json` identifies actual runtime packs.
+The workflow proves rejection of:
-The exact-version notice generator downloads the matching official SDK ZIP, verifies its SHA-512 and imports its license and third-party notice text. The restore graph remains the authority for selected runtime packs.
+- an incomplete package;
+- stale redistribution metadata;
+- a package containing a real runtime DLL after its component mapping is deliberately removed.
-`release/dotnet-redistribution-review.json` records the exact reviewed configuration, notice-template SHA-256 and maintainer decision. No external legal audit is planned. A mismatch or `blocked` decision fails before packaging.
-
-See [Microsoft .NET redistribution analysis](legal/DOTNET-REDISTRIBUTION.md) and [Exact-version notice generation](legal/DOTNET-NOTICE-GENERATION.md).
-
-## Other notices and SBOM
-
-The third-party names/DRM notice governs mark ownership, compatibility wording and protected-content limitations.
-
-The dependency policy, restore graph, workflows, .NET metadata and final publish files feed the SPDX SBOM and license report. `SightAdapt.exe` is the documented single-file container for embedded runtime components.
-
-## Installers, stores and mirrors
+## Distribution formats
-Every maintained packaging workflow must validate its final installed/unpacked file set against `release/required-files.txt` and retain the outer container checksum. Portable packages use the ZIP gate directly. Mirrors publish the same verified bytes.
+The same legal/compliance bundle is required for Actions artifacts, manual ZIPs, installers, store packages, portable builds and mirrors. Each maintained format must validate its final installed or unpacked file set. Mirrors publish the same verified bytes and report.
## Release checklist
-1. verify release metadata and the maintainer redistribution record;
-2. verify the .NET analysis and reviewed notice template;
-3. review compatibility/DRM wording;
-4. restore, build and test;
-5. publish into a clean staging directory;
-6. generate exact-version .NET notices;
-7. generate the maintainer-reviewed redistribution notice;
-8. generate SBOM, license report and dependency inventory;
-9. resolve license-policy failures;
-10. confirm incomplete and stale packages are rejected;
-11. create and verify the final package;
-12. retain/publish the compliance report;
-13. inspect legal documents for readability;
-14. publish the same verified bytes to mirrors.
-
-Do not publish when any package, notice, SBOM, license, metadata or maintainer-decision check fails. The project does not require an external audit; release risk is accepted, conditioned or blocked by the maintainer under the documented governance policy.
+1. verify canonical release metadata and maintainer decision;
+2. restore, build and test;
+3. publish and capture `FilesToBundle`;
+4. import exact official .NET notices;
+5. generate exact package/component coverage;
+6. generate the redistribution summary;
+7. generate SBOM, license report and dependency inventory;
+8. resolve every policy or component-mapping failure;
+9. prove incomplete, stale and unmapped packages are rejected;
+10. create and run both validators against the final package;
+11. retain the package and compliance report;
+12. publish identical verified bytes to official mirrors.
+
+Do not publish when any notice, package hash, component mapping, SBOM, license, metadata or maintainer-decision check fails.
diff --git a/docs/legal/DOTNET-NOTICE-GENERATION.md b/docs/legal/DOTNET-NOTICE-GENERATION.md
index 1b1032b9..ba2f89ee 100644
--- a/docs/legal/DOTNET-NOTICE-GENERATION.md
+++ b/docs/legal/DOTNET-NOTICE-GENERATION.md
@@ -1,95 +1,96 @@
# Exact-version .NET notice generation
-This document defines the release process for Microsoft .NET license and third-party notice material included in a self-contained SightAdapt Windows package.
+This document defines the Microsoft .NET and package notice evidence included in a self-contained SightAdapt Windows package.
-## Authoritative release inputs
+## Authoritative inputs
- `global.json` pins the .NET SDK and disables roll-forward;
-- `Directory.Build.props` records expected SDK/runtime/RID/publish metadata and the official release-metadata URL;
-- `release/dotnet-redistribution-review.json` records the reviewed SDK/runtime/TFM/RID/publish configuration, notice-template SHA-256 and maintainer decision.
+- `Directory.Build.props` records SDK, runtime, RID, publish mode and release-metadata URL;
+- `project.assets.json` records exact runtime packs selected by restore;
+- MSBuild `PrepareForBundle` and `FilesToBundle` provide the exact inputs embedded in the single-file executable;
+- `release/dependency-policy.json` records reviewed package versions, shipped status and license treatment;
+- `release/dotnet-redistribution-review.json` records the maintainer-reviewed release configuration and wording.
-Current inputs:
+## Stage 1: exact official .NET notice import
-| Input | Value |
-|---|---|
-| .NET SDK | `8.0.423` |
-| .NET runtime | `8.0.29` |
-| Windows Desktop Runtime | `8.0.29` |
-| RID | `win-x64` |
-| Publish mode | self-contained single-file |
-
-## Evidence sources
-
-1. `project.assets.json` identifies exact runtime packs selected by restore.
-2. The official, hash-verified matching SDK ZIP supplies Microsoft's `LICENSE.txt` and `ThirdPartyNotices.txt`.
-
-The restore graph identifies selected packs; the SDK archive supplies the authoritative legal text for the same release train.
-
-## Exact-version generator
-
-Run after restore and publish:
+After restore and publish, run:
```powershell
.\tools\generate-dotnet-notices.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-The generator:
+This stage:
- requires the exact pinned SDK;
-- reads exact runtime-pack versions from the restore graph;
-- rejects unreviewed or mismatched packs;
-- confirms the SDK/runtime release mapping;
-- downloads the official SDK ZIP and verifies SHA-512;
-- imports `LICENSE.txt` and `ThirdPartyNotices.txt`;
-- records URLs, versions, packs and checksums;
-- writes `THIRD-PARTY-NOTICES.txt`, `DOTNET-LICENSE-NOTICE.txt` and `DOTNET-NOTICE-METADATA.json`.
+- validates exact runtime-pack versions from the restore graph;
+- confirms the SDK/runtime release mapping through official Microsoft metadata;
+- downloads the matching official SDK ZIP;
+- verifies its published SHA-512;
+- imports `LICENSE.txt` and `ThirdPartyNotices.txt` without substantive modification;
+- writes base `DOTNET-NOTICE-METADATA.json` schema 1.
-## Maintainer-reviewed redistribution notice
+## Stage 2: exact published-component coverage
-Then run:
+`SightAdapt.csproj` captures the SDK-generated `FilesToBundle` list during single-file publication. Then run:
```powershell
-.\tools\generate-dotnet-redistribution-notice.ps1 `
+.\tools\generate-dotnet-component-coverage.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-It renders `release/MICROSOFT-DOTNET-REDISTRIBUTION.template.txt` using canonical release metadata. Before writing the package notice, it verifies:
+This stage:
+
+1. identifies every NuGet package contributing an embedded component;
+2. requires a reviewed dependency-policy entry and correct shipped classification;
+3. records exact NuGet package SHA-512 and repository metadata;
+4. reads exact `.nuspec` license metadata;
+5. maps every embedded package asset with package-relative path and SHA-256;
+6. maps every loose binary by filename and SHA-256 to an exact restored package asset;
+7. adds package-specific sections to `THIRD-PARTY-NOTICES.txt` when components are not covered by the official .NET release bundle;
+8. upgrades `DOTNET-NOTICE-METADATA.json` to schema 2 with package, notice-mapping and component inventories;
+9. fails when a package is unreviewed, marked non-shipped or cannot be mapped.
+
+The temporary `FilesToBundle` manifest may contain absolute build paths. Only its SHA-256 and sanitized component records are retained in the release metadata.
-- reviewed SDK/runtime/TFM/RID/publish values;
-- reviewed template SHA-256;
-- maintainer decision status, owner and issue record;
-- exact-version metadata for the same artifact.
+## Current component evidence
-A `blocked` maintainer decision fails packaging. No external legal audit is required or planned.
+The reviewed alpha build maps 452 package components across three exact packages:
-## Archive validation
+| Package | Components | Notice mapping |
+|---|---:|---|
+| `Microsoft.NETCore.App.Runtime.win-x64/8.0.29` | 165 | Official exact-release .NET license and notices |
+| `Microsoft.WindowsDesktop.App.Runtime.win-x64/8.0.29` | 285 | Official exact-release .NET license and notices |
+| `Microsoft.Windows.SDK.NET.Ref/10.0.19041.56` | 2 | Exact package SHA-512, policy license and `.nuspec` license URL |
+
+The inventory contains 447 embedded components and 5 loose native DLLs. `unmappedExternalComponentCount` must remain zero.
+
+## Stage 3: maintainer redistribution summary
+
+Run:
+
+```powershell
+.\tools\generate-dotnet-redistribution-notice.ps1 `
+ -PublishDirectory .\artifacts\win-x64
+```
-`tools/test-release-package.ps1` verifies:
+This renders `MICROSOFT-DOTNET-REDISTRIBUTION.txt` after validating the reviewed SDK/runtime/TFM/RID/publish configuration, template SHA-256, maintainer decision and exact-version metadata.
-- required files and UTF-8 readability;
-- exact notice metadata and checksums;
-- required runtime-pack mapping;
-- generated third-party notice identity;
-- redistribution notice headers for product, SDK, runtime, TFM, RID, publish mode, review date and maintainer decision.
+## Final-package validation
-CI runs the validator before artifact upload and retains `project.assets.json` in diagnostics.
+Two independent validators run on the final ZIP:
-## Updating .NET
+- `test-release-package.ps1` checks package completeness, official notice identity and redistribution headers;
+- `verify-dotnet-component-coverage.ps1` checks schema-2 component evidence, package hashes, notice mappings, all loose binary SHA-256 values and absence of unmapped binaries.
-A .NET update must:
+The negative package test proves rejection of:
-1. update `global.json` and `Directory.Build.props`;
-2. confirm the official SDK/runtime mapping;
-3. update `release/dotnet-redistribution-review.json` after maintainer review;
-4. regenerate exact-version and redistribution notices;
-5. inspect restore dependencies, metadata and generated text;
-6. review new runtime packs;
-7. run stale-notice negative validation and final-package validation;
-8. update reviewed-version documentation.
+- an incomplete package;
+- stale redistribution wording;
+- a package where one real loose binary remains but its component mapping is removed.
-## Review triggers
+## Maintenance
-Repeat the maintainer review when SDK/runtime, TFM, RID, publish settings, runtime packs, Microsoft licensing sources or redistribution wording change.
+Repeat generation and review when SDK/runtime, TFM, RID, publish settings, restored packages, `FilesToBundle`, loose binaries, package license metadata or Microsoft legal sources change.
-Do not publish when the authoritative package cannot be verified, notice files are absent, the review record is stale, the maintainer decision is blocked or a runtime component is not mapped to reviewed notice material.
+Do not publish when official-text import, package policy, package hashes, component mapping, notice mapping or final ZIP validation fails.
diff --git a/docs/legal/DOTNET-REDISTRIBUTION.md b/docs/legal/DOTNET-REDISTRIBUTION.md
index 7ddc5551..9911380a 100644
--- a/docs/legal/DOTNET-REDISTRIBUTION.md
+++ b/docs/legal/DOTNET-REDISTRIBUTION.md
@@ -7,65 +7,75 @@
| Maintainer review record | `release/dotnet-redistribution-review.json` |
| Reviewed configuration | Exact SDK, runtime, target framework, runtime identifier and publish mode recorded in the review record |
| Reviewed notice wording | `release/MICROSOFT-DOTNET-REDISTRIBUTION.template.txt`, protected by the SHA-256 in the review record |
+| Component evidence | Schema-2 `DOTNET-NOTICE-METADATA.json`, generated from `FilesToBundle`, restored packages and final loose binaries |
| Package notice | Generated as `MICROSOFT-DOTNET-REDISTRIBUTION.txt` from the reviewed template and canonical release metadata |
| Decision owner | KeyffMS / aiteracja.pl |
| External legal audit | Not planned |
This document records the project's internal technical and business-risk decision. It is not legal advice and does not claim legal clearance or review by outside counsel.
-The machine-readable review record is the source of truth for the exact technical configuration covered by the maintainer decision. A change to the SDK, runtime, target framework, runtime identifier, publish mode or notice template fails release validation until the record is deliberately updated.
-
## Authoritative sources
-The review uses official Microsoft sources:
+The review uses:
-1. [.NET license information](https://github.com/dotnet/core/blob/main/license-information.md), describing the licensing model for .NET distributions and self-contained applications;
-2. [.NET Library License Terms](https://dotnet.microsoft.com/en-us/dotnet_library_license.htm);
-3. the exact release metadata URL recorded in `Directory.Build.props`;
+1. official [.NET license information](https://github.com/dotnet/core/blob/main/license-information.md);
+2. official [.NET Library License Terms](https://dotnet.microsoft.com/en-us/dotnet_library_license.htm);
+3. exact .NET release metadata recorded in `Directory.Build.props`;
4. the hash-verified official SDK archive identified in `DOTNET-NOTICE-METADATA.json`;
-5. the actual `project.assets.json` produced by restore.
+5. the actual `project.assets.json` restore graph;
+6. exact NuGet package SHA-512 and `.nuspec` license metadata for every package contributing published components;
+7. MSBuild `PrepareForBundle` / `FilesToBundle` evidence and final loose-binary hashes.
+
+The generated files preserve the exact evidence. This analysis paraphrases project treatment and does not replace the source terms.
+
+## Published Microsoft-origin component inventory
-The generated files preserve the authoritative text and evidence. This analysis paraphrases operational requirements and does not replace them.
+The reviewed `0.5.0.50-alpha` build contains **452 mapped package components**:
-## Microsoft-origin component inventory
+| Exact package | Mapped components | Disposition | Notice treatment |
+|---|---:|---|---|
+| `Microsoft.NETCore.App.Runtime.win-x64/8.0.29` | 165 | Embedded in `SightAdapt.exe` | Exact official SDK `LICENSE.txt` and `ThirdPartyNotices.txt` for the same release train |
+| `Microsoft.WindowsDesktop.App.Runtime.win-x64/8.0.29` | 285 | 280 embedded, 5 loose native DLLs | Exact official SDK `LICENSE.txt` and `ThirdPartyNotices.txt` for the same release train |
+| `Microsoft.Windows.SDK.NET.Ref/10.0.19041.56` | 2 | Embedded in `SightAdapt.exe` | Exact package SHA-512, project policy license and `.nuspec` license URL recorded in `THIRD-PARTY-NOTICES.txt` and metadata |
-### Included in the self-contained binary
+The two Windows SDK package components are:
-| Component family | Release identity authority | Included purpose | Project treatment |
-|---|---|---|---|
-| .NET application host and host policy | `Microsoft.NETCore.App.Runtime` from the restore graph | Starts and hosts the application | Object code included only as part of SightAdapt |
-| .NET managed runtime and base libraries | Exact runtime-pack version from restore | CLR, GC, framework libraries and native support | Microsoft terms and imported notices preserved |
-| Windows Desktop / Windows Forms runtime | Exact `Microsoft.WindowsDesktop.App.Runtime` from restore | Windows Forms UI/runtime | Microsoft terms and imported notices preserved |
-| Runtime third-party components | Official `ThirdPartyNotices.txt` for the release train | Native and managed dependencies incorporated into Microsoft runtime packs | Separate notices preserved in `THIRD-PARTY-NOTICES.txt` |
+- `Microsoft.Windows.SDK.NET.dll`;
+- `WinRT.Runtime.dll`.
-The precise restore identities are recorded in `DOTNET-NOTICE-METADATA.json`. Single-file embedding does not change the separate licensing status of incorporated components.
+They were previously described as build-reference-only. Publish-time `FilesToBundle` evidence proves that they are embedded in the final executable, so `release/dependency-policy.json` now classifies the package as `shipped-embedded`.
-A restore dependency is retained for auditability, but classified as shipped only when the final package and SBOM identify it as present or embedded in the documented single-file container.
+The five loose native files are mapped by exact SHA-256 to `Microsoft.WindowsDesktop.App.Runtime.win-x64/8.0.29`:
-### Inputs not redistributed as product components
+- `D3DCompiler_47_cor3.dll`;
+- `PenImc_cor3.dll`;
+- `PresentationNative_cor3.dll`;
+- `vcruntime140_cor3.dll`;
+- `wpfgfx_cor3.dll`.
+
+`DOTNET-NOTICE-METADATA.json` is the source of truth for the complete component list, package-relative paths, dispositions and hashes. Absolute CI paths are not retained.
+
+## Inputs not redistributed as product components
| Component | Role | Distribution status |
|---|---|---|
| Pinned .NET SDK | Restore, compile, test and publish; source of matching legal text | Build infrastructure only |
-| `Microsoft.Windows.SDK.NET.Ref` | Compile-time Windows API metadata | Build reference only |
| Test SDK/adapter/framework | Automated testing | Test-only |
| GitHub Actions | CI infrastructure | Not shipped |
-| Windows system APIs/DLLs | Operating-system services | Supplied by Windows, not copied as SightAdapt redistributables |
+| Windows system APIs/DLLs not copied by publish | Operating-system services | Supplied by Windows |
## Maintainer redistribution controls
For the reviewed configuration:
-1. Microsoft components are distributed in object-code form only as part of SightAdapt, not as a standalone .NET distribution.
-2. The package preserves Microsoft's license text, exact-version notices and machine-readable source/checksum evidence.
-3. SightAdapt's MIT License applies only to SightAdapt project code.
-4. Recipients receive `MICROSOFT-DOTNET-REDISTRIBUTION.txt` with the project's redistribution conditions and source references.
-5. Downstream packages must preserve the legal bundle and keep Microsoft components within an intact application distribution.
-6. The project does not imply Microsoft sponsorship, certification or endorsement.
-7. The project does not impose reciprocal/source-disclosure terms on Microsoft distributable code.
-8. Notices must not be removed and Microsoft components must not be redistributed standalone.
-9. Applicable export-control and non-waivable consumer rights remain relevant.
-10. Microsoft warranty/support/remedy provisions remain separate from SightAdapt's MIT terms.
+1. Microsoft components are distributed in object-code form only as part of SightAdapt.
+2. Every package contributing a published component has exact identity, SHA-512, policy license and source metadata.
+3. Every embedded or loose component has package-relative identity and SHA-256 evidence.
+4. The package preserves exact official .NET license/notice material plus package-specific notice sections where required.
+5. SightAdapt's MIT License applies only to SightAdapt project code.
+6. Recipients receive the complete package-root legal bundle.
+7. Downstream packages must preserve the legal bundle and must not imply Microsoft sponsorship or endorsement.
+8. An unreviewed package, unmapped bundled component or unmapped loose binary blocks publication.
## Package implementation
@@ -75,28 +85,32 @@ Every binary package includes:
|---|---|
| `LICENSE.txt` | MIT License for SightAdapt project code |
| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed redistribution notice |
-| `DOTNET-LICENSE-NOTICE.txt` | Exact Microsoft license text from the hash-verified SDK archive |
-| `THIRD-PARTY-NOTICES.txt` | Exact Microsoft/.NET third-party notices |
-| `DOTNET-NOTICE-METADATA.json` | Exact versions, runtime packs, sources and checksums |
+| `DOTNET-LICENSE-NOTICE.txt` | Exact Microsoft .NET license text from the hash-verified SDK archive |
+| `THIRD-PARTY-NOTICES.txt` | Exact .NET notices plus exact package-specific sections |
+| `DOTNET-NOTICE-METADATA.json` | Exact package/component identities, sources and checksums |
| `DEPENDENCIES.md` | Human-readable dependency inventory |
-`tools/generate-dotnet-redistribution-notice.ps1` validates the maintainer review record and template checksum, confirms exact-version .NET metadata and renders the package notice. `tools/test-release-package.ps1` independently validates its headers inside the final ZIP.
+Generation and validation are separated:
+
+- `generate-dotnet-notices.ps1` imports exact official .NET legal text;
+- `generate-dotnet-component-coverage.ps1` creates component-level package and notice mappings;
+- `generate-dotnet-redistribution-notice.ps1` renders the reviewed redistribution summary;
+- `test-release-package.ps1` validates general package/notice consistency;
+- `verify-dotnet-component-coverage.ps1` validates every package/component mapping in the final ZIP.
## Review triggers
-Repeat the maintainer review when any of the following changes:
+Repeat the maintainer review when any of these change:
-- .NET SDK, runtime or Windows Desktop Runtime version;
-- target framework or minimum Windows target;
-- runtime identifier or architecture;
-- framework-dependent/self-contained or single-file settings;
-- trimming, ReadyToRun, Native AOT or extraction settings;
-- runtime-pack inventory or Microsoft redistributables;
-- product name, Microsoft compatibility claims or distribution channels;
-- Microsoft licensing, privacy, export or support terms;
-- reviewed notice wording.
+- SDK, runtime, Windows Desktop Runtime, TFM, RID or architecture;
+- self-contained, single-file, trimming, ReadyToRun, Native AOT or extraction settings;
+- restored packages or `FilesToBundle` inventory;
+- loose native binaries;
+- package license metadata or policy classification;
+- distribution channels or Microsoft compatibility claims;
+- Microsoft licensing sources or reviewed notice wording.
-The early metadata check, generator and final-package validator reject configuration mismatches. The negative package test proves that stale notice wording cannot pass.
+The negative package checks prove that incomplete, stale and deliberately unmapped runtime packages are rejected.
## Decision
diff --git a/release/dependency-policy.json b/release/dependency-policy.json
index 5b69e6b0..c3372e0f 100644
--- a/release/dependency-policy.json
+++ b/release/dependency-policy.json
@@ -127,8 +127,8 @@
"Microsoft.Windows.SDK.NET.Ref": {
"expectedVersion": "10.0.19041.56",
"expectedVersionSource": "",
- "scope": "build-reference",
- "shipped": false,
+ "scope": "shipped-embedded",
+ "shipped": true,
"supplier": "Organization: Microsoft Corporation",
"license": "LicenseRef-Microsoft-Windows-SDK-Reference-Terms",
"source": "https://www.nuget.org/packages/Microsoft.Windows.SDK.NET.Ref",
diff --git a/src/SightAdapt/SightAdapt.csproj b/src/SightAdapt/SightAdapt.csproj
index bb17e8d0..d11e0558 100644
--- a/src/SightAdapt/SightAdapt.csproj
+++ b/src/SightAdapt/SightAdapt.csproj
@@ -24,6 +24,8 @@
true
10.0.19041.0
true
+ $(MSBuildProjectDirectory)\..\..\artifacts
+ $(SightAdaptBundleManifestDirectory)\dotnet-files-to-bundle.tsv
@@ -60,4 +62,16 @@
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
+
+
+
+
+
diff --git a/tools/generate-dotnet-component-coverage.ps1 b/tools/generate-dotnet-component-coverage.ps1
new file mode 100644
index 00000000..c358d7f4
--- /dev/null
+++ b/tools/generate-dotnet-component-coverage.ps1
@@ -0,0 +1,572 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$PublishDirectory,
+
+ [string]$AssetsPath,
+
+ [string]$BundleManifestPath,
+
+ [string]$PolicyPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+if ([string]::IsNullOrWhiteSpace($AssetsPath)) {
+ $AssetsPath = Join-Path $root 'src\SightAdapt\obj\project.assets.json'
+}
+if ([string]::IsNullOrWhiteSpace($BundleManifestPath)) {
+ $BundleManifestPath = Join-Path $root 'artifacts\dotnet-files-to-bundle.tsv'
+}
+if ([string]::IsNullOrWhiteSpace($PolicyPath)) {
+ $PolicyPath = Join-Path $root 'release\dependency-policy.json'
+}
+
+function Get-FileSha256([string]$Path) {
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+}
+
+function Get-FileSha512([string]$Path) {
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA512).Hash.ToLowerInvariant()
+}
+
+function Get-TextSha256([string]$Text) {
+ $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text)
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ return [System.BitConverter]::ToString(
+ $sha256.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha256.Dispose()
+ }
+}
+
+function Convert-Base64Sha512ToHex([string]$Value) {
+ try {
+ $bytes = [Convert]::FromBase64String($Value.Trim())
+ }
+ catch {
+ throw "Invalid NuGet SHA-512 value: $($_.Exception.Message)"
+ }
+ if ($bytes.Length -ne 64) {
+ throw "NuGet SHA-512 value has $($bytes.Length) bytes instead of 64."
+ }
+ return [System.BitConverter]::ToString($bytes).Replace('-', '').ToLowerInvariant()
+}
+
+function Get-NormalizedRelativePath(
+ [string]$BasePath,
+ [string]$Path) {
+ return [System.IO.Path]::GetRelativePath(
+ [System.IO.Path]::GetFullPath($BasePath),
+ [System.IO.Path]::GetFullPath($Path)).Replace('\', '/')
+}
+
+function Test-PathUnderRoot(
+ [string]$Path,
+ [string]$CandidateRoot) {
+ $fullPath = [System.IO.Path]::GetFullPath($Path)
+ $fullRoot = [System.IO.Path]::GetFullPath($CandidateRoot).TrimEnd(
+ [System.IO.Path]::DirectorySeparatorChar,
+ [System.IO.Path]::AltDirectorySeparatorChar) +
+ [System.IO.Path]::DirectorySeparatorChar
+ return $fullPath.StartsWith(
+ $fullRoot,
+ [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Get-OutputPath(
+ [string]$SourcePath,
+ [string]$RelativePath,
+ [string]$BundleRelativePath) {
+ foreach ($candidate in @($RelativePath, $BundleRelativePath)) {
+ if (-not [string]::IsNullOrWhiteSpace($candidate)) {
+ return $candidate.Replace('\', '/').TrimStart('/')
+ }
+ }
+ return [System.IO.Path]::GetFileName($SourcePath)
+}
+
+function Get-AssetKind([string]$PackageAssetPath) {
+ $normalized = $PackageAssetPath.Replace('\', '/').ToLowerInvariant()
+ $extension = [System.IO.Path]::GetExtension($normalized)
+ if ($normalized -match '(^|/)native/' -or
+ $extension -in @('.exe', '.so', '.dylib')) {
+ return 'native'
+ }
+ if ($extension -eq '.dll') {
+ return 'managed-or-native-dll'
+ }
+ return 'runtime-content'
+}
+
+$resolvedPublish = (Resolve-Path -LiteralPath $PublishDirectory).Path
+$resolvedAssets = (Resolve-Path -LiteralPath $AssetsPath).Path
+$resolvedBundleManifest = (Resolve-Path -LiteralPath $BundleManifestPath).Path
+$resolvedPolicy = (Resolve-Path -LiteralPath $PolicyPath).Path
+$metadataPath = Join-Path $resolvedPublish 'DOTNET-NOTICE-METADATA.json'
+$thirdPartyNoticesPath = Join-Path $resolvedPublish 'THIRD-PARTY-NOTICES.txt'
+if (-not [System.IO.File]::Exists($metadataPath) -or
+ -not [System.IO.File]::Exists($thirdPartyNoticesPath)) {
+ throw 'Generate exact-version .NET notices before component coverage.'
+}
+
+$metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
+if ([int]$metadata.schemaVersion -ne 1) {
+ throw "Expected base .NET notice metadata schema 1; found '$($metadata.schemaVersion)'."
+}
+$runtimeVersion = [string]$metadata.runtimeVersion
+$rid = [string]$metadata.runtimeIdentifier
+$assets = Get-Content -LiteralPath $resolvedAssets -Raw | ConvertFrom-Json
+$policy = Get-Content -LiteralPath $resolvedPolicy -Raw | ConvertFrom-Json
+$allowedLicenses = @($policy.allowedLicenseExpressions)
+
+$packageFolders = @(
+ $assets.packageFolders.PSObject.Properties |
+ ForEach-Object { [string]$_.Name }
+)
+if ($packageFolders.Count -eq 0) {
+ throw 'The restore graph does not record NuGet package folders.'
+}
+
+function Get-PolicyComponent([string]$PackageId) {
+ $property = @($policy.components.PSObject.Properties | Where-Object {
+ [string]$_.Name -ieq $PackageId
+ }) | Select-Object -First 1
+ if ($null -eq $property) {
+ return $null
+ }
+ return $property.Value
+}
+
+function Resolve-PackageLocation(
+ [string]$PackageId,
+ [string]$PackageVersion) {
+ foreach ($folder in $packageFolders) {
+ $candidate = Join-Path (
+ Join-Path $folder ($PackageId.ToLowerInvariant())) $PackageVersion
+ if ([System.IO.Directory]::Exists($candidate)) {
+ return (Resolve-Path -LiteralPath $candidate).Path
+ }
+ }
+ return $null
+}
+
+$packageRecords = @{}
+function Get-OrCreatePackageRecord(
+ [string]$PackageId,
+ [string]$PackageVersion,
+ [string]$PackageRoot) {
+ $key = "$($PackageId.ToLowerInvariant())/$($PackageVersion.ToLowerInvariant())"
+ if ($packageRecords.ContainsKey($key)) {
+ return $packageRecords[$key]
+ }
+
+ $policyComponent = Get-PolicyComponent $PackageId
+ if ($null -eq $policyComponent) {
+ throw "Package '$PackageId/$PackageVersion' is present in the published bundle but absent from release/dependency-policy.json."
+ }
+ $expectedVersion = [string]$policyComponent.expectedVersion
+ $expectedVersionSource = [string]$policyComponent.expectedVersionSource
+ if ($expectedVersionSource -eq 'runtime') {
+ $expectedVersion = $runtimeVersion
+ }
+ if (-not [string]::IsNullOrWhiteSpace($expectedVersion) -and
+ $expectedVersion -ne $PackageVersion) {
+ throw "Package '$PackageId' version '$PackageVersion' does not match reviewed version '$expectedVersion'."
+ }
+ $policyLicense = [string]$policyComponent.license
+ if ($allowedLicenses -notcontains $policyLicense) {
+ throw "Package '$PackageId/$PackageVersion' uses unapproved policy license '$policyLicense'."
+ }
+
+ $nuspec = Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.nuspec' |
+ Select-Object -First 1
+ if ($null -eq $nuspec) {
+ throw "Package '$PackageId/$PackageVersion' has no extracted nuspec."
+ }
+ [xml]$nuspecXml = Get-Content -LiteralPath $nuspec.FullName
+ $metadataNode = $nuspecXml.SelectSingleNode(
+ "//*[local-name()='metadata']")
+ $canonicalId = [string]$metadataNode.SelectSingleNode(
+ "*[local-name()='id']").InnerText
+ $canonicalVersion = [string]$metadataNode.SelectSingleNode(
+ "*[local-name()='version']").InnerText
+ if ($canonicalId -ine $PackageId -or $canonicalVersion -ne $PackageVersion) {
+ throw "Package cache path '$PackageId/$PackageVersion' does not match nuspec '$canonicalId/$canonicalVersion'."
+ }
+
+ $licenseNode = $metadataNode.SelectSingleNode("*[local-name()='license']")
+ $licenseUrlNode = $metadataNode.SelectSingleNode("*[local-name()='licenseUrl']")
+ $licenseType = $null
+ $licenseValue = $null
+ $licenseFileSha256 = $null
+ $licenseFileText = $null
+ if ($null -ne $licenseNode) {
+ $licenseType = [string]$licenseNode.type
+ $licenseValue = ([string]$licenseNode.InnerText).Trim()
+ if ($licenseType -ieq 'file') {
+ $licenseFilePath = Join-Path $PackageRoot $licenseValue
+ if (-not [System.IO.File]::Exists($licenseFilePath)) {
+ $licenseFile = Get-ChildItem -LiteralPath $PackageRoot -File -Recurse |
+ Where-Object {
+ $_.FullName.EndsWith(
+ $licenseValue.Replace('/', [System.IO.Path]::DirectorySeparatorChar),
+ [StringComparison]::OrdinalIgnoreCase)
+ } |
+ Select-Object -First 1
+ if ($null -ne $licenseFile) {
+ $licenseFilePath = $licenseFile.FullName
+ }
+ }
+ if (-not [System.IO.File]::Exists($licenseFilePath)) {
+ throw "Package '$PackageId/$PackageVersion' references missing license file '$licenseValue'."
+ }
+ $licenseFileText = Get-Content -LiteralPath $licenseFilePath -Raw
+ $licenseFileSha256 = Get-FileSha256 $licenseFilePath
+ }
+ }
+ elseif ($null -ne $licenseUrlNode) {
+ $licenseType = 'url'
+ $licenseValue = ([string]$licenseUrlNode.InnerText).Trim()
+ }
+ else {
+ throw "Package '$PackageId/$PackageVersion' has no nuspec license metadata."
+ }
+
+ $shaFile = Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.nupkg.sha512' |
+ Select-Object -First 1
+ if ($null -eq $shaFile) {
+ throw "Package '$PackageId/$PackageVersion' has no NuGet SHA-512 evidence."
+ }
+ $packageSha512 = Convert-Base64Sha512ToHex (
+ Get-Content -LiteralPath $shaFile.FullName -Raw)
+ $nupkg = Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.nupkg' |
+ Select-Object -First 1
+ if ($null -ne $nupkg -and (Get-FileSha512 $nupkg.FullName) -ne $packageSha512) {
+ throw "Package '$PackageId/$PackageVersion' nupkg does not match its SHA-512 evidence."
+ }
+
+ $repositoryNode = $metadataNode.SelectSingleNode("*[local-name()='repository']")
+ $repositoryUrl = if ($null -ne $repositoryNode) {
+ [string]$repositoryNode.url
+ }
+ else {
+ [string]$policyComponent.source
+ }
+ $repositoryCommit = if ($null -ne $repositoryNode) {
+ [string]$repositoryNode.commit
+ }
+ else {
+ $null
+ }
+
+ $runtimeIdentity = "$canonicalId/$canonicalVersion"
+ $isDotNetRuntimePack = @($metadata.runtimePackages) -contains $runtimeIdentity
+ $mappingId = if ($isDotNetRuntimePack) {
+ 'exact-release-dotnet-bundle'
+ }
+ else {
+ "package-license:$runtimeIdentity"
+ }
+
+ $record = [pscustomobject]@{
+ key = $key
+ id = $canonicalId
+ version = $canonicalVersion
+ identity = $runtimeIdentity
+ root = $PackageRoot
+ policy = $policyComponent
+ policyLicense = $policyLicense
+ packageSha512 = $packageSha512
+ packageUrl = "https://api.nuget.org/v3-flatcontainer/$($canonicalId.ToLowerInvariant())/$canonicalVersion/$($canonicalId.ToLowerInvariant()).$canonicalVersion.nupkg"
+ repositoryUrl = $repositoryUrl
+ repositoryCommit = $repositoryCommit
+ licenseType = $licenseType
+ licenseValue = $licenseValue
+ licenseFileSha256 = $licenseFileSha256
+ licenseFileText = $licenseFileText
+ mappingId = $mappingId
+ isDotNetRuntimePack = $isDotNetRuntimePack
+ }
+ $packageRecords[$key] = $record
+ return $record
+}
+
+function Resolve-PackageForSource([string]$SourcePath) {
+ foreach ($folder in $packageFolders) {
+ if (-not (Test-PathUnderRoot $SourcePath $folder)) {
+ continue
+ }
+ $relative = Get-NormalizedRelativePath $folder $SourcePath
+ $segments = $relative -split '/'
+ if ($segments.Count -lt 3) {
+ throw "Cannot identify package and version for '$SourcePath'."
+ }
+ $packageId = $segments[0]
+ $packageVersion = $segments[1]
+ $packageRoot = Resolve-PackageLocation $packageId $packageVersion
+ if ([string]::IsNullOrWhiteSpace($packageRoot)) {
+ throw "Cannot resolve package root for '$packageId/$packageVersion'."
+ }
+ return Get-OrCreatePackageRecord $packageId $packageVersion $packageRoot
+ }
+ return $null
+}
+
+foreach ($runtimeIdentity in @($metadata.runtimePackages)) {
+ $parts = ([string]$runtimeIdentity) -split '/', 2
+ if ($parts.Count -ne 2) {
+ throw "Invalid runtime package identity '$runtimeIdentity'."
+ }
+ $runtimeRoot = Resolve-PackageLocation $parts[0] $parts[1]
+ if ([string]::IsNullOrWhiteSpace($runtimeRoot)) {
+ throw "Cannot resolve restored runtime package '$runtimeIdentity'."
+ }
+ Get-OrCreatePackageRecord $parts[0] $parts[1] $runtimeRoot | Out-Null
+}
+
+$bundleLines = @(
+ Get-Content -LiteralPath $resolvedBundleManifest |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+)
+if ($bundleLines.Count -eq 0) {
+ throw 'The MSBuild FilesToBundle manifest is empty.'
+}
+
+$components = [System.Collections.Generic.List[object]]::new()
+$applicationBundleEntryCount = 0
+foreach ($line in $bundleLines) {
+ $parts = $line -split '\|', 3
+ $sourcePath = $parts[0].Trim()
+ $relativePath = if ($parts.Count -gt 1) { $parts[1].Trim() } else { '' }
+ $bundleRelativePath = if ($parts.Count -gt 2) { $parts[2].Trim() } else { '' }
+ if ([string]::IsNullOrWhiteSpace($sourcePath) -or
+ -not [System.IO.File]::Exists($sourcePath)) {
+ throw "The FilesToBundle manifest references an unavailable source file: '$sourcePath'."
+ }
+
+ $package = Resolve-PackageForSource $sourcePath
+ if ($null -eq $package) {
+ $applicationBundleEntryCount++
+ continue
+ }
+ if (-not [bool]$package.policy.shipped) {
+ throw "Package '$($package.identity)' contributes to the published bundle but policy marks it as not shipped."
+ }
+ $assetPath = Get-NormalizedRelativePath $package.root $sourcePath
+ $components.Add([ordered]@{
+ disposition = 'embedded'
+ outputPath = Get-OutputPath $sourcePath $relativePath $bundleRelativePath
+ package = $package.identity
+ packageAssetPath = $assetPath
+ assetKind = Get-AssetKind $assetPath
+ sha256 = Get-FileSha256 $sourcePath
+ noticeMapping = $package.mappingId
+ })
+}
+
+$assetCandidatesByName = @{}
+foreach ($package in $packageRecords.Values) {
+ foreach ($asset in Get-ChildItem -LiteralPath $package.root -File -Recurse) {
+ $extension = $asset.Extension.ToLowerInvariant()
+ if ($extension -notin @('.dll', '.exe', '.so', '.dylib')) {
+ continue
+ }
+ $key = $asset.Name.ToLowerInvariant()
+ if (-not $assetCandidatesByName.ContainsKey($key)) {
+ $assetCandidatesByName[$key] = [System.Collections.Generic.List[object]]::new()
+ }
+ $assetCandidatesByName[$key].Add([pscustomobject]@{
+ package = $package
+ file = $asset
+ })
+ }
+}
+
+$looseBinaryFiles = @(
+ Get-ChildItem -LiteralPath $resolvedPublish -File -Recurse |
+ Where-Object {
+ $_.Extension.ToLowerInvariant() -in @('.dll', '.exe', '.so', '.dylib') -and
+ $_.Name -ne 'SightAdapt.exe'
+ }
+)
+foreach ($looseFile in $looseBinaryFiles) {
+ $key = $looseFile.Name.ToLowerInvariant()
+ $looseSha256 = Get-FileSha256 $looseFile.FullName
+ $matches = if ($assetCandidatesByName.ContainsKey($key)) {
+ @($assetCandidatesByName[$key] | Where-Object {
+ (Get-FileSha256 $_.file.FullName) -eq $looseSha256
+ })
+ }
+ else {
+ @()
+ }
+ if ($matches.Count -eq 0) {
+ throw "Loose binary '$($looseFile.Name)' does not match any reviewed package asset."
+ }
+ if ($matches.Count -gt 1) {
+ $identities = @($matches | ForEach-Object {
+ "$($_.package.identity):$(Get-NormalizedRelativePath $_.package.root $_.file.FullName)"
+ })
+ throw "Loose binary '$($looseFile.Name)' has ambiguous package matches:`n$($identities -join "`n")"
+ }
+ $match = $matches[0]
+ if (-not [bool]$match.package.policy.shipped) {
+ throw "Loose binary '$($looseFile.Name)' maps to package '$($match.package.identity)' marked as not shipped."
+ }
+ $assetPath = Get-NormalizedRelativePath $match.package.root $match.file.FullName
+ $components.Add([ordered]@{
+ disposition = 'loose'
+ outputPath = Get-NormalizedRelativePath $resolvedPublish $looseFile.FullName
+ package = $match.package.identity
+ packageAssetPath = $assetPath
+ assetKind = Get-AssetKind $assetPath
+ sha256 = $looseSha256
+ noticeMapping = $match.package.mappingId
+ })
+}
+
+$componentArray = @($components | Sort-Object disposition, outputPath, package)
+if ($componentArray.Count -eq 0) {
+ throw 'No package components were mapped from the published application.'
+}
+$requiredRuntimePacks = @(
+ "Microsoft.NETCore.App.Runtime.$rid/$runtimeVersion",
+ "Microsoft.WindowsDesktop.App.Runtime.$rid/$runtimeVersion"
+)
+foreach ($requiredPack in $requiredRuntimePacks) {
+ if (@($componentArray | Where-Object {
+ [string]$_.package -eq $requiredPack
+ }).Count -eq 0) {
+ throw "Required runtime pack '$requiredPack' has no mapped published component."
+ }
+}
+
+$noticeMappings = [System.Collections.Generic.List[object]]::new()
+$noticeMappings.Add([ordered]@{
+ id = 'exact-release-dotnet-bundle'
+ kind = 'official-dotnet-release-bundle'
+ licenseFile = 'DOTNET-LICENSE-NOTICE.txt'
+ licenseSha256 = ([string]$metadata.source.importedLicenseSha256).ToLowerInvariant()
+ thirdPartyNoticesFile = 'THIRD-PARTY-NOTICES.txt'
+ thirdPartyNoticesSha256 = ([string]$metadata.source.importedThirdPartyNoticesSha256).ToLowerInvariant()
+})
+
+$additionalSections = [System.Collections.Generic.List[string]]::new()
+$publishedPackageEvidence = [System.Collections.Generic.List[object]]::new()
+foreach ($package in ($packageRecords.Values | Sort-Object identity)) {
+ $packageComponents = @($componentArray | Where-Object {
+ [string]$_.package -eq $package.identity
+ })
+ if ($packageComponents.Count -eq 0) {
+ continue
+ }
+
+ $publishedPackageEvidence.Add([ordered]@{
+ package = $package.identity
+ packageUrl = $package.packageUrl
+ packageSha512 = $package.packageSha512
+ repositoryUrl = $package.repositoryUrl
+ repositoryCommit = $package.repositoryCommit
+ policyLicense = $package.policyLicense
+ publishedComponentCount = $packageComponents.Count
+ })
+
+ if ($package.isDotNetRuntimePack) {
+ continue
+ }
+
+ $licenseFileLine = if ([string]::IsNullOrWhiteSpace($package.licenseFileSha256)) {
+ 'NuGet license file SHA-256: none'
+ }
+ else {
+ "NuGet license file SHA-256: $($package.licenseFileSha256)"
+ }
+ $section = @"
+
+================================================================================
+SIGHTADAPT EXACT PACKAGE NOTICE
+
+Package: $($package.identity)
+Package URL: $($package.packageUrl)
+Package SHA-512: $($package.packageSha512)
+Policy license: $($package.policyLicense)
+NuGet license type: $($package.licenseType)
+NuGet license value: $($package.licenseValue)
+$licenseFileLine
+Repository: $($package.repositoryUrl)
+Repository commit: $($package.repositoryCommit)
+Mapped components: $($packageComponents.Count)
+
+This exact package contributes components embedded in or shipped with SightAdapt.
+The package identity, package hash, component paths and component hashes are
+recorded in DOTNET-NOTICE-METADATA.json.
+"@
+ if (-not [string]::IsNullOrWhiteSpace($package.licenseFileText)) {
+ $section += @"
+
+Exact package license file follows without substantive modification:
+
+--------------------------------------------------------------------------------
+$($package.licenseFileText)
+"@
+ }
+ $sectionSha256 = Get-TextSha256 $section
+ $additionalSections.Add($section)
+ $noticeMappings.Add([ordered]@{
+ id = $package.mappingId
+ kind = 'exact-nuget-package-license'
+ package = $package.identity
+ packageSha512 = $package.packageSha512
+ policyLicense = $package.policyLicense
+ nuspecLicenseType = $package.licenseType
+ nuspecLicenseValue = $package.licenseValue
+ licenseFileSha256 = $package.licenseFileSha256
+ noticeFile = 'THIRD-PARTY-NOTICES.txt'
+ noticeSectionSha256 = $sectionSha256
+ })
+}
+
+if ($additionalSections.Count -gt 0) {
+ Add-Content -LiteralPath $thirdPartyNoticesPath `
+ -Value ($additionalSections -join '') `
+ -Encoding utf8
+}
+
+$embeddedCount = @($componentArray | Where-Object {
+ [string]$_.disposition -eq 'embedded'
+}).Count
+$looseCount = @($componentArray | Where-Object {
+ [string]$_.disposition -eq 'loose'
+}).Count
+$metadata.schemaVersion = 2
+$metadata | Add-Member -NotePropertyName componentCoverage -NotePropertyValue ([ordered]@{
+ method = 'MSBuild PrepareForBundle/FilesToBundle plus SHA-256 matching of loose binaries to exact restored package assets'
+ bundleManifestSha256 = Get-FileSha256 $resolvedBundleManifest
+ bundleEntryCount = $bundleLines.Count
+ applicationBundleEntryCount = $applicationBundleEntryCount
+ runtimeComponentCount = $componentArray.Count
+ embeddedRuntimeComponentCount = $embeddedCount
+ looseRuntimeComponentCount = $looseCount
+ unmappedExternalComponentCount = 0
+ noticeMappings = @($noticeMappings)
+ packages = @($publishedPackageEvidence)
+ components = $componentArray
+}) -Force
+
+[System.IO.File]::WriteAllText(
+ $metadataPath,
+ ($metadata | ConvertTo-Json -Depth 14) + [Environment]::NewLine,
+ [System.Text.UTF8Encoding]::new($false))
+
+Write-Host (
+ "Generated exact notice coverage for {0} published package components ({1} embedded, {2} loose) across {3} packages." -f
+ $componentArray.Count,
+ $embeddedCount,
+ $looseCount,
+ $publishedPackageEvidence.Count)
diff --git a/tools/generate-dotnet-notices.ps1 b/tools/generate-dotnet-notices.ps1
index 5219a8c8..b6753375 100644
--- a/tools/generate-dotnet-notices.ps1
+++ b/tools/generate-dotnet-notices.ps1
@@ -174,7 +174,10 @@ try {
function Read-ZipText([System.IO.Compression.ZipArchiveEntry]$Entry) {
$stream = $Entry.Open()
try {
- $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8, $true)
+ $reader = [System.IO.StreamReader]::new(
+ $stream,
+ [System.Text.Encoding]::UTF8,
+ $true)
try {
return $reader.ReadToEnd()
}
@@ -196,8 +199,14 @@ try {
$licenseSourcePath = Join-Path $tempRoot 'LICENSE.txt'
$noticeSourcePath = Join-Path $tempRoot 'ThirdPartyNotices.txt'
- [System.IO.File]::WriteAllText($licenseSourcePath, $licenseText, [System.Text.UTF8Encoding]::new($false))
- [System.IO.File]::WriteAllText($noticeSourcePath, $noticeText, [System.Text.UTF8Encoding]::new($false))
+ [System.IO.File]::WriteAllText(
+ $licenseSourcePath,
+ $licenseText,
+ [System.Text.UTF8Encoding]::new($false))
+ [System.IO.File]::WriteAllText(
+ $noticeSourcePath,
+ $noticeText,
+ [System.Text.UTF8Encoding]::new($false))
$licenseSha256 = (Get-FileHash -LiteralPath $licenseSourcePath -Algorithm SHA256).Hash
$noticeSha256 = (Get-FileHash -LiteralPath $noticeSourcePath -Algorithm SHA256).Hash
diff --git a/tools/test-release-compliance-negative.ps1 b/tools/test-release-compliance-negative.ps1
index 685f3848..9d22cc14 100644
--- a/tools/test-release-compliance-negative.ps1
+++ b/tools/test-release-compliance-negative.ps1
@@ -17,17 +17,19 @@ $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) (
function Assert-PackageRejected(
[string]$ArchivePath,
[string]$Scenario) {
- $failedAsExpected = $false
+ $rejected = $false
try {
& (Join-Path $PSScriptRoot 'test-release-package.ps1') `
-ArchivePath $ArchivePath
+ & (Join-Path $PSScriptRoot 'verify-dotnet-component-coverage.ps1') `
+ -ArchivePath $ArchivePath
}
catch {
- $failedAsExpected = $true
+ $rejected = $true
Write-Host "$Scenario was rejected as expected: $($_.Exception.Message)"
}
- if (-not $failedAsExpected) {
+ if (-not $rejected) {
throw "$Scenario unexpectedly passed validation."
}
}
@@ -47,6 +49,7 @@ try {
if (-not [string]::IsNullOrWhiteSpace($PublishDirectory)) {
$resolvedPublish = (Resolve-Path -LiteralPath $PublishDirectory).Path
+
$staleDirectory = Join-Path $tempRoot 'stale-redistribution-notice'
$staleArchive = Join-Path $tempRoot 'stale-redistribution-notice.zip'
[System.IO.Directory]::CreateDirectory($staleDirectory) | Out-Null
@@ -56,10 +59,6 @@ try {
-Force
$noticePath = Join-Path $staleDirectory 'MICROSOFT-DOTNET-REDISTRIBUTION.txt'
- if (-not [System.IO.File]::Exists($noticePath)) {
- throw 'The published redistribution notice is unavailable for the stale-notice test.'
- }
-
[xml]$props = Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props')
$runtimeVersion = [string]$props.Project.PropertyGroup.SightAdaptDotNetRuntimeVersion
$currentHeader = "Runtime version: $runtimeVersion"
@@ -67,18 +66,51 @@ try {
if (-not $notice.Contains($currentHeader, [StringComparison]::Ordinal)) {
throw "The stale-notice test could not locate '$currentHeader'."
}
- $mutated = $notice.Replace(
- $currentHeader,
- 'Runtime version: 0.0.0-stale')
[System.IO.File]::WriteAllText(
$noticePath,
- $mutated,
+ $notice.Replace($currentHeader, 'Runtime version: 0.0.0-stale'),
[System.Text.UTF8Encoding]::new($false))
-
[System.IO.Compression.ZipFile]::CreateFromDirectory(
$staleDirectory,
$staleArchive)
Assert-PackageRejected $staleArchive 'The package with stale redistribution metadata'
+
+ $unmappedDirectory = Join-Path $tempRoot 'unmapped-runtime-component'
+ $unmappedArchive = Join-Path $tempRoot 'unmapped-runtime-component.zip'
+ [System.IO.Directory]::CreateDirectory($unmappedDirectory) | Out-Null
+ Copy-Item -Path (Join-Path $resolvedPublish '*') `
+ -Destination $unmappedDirectory `
+ -Recurse `
+ -Force
+
+ $metadataPath = Join-Path $unmappedDirectory 'DOTNET-NOTICE-METADATA.json'
+ $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
+ $components = @($metadata.componentCoverage.components)
+ $looseComponents = @($components | Where-Object {
+ [string]$_.disposition -eq 'loose'
+ })
+ if ($looseComponents.Count -eq 0) {
+ throw 'The unmapped-component test requires at least one loose runtime binary.'
+ }
+ $removed = $looseComponents[0]
+ $remaining = @($components | Where-Object {
+ -not (
+ [string]$_.disposition -eq [string]$removed.disposition -and
+ [string]$_.outputPath -eq [string]$removed.outputPath -and
+ [string]$_.packageAssetPath -eq [string]$removed.packageAssetPath)
+ })
+ $metadata.componentCoverage.components = $remaining
+ $metadata.componentCoverage.runtimeComponentCount = $remaining.Count
+ $metadata.componentCoverage.looseRuntimeComponentCount = $looseComponents.Count - 1
+ [System.IO.File]::WriteAllText(
+ $metadataPath,
+ ($metadata | ConvertTo-Json -Depth 14) + [Environment]::NewLine,
+ [System.Text.UTF8Encoding]::new($false))
+ [System.IO.Compression.ZipFile]::CreateFromDirectory(
+ $unmappedDirectory,
+ $unmappedArchive)
+ Assert-PackageRejected $unmappedArchive (
+ "The package with unmapped runtime binary '$($removed.outputPath)'")
}
}
finally {
diff --git a/tools/test-release-package.ps1 b/tools/test-release-package.ps1
index 85075e3c..eacb12a2 100644
--- a/tools/test-release-package.ps1
+++ b/tools/test-release-package.ps1
@@ -19,7 +19,6 @@ if ($null -eq ('System.IO.Compression.ZipFile' -as [type])) {
$resolvedArchive = (Resolve-Path -LiteralPath $ArchivePath).Path
$resolvedManifest = (Resolve-Path -LiteralPath $ManifestPath).Path
$root = Split-Path -Parent (Split-Path -Parent $resolvedManifest)
-
$requiredFiles = @(
Get-Content -LiteralPath $resolvedManifest |
ForEach-Object { $_.Trim() } |
@@ -49,20 +48,6 @@ $failures = [System.Collections.Generic.List[string]]::new()
$entries = @{}
$textEntries = @{}
-function Get-NormalizedTextSha256([string]$Path) {
- $text = Get-Content -LiteralPath $Path -Raw
- $normalized = $text.Replace("`r`n", "`n").Replace("`r", "`n")
- $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($normalized)
- $sha256 = [System.Security.Cryptography.SHA256]::Create()
- try {
- return [System.BitConverter]::ToString(
- $sha256.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
- }
- finally {
- $sha256.Dispose()
- }
-}
-
function Read-ArchiveText(
[System.IO.Compression.ZipArchiveEntry]$Entry,
[string]$DisplayName) {
@@ -100,55 +85,13 @@ function Get-HeaderValue([string]$Text, [string]$Name) {
$reviewPath = Join-Path $root 'release\dotnet-redistribution-review.json'
try {
$review = Get-Content -LiteralPath $reviewPath -Raw | ConvertFrom-Json
- if ([int]$review.schemaVersion -ne 2) {
- $failures.Add("Unsupported .NET redistribution review schema '$($review.schemaVersion)'.")
- }
- if ([string]$review.reviewedAt -notmatch '^\d{4}-\d{2}-\d{2}$') {
- $failures.Add('The .NET redistribution review date must use YYYY-MM-DD.')
- }
-
- $reviewExpected = @{
- sdkVersion = $expectedMetadata.sdkVersion
- runtimeVersion = $expectedMetadata.runtimeVersion
- targetFramework = $targetFramework
- runtimeIdentifier = $expectedMetadata.runtimeIdentifier
- publishMode = $expectedMetadata.publishMode
- }
- foreach ($expected in $reviewExpected.GetEnumerator()) {
- $actual = [string]$review.reviewedConfiguration.($expected.Key)
- if ($actual -ne [string]$expected.Value) {
- $failures.Add("Redistribution review has $($expected.Key)='$actual'; expected '$($expected.Value)'.")
- }
- }
-
- $templatePath = Join-Path $root ([string]$review.noticeTemplate.path)
- if (-not [System.IO.File]::Exists($templatePath)) {
- $failures.Add("Reviewed redistribution template is missing: $($review.noticeTemplate.path)")
- }
- elseif ((Get-NormalizedTextSha256 $templatePath) -ne
- ([string]$review.noticeTemplate.sha256).ToLowerInvariant()) {
- $failures.Add('The redistribution notice template differs from the reviewed SHA-256.')
- }
-
$decisionStatus = [string]$review.maintainerDecision.status
- $decisionOwner = [string]$review.maintainerDecision.decisionOwner
- $decisionIssue = [int]$review.maintainerDecision.decisionIssue
if (@('accepted-for-current-distribution', 'accepted-with-conditions', 'blocked') -notcontains $decisionStatus) {
$failures.Add("Unsupported maintainer decision '$decisionStatus'.")
}
- if ([string]::IsNullOrWhiteSpace($decisionOwner)) {
- $failures.Add('The redistribution maintainer decision has no owner.')
- }
- if ($decisionIssue -le 0) {
- $failures.Add('The redistribution maintainer decision has no valid issue number.')
- }
if ($decisionStatus -eq 'blocked') {
$failures.Add('The redistribution maintainer decision blocks packaging.')
}
- if ($decisionStatus -eq 'accepted-with-conditions' -and
- @($review.maintainerDecision.conditions).Count -eq 0) {
- $failures.Add('A conditional redistribution decision has no conditions.')
- }
}
catch {
$failures.Add("The .NET redistribution review record is invalid: $($_.Exception.Message)")
@@ -208,18 +151,16 @@ try {
"Microsoft.NETCore.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)",
"Microsoft.WindowsDesktop.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)")) {
if (@($noticeMetadata.runtimePackages) -notcontains $runtimePack) {
- $failures.Add("DOTNET-NOTICE-METADATA.json does not map runtime pack '$runtimePack'.")
+ $failures.Add("DOTNET-NOTICE-METADATA.json does not identify runtime pack '$runtimePack'.")
}
}
- if ([string]::IsNullOrWhiteSpace([string]$noticeMetadata.source.packageUrl)) {
- $failures.Add('DOTNET-NOTICE-METADATA.json does not record the official source package URL.')
- }
- if ([string]$noticeMetadata.source.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$') {
- $failures.Add('DOTNET-NOTICE-METADATA.json does not contain a valid package SHA-512.')
+ if ([string]::IsNullOrWhiteSpace([string]$noticeMetadata.source.packageUrl) -or
+ [string]$noticeMetadata.source.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$') {
+ $failures.Add('DOTNET-NOTICE-METADATA.json lacks valid official package evidence.')
}
if ([string]$noticeMetadata.source.importedLicenseSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or
[string]$noticeMetadata.source.importedThirdPartyNoticesSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
- $failures.Add('DOTNET-NOTICE-METADATA.json does not contain valid imported-file SHA-256 values.')
+ $failures.Add('DOTNET-NOTICE-METADATA.json lacks imported notice hashes.')
}
}
catch {
@@ -230,10 +171,14 @@ try {
$noticeKey = 'third-party-notices.txt'
if ($textEntries.ContainsKey($noticeKey)) {
$noticeText = [string]$textEntries[$noticeKey]
- if (-not $noticeText.StartsWith('SIGHTADAPT EXACT-VERSION THIRD-PARTY NOTICES', [StringComparison]::Ordinal)) {
+ if (-not $noticeText.StartsWith(
+ 'SIGHTADAPT EXACT-VERSION THIRD-PARTY NOTICES',
+ [StringComparison]::Ordinal)) {
$failures.Add('THIRD-PARTY-NOTICES.txt is not an exact-version generated notice.')
}
- if (-not $noticeText.Contains(".NET runtime and Windows Desktop Runtime: $($expectedMetadata.runtimeVersion)", [StringComparison]::Ordinal)) {
+ if (-not $noticeText.Contains(
+ ".NET runtime and Windows Desktop Runtime: $($expectedMetadata.runtimeVersion)",
+ [StringComparison]::Ordinal)) {
$failures.Add('THIRD-PARTY-NOTICES.txt does not identify the pinned runtime version.')
}
}
diff --git a/tools/verify-dotnet-component-coverage.ps1 b/tools/verify-dotnet-component-coverage.ps1
new file mode 100644
index 00000000..ab5c984a
--- /dev/null
+++ b/tools/verify-dotnet-component-coverage.ps1
@@ -0,0 +1,250 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ArchivePath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($null -eq ('System.IO.Compression.ZipFile' -as [type])) {
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+}
+
+$resolvedArchive = (Resolve-Path -LiteralPath $ArchivePath).Path
+$failures = [System.Collections.Generic.List[string]]::new()
+$entries = @{}
+$archive = [System.IO.Compression.ZipFile]::OpenRead($resolvedArchive)
+
+function Read-EntryText([System.IO.Compression.ZipArchiveEntry]$Entry) {
+ $stream = $Entry.Open()
+ try {
+ $reader = [System.IO.StreamReader]::new(
+ $stream,
+ [System.Text.UTF8Encoding]::new($false, $true),
+ $true)
+ try {
+ return $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Dispose()
+ }
+ }
+ finally {
+ $stream.Dispose()
+ }
+}
+
+function Get-EntrySha256([System.IO.Compression.ZipArchiveEntry]$Entry) {
+ $stream = $Entry.Open()
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ return [System.BitConverter]::ToString(
+ $sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha256.Dispose()
+ $stream.Dispose()
+ }
+}
+
+try {
+ foreach ($entry in $archive.Entries) {
+ $path = $entry.FullName.Replace('\', '/').TrimStart('/')
+ if ([string]::IsNullOrWhiteSpace($path) -or
+ $path.EndsWith('/', [StringComparison]::Ordinal)) {
+ continue
+ }
+ $key = $path.ToLowerInvariant()
+ if ($entries.ContainsKey($key)) {
+ $failures.Add("Archive contains duplicate path '$path'.")
+ continue
+ }
+ $entries[$key] = $entry
+ }
+
+ foreach ($required in @(
+ 'dotnet-notice-metadata.json',
+ 'third-party-notices.txt',
+ 'dotnet-license-notice.txt',
+ 'sightadapt.exe')) {
+ if (-not $entries.ContainsKey($required)) {
+ $failures.Add("Component coverage validation is missing '$required'.")
+ }
+ }
+ if ($failures.Count -gt 0) {
+ throw 'Required component coverage inputs are unavailable.'
+ }
+
+ $metadataText = Read-EntryText $entries['dotnet-notice-metadata.json']
+ $noticeText = Read-EntryText $entries['third-party-notices.txt']
+ $metadata = $metadataText | ConvertFrom-Json
+ if ([int]$metadata.schemaVersion -ne 2) {
+ $failures.Add("Component coverage requires metadata schema 2, found '$($metadata.schemaVersion)'.")
+ }
+ if ($metadataText -match '(?i)[A-Z]:\\|/home/|/Users/|runneradmin|github\\workspace') {
+ $failures.Add('Component metadata exposes an absolute build-machine path.')
+ }
+
+ $coverage = $metadata.componentCoverage
+ $components = @($coverage.components)
+ $packages = @($coverage.packages)
+ $mappings = @($coverage.noticeMappings)
+ if ([string]::IsNullOrWhiteSpace([string]$coverage.method)) {
+ $failures.Add('Component coverage does not identify its inventory method.')
+ }
+ if ([string]$coverage.bundleManifestSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ $failures.Add('Component coverage lacks a valid FilesToBundle manifest SHA-256.')
+ }
+ if ([int]$coverage.unmappedExternalComponentCount -ne 0) {
+ $failures.Add("Component coverage reports $($coverage.unmappedExternalComponentCount) unmapped components.")
+ }
+ if ($components.Count -ne [int]$coverage.runtimeComponentCount) {
+ $failures.Add('Component count does not match the detailed inventory.')
+ }
+
+ $embedded = @($components | Where-Object {
+ [string]$_.disposition -eq 'embedded'
+ })
+ $loose = @($components | Where-Object {
+ [string]$_.disposition -eq 'loose'
+ })
+ if ($embedded.Count -ne [int]$coverage.embeddedRuntimeComponentCount -or
+ $loose.Count -ne [int]$coverage.looseRuntimeComponentCount) {
+ $failures.Add('Embedded/loose component totals do not match the inventory.')
+ }
+ if ($embedded.Count -le 0) {
+ $failures.Add('No embedded package components were recorded for SightAdapt.exe.')
+ }
+
+ $mappingById = @{}
+ foreach ($mapping in $mappings) {
+ $id = [string]$mapping.id
+ if ([string]::IsNullOrWhiteSpace($id) -or $mappingById.ContainsKey($id)) {
+ $failures.Add("Invalid or duplicate notice mapping '$id'.")
+ continue
+ }
+ $mappingById[$id] = $mapping
+ if ([string]$mapping.kind -eq 'official-dotnet-release-bundle') {
+ if ([string]$mapping.licenseFile -ne 'DOTNET-LICENSE-NOTICE.txt' -or
+ [string]$mapping.thirdPartyNoticesFile -ne 'THIRD-PARTY-NOTICES.txt' -or
+ [string]$mapping.licenseSha256 -ne
+ ([string]$metadata.source.importedLicenseSha256).ToLowerInvariant() -or
+ [string]$mapping.thirdPartyNoticesSha256 -ne
+ ([string]$metadata.source.importedThirdPartyNoticesSha256).ToLowerInvariant()) {
+ $failures.Add('The official .NET notice mapping does not match imported exact-release evidence.')
+ }
+ }
+ elseif ([string]$mapping.kind -eq 'exact-nuget-package-license') {
+ if ([string]$mapping.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$' -or
+ [string]::IsNullOrWhiteSpace([string]$mapping.policyLicense) -or
+ [string]::IsNullOrWhiteSpace([string]$mapping.nuspecLicenseType) -or
+ [string]::IsNullOrWhiteSpace([string]$mapping.nuspecLicenseValue) -or
+ [string]$mapping.noticeSectionSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ $failures.Add("Package notice mapping '$id' has incomplete exact-license evidence.")
+ }
+ $package = [string]$mapping.package
+ if (-not $noticeText.Contains("Package: $package", [StringComparison]::Ordinal) -or
+ -not $noticeText.Contains("Package SHA-512: $($mapping.packageSha512)", [StringComparison]::Ordinal) -or
+ -not $noticeText.Contains("Policy license: $($mapping.policyLicense)", [StringComparison]::Ordinal)) {
+ $failures.Add("THIRD-PARTY-NOTICES.txt lacks the exact package section for '$package'.")
+ }
+ }
+ else {
+ $failures.Add("Unsupported notice mapping kind '$($mapping.kind)'.")
+ }
+ }
+
+ $packageByIdentity = @{}
+ foreach ($package in $packages) {
+ $identity = [string]$package.package
+ if ([string]::IsNullOrWhiteSpace($identity) -or
+ $packageByIdentity.ContainsKey($identity)) {
+ $failures.Add("Invalid or duplicate package evidence '$identity'.")
+ continue
+ }
+ $packageByIdentity[$identity] = $package
+ if ([string]$package.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$' -or
+ [int]$package.publishedComponentCount -le 0 -or
+ [string]::IsNullOrWhiteSpace([string]$package.policyLicense)) {
+ $failures.Add("Published package evidence '$identity' is incomplete.")
+ }
+ }
+
+ $componentKeys = [System.Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::OrdinalIgnoreCase)
+ foreach ($component in $components) {
+ $disposition = [string]$component.disposition
+ $outputPath = ([string]$component.outputPath).Replace('\', '/').TrimStart('/')
+ $package = [string]$component.package
+ $assetPath = ([string]$component.packageAssetPath).Replace('\', '/')
+ $sha256 = [string]$component.sha256
+ $mappingId = [string]$component.noticeMapping
+ if (@('embedded', 'loose') -notcontains $disposition -or
+ [string]::IsNullOrWhiteSpace($outputPath) -or
+ [string]::IsNullOrWhiteSpace($package) -or
+ [string]::IsNullOrWhiteSpace($assetPath) -or
+ $sha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ $failures.Add("Invalid component mapping for '$outputPath'.")
+ continue
+ }
+ if (-not $packageByIdentity.ContainsKey($package)) {
+ $failures.Add("Component '$outputPath' references package '$package' without package evidence.")
+ }
+ if (-not $mappingById.ContainsKey($mappingId)) {
+ $failures.Add("Component '$outputPath' references missing notice mapping '$mappingId'.")
+ }
+ $key = "$disposition|$outputPath|$package|$assetPath"
+ if (-not $componentKeys.Add($key)) {
+ $failures.Add("Duplicate component mapping '$key'.")
+ }
+ if ($disposition -eq 'loose') {
+ $archiveKey = $outputPath.ToLowerInvariant()
+ if (-not $entries.ContainsKey($archiveKey)) {
+ $failures.Add("Mapped loose binary '$outputPath' is absent from the ZIP.")
+ }
+ elseif ((Get-EntrySha256 $entries[$archiveKey]) -ne $sha256.ToLowerInvariant()) {
+ $failures.Add("Mapped loose binary '$outputPath' has a SHA-256 mismatch.")
+ }
+ }
+ }
+
+ foreach ($requiredPack in @(
+ "Microsoft.NETCore.App.Runtime.$($metadata.runtimeIdentifier)/$($metadata.runtimeVersion)",
+ "Microsoft.WindowsDesktop.App.Runtime.$($metadata.runtimeIdentifier)/$($metadata.runtimeVersion)")) {
+ if (@($components | Where-Object {
+ [string]$_.package -eq $requiredPack
+ }).Count -eq 0) {
+ $failures.Add("Required runtime pack '$requiredPack' has no mapped published component.")
+ }
+ }
+
+ $mappedLoosePaths = @($loose | ForEach-Object {
+ ([string]$_.outputPath).Replace('\', '/').TrimStart('/').ToLowerInvariant()
+ })
+ foreach ($entryPair in $entries.GetEnumerator()) {
+ $path = [string]$entryPair.Key
+ $extension = [System.IO.Path]::GetExtension($path).ToLowerInvariant()
+ if ($extension -in @('.dll', '.exe', '.so', '.dylib') -and
+ $path -ne 'sightadapt.exe' -and
+ $mappedLoosePaths -notcontains $path) {
+ $failures.Add("Archive binary '$path' has no component notice mapping.")
+ }
+ }
+}
+catch {
+ if ($failures.Count -eq 0) {
+ $failures.Add("Component coverage validation failed: $($_.Exception.Message)")
+ }
+}
+finally {
+ $archive.Dispose()
+}
+
+if ($failures.Count -gt 0) {
+ $details = $failures | ForEach-Object { " - $_" }
+ throw "Exact .NET component coverage validation failed:`n$($details -join "`n")"
+}
+
+Write-Host "Exact .NET component coverage verified for $resolvedArchive."