From c318dd9cf158163b6f679b608158d25f6d85d803 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:04:31 +0200 Subject: [PATCH 01/19] Capture exact single-file bundle inputs Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SightAdapt.csproj | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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" /> + + + + + From 94c86e447af9248a726ec5dc46d4f82443c58857 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:06:56 +0200 Subject: [PATCH 02/19] Map exact bundled runtime components to notices Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/generate-dotnet-notices.ps1 | 366 +++++++++++++++++++++++++++++- 1 file changed, 359 insertions(+), 7 deletions(-) diff --git a/tools/generate-dotnet-notices.ps1 b/tools/generate-dotnet-notices.ps1 index 5219a8c8..894d627e 100644 --- a/tools/generate-dotnet-notices.ps1 +++ b/tools/generate-dotnet-notices.ps1 @@ -6,6 +6,8 @@ param( [string]$AssetsPath, + [string]$BundleManifestPath, + [string]$PropsPath ) @@ -19,9 +21,80 @@ if ([string]::IsNullOrWhiteSpace($PropsPath)) { 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' +} + +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-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 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-RuntimeAssetKind([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' -and $normalized -match '(^|/)lib/') { + return 'managed' + } + return 'runtime-content' +} + +function Get-BundleOutputPath( + [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) +} $resolvedProps = (Resolve-Path -LiteralPath $PropsPath).Path $resolvedAssets = (Resolve-Path -LiteralPath $AssetsPath).Path +$resolvedBundleManifest = (Resolve-Path -LiteralPath $BundleManifestPath).Path $resolvedPublish = [System.IO.Path]::GetFullPath($PublishDirectory) [System.IO.Directory]::CreateDirectory($resolvedPublish) | Out-Null @@ -123,6 +196,225 @@ if ($wrongVersionPacks.Count -gt 0) { throw "Runtime packs do not match pinned runtime ${runtimeVersion}:`n$($wrongVersionPacks -join "`n")" } +$packageFolders = @( + $assets.packageFolders.PSObject.Properties | + ForEach-Object { [string]$_.Name } +) +if ($packageFolders.Count -eq 0) { + throw 'The restored assets do not record NuGet package folders.' +} + +$runtimePackRecords = @( + foreach ($runtimePackage in ($runtimePackages | Sort-Object)) { + $parts = $runtimePackage -split '/', 2 + $packageId = $parts[0] + $packageVersion = $parts[1] + $packageRoot = $null + foreach ($packageFolder in $packageFolders) { + $candidate = Join-Path ( + Join-Path $packageFolder $packageId.ToLowerInvariant()) $packageVersion + if ([System.IO.Directory]::Exists($candidate)) { + $packageRoot = (Resolve-Path -LiteralPath $candidate).Path + break + } + } + if ([string]::IsNullOrWhiteSpace($packageRoot)) { + throw "Runtime pack '$runtimePackage' is not available in the restored NuGet package folders." + } + + $nupkg = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nupkg' | + Select-Object -First 1 + $shaFile = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nupkg.sha512' | + Select-Object -First 1 + if ($null -eq $nupkg -and $null -eq $shaFile) { + throw "Runtime pack '$runtimePackage' has no NuGet package or SHA-512 evidence." + } + + $actualPackageSha512 = if ($null -ne $nupkg) { + Get-FileSha512 $nupkg.FullName + } + else { + $null + } + $declaredPackageSha512 = if ($null -ne $shaFile) { + Convert-Base64Sha512ToHex ( + Get-Content -LiteralPath $shaFile.FullName -Raw) + } + else { + $actualPackageSha512 + } + if ($null -ne $actualPackageSha512 -and + $actualPackageSha512 -ne $declaredPackageSha512) { + throw "Runtime pack '$runtimePackage' NuGet package SHA-512 does not match its restore-cache evidence." + } + + $repositoryUrl = $null + $repositoryCommit = $null + $nuspec = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nuspec' | + Select-Object -First 1 + if ($null -ne $nuspec) { + [xml]$nuspecXml = Get-Content -LiteralPath $nuspec.FullName + $repositoryNode = $nuspecXml.SelectSingleNode( + "//*[local-name()='metadata']/*[local-name()='repository']") + if ($null -ne $repositoryNode) { + $repositoryUrl = [string]$repositoryNode.url + $repositoryCommit = [string]$repositoryNode.commit + } + } + + [pscustomobject]@{ + package = $runtimePackage + id = $packageId + version = $packageVersion + root = $packageRoot + packageSha512 = $declaredPackageSha512 + packageUrl = "https://api.nuget.org/v3-flatcontainer/$($packageId.ToLowerInvariant())/$packageVersion/$($packageId.ToLowerInvariant()).$packageVersion.nupkg" + repositoryUrl = $repositoryUrl + repositoryCommit = $repositoryCommit + } + } +) + +$bundleLines = @( + Get-Content -LiteralPath $resolvedBundleManifest | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +) +if ($bundleLines.Count -eq 0) { + throw 'The MSBuild FilesToBundle manifest is empty.' +} +$bundleManifestSha256 = Get-FileSha256 $resolvedBundleManifest +$components = [System.Collections.Generic.List[object]]::new() +$applicationBundleEntryCount = 0 +$unmappedExternalComponents = [System.Collections.Generic.List[string]]::new() + +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'." + } + + $matchedPacks = @( + $runtimePackRecords | Where-Object { + Test-PathUnderRoot $sourcePath $_.root + } + ) + if ($matchedPacks.Count -gt 1) { + throw "Bundled file '$sourcePath' maps to more than one runtime pack." + } + if ($matchedPacks.Count -eq 1) { + $pack = $matchedPacks[0] + $assetPath = Get-NormalizedRelativePath $pack.root $sourcePath + $components.Add([ordered]@{ + disposition = 'embedded' + outputPath = Get-BundleOutputPath ` + $sourcePath $relativePath $bundleRelativePath + package = $pack.package + packageAssetPath = $assetPath + assetKind = Get-RuntimeAssetKind $assetPath + sha256 = Get-FileSha256 $sourcePath + noticeMapping = 'exact-release-dotnet-bundle' + }) + continue + } + + $fromPackageCache = $false + foreach ($packageFolder in $packageFolders) { + if (Test-PathUnderRoot $sourcePath $packageFolder) { + $fromPackageCache = $true + break + } + } + if ($fromPackageCache) { + $unmappedExternalComponents.Add( + "Bundled package-cache file is not mapped to a reviewed runtime pack: $sourcePath") + } + else { + $applicationBundleEntryCount++ + } +} + +$assetCandidatesByName = @{} +foreach ($pack in $runtimePackRecords) { + $runtimeAssetsRoot = Join-Path $pack.root 'runtimes' + if (-not [System.IO.Directory]::Exists($runtimeAssetsRoot)) { + continue + } + foreach ($asset in Get-ChildItem -LiteralPath $runtimeAssetsRoot -File -Recurse) { + $key = $asset.Name.ToLowerInvariant() + if (-not $assetCandidatesByName.ContainsKey($key)) { + $assetCandidatesByName[$key] = [System.Collections.Generic.List[object]]::new() + } + $assetCandidatesByName[$key].Add([pscustomobject]@{ + pack = $pack + file = $asset + }) + } +} + +$binaryExtensions = @('.dll', '.so', '.dylib', '.exe') +$looseBinaryFiles = @( + Get-ChildItem -LiteralPath $resolvedPublish -File -Recurse | + Where-Object { + $binaryExtensions -contains $_.Extension.ToLowerInvariant() -and + $_.Name -ne 'SightAdapt.exe' + } +) +foreach ($looseFile in $looseBinaryFiles) { + $key = $looseFile.Name.ToLowerInvariant() + $looseSha256 = Get-FileSha256 $looseFile.FullName + $matches = @() + if ($assetCandidatesByName.ContainsKey($key)) { + $matches = @( + $assetCandidatesByName[$key] | Where-Object { + (Get-FileSha256 $_.file.FullName) -eq $looseSha256 + } + ) + } + if ($matches.Count -eq 0) { + $unmappedExternalComponents.Add( + "Loose binary '$($looseFile.Name)' does not match an asset in the reviewed runtime packs.") + continue + } + if ($matches.Count -gt 1) { + $identities = @($matches | ForEach-Object { + "$($_.pack.package):$(Get-NormalizedRelativePath $_.pack.root $_.file.FullName)" + }) + throw "Loose binary '$($looseFile.Name)' has ambiguous runtime-pack matches:`n$($identities -join "`n")" + } + + $match = $matches[0] + $assetPath = Get-NormalizedRelativePath $match.pack.root $match.file.FullName + $components.Add([ordered]@{ + disposition = 'loose' + outputPath = Get-NormalizedRelativePath $resolvedPublish $looseFile.FullName + package = $match.pack.package + packageAssetPath = $assetPath + assetKind = Get-RuntimeAssetKind $assetPath + sha256 = $looseSha256 + noticeMapping = 'exact-release-dotnet-bundle' + }) +} + +if ($unmappedExternalComponents.Count -gt 0) { + throw "Runtime component notice mapping failed:`n$($unmappedExternalComponents -join "`n")" +} + +$componentArray = @($components | Sort-Object disposition, outputPath, package) +if ($componentArray.Count -eq 0) { + throw 'No runtime components were mapped from FilesToBundle or the final publish directory.' +} +foreach ($requiredPack in $requiredRuntimePacks) { + if (-not @($componentArray | Where-Object { + [string]$_.package -eq $requiredPack + })) { + throw "Required runtime pack '$requiredPack' has no mapped published component." + } +} + $releaseMetadata = Invoke-RestMethod -Uri $metadataUrl -Method Get $release = @($releaseMetadata.releases | Where-Object { [string]$_.'release-version' -eq $runtimeVersion @@ -174,7 +466,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,27 +491,42 @@ 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 $generatedAt = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + $embeddedCount = @($componentArray | Where-Object { + [string]$_.disposition -eq 'embedded' + }).Count + $looseCount = @($componentArray | Where-Object { + [string]$_.disposition -eq 'loose' + }).Count + $thirdPartyHeader = @" SIGHTADAPT EXACT-VERSION THIRD-PARTY NOTICES -Generated from the official Microsoft .NET SDK distribution associated with the exact runtime packs used by this build. +Generated from the official Microsoft .NET SDK distribution associated with the exact runtime packs and component inventory used by this build. SightAdapt product version: $productVersion .NET SDK: $sdkVersion .NET runtime and Windows Desktop Runtime: $runtimeVersion Runtime identifier: $rid Publish mode: $publishMode +Mapped runtime components: $($componentArray.Count) ($embeddedCount embedded, $looseCount loose) Generated at (UTC): $generatedAt Release metadata: $metadataUrl Source package: $($legalSourcePackage.url) Source package SHA-512: $actualPackageHash Imported ThirdPartyNotices.txt SHA-256: $noticeSha256 +Component coverage evidence: DOTNET-NOTICE-METADATA.json The notice text below is imported without substantive modification from the exact official SDK archive. @@ -233,11 +543,13 @@ SightAdapt product version: $productVersion .NET runtime and Windows Desktop Runtime: $runtimeVersion Runtime identifier: $rid Publish mode: $publishMode +Mapped runtime components: $($componentArray.Count) ($embeddedCount embedded, $looseCount loose) Generated at (UTC): $generatedAt Release metadata: $metadataUrl Source package: $($legalSourcePackage.url) Source package SHA-512: $actualPackageHash Imported LICENSE.txt SHA-256: $licenseSha256 +Component coverage evidence: DOTNET-NOTICE-METADATA.json The license text below is imported without substantive modification from the exact official SDK archive. @@ -254,8 +566,24 @@ The license text below is imported without substantive modification from the exa $licenseHeader + $licenseText, [System.Text.UTF8Encoding]::new($false)) + $runtimePackEvidence = @( + foreach ($pack in $runtimePackRecords) { + $packComponents = @($componentArray | Where-Object { + [string]$_.package -eq [string]$pack.package + }) + [ordered]@{ + package = $pack.package + packageUrl = $pack.packageUrl + packageSha512 = $pack.packageSha512 + repositoryUrl = $pack.repositoryUrl + repositoryCommit = $pack.repositoryCommit + publishedComponentCount = $packComponents.Count + } + } + ) + $metadata = [ordered]@{ - schemaVersion = 1 + schemaVersion = 2 productVersion = $productVersion sdkVersion = $sdkVersion runtimeVersion = $runtimeVersion @@ -274,8 +602,27 @@ The license text below is imported without substantive modification from the exa importedLicenseSha256 = $licenseSha256 importedThirdPartyNoticesSha256 = $noticeSha256 } + componentCoverage = [ordered]@{ + method = 'MSBuild PrepareForBundle/FilesToBundle plus SHA-256 matching of loose runtime binaries to exact restored runtime packs' + bundleManifestSha256 = $bundleManifestSha256 + bundleEntryCount = $bundleLines.Count + applicationBundleEntryCount = $applicationBundleEntryCount + runtimeComponentCount = $componentArray.Count + embeddedRuntimeComponentCount = $embeddedCount + looseRuntimeComponentCount = $looseCount + unmappedExternalComponentCount = 0 + noticeMapping = [ordered]@{ + id = 'exact-release-dotnet-bundle' + licenseFile = 'DOTNET-LICENSE-NOTICE.txt' + licenseSha256 = $licenseSha256 + thirdPartyNoticesFile = 'THIRD-PARTY-NOTICES.txt' + thirdPartyNoticesSha256 = $noticeSha256 + } + runtimePacks = $runtimePackEvidence + components = $componentArray + } } - $metadataJson = $metadata | ConvertTo-Json -Depth 8 + $metadataJson = $metadata | ConvertTo-Json -Depth 12 [System.IO.File]::WriteAllText( (Join-Path $resolvedPublish 'DOTNET-NOTICE-METADATA.json'), $metadataJson + [Environment]::NewLine, @@ -285,4 +632,9 @@ finally { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } -Write-Host "Generated exact-version .NET notices for SDK $sdkVersion and runtime $runtimeVersion ($rid)." +Write-Host ( + "Generated exact-version .NET notices for SDK {0} and runtime {1} ({2}); mapped {3} runtime components." -f + $sdkVersion, + $runtimeVersion, + $rid, + $componentArray.Count) From fe64168301640ffd7f5290ae16c6ad550f258370 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:08:28 +0200 Subject: [PATCH 03/19] Validate exact runtime component notice coverage Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/test-release-package.ps1 | 136 ++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/tools/test-release-package.ps1 b/tools/test-release-package.ps1 index 85075e3c..305c07a3 100644 --- a/tools/test-release-package.ps1 +++ b/tools/test-release-package.ps1 @@ -88,6 +88,20 @@ function Read-ArchiveText( } } +function Get-ArchiveEntrySha256( + [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() + } +} + function Get-HeaderValue([string]$Text, [string]$Name) { $pattern = '(?m)^' + [regex]::Escape($Name) + ':\s*(?[^\r\n]+?)\s*$' $match = [regex]::Match($Text, $pattern) @@ -198,15 +212,20 @@ try { if ($textEntries.ContainsKey($metadataKey)) { try { $noticeMetadata = $textEntries[$metadataKey] | ConvertFrom-Json + if ([int]$noticeMetadata.schemaVersion -ne 2) { + $failures.Add("DOTNET-NOTICE-METADATA.json uses unsupported schema '$($noticeMetadata.schemaVersion)'.") + } foreach ($expected in $expectedMetadata.GetEnumerator()) { $actual = [string]$noticeMetadata.($expected.Key) if ($actual -ne [string]$expected.Value) { $failures.Add("DOTNET-NOTICE-METADATA.json has $($expected.Key)='$actual'; expected '$($expected.Value)'.") } } - foreach ($runtimePack in @( + + $requiredPacks = @( "Microsoft.NETCore.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)", - "Microsoft.WindowsDesktop.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)")) { + "Microsoft.WindowsDesktop.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)") + foreach ($runtimePack in $requiredPacks) { if (@($noticeMetadata.runtimePackages) -notcontains $runtimePack) { $failures.Add("DOTNET-NOTICE-METADATA.json does not map runtime pack '$runtimePack'.") } @@ -221,6 +240,114 @@ try { [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.') } + + $coverage = $noticeMetadata.componentCoverage + $components = @($coverage.components) + $runtimePacks = @($coverage.runtimePacks) + 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 does not contain a valid FilesToBundle manifest SHA-256.') + } + if ([int]$coverage.unmappedExternalComponentCount -ne 0) { + $failures.Add("Component coverage reports $($coverage.unmappedExternalComponentCount) unmapped external components.") + } + if ($components.Count -ne [int]$coverage.runtimeComponentCount) { + $failures.Add('Component coverage runtime-component count does not match the component list.') + } + $embeddedComponents = @($components | Where-Object { + [string]$_.disposition -eq 'embedded' + }) + $looseComponents = @($components | Where-Object { + [string]$_.disposition -eq 'loose' + }) + if ($embeddedComponents.Count -ne [int]$coverage.embeddedRuntimeComponentCount -or + $looseComponents.Count -ne [int]$coverage.looseRuntimeComponentCount) { + $failures.Add('Component coverage disposition counts do not match the component list.') + } + if ($components.Count -le 0 -or $embeddedComponents.Count -le 0) { + $failures.Add('Component coverage does not contain the embedded runtime inventory.') + } + + $mapping = $coverage.noticeMapping + if ([string]$mapping.id -ne 'exact-release-dotnet-bundle' -or + [string]$mapping.licenseFile -ne 'DOTNET-LICENSE-NOTICE.txt' -or + [string]$mapping.thirdPartyNoticesFile -ne 'THIRD-PARTY-NOTICES.txt') { + $failures.Add('Component coverage does not use the reviewed exact-release notice mapping.') + } + if ([string]$mapping.licenseSha256 -ne + ([string]$noticeMetadata.source.importedLicenseSha256).ToLowerInvariant() -or + [string]$mapping.thirdPartyNoticesSha256 -ne + ([string]$noticeMetadata.source.importedThirdPartyNoticesSha256).ToLowerInvariant()) { + $failures.Add('Component notice-mapping hashes do not match the imported official notice material.') + } + + $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 + if (@('embedded', 'loose') -notcontains $disposition -or + [string]::IsNullOrWhiteSpace($outputPath) -or + [string]::IsNullOrWhiteSpace($assetPath) -or + $sha256 -notmatch '^[0-9A-Fa-f]{64}$' -or + [string]$component.noticeMapping -ne 'exact-release-dotnet-bundle') { + $failures.Add("Invalid runtime component mapping for '$outputPath'.") + continue + } + if (@($noticeMetadata.runtimePackages) -notcontains $package) { + $failures.Add("Runtime component '$outputPath' references unknown package '$package'.") + } + $componentKey = "$disposition|$outputPath|$package|$assetPath" + if (-not $componentKeys.Add($componentKey)) { + $failures.Add("Duplicate runtime component mapping '$componentKey'.") + } + + if ($disposition -eq 'loose') { + $archiveKey = $outputPath.ToLowerInvariant() + if (-not $entries.ContainsKey($archiveKey)) { + $failures.Add("Mapped loose runtime component '$outputPath' is missing from the archive.") + } + else { + $archiveSha256 = Get-ArchiveEntrySha256 $entries[$archiveKey] + if ($archiveSha256 -ne $sha256.ToLowerInvariant()) { + $failures.Add("Mapped loose runtime component '$outputPath' has a SHA-256 mismatch.") + } + } + } + } + + foreach ($requiredPack in $requiredPacks) { + if (@($components | Where-Object { + [string]$_.package -eq $requiredPack + }).Count -le 0) { + $failures.Add("Required runtime pack '$requiredPack' has no mapped published component.") + } + $packEvidence = @($runtimePacks | Where-Object { + [string]$_.package -eq $requiredPack + }) | Select-Object -First 1 + if ($null -eq $packEvidence -or + [string]$packEvidence.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$') { + $failures.Add("Required runtime pack '$requiredPack' lacks exact NuGet SHA-512 evidence.") + } + } + + $mappedLoosePaths = @($looseComponents | 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', '.so', '.dylib', '.exe') -and + $path -ne 'sightadapt.exe' -and + $mappedLoosePaths -notcontains $path) { + $failures.Add("Archive binary '$path' has no runtime component notice mapping.") + } + } } catch { $failures.Add("DOTNET-NOTICE-METADATA.json is invalid: $($_.Exception.Message)") @@ -236,6 +363,9 @@ try { 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.') } + if (-not $noticeText.Contains('Component coverage evidence: DOTNET-NOTICE-METADATA.json', [StringComparison]::Ordinal)) { + $failures.Add('THIRD-PARTY-NOTICES.txt does not identify the component coverage evidence.') + } } $redistributionKey = 'microsoft-dotnet-redistribution.txt' @@ -278,6 +408,6 @@ if ($failures.Count -gt 0) { } Write-Host ( - "Release package verified: {0} required files are present, readable and consistent with the pinned .NET release in {1}." -f + "Release package verified: {0} required files and exact runtime component notice mappings are consistent with the pinned .NET release in {1}." -f $requiredFiles.Count, $resolvedArchive) From f53cebe82c24f566986fac61b80cf9cfd6021cb2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:09:07 +0200 Subject: [PATCH 04/19] Reject packages with unmapped runtime binaries Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/test-release-compliance-negative.ps1 | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tools/test-release-compliance-negative.ps1 b/tools/test-release-compliance-negative.ps1 index 685f3848..b44f0bd5 100644 --- a/tools/test-release-compliance-negative.ps1 +++ b/tools/test-release-compliance-negative.ps1 @@ -47,6 +47,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 @@ -79,6 +80,47 @@ try { $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' + if (-not [System.IO.File]::Exists($metadataPath)) { + throw 'The .NET notice metadata is unavailable for the unmapped-component test.' + } + $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 12) + [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 { From d366e2777cd96d3abb8a30b2e5d8c414c66f1d19 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:10:25 +0200 Subject: [PATCH 05/19] Document exact runtime component notice mapping Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/legal/DOTNET-NOTICE-GENERATION.md | 112 ++++++++++++------------- 1 file changed, 53 insertions(+), 59 deletions(-) diff --git a/docs/legal/DOTNET-NOTICE-GENERATION.md b/docs/legal/DOTNET-NOTICE-GENERATION.md index 1b1032b9..4e06b761 100644 --- a/docs/legal/DOTNET-NOTICE-GENERATION.md +++ b/docs/legal/DOTNET-NOTICE-GENERATION.md @@ -1,14 +1,16 @@ # 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 license and third-party 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/dotnet-redistribution-review.json` records the maintainer-reviewed configuration and redistribution wording. -Current inputs: +Current reviewed inputs: | Input | Value | |---|---| @@ -18,78 +20,70 @@ Current inputs: | RID | `win-x64` | | Publish mode | self-contained single-file | -## Evidence sources +## Publish-time component inventory -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`. +`SightAdapt.csproj` runs `CaptureSightAdaptFilesToBundle` between `PrepareForBundle` and `GenerateSingleFileBundle`. It writes `artifacts/dotnet-files-to-bundle.tsv` from the SDK-provided `FilesToBundle` item list. -The restore graph identifies selected packs; the SDK archive supplies the authoritative legal text for the same release train. +The temporary manifest is build evidence only. Absolute build-machine paths are not copied into the release archive. -## Exact-version generator +After publish, `tools/generate-dotnet-notices.ps1`: -Run after restore and publish: +1. resolves each exact runtime pack in the restored NuGet cache; +2. verifies its NuGet SHA-512 evidence and records repository metadata when present; +3. maps every `FilesToBundle` entry originating from a runtime pack; +4. records the bundle-relative output path, package asset path, component kind and SHA-256; +5. scans loose binary files in the final publish directory; +6. matches each loose runtime binary by filename and SHA-256 to an exact runtime-pack asset; +7. fails when a bundled package-cache file or loose binary cannot be mapped to a reviewed runtime pack. -```powershell -.\tools\generate-dotnet-notices.ps1 ` - -PublishDirectory .\artifacts\win-x64 -``` +This covers both components embedded in `SightAdapt.exe` and separately shipped native libraries. -The generator: +## Official notice material -- 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`. +The generator confirms the SDK/runtime release mapping through Microsoft's release metadata, downloads the matching official SDK ZIP, verifies its published SHA-512 and imports: -## Maintainer-reviewed redistribution notice +- `LICENSE.txt` into `DOTNET-LICENSE-NOTICE.txt`; +- `ThirdPartyNotices.txt` into `THIRD-PARTY-NOTICES.txt`. -Then run: +The imported texts are not substantively modified. SightAdapt adds an exact-build header and points recipients to `DOTNET-NOTICE-METADATA.json` for component-level coverage evidence. -```powershell -.\tools\generate-dotnet-redistribution-notice.ps1 ` - -PublishDirectory .\artifacts\win-x64 -``` +## Metadata schema -It renders `release/MICROSOFT-DOTNET-REDISTRIBUTION.template.txt` using canonical release metadata. Before writing the package notice, it verifies: +`DOTNET-NOTICE-METADATA.json` schema 2 records: -- 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. +- product, SDK, runtime, RID and publish mode; +- exact runtime-pack identities and NuGet SHA-512 values; +- official release/package URLs and imported notice hashes; +- SHA-256 of the `FilesToBundle` manifest; +- embedded and loose runtime-component counts; +- one entry per mapped runtime component containing disposition, output path, runtime pack, package asset path, kind and SHA-256; +- `unmappedExternalComponentCount`, which must be zero. -A `blocked` maintainer decision fails packaging. No external legal audit is required or planned. +All runtime components use the mapping identifier `exact-release-dotnet-bundle`, tied to the imported exact-release license and third-party notice files. -## Archive validation +## Final-package validation -`tools/test-release-package.ps1` verifies: +`tools/test-release-package.ps1` independently verifies that: -- 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. +- metadata uses schema 2 and matches canonical release values; +- both required runtime packs have published components and NuGet SHA-512 evidence; +- component totals match the detailed inventory; +- every loose binary in the ZIP has a component mapping and matching SHA-256; +- embedded runtime components are represented by the captured `FilesToBundle` evidence; +- no external component is reported as unmapped; +- notice-mapping hashes equal the imported official-text hashes. -CI runs the validator before artifact upload and retains `project.assets.json` in diagnostics. +The negative test removes one real loose runtime mapping while leaving the binary in the package. CI must reject that archive, in addition to rejecting incomplete and stale-notice packages. -## Updating .NET +## Maintenance -A .NET update must: +A .NET or publish change must update the pinned inputs and maintainer review, then regenerate all evidence. Repeat the review when any of these change: -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. +- SDK or runtime version; +- target framework, RID or architecture; +- self-contained, single-file, trimming, ReadyToRun, Native AOT or extraction settings; +- restored runtime packs; +- bundled or loose runtime-component inventory; +- Microsoft release metadata, license or notice source. -## Review triggers - -Repeat the maintainer review when SDK/runtime, TFM, RID, publish settings, runtime packs, Microsoft licensing sources or redistribution wording 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 package hashes, runtime-pack mapping, `FilesToBundle` capture, loose-binary matching, official notice import or final ZIP validation fails. From 9df261c1890c3f06743b8e2bfb2143e1323c89f0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:11:05 +0200 Subject: [PATCH 06/19] Document bundle component evidence generation Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/BUILD.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/BUILD.md b/docs/BUILD.md index c77d0eca..5bf13bf5 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -41,7 +41,7 @@ dotnet test .\tests\SightAdapt.Tests\SightAdapt.Tests.csproj ` --no-restore ``` -The application restore graph is the authority for runtime-pack inventory. +The application restore graph is the authority for exact runtime-pack identities. ## 3. Publish @@ -52,7 +52,7 @@ 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 static legal baseline is copied to the publish directory. During single-file publication, the project captures the SDK-provided `FilesToBundle` list into `artifacts\dotnet-files-to-bundle.tsv`. This temporary file identifies the exact inputs embedded in `SightAdapt.exe`; it is converted into sanitized component evidence and is not copied into the release ZIP. ## 4. Generate exact-version Microsoft notices @@ -61,11 +61,17 @@ The static legal baseline is copied to the publish directory. The Microsoft redi -PublishDirectory .\artifacts\win-x64 ``` -This verifies the restored runtime packs, downloads the matching official SDK ZIP, verifies SHA-512 and writes: +This step: -- `THIRD-PARTY-NOTICES.txt`; -- `DOTNET-LICENSE-NOTICE.txt`; -- `DOTNET-NOTICE-METADATA.json`. +- validates the exact restored runtime packs and their NuGet SHA-512 evidence; +- maps every runtime-pack file embedded through `FilesToBundle`; +- maps every loose runtime binary by filename and SHA-256 to an exact runtime-pack asset; +- rejects any package-cache or loose binary component without a reviewed mapping; +- downloads the matching official SDK ZIP and verifies SHA-512; +- imports the exact Microsoft license and third-party notice material; +- writes `THIRD-PARTY-NOTICES.txt`, `DOTNET-LICENSE-NOTICE.txt` and schema-2 `DOTNET-NOTICE-METADATA.json`. + +The metadata contains one sanitized entry per runtime component, including embedded/loose disposition, package, package-relative asset path, kind and SHA-256. ## 5. Generate the maintainer-reviewed redistribution notice @@ -105,6 +111,8 @@ LICENSE-REPORT.json PRIVACY.md ``` +Additional loose native runtime DLLs may be present. Every such binary must have a matching component entry in `DOTNET-NOTICE-METADATA.json`. + ## 8. Run negative package checks ```powershell @@ -112,7 +120,11 @@ PRIVACY.md -PublishDirectory .\artifacts\win-x64 ``` -The validator must reject an incomplete package and a package with stale redistribution metadata. +The validator must reject: + +- an incomplete package; +- a package with stale redistribution metadata; +- a package containing a runtime binary whose component mapping was removed. ## 9. Create and verify the final archive @@ -131,7 +143,7 @@ Compress-Archive ` -ReportPath $report ``` -Retain the compliance report with the archive. +Final validation opens the ZIP, verifies every loose binary hash against the component map, checks embedded-component inventory totals, validates exact notice sources and confirms all other package invariants. Retain the compliance report with the archive. ## 10. Run and inspect the version @@ -147,8 +159,9 @@ $process = Get-Process SightAdapt ```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. +A release must not be published if metadata review, bundle capture, runtime-component mapping, notice generation, the maintainer decision, negative checks or final-package validation fail. From b873abba9843787770a206ae6cd702630d0dce0b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:11:50 +0200 Subject: [PATCH 07/19] Require exact component notice coverage in packages Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/PACKAGING.md | 54 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index d1e68c05..93987e52 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -10,11 +10,11 @@ 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 | +| `DOTNET-NOTICE-METADATA.json` | Exact runtime-pack, component, source and checksum evidence | | `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed Microsoft redistribution notice | | `THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt` | Mark ownership, no-endorsement and DRM/access-control boundaries | | `DEPENDENCIES.md` | Human-readable dependency inventory | @@ -22,7 +22,7 @@ Every package must place these files at the package root: | `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, empty, unreadable, stale, inconsistent with pinned inputs or contains a binary component without notice coverage evidence. ## Distribution formats @@ -33,9 +33,10 @@ Mirrors must publish the same verified bytes and compliance report without strip ## Publish behavior 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`. +2. During single-file publication, `CaptureSightAdaptFilesToBundle` records the exact SDK `FilesToBundle` inventory in a temporary build manifest. +3. `generate-dotnet-notices.ps1` maps every embedded runtime-pack input and every separately shipped runtime binary, verifies package hashes and imports exact Microsoft license/notice material. +4. `generate-dotnet-redistribution-notice.ps1` validates the reviewed configuration, template checksum and maintainer decision, then generates the package notice. +5. `generate-sbom.ps1` creates `DEPENDENCIES.md`, `SBOM.spdx.json` and `LICENSE-REPORT.json`. Expected package root: @@ -53,6 +54,21 @@ LICENSE-REPORT.json PRIVACY.md ``` +Exact loose native runtime files may also be present. They are permitted only when `DOTNET-NOTICE-METADATA.json` maps each file by output path and SHA-256 to an exact runtime-pack asset. + +## Runtime component coverage + +Schema 2 of `DOTNET-NOTICE-METADATA.json` is the component-level source of truth. It records: + +- SHA-256 of the temporary MSBuild `FilesToBundle` manifest; +- exact NuGet SHA-512 evidence for restored runtime packs; +- one sanitized entry per embedded or loose runtime component; +- component output path, package identity, package-relative asset path, kind and SHA-256; +- the imported exact-release license and third-party-notice hashes; +- an unmapped-component count that must be zero. + +Absolute paths from the build machine are not retained in the release artifact. + ## Final package compliance gate Create the archive from the publish directory and validate both representations: @@ -72,7 +88,7 @@ Compress-Archive ` -ReportPath $report ``` -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 gate validates the manifest, staged files, final ZIP, release identity, exact .NET metadata, component coverage, maintainer redistribution decision, license-policy result and SBOM coverage. It writes the final archive SHA-256 and decision evidence to the compliance report. The negative check must also pass: @@ -81,15 +97,19 @@ The negative check must also pass: -PublishDirectory '.\artifacts\win-x64' ``` -It proves that incomplete packages and stale redistribution notices are rejected. +It proves rejection of: + +- an incomplete package; +- a stale redistribution notice; +- a package that still contains a runtime binary after its notice mapping is removed. ## 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. +`global.json` pins the SDK, `Directory.Build.props` records expected runtime/RID/publish inputs, `project.assets.json` identifies exact runtime packs and MSBuild `FilesToBundle` identifies the exact embedded single-file inputs. -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. +The exact-version generator downloads the matching official SDK ZIP, verifies SHA-512 and imports its license and third-party notice text. Runtime-pack NuGet hashes and component hashes connect that text to the actual published inventory. -`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. +`release/dotnet-redistribution-review.json` records the 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). @@ -97,7 +117,7 @@ See [Microsoft .NET redistribution analysis](legal/DOTNET-REDISTRIBUTION.md) and 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. +The dependency policy, restore graph, workflows, exact .NET component metadata and final publish files feed the SPDX SBOM and license report. `SightAdapt.exe` is the documented container for embedded runtime components. ## Installers, stores and mirrors @@ -109,15 +129,15 @@ Every maintained packaging workflow must validate its final installed/unpacked f 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; +5. publish and capture `FilesToBundle`; +6. generate exact-version .NET notices and component coverage; 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; +9. resolve license-policy or component-mapping failures; +10. confirm incomplete, stale and unmapped-component 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. +Do not publish when any package, notice, component map, 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. From 33dccacf228b1e98749b0626b55525d7e8dc99df Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:16:38 +0200 Subject: [PATCH 08/19] Classify embedded Windows SDK assemblies as shipped Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- release/dependency-policy.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 818a6235cfe1ea1d47e2f0c1586cb073f0a4bfb4 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:18:10 +0200 Subject: [PATCH 09/19] Keep exact notice import focused on official texts Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/generate-dotnet-notices.ps1 | 351 +----------------------------- 1 file changed, 4 insertions(+), 347 deletions(-) diff --git a/tools/generate-dotnet-notices.ps1 b/tools/generate-dotnet-notices.ps1 index 894d627e..b6753375 100644 --- a/tools/generate-dotnet-notices.ps1 +++ b/tools/generate-dotnet-notices.ps1 @@ -6,8 +6,6 @@ param( [string]$AssetsPath, - [string]$BundleManifestPath, - [string]$PropsPath ) @@ -21,80 +19,9 @@ if ([string]::IsNullOrWhiteSpace($PropsPath)) { 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' -} - -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-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 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-RuntimeAssetKind([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' -and $normalized -match '(^|/)lib/') { - return 'managed' - } - return 'runtime-content' -} - -function Get-BundleOutputPath( - [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) -} $resolvedProps = (Resolve-Path -LiteralPath $PropsPath).Path $resolvedAssets = (Resolve-Path -LiteralPath $AssetsPath).Path -$resolvedBundleManifest = (Resolve-Path -LiteralPath $BundleManifestPath).Path $resolvedPublish = [System.IO.Path]::GetFullPath($PublishDirectory) [System.IO.Directory]::CreateDirectory($resolvedPublish) | Out-Null @@ -196,225 +123,6 @@ if ($wrongVersionPacks.Count -gt 0) { throw "Runtime packs do not match pinned runtime ${runtimeVersion}:`n$($wrongVersionPacks -join "`n")" } -$packageFolders = @( - $assets.packageFolders.PSObject.Properties | - ForEach-Object { [string]$_.Name } -) -if ($packageFolders.Count -eq 0) { - throw 'The restored assets do not record NuGet package folders.' -} - -$runtimePackRecords = @( - foreach ($runtimePackage in ($runtimePackages | Sort-Object)) { - $parts = $runtimePackage -split '/', 2 - $packageId = $parts[0] - $packageVersion = $parts[1] - $packageRoot = $null - foreach ($packageFolder in $packageFolders) { - $candidate = Join-Path ( - Join-Path $packageFolder $packageId.ToLowerInvariant()) $packageVersion - if ([System.IO.Directory]::Exists($candidate)) { - $packageRoot = (Resolve-Path -LiteralPath $candidate).Path - break - } - } - if ([string]::IsNullOrWhiteSpace($packageRoot)) { - throw "Runtime pack '$runtimePackage' is not available in the restored NuGet package folders." - } - - $nupkg = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nupkg' | - Select-Object -First 1 - $shaFile = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nupkg.sha512' | - Select-Object -First 1 - if ($null -eq $nupkg -and $null -eq $shaFile) { - throw "Runtime pack '$runtimePackage' has no NuGet package or SHA-512 evidence." - } - - $actualPackageSha512 = if ($null -ne $nupkg) { - Get-FileSha512 $nupkg.FullName - } - else { - $null - } - $declaredPackageSha512 = if ($null -ne $shaFile) { - Convert-Base64Sha512ToHex ( - Get-Content -LiteralPath $shaFile.FullName -Raw) - } - else { - $actualPackageSha512 - } - if ($null -ne $actualPackageSha512 -and - $actualPackageSha512 -ne $declaredPackageSha512) { - throw "Runtime pack '$runtimePackage' NuGet package SHA-512 does not match its restore-cache evidence." - } - - $repositoryUrl = $null - $repositoryCommit = $null - $nuspec = Get-ChildItem -LiteralPath $packageRoot -File -Filter '*.nuspec' | - Select-Object -First 1 - if ($null -ne $nuspec) { - [xml]$nuspecXml = Get-Content -LiteralPath $nuspec.FullName - $repositoryNode = $nuspecXml.SelectSingleNode( - "//*[local-name()='metadata']/*[local-name()='repository']") - if ($null -ne $repositoryNode) { - $repositoryUrl = [string]$repositoryNode.url - $repositoryCommit = [string]$repositoryNode.commit - } - } - - [pscustomobject]@{ - package = $runtimePackage - id = $packageId - version = $packageVersion - root = $packageRoot - packageSha512 = $declaredPackageSha512 - packageUrl = "https://api.nuget.org/v3-flatcontainer/$($packageId.ToLowerInvariant())/$packageVersion/$($packageId.ToLowerInvariant()).$packageVersion.nupkg" - repositoryUrl = $repositoryUrl - repositoryCommit = $repositoryCommit - } - } -) - -$bundleLines = @( - Get-Content -LiteralPath $resolvedBundleManifest | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } -) -if ($bundleLines.Count -eq 0) { - throw 'The MSBuild FilesToBundle manifest is empty.' -} -$bundleManifestSha256 = Get-FileSha256 $resolvedBundleManifest -$components = [System.Collections.Generic.List[object]]::new() -$applicationBundleEntryCount = 0 -$unmappedExternalComponents = [System.Collections.Generic.List[string]]::new() - -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'." - } - - $matchedPacks = @( - $runtimePackRecords | Where-Object { - Test-PathUnderRoot $sourcePath $_.root - } - ) - if ($matchedPacks.Count -gt 1) { - throw "Bundled file '$sourcePath' maps to more than one runtime pack." - } - if ($matchedPacks.Count -eq 1) { - $pack = $matchedPacks[0] - $assetPath = Get-NormalizedRelativePath $pack.root $sourcePath - $components.Add([ordered]@{ - disposition = 'embedded' - outputPath = Get-BundleOutputPath ` - $sourcePath $relativePath $bundleRelativePath - package = $pack.package - packageAssetPath = $assetPath - assetKind = Get-RuntimeAssetKind $assetPath - sha256 = Get-FileSha256 $sourcePath - noticeMapping = 'exact-release-dotnet-bundle' - }) - continue - } - - $fromPackageCache = $false - foreach ($packageFolder in $packageFolders) { - if (Test-PathUnderRoot $sourcePath $packageFolder) { - $fromPackageCache = $true - break - } - } - if ($fromPackageCache) { - $unmappedExternalComponents.Add( - "Bundled package-cache file is not mapped to a reviewed runtime pack: $sourcePath") - } - else { - $applicationBundleEntryCount++ - } -} - -$assetCandidatesByName = @{} -foreach ($pack in $runtimePackRecords) { - $runtimeAssetsRoot = Join-Path $pack.root 'runtimes' - if (-not [System.IO.Directory]::Exists($runtimeAssetsRoot)) { - continue - } - foreach ($asset in Get-ChildItem -LiteralPath $runtimeAssetsRoot -File -Recurse) { - $key = $asset.Name.ToLowerInvariant() - if (-not $assetCandidatesByName.ContainsKey($key)) { - $assetCandidatesByName[$key] = [System.Collections.Generic.List[object]]::new() - } - $assetCandidatesByName[$key].Add([pscustomobject]@{ - pack = $pack - file = $asset - }) - } -} - -$binaryExtensions = @('.dll', '.so', '.dylib', '.exe') -$looseBinaryFiles = @( - Get-ChildItem -LiteralPath $resolvedPublish -File -Recurse | - Where-Object { - $binaryExtensions -contains $_.Extension.ToLowerInvariant() -and - $_.Name -ne 'SightAdapt.exe' - } -) -foreach ($looseFile in $looseBinaryFiles) { - $key = $looseFile.Name.ToLowerInvariant() - $looseSha256 = Get-FileSha256 $looseFile.FullName - $matches = @() - if ($assetCandidatesByName.ContainsKey($key)) { - $matches = @( - $assetCandidatesByName[$key] | Where-Object { - (Get-FileSha256 $_.file.FullName) -eq $looseSha256 - } - ) - } - if ($matches.Count -eq 0) { - $unmappedExternalComponents.Add( - "Loose binary '$($looseFile.Name)' does not match an asset in the reviewed runtime packs.") - continue - } - if ($matches.Count -gt 1) { - $identities = @($matches | ForEach-Object { - "$($_.pack.package):$(Get-NormalizedRelativePath $_.pack.root $_.file.FullName)" - }) - throw "Loose binary '$($looseFile.Name)' has ambiguous runtime-pack matches:`n$($identities -join "`n")" - } - - $match = $matches[0] - $assetPath = Get-NormalizedRelativePath $match.pack.root $match.file.FullName - $components.Add([ordered]@{ - disposition = 'loose' - outputPath = Get-NormalizedRelativePath $resolvedPublish $looseFile.FullName - package = $match.pack.package - packageAssetPath = $assetPath - assetKind = Get-RuntimeAssetKind $assetPath - sha256 = $looseSha256 - noticeMapping = 'exact-release-dotnet-bundle' - }) -} - -if ($unmappedExternalComponents.Count -gt 0) { - throw "Runtime component notice mapping failed:`n$($unmappedExternalComponents -join "`n")" -} - -$componentArray = @($components | Sort-Object disposition, outputPath, package) -if ($componentArray.Count -eq 0) { - throw 'No runtime components were mapped from FilesToBundle or the final publish directory.' -} -foreach ($requiredPack in $requiredRuntimePacks) { - if (-not @($componentArray | Where-Object { - [string]$_.package -eq $requiredPack - })) { - throw "Required runtime pack '$requiredPack' has no mapped published component." - } -} - $releaseMetadata = Invoke-RestMethod -Uri $metadataUrl -Method Get $release = @($releaseMetadata.releases | Where-Object { [string]$_.'release-version' -eq $runtimeVersion @@ -503,30 +211,21 @@ try { $noticeSha256 = (Get-FileHash -LiteralPath $noticeSourcePath -Algorithm SHA256).Hash $generatedAt = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') - $embeddedCount = @($componentArray | Where-Object { - [string]$_.disposition -eq 'embedded' - }).Count - $looseCount = @($componentArray | Where-Object { - [string]$_.disposition -eq 'loose' - }).Count - $thirdPartyHeader = @" SIGHTADAPT EXACT-VERSION THIRD-PARTY NOTICES -Generated from the official Microsoft .NET SDK distribution associated with the exact runtime packs and component inventory used by this build. +Generated from the official Microsoft .NET SDK distribution associated with the exact runtime packs used by this build. SightAdapt product version: $productVersion .NET SDK: $sdkVersion .NET runtime and Windows Desktop Runtime: $runtimeVersion Runtime identifier: $rid Publish mode: $publishMode -Mapped runtime components: $($componentArray.Count) ($embeddedCount embedded, $looseCount loose) Generated at (UTC): $generatedAt Release metadata: $metadataUrl Source package: $($legalSourcePackage.url) Source package SHA-512: $actualPackageHash Imported ThirdPartyNotices.txt SHA-256: $noticeSha256 -Component coverage evidence: DOTNET-NOTICE-METADATA.json The notice text below is imported without substantive modification from the exact official SDK archive. @@ -543,13 +242,11 @@ SightAdapt product version: $productVersion .NET runtime and Windows Desktop Runtime: $runtimeVersion Runtime identifier: $rid Publish mode: $publishMode -Mapped runtime components: $($componentArray.Count) ($embeddedCount embedded, $looseCount loose) Generated at (UTC): $generatedAt Release metadata: $metadataUrl Source package: $($legalSourcePackage.url) Source package SHA-512: $actualPackageHash Imported LICENSE.txt SHA-256: $licenseSha256 -Component coverage evidence: DOTNET-NOTICE-METADATA.json The license text below is imported without substantive modification from the exact official SDK archive. @@ -566,24 +263,8 @@ The license text below is imported without substantive modification from the exa $licenseHeader + $licenseText, [System.Text.UTF8Encoding]::new($false)) - $runtimePackEvidence = @( - foreach ($pack in $runtimePackRecords) { - $packComponents = @($componentArray | Where-Object { - [string]$_.package -eq [string]$pack.package - }) - [ordered]@{ - package = $pack.package - packageUrl = $pack.packageUrl - packageSha512 = $pack.packageSha512 - repositoryUrl = $pack.repositoryUrl - repositoryCommit = $pack.repositoryCommit - publishedComponentCount = $packComponents.Count - } - } - ) - $metadata = [ordered]@{ - schemaVersion = 2 + schemaVersion = 1 productVersion = $productVersion sdkVersion = $sdkVersion runtimeVersion = $runtimeVersion @@ -602,27 +283,8 @@ The license text below is imported without substantive modification from the exa importedLicenseSha256 = $licenseSha256 importedThirdPartyNoticesSha256 = $noticeSha256 } - componentCoverage = [ordered]@{ - method = 'MSBuild PrepareForBundle/FilesToBundle plus SHA-256 matching of loose runtime binaries to exact restored runtime packs' - bundleManifestSha256 = $bundleManifestSha256 - bundleEntryCount = $bundleLines.Count - applicationBundleEntryCount = $applicationBundleEntryCount - runtimeComponentCount = $componentArray.Count - embeddedRuntimeComponentCount = $embeddedCount - looseRuntimeComponentCount = $looseCount - unmappedExternalComponentCount = 0 - noticeMapping = [ordered]@{ - id = 'exact-release-dotnet-bundle' - licenseFile = 'DOTNET-LICENSE-NOTICE.txt' - licenseSha256 = $licenseSha256 - thirdPartyNoticesFile = 'THIRD-PARTY-NOTICES.txt' - thirdPartyNoticesSha256 = $noticeSha256 - } - runtimePacks = $runtimePackEvidence - components = $componentArray - } } - $metadataJson = $metadata | ConvertTo-Json -Depth 12 + $metadataJson = $metadata | ConvertTo-Json -Depth 8 [System.IO.File]::WriteAllText( (Join-Path $resolvedPublish 'DOTNET-NOTICE-METADATA.json'), $metadataJson + [Environment]::NewLine, @@ -632,9 +294,4 @@ finally { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } -Write-Host ( - "Generated exact-version .NET notices for SDK {0} and runtime {1} ({2}); mapped {3} runtime components." -f - $sdkVersion, - $runtimeVersion, - $rid, - $componentArray.Count) +Write-Host "Generated exact-version .NET notices for SDK $sdkVersion and runtime $runtimeVersion ($rid)." From c99bb0cc94cf2a8de8944f89630fc7792e62dcb7 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:19:57 +0200 Subject: [PATCH 10/19] Generate exact component notice coverage Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/generate-dotnet-component-coverage.ps1 | 572 +++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 tools/generate-dotnet-component-coverage.ps1 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) From b8f1dea5e246de42c489dfc56e54d8c5fa296f01 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:20:36 +0200 Subject: [PATCH 11/19] Generate exact component notice coverage in CI Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 798b339a..332b5d09 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 From a84da798f374614becdc31eb163920b7aedd8893 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:22:19 +0200 Subject: [PATCH 12/19] Separate package and component coverage validation Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/test-release-package.ps1 | 213 +++------------------------------ 1 file changed, 14 insertions(+), 199 deletions(-) diff --git a/tools/test-release-package.ps1 b/tools/test-release-package.ps1 index 305c07a3..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) { @@ -88,20 +73,6 @@ function Read-ArchiveText( } } -function Get-ArchiveEntrySha256( - [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() - } -} - function Get-HeaderValue([string]$Text, [string]$Name) { $pattern = '(?m)^' + [regex]::Escape($Name) + ':\s*(?[^\r\n]+?)\s*$' $match = [regex]::Match($Text, $pattern) @@ -114,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)") @@ -212,141 +141,26 @@ try { if ($textEntries.ContainsKey($metadataKey)) { try { $noticeMetadata = $textEntries[$metadataKey] | ConvertFrom-Json - if ([int]$noticeMetadata.schemaVersion -ne 2) { - $failures.Add("DOTNET-NOTICE-METADATA.json uses unsupported schema '$($noticeMetadata.schemaVersion)'.") - } foreach ($expected in $expectedMetadata.GetEnumerator()) { $actual = [string]$noticeMetadata.($expected.Key) if ($actual -ne [string]$expected.Value) { $failures.Add("DOTNET-NOTICE-METADATA.json has $($expected.Key)='$actual'; expected '$($expected.Value)'.") } } - - $requiredPacks = @( + foreach ($runtimePack in @( "Microsoft.NETCore.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)", - "Microsoft.WindowsDesktop.App.Runtime.$($expectedMetadata.runtimeIdentifier)/$($expectedMetadata.runtimeVersion)") - foreach ($runtimePack in $requiredPacks) { + "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.') - } - - $coverage = $noticeMetadata.componentCoverage - $components = @($coverage.components) - $runtimePacks = @($coverage.runtimePacks) - 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 does not contain a valid FilesToBundle manifest SHA-256.') - } - if ([int]$coverage.unmappedExternalComponentCount -ne 0) { - $failures.Add("Component coverage reports $($coverage.unmappedExternalComponentCount) unmapped external components.") - } - if ($components.Count -ne [int]$coverage.runtimeComponentCount) { - $failures.Add('Component coverage runtime-component count does not match the component list.') - } - $embeddedComponents = @($components | Where-Object { - [string]$_.disposition -eq 'embedded' - }) - $looseComponents = @($components | Where-Object { - [string]$_.disposition -eq 'loose' - }) - if ($embeddedComponents.Count -ne [int]$coverage.embeddedRuntimeComponentCount -or - $looseComponents.Count -ne [int]$coverage.looseRuntimeComponentCount) { - $failures.Add('Component coverage disposition counts do not match the component list.') - } - if ($components.Count -le 0 -or $embeddedComponents.Count -le 0) { - $failures.Add('Component coverage does not contain the embedded runtime inventory.') - } - - $mapping = $coverage.noticeMapping - if ([string]$mapping.id -ne 'exact-release-dotnet-bundle' -or - [string]$mapping.licenseFile -ne 'DOTNET-LICENSE-NOTICE.txt' -or - [string]$mapping.thirdPartyNoticesFile -ne 'THIRD-PARTY-NOTICES.txt') { - $failures.Add('Component coverage does not use the reviewed exact-release notice mapping.') - } - if ([string]$mapping.licenseSha256 -ne - ([string]$noticeMetadata.source.importedLicenseSha256).ToLowerInvariant() -or - [string]$mapping.thirdPartyNoticesSha256 -ne - ([string]$noticeMetadata.source.importedThirdPartyNoticesSha256).ToLowerInvariant()) { - $failures.Add('Component notice-mapping hashes do not match the imported official notice material.') - } - - $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 - if (@('embedded', 'loose') -notcontains $disposition -or - [string]::IsNullOrWhiteSpace($outputPath) -or - [string]::IsNullOrWhiteSpace($assetPath) -or - $sha256 -notmatch '^[0-9A-Fa-f]{64}$' -or - [string]$component.noticeMapping -ne 'exact-release-dotnet-bundle') { - $failures.Add("Invalid runtime component mapping for '$outputPath'.") - continue - } - if (@($noticeMetadata.runtimePackages) -notcontains $package) { - $failures.Add("Runtime component '$outputPath' references unknown package '$package'.") - } - $componentKey = "$disposition|$outputPath|$package|$assetPath" - if (-not $componentKeys.Add($componentKey)) { - $failures.Add("Duplicate runtime component mapping '$componentKey'.") - } - - if ($disposition -eq 'loose') { - $archiveKey = $outputPath.ToLowerInvariant() - if (-not $entries.ContainsKey($archiveKey)) { - $failures.Add("Mapped loose runtime component '$outputPath' is missing from the archive.") - } - else { - $archiveSha256 = Get-ArchiveEntrySha256 $entries[$archiveKey] - if ($archiveSha256 -ne $sha256.ToLowerInvariant()) { - $failures.Add("Mapped loose runtime component '$outputPath' has a SHA-256 mismatch.") - } - } - } - } - - foreach ($requiredPack in $requiredPacks) { - if (@($components | Where-Object { - [string]$_.package -eq $requiredPack - }).Count -le 0) { - $failures.Add("Required runtime pack '$requiredPack' has no mapped published component.") - } - $packEvidence = @($runtimePacks | Where-Object { - [string]$_.package -eq $requiredPack - }) | Select-Object -First 1 - if ($null -eq $packEvidence -or - [string]$packEvidence.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$') { - $failures.Add("Required runtime pack '$requiredPack' lacks exact NuGet SHA-512 evidence.") - } - } - - $mappedLoosePaths = @($looseComponents | 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', '.so', '.dylib', '.exe') -and - $path -ne 'sightadapt.exe' -and - $mappedLoosePaths -notcontains $path) { - $failures.Add("Archive binary '$path' has no runtime component notice mapping.") - } + $failures.Add('DOTNET-NOTICE-METADATA.json lacks imported notice hashes.') } } catch { @@ -357,15 +171,16 @@ 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.') } - if (-not $noticeText.Contains('Component coverage evidence: DOTNET-NOTICE-METADATA.json', [StringComparison]::Ordinal)) { - $failures.Add('THIRD-PARTY-NOTICES.txt does not identify the component coverage evidence.') - } } $redistributionKey = 'microsoft-dotnet-redistribution.txt' @@ -408,6 +223,6 @@ if ($failures.Count -gt 0) { } Write-Host ( - "Release package verified: {0} required files and exact runtime component notice mappings are consistent with the pinned .NET release in {1}." -f + "Release package verified: {0} required files are present, readable and consistent with the pinned .NET release in {1}." -f $requiredFiles.Count, $resolvedArchive) From 63a28ce67a3464674165f05e847a1a393e5be46c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:23:17 +0200 Subject: [PATCH 13/19] Verify exact component notice coverage Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/verify-dotnet-component-coverage.ps1 | 250 +++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tools/verify-dotnet-component-coverage.ps1 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." From 128f768d1833d5a5055f3777e3e7983a069c1ceb Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:23:49 +0200 Subject: [PATCH 14/19] Run component coverage in negative package tests Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- tools/test-release-compliance-negative.ps1 | 24 +++++++--------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/tools/test-release-compliance-negative.ps1 b/tools/test-release-compliance-negative.ps1 index b44f0bd5..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." } } @@ -57,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" @@ -68,14 +66,10 @@ 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) @@ -90,9 +84,6 @@ try { -Force $metadataPath = Join-Path $unmappedDirectory 'DOTNET-NOTICE-METADATA.json' - if (-not [System.IO.File]::Exists($metadataPath)) { - throw 'The .NET notice metadata is unavailable for the unmapped-component test.' - } $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json $components = @($metadata.componentCoverage.components) $looseComponents = @($components | Where-Object { @@ -113,9 +104,8 @@ try { $metadata.componentCoverage.looseRuntimeComponentCount = $looseComponents.Count - 1 [System.IO.File]::WriteAllText( $metadataPath, - ($metadata | ConvertTo-Json -Depth 12) + [Environment]::NewLine, + ($metadata | ConvertTo-Json -Depth 14) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) - [System.IO.Compression.ZipFile]::CreateFromDirectory( $unmappedDirectory, $unmappedArchive) From d7d389cd1e107eb44e9983707bbf75114387e20b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:24:34 +0200 Subject: [PATCH 15/19] Validate component coverage in the final archive Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 332b5d09..68a6e12f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -127,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 From 5118ee8a9f69782931f34aa1522c19cccf3ea2ed Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:28:46 +0200 Subject: [PATCH 16/19] Correct shipped Windows SDK component inventory Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/legal/DOTNET-REDISTRIBUTION.md | 106 ++++++++++++++++------------ 1 file changed, 60 insertions(+), 46 deletions(-) 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 From b0444c840b77609e594fb1f820ac83543d213d37 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:29:31 +0200 Subject: [PATCH 17/19] Document separate component coverage stage Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/legal/DOTNET-NOTICE-GENERATION.md | 117 +++++++++++++------------ 1 file changed, 62 insertions(+), 55 deletions(-) diff --git a/docs/legal/DOTNET-NOTICE-GENERATION.md b/docs/legal/DOTNET-NOTICE-GENERATION.md index 4e06b761..ba2f89ee 100644 --- a/docs/legal/DOTNET-NOTICE-GENERATION.md +++ b/docs/legal/DOTNET-NOTICE-GENERATION.md @@ -1,6 +1,6 @@ # Exact-version .NET notice generation -This document defines the Microsoft .NET license and third-party notice evidence 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 inputs @@ -8,82 +8,89 @@ This document defines the Microsoft .NET license and third-party notice evidence - `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/dotnet-redistribution-review.json` records the maintainer-reviewed configuration and redistribution wording. +- `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 reviewed 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 | +After restore and publish, run: -## Publish-time component inventory +```powershell +.\tools\generate-dotnet-notices.ps1 ` + -PublishDirectory .\artifacts\win-x64 +``` -`SightAdapt.csproj` runs `CaptureSightAdaptFilesToBundle` between `PrepareForBundle` and `GenerateSingleFileBundle`. It writes `artifacts/dotnet-files-to-bundle.tsv` from the SDK-provided `FilesToBundle` item list. +This stage: -The temporary manifest is build evidence only. Absolute build-machine paths are not copied into the release archive. +- requires the exact pinned SDK; +- 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. -After publish, `tools/generate-dotnet-notices.ps1`: +## Stage 2: exact published-component coverage -1. resolves each exact runtime pack in the restored NuGet cache; -2. verifies its NuGet SHA-512 evidence and records repository metadata when present; -3. maps every `FilesToBundle` entry originating from a runtime pack; -4. records the bundle-relative output path, package asset path, component kind and SHA-256; -5. scans loose binary files in the final publish directory; -6. matches each loose runtime binary by filename and SHA-256 to an exact runtime-pack asset; -7. fails when a bundled package-cache file or loose binary cannot be mapped to a reviewed runtime pack. +`SightAdapt.csproj` captures the SDK-generated `FilesToBundle` list during single-file publication. Then run: -This covers both components embedded in `SightAdapt.exe` and separately shipped native libraries. +```powershell +.\tools\generate-dotnet-component-coverage.ps1 ` + -PublishDirectory .\artifacts\win-x64 +``` -## Official notice material +This stage: -The generator confirms the SDK/runtime release mapping through Microsoft's release metadata, downloads the matching official SDK ZIP, verifies its published SHA-512 and imports: +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. -- `LICENSE.txt` into `DOTNET-LICENSE-NOTICE.txt`; -- `ThirdPartyNotices.txt` into `THIRD-PARTY-NOTICES.txt`. +The temporary `FilesToBundle` manifest may contain absolute build paths. Only its SHA-256 and sanitized component records are retained in the release metadata. -The imported texts are not substantively modified. SightAdapt adds an exact-build header and points recipients to `DOTNET-NOTICE-METADATA.json` for component-level coverage evidence. +## Current component evidence -## Metadata schema +The reviewed alpha build maps 452 package components across three exact packages: -`DOTNET-NOTICE-METADATA.json` schema 2 records: +| 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 | -- product, SDK, runtime, RID and publish mode; -- exact runtime-pack identities and NuGet SHA-512 values; -- official release/package URLs and imported notice hashes; -- SHA-256 of the `FilesToBundle` manifest; -- embedded and loose runtime-component counts; -- one entry per mapped runtime component containing disposition, output path, runtime pack, package asset path, kind and SHA-256; -- `unmappedExternalComponentCount`, which must be zero. +The inventory contains 447 embedded components and 5 loose native DLLs. `unmappedExternalComponentCount` must remain zero. -All runtime components use the mapping identifier `exact-release-dotnet-bundle`, tied to the imported exact-release license and third-party notice files. +## Stage 3: maintainer redistribution summary + +Run: + +```powershell +.\tools\generate-dotnet-redistribution-notice.ps1 ` + -PublishDirectory .\artifacts\win-x64 +``` + +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. ## Final-package validation -`tools/test-release-package.ps1` independently verifies that: +Two independent validators run on the final ZIP: -- metadata uses schema 2 and matches canonical release values; -- both required runtime packs have published components and NuGet SHA-512 evidence; -- component totals match the detailed inventory; -- every loose binary in the ZIP has a component mapping and matching SHA-256; -- embedded runtime components are represented by the captured `FilesToBundle` evidence; -- no external component is reported as unmapped; -- notice-mapping hashes equal the imported official-text hashes. +- `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. -The negative test removes one real loose runtime mapping while leaving the binary in the package. CI must reject that archive, in addition to rejecting incomplete and stale-notice packages. +The negative package test proves rejection of: -## Maintenance +- an incomplete package; +- stale redistribution wording; +- a package where one real loose binary remains but its component mapping is removed. -A .NET or publish change must update the pinned inputs and maintainer review, then regenerate all evidence. Repeat the review when any of these change: +## Maintenance -- SDK or runtime version; -- target framework, RID or architecture; -- self-contained, single-file, trimming, ReadyToRun, Native AOT or extraction settings; -- restored runtime packs; -- bundled or loose runtime-component inventory; -- Microsoft release metadata, license or notice source. +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 package hashes, runtime-pack mapping, `FilesToBundle` capture, loose-binary matching, official notice import or final ZIP validation fails. +Do not publish when official-text import, package policy, package hashes, component mapping, notice mapping or final ZIP validation fails. From 9a326f9abfe6090edc8d4c0aa954be69d3edf951 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:30:05 +0200 Subject: [PATCH 18/19] Add component coverage to build instructions Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/BUILD.md | 114 ++++++++++++++++++++------------------------------ 1 file changed, 45 insertions(+), 69 deletions(-) diff --git a/docs/BUILD.md b/docs/BUILD.md index 5bf13bf5..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 exact runtime-pack identities. - -## 3. Publish +## 3. Publish and capture bundle inputs ```powershell dotnet publish .\src\SightAdapt\SightAdapt.csproj ` @@ -52,81 +40,58 @@ dotnet publish .\src\SightAdapt\SightAdapt.csproj ` --output .\artifacts\win-x64 ``` -The static legal baseline is copied to the publish directory. During single-file publication, the project captures the SDK-provided `FilesToBundle` list into `artifacts\dotnet-files-to-bundle.tsv`. This temporary file identifies the exact inputs embedded in `SightAdapt.exe`; it is converted into sanitized component evidence and is not copied into the release ZIP. +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 step: - -- validates the exact restored runtime packs and their NuGet SHA-512 evidence; -- maps every runtime-pack file embedded through `FilesToBundle`; -- maps every loose runtime binary by filename and SHA-256 to an exact runtime-pack asset; -- rejects any package-cache or loose binary component without a reviewed mapping; -- downloads the matching official SDK ZIP and verifies SHA-512; -- imports the exact Microsoft license and third-party notice material; -- writes `THIRD-PARTY-NOTICES.txt`, `DOTNET-LICENSE-NOTICE.txt` and schema-2 `DOTNET-NOTICE-METADATA.json`. - -The metadata contains one sanitized entry per runtime component, including embedded/loose disposition, package, package-relative asset path, kind and SHA-256. +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: + +- exact package and version; +- package SHA-512; +- package-relative asset path; +- component SHA-256; +- reviewed notice mapping. -No external legal audit is required by the project plan. The generated file remains an internal project notice, not legal advice or clearance. +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 SBOM and license report +## 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. Inspect the staged directory - -At minimum: +## 7. Generate SBOM and license report -```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 ``` -Additional loose native runtime DLLs may be present. Every such binary must have a matching component entry in `DOTNET-NOTICE-METADATA.json`. - -## 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; -- a package with stale redistribution metadata; -- a package containing a runtime binary whose component mapping was removed. +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' @@ -141,20 +106,31 @@ Compress-Archive ` -DirectoryPath .\artifacts\win-x64 ` -ArchivePath $archive ` -ReportPath $report -``` -Final validation opens the ZIP, verifies every loose binary hash against the component map, checks embedded-component inventory totals, validates exact notice sources and confirms all other package invariants. 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 @@ -164,4 +140,4 @@ Remove-Item .\artifacts\SightAdapt-*-win-x64.zip -Force -ErrorAction SilentlyCon Remove-Item .\artifacts\SightAdapt-*-win-x64-compliance.json -Force -ErrorAction SilentlyContinue ``` -A release must not be published if metadata review, bundle capture, runtime-component mapping, 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. From 6d9b887fea87ac8a8b0141ce3d63bad5d04155d2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:30:40 +0200 Subject: [PATCH 19/19] Document exact package component coverage Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/PACKAGING.md | 138 +++++++++++++++++----------------------------- 1 file changed, 51 insertions(+), 87 deletions(-) diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 93987e52..13829402 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -2,76 +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 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` | Exact runtime-pack, component, 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, inconsistent with pinned inputs or contains a binary component without notice coverage evidence. - -## Distribution formats +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. -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. +## Publish sequence -Mirrors must publish the same verified bytes and compliance report without stripping or replacing notices. +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. -## Publish behavior +## Exact component coverage -1. `SightAdapt.csproj` copies the repository legal baseline. -2. During single-file publication, `CaptureSightAdaptFilesToBundle` records the exact SDK `FilesToBundle` inventory in a temporary build manifest. -3. `generate-dotnet-notices.ps1` maps every embedded runtime-pack input and every separately shipped runtime binary, verifies package hashes and imports exact Microsoft license/notice material. -4. `generate-dotnet-redistribution-notice.ps1` validates the reviewed configuration, template checksum and maintainer decision, then generates the package notice. -5. `generate-sbom.ps1` creates `DEPENDENCIES.md`, `SBOM.spdx.json` and `LICENSE-REPORT.json`. +Schema-2 `DOTNET-NOTICE-METADATA.json` records: -Expected package root: - -```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 -``` +- 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. -Exact loose native runtime files may also be present. They are permitted only when `DOTNET-NOTICE-METADATA.json` maps each file by output path and SHA-256 to an exact runtime-pack asset. +The reviewed alpha contains 452 mapped package components across: -## Runtime component coverage +- `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`. -Schema 2 of `DOTNET-NOTICE-METADATA.json` is the component-level source of truth. It records: +`Microsoft.Windows.SDK.NET.Ref` is classified as `shipped-embedded`, because `Microsoft.Windows.SDK.NET.dll` and `WinRT.Runtime.dll` are present in `FilesToBundle`. -- SHA-256 of the temporary MSBuild `FilesToBundle` manifest; -- exact NuGet SHA-512 evidence for restored runtime packs; -- one sanitized entry per embedded or loose runtime component; -- component output path, package identity, package-relative asset path, kind and SHA-256; -- the imported exact-release license and third-party-notice hashes; -- an unmapped-component count that must be zero. - -Absolute paths from the build machine are not retained in the release artifact. - -## Final package compliance gate - -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' @@ -86,58 +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, component coverage, 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 rejection of: +The workflow proves rejection of: - an incomplete package; -- a stale redistribution notice; -- a package that still contains a runtime binary after its notice mapping is removed. +- stale redistribution metadata; +- a package containing a real runtime DLL after its component mapping is deliberately removed. -## Microsoft .NET evidence - -`global.json` pins the SDK, `Directory.Build.props` records expected runtime/RID/publish inputs, `project.assets.json` identifies exact runtime packs and MSBuild `FilesToBundle` identifies the exact embedded single-file inputs. - -The exact-version generator downloads the matching official SDK ZIP, verifies SHA-512 and imports its license and third-party notice text. Runtime-pack NuGet hashes and component hashes connect that text to the actual published inventory. - -`release/dotnet-redistribution-review.json` records the 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, exact .NET component metadata and final publish files feed the SPDX SBOM and license report. `SightAdapt.exe` is the documented 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 and capture `FilesToBundle`; -6. generate exact-version .NET notices and component coverage; -7. generate the maintainer-reviewed redistribution notice; -8. generate SBOM, license report and dependency inventory; -9. resolve license-policy or component-mapping failures; -10. confirm incomplete, stale and unmapped-component 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, component map, 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.