From 9efebd15f4708d564aefdeac125eb39bc666105e Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:53:54 -0400 Subject: [PATCH 01/43] feat: support PowerShell 7.4 through 7.6 --- .github/ci-scripts/Actions_Bootstrap.ps1 | 112 +++--- build/DLLPickle.Build.ps1 | 34 +- build/DLLPickle.Tooling.ps1 | 147 +++++++ build/build-tool-versions.json | 25 ++ build/powershell-test-matrix.json | 170 +++++++++ global.json | 5 +- src/DLLPickle.Build/DLLPickle.csproj | 8 +- src/DLLPickle.Build/packages.lock.json | 232 +++++++++++ .../Private/Get-DPRuntimeProfile.ps1 | 124 ++++++ src/DLLPickle/Public/Import-DPLibrary.ps1 | 5 +- src/DLLPickle/SupportedRuntimeProfiles.json | 23 ++ .../DLLPickle.IntegrationTest.Tests.ps1 | 16 +- .../DLLPickle.Issue34.GraphAuth.Tests.ps1 | 125 ++++++ .../DependencyPolicyRealization.Tests.ps1 | 10 +- .../Integration/Invoke-DLLPickleScenario.ps1 | 31 +- tests/Unit/BuildTooling.Tests.ps1 | 64 ++++ tests/Unit/Import-DPLibrary.Tests.ps1 | 34 +- tests/Unit/PowerShellTestMatrix.Tests.ps1 | 38 ++ tests/Unit/RuntimeProfileEvidence.Tests.ps1 | 12 + tests/Unit/RuntimeProfilePolicy.Tests.ps1 | 121 ++++++ tests/Unit/RuntimeProfileSelection.Tests.ps1 | 60 +++ tests/Unit/RuntimeProvisioning.Tests.ps1 | 53 +++ tools/Install-DLLPickleTestPowerShell.ps1 | 360 ++++++++++++++++++ tools/Invoke-DLLPickleBuild.ps1 | 51 +++ tools/New-DLLPicklePowerShellTestMatrix.ps1 | 58 +++ tools/New-DLLPickleRuntimeProfileEvidence.ps1 | 135 +++++++ tools/Test-DLLPickleRuntimeProfilePolicy.ps1 | 163 ++++++++ 27 files changed, 2126 insertions(+), 90 deletions(-) create mode 100644 build/DLLPickle.Tooling.ps1 create mode 100644 build/build-tool-versions.json create mode 100644 build/powershell-test-matrix.json create mode 100644 src/DLLPickle/Private/Get-DPRuntimeProfile.ps1 create mode 100644 src/DLLPickle/SupportedRuntimeProfiles.json create mode 100644 tests/Integration/DLLPickle.Issue34.GraphAuth.Tests.ps1 create mode 100644 tests/Unit/BuildTooling.Tests.ps1 create mode 100644 tests/Unit/PowerShellTestMatrix.Tests.ps1 create mode 100644 tests/Unit/RuntimeProfileEvidence.Tests.ps1 create mode 100644 tests/Unit/RuntimeProfilePolicy.Tests.ps1 create mode 100644 tests/Unit/RuntimeProfileSelection.Tests.ps1 create mode 100644 tests/Unit/RuntimeProvisioning.Tests.ps1 create mode 100644 tools/Install-DLLPickleTestPowerShell.ps1 create mode 100644 tools/Invoke-DLLPickleBuild.ps1 create mode 100644 tools/New-DLLPicklePowerShellTestMatrix.ps1 create mode 100644 tools/New-DLLPickleRuntimeProfileEvidence.ps1 create mode 100644 tools/Test-DLLPickleRuntimeProfilePolicy.ps1 diff --git a/.github/ci-scripts/Actions_Bootstrap.ps1 b/.github/ci-scripts/Actions_Bootstrap.ps1 index 0a367098..782a7ca8 100644 --- a/.github/ci-scripts/Actions_Bootstrap.ps1 +++ b/.github/ci-scripts/Actions_Bootstrap.ps1 @@ -9,6 +9,10 @@ .EXAMPLE ./.github/scripts/Actions_Bootstrap.ps1 +.PARAMETER ModuleInstallPath + Optional isolated module root. Exact tool versions are saved here and the path is + prepended only to this process's PSModulePath. No user-scope module path is mutated. + .NOTES Run this script at the beginning of CI/CD workflows to ensure all dependencies are available. #> @@ -16,76 +20,72 @@ [CmdletBinding()] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] -param() +param( + [Parameter()] + [string]$ModuleInstallPath +) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' Write-Host '🔨 Bootstrapping CI/CD Environment...' +$RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent +$ToolingScriptPath = Join-Path -Path $RepositoryRoot -ChildPath 'build/DLLPickle.Tooling.ps1' +$ToolPolicyPath = Join-Path -Path $RepositoryRoot -ChildPath 'build/build-tool-versions.json' +. $ToolingScriptPath +$ToolPolicy = Get-DLLPickleBuildToolPolicy -Path $ToolPolicyPath + +if (-not [string]::IsNullOrWhiteSpace($ModuleInstallPath)) { + $ModuleInstallPath = [System.IO.Path]::GetFullPath($ModuleInstallPath) + if (-not (Test-Path -LiteralPath $ModuleInstallPath -PathType Container)) { + $null = New-Item -Path $ModuleInstallPath -ItemType Directory -Force + } + $ExistingModulePathEntries = @($env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $env:PSModulePath = @($ModuleInstallPath) + $ExistingModulePathEntries -join [System.IO.Path]::PathSeparator +} + # https://docs.microsoft.com/powershell/module/packagemanagement/get-packageprovider Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null # https://docs.microsoft.com/powershell/module/powershellget/set-psrepository Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -# List of PowerShell Modules required for the build. -$ModulesToInstall = New-Object System.Collections.Generic.List[object] - -# https://github.com/pester/Pester -$ModulesToInstall.Add(([PSCustomObject]@{ - ModuleName = 'Pester' - SkipPublisherCheck = $true # Skip publisher check for older Pester versions due to certificate mismatch. - #ModuleVersion = '5.7.1' - })) | Out-Null - -# https://github.com/nightroman/Invoke-Build -$ModulesToInstall.Add(([PSCustomObject]@{ - ModuleName = 'InvokeBuild' - #ModuleVersion = '5.12.1' - })) | Out-Null - -# https://github.com/PowerShell/PSScriptAnalyzer -$ModulesToInstall.Add(([PSCustomObject]@{ - ModuleName = 'PSScriptAnalyzer' - #ModuleVersion = '1.23.0' - })) | Out-Null - -# https://github.com/PowerShell/Microsoft.PowerShell.PlatyPS -$ModulesToInstall.Add(([PSCustomObject]@{ - ModuleName = 'Microsoft.PowerShell.PlatyPS' - })) | Out-Null -# https://github.com/PowerShell/platyPS -# Older version used due to: https://github.com/PowerShell/platyPS/issues/457 -#$ModulesToInstall.Add(([PSCustomObject]@{ -# ModuleName = 'platyPS' -# #ModuleVersion = '0.12.0' -#})) | Out-Null - -Write-Host '📦 Installing PowerShell Modules' -foreach ($Module in $ModulesToInstall) { - $InstallSplat = @{ - Name = $Module.ModuleName - Repository = 'PSGallery' - Force = $true - ErrorAction = 'Stop' - } - if ($Module.ModuleVersion) { - $InstallSplat['RequiredVersion'] = $Module.ModuleVersion - } - if ($Module.SkipPublisherCheck) { - $InstallSplat['SkipPublisherCheck'] = $true +Write-Host '📦 Installing exact PowerShell build-tool versions' +foreach ($Module in @($ToolPolicy.modules)) { + $RequiredVersion = [version]$Module.version + $InstalledModule = Get-Module -ListAvailable -Name $Module.name | + Where-Object { Test-DLLPickleToolVersionMatch -ActualVersion $_.Version -RequiredVersion $RequiredVersion } | + Select-Object -First 1 + + if (-not $InstalledModule) { + $ModuleCommandSplat = @{ + Name = $Module.name + RequiredVersion = $Module.version + Repository = 'PSGallery' + Force = $true + ErrorAction = 'Stop' + } + if ($Module.skipPublisherCheck) { + $ModuleCommandSplat['SkipPublisherCheck'] = $true + } + + try { + if ([string]::IsNullOrWhiteSpace($ModuleInstallPath)) { + $ModuleCommandSplat['Scope'] = 'CurrentUser' + Install-Module @ModuleCommandSplat + } else { + $ModuleCommandSplat['Path'] = $ModuleInstallPath + Save-Module @ModuleCommandSplat + } + } catch { + Write-Host " - Failed to install $($Module.name) $RequiredVersion" + throw + } } - try { - Install-Module @InstallSplat - Import-Module -Name $Module.ModuleName -ErrorAction Stop - Write-Host " - Successfully installed $($Module.ModuleName)" - } catch { - $message = 'Failed to install {0}' -f $Module.ModuleName - Write-Host " - $message" - throw - } + $ImportedModule = Import-DLLPickleBuildTool -Name $Module.name -RequiredVersion $RequiredVersion + Write-Host " - $($ImportedModule.Name) $($ImportedModule.Version) ready" } # Ensure .NET tools are available diff --git a/build/DLLPickle.Build.ps1 b/build/DLLPickle.Build.ps1 index 825776f3..31ef1a3a 100644 --- a/build/DLLPickle.Build.ps1 +++ b/build/DLLPickle.Build.ps1 @@ -43,6 +43,14 @@ ### QUESTION: Is the $BuildFile variable created by Invoke-Build automatically? $script:ModuleName = [regex]::Match((Get-Item $BuildFile).Name, '^(.*)\.Build\.ps1$').Groups[1].Value . "$(Join-Path -Path $PSScriptRoot -ChildPath "${ModuleName}.Settings.ps1")" +$script:BuildToolPolicyPath = Join-Path -Path $PSScriptRoot -ChildPath 'build-tool-versions.json' +. (Join-Path -Path $PSScriptRoot -ChildPath 'DLLPickle.Tooling.ps1') +$script:BuildToolPolicy = Get-DLLPickleBuildToolPolicy -Path $script:BuildToolPolicyPath +$script:RequiredInvokeBuildVersion = Get-DLLPickleBuildToolVersion -Policy $script:BuildToolPolicy -Name 'InvokeBuild' +$script:RequiredPesterVersion = Get-DLLPickleBuildToolVersion -Policy $script:BuildToolPolicy -Name 'Pester' +$script:RequiredPSScriptAnalyzerVersion = Get-DLLPickleBuildToolVersion -Policy $script:BuildToolPolicy -Name 'PSScriptAnalyzer' +$script:RequiredPlatyPSVersion = Get-DLLPickleBuildToolVersion -Policy $script:BuildToolPolicy -Name 'Microsoft.PowerShell.PlatyPS' +$null = Import-DLLPickleBuildTool -Name 'InvokeBuild' -RequiredVersion $script:RequiredInvokeBuildVersion function Test-ManifestBool ($Path) { # Validate the module manifest file @@ -127,8 +135,6 @@ Enter-Build { '*[\\/]Private[\\/]Show-DLLPickleLogo.ps1' ) - [version]$script:MinPesterVersion = '5.2.2' - [version]$script:MaxPesterVersion = '5.99.99' $script:TestOutputFormat = 'NUnitXML' } #Enter-Build @@ -231,6 +237,7 @@ Add-BuildTask Clean { #Synopsis: Invoke PSScriptAnalyzer against the Module source path Add-BuildTask Analyze { + $null = Import-DLLPickleBuildTool -Name 'PSScriptAnalyzer' -RequiredVersion $script:RequiredPSScriptAnalyzerVersion $ScriptAnalyzerParams = @{ Path = $script:ModuleSourcePath Setting = 'PSScriptAnalyzerSettings.psd1' @@ -251,6 +258,7 @@ Add-BuildTask Analyze { #Synopsis: Invoke Script Analyzer against the Tests path if it exists Add-BuildTask AnalyzeTests -After Analyze { if (Test-Path -Path $script:TestsPath) { + $null = Import-DLLPickleBuildTool -Name 'PSScriptAnalyzer' -RequiredVersion $script:RequiredPSScriptAnalyzerVersion $ScriptAnalyzerParams = @{ Path = $script:TestsPath Setting = 'PSScriptAnalyzerSettings.psd1' @@ -273,6 +281,7 @@ Add-BuildTask AnalyzeTests -After Analyze { #Synopsis: Invoke Script Analyzer against repository maintenance tools if they exist Add-BuildTask AnalyzeTools -After AnalyzeTests { if (Test-Path -Path $script:ToolsPath) { + $null = Import-DLLPickleBuildTool -Name 'PSScriptAnalyzer' -RequiredVersion $script:RequiredPSScriptAnalyzerVersion $ScriptAnalyzerParams = @{ Path = $script:ToolsPath Setting = 'PSScriptAnalyzerSettings.psd1' @@ -293,6 +302,7 @@ Add-BuildTask AnalyzeTools -After AnalyzeTests { #Synopsis: Analyze scripts to verify if they adhere to desired coding format (Stroustrup / OTBS / Allman) Add-BuildTask FormattingCheck { + $null = Import-DLLPickleBuildTool -Name 'PSScriptAnalyzer' -RequiredVersion $script:RequiredPSScriptAnalyzerVersion $ScriptAnalyzerParams = @{ Setting = 'CodeFormattingOTBS' ExcludeRule = 'PSUseConsistentWhitespace' @@ -312,10 +322,8 @@ Add-BuildTask FormattingCheck { #Synopsis: Invoke all Pester Unit Tests in the Tests\Unit folder (if it exists) Add-BuildTask Test { - - Write-Build White " Importing desired Pester version. Min: $script:MinPesterVersion Max: $script:MaxPesterVersion" - Remove-Module -Name Pester -Force -ErrorAction 'SilentlyContinue' # there are instances where some containers have Pester already in the session - Import-Module -Name Pester -MinimumVersion $script:MinPesterVersion -MaximumVersion $script:MaxPesterVersion -ErrorAction 'Stop' + Write-Build White " Importing exact Pester version: $script:RequiredPesterVersion" + $null = Import-DLLPickleBuildTool -Name 'Pester' -RequiredVersion $script:RequiredPesterVersion $CodeCovPath = Join-Path -Path $script:ArtifactsPath -ChildPath 'ccReport' $TestOutputPath = Join-Path -Path $script:ArtifactsPath -ChildPath 'testOutput' @@ -389,9 +397,8 @@ Add-BuildTask Test { #Synopsis: Used primarily during active development to generate XML file to graphically display code coverage in VSCode using Coverage Gutters Add-BuildTask DevCC { Write-Build White ' Generating code coverage report at root...' - Write-Build White " Importing desired Pester version. Min: $script:MinPesterVersion Max: $script:MaxPesterVersion" - Remove-Module -Name Pester -Force -ErrorAction SilentlyContinue # there are instances where some containers have Pester already in the session - Import-Module -Name Pester -MinimumVersion $script:MinPesterVersion -MaximumVersion $script:MaxPesterVersion -ErrorAction 'Stop' + Write-Build White " Importing exact Pester version: $script:RequiredPesterVersion" + $null = Import-DLLPickleBuildTool -Name 'Pester' -RequiredVersion $script:RequiredPesterVersion $PesterConfiguration = New-PesterConfiguration $PesterConfiguration.run.Path = $script:UnitTestsPath $PesterConfiguration.CodeCoverage.Enabled = $true @@ -413,8 +420,8 @@ Add-BuildTask DevCC { Add-BuildTask CreateHelpStart { Write-Build White ' Performing all help related actions.' - Write-Build Gray ' Importing Microsoft.PowerShell.PlatyPS ...' - Import-Module Microsoft.PowerShell.PlatyPS -ErrorAction Stop + Write-Build Gray " Importing Microsoft.PowerShell.PlatyPS $script:RequiredPlatyPSVersion ..." + $null = Import-DLLPickleBuildTool -Name 'Microsoft.PowerShell.PlatyPS' -RequiredVersion $script:RequiredPlatyPSVersion Write-Build Gray ' ...Microsoft.PowerShell.PlatyPS imported successfully.' } #CreateHelpStart @@ -899,9 +906,8 @@ Add-BuildTask Build { #Synopsis: Invokes all Pester Integration Tests in the Tests\Integration folder (if it exists) Add-BuildTask IntegrationTest { if (Test-Path -Path $script:IntegrationTestsPath) { - Write-Build White " Importing desired Pester version. Min: $script:MinPesterVersion Max: $script:MaxPesterVersion" - Remove-Module -Name Pester -Force -ErrorAction SilentlyContinue # There are instances where some containers have Pester already in the session - Import-Module -Name Pester -MinimumVersion $script:MinPesterVersion -MaximumVersion $script:MaxPesterVersion -ErrorAction 'Stop' + Write-Build White " Importing exact Pester version: $script:RequiredPesterVersion" + $null = Import-DLLPickleBuildTool -Name 'Pester' -RequiredVersion $script:RequiredPesterVersion Write-Build White " Performing Pester Integration Tests in $script:IntegrationTestsPath" diff --git a/build/DLLPickle.Tooling.ps1 b/build/DLLPickle.Tooling.ps1 new file mode 100644 index 00000000..70cd1f15 --- /dev/null +++ b/build/DLLPickle.Tooling.ps1 @@ -0,0 +1,147 @@ +function Get-DLLPickleBuildToolPolicy { + [CmdletBinding()] + param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$Path = (Join-Path -Path $PSScriptRoot -ChildPath 'build-tool-versions.json') + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Build-tool policy not found: $Path" + } + + $Policy = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($Policy.schemaVersion -ne 1) { + throw "Unsupported build-tool policy schema version '$($Policy.schemaVersion)' in $Path." + } + + $Modules = @($Policy.modules) + if ($Modules.Count -eq 0) { + throw "Build-tool policy contains no modules: $Path" + } + + $Names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Module in $Modules) { + if ([string]::IsNullOrWhiteSpace($Module.name)) { + throw "Build-tool policy contains a module with no name: $Path" + } + if (-not $Names.Add([string]$Module.name)) { + throw "Build-tool policy contains duplicate module '$($Module.name)': $Path" + } + + $ParsedVersion = $null + if (-not [version]::TryParse([string]$Module.version, [ref]$ParsedVersion)) { + throw "Build-tool policy contains invalid version '$($Module.version)' for '$($Module.name)': $Path" + } + if ($ParsedVersion.Revision -ge 0) { + throw "Build-tool policy versions must use Major.Minor.Patch syntax; found '$($Module.version)' for '$($Module.name)'." + } + } + + return $Policy +} + +function Get-DLLPickleBuildToolVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object]$Policy, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Name + ) + + $Matches = @($Policy.modules | Where-Object { $_.name -eq $Name }) + if ($Matches.Count -ne 1) { + throw "Expected exactly one build-tool policy entry for '$Name'; found $($Matches.Count)." + } + + return [version]$Matches[0].version +} + +function Test-DLLPickleToolVersionMatch { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [version]$ActualVersion, + + [Parameter(Mandatory)] + [version]$RequiredVersion + ) + + if ( + $ActualVersion.Major -ne $RequiredVersion.Major -or + $ActualVersion.Minor -ne $RequiredVersion.Minor -or + $ActualVersion.Build -ne $RequiredVersion.Build + ) { + return $false + } + + return $RequiredVersion.Revision -lt 0 -or $ActualVersion.Revision -eq $RequiredVersion.Revision +} + +function Assert-DLLPicklePesterAssemblyVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [version]$RequiredVersion, + + [Parameter(Mandatory)] + [System.Reflection.AssemblyName]$LoadedAssemblyName + ) + + if (-not (Test-DLLPickleToolVersionMatch -ActualVersion $LoadedAssemblyName.Version -RequiredVersion $RequiredVersion)) { + throw "Pester assembly mismatch: version '$($LoadedAssemblyName.Version)' is already loaded, but version '$RequiredVersion' is required. Start a fresh pwsh -NoProfile -NonInteractive process." + } +} + +function Import-DLLPickleBuildTool { + [CmdletBinding()] + [OutputType([System.Management.Automation.PSModuleInfo])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Name, + + [Parameter(Mandatory)] + [version]$RequiredVersion + ) + + if ($Name -eq 'Pester') { + $LoadedPesterAssemblyNames = @( + [System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq 'Pester' } | + ForEach-Object { $_.GetName() } + ) + foreach ($LoadedAssemblyName in $LoadedPesterAssemblyNames) { + Assert-DLLPicklePesterAssemblyVersion -RequiredVersion $RequiredVersion -LoadedAssemblyName $LoadedAssemblyName + } + } + + $LoadedModules = @(Get-Module -Name $Name) + foreach ($LoadedModule in $LoadedModules) { + if (-not (Test-DLLPickleToolVersionMatch -ActualVersion $LoadedModule.Version -RequiredVersion $RequiredVersion)) { + throw "Build-tool module mismatch: '$Name' version '$($LoadedModule.Version)' is already loaded, but version '$RequiredVersion' is required. Start a fresh pwsh -NoProfile -NonInteractive process." + } + } + + $ExactLoadedModule = $LoadedModules | + Where-Object { Test-DLLPickleToolVersionMatch -ActualVersion $_.Version -RequiredVersion $RequiredVersion } | + Select-Object -First 1 + if ($ExactLoadedModule) { + return $ExactLoadedModule + } + + Import-Module -Name $Name -RequiredVersion $RequiredVersion -Global -ErrorAction Stop + $ImportedModule = Get-Module -Name $Name | + Where-Object { Test-DLLPickleToolVersionMatch -ActualVersion $_.Version -RequiredVersion $RequiredVersion } | + Select-Object -First 1 + if (-not $ImportedModule) { + throw "Failed to import exact build-tool module '$Name' version '$RequiredVersion'." + } + + return $ImportedModule +} diff --git a/build/build-tool-versions.json b/build/build-tool-versions.json new file mode 100644 index 00000000..51f9ba2a --- /dev/null +++ b/build/build-tool-versions.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "modules": [ + { + "name": "Pester", + "version": "5.7.1", + "skipPublisherCheck": true + }, + { + "name": "InvokeBuild", + "version": "5.14.23", + "skipPublisherCheck": false + }, + { + "name": "PSScriptAnalyzer", + "version": "1.25.0", + "skipPublisherCheck": false + }, + { + "name": "Microsoft.PowerShell.PlatyPS", + "version": "1.0.3", + "skipPublisherCheck": false + } + ] +} diff --git a/build/powershell-test-matrix.json b/build/powershell-test-matrix.json new file mode 100644 index 00000000..a4c2536f --- /dev/null +++ b/build/powershell-test-matrix.json @@ -0,0 +1,170 @@ +{ + "schemaVersion": 1, + "lastVerifiedUtc": "2026-08-09T01:11:47Z", + "lifecycleSourceUrl": "https://learn.microsoft.com/en-us/lifecycle/products/powershell", + "evidenceFreshnessDays": 30, + "retirementWarningDays": 90, + "profiles": [ + { + "powerShellVersion": "7.4.18", + "powerShellMajor": 7, + "powerShellMinor": 4, + "dotnetMajor": 8, + "dotnetRuntimeVersion": "8.0.29", + "targetFramework": "net8.0", + "lifecycleState": "Supported", + "lifecycleEndDate": "2026-11-10", + "releaseNotesUrl": "https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-74" + }, + { + "powerShellVersion": "7.5.9", + "powerShellMajor": 7, + "powerShellMinor": 5, + "dotnetMajor": 9, + "dotnetRuntimeVersion": "9.0.18", + "targetFramework": "net9.0", + "lifecycleState": "Supported", + "lifecycleEndDate": "2026-11-10", + "releaseNotesUrl": "https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-75" + }, + { + "powerShellVersion": "7.6.4", + "powerShellMajor": 7, + "powerShellMinor": 6, + "dotnetMajor": 10, + "dotnetRuntimeVersion": "10.0.10", + "targetFramework": "net10.0", + "lifecycleState": "Supported", + "lifecycleEndDate": "2028-11-14", + "releaseNotesUrl": "https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-76" + } + ], + "lanes": [ + { + "platform": "windows", + "runner": "windows-2025", + "architecture": "x64", + "executableName": "pwsh.exe" + }, + { + "platform": "linux", + "runner": "ubuntu-24.04", + "architecture": "x64", + "executableName": "pwsh" + }, + { + "platform": "macos", + "runner": "macos-15-intel", + "architecture": "x64", + "executableName": "pwsh" + } + ], + "archiveAssets": [ + { + "powerShellVersion": "7.4.18", + "platform": "windows", + "architecture": "x64", + "fileName": "PowerShell-7.4.18-win-x64.zip", + "sha256": "d018ed5f92ff15a28442dce6a804b1e2aa6153d9fe1e9a06de8fd8142b171f4a", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.4.18/PowerShell-7.4.18-win-x64.zip" + }, + { + "powerShellVersion": "7.4.18", + "platform": "linux", + "architecture": "x64", + "fileName": "powershell-7.4.18-linux-x64.tar.gz", + "sha256": "21962bfc832119fc8a58e5eba24bc48f0d31707ce94a4e48a90178a223eba619", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.4.18/powershell-7.4.18-linux-x64.tar.gz" + }, + { + "powerShellVersion": "7.4.18", + "platform": "macos", + "architecture": "x64", + "fileName": "powershell-7.4.18-osx-x64.tar.gz", + "sha256": "7bcd1c95f3ae6e859a8209766db075bea6edf31952017e624ec79fb02879ab9d", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.4.18/powershell-7.4.18-osx-x64.tar.gz" + }, + { + "powerShellVersion": "7.5.9", + "platform": "windows", + "architecture": "x64", + "fileName": "PowerShell-7.5.9-win-x64.zip", + "sha256": "1e769394e2cde496bf6c55797a51a88873091415a853fa3b56255b2329fa3ef8", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.5.9/PowerShell-7.5.9-win-x64.zip" + }, + { + "powerShellVersion": "7.5.9", + "platform": "linux", + "architecture": "x64", + "fileName": "powershell-7.5.9-linux-x64.tar.gz", + "sha256": "492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.5.9/powershell-7.5.9-linux-x64.tar.gz" + }, + { + "powerShellVersion": "7.5.9", + "platform": "macos", + "architecture": "x64", + "fileName": "powershell-7.5.9-osx-x64.tar.gz", + "sha256": "528c261a07bc01466559183f868f8ed7fddc7e29c440e2339eba6f8a682a73a1", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.5.9/powershell-7.5.9-osx-x64.tar.gz" + }, + { + "powerShellVersion": "7.6.4", + "platform": "windows", + "architecture": "x64", + "fileName": "PowerShell-7.6.4-win-x64.zip", + "sha256": "80832551c52809301e6071c8bac977beb5a2f1ec953eb4db9f94deb953333793", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/PowerShell-7.6.4-win-x64.zip" + }, + { + "powerShellVersion": "7.6.4", + "platform": "linux", + "architecture": "x64", + "fileName": "powershell-7.6.4-linux-x64.tar.gz", + "sha256": "4471b5a36bfe86ec7af8525d36bb1cacba0128e7aac22d05cc064bc00e604721", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/powershell-7.6.4-linux-x64.tar.gz" + }, + { + "powerShellVersion": "7.6.4", + "platform": "macos", + "architecture": "x64", + "fileName": "powershell-7.6.4-osx-x64.tar.gz", + "sha256": "b58e4b96dbdca20c058d4462f33509d386c0d768751344611bc04aaf32e4187c", + "downloadUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/powershell-7.6.4-osx-x64.tar.gz" + } + ], + "provisioning": { + "defaultProvider": "DirectArchive", + "optionalProvider": { + "name": "MultiPwsh", + "version": "0.18.0", + "publishedUtc": "2026-08-07T01:40:45Z", + "repositoryUrl": "https://github.com/Devolutions/multi-pwsh", + "releaseUrl": "https://github.com/Devolutions/multi-pwsh/releases/tag/v0.18.0", + "ciOnly": true, + "assets": [ + { + "platform": "windows", + "architecture": "x64", + "fileName": "multi-pwsh-windows-x64.zip", + "sha256": "6998a9fce8ed77307b7802c9763f27762ea5b20dc6c73042f5bf9ac46b2f0ecb", + "downloadUrl": "https://github.com/Devolutions/multi-pwsh/releases/download/v0.18.0/multi-pwsh-windows-x64.zip" + }, + { + "platform": "linux", + "architecture": "x64", + "fileName": "multi-pwsh-linux-x64.zip", + "sha256": "5570ab938790b869782f6b192954f0038de8e4bec57e1ec50d0bd72d69b52a53", + "downloadUrl": "https://github.com/Devolutions/multi-pwsh/releases/download/v0.18.0/multi-pwsh-linux-x64.zip" + }, + { + "platform": "macos", + "architecture": "x64", + "fileName": "multi-pwsh-macos-x64.zip", + "sha256": "c207ddb69404bf42e1bd055f1d35d2480b0d128019b32c156304185f873e1971", + "downloadUrl": "https://github.com/Devolutions/multi-pwsh/releases/download/v0.18.0/multi-pwsh-macos-x64.zip" + } + ] + } + } +} diff --git a/global.json b/global.json index 391ba3c2..c66295ec 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { - "version": "8.0.100", - "rollForward": "latestFeature" + "version": "10.0.302", + "rollForward": "disable", + "allowPrerelease": false } } diff --git a/src/DLLPickle.Build/DLLPickle.csproj b/src/DLLPickle.Build/DLLPickle.csproj index 6e695be5..16662559 100644 --- a/src/DLLPickle.Build/DLLPickle.csproj +++ b/src/DLLPickle.Build/DLLPickle.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net9.0;net10.0 false false 2008 @@ -10,14 +10,14 @@ - ' -f $FindingFingerprint $report = [PSCustomObject]@{ + ProfileKey = $ProfileKey BaselineFingerprint = $baseline CurrentFingerprint = $fingerprint + FindingFingerprint = $FindingFingerprint ModuleVersions = [ordered]@{} - Comparison = $comparison } foreach ($module in $inventory.Modules) { $report.ModuleVersions[[string]$module.Name] = [string]$module.Version } $report | ConvertTo-Json -Depth 20 | Set-Content ./artifacts/upstreamCompatibility/comparison-report.json -Encoding utf8NoBOM @@ -383,10 +612,32 @@ jobs: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' '' 'Re-run the runtime ALC adjudication and update build/dependency-policy.json (classification + evidence + baseline) per docs/Architecture.md section 8.' + '' + $FindingMarker ) -join [System.Environment]::NewLine - $Existing = gh issue list --state open --search "$Title in:title" --json number --jq '.[0].number' - if ([string]::IsNullOrWhiteSpace($Existing)) { gh issue create --title $Title --body $Body } - else { gh issue comment $Existing --body $Body } + $Candidates = @(gh issue list --state all --search "$Title in:title" --limit 20 --json number,state,title | ConvertFrom-Json | Where-Object title -EQ $Title) + if ($LASTEXITCODE -ne 0) { throw 'Failed to query existing conflict-surface drift issues.' } + $ExistingIssue = $Candidates | Select-Object -First 1 + $AlreadyReported = $false + if ($ExistingIssue) { + $ExistingContent = gh issue view $ExistingIssue.number --json body,comments | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw 'Failed to read the existing conflict-surface drift issue.' } + $PublishedText = @($ExistingContent.body) + @($ExistingContent.comments.body) + $AlreadyReported = ./tools/Test-DLLPickleFindingFingerprintReported.ps1 -Fingerprint $FindingFingerprint -Text $PublishedText + } + if ($AlreadyReported) { + Write-Host "Conflict finding fingerprint $FindingFingerprint is already reported; suppressing a duplicate comment." + } elseif ($ExistingIssue) { + if ([string]$ExistingIssue.state -eq 'CLOSED') { + gh issue reopen $ExistingIssue.number + if ($LASTEXITCODE -ne 0) { throw 'Failed to reopen the conflict-surface drift issue.' } + } + gh issue comment $ExistingIssue.number --body $Body + if ($LASTEXITCODE -ne 0) { throw 'Failed to publish the new conflict-surface drift finding.' } + } else { + gh issue create --title $Title --body $Body + if ($LASTEXITCODE -ne 0) { throw 'Failed to open the conflict-surface drift issue.' } + } } else { Write-Host "Conflict surface unchanged (fingerprint '$fingerprint')." "drift=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 @@ -410,7 +661,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' dotnet restore ./src/DLLPickle.Build/DLLPickle.csproj --locked-mode - Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task IssueReproTest + ./tools/Invoke-DLLPickleBuild.ps1 -Task IssueReproTest - name: Inspect preload TFM alignment (Step 0b) if: steps.drift.outputs.drift != 'true' @@ -418,9 +669,9 @@ jobs: run: | # docs/Architecture.md section 8.2 Step 0(b): the explicit half of "TFM alignment". The # Build gate (0a) proves restore+build green under --locked-mode; this proves each preload - # package actually ships a net8.0/netstandard2.0-compatible asset (not just transitive luck). - # The preceding --locked-mode restore populated the NuGet global-packages folder, so the - # tool can inspect each resolved package's lib// assets. Fail-closed: a misaligned + # package receives an actual resolved assembly asset in every supported target graph. + # The preceding --locked-mode restore generated project.assets.json, which is the authority + # for NuGet asset selection. Fail-closed: a missing selection # candidate must not proceed on the automated path. $ErrorActionPreference = 'Stop' $report = ./tools/Test-DLLPickleTfmAlignment.ps1 ` @@ -428,19 +679,19 @@ jobs: -LockFilePath ./src/DLLPickle.Build/packages.lock.json ` -OutputPath ./artifacts/upstreamCompatibility/tfm-alignment.json @( - '# Preload TFM alignment (net8.0)' + '# Preload TFM alignment (all supported profiles)' '' "- Aligned: ``$($report.IsAligned)``" '' - '| Package | Version | Aligned | Compatible assets |' - '| --- | --- | --- | --- |' - ($report.Packages | ForEach-Object { "| $($_.PackageName) | $($_.ResolvedVersion) | $($_.IsAligned) | $((@($_.CompatibleAssets) -join ', ')) |" }) + '| TFM | Package | Version | Aligned | NuGet-selected assets |' + '| --- | --- | --- | --- | --- |' + ($report.Packages | ForEach-Object { "| $($_.TargetFramework) | $($_.PackageName) | $($_.ResolvedVersion) | $($_.IsAligned) | $((@($_.SelectedAssets) -join ', ')) |" }) ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 if (-not $report.IsAligned) { - Write-Host "::error::Preload TFM alignment failed for: $((@($report.Misaligned)) -join ', '). A candidate that fails the explicit net8.0/netstandard2.0 asset check (docs/Architecture.md section 8.2 Step 0b) must not proceed." + Write-Host "::error::Preload TFM alignment failed for: $((@($report.Misaligned)) -join ', '). A candidate missing a NuGet-selected asset in any supported TFM must not proceed." throw 'TFM alignment check failed; blocking candidate generation.' } - Write-Host "Preload TFM alignment passed: every preload package ships a net8.0-compatible asset." + Write-Host "Preload TFM alignment passed: every preload package has a NuGet-selected asset in every supported TFM." - name: Detect generated changes if: steps.drift.outputs.drift != 'true' @@ -449,7 +700,20 @@ jobs: run: | $ErrorActionPreference = 'Stop' $Changed = -not [string]::IsNullOrWhiteSpace((git status --short -- src/DLLPickle.Build/DLLPickle.csproj src/DLLPickle.Build/packages.lock.json)) + $ProfileSummary = Get-Content -LiteralPath './downloaded-profile-summary/profile-evidence-summary.json' -Raw | ConvertFrom-Json + if (-not $ProfileSummary.ReadyForCandidateUpdate) { + throw 'Candidate publication requires a complete, accepted, unchanged exact-profile evidence summary.' + } + $CanonicalLines = [System.Collections.Generic.List[string]]::new() + $CanonicalLines.Add("profile-evidence|$($ProfileSummary.AggregateFindingFingerprint)") + foreach ($Path in @('src/DLLPickle.Build/DLLPickle.csproj', 'src/DLLPickle.Build/packages.lock.json')) { + $Hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + $CanonicalLines.Add("candidate-file|$Path|$Hash") + } + $FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes((@($CanonicalLines | Sort-Object) -join [char]10)) + $PublicationFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() "has_changes=$($Changed.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "publication_fingerprint=$PublicationFingerprint" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Create candidate pull request if: steps.drift.outputs.drift != 'true' && steps.generated_changes.outputs.has_changes == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.create_pull_request)) @@ -458,18 +722,41 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $ErrorActionPreference = 'Stop' - $BranchName = 'automation/upstream-compatibility-${{ github.run_id }}' + $Fingerprint = '${{ steps.generated_changes.outputs.publication_fingerprint }}' + if ($Fingerprint -notmatch '^[a-f0-9]{64}$') { + throw 'Candidate publication did not receive a stable SHA-256 fingerprint.' + } + $BranchName = "automation/upstream-compatibility-$($Fingerprint.Substring(0, 16))" + $ExistingPullRequests = @(gh pr list --state all --head $BranchName --limit 10 --json number,state,url,body | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { throw 'Failed to query existing upstream candidate pull requests.' } + if ($ExistingPullRequests.Count -gt 0) { + Write-Host "Candidate fingerprint $Fingerprint is already published as $($ExistingPullRequests[0].url); suppressing a duplicate pull request." + exit 0 + } + git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git switch -c $BranchName git add src/DLLPickle.Build/DLLPickle.csproj src/DLLPickle.Build/packages.lock.json git commit -m 'deps: update upstream compatibility pins' + if ($LASTEXITCODE -ne 0) { throw 'Failed to commit the validated upstream candidate.' } git push --set-upstream origin $BranchName - gh pr create ` - --title 'deps: update upstream compatibility pins' ` - --body 'Automated upstream compatibility validation detected a safe candidate dependency pin update. The workflow regenerated the NuGet lock file and validated the issue reproduction suite before opening this PR.' ` - --base main ` - --head $BranchName + if ($LASTEXITCODE -ne 0) { throw 'Failed to publish the validated upstream candidate branch.' } + + $Body = @( + 'Automated upstream compatibility validation detected a safe candidate dependency pin update.' + '' + '- All nine accepted exact-profile fingerprints were complete and unchanged.' + '- The workflow regenerated the NuGet lock file.' + '- Restore, issue-reproduction tests, and per-TFM asset alignment passed before publication.' + '' + "Validation run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + '' + "" + ) -join [System.Environment]::NewLine + $Arguments = @('pr', 'create', '--title', 'deps: update upstream compatibility pins', '--body', $Body, '--base', 'main', '--head', $BranchName) + & gh @Arguments + if ($LASTEXITCODE -ne 0) { throw 'Failed to open the validated upstream candidate pull request.' } - name: Upload upstream compatibility artifacts if: always() @@ -491,18 +778,60 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + $ErrorActionPreference = 'Stop' $Title = 'Upstream compatibility automation failed' + $FailedSurfaces = [System.Collections.Generic.List[string]]::new() + $RunJobsJson = gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs?filter=latest&per_page=100" + if ($LASTEXITCODE -eq 0) { + $RunJobs = $RunJobsJson | ConvertFrom-Json + foreach ($RunJob in @($RunJobs.jobs)) { + foreach ($RunStep in @($RunJob.steps | Where-Object conclusion -EQ 'failure')) { + $FailedSurfaces.Add("$($RunJob.name)|$($RunStep.number)|$($RunStep.name)") + } + } + } + if ($FailedSurfaces.Count -eq 0) { + $FailedSurfaces.Add('${{ github.job }}|unresolved-failure-surface') + } + $FailureCanonicalText = @($FailedSurfaces | Sort-Object) -join [char]10 + $FailureBytes = [System.Text.Encoding]::UTF8.GetBytes($FailureCanonicalText) + $FailureFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FailureBytes)).Replace('-', '').ToLowerInvariant() + $FailureMarker = '' -f $FailureFingerprint $Body = @( 'The scheduled upstream compatibility workflow failed.' '' + 'Failed job/step surface:' + @($FailedSurfaces | Sort-Object | ForEach-Object { "- $_" }) + '' 'Review the workflow run and uploaded artifacts for details:' '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' '' 'This workflow is fail-closed. It does not publish or merge dependency preload changes unless inventory, candidate generation, restore, build, and issue repro validation all pass.' + '' + $FailureMarker ) -join [System.Environment]::NewLine - $ExistingIssue = gh issue list --state open --search "$Title in:title" --json number --jq '.[0].number' - if ([string]::IsNullOrWhiteSpace($ExistingIssue)) { - gh issue create --title $Title --body $Body + + $Candidates = @(gh issue list --state all --search "$Title in:title" --limit 20 --json number,state,title | ConvertFrom-Json | Where-Object title -EQ $Title) + if ($LASTEXITCODE -ne 0) { throw 'Failed to query existing upstream automation failure issues.' } + $ExistingIssue = $Candidates | Select-Object -First 1 + $AlreadyReported = $false + if ($ExistingIssue) { + $ExistingContent = gh issue view $ExistingIssue.number --json body,comments | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw 'Failed to read the existing upstream automation failure issue.' } + $PublishedText = @($ExistingContent.body) + @($ExistingContent.comments.body) + $AlreadyReported = ./tools/Test-DLLPickleFindingFingerprintReported.ps1 -Fingerprint $FailureFingerprint -Text $PublishedText + } + + if ($AlreadyReported) { + Write-Host "Failure fingerprint $FailureFingerprint is already reported; suppressing a duplicate comment." + } elseif ($ExistingIssue) { + if ([string]$ExistingIssue.state -eq 'CLOSED') { + gh issue reopen $ExistingIssue.number + if ($LASTEXITCODE -ne 0) { throw 'Failed to reopen the upstream automation failure issue.' } + } + gh issue comment $ExistingIssue.number --body $Body + if ($LASTEXITCODE -ne 0) { throw 'Failed to publish the new upstream automation failure finding.' } } else { - gh issue comment $ExistingIssue --body $Body + gh issue create --title $Title --body $Body + if ($LASTEXITCODE -ne 0) { throw 'Failed to open the upstream automation failure issue.' } } diff --git a/.github/workflows/Validate-Packages.yml b/.github/workflows/Validate-Packages.yml index f00e5a92..44310b9c 100644 --- a/.github/workflows/Validate-Packages.yml +++ b/.github/workflows/Validate-Packages.yml @@ -35,7 +35,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.302' cache: true cache-dependency-path: 'src/DLLPickle.Build/packages.lock.json' @@ -46,3 +46,20 @@ jobs: - name: Build shell: pwsh run: dotnet build src/DLLPickle.Build/DLLPickle.csproj --configuration Release --no-restore + + - name: Verify all target-framework assets selected by NuGet + shell: pwsh + run: | + $Parameters = @{ + OutputPath = './artifacts/package/tfm-alignment.json' + Strict = $true + } + ./tools/Test-DLLPickleTfmAlignment.ps1 @Parameters + + - name: Upload TFM alignment report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-tfm-alignment + path: ./artifacts/package/tfm-alignment.json + if-no-files-found: error diff --git a/build/artifact-size-baseline.json b/build/artifact-size-baseline.json new file mode 100644 index 00000000..f0b3acaf --- /dev/null +++ b/build/artifact-size-baseline.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "approvalStatus": "requires-maintainer-approval", + "capturedAtUtc": "2026-08-09T03:43:28Z", + "approvedAtUtc": null, + "thresholds": { + "maximumIncreasePercent": 10, + "maximumIncreaseBytes": 2097152 + }, + "profiles": [ + { + "name": "net8.0", + "unpackedBytes": 65217096, + "compressedBytes": 21966070 + }, + { + "name": "net9.0", + "unpackedBytes": 65217056, + "compressedBytes": 21966426 + }, + { + "name": "net10.0", + "unpackedBytes": 65217176, + "compressedBytes": 21966409 + } + ], + "fullArtifact": { + "unpackedBytes": 195783343, + "compressedBytes": 65935253 + } +} diff --git a/build/dependency-policy.json b/build/dependency-policy.json index 06ca4752..a6391bf1 100644 --- a/build/dependency-policy.json +++ b/build/dependency-policy.json @@ -1,36 +1,54 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "DLLPickle upstream dependency compatibility policy.", + "description": "DLLPickle profile-aware upstream dependency compatibility policy. Classifications are evaluated independently by PowerShell line, TFM, platform, module set, and import order.", "monitoredModules": [ { "name": "Microsoft.Graph.Authentication", "repository": "PSGallery", - "purpose": "Graph authentication stack and Azure.Core compatibility source." + "purpose": "Graph authentication stack and Azure.Core compatibility source.", + "umbrellaModule": "Microsoft.Graph", + "deterministicProbeCommand": "Get-Command Connect-MgGraph | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-MgContext | Out-Null" }, { "name": "ExchangeOnlineManagement", "repository": "PSGallery", - "purpose": "Exchange identity, broker, and OData compatibility source." + "purpose": "Exchange identity, broker, and OData compatibility source.", + "umbrellaModule": "ExchangeOnlineManagement", + "deterministicProbeCommand": "Get-ConnectionInformation -ErrorAction SilentlyContinue | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-EXOMailbox -ResultSize 1 | Out-Null" }, { "name": "Az.Storage", "repository": "PSGallery", - "purpose": "Az Storage OData compatibility source for Exchange coexistence." + "purpose": "Az Storage OData compatibility source for Exchange coexistence.", + "umbrellaModule": "Az", + "deterministicProbeCommand": "Get-Command Get-AzStorageAccount | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-AzStorageAccount | Select-Object -First 1 | Out-Null" }, { "name": "Az.Accounts", "repository": "PSGallery", - "purpose": "Az identity stack compatibility source." + "purpose": "Az identity stack compatibility source.", + "umbrellaModule": "Az", + "deterministicProbeCommand": "Get-AzContext -ErrorAction SilentlyContinue | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-AzContext | Out-Null" }, { "name": "MicrosoftTeams", "repository": "PSGallery", - "purpose": "Teams identity stack compatibility source." + "purpose": "Teams identity stack compatibility source.", + "umbrellaModule": "MicrosoftTeams", + "deterministicProbeCommand": "Get-Command Get-Team | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-CsTenant | Out-Null" }, { "name": "Az.Resources", "repository": "PSGallery", - "purpose": "Observed #193 collision source for Microsoft.Extensions.* transitive assemblies." + "purpose": "Observed #193 collision source for Microsoft.Extensions.* transitive assemblies.", + "umbrellaModule": "Az", + "deterministicProbeCommand": "Get-Command Get-AzResource | Out-Null", + "authenticatedReadOnlyProbeCommand": "Get-AzResource | Select-Object -First 1 | Out-Null" } ], "trackedAssemblies": [ @@ -322,7 +340,6 @@ { "packageName": "Microsoft.Identity.Client", "assemblyName": "Microsoft.Identity.Client", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -337,12 +354,16 @@ "alcOwner": "Default", "basis": "In the cross-module conflict surface; default-ALC-shared. Preloading the MSAL line is validated by the 2.0.1/2.0.2 four-module connect and underpins the #156 broker fix.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Identity.Client.Broker", "assemblyName": "Microsoft.Identity.Client.Broker", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -357,12 +378,16 @@ "alcOwner": "Default", "basis": "Default-ALC-shared; the #156 reproduction is a WithBroker missing-method failure when the broker line is misaligned. Validated by the 2.0.1/2.0.2 four-module connect.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Identity.Client.Extensions.Msal", "assemblyName": "Microsoft.Identity.Client.Extensions.Msal", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -376,12 +401,16 @@ "alcOwner": "Default", "basis": "Block CANDIDATE by ALC ownership (observed in Az's AzSharedAssemblyLoadContext), but runtime decides PRELOAD: preloading the MSAL/cache-helper line is proven safe by the 2.0.1/2.0.2 four-module connect. Only the Azure SDK stack breaks when preloaded.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Identity.Client.NativeInterop", "assemblyName": "Microsoft.Identity.Client.NativeInterop", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -394,12 +423,16 @@ "alcOwner": "Default", "basis": "Ships native runtime DLLs that Import-DPLibrary surfaces on PATH. Tracks the broker requirement; validated by the 2.0.1/2.0.2 four-module connect.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.IdentityModel.Abstractions", "assemblyName": "Microsoft.IdentityModel.Abstractions", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -414,12 +447,16 @@ "alcOwner": "Default", "basis": "Default-ALC-shared token-handling stack in the conflict surface; preloading a coherent 8.x line keeps mixed imports consistent (validated 2.0.1/2.0.2).", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.IdentityModel.JsonWebTokens", "assemblyName": "Microsoft.IdentityModel.JsonWebTokens", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -434,12 +471,16 @@ "alcOwner": "Default", "basis": "Default-ALC-shared token-handling stack in the conflict surface; coherent 8.x preload validated 2.0.1/2.0.2.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.IdentityModel.Logging", "assemblyName": "Microsoft.IdentityModel.Logging", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -454,12 +495,16 @@ "alcOwner": "Default", "basis": "Default-ALC-shared token-handling stack in the conflict surface; coherent 8.x preload validated 2.0.1/2.0.2.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.IdentityModel.Tokens", "assemblyName": "Microsoft.IdentityModel.Tokens", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -474,12 +519,16 @@ "alcOwner": "Default", "basis": "Default-ALC-shared token-handling stack in the conflict surface; coherent 8.x preload validated 2.0.1/2.0.2. Its incidental Microsoft.Extensions.* transitives are excluded from the bundle (see blockedPreloadAssemblies / #193).", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "System.IdentityModel.Tokens.Jwt", "assemblyName": "System.IdentityModel.Tokens.Jwt", - "targetFramework": "net8.0", "classification": "preload", "versionPolicy": "minorPatchFloat", "sourceModules": [ @@ -494,7 +543,12 @@ "alcOwner": "Default", "basis": "Default-ALC-shared; aligned with the IdentityModel 8.x preload. Validated 2.0.1/2.0.2.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] } ], "blockedPreloadAssemblies": [ @@ -513,7 +567,12 @@ "alcOwner": "AzSharedAssemblyLoadContext / msgraph-load-context", "basis": "Runtime probe 2026-05-31: Az.Accounts and Microsoft.Graph.Authentication both self-isolate Azure.Core in private ALCs and run different versions side-by-side (1.50 vs 1.51.1). Preloading into the default ALC splits TokenRequestContext -> Connect-AzAccount MissingMethodException. Removed from the bundle in 2.0.1.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Azure.Identity", @@ -528,7 +587,12 @@ "alcOwner": "AzSharedAssemblyLoadContext", "basis": "Runtime probe 2026-05-31: loaded into Az's private ALC (1.13.0). Belongs to the Azure SDK stack that breaks when preloaded (Azure.Core class). Not bundled by DLLPickle.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Azure.Identity.Broker", @@ -543,7 +607,12 @@ "alcOwner": "AzSharedAssemblyLoadContext", "basis": "In the conflict surface; part of the Az-self-isolated Azure SDK stack. Not bundled by DLLPickle.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "System.ClientModel", @@ -558,7 +627,12 @@ "alcOwner": "msgraph-load-context", "basis": "Runtime probe 2026-05-31: loaded into Graph's private ALC (1.9.0) alongside Azure.Core. Transitive of the blocked Azure.Core. Not bundled by DLLPickle.", "decidedOn": "2026-05-31" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Extensions.DependencyInjection.Abstractions", @@ -576,7 +650,12 @@ "decidedOn": "2026-05-31", "issue": "193", "trackingScope": "Az.Resources is included in monitoredModules so upstream inventory captures this assembly family from the observed #193 collision source. #193 remains a DLLPickle-bundle-vs-consumer collision (not a cross-module version split), so the cross-module conflict-surface gate still may not flag every change by itself; preserve the regression guard against re-bundling in tests/Integration/DLLPickle.IntegrationTest.Tests.ps1 and re-adjudicate runtime behavior when Az.Resources dependency content changes." - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Extensions.Logging.Abstractions", @@ -593,7 +672,12 @@ "decidedOn": "2026-05-31", "issue": "193", "trackingScope": "Az.Resources does not ship this assembly; the refreshed inventory observes Microsoft.Extensions.Logging.Abstractions only in MicrosoftTeams (a single shipper, so it does not diverge cross-module and is not in the conflict surface). It is excluded from the bundle as the paired dependency of Microsoft.Extensions.DependencyInjection.Abstractions, which Az.Resources does ship and which is now monitored and present in the conflict surface. #193 remains a DLLPickle-bundle-vs-consumer collision; preserve the regression guard against re-bundling in tests/Integration/DLLPickle.IntegrationTest.Tests.ps1 and re-adjudicate runtime behavior when Az.Resources dependency content changes." - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "System.Security.Cryptography.ProtectedData", @@ -611,7 +695,12 @@ "alcOwner": "PowerShell host / Default (Windows)", "basis": "Windows build validation on 2026-06-23 found that the MSAL extensions dependency copied NuGet System.Security.Cryptography.ProtectedData 4.5.0 into the bundle while PowerShell already supplied the runtime assembly. A direct ExcludeAssets=runtime reference keeps that host-provided assembly out of DLLPickle's preload set. On non-Windows platforms, the runtime assets are retained to satisfy the dependency chain.", "decidedOn": "2026-06-23" - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.OData.Core", @@ -625,7 +714,12 @@ "reason": "Issue #174 showed that default OData preloading can break Az.Storage when ExchangeOnlineManagement and Az.Storage require incompatible OData identities.", "evidence": { "basis": "Az.Storage 9.6.1 ships Microsoft.OData.Core 7.6.4; ExchangeOnlineManagement 3.9.2 requires 7.22.0. Both load into the default ALC; no preload version can satisfy both. See issue #174 and the shipped conflict-data entry '174-odata-azstorage-exo' (src/DLLPickle/KnownConflicts.json) for the runtime adjudication." - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.OData.Edm", @@ -639,7 +733,12 @@ "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented.", "evidence": { "basis": "OData family dependency paired with Microsoft.OData.Core; same default-ALC conflict as Core. See issue #174 and the shipped conflict-data entry '174-odata-azstorage-exo' (src/DLLPickle/KnownConflicts.json) for the runtime adjudication." - } + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] }, { "packageName": "Microsoft.Spatial", @@ -653,6 +752,284 @@ "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented.", "evidence": { "basis": "OData family dependency paired with Microsoft.OData.Core; same default-ALC conflict as Core. See issue #174 and the shipped conflict-data entry '174-odata-azstorage-exo' (src/DLLPickle/KnownConflicts.json) for the runtime adjudication." + }, + "targetFrameworks": [ + "net8.0", + "net9.0", + "net10.0" + ] + } + ], + "runtimeProfiles": [ + { + "powerShellLine": "7.4", + "targetFramework": "net8.0", + "dotnetMajor": 8, + "baselineStatus": "stale-requires-refresh-issue-273", + "baselineEvidence": "Legacy Windows snapshot from 2026-06-25; not authoritative for other operating systems.", + "platforms": [ + "windows", + "linux", + "macos" + ], + "monitoredModuleSet": [ + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "Az.Storage", + "Az.Accounts", + "MicrosoftTeams", + "Az.Resources" + ], + "importOrders": [ + [ + "ExchangeOnlineManagement", + "MicrosoftTeams", + "Microsoft.Graph.Authentication", + "Az.Accounts" + ], + [ + "Az.Accounts", + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "MicrosoftTeams" + ] + ], + "preloadAssemblyNames": [ + "Microsoft.Identity.Client", + "Microsoft.Identity.Client.Broker", + "Microsoft.Identity.Client.Extensions.Msal", + "Microsoft.Identity.Client.NativeInterop", + "Microsoft.IdentityModel.Abstractions", + "Microsoft.IdentityModel.JsonWebTokens", + "Microsoft.IdentityModel.Logging", + "Microsoft.IdentityModel.Tokens", + "System.IdentityModel.Tokens.Jwt" + ], + "blockedAssemblyNames": [ + "Azure.Core", + "Azure.Identity", + "Azure.Identity.Broker", + "System.ClientModel", + "Microsoft.Extensions.DependencyInjection.Abstractions", + "Microsoft.Extensions.Logging.Abstractions", + "System.Security.Cryptography.ProtectedData", + "Microsoft.OData.Core", + "Microsoft.OData.Edm", + "Microsoft.Spatial" + ], + "knownConflictIds": [ + "174-odata-azstorage-exo" + ], + "validationTiers": { + "deterministicImportNoAuth": { + "required": true, + "credentialsRequired": false + }, + "authenticatedReadOnly": { + "requiredBeforeRelease": true, + "credentialsRequired": true, + "writesAllowed": false, + "status": "not-run-no-approved-credentials" + } + }, + "baselines": { + "windows": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "linux": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "macos": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + } + }, + "legacyUnscopedBaseline": { + "conflictSurfaceFingerprint": "03b94d1603f2d7af09021a738fe9b7ed028dd18bba361e55bbe866bb1febaae0", + "evidence": "Legacy Windows snapshot from 2026-06-25; not authoritative for other operating systems." + } + }, + { + "powerShellLine": "7.5", + "targetFramework": "net9.0", + "dotnetMajor": 9, + "baselineStatus": "requires-ci-evidence", + "baselineEvidence": "No profile-specific baseline has been accepted; the nine-cell CI evidence lane must run.", + "platforms": [ + "windows", + "linux", + "macos" + ], + "monitoredModuleSet": [ + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "Az.Storage", + "Az.Accounts", + "MicrosoftTeams", + "Az.Resources" + ], + "importOrders": [ + [ + "ExchangeOnlineManagement", + "MicrosoftTeams", + "Microsoft.Graph.Authentication", + "Az.Accounts" + ], + [ + "Az.Accounts", + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "MicrosoftTeams" + ] + ], + "preloadAssemblyNames": [ + "Microsoft.Identity.Client", + "Microsoft.Identity.Client.Broker", + "Microsoft.Identity.Client.Extensions.Msal", + "Microsoft.Identity.Client.NativeInterop", + "Microsoft.IdentityModel.Abstractions", + "Microsoft.IdentityModel.JsonWebTokens", + "Microsoft.IdentityModel.Logging", + "Microsoft.IdentityModel.Tokens", + "System.IdentityModel.Tokens.Jwt" + ], + "blockedAssemblyNames": [ + "Azure.Core", + "Azure.Identity", + "Azure.Identity.Broker", + "System.ClientModel", + "Microsoft.Extensions.DependencyInjection.Abstractions", + "Microsoft.Extensions.Logging.Abstractions", + "System.Security.Cryptography.ProtectedData", + "Microsoft.OData.Core", + "Microsoft.OData.Edm", + "Microsoft.Spatial" + ], + "knownConflictIds": [ + "174-odata-azstorage-exo" + ], + "validationTiers": { + "deterministicImportNoAuth": { + "required": true, + "credentialsRequired": false + }, + "authenticatedReadOnly": { + "requiredBeforeRelease": true, + "credentialsRequired": true, + "writesAllowed": false, + "status": "not-run-no-approved-credentials" + } + }, + "baselines": { + "windows": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "linux": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "macos": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + } + } + }, + { + "powerShellLine": "7.6", + "targetFramework": "net10.0", + "dotnetMajor": 10, + "baselineStatus": "requires-ci-evidence", + "baselineEvidence": "No profile-specific baseline has been accepted; the nine-cell CI evidence lane must run.", + "platforms": [ + "windows", + "linux", + "macos" + ], + "monitoredModuleSet": [ + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "Az.Storage", + "Az.Accounts", + "MicrosoftTeams", + "Az.Resources" + ], + "importOrders": [ + [ + "ExchangeOnlineManagement", + "MicrosoftTeams", + "Microsoft.Graph.Authentication", + "Az.Accounts" + ], + [ + "Az.Accounts", + "Microsoft.Graph.Authentication", + "ExchangeOnlineManagement", + "MicrosoftTeams" + ] + ], + "preloadAssemblyNames": [ + "Microsoft.Identity.Client", + "Microsoft.Identity.Client.Broker", + "Microsoft.Identity.Client.Extensions.Msal", + "Microsoft.Identity.Client.NativeInterop", + "Microsoft.IdentityModel.Abstractions", + "Microsoft.IdentityModel.JsonWebTokens", + "Microsoft.IdentityModel.Logging", + "Microsoft.IdentityModel.Tokens", + "System.IdentityModel.Tokens.Jwt" + ], + "blockedAssemblyNames": [ + "Azure.Core", + "Azure.Identity", + "Azure.Identity.Broker", + "System.ClientModel", + "Microsoft.Extensions.DependencyInjection.Abstractions", + "Microsoft.Extensions.Logging.Abstractions", + "System.Security.Cryptography.ProtectedData", + "Microsoft.OData.Core", + "Microsoft.OData.Edm", + "Microsoft.Spatial" + ], + "knownConflictIds": [ + "174-odata-azstorage-exo" + ], + "validationTiers": { + "deterministicImportNoAuth": { + "required": true, + "credentialsRequired": false + }, + "authenticatedReadOnly": { + "requiredBeforeRelease": true, + "credentialsRequired": true, + "writesAllowed": false, + "status": "not-run-no-approved-credentials" + } + }, + "baselines": { + "windows": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "linux": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + }, + "macos": { + "status": "requires-profile-refresh", + "conflictSurfaceFingerprint": null, + "scenarioFingerprint": null + } } } ] diff --git a/docs/generated/Compatibility-Evidence.md b/docs/generated/Compatibility-Evidence.md new file mode 100644 index 00000000..b4f01f54 --- /dev/null +++ b/docs/generated/Compatibility-Evidence.md @@ -0,0 +1,86 @@ +# Upstream compatibility evidence + + + +Microsoft runtime support and upstream module behavior are tracked independently. The rows below deliberately remain release-gating gaps until a profile-specific CI run is accepted; a supported PowerShell line does not imply that every upstream module combination can coexist in one process. + +| Module | Version | PowerShell | TFM | OS | Selected asset | Assembly / ALC result | Verdict | Evidence date | Run ID | +|---|---|---:|---:|---|---|---|---|---:|---| +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.4 | `net8.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.4 | `net8.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.4 | `net8.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.5 | `net9.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.5 | `net9.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.5 | `net9.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.6 | `net10.0` | windows | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.6 | `net10.0` | linux | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Microsoft.Graph.Authentication | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| ExchangeOnlineManagement | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Storage | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Accounts | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| MicrosoftTeams | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | +| Az.Resources | pending exact-profile inventory | 7.6 | `net10.0` | macos | pending CI artifact | pending hash and ALC | **requires-profile-refresh** | 2026-08-09 | not-run | + +## Known process-isolation requirement + +Issue #174 remains an expected limitation: ExchangeOnlineManagement and Az.Storage can require incompatible Microsoft.OData major versions. Both import orders are tested, but separate PowerShell processes remain the documented safe boundary until fresh evidence supports a narrower rule. + +## Preserved regression coverage + +- Issue #34: a deterministic negative-control/protected scenario verifies that Microsoft Graph authentication can bind `BaseAbstractApplicationBuilder.WithLogging(IIdentityLogger, Boolean)` after DLLPickle preloading. +- Issue #193: package and runtime checks keep incidental `Microsoft.Extensions.*` assemblies out of the preload bundle. +- PR #215: package, runtime, and ALC checks keep `Azure.Core` and `System.ClientModel` out of DLLPickle so Az.Accounts retains ownership of its private Azure SDK load context. +- Issue #242: a worker-thread assembly-load scenario verifies that DLLPickle does not install PowerShell script-block assembly callbacks that can crash the process. + +## Authenticated read-only release gates + +The deterministic no-auth tier runs in CI. The following credential-dependent commands are not executed without approved credentials and must not be represented as passing: + +- `Microsoft.Graph.Authentication`: `Get-MgContext | Out-Null` +- `ExchangeOnlineManagement`: `Get-EXOMailbox -ResultSize 1 | Out-Null` +- `Az.Storage`: `Get-AzStorageAccount | Select-Object -First 1 | Out-Null` +- `Az.Accounts`: `Get-AzContext | Out-Null` +- `MicrosoftTeams`: `Get-CsTenant | Out-Null` +- `Az.Resources`: `Get-AzResource | Select-Object -First 1 | Out-Null` + +These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it. diff --git a/docs/generated/Support-Matrix.md b/docs/generated/Support-Matrix.md new file mode 100644 index 00000000..436a3853 --- /dev/null +++ b/docs/generated/Support-Matrix.md @@ -0,0 +1,17 @@ +# Supported PowerShell runtime matrix + + + +Lifecycle and servicing data last verified: **2026-08-09**. + +| PowerShell line | Exact CI patch | .NET runtime | Bundled TFM | Microsoft lifecycle | Support end | Required OS cells | +|---|---:|---:|---:|---|---:|---| +| 7.4 | 7.4.18 | 8.0.29 | `net8.0` | Supported | 2026-11-10 | Windows, Linux, macOS | +| 7.5 | 7.5.9 | 9.0.18 | `net9.0` | Supported | 2026-11-10 | Windows, Linux, macOS | +| 7.6 | 7.6.4 | 10.0.10 | `net10.0` | Supported | 2028-11-14 | Windows, Linux, macOS | + +This table is the Microsoft-supported runtime contract used by the loader and CI. Upstream module compatibility is a separate evidence question; see [Upstream compatibility evidence](Compatibility-Evidence.md). Exact servicing patches are test pins, not minimum patch claims for end users. + +The module selects a bundle using both the PowerShell minor line and CLR major, then fails closed on a mismatched or unknown pair. Only the rows above are part of the current support contract. + +`multi-pwsh` is optional, checksum-pinned CI provisioning infrastructure. CI invokes the provisioned official `pwsh` executable directly. No multi-pwsh executable, package, manifest dependency, runtime reference, or license payload is published with DLLPickle. diff --git a/src/DLLPickle/KnownConflicts.json b/src/DLLPickle/KnownConflicts.json index 067ac14a..1a1639e9 100644 --- a/src/DLLPickle/KnownConflicts.json +++ b/src/DLLPickle/KnownConflicts.json @@ -18,6 +18,53 @@ "runtimeProbe": "2026-06-01 scenarios 1-4: both load OData into the Default ALC; Az.Storage-first -> EXO REF_DEF_MISMATCH (0x80131040); EXO-first -> Az.Storage 'same name already loaded'.", "decidedOn": "2026-06-01", "reAdjudicationRequired": "Any OData classification change requires fresh runtime evidence for both import orders in one process; static dependency updates alone are insufficient." + }, + "importOrders": [ + [ + "Az.Storage", + "ExchangeOnlineManagement" + ], + [ + "ExchangeOnlineManagement", + "Az.Storage" + ] + ], + "requiresProcessIsolation": true, + "runtimeProfiles": [ + { + "powerShellLine": "7.4", + "targetFramework": "net8.0", + "platforms": [ + "windows", + "linux", + "macos" + ], + "evidenceStatus": "legacy-windows-evidence-requires-refresh" + }, + { + "powerShellLine": "7.5", + "targetFramework": "net9.0", + "platforms": [ + "windows", + "linux", + "macos" + ], + "evidenceStatus": "pending-ci-evidence" + }, + { + "powerShellLine": "7.6", + "targetFramework": "net10.0", + "platforms": [ + "windows", + "linux", + "macos" + ], + "evidenceStatus": "pending-ci-evidence" + } + ], + "validationTiers": { + "deterministicImportNoAuth": "required", + "authenticatedReadOnly": "not-run-no-approved-credentials" } } ] diff --git a/tests/Unit/ArtifactPolicy.Tests.ps1 b/tests/Unit/ArtifactPolicy.Tests.ps1 new file mode 100644 index 00000000..37af7b57 --- /dev/null +++ b/tests/Unit/ArtifactPolicy.Tests.ps1 @@ -0,0 +1,145 @@ +BeforeAll { + Set-Location -Path $PSScriptRoot + $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:ArtifactInspectionPath = Join-Path $ProjectRoot 'tools\Test-DLLPicklePackageArtifact.ps1' + $script:ArtifactSizePath = Join-Path $ProjectRoot 'tools\New-DLLPickleArtifactSizeReport.ps1' +} + +Describe 'DLLPickle package artifact policy' -Tag 'Unit' { + BeforeEach { + $script:FixtureRoot = Join-Path $TestDrive 'fixture' + $script:ModulePath = Join-Path $script:FixtureRoot 'module\DLLPickle' + $script:BuildOutputRoot = Join-Path $script:FixtureRoot 'build-output' + $script:PolicyPath = Join-Path $script:FixtureRoot 'SupportedRuntimeProfiles.json' + $script:ProjectPath = Join-Path $script:FixtureRoot 'DLLPickle.csproj' + $script:LockPath = Join-Path $script:FixtureRoot 'packages.lock.json' + $null = New-Item -Path $script:ModulePath -ItemType Directory -Force + + @{ + schemaVersion = 1 + profiles = @( + @{ powerShell = '7.4'; clrMajor = 8; targetFramework = 'net8.0' } + @{ powerShell = '7.5'; clrMajor = 9; targetFramework = 'net9.0' } + @{ powerShell = '7.6'; clrMajor = 10; targetFramework = 'net10.0' } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $script:PolicyPath -Encoding UTF8 + '' | Set-Content -LiteralPath $script:ProjectPath -Encoding UTF8 + '{ "version": 1 }' | Set-Content -LiteralPath $script:LockPath -Encoding UTF8 + '@{ RootModule = ''DLLPickle.psm1''; RequiredModules = @() }' | Set-Content -LiteralPath (Join-Path $script:ModulePath 'DLLPickle.psd1') -Encoding UTF8 + + foreach ($TargetFramework in @('net8.0', 'net9.0', 'net10.0')) { + $ArtifactTfmPath = Join-Path (Join-Path $script:ModulePath 'bin') $TargetFramework + $BuildTfmPath = Join-Path $script:BuildOutputRoot $TargetFramework + $null = New-Item -Path $ArtifactTfmPath -ItemType Directory -Force + $null = New-Item -Path $BuildTfmPath -ItemType Directory -Force + "artifact-$TargetFramework" | Set-Content -LiteralPath (Join-Path $ArtifactTfmPath 'Microsoft.Fixture.dll') -Encoding UTF8 + "artifact-$TargetFramework" | Set-Content -LiteralPath (Join-Path $BuildTfmPath 'Microsoft.Fixture.dll') -Encoding UTF8 + } + } + + It 'accepts exactly the three policy-derived TFM payloads with no test-tool references' { + $Parameters = @{ + ModulePath = $script:ModulePath + BuildOutputRoot = $script:BuildOutputRoot + SupportPolicyPath = $script:PolicyPath + ProjectPath = $script:ProjectPath + LockFilePath = $script:LockPath + OutputPath = Join-Path $TestDrive 'artifact-report.json' + Strict = $true + } + $Report = & $script:ArtifactInspectionPath @Parameters + + $Report.Passed | Should -BeTrue + $Report.ActualTargetFrameworks | Should -Be @('net10.0', 'net8.0', 'net9.0') + @($Report.Profiles) | Should -HaveCount 3 + @($Report.ForbiddenHits) | Should -HaveCount 0 + } + + It 'fails closed on an unexpected target framework' { + $null = New-Item -Path (Join-Path $script:ModulePath 'bin\net11.0') -ItemType Directory -Force + { + & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'unexpected.json') -Strict + } | Should -Throw '*Unexpected target-framework directory*' + } + + It 'fails closed when optional multi-pwsh tooling leaks into artifact content' { + 'multi-pwsh must never ship here' | Set-Content -LiteralPath (Join-Path $script:ModulePath 'leak.txt') -Encoding UTF8 + { + & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'leak.json') -Strict + } | Should -Throw '*Forbidden multi-pwsh reference*' + } + + It 'reports deterministic per-TFM and full-artifact sizes against the approved threshold' { + $BaselinePath = Join-Path $TestDrive 'size-baseline.json' + @{ + schemaVersion = 1 + approvalStatus = 'accepted' + approvedAtUtc = '2026-08-09T00:00:00Z' + thresholds = @{ maximumIncreasePercent = 10; maximumIncreaseBytes = 2097152 } + profiles = @( + @{ name = 'net8.0'; unpackedBytes = 0; compressedBytes = 0 } + @{ name = 'net9.0'; unpackedBytes = 0; compressedBytes = 0 } + @{ name = 'net10.0'; unpackedBytes = 0; compressedBytes = 0 } + ) + fullArtifact = @{ unpackedBytes = 0; compressedBytes = 0 } + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $BaselinePath -Encoding UTF8 + + $Report = & $script:ArtifactSizePath -ModulePath $script:ModulePath -SupportPolicyPath $script:PolicyPath -BaselinePath $BaselinePath -OutputPath (Join-Path $TestDrive 'size-report.json') -Strict + + $Report.ReviewRequired | Should -BeFalse + $Report.BaselineApproved | Should -BeTrue + @($Report.Profiles) | Should -HaveCount 3 + @($Report.Profiles | Where-Object { $_.CompressedBytes -le 0 }) | Should -HaveCount 0 + $Report.FullArtifact.UnpackedBytes | Should -BeGreaterThan 0 + $Report.FullArtifact.CompressedBytes | Should -BeGreaterThan 0 + } + + It 'routes material growth to review and fails in strict mode' { + $BaselinePath = Join-Path $TestDrive 'strict-size-baseline.json' + @{ + schemaVersion = 1 + approvalStatus = 'accepted' + approvedAtUtc = '2026-08-09T00:00:00Z' + thresholds = @{ maximumIncreasePercent = 0; maximumIncreaseBytes = 0 } + profiles = @( + @{ name = 'net8.0'; unpackedBytes = 1; compressedBytes = 1 } + @{ name = 'net9.0'; unpackedBytes = 1; compressedBytes = 1 } + @{ name = 'net10.0'; unpackedBytes = 1; compressedBytes = 1 } + ) + fullArtifact = @{ unpackedBytes = 1; compressedBytes = 1 } + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $BaselinePath -Encoding UTF8 + + { + & $script:ArtifactSizePath -ModulePath $script:ModulePath -SupportPolicyPath $script:PolicyPath -BaselinePath $BaselinePath -OutputPath (Join-Path $TestDrive 'strict-size-report.json') -Strict + } | Should -Throw '*exceeds the approved material-growth policy*' + } + + It 'fails closed until the captured size baseline is explicitly accepted' { + $BaselinePath = Join-Path $TestDrive 'unapproved-size-baseline.json' + @{ + schemaVersion = 1 + approvalStatus = 'requires-maintainer-approval' + capturedAtUtc = '2026-08-09T00:00:00Z' + approvedAtUtc = $null + thresholds = @{ maximumIncreasePercent = 10; maximumIncreaseBytes = 2097152 } + profiles = @( + @{ name = 'net8.0'; unpackedBytes = 0; compressedBytes = 0 } + @{ name = 'net9.0'; unpackedBytes = 0; compressedBytes = 0 } + @{ name = 'net10.0'; unpackedBytes = 0; compressedBytes = 0 } + ) + fullArtifact = @{ unpackedBytes = 0; compressedBytes = 0 } + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $BaselinePath -Encoding UTF8 + + $Parameters = @{ + ModulePath = $script:ModulePath + SupportPolicyPath = $script:PolicyPath + BaselinePath = $BaselinePath + OutputPath = Join-Path $TestDrive 'unapproved-size-report.json' + } + $Report = & $script:ArtifactSizePath @Parameters + + $Report.BaselineApproved | Should -BeFalse + $Report.ReviewRequired | Should -BeTrue + { & $script:ArtifactSizePath @Parameters -Strict } | Should -Throw '*baseline is not accepted by a maintainer*' + } +} diff --git a/tests/Unit/ConflictMatrix.Tests.ps1 b/tests/Unit/ConflictMatrix.Tests.ps1 index 132c738a..1f763856 100644 --- a/tests/Unit/ConflictMatrix.Tests.ps1 +++ b/tests/Unit/ConflictMatrix.Tests.ps1 @@ -120,4 +120,17 @@ Describe 'New-DLLPickleConflictMatrix' -Tag 'Unit' { Should -Be @($viaTeams.Assemblies | Where-Object Name -EQ 'Azure.Core').Versions # same version set $viaGraph.Fingerprint | Should -Not -Be $viaTeams.Fingerprint # different contributors } + + It 'keys otherwise identical fingerprints by exact runtime profile' { + $FirstInventory = Get-TestInventory + $FirstInventory | Add-Member -NotePropertyName ProfileKey -NotePropertyValue 'ps7.4-net8.0-windows-x64' + $SecondInventory = Get-TestInventory + $SecondInventory | Add-Member -NotePropertyName ProfileKey -NotePropertyValue 'ps7.5-net9.0-windows-x64' + + $first = & $ScriptPath -Inventory $FirstInventory + $second = & $ScriptPath -Inventory $SecondInventory + + $first.ProfileKey | Should -Be 'ps7.4-net8.0-windows-x64' + $first.Fingerprint | Should -Not -Be $second.Fingerprint + } } diff --git a/tests/Unit/ConflictMatrixDrift.Tests.ps1 b/tests/Unit/ConflictMatrixDrift.Tests.ps1 index 31c114cb..cdc24403 100644 --- a/tests/Unit/ConflictMatrixDrift.Tests.ps1 +++ b/tests/Unit/ConflictMatrixDrift.Tests.ps1 @@ -79,4 +79,24 @@ Describe 'Compare-DLLPickleConflictMatrix' -Tag 'Unit' { $r.HasMaterialDrift | Should -BeTrue $r.Findings.RemovedConflicts | Should -Contain 'Microsoft.OData.Core' } + + It 'emits a stable finding fingerprint for report deduplication' { + $b = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true 'Default' @('1.50.0.0') @('Az.Accounts')) + $c = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true 'Default' @('1.51.0.0') @('Az.Accounts')) + + $first = & $ScriptPath -Baseline $b -Current $c + $second = & $ScriptPath -Baseline $b -Current $c + + $first.FindingFingerprint | Should -Match '^[a-f0-9]{64}$' + $first.FindingFingerprint | Should -BeExactly $second.FindingFingerprint + } + + It 'rejects comparisons across different runtime profiles' { + $b = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true) + $b | Add-Member -NotePropertyName ProfileKey -NotePropertyValue 'ps7.4-net8.0-windows-x64' + $c = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true) + $c | Add-Member -NotePropertyName ProfileKey -NotePropertyValue 'ps7.5-net9.0-windows-x64' + + { & $ScriptPath -Baseline $b -Current $c } | Should -Throw '*different runtime profiles*' + } } diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index ec809d80..48ccfe4c 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -40,6 +40,8 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $ModuleRoot = Join-Path -Path $Path -ChildPath ([System.IO.Path]::Combine($Name, [string]$RequiredVersion)) $null = New-Item -Path $ModuleRoot -ItemType Directory -Force Copy-Item -LiteralPath $State.AssemblyLocation -Destination (Join-Path $ModuleRoot "$($State.AssemblyName).dll") -Force + Set-Content -LiteralPath (Join-Path $ModuleRoot "$Name.psm1") -Value '# Synthetic importable module.' -Encoding UTF8 + New-ModuleManifest -Path (Join-Path $ModuleRoot "$Name.psd1") -RootModule "$Name.psm1" -ModuleVersion ([string]$RequiredVersion) } $null = & $script:InventoryScriptPath -PolicyPath $PolicyPath -ModuleCachePath $ModuleCachePath -OutputPath (Join-Path $TestDrive 'atomic-inventory.json') @@ -56,6 +58,8 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $ModuleRoot = Join-Path -Path $ModuleCachePath -ChildPath ([System.IO.Path]::Combine('Synthetic.Graph', '1.0.0')) $null = New-Item -Path $ModuleRoot -ItemType Directory -Force Copy-Item -Path $Assembly.Location -Destination (Join-Path -Path $ModuleRoot -ChildPath "$AssemblyName.dll") -Force + Set-Content -LiteralPath (Join-Path $ModuleRoot 'Synthetic.Graph.psm1') -Value '# Synthetic importable module.' -Encoding UTF8 + New-ModuleManifest -Path (Join-Path $ModuleRoot 'Synthetic.Graph.psd1') -RootModule 'Synthetic.Graph.psm1' -ModuleVersion '1.0.0' $PolicyPath = Join-Path -Path $TestDrive -ChildPath 'policy.json' @{ @@ -78,12 +82,46 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { @($Result.Modules[0].TrackedAssemblies).Name | Should -Contain $AssemblyName } + It 'reads gallery manifests that use allowed dynamic module-manifest expressions' { + $Assembly = [System.String].Assembly + $AssemblyName = $Assembly.GetName().Name + $ModuleCachePath = Join-Path $TestDrive 'dynamic-modules' + $ModuleRoot = Join-Path $ModuleCachePath 'Synthetic.Dynamic\1.0.0' + $null = New-Item -Path $ModuleRoot -ItemType Directory -Force + Copy-Item -LiteralPath $Assembly.Location -Destination (Join-Path $ModuleRoot "$AssemblyName.dll") + Set-Content -LiteralPath (Join-Path $ModuleRoot 'Synthetic.Dynamic.psm1') -Value '# Synthetic dynamic-manifest module.' -Encoding UTF8 + @' +@{ + RootModule = if ($PSEdition -eq 'Core') { 'Synthetic.Dynamic.psm1' } else { 'Synthetic.Dynamic.psm1' } + ModuleVersion = '1.0.0' + GUID = '9be890c0-c2a2-47ea-aa9b-22b3395c35c4' + PowerShellVersion = '7.0' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} +'@ | Set-Content -LiteralPath (Join-Path $ModuleRoot 'Synthetic.Dynamic.psd1') -Encoding UTF8 + + $PolicyPath = Join-Path $TestDrive 'dynamic-policy.json' + @{ + monitoredModules = @(@{ name = 'Synthetic.Dynamic'; repository = 'PSGallery'; purpose = 'Dynamic manifest regression.' }) + trackedAssemblies = @($AssemblyName) + preload = @() + blockedPreloadAssemblies = @() + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + + $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'dynamic-inventory.json') + + $Result.Modules[0].ManifestPowerShellVersion | Should -Be '7.0' + } + It 'updates exact package pins from upstream inventory and reports blocked preload findings' { $ProjectPath = Join-Path -Path $TestDrive -ChildPath 'DLLPickle.csproj' @' - net8.0 + net8.0;net9.0;net10.0 @@ -98,7 +136,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { @{ packageName = 'Contoso.CappedLibrary' assemblyName = 'Contoso.CappedLibrary' - targetFramework = 'net8.0' + targetFrameworks = @('net8.0', 'net9.0', 'net10.0') classification = 'preload' versionPolicy = 'minorPatchFloat' maximumPackageVersion = '1.50.0' @@ -109,7 +147,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { @{ packageName = 'Microsoft.Identity.Client' assemblyName = 'Microsoft.Identity.Client' - targetFramework = 'net8.0' + targetFrameworks = @('net8.0', 'net9.0', 'net10.0') classification = 'preload' versionPolicy = 'minorPatchFloat' sourceModules = @('Az.Accounts', 'Microsoft.Graph.Authentication') @@ -121,6 +159,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { @{ packageName = 'Microsoft.OData.Core' assemblyName = 'Microsoft.OData.Core' + targetFrameworks = @('net8.0', 'net9.0', 'net10.0') sourceModules = @('ExchangeOnlineManagement', 'Az.Storage') updateMode = 'reportOnly' reason = 'Synthetic blocked preload test.' @@ -190,10 +229,137 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { # it is pinned exactly at the capped version. The uncapped MSAL entry floats as 4.*. $Report.Changes[0].CandidateVersion | Should -Be '[1.50.0]' $Report.Changes[0].SourceModule | Should -Be 'MicrosoftTeams' - $Report.Warnings | Should -Contain "PackageReference 'Contoso.CappedLibrary' candidate '1.53.0' exceeds maximum '1.50.0' for target framework 'net8.0'; using maximum version." + $Report.Changes[0].TargetFrameworks | Should -Be @('net8.0', 'net9.0', 'net10.0') + @($Report.Changes[0].TfmResults) | Should -HaveCount 3 + $Report.Changes[0].UsesConditionalReferences | Should -BeFalse + $Report.ReviewRequired | Should -BeFalse + $Report.Warnings | Should -Contain "PackageReference 'Contoso.CappedLibrary' candidate '1.53.0' exceeds maximum '1.50.0' for target frameworks 'net8.0, net9.0, net10.0'; using maximum version." Get-Content -LiteralPath $ProjectPath -Raw | Should -Match 'Include="Contoso\.CappedLibrary" Version="\[1\.50\.0\]"' Get-Content -LiteralPath $ProjectPath -Raw | Should -Match 'Include="Microsoft\.Identity\.Client" Version="4\.\*"' @($Report.BlockedFindings) | Should -HaveCount 1 $Report.BlockedFindings[0].AssemblyName | Should -Be 'Microsoft.OData.Core' } + + It 'flags existing per-TFM conditional pins for maintainer review' { + $ProjectPath = Join-Path -Path $TestDrive -ChildPath 'conditional.csproj' + @' + + + net8.0;net9.0;net10.0 + + + + + + + +'@ | Set-Content -LiteralPath $ProjectPath -Encoding UTF8 + + $PolicyPath = Join-Path -Path $TestDrive -ChildPath 'conditional-policy.json' + @{ + preload = @( + @{ + packageName = 'Contoso.Library' + assemblyName = 'Contoso.Library' + targetFrameworks = @('net8.0', 'net9.0', 'net10.0') + versionPolicy = 'exact' + sourceModules = @('Contoso.Module') + reason = 'Synthetic conditional-pin test.' + } + ) + blockedPreloadAssemblies = @() + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + + $InventoryPath = Join-Path -Path $TestDrive -ChildPath 'conditional-inventory.json' + @{ + Modules = @( + @{ + Name = 'Contoso.Module' + Version = '2.0.0' + TrackedAssemblies = @( + @{ + Name = 'Contoso.Library' + Version = '2.0.0.0' + RelativePath = 'Contoso.Library.dll' + } + ) + } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $InventoryPath -Encoding UTF8 + + $Report = & $script:UpdateScriptPath -InventoryPath $InventoryPath -PolicyPath $PolicyPath -ProjectPath $ProjectPath -OutputPath (Join-Path $TestDrive 'conditional-report.json') -Confirm:$false + + $Report.ProjectChanged | Should -BeTrue + $Report.ReviewRequired | Should -BeTrue + $Report.Changes[0].UsesConditionalReferences | Should -BeTrue + $Report.Changes[0].ConditionalPinRequired | Should -BeFalse + @($Report.Changes[0].TfmResults | Where-Object Applied) | Should -HaveCount 3 + ([regex]::Matches((Get-Content -LiteralPath $ProjectPath -Raw), 'Version="\[2\.0\.0\]"')).Count | Should -Be 3 + } + + It 'does not introduce conditional pins when profile inventories require different TFM versions' { + $ProjectPath = Join-Path $TestDrive 'common-pin.csproj' + @' + + + net8.0;net9.0;net10.0 + + + + + +'@ | Set-Content -LiteralPath $ProjectPath -Encoding UTF8 + + $PolicyPath = Join-Path $TestDrive 'multi-profile-policy.json' + @{ + preload = @( + @{ + packageName = 'Contoso.Library' + assemblyName = 'Contoso.Library' + targetFrameworks = @('net8.0', 'net9.0', 'net10.0') + versionPolicy = 'exact' + sourceModules = @('Contoso.Module') + reason = 'Synthetic profile reconciliation test.' + } + ) + blockedPreloadAssemblies = @() + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + + $InventoryPaths = @( + foreach ($TfmRow in @( + @{ Tfm = 'net8.0'; Version = '1.0.0.0' } + @{ Tfm = 'net9.0'; Version = '2.0.0.0' } + @{ Tfm = 'net10.0'; Version = '2.0.0.0' } + )) { + $Path = Join-Path $TestDrive "inventory-$($TfmRow.Tfm).json" + @{ + ProfileKey = "ps-test-$($TfmRow.Tfm)-windows-x64" + Profile = @{ TargetFramework = $TfmRow.Tfm } + Modules = @( + @{ + Name = 'Contoso.Module' + Version = '3.0.0' + TrackedAssemblies = @( + @{ + Name = 'Contoso.Library' + Version = $TfmRow.Version + RelativePath = 'Contoso.Library.dll' + } + ) + } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Path -Encoding UTF8 + $Path + } + ) + + $Report = & $script:UpdateScriptPath -InventoryPath $InventoryPaths -PolicyPath $PolicyPath -ProjectPath $ProjectPath -OutputPath (Join-Path $TestDrive 'multi-profile-report.json') -Confirm:$false + + $Report.ProjectChanged | Should -BeFalse + $Report.ReviewRequired | Should -BeTrue + $Report.Changes[0].ConditionalPinRequired | Should -BeTrue + $Report.Changes[0].CandidateVersions | Should -Be @('[1.0.0]', '[2.0.0]') + @($Report.Changes[0].TfmResults | Where-Object Applied) | Should -HaveCount 0 + Get-Content -LiteralPath $ProjectPath -Raw | Should -Match 'Version="1\.0\.0"' + } } diff --git a/tests/Unit/DependencyChangeReport.Tests.ps1 b/tests/Unit/DependencyChangeReport.Tests.ps1 new file mode 100644 index 00000000..21486677 --- /dev/null +++ b/tests/Unit/DependencyChangeReport.Tests.ps1 @@ -0,0 +1,76 @@ +BeforeAll { + Set-Location -Path $PSScriptRoot + $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:ReportScriptPath = Join-Path $ProjectRoot 'tools\New-DLLPickleDependencyChangeReport.ps1' +} + +Describe 'Per-TFM dependency change report' -Tag 'Unit' { + It 'records resolved graph, selected assets, assembly deltas, size, and required conflict/scenario gates' { + $BaselineAssetsPath = Join-Path $TestDrive 'baseline.assets.json' + $CandidateAssetsPath = Join-Path $TestDrive 'candidate.assets.json' + $BaselineOutput = Join-Path $TestDrive 'baseline-output' + $CandidateOutput = Join-Path $TestDrive 'candidate-output' + $PolicyPath = Join-Path $TestDrive 'support.json' + $DependencyPolicyPath = Join-Path $TestDrive 'dependency-policy.json' + $SizePath = Join-Path $TestDrive 'size.json' + + $BaselineAssets = @{ + targets = @{ + 'net8.0' = @{ + 'Contoso.Library/1.0.0' = @{ compile = @{ 'lib/net8.0/Contoso.Library.dll' = @{} }; runtime = @{ 'lib/net8.0/Contoso.Library.dll' = @{} } } + } + } + } + $CandidateAssets = @{ + targets = @{ + 'net8.0' = @{ + 'Contoso.Library/2.0.0' = @{ compile = @{ 'lib/net8.0/Contoso.Library.dll' = @{} }; runtime = @{ 'lib/net8.0/Contoso.Library.dll' = @{} } } + 'Contoso.Added/1.0.0' = @{ compile = @{ 'lib/net8.0/Contoso.Added.dll' = @{} }; runtime = @{ 'lib/net8.0/Contoso.Added.dll' = @{} } } + } + } + } + $BaselineAssets | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $BaselineAssetsPath -Encoding UTF8 + $CandidateAssets | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $CandidateAssetsPath -Encoding UTF8 + $ConflictAssembly = [System.Text.Json.JsonDocument].Assembly + @{ schemaVersion = 1; profiles = @(@{ targetFramework = 'net8.0' }) } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + @{ + preload = @() + blockedPreloadAssemblies = @(@{ assemblyName = $ConflictAssembly.GetName().Name }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $DependencyPolicyPath -Encoding UTF8 + @{ Profiles = @(@{ Name = 'net8.0'; UnpackedDeltaBytes = 42; ReviewRequired = $false }) } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $SizePath -Encoding UTF8 + + $null = New-Item -Path (Join-Path $BaselineOutput 'net8.0') -ItemType Directory -Force + $null = New-Item -Path (Join-Path $CandidateOutput 'net8.0') -ItemType Directory -Force + 'baseline' | Set-Content -LiteralPath (Join-Path $BaselineOutput 'net8.0\Microsoft.Contoso.dll') -Encoding UTF8 + 'candidate' | Set-Content -LiteralPath (Join-Path $CandidateOutput 'net8.0\Microsoft.Contoso.dll') -Encoding UTF8 + 'added' | Set-Content -LiteralPath (Join-Path $CandidateOutput 'net8.0\System.Added.dll') -Encoding UTF8 + Copy-Item -LiteralPath $ConflictAssembly.Location -Destination (Join-Path $CandidateOutput "net8.0\$($ConflictAssembly.GetName().Name).dll") + + $Parameters = @{ + BaselineProjectAssetsPath = $BaselineAssetsPath + CandidateProjectAssetsPath = $CandidateAssetsPath + BaselineBuildOutputRoot = $BaselineOutput + CandidateBuildOutputRoot = $CandidateOutput + SupportPolicyPath = $PolicyPath + DependencyPolicyPath = $DependencyPolicyPath + SizeReportPath = $SizePath + OutputPath = Join-Path $TestDrive 'report.json' + } + $Report = & $script:ReportScriptPath @Parameters + $ProfileReport = $Report.Profiles[0] + + $ProfileReport.ResolvedGraphDelta.Changed[0].Baseline | Should -Be '1.0.0' + $ProfileReport.ResolvedGraphDelta.Changed[0].Candidate | Should -Be '2.0.0' + @($ProfileReport.ResolvedGraphDelta.Added).PackageName | Should -Contain 'Contoso.Added' + @($ProfileReport.CandidateSelectedAssets) | Should -HaveCount 2 + @($ProfileReport.AssemblyDelta.Added).RelativePath | Should -Contain 'System.Added.dll' + @($ProfileReport.AssemblyDelta.Changed).Key | Should -Contain 'Microsoft.Contoso.dll' + $ProfileReport.Size.UnpackedDeltaBytes | Should -Be 42 + $ProfileReport.ConflictSurfaceDelta.RequiredCheck | Should -Be 'Validate upstream compatibility tooling' + $ProfileReport.ConflictSurfaceDelta.HasChanges | Should -BeTrue + @($ProfileReport.ConflictSurfaceDelta.Delta.Added).AssemblyName | Should -Contain $ConflictAssembly.GetName().Name + $ProfileReport.ConflictSurfaceDelta.UpstreamAlcAdjudication | Should -Be 'required-profile-aware-gate' + $Report.RequiredChecks | Should -Contain 'Build gate' + $Report.ReviewRequired | Should -BeTrue + } +} diff --git a/tests/Unit/DependencyPolicy.Tests.ps1 b/tests/Unit/DependencyPolicy.Tests.ps1 index 941f5a05..104cfb69 100644 --- a/tests/Unit/DependencyPolicy.Tests.ps1 +++ b/tests/Unit/DependencyPolicy.Tests.ps1 @@ -4,6 +4,41 @@ BeforeAll { } Describe 'Dependency policy baseline' -Tag 'Unit' { + It 'keys classifications and validation gates by every supported PowerShell/TFM profile' { + @($script:Policy.runtimeProfiles) | Should -HaveCount 3 + @($script:Policy.runtimeProfiles.powerShellLine) | Should -Be @('7.4', '7.5', '7.6') + @($script:Policy.runtimeProfiles.targetFramework) | Should -Be @('net8.0', 'net9.0', 'net10.0') + + foreach ($RuntimeProfile in @($script:Policy.runtimeProfiles)) { + @($RuntimeProfile.platforms) | Should -Be @('windows', 'linux', 'macos') + @($RuntimeProfile.monitoredModuleSet) | Should -Not -BeNullOrEmpty + @($RuntimeProfile.importOrders) | Should -HaveCount 2 + @($RuntimeProfile.preloadAssemblyNames) | Should -Not -BeNullOrEmpty + @($RuntimeProfile.blockedAssemblyNames) | Should -Not -BeNullOrEmpty + $RuntimeProfile.validationTiers.deterministicImportNoAuth.required | Should -BeTrue + $RuntimeProfile.validationTiers.authenticatedReadOnly.writesAllowed | Should -BeFalse + foreach ($Platform in @('windows', 'linux', 'macos')) { + $RuntimeProfile.baselines.$Platform.PSObject.Properties.Name | Should -Contain 'scenarioFingerprint' + } + } + } + + It 'applies every preload and block decision to all three isolated TFMs' { + foreach ($decision in @($script:Policy.preload + $script:Policy.blockedPreloadAssemblies)) { + @($decision.targetFrameworks) | Should -Be @('net8.0', 'net9.0', 'net10.0') + } + } + + It 'records deterministic and authenticated read-only probes separately' { + foreach ($module in @($script:Policy.monitoredModules)) { + $module.umbrellaModule | Should -Not -BeNullOrEmpty + $module.deterministicProbeCommand | Should -Not -BeNullOrEmpty + $module.authenticatedReadOnlyProbeCommand | Should -Not -BeNullOrEmpty + } + ($script:Policy.monitoredModules | Where-Object name -eq 'ExchangeOnlineManagement').authenticatedReadOnlyProbeCommand | Should -Match 'Get-EXOMailbox' + ($script:Policy.monitoredModules | Where-Object name -eq 'MicrosoftTeams').authenticatedReadOnlyProbeCommand | Should -Match 'Get-CsTenant' + } + It 'explicitly monitors Az.Resources as the #193 collision source' { $MonitoredNames = @($script:Policy.monitoredModules.name) $MonitoredNames | Should -Contain 'Az.Resources' diff --git a/tests/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 index 11480431..59ac7ab0 100644 --- a/tests/Unit/KnownConflicts.Tests.ps1 +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -92,6 +92,11 @@ Describe 'Shipped KnownConflicts.json source' -Tag 'Unit' { $Odata.workaround | Should -Match 'runspace in the same process does NOT help' $Odata.workaround | Should -Match 'Do not re-enable OData preloading' $Odata.evidence.reAdjudicationRequired | Should -Match 'both import orders' + $Odata.requiresProcessIsolation | Should -BeTrue + @($Odata.importOrders) | Should -HaveCount 2 + @($Odata.runtimeProfiles.powerShellLine) | Should -Be @('7.4', '7.5', '7.6') + @($Odata.runtimeProfiles.targetFramework) | Should -Be @('net8.0', 'net9.0', 'net10.0') + $Odata.validationTiers.authenticatedReadOnly | Should -Be 'not-run-no-approved-credentials' } } diff --git a/tests/Unit/PowerShellSupportUpdate.Tests.ps1 b/tests/Unit/PowerShellSupportUpdate.Tests.ps1 new file mode 100644 index 00000000..c0287a32 --- /dev/null +++ b/tests/Unit/PowerShellSupportUpdate.Tests.ps1 @@ -0,0 +1,148 @@ +BeforeAll { + Set-Location -Path $PSScriptRoot + $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:DiscoveryPath = Join-Path $ProjectRoot 'tools\Get-DLLPicklePowerShellSupportUpdate.ps1' + $script:MatrixUpdatePath = Join-Path $ProjectRoot 'tools\Update-DLLPicklePowerShellTestMatrix.ps1' + + function Get-SupportUpdateFixture { + param( + [string]$Root, + [string[]]$ReleaseVersions + ) + + $MatrixPath = Join-Path $Root 'matrix.json' + $ReleasePath = Join-Path $Root 'releases.json' + $LifecyclePath = Join-Path $Root 'lifecycle.html' + @{ + schemaVersion = 1 + lifecycleSourceUrl = 'https://learn.microsoft.com/en-us/lifecycle/products/powershell' + retirementWarningDays = 90 + profiles = @( + @{ powerShellVersion = '7.4.18'; powerShellMajor = 7; powerShellMinor = 4; dotnetMajor = 8; dotnetRuntimeVersion = '8.0.29'; targetFramework = 'net8.0'; lifecycleEndDate = '2026-11-10' } + @{ powerShellVersion = '7.5.9'; powerShellMajor = 7; powerShellMinor = 5; dotnetMajor = 9; dotnetRuntimeVersion = '9.0.18'; targetFramework = 'net9.0'; lifecycleEndDate = '2026-11-10' } + @{ powerShellVersion = '7.6.4'; powerShellMajor = 7; powerShellMinor = 6; dotnetMajor = 10; dotnetRuntimeVersion = '10.0.10'; targetFramework = 'net10.0'; lifecycleEndDate = '2028-11-14' } + ) + lanes = @( + @{ platform = 'windows'; architecture = 'x64' } + @{ platform = 'linux'; architecture = 'x64' } + @{ platform = 'macos'; architecture = 'x64' } + ) + archiveAssets = @( + foreach ($Version in @('7.4.18', '7.5.9', '7.6.4')) { + foreach ($Asset in @( + @{ platform = 'windows'; file = "PowerShell-$Version-win-x64.zip" } + @{ platform = 'linux'; file = "powershell-$Version-linux-x64.tar.gz" } + @{ platform = 'macos'; file = "powershell-$Version-osx-x64.tar.gz" } + )) { + @{ powerShellVersion = $Version; platform = $Asset.platform; architecture = 'x64'; fileName = $Asset.file; sha256 = ('b' * 64); downloadUrl = "https://example.invalid/$($Asset.file)" } + } + } + ) + } | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $MatrixPath -Encoding UTF8 + + @( + foreach ($Version in $ReleaseVersions) { + $Assets = foreach ($FileName in @("PowerShell-$Version-win-x64.zip", "powershell-$Version-linux-x64.tar.gz", "powershell-$Version-osx-x64.tar.gz")) { + @{ name = $FileName; browser_download_url = "https://example.invalid/$FileName"; digest = 'sha256:' + ('a' * 64) } + } + @{ tag_name = "v$Version"; draft = $false; prerelease = $false; published_at = '2026-08-01T00:00:00Z'; html_url = "https://example.invalid/v$Version"; assets = @($Assets) } + } + ) | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ReleasePath -Encoding UTF8 + + $LifecycleRows = @( + 'PowerShell 7.4 (LTS)' + 'PowerShell 7.5' + 'PowerShell 7.6 (LTS)' + if (@($ReleaseVersions | Where-Object { [version]$_ -ge [version]'7.7.0' }).Count -gt 0) { + 'PowerShell 7.7' + } + ) + "$($LifecycleRows -join '')
" | Set-Content -LiteralPath $LifecyclePath -Encoding UTF8 + + [PSCustomObject]@{ MatrixPath = $MatrixPath; ReleasePath = $ReleasePath; LifecyclePath = $LifecyclePath } + } +} + +Describe 'PowerShell support update discovery' -Tag 'Unit' { + It 'passes release-current validation when exact pins are newest' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.9', '7.6.4') + $Report = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'current.json') -AsOfUtc '2026-08-08T00:00:00Z' -RequireCurrent + + @($Report.PatchUpdates) | Should -HaveCount 0 + @($Report.NewLines) | Should -HaveCount 0 + $Report.ProposalPublishingStatus | Should -Be 'pending-workflow-publication' + $Report.PatchProposalFingerprint | Should -Match '^[a-f0-9]{64}$' + $Report.SupportContractFingerprint | Should -Match '^[a-f0-9]{64}$' + } + + It 'produces a checksum-complete matrix-only patch proposal' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.10', '7.6.4') + $Report = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'patch.json') -AsOfUtc '2026-08-08T00:00:00Z' + + @($Report.PatchUpdates) | Should -HaveCount 1 + $Report.PatchUpdates[0].CandidateVersion | Should -Be '7.5.10' + @($Report.PatchUpdates[0].Archives) | Should -HaveCount 3 + $Report.PatchUpdates[0].ChecksumsComplete | Should -BeTrue + $Report.MatrixOnlyUpdateAvailable | Should -BeTrue + $Report.PatchProposalMarker | Should -Be "" + } + + It 'finalizes a patch-only matrix proposal only after every lane reports one consistent runtime' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.10', '7.6.4') + $ReportPath = Join-Path $TestDrive 'patch-report.json' + $null = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath $ReportPath -AsOfUtc '2026-08-08T00:00:00Z' + $CandidatePath = Join-Path $TestDrive 'candidate-matrix.json' + $Preparation = & $script:MatrixUpdatePath -TestMatrixPath $Fixture.MatrixPath -UpdateReportPath $ReportPath -OutputPath $CandidatePath -PrepareCandidate + $Preparation.Mode | Should -Be 'CandidatePreparation' + (Get-Content -LiteralPath $CandidatePath -Raw | ConvertFrom-Json).candidateValidationPending | Should -BeTrue + + $IdentityDirectory = Join-Path $TestDrive 'identities' + $null = New-Item -Path $IdentityDirectory -ItemType Directory + foreach ($Platform in @('windows', 'linux', 'macos')) { + @{ + PowerShellVersion = '7.5.10' + DotNetVersion = '9.0.19' + Platform = $Platform + Architecture = 'x64' + TargetFramework = 'net9.0' + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $IdentityDirectory "$Platform.json") -Encoding UTF8 + } + $FinalPath = Join-Path $TestDrive 'final-matrix.json' + $Final = & $script:MatrixUpdatePath -TestMatrixPath $Fixture.MatrixPath -UpdateReportPath $ReportPath -OutputPath $FinalPath -RuntimeIdentityPath $IdentityDirectory -VerifiedAtUtc '2026-08-08T12:34:56Z' + $Final.Mode | Should -Be 'VerifiedProposal' + $Updated = Get-Content -LiteralPath $FinalPath -Raw | ConvertFrom-Json + @($Updated.profiles | Where-Object powerShellVersion -EQ '7.5.10') | Should -HaveCount 1 + @($Updated.profiles | Where-Object powerShellVersion -EQ '7.5.10')[0].dotnetRuntimeVersion | Should -Be '9.0.19' + @($Updated.archiveAssets | Where-Object powerShellVersion -EQ '7.5.10') | Should -HaveCount 3 + ([datetime]$Updated.lastVerifiedUtc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | Should -Be '2026-08-08T12:34:56Z' + $Updated.PSObject.Properties.Name | Should -Not -Contain 'candidateValidationPending' + } + + It 'routes a new GA minor line to support-contract review and fails release-current validation' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.9', '7.6.4', '7.7.0') + $Parameters = @{ + TestMatrixPath = $Fixture.MatrixPath + ReleaseDataPath = $Fixture.ReleasePath + LifecycleDataPath = $Fixture.LifecyclePath + OutputPath = Join-Path $TestDrive 'new-line.json' + AsOfUtc = '2026-08-08T00:00:00Z' + RequireCurrent = $true + } + + { & $script:DiscoveryPath @Parameters } | Should -Throw '*new GA PowerShell lines: 7.7*' + $Report = Get-Content -LiteralPath $Parameters.OutputPath -Raw | ConvertFrom-Json + $Report.SupportContractReviewRequired | Should -BeTrue + $Report.NewLines[0].RequiredDecision | Should -Match 'CLR/TFM' + $Report.SupportContractMarker | Should -Be "" + } + + It 'keeps a retirement-warning fingerprint stable while the remaining day count changes' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.9', '7.6.4') + $First = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'retirement-first.json') -AsOfUtc '2026-08-15T00:00:00Z' + $Second = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'retirement-second.json') -AsOfUtc '2026-08-16T00:00:00Z' + + $First.SupportContractReviewRequired | Should -BeTrue + @($First.Lifecycle | Where-Object Status -EQ 'RetiringSoon') | Should -HaveCount 2 + $First.SupportContractFingerprint | Should -BeExactly $Second.SupportContractFingerprint + } +} diff --git a/tests/Unit/ProfileConflictBaseline.Tests.ps1 b/tests/Unit/ProfileConflictBaseline.Tests.ps1 new file mode 100644 index 00000000..781ab668 --- /dev/null +++ b/tests/Unit/ProfileConflictBaseline.Tests.ps1 @@ -0,0 +1,138 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:ToolPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'tools/Test-DLLPickleProfileConflictBaseline.ps1' + + function Get-ProfileBaselineFixture { + param( + [string]$Status = 'accepted', + [string]$BaselineFingerprint = ('a' * 64), + [string]$CurrentFingerprint = ('a' * 64), + [string]$BaselineScenarioFingerprint = ('b' * 64), + [string]$CurrentScenarioFingerprint = ('b' * 64) + ) + + $root = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('n')) + $null = New-Item -Path $root -ItemType Directory -Force + $policyPath = Join-Path $root 'policy.json' + $matrixPath = Join-Path $root 'matrix.json' + $scenarioPath = Join-Path $root 'scenario.json' + [ordered]@{ + runtimeProfiles = @( + [ordered]@{ + powerShellLine = '7.6' + targetFramework = 'net10.0' + baselines = [ordered]@{ + windows = [ordered]@{ + status = $Status + conflictSurfaceFingerprint = $BaselineFingerprint + scenarioFingerprint = $BaselineScenarioFingerprint + } + } + } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $policyPath -Encoding utf8 + [ordered]@{ + ProfileKey = 'ps7.6-net10.0-windows-x64' + Profile = [ordered]@{ + PowerShellLine = '7.6' + TargetFramework = 'net10.0' + Platform = 'windows' + } + Fingerprint = $CurrentFingerprint + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $matrixPath -Encoding utf8 + + [ordered]@{ + ProfileKey = 'ps7.6-net10.0-windows-x64' + ValidationTier = 'deterministic-import-no-auth' + WritesPerformed = $false + Passed = $true + ScenarioFingerprint = $CurrentScenarioFingerprint + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $scenarioPath -Encoding utf8 + + [pscustomobject]@{ PolicyPath = $policyPath; MatrixPath = $matrixPath; ScenarioPath = $scenarioPath } + } +} + +Describe 'Profile-specific conflict baseline enforcement' -Tag 'Unit' { + It 'passes only an accepted unchanged exact-profile baseline' { + $fixture = Get-ProfileBaselineFixture + + $result = & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath -PassThru + + $result.Status | Should -Be 'AcceptedUnchanged' + $result.ProfileKey | Should -Be 'ps7.6-net10.0-windows-x64' + } + + It 'fails closed when profile evidence has not been accepted' { + $fixture = Get-ProfileBaselineFixture -Status 'requires-profile-refresh' -BaselineFingerprint $null + + { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath } | + Should -Throw '*not accepted*requires-profile-refresh*' + } + + It 'fails closed when the accepted profile fingerprint drifts' { + $fixture = Get-ProfileBaselineFixture -CurrentFingerprint ('b' * 64) + + { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath } | + Should -Throw '*drift*baseline*current*' + } + + It 'fails closed when deterministic import-order evidence drifts' { + $fixture = Get-ProfileBaselineFixture -CurrentScenarioFingerprint ('c' * 64) + + { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath } | + Should -Throw '*scenario drift*' + } + + It 'writes a stable finding before an unaccepted baseline fails closed' { + $fixture = Get-ProfileBaselineFixture -Status 'requires-profile-refresh' -BaselineFingerprint $null + $OutputPath = Join-Path $TestDrive 'baseline-comparison.json' + + $Parameters = @{ + PolicyPath = $fixture.PolicyPath + ConflictMatrixPath = $fixture.MatrixPath + ScenarioEvidencePath = $fixture.ScenarioPath + OutputPath = $OutputPath + } + { & $script:ToolPath @Parameters } | Should -Throw '*not accepted*' + $Result = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + $Result.Status | Should -Be 'RequiresAcceptance' + $Result.FindingFingerprint | Should -Match '^[a-f0-9]{64}$' + } +} + +Describe 'Finding fingerprint report deduplication' -Tag 'Unit' { + It 'matches only the exact stable marker' { + $ScriptPath = Join-Path $script:RepositoryRoot 'tools\Test-DLLPickleFindingFingerprintReported.ps1' + $Fingerprint = 'a' * 64 + & $ScriptPath -Fingerprint $Fingerprint -Text @('ordinary text', "") | Should -BeTrue + & $ScriptPath -Fingerprint $Fingerprint -Text @('ordinary text', '') | Should -BeFalse + } + + It 'aggregates profile findings into one deterministic marker' { + $SummaryScriptPath = Join-Path $script:RepositoryRoot 'tools\New-DLLPickleProfileEvidenceSummary.ps1' + $MatrixPath = Join-Path $TestDrive 'summary-matrix.json' + $EvidenceRoot = Join-Path $TestDrive 'summary-evidence' + $null = New-Item -Path $EvidenceRoot -ItemType Directory + @{ + profiles = @(@{ powerShellMajor = 7; powerShellMinor = 6; targetFramework = 'net10.0' }) + lanes = @(@{ platform = 'windows'; architecture = 'x64' }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $MatrixPath -Encoding UTF8 + @{ + ProfileKey = 'ps7.6-net10.0-windows-x64' + Status = 'RequiresAcceptance' + BaselineStatus = 'requires-profile-refresh' + BaselineFingerprint = $null + CurrentFingerprint = 'b' * 64 + FindingFingerprint = 'c' * 64 + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $EvidenceRoot 'baseline-comparison.json') -Encoding UTF8 + + $First = & $SummaryScriptPath -EvidenceRoot $EvidenceRoot -TestMatrixPath $MatrixPath -OutputPath (Join-Path $TestDrive 'first-summary.json') + $Second = & $SummaryScriptPath -EvidenceRoot $EvidenceRoot -TestMatrixPath $MatrixPath -OutputPath (Join-Path $TestDrive 'second-summary.json') + + $First.AllProfileEvidencePresent | Should -BeTrue + $First.ReadyForCandidateUpdate | Should -BeFalse + $First.AggregateFindingFingerprint | Should -BeExactly $Second.AggregateFindingFingerprint + $First.FindingMarker | Should -Be "" + } +} diff --git a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 index 7b9918f0..a1ef6e2b 100644 --- a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 +++ b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 @@ -47,21 +47,46 @@ Describe 'Get-DLLPickleRuntimeAssemblySnapshot' -Tag 'Unit' { It 'sources its filter from -PolicyPath and returns tracked assemblies loaded in the child session' { $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') # Microsoft.PowerShell.Management is always importable; the child always has SMA loaded. - $Result = & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy + $Result = & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy -PowerShellExecutable ([Environment]::ProcessPath) -PowerShellVersion $PSVersionTable.PSVersion -TargetFramework ('net{0}.0' -f [Environment]::Version.Major) ($Result | Where-Object Name -EQ 'System.Management.Automation') | Should -Not -BeNullOrEmpty + $Result[0].PowerShellVersion | Should -Be $PSVersionTable.PSVersion.ToString() + $Result[0].TargetFramework | Should -Be ('net{0}.0' -f [Environment]::Version.Major) + $Result[0].ExecutablePath | Should -Not -BeNullOrEmpty + $Result[0].Architecture | Should -Not -BeNullOrEmpty } It 'throws in strict mode when a module cannot be imported' { $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') - { & $SnapshotScript -ModuleName 'DLLPickle.DefinitelyMissing' -PolicyPath $Policy -Strict } | + { & $SnapshotScript -ModuleName 'DLLPickle.DefinitelyMissing' -PolicyPath $Policy -PowerShellExecutable ([Environment]::ProcessPath) -Strict } | Should -Throw '*runtime assembly snapshot failed*' } It 'throws in strict mode when the probe command fails' { $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') - { & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy -ProbeCommand "throw 'probe failed'" -Strict } | + { & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy -PowerShellExecutable ([Environment]::ProcessPath) -ProbeCommand "throw 'probe failed'" -Strict } | Should -Throw '*runtime assembly snapshot failed*' } + + It 'imports an exact manifest under an explicitly isolated module path' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $ModuleRoot = Join-Path $TestDrive 'isolated-module' + $null = New-Item -Path $ModuleRoot -ItemType Directory + Set-Content -LiteralPath (Join-Path $ModuleRoot 'Synthetic.Isolated.psm1') -Value '# isolated import target' -Encoding UTF8 + $ManifestPath = Join-Path $ModuleRoot 'Synthetic.Isolated.psd1' + New-ModuleManifest -Path $ManifestPath -RootModule 'Synthetic.Isolated.psm1' -ModuleVersion '1.0.0' + + $Result = & $SnapshotScript -ModuleName 'Synthetic.Isolated' -ModuleManifestPath $ManifestPath -ModuleSearchPath @($ModuleRoot, (Join-Path $PSHOME 'Modules')) -PolicyPath $Policy -PowerShellExecutable ([Environment]::ProcessPath) -Strict + + $Result[0].ImportedModulePaths | Should -Contain $ManifestPath + $Result[0].IsolatedModulePath | Should -Be (@($ModuleRoot, (Join-Path $PSHOME 'Modules')) -join [System.IO.Path]::PathSeparator) + } + + It 'never launches a generic pwsh command from PATH' { + $Source = Get-Content -LiteralPath $SnapshotScript -Raw + + $Source | Should -Match 'PowerShellExecutable' + $Source | Should -Not -Match '(?m)&\s+pwsh\b' + } } diff --git a/tests/Unit/SupportDocumentation.Tests.ps1 b/tests/Unit/SupportDocumentation.Tests.ps1 new file mode 100644 index 00000000..ee54b5dd --- /dev/null +++ b/tests/Unit/SupportDocumentation.Tests.ps1 @@ -0,0 +1,33 @@ +BeforeAll { + Set-Location -Path $PSScriptRoot + $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:GeneratorPath = Join-Path $ProjectRoot 'tools\New-DLLPickleSupportDocumentation.ps1' + $script:ReadmePath = Join-Path $ProjectRoot 'README.md' + $script:ArchitecturePath = Join-Path $ProjectRoot 'docs\Architecture.md' + $script:DependencyDocPath = Join-Path $ProjectRoot 'docs\DEPENDENCIES.md' +} + +Describe 'Generated support documentation' -Tag 'Unit' { + It 'matches the canonical support and dependency policies' { + { & $script:GeneratorPath -Check } | Should -Not -Throw + } + + It 'keeps the primary documentation linked to the generated support contract' { + Get-Content -LiteralPath $script:ReadmePath -Raw | Should -Match 'generated/Support-Matrix\.md' + Get-Content -LiteralPath $script:ArchitecturePath -Raw | Should -Match 'generated/Support-Matrix\.md' + Get-Content -LiteralPath $script:DependencyDocPath -Raw | Should -Match 'generated/Compatibility-Evidence\.md' + } + + It 'separates Microsoft support, upstream evidence, and optional CI tooling claims' { + $SupportMatrix = Get-Content -LiteralPath (Join-Path $ProjectRoot 'docs\generated\Support-Matrix.md') -Raw + $Compatibility = Get-Content -LiteralPath (Join-Path $ProjectRoot 'docs\generated\Compatibility-Evidence.md') -Raw + $SupportMatrix | Should -Match 'Microsoft-supported runtime contract' + $SupportMatrix | Should -Match 'multi-pwsh.*optional' + $Compatibility | Should -Match 'release-gating gaps' + $Compatibility | Should -Match 'process-isolation requirement' + $Compatibility | Should -Match 'Issue #34' + $Compatibility | Should -Match 'PR #215' + $Compatibility | Should -Match 'Issue #242' + $Compatibility | Should -Match 'not executed without approved credentials' + } +} diff --git a/tests/Unit/TfmAlignment.Tests.ps1 b/tests/Unit/TfmAlignment.Tests.ps1 index c2a497f7..a12664eb 100644 --- a/tests/Unit/TfmAlignment.Tests.ps1 +++ b/tests/Unit/TfmAlignment.Tests.ps1 @@ -78,6 +78,27 @@ BeforeAll { } } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $LockPath -Encoding utf8 + $AssetsPath = Join-Path $Context 'project.assets.json' + $HasCompatibleAsset = @($AlignedLibFramework | Where-Object { $_ -in @('net8.0', 'net6.0', 'netstandard2.0', 'netstandard2.1', 'netcoreapp3.1') }).Count -gt 0 + $TargetEntry = if ($HasCompatibleAsset) { + @{ + 'Contoso.Fixture/1.2.3' = @{ + type = 'package' + runtime = @{ 'lib/net8.0/Contoso.Fixture.dll' = @{} } + } + } + } else { + @{ + 'Contoso.Fixture/1.2.3' = @{ + type = 'package' + runtime = @{} + } + } + } + @{ version = 4; targets = @{ 'net8.0' = $TargetEntry } } | + ConvertTo-Json -Depth 15 | + Set-Content -LiteralPath $AssetsPath -Encoding utf8 + # NuGet lowercases both the package id folder and the version folder under the global cache. $RestoredLib = Join-Path $PackagesRoot 'contoso.fixture\1.2.3\lib' foreach ($Tfm in $AlignedLibFramework) { @@ -90,6 +111,7 @@ BeforeAll { PolicyPath = $PolicyPath LockPath = $LockPath PackagesRoot = $PackagesRoot + AssetsPath = $AssetsPath } } } @@ -165,7 +187,7 @@ Describe 'Test-DLLPickleTfmAlignment policy-driven inspection' -Tag 'Unit' { It 'reports an aggregate aligned result when every preload package is aligned' { $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net8.0', 'netstandard2.0') $OutputPath = Join-Path $TestDrive 'aligned-report.json' - $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -PackagesRoot $Fixture.PackagesRoot -OutputPath $OutputPath + $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath -OutputPath $OutputPath $Report.IsAligned | Should -BeTrue @($Report.Packages).Count | Should -Be 1 @@ -177,15 +199,22 @@ Describe 'Test-DLLPickleTfmAlignment policy-driven inspection' -Tag 'Unit' { It 'reports an aggregate misaligned result and names the offending package' { $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net48') - $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -PackagesRoot $Fixture.PackagesRoot + $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath $Report.IsAligned | Should -BeFalse - @($Report.Misaligned) | Should -Contain 'Contoso.Fixture' + @($Report.Misaligned) | Should -Contain 'net8.0/Contoso.Fixture' } It 'throws in strict mode when a preload package is misaligned' { $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net48') - { & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -PackagesRoot $Fixture.PackagesRoot -Strict } | + { & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath -Strict } | Should -Throw '*TFM*' } + + It 'uses NuGet project.assets.json as the policy-mode compatibility authority' { + $Source = Get-Content -LiteralPath $script:ToolPath -Raw + + $Source | Should -Match 'ProjectAssetsPath' + $Source | Should -Match ([regex]::Escape('$ProjectAssets.targets')) + } } diff --git a/tests/Unit/UpstreamInventoryProfile.Tests.ps1 b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 new file mode 100644 index 00000000..8ed2642f --- /dev/null +++ b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 @@ -0,0 +1,86 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:InventoryToolPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'tools/Get-DLLPickleUpstreamInventory.ps1' + $script:TestMatrixPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'build/powershell-test-matrix.json' + + function Get-UpstreamInventoryFixture { + $root = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('n')) + $moduleCache = Join-Path -Path $root -ChildPath 'modules' + $moduleVersionRoot = Join-Path -Path $moduleCache -ChildPath 'Contoso.ProfileProbe/1.0.0' + $null = New-Item -Path $moduleVersionRoot -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $moduleVersionRoot 'Contoso.ProfileProbe.psm1') -Value '# profile probe fixture' -Encoding utf8 + @' +@{ + RootModule = 'Contoso.ProfileProbe.psm1' + ModuleVersion = '1.0.0' + GUID = '471d52e5-d025-47f6-9706-f0f3b1bdb198' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') +} +'@ | Set-Content -LiteralPath (Join-Path $moduleVersionRoot 'Contoso.ProfileProbe.psd1') -Encoding utf8 + + $policyPath = Join-Path -Path $root -ChildPath 'policy.json' + [ordered]@{ + monitoredModules = @( + [ordered]@{ + name = 'Contoso.ProfileProbe' + umbrellaModule = 'Contoso.Umbrella' + repository = 'PSGallery' + purpose = 'Fixture' + deterministicProbeCommand = 'Get-Command Get-Item | Out-Null' + } + ) + trackedAssemblies = @('System.Management.Automation') + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $policyPath -Encoding utf8 + + [pscustomobject]@{ + PolicyPath = $policyPath + ModuleCachePath = $moduleCache + OutputPath = Join-Path -Path $root -ChildPath 'inventory.json' + } + } +} + +Describe 'Profile-aware upstream inventory' -Tag 'Unit' { + It 'records exact runtime identity and only actually selected tracked assets' { + $fixture = Get-UpstreamInventoryFixture + $parameters = @{ + PolicyPath = $fixture.PolicyPath + TestMatrixPath = $script:TestMatrixPath + ModuleCachePath = $fixture.ModuleCachePath + OutputPath = $fixture.OutputPath + PowerShellExecutable = [Environment]::ProcessPath + SkipDownload = $true + } + + $report = & $script:InventoryToolPath @parameters + + $report.SchemaVersion | Should -Be 2 + $report.ValidationTier | Should -Be 'DeterministicImportNoAuth' + $report.Profile.PowerShellVersion | Should -Be $PSVersionTable.PSVersion.ToString() + $report.Profile.DotNetMajor | Should -Be ([Environment]::Version.Major) + $report.Profile.TargetFramework | Should -Be ('net{0}.0' -f [Environment]::Version.Major) + $report.Profile.ExecutablePath | Should -Not -BeNullOrEmpty + $report.Profile.PSHome | Should -Not -BeNullOrEmpty + $report.Profile.Platform | Should -Not -BeNullOrEmpty + $report.Profile.Architecture | Should -Not -BeNullOrEmpty + + $module = $report.Modules[0] + $module.UmbrellaModule | Should -Be 'Contoso.Umbrella' + $module.ConstituentModule | Should -Be 'Contoso.ProfileProbe' + $module.ManifestPowerShellVersion | Should -Be '7.4' + @($module.CompatiblePSEditions) | Should -Contain 'Core' + $row = $module.TrackedAssemblies | Where-Object Name -eq 'System.Management.Automation' + $row.SelectedAssetPath | Should -Not -BeNullOrEmpty + $row.Sha256 | Should -Match '^[a-f0-9]{64}$' + $row.Alc | Should -Not -BeNullOrEmpty + $row.TargetFramework | Should -Be $report.Profile.TargetFramework + } + + It 'does not recursively mix every DLL asset in a saved module' { + $source = Get-Content -LiteralPath $script:InventoryToolPath -Raw + + $source | Should -Not -Match "Get-ChildItem[^\r\n]+-Filter '\*\.dll'[^\r\n]+-Recurse" + $source | Should -Match ([regex]::Escape('Get-DLLPickleRuntimeAssemblySnapshot.ps1')) + } +} diff --git a/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 new file mode 100644 index 00000000..7a501562 --- /dev/null +++ b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 @@ -0,0 +1,80 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:ToolPath = Join-Path $script:RepositoryRoot 'tools\New-DLLPickleUpstreamScenarioEvidence.ps1' +} + +Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { + It 'runs every configured order with and without DLLPickle under exact manifests' { + $ModuleCache = Join-Path $TestDrive 'modules' + $ModuleRows = @( + foreach ($Name in @('Synthetic.One', 'Synthetic.Two')) { + $ModuleRoot = Join-Path $ModuleCache "$Name\1.0.0" + $null = New-Item -Path $ModuleRoot -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $ModuleRoot "$Name.psm1") -Value "function Get-$($Name.Replace('.', '')) { 'ok' }" -Encoding UTF8 + $ManifestPath = Join-Path $ModuleRoot "$Name.psd1" + New-ModuleManifest -Path $ManifestPath -RootModule "$Name.psm1" -ModuleVersion '1.0.0' -FunctionsToExport @("Get-$($Name.Replace('.', ''))") + [PSCustomObject]@{ Name = $Name; ModuleManifestPath = $ManifestPath } + } + ) + $DllPickleRoot = Join-Path $TestDrive 'dllpickle' + $null = New-Item -Path $DllPickleRoot -ItemType Directory + Set-Content -LiteralPath (Join-Path $DllPickleRoot 'DLLPickle.psm1') -Value 'function Import-DPLibrary { param([switch]$SuppressLogo) }' -Encoding UTF8 + $DllPickleManifest = Join-Path $DllPickleRoot 'DLLPickle.psd1' + New-ModuleManifest -Path $DllPickleManifest -RootModule 'DLLPickle.psm1' -ModuleVersion '1.0.0' -FunctionsToExport @('Import-DPLibrary') + + $PowerShellLine = '{0}.{1}' -f $PSVersionTable.PSVersion.Major, $PSVersionTable.PSVersion.Minor + $TargetFramework = 'net{0}.0' -f [Environment]::Version.Major + $ProfileKey = "ps$PowerShellLine-$TargetFramework-windows-x64" + $PolicyPath = Join-Path $TestDrive 'policy.json' + @{ + trackedAssemblies = @('System.Management.Automation') + monitoredModules = @( + @{ name = 'Synthetic.One'; deterministicProbeCommand = 'Get-Command Get-SyntheticOne | Out-Null' } + @{ name = 'Synthetic.Two'; deterministicProbeCommand = 'Get-Command Get-SyntheticTwo | Out-Null' } + ) + runtimeProfiles = @( + @{ + powerShellLine = $PowerShellLine + targetFramework = $TargetFramework + importOrders = @( + , @('Synthetic.One', 'Synthetic.Two') + , @('Synthetic.Two', 'Synthetic.One') + ) + } + ) + } | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + $InventoryPath = Join-Path $TestDrive 'inventory.json' + @{ + ProfileKey = $ProfileKey + ModuleCachePath = $ModuleCache + Profile = @{ + PowerShellVersion = $PSVersionTable.PSVersion.ToString() + PowerShellLine = $PowerShellLine + TargetFramework = $TargetFramework + PSHome = $PSHOME + Platform = 'windows' + Architecture = 'x64' + } + Modules = @($ModuleRows) + } | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $InventoryPath -Encoding UTF8 + + $Parameters = @{ + PolicyPath = $PolicyPath + InventoryPath = $InventoryPath + PowerShellExecutable = [Environment]::ProcessPath + DLLPickleManifestPath = $DllPickleManifest + OutputPath = Join-Path $TestDrive 'scenario-evidence.json' + Strict = $true + } + $First = & $script:ToolPath @Parameters + $Parameters.OutputPath = Join-Path $TestDrive 'scenario-evidence-second.json' + $Second = & $script:ToolPath @Parameters + + @($First.Scenarios) | Should -HaveCount 4 + @($First.Scenarios | Where-Object DllPicklePreloaded) | Should -HaveCount 2 + @($First.Scenarios | Where-Object { -not $_.DllPicklePreloaded }) | Should -HaveCount 2 + $First.Passed | Should -BeTrue + $First.WritesPerformed | Should -BeFalse + $First.ScenarioFingerprint | Should -BeExactly $Second.ScenarioFingerprint + } +} diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index 16db7e87..9f20dcce 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -2,7 +2,10 @@ BeforeAll { $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $UpstreamWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Upstream-Compatibility.yml') -Raw $DependabotWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Dependabot-Auto-Approve.yml') -Raw + $DependabotConfig = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\dependabot.yml') -Raw $ReleaseWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Release-and-Publish.yml') -Raw + $LifecycleWorkflowPath = Join-Path $ProjectRoot '.github\workflows\PowerShell-Support-Lifecycle.yml' + $BuildWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Build Module.yml') -Raw } Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { @@ -11,7 +14,7 @@ Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { } It 'exposes an always-reported aggregate required check' { - $UpstreamWorkflow | Should -Match '(?ms)^ pr-gate:\s+name: Validate upstream compatibility tooling\s+needs: \[pr-changes, pr-smoke-validation\]\s+if: \$\{\{ always\(\) \}\}' + $UpstreamWorkflow | Should -Match '(?ms)^ pr-gate:\s+name: Validate upstream compatibility tooling\s+needs: \[pr-changes, pr-smoke-validation, profile-evidence-gate\]\s+if: \$\{\{ always\(\) \}\}' } It 'routes policy and fingerprint-generator changes through live validation' { @@ -33,6 +36,22 @@ Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleTfmAlignment.ps1')) $UpstreamWorkflow | Should -Match ([regex]::Escape('tfm-alignment.json')) } + + It 'publishes profile-aware findings once per stable fingerprint' { + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleProfileEvidenceSummary.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleFindingFingerprintReported.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('$Summary.FindingMarker')) + $UpstreamWorkflow | Should -Match 'issues: write' + $UpstreamWorkflow | Should -Match ([regex]::Escape('suppressing a duplicate comment')) + } + + It 'uses exact-profile baselines and fingerprint-derived candidate branches for scheduled writes' { + $UpstreamWorkflow | Should -Match ([regex]::Escape('$ProfilePolicy[0].baselines.windows')) + $UpstreamWorkflow | Should -Not -Match ([regex]::Escape('$policy.baseline.conflictSurfaceFingerprint')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('automation/upstream-compatibility-$($Fingerprint.Substring(0, 16))')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('publication_fingerprint=')) + $UpstreamWorkflow | Should -Not -Match ([regex]::Escape('automation/upstream-compatibility-${{ github.run_id }}')) + } } Describe 'Dependabot auto-merge guardrails' -Tag 'Unit' { @@ -46,6 +65,17 @@ Describe 'Dependabot auto-merge guardrails' -Tag 'Unit' { $DependabotWorkflow | Should -Match ([regex]::Escape('Validate upstream compatibility tooling')) $DependabotWorkflow | Should -Match ([regex]::Escape('dependency-review')) } + + It 'routes a newly introduced conditional TFM pin to maintainer review' { + $DependabotWorkflow | Should -Match ([regex]::Escape('CONDITIONAL_TFM_ADDITION')) + $DependabotWorkflow | Should -Match 'PackageReference.*Condition=.*TargetFramework' + } + + It 'keeps runtime NuGet updates separate from CI toolchain updates' { + $DependabotConfig | Should -Match 'runtime-bundle-minor-patch' + $DependabotConfig | Should -Match 'ci-tooling-actions' + $DependabotConfig | Should -Match 'multi-pwsh.*CI provisioning policy' + } } Describe 'Dependabot major-version draft-PR flow' -Tag 'Unit' { @@ -57,11 +87,14 @@ Describe 'Dependabot major-version draft-PR flow' -Tag 'Unit' { $DependabotWorkflow | Should -Match ([regex]::Escape("update-type == 'version-update:semver-major'")) } - It 'posts structured notes covering the version delta, TFM alignment, conflict surface, and a maintainer checklist' { + It 'posts a per-TFM evidence index covering graph, assets, assembly and conflict deltas, size, scenarios, and maintainer review' { $DependabotWorkflow | Should -Match 'Version change' - $DependabotWorkflow | Should -Match 'TFM alignment' - $DependabotWorkflow | Should -Match ([regex]::Escape('Test-DLLPickleTfmAlignment.ps1')) - $DependabotWorkflow | Should -Match ([regex]::Escape('dependency-policy.json')) + $DependabotWorkflow | Should -Match 'Resolved graph \+ selected assets' + $DependabotWorkflow | Should -Match 'Added/removed/changed assemblies' + $DependabotWorkflow | Should -Match 'Conflict-surface delta' + $DependabotWorkflow | Should -Match 'Scenario outcomes' + $DependabotWorkflow | Should -Match ([regex]::Escape('artifact-size-baseline.json')) + $DependabotWorkflow | Should -Match ([regex]::Escape('Compatibility-Evidence.md')) $DependabotWorkflow | Should -Match 'Maintainer checklist' } @@ -73,6 +106,33 @@ Describe 'Dependabot major-version draft-PR flow' -Tag 'Unit' { } Describe 'Release publish gating guardrails' -Tag 'Unit' { + It 'fails closed on the runtime lifecycle policy before version analysis' { + $ReleaseWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleRuntimeProfilePolicy.ps1')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('-Mode Release')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('tools/Get-DLLPicklePowerShellSupportUpdate.ps1')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('-RequireCurrent')) + } + + It 'runs profile-aware evidence and fail-closed baselines across the exact runtime matrix' { + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPicklePowerShellTestMatrix.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Install-DLLPickleTestPowerShell.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleProfileConflictBaseline.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleUpstreamScenarioEvidence.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('ScenarioEvidencePath')) + $UpstreamWorkflow | Should -Match 'executed-two-orders-with-and-without-dllpickle' + $UpstreamWorkflow | Should -Match ([regex]::Escape('-PowerShellExecutable')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('fromJson(needs.profile-matrix.outputs.matrix)')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('not-run-no-approved-credentials')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('writesPerformed = $false')) + } + + It 'revalidates composition and size after stamping the release artifact' { + $ReleaseWorkflow | Should -Match ([regex]::Escape('Test-DLLPicklePackageArtifact.ps1')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('New-DLLPickleArtifactSizeReport.ps1')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('SkipBuildOutputComparison')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('stamped-release-policy-reports')) + } + It 'auto-triggers only on closed pull requests to main' { $ReleaseWorkflow | Should -Match '(?ms)on:\s+pull_request:\s+types:\s*\[closed\]' $ReleaseWorkflow | Should -Match '(?ms)branches:\s+- main' @@ -124,3 +184,86 @@ Describe 'Release publish gating guardrails' -Tag 'Unit' { $ReleaseWorkflow | Should -Match '(?ms)options:\s+- auto\s+- major\s+- minor\s+- patch' } } + +Describe 'PowerShell support lifecycle workflow guardrails' -Tag 'Unit' { + It 'runs a scheduled and manually dispatchable lifecycle check' { + $LifecycleWorkflowPath | Should -Exist + $lifecycleWorkflow = Get-Content -LiteralPath $LifecycleWorkflowPath -Raw + + $lifecycleWorkflow | Should -Match '(?m)^\s+schedule:' + $lifecycleWorkflow | Should -Match '(?m)^\s+workflow_dispatch:' + $lifecycleWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleRuntimeProfilePolicy.ps1')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('-Mode Scheduled')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('tools/Get-DLLPicklePowerShellSupportUpdate.ps1')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('powershell-support-update.json')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('tools/Update-DLLPicklePowerShellTestMatrix.ps1')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('tools/Install-DLLPickleTestPowerShell.ps1')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('fromJson(needs.discover.outputs.runtime_matrix)')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('PatchProposalFingerprint')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('SupportContractFingerprint')) + } + + It 'keeps discovery read-only and scopes repository writes to fingerprinted publication jobs' { + $lifecycleWorkflow = Get-Content -LiteralPath $LifecycleWorkflowPath -Raw + + $lifecycleWorkflow | Should -Match '(?ms)^permissions:\s+contents: read\s*$' + $lifecycleWorkflow | Should -Match '(?ms)^ finalize-patch-proposal:.*?permissions:\s+contents: write\s+pull-requests: write' + $lifecycleWorkflow | Should -Match '(?ms)^ support-contract-warning:.*?permissions:\s+contents: read\s+issues: write' + $lifecycleWorkflow | Should -Match ([regex]::Escape('automation/powershell-patch-$($Fingerprint.Substring(0, 16))')) + $lifecycleWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleFindingFingerprintReported.ps1')) + $lifecycleWorkflow | Should -Match 'gh\s+pr\s+create|@\(''pr'', ''create''' + $lifecycleWorkflow | Should -Match 'gh\s+issue\s+(create|comment)' + $lifecycleWorkflow | Should -Not -Match 'gh\s+pr\s+merge|git\s+push\s+--force' + } + + It 'treats runtime policy and SDK changes as build relevant' { + $BuildWorkflow | Should -Match ([regex]::Escape("'^global\.json$'")) + $BuildWorkflow | Should -Match ([regex]::Escape("'^build/powershell-test-matrix\.json$'")) + } +} + +Describe 'Exact PowerShell runtime matrix workflow guardrails' -Tag 'Unit' { + It 'generates the authoritative matrix from policy rather than a handwritten version list' { + $BuildWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPicklePowerShellTestMatrix.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('fromJson(needs.runtime-matrix.outputs.matrix)')) + } + + It 'provisions and directly invokes each exact stock executable in fresh processes' { + $BuildWorkflow | Should -Match ([regex]::Escape('tools/Install-DLLPickleTestPowerShell.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('$env:DLLPICKLE_TEST_PWSH -NoLogo -NoProfile -NonInteractive')) + $BuildWorkflow | Should -Not -Match 'multi-pwsh\s+host|pwsh-7\.' + } + + It 'captures structured selected-bundle and assembly load-context evidence' { + $BuildWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleRuntimeProfileEvidence.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('runtime-evidence-')) + $BuildWorkflow | Should -Match 'upload-artifact@' + } + + It 'runs strict package composition and resolved-TFM checks in every exact-runtime cell' { + $RuntimeJob = [regex]::Match($BuildWorkflow, '(?ms)^ runtime-tests:.*?(?=^ dependency-change-report:)').Value + $RuntimeJob | Should -Match ([regex]::Escape('tools/Test-DLLPicklePackageArtifact.ps1 -Strict')) + $RuntimeJob | Should -Match ([regex]::Escape('tools/Test-DLLPickleTfmAlignment.ps1 -Strict')) + } + + It 'keeps Build gate stable and aggregates both hosted and exact-runtime jobs' { + $BuildWorkflow | Should -Match '(?m)^\s+name: Build gate\s*$' + $BuildWorkflow | Should -Match 'needs: \[build, runtime-tests, dependency-change-report\]' + $BuildWorkflow | Should -Match ([regex]::Escape('runtimeTests =')) + $BuildWorkflow | Should -Match ([regex]::Escape('needs.runtime-tests.result')) + $BuildWorkflow | Should -Match ([regex]::Escape('needs.dependency-change-report.result')) + } + + It 'enforces artifact composition and material size growth in the hosted build gate' { + $BuildWorkflow | Should -Match ([regex]::Escape('Test-DLLPicklePackageArtifact.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('New-DLLPickleArtifactSizeReport.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('package-policy-reports')) + } + + It 'attaches a base-versus-candidate per-TFM report for Dependabot changes' { + $BuildWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleDependencyChangeReport.ps1')) + $BuildWorkflow | Should -Match ([regex]::Escape('dependency-change-report.json')) + $BuildWorkflow | Should -Match ([regex]::Escape('BaselineProjectAssetsPath')) + $BuildWorkflow | Should -Match ([regex]::Escape('ScenarioEvidencePath')) + } +} diff --git a/tools/Compare-DLLPickleConflictMatrix.ps1 b/tools/Compare-DLLPickleConflictMatrix.ps1 index 04529a21..0fd64406 100644 --- a/tools/Compare-DLLPickleConflictMatrix.ps1 +++ b/tools/Compare-DLLPickleConflictMatrix.ps1 @@ -20,6 +20,16 @@ param( $ErrorActionPreference = 'Stop' +$BaselineProfileKey = if ($Baseline.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Baseline.ProfileKey } else { $null } +$CurrentProfileKey = if ($Current.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Current.ProfileKey } else { $null } +if ( + -not [string]::IsNullOrWhiteSpace($BaselineProfileKey) -and + -not [string]::IsNullOrWhiteSpace($CurrentProfileKey) -and + $BaselineProfileKey -ne $CurrentProfileKey +) { + throw "Cannot compare conflict matrices from different runtime profiles: '$BaselineProfileKey' and '$CurrentProfileKey'." +} + function Test-DLLPickleStringSetEqual { [CmdletBinding()] param( @@ -97,7 +107,20 @@ $Findings = [PSCustomObject]@{ AlcOwnershipChanges = $AlcChanges } +$FindingCanonicalText = @( + "profile=$CurrentProfileKey" + "new=$(@($NewConflicts | Sort-Object) -join ',')" + "removed=$(@($RemovedConflicts | Sort-Object) -join ',')" + "versions=$(@($VersionChanges | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" + "contributors=$(@($ContributorChanges | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" + "alc=$(@($AlcChanges | Sort-Object) -join ',')" +) -join '|' +$FindingFingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($FindingCanonicalText) +$FindingFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FindingFingerprintBytes)).Replace('-', '').ToLowerInvariant() + [PSCustomObject]@{ + ProfileKey = $CurrentProfileKey + FindingFingerprint = $FindingFingerprint HasMaterialDrift = ( $NewConflicts.Count -gt 0 -or $RemovedConflicts.Count -gt 0 -or diff --git a/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 index 68f0bc37..463560e2 100644 --- a/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 +++ b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 @@ -17,7 +17,7 @@ .PARAMETER NameLike Optional wildcard patterns; when supplied, an assembly must ALSO match one of them to be returned. .OUTPUTS - PSCustomObject[] with Name, Version, Alc, Path. Sorted by Name. + PSCustomObject[] with Name, Version, ALC, path, hash, OS, and architecture. Sorted by Name. #> [CmdletBinding()] param( @@ -48,11 +48,21 @@ $TrackedNames = @((Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json) } | ForEach-Object { $Alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) + $AssemblyPath = $_.Location [PSCustomObject]@{ - Name = $_.GetName().Name - Version = $_.GetName().Version.ToString() - Alc = if ($Alc -and $Alc.Name) { $Alc.Name } else { 'Default' } - Path = $_.Location + Name = $_.GetName().Name + Version = $_.GetName().Version.ToString() + FullName = $_.FullName + Alc = if ($Alc -and $Alc.Name) { $Alc.Name } else { 'Default' } + IsCollectible = if ($Alc) { $Alc.IsCollectible } else { $false } + Path = $AssemblyPath + Sha256 = if (-not [string]::IsNullOrWhiteSpace($AssemblyPath) -and (Test-Path -LiteralPath $AssemblyPath -PathType Leaf)) { + (Get-FileHash -LiteralPath $AssemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + $null + } + OS = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() } } | Sort-Object Name diff --git a/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 new file mode 100644 index 00000000..dc103e22 --- /dev/null +++ b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 @@ -0,0 +1,302 @@ +<# +.SYNOPSIS + Discovers PowerShell servicing and support-contract update candidates. + +.DESCRIPTION + Compares the canonical exact test matrix with stable releases from the official + PowerShell GitHub repository. Patch candidates include the required platform + archives and GitHub-published SHA-256 digests when available. New GA minor lines, + approaching retirement, and expired lines are reported separately. The tool is + read-only; publishing a proposal is a distinct workflow action. Stable patch and + support-contract fingerprints allow that workflow to suppress duplicate PRs, + issues, and comments without using timestamps as publication identities. + +.PARAMETER ReleaseDataPath + Optional JSON fixture or captured GitHub releases response. When omitted, query + the official PowerShell/PowerShell releases API. + +.PARAMETER LifecycleDataPath + Optional captured Microsoft Lifecycle HTML fixture. When omitted, query the + official Microsoft PowerShell lifecycle page. + +.PARAMETER RequireCurrent + Throw if a newer patch, new GA line, expired line, or incomplete checksum set is found. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + + [Parameter()] + [string]$ReleaseDataPath, + + [Parameter()] + [string]$LifecycleDataPath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\lifecycle\powershell-support-update.json'), + + [Parameter()] + [datetime]$AsOfUtc = [datetime]::UtcNow, + + [Parameter()] + [switch]$RequireCurrent +) + +$ErrorActionPreference = 'Stop' + +function Get-DLLPickleStableFingerprint { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowEmptyCollection()] + [string[]]$CanonicalLine = @() + ) + + $CanonicalText = @($CanonicalLine | Sort-Object) -join [char]10 + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalText) + return [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() +} + +if (-not (Test-Path -LiteralPath $TestMatrixPath -PathType Leaf)) { + throw "PowerShell test matrix was not found: $TestMatrixPath" +} + +$TestMatrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$ReleaseData = if (-not [string]::IsNullOrWhiteSpace($ReleaseDataPath)) { + if (-not (Test-Path -LiteralPath $ReleaseDataPath -PathType Leaf)) { + throw "PowerShell release data was not found: $ReleaseDataPath" + } + @(Get-Content -LiteralPath $ReleaseDataPath -Raw | ConvertFrom-Json -ErrorAction Stop) +} else { + $Headers = @{ + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + 'User-Agent' = 'DLLPickle-support-policy' + } + @(Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/PowerShell/releases?per_page=100' -Headers $Headers -ErrorAction Stop) +} + +$StableReleases = @( + foreach ($Release in $ReleaseData) { + $TagMatch = [regex]::Match([string]$Release.tag_name, '^v(?7\.\d+\.\d+)$') + if (-not $Release.draft -and -not $Release.prerelease -and $TagMatch.Success) { + $ReleaseVersion = [version]$TagMatch.Groups['version'].Value + $PublishedAt = if ($Release.published_at) { [datetime]$Release.published_at } else { [datetime]::MinValue } + if ($PublishedAt -le $AsOfUtc.ToUniversalTime()) { + [PSCustomObject]@{ + Version = $ReleaseVersion + ReleaseLine = '{0}.{1}' -f $ReleaseVersion.Major, $ReleaseVersion.Minor + PublishedAt = $PublishedAt.ToUniversalTime() + HtmlUrl = [string]$Release.html_url + Assets = @($Release.assets) + } + } + } + } +) +if ($StableReleases.Count -eq 0) { + throw 'No stable PowerShell 7 release records were found in the official release data.' +} + +$LifecycleContent = if (-not [string]::IsNullOrWhiteSpace($LifecycleDataPath)) { + if (-not (Test-Path -LiteralPath $LifecycleDataPath -PathType Leaf)) { + throw "PowerShell lifecycle data was not found: $LifecycleDataPath" + } + Get-Content -LiteralPath $LifecycleDataPath -Raw +} else { + (Invoke-WebRequest -Uri ([string]$TestMatrix.lifecycleSourceUrl) -UseBasicParsing -ErrorAction Stop).Content +} +$PacificTimeZone = try { + [System.TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles') +} catch { + [System.TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time') +} +$LiveLifecycleRows = @( + foreach ($TableRow in [regex]::Matches($LifecycleContent, '(?is)]*>(?.*?)')) { + $VersionMatch = [regex]::Match($TableRow.Groups['content'].Value, '(?is)]*>\s*PowerShell\s+(?7\.\d+)(?:\s*\(LTS\))?\s*') + $DateMatches = [regex]::Matches($TableRow.Groups['content'].Value, '(?is)]*\sdatetime="(?[^"]+)"') + if (-not $VersionMatch.Success -or $DateMatches.Count -ne 2) { + continue + } + $StartUtc = [datetime]::SpecifyKind([datetime]::Parse($DateMatches[0].Groups['date'].Value, [System.Globalization.CultureInfo]::InvariantCulture), [System.DateTimeKind]::Utc) + $EndUtc = [datetime]::SpecifyKind([datetime]::Parse($DateMatches[1].Groups['date'].Value, [System.Globalization.CultureInfo]::InvariantCulture), [System.DateTimeKind]::Utc) + [PSCustomObject]@{ + ReleaseLine = $VersionMatch.Groups['line'].Value + StartDate = [System.TimeZoneInfo]::ConvertTimeFromUtc($StartUtc, $PacificTimeZone).ToString('yyyy-MM-dd') + EndDate = [System.TimeZoneInfo]::ConvertTimeFromUtc($EndUtc, $PacificTimeZone).ToString('yyyy-MM-dd') + } + } +) +if ($LiveLifecycleRows.Count -eq 0) { + throw 'No PowerShell 7 release rows were parsed from the official Microsoft lifecycle data.' +} +$DuplicateLifecycleLines = @($LiveLifecycleRows | Group-Object ReleaseLine | Where-Object Count -NE 1) +if ($DuplicateLifecycleLines.Count -gt 0) { + throw "Microsoft lifecycle data contains duplicate PowerShell lines: $($DuplicateLifecycleLines.Name -join ', ')." +} +$AsOfPacificDate = [System.TimeZoneInfo]::ConvertTimeFromUtc($AsOfUtc.ToUniversalTime(), $PacificTimeZone).Date +$SupportedLifecycleLines = @($LiveLifecycleRows | Where-Object { [datetime]$_.EndDate -ge $AsOfPacificDate } | ForEach-Object ReleaseLine) + +$DeclaredLines = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}' -f $_.powerShellMajor, $_.powerShellMinor }) +$PatchUpdates = @( + foreach ($MatrixProfile in @($TestMatrix.profiles)) { + $ReleaseLine = '{0}.{1}' -f $MatrixProfile.powerShellMajor, $MatrixProfile.powerShellMinor + $NewestRelease = $StableReleases | Where-Object ReleaseLine -EQ $ReleaseLine | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $NewestRelease -or $NewestRelease.Version -le [version]$MatrixProfile.powerShellVersion) { + continue + } + + $ArchiveCandidates = @( + foreach ($CurrentArchive in @($TestMatrix.archiveAssets | Where-Object powerShellVersion -EQ $MatrixProfile.powerShellVersion)) { + $ExpectedFileName = ([string]$CurrentArchive.fileName).Replace([string]$MatrixProfile.powerShellVersion, $NewestRelease.Version.ToString()) + $ReleaseAsset = @($NewestRelease.Assets | Where-Object name -EQ $ExpectedFileName | Select-Object -First 1)[0] + $Digest = if ($ReleaseAsset -and [string]$ReleaseAsset.digest -match '^sha256:(?[a-fA-F0-9]{64})$') { + $Matches['hash'].ToLowerInvariant() + } else { + $null + } + [PSCustomObject]@{ + Platform = [string]$CurrentArchive.platform + Architecture = [string]$CurrentArchive.architecture + FileName = $ExpectedFileName + DownloadUrl = if ($ReleaseAsset) { [string]$ReleaseAsset.browser_download_url } else { $null } + Sha256 = $Digest + Complete = $null -ne $ReleaseAsset -and -not [string]::IsNullOrWhiteSpace($Digest) + } + } + ) + [PSCustomObject]@{ + ReleaseLine = $ReleaseLine + CurrentVersion = [string]$MatrixProfile.powerShellVersion + CandidateVersion = $NewestRelease.Version.ToString() + ReleaseUrl = $NewestRelease.HtmlUrl + Archives = @($ArchiveCandidates) + ChecksumsComplete = @($ArchiveCandidates | Where-Object { -not $_.Complete }).Count -eq 0 + } + } +) + +$MaximumDeclaredMinor = @($TestMatrix.profiles.powerShellMinor | Measure-Object -Maximum)[0].Maximum +$NewLines = @( + $StableReleases | + Where-Object { $_.Version.Minor -gt $MaximumDeclaredMinor -and $_.ReleaseLine -notin $DeclaredLines -and $_.ReleaseLine -in $SupportedLifecycleLines } | + Group-Object ReleaseLine | + ForEach-Object { $_.Group | Sort-Object Version -Descending | Select-Object -First 1 } | + Sort-Object Version | + ForEach-Object { + [PSCustomObject]@{ + ReleaseLine = $_.ReleaseLine + LatestVersion = $_.Version.ToString() + PublishedAtUtc = $_.PublishedAt.ToString('o') + ReleaseUrl = $_.HtmlUrl + LifecycleEndDate = [string]@($LiveLifecycleRows | Where-Object ReleaseLine -EQ $_.ReleaseLine)[0].EndDate + ProposedMapping = [PSCustomObject]@{ + DotNetMajor = $null + TargetFramework = $null + Status = 'pending checksum-verified runtime identity and maintainer support-contract review' + } + PackageSizeEstimate = 'not-run-pending-reviewed-mapping' + BuildResults = 'not-run-pending-reviewed-mapping' + InitialUpstreamConflictEvidence = 'not-run-pending-reviewed-mapping' + RequiredDecision = 'Map PowerShell line to CLR/TFM, estimate artifact size, and collect initial profile-aware conflict evidence.' + } + } +) + +$LifecycleRows = @( + foreach ($MatrixProfile in @($TestMatrix.profiles)) { + $ReleaseLine = '{0}.{1}' -f $MatrixProfile.powerShellMajor, $MatrixProfile.powerShellMinor + $LiveLifecycle = @($LiveLifecycleRows | Where-Object ReleaseLine -EQ $ReleaseLine) + if ($LiveLifecycle.Count -ne 1) { + continue + } + $EndDate = [datetime]::ParseExact([string]$LiveLifecycle[0].EndDate, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) + $DaysRemaining = [math]::Floor(($EndDate.Date.AddDays(1) - $AsOfPacificDate).TotalDays) + [PSCustomObject]@{ + ReleaseLine = $ReleaseLine + LifecycleEnd = [string]$LiveLifecycle[0].EndDate + MatrixLifecycleEnd = [string]$MatrixProfile.lifecycleEndDate + DaysRemaining = $DaysRemaining + Status = if ($DaysRemaining -lt 0) { 'Expired' } elseif ($DaysRemaining -le [int]$TestMatrix.retirementWarningDays) { 'RetiringSoon' } else { 'Supported' } + } + } +) + +$IncompletePatchUpdates = @($PatchUpdates | Where-Object { -not $_.ChecksumsComplete }) +$LifecycleMissingLines = @($DeclaredLines | Where-Object { $_ -notin $LiveLifecycleRows.ReleaseLine }) +$LifecycleDateChanges = @($LifecycleRows | Where-Object { $_.LifecycleEnd -ne $_.MatrixLifecycleEnd }) +$UndeclaredSupportedLines = @($SupportedLifecycleLines | Where-Object { $_ -notin $DeclaredLines }) +$PatchCanonicalLines = @( + foreach ($PatchUpdate in @($PatchUpdates | Sort-Object ReleaseLine)) { + 'patch|{0}|{1}|{2}' -f $PatchUpdate.ReleaseLine, $PatchUpdate.CurrentVersion, $PatchUpdate.CandidateVersion + foreach ($Archive in @($PatchUpdate.Archives | Sort-Object Platform, Architecture)) { + 'archive|{0}|{1}|{2}|{3}|{4}' -f $PatchUpdate.CandidateVersion, $Archive.Platform, $Archive.Architecture, $Archive.FileName, $Archive.Sha256 + } + } +) +$SupportContractCanonicalLines = @( + foreach ($NewLine in @($NewLines | Sort-Object ReleaseLine)) { + 'new-line|{0}|{1}|{2}' -f $NewLine.ReleaseLine, $NewLine.LatestVersion, $NewLine.LifecycleEndDate + } + foreach ($LifecycleChange in @($LifecycleDateChanges | Sort-Object ReleaseLine)) { + 'lifecycle-date|{0}|{1}|{2}' -f $LifecycleChange.ReleaseLine, $LifecycleChange.MatrixLifecycleEnd, $LifecycleChange.LifecycleEnd + } + foreach ($MissingLine in @($LifecycleMissingLines | Sort-Object)) { + 'lifecycle-missing|{0}' -f $MissingLine + } + foreach ($UndeclaredLine in @($UndeclaredSupportedLines | Sort-Object)) { + 'undeclared-supported|{0}' -f $UndeclaredLine + } + foreach ($LifecycleState in @($LifecycleRows | Where-Object Status -IN @('RetiringSoon', 'Expired') | Sort-Object ReleaseLine)) { + 'lifecycle-state|{0}|{1}|{2}' -f $LifecycleState.ReleaseLine, $LifecycleState.Status, $LifecycleState.LifecycleEnd + } +) +$PatchProposalFingerprint = Get-DLLPickleStableFingerprint -CanonicalLine $PatchCanonicalLines +$SupportContractFingerprint = Get-DLLPickleStableFingerprint -CanonicalLine $SupportContractCanonicalLines +$Report = [PSCustomObject]@{ + SchemaVersion = 1 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + ReleaseSource = 'https://api.github.com/repos/PowerShell/PowerShell/releases' + LifecycleSource = [string]$TestMatrix.lifecycleSourceUrl + PatchUpdates = @($PatchUpdates) + NewLines = @($NewLines) + Lifecycle = @($LifecycleRows) + LifecycleDateChanges = @($LifecycleDateChanges) + LifecycleMissingLines = @($LifecycleMissingLines) + UndeclaredSupportedLines = @($UndeclaredSupportedLines) + PatchProposalFingerprint = $PatchProposalFingerprint + PatchProposalMarker = '' -f $PatchProposalFingerprint + SupportContractFingerprint = $SupportContractFingerprint + SupportContractMarker = '' -f $SupportContractFingerprint + MatrixOnlyUpdateAvailable = $PatchUpdates.Count -gt 0 -and $NewLines.Count -eq 0 -and $IncompletePatchUpdates.Count -eq 0 + SupportContractReviewRequired = $NewLines.Count -gt 0 -or $UndeclaredSupportedLines.Count -gt 0 -or $LifecycleDateChanges.Count -gt 0 -or $LifecycleMissingLines.Count -gt 0 -or @($LifecycleRows | Where-Object Status -IN @('RetiringSoon', 'Expired')).Count -gt 0 + ProposalPublishingStatus = 'pending-workflow-publication' +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Report | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +if ($RequireCurrent.IsPresent) { + $Violations = [System.Collections.Generic.List[string]]::new() + if ($PatchUpdates.Count -gt 0) { $Violations.Add("newer servicing patches: $($PatchUpdates.CandidateVersion -join ', ')") } + if ($NewLines.Count -gt 0) { $Violations.Add("new GA PowerShell lines: $($NewLines.ReleaseLine -join ', ')") } + if ($UndeclaredSupportedLines.Count -gt 0) { $Violations.Add("undeclared Microsoft-supported lines: $($UndeclaredSupportedLines -join ', ')") } + if ($LifecycleDateChanges.Count -gt 0) { $Violations.Add("lifecycle date changes: $($LifecycleDateChanges.ReleaseLine -join ', ')") } + if ($LifecycleMissingLines.Count -gt 0) { $Violations.Add("declared lines missing from lifecycle data: $($LifecycleMissingLines -join ', ')") } + $ExpiredLines = @($LifecycleRows | Where-Object Status -EQ 'Expired') + if ($ExpiredLines.Count -gt 0) { $Violations.Add("expired lines: $($ExpiredLines.ReleaseLine -join ', ')") } + if ($IncompletePatchUpdates.Count -gt 0) { $Violations.Add('candidate patch assets lack a complete official checksum set') } + if ($Violations.Count -gt 0) { + throw "PowerShell support matrix is not release-current: $($Violations -join '; ')." + } +} + +$Report diff --git a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 index 61e0a699..adadfb62 100644 --- a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 +++ b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 @@ -19,6 +19,17 @@ .PARAMETER Strict Fails when a requested module cannot be imported or the probe command throws. Use this mode when collecting adjudication evidence so a partial snapshot cannot be mistaken for a successful probe. +.PARAMETER PowerShellExecutable + Exact stock pwsh/pwsh.exe to launch. Defaults to the current process executable. +.PARAMETER PowerShellVersion + Optional exact servicing patch expected from the child process. +.PARAMETER TargetFramework + Expected TFM for the child CLR. The probe fails if it does not match. +.PARAMETER ModuleManifestPath + Optional exact manifest path for each ModuleName, in the same order. This prevents + a user- or machine-wide module of the same name from satisfying the evidence run. +.PARAMETER ModuleSearchPath + Optional isolated module roots assigned inside the fresh child process before import. .OUTPUTS PSCustomObject[] one row per loaded tracked assembly: Name, Version, Alc, Path. #> @@ -36,11 +47,43 @@ param( [Parameter()] [string]$PolicyPath, + [Parameter()] + [string]$PowerShellExecutable = [Environment]::ProcessPath, + + [Parameter()] + [version]$PowerShellVersion, + + [Parameter()] + [ValidatePattern('^net\d+\.0$')] + [string]$TargetFramework, + + [Parameter()] + [string[]]$ModuleManifestPath, + + [Parameter()] + [string[]]$ModuleSearchPath, + [Parameter()] [switch]$Strict ) $ErrorActionPreference = 'Stop' +$ResolvedModuleManifestPaths = @($ModuleManifestPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if ($ResolvedModuleManifestPaths.Count -gt 0 -and $ResolvedModuleManifestPaths.Count -ne $ModuleName.Count) { + throw 'ModuleManifestPath must contain one exact path for every ModuleName.' +} +foreach ($ManifestPath in $ResolvedModuleManifestPaths) { + if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + throw "Module manifest was not found: $ManifestPath" + } +} + +$ExecutableCommand = Get-Command -Name $PowerShellExecutable -ErrorAction Stop +$ResolvedPowerShellExecutable = if ($ExecutableCommand.CommandType -eq 'Application') { + $ExecutableCommand.Source +} else { + throw "PowerShellExecutable must resolve to an application, not $($ExecutableCommand.CommandType): $PowerShellExecutable" +} $HelperScript = Join-Path -Path $PSScriptRoot -ChildPath 'Get-DLLPickleLoadedTrackedAssembly.ps1' if (-not $PolicyPath) { @@ -48,9 +91,24 @@ if (-not $PolicyPath) { } $ChildScript = @' -param($ModuleNames, $PreloadManifest, $ProbeCommand, $HelperScript, $PolicyPath, [switch]$StrictMode) +param($ModuleNames, $ModuleManifestPathsEncoded, $IsolatedModulePath, $PreloadManifest, $ProbeCommand, $HelperScript, $PolicyPath, $ExpectedPowerShellVersion, $ExpectedTargetFramework, [switch]$StrictMode) $ModuleNames = $ModuleNames -split ',' +$ModuleManifestPaths = if ($ModuleManifestPathsEncoded) { + $ManifestJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($ModuleManifestPathsEncoded)) + @($ManifestJson | ConvertFrom-Json) +} else { + @() +} +$ModuleManifestPaths = @($ModuleManifestPaths) +$env:PSModulePath = if ($IsolatedModulePath) { $IsolatedModulePath } else { $env:PSModulePath } $ErrorActionPreference = 'Continue' +$ActualTargetFramework = 'net{0}.0' -f [Environment]::Version.Major +if ($ExpectedPowerShellVersion -and $PSVersionTable.PSVersion.ToString() -ne $ExpectedPowerShellVersion) { + throw "PowerShell version mismatch. Expected $ExpectedPowerShellVersion but detected $($PSVersionTable.PSVersion)." +} +if ($ExpectedTargetFramework -and $ActualTargetFramework -ne $ExpectedTargetFramework) { + throw "Target framework mismatch. Expected $ExpectedTargetFramework but detected $ActualTargetFramework." +} if ($PreloadManifest) { if ($StrictMode) { Import-Module $PreloadManifest -Force -ErrorAction Stop @@ -60,12 +118,16 @@ if ($PreloadManifest) { Import-DPLibrary -SuppressLogo | Out-Null } } -foreach ($Name in $ModuleNames) { +$ImportedModulePaths = @() +for ($ModuleIndex = 0; $ModuleIndex -lt $ModuleNames.Count; $ModuleIndex++) { + $Name = $ModuleNames[$ModuleIndex] + $ImportTarget = if ($ModuleManifestPaths.Count -gt 0) { [string]$ModuleManifestPaths[$ModuleIndex] } else { $Name } if ($StrictMode) { - Import-Module $Name -Force -ErrorAction Stop + Import-Module -Name $ImportTarget -Force -ErrorAction Stop } else { - Import-Module $Name -Force -ErrorAction Continue + Import-Module -Name $ImportTarget -Force -ErrorAction Continue } + $ImportedModulePaths += $ImportTarget } if ($ProbeCommand) { if ($StrictMode) { @@ -74,7 +136,20 @@ if ($ProbeCommand) { try { Invoke-Expression $ProbeCommand | Out-Null } catch { } } } -& $HelperScript -PolicyPath $PolicyPath | ConvertTo-Json -Depth 5 +$Rows = @(& $HelperScript -PolicyPath $PolicyPath) +foreach ($Row in $Rows) { + $Row | Add-Member -NotePropertyName PowerShellVersion -NotePropertyValue $PSVersionTable.PSVersion.ToString() + $Row | Add-Member -NotePropertyName DotNetVersion -NotePropertyValue ([Environment]::Version.ToString()) + $Row | Add-Member -NotePropertyName TargetFramework -NotePropertyValue $ActualTargetFramework + $Row | Add-Member -NotePropertyName ExecutablePath -NotePropertyValue ([Environment]::ProcessPath) + $Row | Add-Member -NotePropertyName PSHome -NotePropertyValue $PSHOME + $Row | Add-Member -NotePropertyName ModuleSet -NotePropertyValue @($ModuleNames) + $Row | Add-Member -NotePropertyName ImportOrder -NotePropertyValue @($ModuleNames) + $Row | Add-Member -NotePropertyName ImportedModulePaths -NotePropertyValue @($ImportedModulePaths) + $Row | Add-Member -NotePropertyName IsolatedModulePath -NotePropertyValue $env:PSModulePath + $Row | Add-Member -NotePropertyName DllPicklePreloaded -NotePropertyValue (-not [string]::IsNullOrWhiteSpace($PreloadManifest)) +} +$Rows | ConvertTo-Json -Depth 8 '@ $TempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("dpp-snap-{0}.ps1" -f ([System.Guid]::NewGuid().ToString('n'))) @@ -86,17 +161,27 @@ try { '-HelperScript', $HelperScript, '-PolicyPath', $PolicyPath ) + if ($PowerShellVersion) { $ChildArguments += @('-ExpectedPowerShellVersion', $PowerShellVersion.ToString()) } + if ($TargetFramework) { $ChildArguments += @('-ExpectedTargetFramework', $TargetFramework) } + if ($ResolvedModuleManifestPaths.Count -gt 0) { + $ManifestJson = ConvertTo-Json -InputObject @($ResolvedModuleManifestPaths) -Compress + $ManifestEncoded = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($ManifestJson)) + $ChildArguments += @('-ModuleManifestPathsEncoded', $ManifestEncoded) + } + if ($ModuleSearchPath.Count -gt 0) { + $ChildArguments += @('-IsolatedModulePath', (@($ModuleSearchPath) -join [System.IO.Path]::PathSeparator)) + } if ($PreloadDllPickleManifest) { $ChildArguments += @('-PreloadManifest', $PreloadDllPickleManifest) } if ($ProbeCommand) { $ChildArguments += @('-ProbeCommand', $ProbeCommand) } if ($Strict.IsPresent) { $ChildArguments += '-StrictMode' - $Raw = & pwsh @ChildArguments 2>&1 + $Raw = & $ResolvedPowerShellExecutable @ChildArguments 2>&1 if ($LASTEXITCODE -ne 0) { $ChildError = ($Raw | Out-String).Trim() throw "DLLPickle runtime assembly snapshot failed in strict mode. $ChildError" } } else { - $Raw = & pwsh @ChildArguments + $Raw = & $ResolvedPowerShellExecutable @ChildArguments } $Json = ($Raw | Out-String).Trim() if ([string]::IsNullOrWhiteSpace($Json)) { return @() } diff --git a/tools/Get-DLLPickleUpstreamInventory.ps1 b/tools/Get-DLLPickleUpstreamInventory.ps1 index 4deb0efa..e250cd0a 100644 --- a/tools/Get-DLLPickleUpstreamInventory.ps1 +++ b/tools/Get-DLLPickleUpstreamInventory.ps1 @@ -3,10 +3,11 @@ Builds an assembly inventory for upstream PowerShell modules. .DESCRIPTION - Reads build/dependency-policy.json, downloads the latest monitored modules - from PSGallery unless SkipDownload is used, inventories bundled DLL assembly - identities, and writes a structured JSON report for CI/CD compatibility - checks. + Reads build/dependency-policy.json, resolves the newest monitored module release + compatible with the exact tested PowerShell line, and launches that explicit stock + executable to capture only the tracked assembly assets actually selected at runtime. + The report records the PowerShell/CLR/TFM/OS profile, umbrella and constituent module, + assembly identity, hash, path, and load context. .PARAMETER PolicyPath Path to the dependency policy JSON file. @@ -27,6 +28,12 @@ .PARAMETER Force Removes any existing saved copy before downloading a module. +.PARAMETER PowerShellExecutable + Exact stock pwsh/pwsh.exe used for runtime asset selection. + +.PARAMETER TestMatrixPath + Canonical exact servicing-patch and profile matrix. + .EXAMPLE ./tools/Get-DLLPickleUpstreamInventory.ps1 -OutputPath ./artifacts/upstream/inventory.json @@ -56,7 +63,15 @@ param( [switch]$SkipDownload, [Parameter()] - [switch]$Force + [switch]$Force, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$PowerShellExecutable = [Environment]::ProcessPath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build\powershell-test-matrix.json') ) $ErrorActionPreference = 'Stop' @@ -102,44 +117,60 @@ function Get-DLLPickleLatestModulePath { Select-Object -First 1 } -function Get-DLLPickleAssemblyInventory { +function Get-DLLPickleRuntimeIdentity { [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$ModulePath, - - [Parameter(Mandatory)] - [string[]]$TrackedAssembly + [string]$ExecutablePath ) - $TrackedLookup = @{} - foreach ($AssemblyName in $TrackedAssembly) { - $TrackedLookup[$AssemblyName] = $true + $Probe = @' +$Platform = if ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) { + 'windows' +} elseif ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::OSX)) { + 'macos' +} else { + 'linux' +} +[ordered]@{ + powerShellVersion = $PSVersionTable.PSVersion.ToString() + dotNetVersion = [Environment]::Version.ToString() + dotNetMajor = [Environment]::Version.Major + executablePath = [Environment]::ProcessPath + psHome = $PSHOME + platform = $Platform + architecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +} | ConvertTo-Json -Compress +'@ + $Raw = @(& $ExecutablePath -NoLogo -NoProfile -NonInteractive -Command $Probe 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "PowerShell runtime identity probe failed: $($Raw -join [Environment]::NewLine)" } - - Get-ChildItem -LiteralPath $ModulePath -Filter '*.dll' -File -Recurse | - ForEach-Object { - try { - $AssemblyName = [System.Reflection.AssemblyName]::GetAssemblyName($_.FullName) - [PSCustomObject]@{ - Name = $AssemblyName.Name - Version = $AssemblyName.Version.ToString() - PackageVersionCandidate = ConvertTo-DLLPicklePackageVersion -AssemblyVersion $AssemblyName.Version - FullName = $AssemblyName.FullName - RelativePath = $_.FullName.Substring($ModulePath.Length).TrimStart('\', '/') - Path = $_.FullName - IsTracked = [bool]$TrackedLookup[$AssemblyName.Name] - } - } catch [System.BadImageFormatException] { - Write-Verbose "Skipping non-.NET DLL '$($_.FullName)'." - } - } | - Sort-Object -Property Name, Version, RelativePath + $Raw -join [Environment]::NewLine | ConvertFrom-Json -ErrorAction Stop } $ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path $Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json -$TrackedAssemblies = @($Policy.trackedAssemblies | ForEach-Object { [string]$_ }) +$ResolvedMatrixPath = (Resolve-Path -LiteralPath $TestMatrixPath).Path +$TestMatrix = Get-Content -LiteralPath $ResolvedMatrixPath -Raw | ConvertFrom-Json +$ExecutableCommand = Get-Command -Name $PowerShellExecutable -ErrorAction Stop +if ($ExecutableCommand.CommandType -ne 'Application') { + throw "PowerShellExecutable must resolve to an application: $PowerShellExecutable" +} +$ResolvedPowerShellExecutable = $ExecutableCommand.Source +$RuntimeIdentity = Get-DLLPickleRuntimeIdentity -ExecutablePath $ResolvedPowerShellExecutable +$RuntimeProfiles = @($TestMatrix.profiles | Where-Object powerShellVersion -eq $RuntimeIdentity.powerShellVersion) +if ($RuntimeProfiles.Count -ne 1) { + throw "Runtime PowerShell $($RuntimeIdentity.powerShellVersion) is not an exact, unique test-matrix profile." +} +$RuntimeProfile = $RuntimeProfiles[0] +if ([int]$RuntimeIdentity.dotNetMajor -ne [int]$RuntimeProfile.dotnetMajor) { + throw "Runtime CLR mismatch for PowerShell $($RuntimeIdentity.powerShellVersion). Expected CLR $($RuntimeProfile.dotnetMajor), detected CLR $($RuntimeIdentity.dotNetMajor)." +} +$ProfileKey = 'ps{0}-{1}-{2}-{3}' -f ( + '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor +), $RuntimeProfile.targetFramework, $RuntimeIdentity.platform, $RuntimeIdentity.architecture +$SnapshotScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'Get-DLLPickleRuntimeAssemblySnapshot.ps1' $PolicyModules = @($Policy.monitoredModules) if ($ModuleName) { $Requested = @{} @@ -161,7 +192,16 @@ if (-not $SkipDownload.IsPresent) { foreach ($PolicyModule in $PolicyModules) { $Name = [string]$PolicyModule.name $Repository = if ($PolicyModule.repository) { [string]$PolicyModule.repository } else { 'PSGallery' } - $GalleryModule = Find-Module -Name $Name -Repository $Repository -ErrorAction Stop + $GalleryModules = @(Find-Module -Name $Name -Repository $Repository -AllVersions -ErrorAction Stop) + $GalleryModule = $GalleryModules | + Where-Object { + -not $_.PowerShellVersion -or [version]$_.PowerShellVersion -le [version]$RuntimeIdentity.powerShellVersion + } | + Sort-Object -Property Version -Descending | + Select-Object -First 1 + if (-not $GalleryModule) { + throw "No release of module '$Name' declares compatibility with PowerShell $($RuntimeIdentity.powerShellVersion)." + } $ResolvedModuleVersions[$Name] = $GalleryModule.Version } } @@ -195,22 +235,119 @@ $ModuleResults = foreach ($PolicyModule in $PolicyModules) { throw "Module '$Name' was not found under '$ModuleCachePath'." } - $Assemblies = @(Get-DLLPickleAssemblyInventory -ModulePath $SavedModule.FullName -TrackedAssembly $TrackedAssemblies) + $ModuleManifestPath = Get-ChildItem -LiteralPath $SavedModule.FullName -Filter "$Name.psd1" -File -Recurse | + Sort-Object -Property { $_.FullName.Length } | + Select-Object -First 1 + $Manifest = if ($ModuleManifestPath) { + # Real gallery manifests can contain module-manifest expressions such as a + # PSEdition-dependent RootModule. Import-PowerShellDataFile deliberately rejects + # those expressions; Test-ModuleManifest evaluates the constrained manifest grammar + # and returns the compatibility metadata PowerShell itself uses. + Test-ModuleManifest -Path $ModuleManifestPath.FullName -ErrorAction Stop + } else { + $null + } + + $OriginalPSModulePath = $env:PSModulePath + try { + $SystemModulePath = Join-Path -Path $RuntimeIdentity.psHome -ChildPath 'Modules' + $env:PSModulePath = @($ModuleCachePath, $SystemModulePath) -join [System.IO.Path]::PathSeparator + $SnapshotParameters = @{ + ModuleName = @($Name) + ModuleManifestPath = @($ModuleManifestPath.FullName) + ModuleSearchPath = @($ModuleCachePath, $SystemModulePath) + PolicyPath = $ResolvedPolicyPath + PowerShellExecutable = $ResolvedPowerShellExecutable + PowerShellVersion = [version]$RuntimeIdentity.powerShellVersion + TargetFramework = [string]$RuntimeProfile.targetFramework + Strict = $true + } + if (-not [string]::IsNullOrWhiteSpace([string]$PolicyModule.deterministicProbeCommand)) { + $SnapshotParameters['ProbeCommand'] = [string]$PolicyModule.deterministicProbeCommand + } + $RuntimeAssemblies = @(& $SnapshotScriptPath @SnapshotParameters) + } finally { + $env:PSModulePath = $OriginalPSModulePath + } + + $Assemblies = @( + foreach ($Assembly in $RuntimeAssemblies) { + $ConstituentModule = $Name + if (-not [string]::IsNullOrWhiteSpace([string]$Assembly.Path)) { + $FullAssemblyPath = [System.IO.Path]::GetFullPath($Assembly.Path) + $FullModuleCachePath = [System.IO.Path]::GetFullPath($ModuleCachePath) + $FullPSHomePath = [System.IO.Path]::GetFullPath($RuntimeIdentity.psHome) + $RelativeToCache = [System.IO.Path]::GetRelativePath( + $FullModuleCachePath, + $FullAssemblyPath + ) + $RelativeToPSHome = [System.IO.Path]::GetRelativePath($FullPSHomePath, $FullAssemblyPath) + $IsWithinModuleCache = -not $RelativeToCache.StartsWith('..', [System.StringComparison]::Ordinal) + $IsWithinPSHome = -not $RelativeToPSHome.StartsWith('..', [System.StringComparison]::Ordinal) + if (-not $IsWithinModuleCache -and -not $IsWithinPSHome) { + throw "Runtime evidence selected an assembly outside the isolated module cache and exact PSHOME: $FullAssemblyPath" + } + if ($IsWithinModuleCache) { + $ConstituentModule = ($RelativeToCache -split '[\\/]')[0] + } + } + [PSCustomObject]@{ + Name = [string]$Assembly.Name + Version = [string]$Assembly.Version + PackageVersionCandidate = ConvertTo-DLLPicklePackageVersion -AssemblyVersion ([version]$Assembly.Version) + FullName = [string]$Assembly.FullName + Path = [string]$Assembly.Path + SelectedAssetPath = [string]$Assembly.Path + Sha256 = [string]$Assembly.Sha256 + Alc = [string]$Assembly.Alc + IsCollectible = [bool]$Assembly.IsCollectible + ConstituentModule = $ConstituentModule + PowerShellVersion = [string]$Assembly.PowerShellVersion + DotNetVersion = [string]$Assembly.DotNetVersion + TargetFramework = [string]$Assembly.TargetFramework + OS = [string]$Assembly.OS + Architecture = [string]$Assembly.Architecture + } + } + ) [PSCustomObject]@{ - Name = $Name - Version = $SavedModule.Name - Repository = $Repository - ModulePath = $SavedModule.FullName - Purpose = [string]$PolicyModule.purpose - Assemblies = $Assemblies - TrackedAssemblies = @($Assemblies | Where-Object IsTracked) + Name = $Name + UmbrellaModule = if ($PolicyModule.umbrellaModule) { [string]$PolicyModule.umbrellaModule } else { $Name } + ConstituentModule = $Name + Version = $SavedModule.Name + LatestCompatibleVersion = $SavedModule.Name + Repository = $Repository + ModulePath = $SavedModule.FullName + ModuleManifestPath = if ($ModuleManifestPath) { $ModuleManifestPath.FullName } else { $null } + ManifestPowerShellVersion = if ($Manifest -and $Manifest.PowerShellVersion) { $Manifest.PowerShellVersion.ToString() } else { $null } + CompatiblePSEditions = if ($Manifest) { @($Manifest.CompatiblePSEditions) } else { @() } + Purpose = [string]$PolicyModule.purpose + DeterministicProbeCommand = [string]$PolicyModule.deterministicProbeCommand + Assemblies = $Assemblies + TrackedAssemblies = $Assemblies } } $Report = [PSCustomObject]@{ + SchemaVersion = 2 GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') PolicyPath = $ResolvedPolicyPath + TestMatrixPath = $ResolvedMatrixPath ModuleCachePath = (Resolve-Path -LiteralPath $ModuleCachePath).Path + ProfileKey = $ProfileKey + ValidationTier = 'DeterministicImportNoAuth' + Profile = [PSCustomObject]@{ + PowerShellVersion = [string]$RuntimeIdentity.powerShellVersion + PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + DotNetVersion = [string]$RuntimeIdentity.dotNetVersion + DotNetMajor = [int]$RuntimeIdentity.dotNetMajor + TargetFramework = [string]$RuntimeProfile.targetFramework + ExecutablePath = [string]$RuntimeIdentity.executablePath + PSHome = [string]$RuntimeIdentity.psHome + Platform = [string]$RuntimeIdentity.platform + Architecture = [string]$RuntimeIdentity.architecture + } + ModuleSet = @($ModuleResults.Name) Modules = @($ModuleResults) } diff --git a/tools/New-DLLPickleArtifactSizeReport.ps1 b/tools/New-DLLPickleArtifactSizeReport.ps1 new file mode 100644 index 00000000..54bb871f --- /dev/null +++ b/tools/New-DLLPickleArtifactSizeReport.ps1 @@ -0,0 +1,189 @@ +<# +.SYNOPSIS + Generates deterministic unpacked and compressed module-size evidence. + +.DESCRIPTION + Measures each shipped target-framework payload and the complete module. The + committed baseline supplies material-growth thresholds. Compressed sizes use a + sorted in-memory ZIP with fixed timestamps so reruns are stable. + +.PARAMETER Strict + Throw when a baseline entry is missing or a material unpacked-size increase occurs. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module\DLLPickle'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$BaselinePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\artifact-size-baseline.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\package\artifact-size.json'), + + [Parameter()] + [switch]$Strict +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression + +function Get-DLLPickleDeterministicCompressedSize { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$RootPath + ) + + $MemoryStream = [System.IO.MemoryStream]::new() + try { + $Archive = [System.IO.Compression.ZipArchive]::new($MemoryStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + $Files = @(Get-ChildItem -LiteralPath $RootPath -File -Recurse | Sort-Object FullName) + foreach ($File in $Files) { + $EntryName = [System.IO.Path]::GetRelativePath($RootPath, $File.FullName).Replace('\', '/') + $Entry = $Archive.CreateEntry($EntryName, [System.IO.Compression.CompressionLevel]::Optimal) + $Entry.LastWriteTime = [System.DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [System.TimeSpan]::Zero) + $InputStream = $File.OpenRead() + $OutputStream = $Entry.Open() + try { + $InputStream.CopyTo($OutputStream) + } finally { + $OutputStream.Dispose() + $InputStream.Dispose() + } + } + } finally { + $Archive.Dispose() + } + return $MemoryStream.Length + } finally { + $MemoryStream.Dispose() + } +} + +function Get-DLLPickleSizeMeasurement { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Path, + + [Parameter()] + [object]$Baseline, + + [Parameter(Mandatory)] + [double]$MaximumIncreasePercent, + + [Parameter(Mandatory)] + [long]$MaximumIncreaseBytes + ) + + $Files = @(Get-ChildItem -LiteralPath $Path -File -Recurse) + $UnpackedBytes = [long](($Files | Measure-Object Length -Sum).Sum) + $CompressedBytes = [long](Get-DLLPickleDeterministicCompressedSize -RootPath $Path) + $HasBaseline = $null -ne $Baseline + $BaselineUnpackedBytes = if ($HasBaseline) { [long]$Baseline.unpackedBytes } else { $null } + $BaselineCompressedBytes = if ($HasBaseline) { [long]$Baseline.compressedBytes } else { $null } + $UnpackedDeltaBytes = if ($HasBaseline) { $UnpackedBytes - $BaselineUnpackedBytes } else { $null } + $CompressedDeltaBytes = if ($HasBaseline) { $CompressedBytes - $BaselineCompressedBytes } else { $null } + $PercentThresholdBytes = if ($HasBaseline) { [long][Math]::Ceiling($BaselineUnpackedBytes * ($MaximumIncreasePercent / 100)) } else { $null } + $AllowedIncreaseBytes = if ($HasBaseline) { [Math]::Max($MaximumIncreaseBytes, $PercentThresholdBytes) } else { $null } + $ReviewRequired = -not $HasBaseline -or $UnpackedDeltaBytes -gt $AllowedIncreaseBytes + + [PSCustomObject]@{ + Name = $Name + FileCount = $Files.Count + UnpackedBytes = $UnpackedBytes + CompressedBytes = $CompressedBytes + BaselinePresent = $HasBaseline + BaselineUnpackedBytes = $BaselineUnpackedBytes + BaselineCompressedBytes = $BaselineCompressedBytes + UnpackedDeltaBytes = $UnpackedDeltaBytes + CompressedDeltaBytes = $CompressedDeltaBytes + AllowedUnpackedIncreaseBytes = $AllowedIncreaseBytes + ReviewRequired = $ReviewRequired + } +} + +foreach ($RequiredPath in @($ModulePath, $SupportPolicyPath, $BaselinePath)) { + if (-not (Test-Path -LiteralPath $RequiredPath)) { + throw "Required size-report path was not found: $RequiredPath" + } +} + +$ResolvedModulePath = (Resolve-Path -LiteralPath $ModulePath).Path +$SupportPolicy = Get-Content -LiteralPath $SupportPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Baseline = Get-Content -LiteralPath $BaselinePath -Raw | ConvertFrom-Json -ErrorAction Stop +$BaselineApprovalStatus = [string]$Baseline.approvalStatus +$ApprovedAtUtc = [System.DateTimeOffset]::MinValue +$HasValidApprovalTimestamp = [System.DateTimeOffset]::TryParse( + [string]$Baseline.approvedAtUtc, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal, + [ref]$ApprovedAtUtc +) +$BaselineApproved = $BaselineApprovalStatus -ceq 'accepted' -and $HasValidApprovalTimestamp +$MaximumIncreasePercent = [double]$Baseline.thresholds.maximumIncreasePercent +$MaximumIncreaseBytes = [long]$Baseline.thresholds.maximumIncreaseBytes +$ExpectedTargetFrameworks = @($SupportPolicy.profiles.targetFramework | ForEach-Object { [string]$_ } | Sort-Object -Unique) + +$Measurements = @( + foreach ($TargetFramework in $ExpectedTargetFrameworks) { + $TfmPath = Join-Path (Join-Path $ResolvedModulePath 'bin') $TargetFramework + if (-not (Test-Path -LiteralPath $TfmPath -PathType Container)) { + [PSCustomObject]@{ + Name = $TargetFramework + BaselinePresent = $false + ReviewRequired = $true + Error = "Target-framework directory was not found: $TfmPath" + } + continue + } + $BaselineEntry = @($Baseline.profiles | Where-Object name -EQ $TargetFramework | Select-Object -First 1) + Get-DLLPickleSizeMeasurement -Name $TargetFramework -Path $TfmPath -Baseline $BaselineEntry[0] -MaximumIncreasePercent $MaximumIncreasePercent -MaximumIncreaseBytes $MaximumIncreaseBytes + } +) +$FullBaseline = @($Baseline.fullArtifact | Select-Object -First 1) +$FullMeasurement = Get-DLLPickleSizeMeasurement -Name 'fullArtifact' -Path $ResolvedModulePath -Baseline $FullBaseline[0] -MaximumIncreasePercent $MaximumIncreasePercent -MaximumIncreaseBytes $MaximumIncreaseBytes +$SizeGrowthReviewRequired = @($Measurements | Where-Object ReviewRequired).Count -gt 0 -or $FullMeasurement.ReviewRequired +$ReviewRequired = -not $BaselineApproved -or $SizeGrowthReviewRequired + +$Report = [PSCustomObject]@{ + SchemaVersion = 1 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + ModulePath = $ResolvedModulePath + BaselineApprovalStatus = $BaselineApprovalStatus + BaselineApproved = $BaselineApproved + BaselineApprovedAtUtc = if ($BaselineApproved) { $ApprovedAtUtc.ToUniversalTime().ToString('o') } else { $null } + Thresholds = $Baseline.thresholds + Profiles = @($Measurements) + FullArtifact = $FullMeasurement + SizeGrowthReviewRequired = $SizeGrowthReviewRequired + ReviewRequired = $ReviewRequired +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +if ($Strict.IsPresent -and -not $BaselineApproved) { + throw "DLLPickle artifact size baseline is not accepted by a maintainer (status '$BaselineApprovalStatus' or approval timestamp missing). Review build/artifact-size-baseline.json." +} +if ($Strict.IsPresent -and $SizeGrowthReviewRequired) { + throw 'DLLPickle artifact size exceeds the approved material-growth policy or lacks a baseline. Review artifact-size.json.' +} + +$Report diff --git a/tools/New-DLLPickleConflictMatrix.ps1 b/tools/New-DLLPickleConflictMatrix.ps1 index 5c30760a..afd1eec0 100644 --- a/tools/New-DLLPickleConflictMatrix.ps1 +++ b/tools/New-DLLPickleConflictMatrix.ps1 @@ -79,11 +79,20 @@ $SurfaceRows = @( '{0}={1};by={2}' -f $_.Name, (@($_.Versions | Sort-Object) -join ','), (@($_.ShippedBy | Sort-Object) -join ',') } ) -$FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes(($SurfaceRows -join '|')) +$ProfileKey = if ($Inventory.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Inventory.ProfileKey } else { $null } +$FingerprintInput = if ([string]::IsNullOrWhiteSpace($ProfileKey)) { + $SurfaceRows -join '|' +} else { + '{0}|{1}' -f $ProfileKey, ($SurfaceRows -join '|') +} +$FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($FingerprintInput) $Fingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() $Matrix = [PSCustomObject]@{ GeneratedAtUtc = $null # stamped by the caller; avoids non-deterministic test output + ProfileKey = $ProfileKey + Profile = if ($Inventory.PSObject.Properties.Name -contains 'Profile') { $Inventory.Profile } else { $null } + ValidationTier = if ($Inventory.PSObject.Properties.Name -contains 'ValidationTier') { $Inventory.ValidationTier } else { $null } Assemblies = @($AssemblyRows) ConflictSurface = @($AssemblyRows | Where-Object Diverges | ForEach-Object Name) Fingerprint = $Fingerprint diff --git a/tools/New-DLLPickleDependencyChangeReport.ps1 b/tools/New-DLLPickleDependencyChangeReport.ps1 new file mode 100644 index 00000000..6fae535a --- /dev/null +++ b/tools/New-DLLPickleDependencyChangeReport.ps1 @@ -0,0 +1,276 @@ +<# +.SYNOPSIS + Builds a per-TFM dependency-change report for a Dependabot candidate. + +.DESCRIPTION + Compares baseline and candidate NuGet target graphs, selected assets, and + packaged assembly inputs for every supported TFM. It also summarizes exact-host + Pester XML evidence and incorporates the package-size report. The report computes + a deterministic per-TFM delta for every assembly classified as preload or blocked; + live upstream and ALC adjudication remains owned by the profile-aware upstream gate. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$BaselineProjectAssetsPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$CandidateProjectAssetsPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$BaselineBuildOutputRoot, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$CandidateBuildOutputRoot, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$SupportPolicyPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$DependencyPolicyPath, + + [Parameter()] + [string]$SizeReportPath, + + [Parameter()] + [string]$ScenarioEvidencePath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\dependency\dependency-change-report.json') +) + +$ErrorActionPreference = 'Stop' + +function Get-DLLPickleNuGetTargetGraph { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Assets, + + [Parameter(Mandatory)] + [string]$TargetFramework + ) + + $TargetProperty = $Assets.targets.PSObject.Properties[$TargetFramework] + if (-not $TargetProperty) { + return @() + } + + @( + foreach ($LibraryProperty in @($TargetProperty.Value.PSObject.Properties | Sort-Object Name)) { + $SeparatorIndex = $LibraryProperty.Name.LastIndexOf('/') + $PackageName = if ($SeparatorIndex -gt 0) { $LibraryProperty.Name.Substring(0, $SeparatorIndex) } else { $LibraryProperty.Name } + $PackageVersion = if ($SeparatorIndex -gt 0) { $LibraryProperty.Name.Substring($SeparatorIndex + 1) } else { $null } + $CompileAssets = @($LibraryProperty.Value.compile.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) + $RuntimeAssets = @($LibraryProperty.Value.runtime.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) + [PSCustomObject]@{ + PackageName = $PackageName + PackageVersion = $PackageVersion + CompileAssets = @($CompileAssets) + RuntimeAssets = @($RuntimeAssets) + } + } + ) +} + +function Get-DLLPicklePackagedAssemblyInput { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BuildOutputRoot, + + [Parameter(Mandatory)] + [string]$TargetFramework + ) + + $TfmPath = Join-Path $BuildOutputRoot $TargetFramework + if (-not (Test-Path -LiteralPath $TfmPath -PathType Container)) { + return @() + } + + $Files = @( + Get-ChildItem -LiteralPath $TfmPath -File -Filter '*.dll' | + Where-Object Name -Match '^(Azure\.|Microsoft\.|System\.)' + $RuntimePath = Join-Path $TfmPath 'runtimes' + if (Test-Path -LiteralPath $RuntimePath -PathType Container) { + Get-ChildItem -LiteralPath $RuntimePath -File -Recurse | + Where-Object FullName -Match '[\\/]native[\\/]' + } + ) + + @( + foreach ($File in @($Files | Sort-Object FullName)) { + $AssemblyName = $null + $AssemblyVersion = $null + try { + $ManagedIdentity = [System.Reflection.AssemblyName]::GetAssemblyName($File.FullName) + $AssemblyName = [string]$ManagedIdentity.Name + $AssemblyVersion = [string]$ManagedIdentity.Version + } catch { + # Native runtime payloads are still package inputs but do not have a managed identity. + $AssemblyName = $null + $AssemblyVersion = $null + } + $Sha256 = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + [PSCustomObject]@{ + RelativePath = [System.IO.Path]::GetRelativePath($TfmPath, $File.FullName).Replace('\', '/') + Length = [long]$File.Length + Sha256 = $Sha256 + AssemblyName = $AssemblyName + AssemblyVersion = $AssemblyVersion + IdentityFingerprint = if ($AssemblyName) { "$AssemblyVersion|$Sha256" } else { $null } + } + } + ) +} + +function Compare-DLLPickleNamedRow { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Baseline, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Candidate, + + [Parameter(Mandatory)] + [string]$KeyProperty, + + [Parameter(Mandatory)] + [string]$ValueProperty + ) + + $BaselineByKey = @{} + foreach ($Row in $Baseline) { $BaselineByKey[[string]$Row.$KeyProperty] = $Row } + $CandidateByKey = @{} + foreach ($Row in $Candidate) { $CandidateByKey[[string]$Row.$KeyProperty] = $Row } + $Keys = @($BaselineByKey.Keys + $CandidateByKey.Keys | Sort-Object -Unique) + + [PSCustomObject]@{ + Added = @($Keys | Where-Object { -not $BaselineByKey.ContainsKey($_) } | ForEach-Object { $CandidateByKey[$_] }) + Removed = @($Keys | Where-Object { -not $CandidateByKey.ContainsKey($_) } | ForEach-Object { $BaselineByKey[$_] }) + Changed = @( + foreach ($Key in $Keys) { + if ($BaselineByKey.ContainsKey($Key) -and $CandidateByKey.ContainsKey($Key) -and [string]$BaselineByKey[$Key].$ValueProperty -cne [string]$CandidateByKey[$Key].$ValueProperty) { + [PSCustomObject]@{ + Key = $Key + Baseline = $BaselineByKey[$Key].$ValueProperty + Candidate = $CandidateByKey[$Key].$ValueProperty + } + } + } + ) + } +} + +foreach ($RequiredPath in @($BaselineProjectAssetsPath, $CandidateProjectAssetsPath, $BaselineBuildOutputRoot, $CandidateBuildOutputRoot, $SupportPolicyPath, $DependencyPolicyPath)) { + if (-not (Test-Path -LiteralPath $RequiredPath)) { + throw "Required dependency-report path was not found: $RequiredPath" + } +} + +$BaselineAssets = Get-Content -LiteralPath $BaselineProjectAssetsPath -Raw | ConvertFrom-Json -ErrorAction Stop +$CandidateAssets = Get-Content -LiteralPath $CandidateProjectAssetsPath -Raw | ConvertFrom-Json -ErrorAction Stop +$SupportPolicy = Get-Content -LiteralPath $SupportPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$DependencyPolicy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$TargetFrameworks = @($SupportPolicy.profiles.targetFramework | ForEach-Object { [string]$_ } | Sort-Object -Unique) +$ConflictSensitiveAssemblyNames = @( + @($DependencyPolicy.preload.assemblyName) + @($DependencyPolicy.blockedPreloadAssemblies.assemblyName) +) | + ForEach-Object { [string]$_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique +$SizeReport = if ($SizeReportPath -and (Test-Path -LiteralPath $SizeReportPath -PathType Leaf)) { + Get-Content -LiteralPath $SizeReportPath -Raw | ConvertFrom-Json -ErrorAction Stop +} else { + $null +} + +$ProfileReports = @( + foreach ($TargetFramework in $TargetFrameworks) { + $BaselineGraph = @(Get-DLLPickleNuGetTargetGraph -Assets $BaselineAssets -TargetFramework $TargetFramework) + $CandidateGraph = @(Get-DLLPickleNuGetTargetGraph -Assets $CandidateAssets -TargetFramework $TargetFramework) + $BaselineInputs = @(Get-DLLPicklePackagedAssemblyInput -BuildOutputRoot $BaselineBuildOutputRoot -TargetFramework $TargetFramework) + $CandidateInputs = @(Get-DLLPicklePackagedAssemblyInput -BuildOutputRoot $CandidateBuildOutputRoot -TargetFramework $TargetFramework) + $BaselineConflictInputs = @($BaselineInputs | Where-Object AssemblyName -IN $ConflictSensitiveAssemblyNames) + $CandidateConflictInputs = @($CandidateInputs | Where-Object AssemblyName -IN $ConflictSensitiveAssemblyNames) + $ConflictDelta = Compare-DLLPickleNamedRow -Baseline $BaselineConflictInputs -Candidate $CandidateConflictInputs -KeyProperty AssemblyName -ValueProperty IdentityFingerprint + $SizeRow = if ($SizeReport) { @($SizeReport.Profiles | Where-Object Name -EQ $TargetFramework | Select-Object -First 1)[0] } else { $null } + + [PSCustomObject]@{ + TargetFramework = $TargetFramework + BaselineResolvedGraph = @($BaselineGraph) + CandidateResolvedGraph = @($CandidateGraph) + ResolvedGraphDelta = Compare-DLLPickleNamedRow -Baseline $BaselineGraph -Candidate $CandidateGraph -KeyProperty PackageName -ValueProperty PackageVersion + CandidateSelectedAssets = @($CandidateGraph | Select-Object PackageName,PackageVersion,CompileAssets,RuntimeAssets) + AssemblyDelta = Compare-DLLPickleNamedRow -Baseline $BaselineInputs -Candidate $CandidateInputs -KeyProperty RelativePath -ValueProperty Sha256 + Size = $SizeRow + ConflictSurfaceDelta = [PSCustomObject]@{ + RequiredCheck = 'Validate upstream compatibility tooling' + SensitiveAssemblyNames = @($ConflictSensitiveAssemblyNames) + BaselineAssemblies = @($BaselineConflictInputs) + CandidateAssemblies = @($CandidateConflictInputs) + Delta = $ConflictDelta + HasChanges = ( + @($ConflictDelta.Added).Count -gt 0 -or + @($ConflictDelta.Removed).Count -gt 0 -or + @($ConflictDelta.Changed).Count -gt 0 + ) + UpstreamAlcAdjudication = 'required-profile-aware-gate' + } + } + } +) + +$ScenarioResults = @( + if ($ScenarioEvidencePath -and (Test-Path -LiteralPath $ScenarioEvidencePath -PathType Container)) { + foreach ($XmlFile in @(Get-ChildItem -LiteralPath $ScenarioEvidencePath -File -Filter '*.xml' -Recurse | Sort-Object FullName)) { + try { + [xml]$Xml = Get-Content -LiteralPath $XmlFile.FullName -Raw + $Root = $Xml.DocumentElement + [PSCustomObject]@{ + File = [System.IO.Path]::GetRelativePath($ScenarioEvidencePath, $XmlFile.FullName).Replace('\', '/') + Total = [int]$Root.total + Failures = [int]$Root.failures + Errors = [int]$Root.errors + NotRun = [int]$Root.'not-run' + Passed = ([int]$Root.failures + [int]$Root.errors) -eq 0 + } + } catch { + [PSCustomObject]@{ + File = [System.IO.Path]::GetRelativePath($ScenarioEvidencePath, $XmlFile.FullName).Replace('\', '/') + Passed = $false + Error = $_.Exception.Message + } + } + } + } +) + +$Report = [PSCustomObject]@{ + SchemaVersion = 2 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + TargetFrameworks = @($TargetFrameworks) + Profiles = @($ProfileReports) + ScenarioOutcomes = @($ScenarioResults) + RequiredChecks = @('Build gate', 'Validate upstream compatibility tooling', 'dependency-review') + ReviewRequired = $true +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Report | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +$Report diff --git a/tools/New-DLLPickleProfileEvidenceSummary.ps1 b/tools/New-DLLPickleProfileEvidenceSummary.ps1 new file mode 100644 index 00000000..15195f8c --- /dev/null +++ b/tools/New-DLLPickleProfileEvidenceSummary.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS +Aggregates exact-profile upstream baseline comparisons into one stable finding. + +.DESCRIPTION +The summary is deterministic for the same comparison inputs. Its aggregate finding +fingerprint and HTML marker can be used to suppress repeated issue comments without +making this read-only command responsible for GitHub writes. +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$EvidenceRoot, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath, + + [Parameter()] + [switch]$Strict +) + +$ErrorActionPreference = 'Stop' +if (-not (Test-Path -LiteralPath $TestMatrixPath -PathType Leaf)) { + throw "PowerShell test matrix was not found: $TestMatrixPath" +} +$Matrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$ExpectedProfileKeys = @( + foreach ($RuntimeProfile in @($Matrix.profiles)) { + foreach ($Lane in @($Matrix.lanes)) { + 'ps{0}.{1}-{2}-{3}-{4}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor, $RuntimeProfile.targetFramework, $Lane.platform, $Lane.architecture + } + } +) + +$Comparisons = @( + if (Test-Path -LiteralPath $EvidenceRoot -PathType Container) { + foreach ($ComparisonFile in @(Get-ChildItem -LiteralPath $EvidenceRoot -File -Filter 'baseline-comparison.json' -Recurse | Sort-Object FullName)) { + $Comparison = Get-Content -LiteralPath $ComparisonFile.FullName -Raw | ConvertFrom-Json -ErrorAction Stop + [PSCustomObject]@{ + ProfileKey = [string]$Comparison.ProfileKey + Status = [string]$Comparison.Status + BaselineStatus = [string]$Comparison.BaselineStatus + BaselineFingerprint = [string]$Comparison.BaselineFingerprint + CurrentFingerprint = [string]$Comparison.CurrentFingerprint + BaselineScenarioFingerprint = [string]$Comparison.BaselineScenarioFingerprint + CurrentScenarioFingerprint = [string]$Comparison.CurrentScenarioFingerprint + FindingFingerprint = [string]$Comparison.FindingFingerprint + SourcePath = [System.IO.Path]::GetRelativePath((Resolve-Path -LiteralPath $EvidenceRoot).Path, $ComparisonFile.FullName).Replace('\', '/') + } + } + } +) +$DuplicateProfileKeys = @($Comparisons | Group-Object ProfileKey | Where-Object Count -GT 1 | ForEach-Object Name) +$MissingProfileKeys = @($ExpectedProfileKeys | Where-Object { $_ -notin $Comparisons.ProfileKey }) +$UnexpectedProfileKeys = @($Comparisons.ProfileKey | Where-Object { $_ -notin $ExpectedProfileKeys }) +$Findings = @($Comparisons | Where-Object Status -NE 'AcceptedUnchanged' | Sort-Object ProfileKey) +$CanonicalText = @( + "missing=$($MissingProfileKeys -join ',')" + "unexpected=$($UnexpectedProfileKeys -join ',')" + "duplicates=$($DuplicateProfileKeys -join ',')" + @($Comparisons | Sort-Object ProfileKey | ForEach-Object { '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f $_.ProfileKey, $_.Status, $_.BaselineFingerprint, $_.CurrentFingerprint, $_.BaselineScenarioFingerprint, $_.CurrentScenarioFingerprint, $_.FindingFingerprint }) +) -join [char]10 +$FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalText) +$AggregateFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() +$Complete = $Comparisons.Count -eq $ExpectedProfileKeys.Count -and $MissingProfileKeys.Count -eq 0 -and $UnexpectedProfileKeys.Count -eq 0 -and $DuplicateProfileKeys.Count -eq 0 +$Ready = $Complete -and $Findings.Count -eq 0 + +$Summary = [PSCustomObject]@{ + SchemaVersion = 1 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + ExpectedProfileKeys = @($ExpectedProfileKeys) + Comparisons = @($Comparisons | Sort-Object ProfileKey) + MissingProfileKeys = @($MissingProfileKeys) + UnexpectedProfileKeys = @($UnexpectedProfileKeys) + DuplicateProfileKeys = @($DuplicateProfileKeys) + Findings = @($Findings) + AggregateFindingFingerprint = $AggregateFingerprint + FindingMarker = '' -f $AggregateFingerprint + AllProfileEvidencePresent = $Complete + ReadyForCandidateUpdate = $Ready +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Summary | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +if ($Strict.IsPresent -and -not $Ready) { + throw "Exact-profile upstream evidence is not ready: present=$Complete, findings=$($Findings.Count)." +} +$Summary diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 new file mode 100644 index 00000000..d48cf1e5 --- /dev/null +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS + Generates support and upstream-compatibility documentation from policy data. + +.DESCRIPTION + Produces the human-readable Microsoft support matrix and the profile-aware + upstream evidence register. Pending CI or credential-dependent evidence is + rendered as a gap and is never represented as passing. + +.PARAMETER Check + Compare generated content with committed files and throw on drift without writing. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$DependencyPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\dependency-policy.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs\generated'), + + [Parameter()] + [switch]$Check +) + +$ErrorActionPreference = 'Stop' + +foreach ($RequiredPath in @($SupportPolicyPath, $TestMatrixPath, $DependencyPolicyPath)) { + if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { + throw "Required documentation source was not found: $RequiredPath" + } +} + +$SupportPolicy = Get-Content -LiteralPath $SupportPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$TestMatrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$DependencyPolicy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$ShippedProfileKeys = @($SupportPolicy.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' -f $_.powerShellMajor, $_.powerShellMinor, $_.dotnetMajor, $_.targetFramework }) +$TestProfileKeys = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' -f $_.powerShellMajor, $_.powerShellMinor, $_.dotnetMajor, $_.targetFramework }) +if (Compare-Object -ReferenceObject $ShippedProfileKeys -DifferenceObject $TestProfileKeys) { + throw 'Shipped runtime profiles and documentation test profiles do not align.' +} +$VerifiedDate = ([System.DateTimeOffset]$TestMatrix.lastVerifiedUtc).ToString('yyyy-MM-dd') +$NewLine = [Environment]::NewLine + +$SupportLines = [System.Collections.Generic.List[string]]::new() +$SupportLines.Add('# Supported PowerShell runtime matrix') +$SupportLines.Add('') +$SupportLines.Add('') +$SupportLines.Add('') +$SupportLines.Add("Lifecycle and servicing data last verified: **$VerifiedDate**.") +$SupportLines.Add('') +$SupportLines.Add('| PowerShell line | Exact CI patch | .NET runtime | Bundled TFM | Microsoft lifecycle | Support end | Required OS cells |') +$SupportLines.Add('|---|---:|---:|---:|---|---:|---|') +foreach ($MatrixProfile in @($TestMatrix.profiles)) { + $PowerShellLine = '{0}.{1}' -f $MatrixProfile.powerShellMajor, $MatrixProfile.powerShellMinor + $SupportRow = '| {0} | {1} | {2} | `{3}` | {4} | {5} | Windows, Linux, macOS |' -f $PowerShellLine, $MatrixProfile.powerShellVersion, $MatrixProfile.dotnetRuntimeVersion, $MatrixProfile.targetFramework, $MatrixProfile.lifecycleState, $MatrixProfile.lifecycleEndDate + $SupportLines.Add($SupportRow) +} +$SupportLines.Add('') +$SupportLines.Add('This table is the Microsoft-supported runtime contract used by the loader and CI. Upstream module compatibility is a separate evidence question; see [Upstream compatibility evidence](Compatibility-Evidence.md). Exact servicing patches are test pins, not minimum patch claims for end users.') +$SupportLines.Add('') +$SupportLines.Add('The module selects a bundle using both the PowerShell minor line and CLR major, then fails closed on a mismatched or unknown pair. Only the rows above are part of the current support contract.') +$SupportLines.Add('') +$SupportLines.Add('`multi-pwsh` is optional, checksum-pinned CI provisioning infrastructure. CI invokes the provisioned official `pwsh` executable directly. No multi-pwsh executable, package, manifest dependency, runtime reference, or license payload is published with DLLPickle.') + +$EvidenceLines = [System.Collections.Generic.List[string]]::new() +$EvidenceLines.Add('# Upstream compatibility evidence') +$EvidenceLines.Add('') +$EvidenceLines.Add('') +$EvidenceLines.Add('') +$EvidenceLines.Add('Microsoft runtime support and upstream module behavior are tracked independently. The rows below deliberately remain release-gating gaps until a profile-specific CI run is accepted; a supported PowerShell line does not imply that every upstream module combination can coexist in one process.') +$EvidenceLines.Add('') +$EvidenceLines.Add('| Module | Version | PowerShell | TFM | OS | Selected asset | Assembly / ALC result | Verdict | Evidence date | Run ID |') +$EvidenceLines.Add('|---|---|---:|---:|---|---|---|---|---:|---|') +foreach ($RuntimeProfile in @($DependencyPolicy.runtimeProfiles)) { + foreach ($Platform in @($RuntimeProfile.platforms)) { + $PlatformBaseline = $RuntimeProfile.baselines.$Platform + $Verdict = if ($PlatformBaseline) { [string]$PlatformBaseline.status } else { 'missing-baseline' } + foreach ($Module in @($DependencyPolicy.monitoredModules)) { + $EvidenceRow = '| {0} | pending exact-profile inventory | {1} | `{2}` | {3} | pending CI artifact | pending hash and ALC | **{4}** | {5} | not-run |' -f $Module.name, $RuntimeProfile.powerShellLine, $RuntimeProfile.targetFramework, $Platform, $Verdict, $VerifiedDate + $EvidenceLines.Add($EvidenceRow) + } + } +} +$EvidenceLines.Add('') +$EvidenceLines.Add('## Known process-isolation requirement') +$EvidenceLines.Add('') +$EvidenceLines.Add('Issue #174 remains an expected limitation: ExchangeOnlineManagement and Az.Storage can require incompatible Microsoft.OData major versions. Both import orders are tested, but separate PowerShell processes remain the documented safe boundary until fresh evidence supports a narrower rule.') +$EvidenceLines.Add('') +$EvidenceLines.Add('## Preserved regression coverage') +$EvidenceLines.Add('') +$EvidenceLines.Add('- Issue #34: a deterministic negative-control/protected scenario verifies that Microsoft Graph authentication can bind `BaseAbstractApplicationBuilder.WithLogging(IIdentityLogger, Boolean)` after DLLPickle preloading.') +$EvidenceLines.Add('- Issue #193: package and runtime checks keep incidental `Microsoft.Extensions.*` assemblies out of the preload bundle.') +$EvidenceLines.Add('- PR #215: package, runtime, and ALC checks keep `Azure.Core` and `System.ClientModel` out of DLLPickle so Az.Accounts retains ownership of its private Azure SDK load context.') +$EvidenceLines.Add('- Issue #242: a worker-thread assembly-load scenario verifies that DLLPickle does not install PowerShell script-block assembly callbacks that can crash the process.') +$EvidenceLines.Add('') +$EvidenceLines.Add('## Authenticated read-only release gates') +$EvidenceLines.Add('') +$EvidenceLines.Add('The deterministic no-auth tier runs in CI. The following credential-dependent commands are not executed without approved credentials and must not be represented as passing:') +$EvidenceLines.Add('') +foreach ($Module in @($DependencyPolicy.monitoredModules)) { + $GapRow = '- `{0}`: `{1}`' -f $Module.name, $Module.authenticatedReadOnlyProbeCommand + $EvidenceLines.Add($GapRow) +} +$EvidenceLines.Add('') +$EvidenceLines.Add('These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it.') + +$Documents = [ordered]@{ + 'Support-Matrix.md' = ($SupportLines -join $NewLine) + $NewLine + 'Compatibility-Evidence.md' = ($EvidenceLines -join $NewLine) + $NewLine +} + +if ($Check.IsPresent) { + $Drift = [System.Collections.Generic.List[string]]::new() + foreach ($Document in $Documents.GetEnumerator()) { + $Path = Join-Path $OutputDirectory $Document.Key + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $Drift.Add("Missing generated document: $Path") + continue + } + $Actual = Get-Content -LiteralPath $Path -Raw + if ($Actual -ne $Document.Value) { + $Drift.Add("Generated document is stale: $Path") + } + } + if ($Drift.Count -gt 0) { + throw ($Drift -join ' ') + } +} else { + if (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force + } + foreach ($Document in $Documents.GetEnumerator()) { + Set-Content -LiteralPath (Join-Path $OutputDirectory $Document.Key) -Value $Document.Value -Encoding UTF8 -NoNewline + } +} + +[PSCustomObject]@{ + Checked = $Check.IsPresent + Documents = @($Documents.Keys | ForEach-Object { Join-Path $OutputDirectory $_ }) +} diff --git a/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 new file mode 100644 index 00000000..1ebb1482 --- /dev/null +++ b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 @@ -0,0 +1,201 @@ +<# +.SYNOPSIS +Executes deterministic upstream import orders with and without DLLPickle. + +.DESCRIPTION +Uses an exact stock PowerShell executable, exact saved module manifests, and an +isolated module path. Every configured import order runs twice in a fresh process: +without DLLPickle and after DLLPickle preloading. The report captures selected +assemblies and ALC ownership and emits a stable profile-keyed scenario fingerprint. +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$PolicyPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$InventoryPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$PowerShellExecutable, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$DLLPickleManifestPath, + + [Parameter()] + [string]$KnownConflictsPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath, + + [Parameter()] + [switch]$Strict +) + +$ErrorActionPreference = 'Stop' +foreach ($Path in @($PolicyPath, $InventoryPath, $PowerShellExecutable, $DLLPickleManifestPath)) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Required upstream scenario input was not found: $Path" + } +} + +$ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path +$ResolvedInventoryPath = (Resolve-Path -LiteralPath $InventoryPath).Path +$ResolvedPowerShellExecutable = (Resolve-Path -LiteralPath $PowerShellExecutable).Path +$ResolvedDLLPickleManifestPath = (Resolve-Path -LiteralPath $DLLPickleManifestPath).Path +$Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Inventory = Get-Content -LiteralPath $ResolvedInventoryPath -Raw | ConvertFrom-Json -ErrorAction Stop +if (-not $Inventory.Profile -or [string]::IsNullOrWhiteSpace([string]$Inventory.ProfileKey)) { + throw 'The upstream inventory is not keyed to an exact runtime profile.' +} + +$ProfilePolicy = @($Policy.runtimeProfiles | Where-Object { + $_.powerShellLine -eq $Inventory.Profile.PowerShellLine -and + $_.targetFramework -eq $Inventory.Profile.TargetFramework + }) +if ($ProfilePolicy.Count -ne 1) { + throw "No unique dependency-policy profile matches '$($Inventory.ProfileKey)'." +} +$ModulePolicyByName = @{} +foreach ($ModulePolicy in @($Policy.monitoredModules)) { + $ModulePolicyByName[[string]$ModulePolicy.name] = $ModulePolicy +} +$InventoryModuleByName = @{} +foreach ($Module in @($Inventory.Modules)) { + $InventoryModuleByName[[string]$Module.Name] = $Module +} +$ModuleSearchPath = @([string]$Inventory.ModuleCachePath, (Join-Path -Path ([string]$Inventory.Profile.PSHome) -ChildPath 'Modules')) +$SnapshotScriptPath = Join-Path $PSScriptRoot 'Get-DLLPickleRuntimeAssemblySnapshot.ps1' +$ScenarioDefinitions = [System.Collections.Generic.List[object]]::new() +foreach ($ImportOrder in @($ProfilePolicy[0].importOrders)) { + $ScenarioDefinitions.Add([PSCustomObject]@{ + ScenarioId = 'profile-target-scenario' + ImportOrder = @($ImportOrder) + ExpectedLimitation = $false + }) +} +if (-not [string]::IsNullOrWhiteSpace($KnownConflictsPath)) { + if (-not (Test-Path -LiteralPath $KnownConflictsPath -PathType Leaf)) { + throw "Known-conflicts policy was not found: $KnownConflictsPath" + } + $KnownConflicts = @(Get-Content -LiteralPath $KnownConflictsPath -Raw | ConvertFrom-Json -ErrorAction Stop) + foreach ($KnownConflict in @($KnownConflicts | Where-Object { [string]$_.id -in @($ProfilePolicy[0].knownConflictIds) })) { + foreach ($ImportOrder in @($KnownConflict.importOrders)) { + $ScenarioDefinitions.Add([PSCustomObject]@{ + ScenarioId = [string]$KnownConflict.id + ImportOrder = @($ImportOrder) + ExpectedLimitation = [bool]$KnownConflict.requiresProcessIsolation + }) + } + } +} + +$ScenarioResults = [System.Collections.Generic.List[object]]::new() +$OrderIndex = 0 +foreach ($ScenarioDefinition in $ScenarioDefinitions) { + $OrderIndex++ + $ImportOrder = @($ScenarioDefinition.ImportOrder | ForEach-Object { [string]$_ }) + $ManifestPaths = @( + foreach ($ModuleName in $ImportOrder) { + if (-not $InventoryModuleByName.ContainsKey($ModuleName)) { + throw "Upstream inventory '$($Inventory.ProfileKey)' does not contain module '$ModuleName'." + } + $ManifestPath = [string]$InventoryModuleByName[$ModuleName].ModuleManifestPath + if ([string]::IsNullOrWhiteSpace($ManifestPath)) { + throw "Upstream inventory module '$ModuleName' has no exact manifest path." + } + $ManifestPath + } + ) + $ProbeCommands = @( + foreach ($ModuleName in $ImportOrder) { + if (-not $ModulePolicyByName.ContainsKey($ModuleName)) { + throw "Dependency policy has no monitored-module row for '$ModuleName'." + } + [string]$ModulePolicyByName[$ModuleName].deterministicProbeCommand + } + ) + $CombinedProbeCommand = @($ProbeCommands | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join '; ' + + foreach ($PreloadDllPickle in @($false, $true)) { + $Scenario = [ordered]@{ + ScenarioId = [string]$ScenarioDefinition.ScenarioId + OrderIndex = $OrderIndex + ImportOrder = @($ImportOrder) + DllPicklePreloaded = $PreloadDllPickle + ExpectedLimitation = [bool]$ScenarioDefinition.ExpectedLimitation + ProbeCommands = @($ProbeCommands) + Success = $false + Assemblies = @() + Error = $null + } + try { + $SnapshotParameters = @{ + ModuleName = $ImportOrder + ModuleManifestPath = $ManifestPaths + ModuleSearchPath = $ModuleSearchPath + ProbeCommand = $CombinedProbeCommand + PolicyPath = $ResolvedPolicyPath + PowerShellExecutable = $ResolvedPowerShellExecutable + PowerShellVersion = [version]$Inventory.Profile.PowerShellVersion + TargetFramework = [string]$Inventory.Profile.TargetFramework + Strict = $true + } + if ($PreloadDllPickle) { + $SnapshotParameters['PreloadDllPickleManifest'] = $ResolvedDLLPickleManifestPath + } + $Scenario.Assemblies = @(& $SnapshotScriptPath @SnapshotParameters) + $Scenario.Success = $true + } catch { + $Scenario.Error = $_.Exception.Message + } + $ScenarioResults.Add([PSCustomObject]$Scenario) + } +} + +$CanonicalRows = @( + "profile=$($Inventory.ProfileKey)" + foreach ($Scenario in @($ScenarioResults | Sort-Object OrderIndex,DllPicklePreloaded)) { + 'scenario={0}|order={1}|preload={2}|expectedLimitation={3}|success={4}|modules={5}|assemblies={6}' -f ( + $Scenario.ScenarioId, + $Scenario.OrderIndex, + $Scenario.DllPicklePreloaded, + $Scenario.ExpectedLimitation, + $Scenario.Success, + (@($Scenario.ImportOrder) -join ','), + (@($Scenario.Assemblies | Sort-Object Name,Path | ForEach-Object { '{0},{1},{2},{3},{4}' -f $_.Name, $_.Version, $_.Sha256, $_.Path, $_.Alc }) -join ';') + ) + } +) +$FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes(($CanonicalRows -join [char]10)) +$ScenarioFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() +$Report = [PSCustomObject]@{ + SchemaVersion = 1 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + ProfileKey = [string]$Inventory.ProfileKey + Profile = $Inventory.Profile + ValidationTier = 'deterministic-import-no-auth' + WritesPerformed = $false + ScenarioFingerprint = $ScenarioFingerprint + Scenarios = @($ScenarioResults) + Passed = @($ScenarioResults | Where-Object { -not $_.Success }).Count -eq 0 +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Report | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +if ($Strict.IsPresent -and -not $Report.Passed) { + $FailedLabels = @($ScenarioResults | Where-Object { -not $_.Success } | ForEach-Object { "order $($_.OrderIndex), preload=$($_.DllPicklePreloaded)" }) + throw "Deterministic upstream scenarios failed for '$($Inventory.ProfileKey)': $($FailedLabels -join '; ')." +} +$Report diff --git a/tools/Test-DLLPickleFindingFingerprintReported.ps1 b/tools/Test-DLLPickleFindingFingerprintReported.ps1 new file mode 100644 index 00000000..5bcf3a6a --- /dev/null +++ b/tools/Test-DLLPickleFindingFingerprintReported.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS +Tests whether a stable DLLPickle finding fingerprint is already present in report text. + +.DESCRIPTION +This deterministic helper recognizes only the exact HTML marker used by scheduled +GitHub issue bodies and comments. It performs no network access or external writes. +#> + +[CmdletBinding()] +[OutputType([bool])] +param ( + [Parameter(Mandatory)] + [ValidatePattern('^[a-fA-F0-9]{64}$')] + [string]$Fingerprint, + + [Parameter()] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Text = @() +) + +$Marker = '' -f $Fingerprint.ToLowerInvariant() +return @($Text | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_.Contains($Marker) }).Count -gt 0 diff --git a/tools/Test-DLLPicklePackageArtifact.ps1 b/tools/Test-DLLPicklePackageArtifact.ps1 new file mode 100644 index 00000000..ff14bdba --- /dev/null +++ b/tools/Test-DLLPicklePackageArtifact.ps1 @@ -0,0 +1,232 @@ +<# +.SYNOPSIS + Verifies the supported-profile composition of the built DLLPickle module. + +.DESCRIPTION + Asserts that the module contains exactly the target-framework directories declared + by the shipped runtime policy, that each directory matches the build output used by + the packaging task, and that optional multi-pwsh test tooling is absent from the + artifact and runtime/build dependency declarations. + +.PARAMETER ModulePath + Built module directory to inspect. + +.PARAMETER BuildOutputRoot + Root containing one Release build-output directory per target framework. + +.PARAMETER Strict + Throw when any composition finding is present. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module\DLLPickle'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$BuildOutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\bin\Release'), + + [Parameter()] + [switch]$SkipBuildOutputComparison, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ProjectPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\DLLPickle.csproj'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$LockFilePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\packages.lock.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\package\artifact-composition.json'), + + [Parameter()] + [switch]$Strict +) + +$ErrorActionPreference = 'Stop' +$Findings = [System.Collections.Generic.List[object]]::new() + +function Add-DLLPickleArtifactFinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Code, + + [Parameter(Mandatory)] + [string]$Message, + + [Parameter()] + [string]$TargetFramework + ) + + $Findings.Add([PSCustomObject]@{ + Code = $Code + TargetFramework = $TargetFramework + Message = $Message + }) +} + +function Get-DLLPickleRelativeFileSet { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Root, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.IO.FileInfo[]]$Files + ) + + @( + foreach ($File in $Files) { + [System.IO.Path]::GetRelativePath($Root, $File.FullName).Replace('\', '/') + } + ) | Sort-Object -Unique +} + +$RequiredPaths = @($ModulePath, $SupportPolicyPath, $ProjectPath, $LockFilePath) +if (-not $SkipBuildOutputComparison.IsPresent) { + $RequiredPaths += $BuildOutputRoot +} +foreach ($RequiredPath in $RequiredPaths) { + if (-not (Test-Path -LiteralPath $RequiredPath)) { + throw "Required package-inspection path was not found: $RequiredPath" + } +} + +$ResolvedModulePath = (Resolve-Path -LiteralPath $ModulePath).Path +$ResolvedBuildOutputRoot = if ($SkipBuildOutputComparison.IsPresent) { $null } else { (Resolve-Path -LiteralPath $BuildOutputRoot).Path } +$SupportPolicy = Get-Content -LiteralPath $SupportPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop +$ExpectedTargetFrameworks = @($SupportPolicy.profiles.targetFramework | ForEach-Object { [string]$_ } | Sort-Object -Unique) +if ($ExpectedTargetFrameworks.Count -eq 0) { + throw 'The shipped support policy does not declare any target frameworks.' +} + +$BinPath = Join-Path $ResolvedModulePath 'bin' +if (-not (Test-Path -LiteralPath $BinPath -PathType Container)) { + Add-DLLPickleArtifactFinding -Code 'MissingBinDirectory' -Message "Module bin directory was not found: $BinPath" + $ActualTargetFrameworks = @() +} else { + $ActualTargetFrameworks = @(Get-ChildItem -LiteralPath $BinPath -Directory | Select-Object -ExpandProperty Name | Sort-Object -Unique) +} + +foreach ($TargetFramework in $ExpectedTargetFrameworks) { + if ($TargetFramework -notin $ActualTargetFrameworks) { + Add-DLLPickleArtifactFinding -Code 'MissingTargetFramework' -TargetFramework $TargetFramework -Message "Expected target-framework directory '$TargetFramework' is absent from the module artifact." + } +} +foreach ($TargetFramework in $ActualTargetFrameworks) { + if ($TargetFramework -notin $ExpectedTargetFrameworks) { + Add-DLLPickleArtifactFinding -Code 'UnexpectedTargetFramework' -TargetFramework $TargetFramework -Message "Unexpected target-framework directory '$TargetFramework' is present in the module artifact." + } +} + +$ProfileResults = @( + foreach ($TargetFramework in $ExpectedTargetFrameworks) { + $ArtifactTfmPath = Join-Path $BinPath $TargetFramework + $BuildTfmPath = if ($ResolvedBuildOutputRoot) { Join-Path $ResolvedBuildOutputRoot $TargetFramework } else { $null } + if (-not (Test-Path -LiteralPath $ArtifactTfmPath -PathType Container) -or ($BuildTfmPath -and -not (Test-Path -LiteralPath $BuildTfmPath -PathType Container))) { + if ($BuildTfmPath -and -not (Test-Path -LiteralPath $BuildTfmPath -PathType Container)) { + Add-DLLPickleArtifactFinding -Code 'MissingBuildOutput' -TargetFramework $TargetFramework -Message "Build output for '$TargetFramework' was not found at '$BuildTfmPath'." + } + continue + } + + $ActualDlls = @(Get-ChildItem -LiteralPath $ArtifactTfmPath -File -Filter '*.dll' | Select-Object -ExpandProperty Name | Sort-Object -Unique) + if ($ActualDlls.Count -eq 0) { + Add-DLLPickleArtifactFinding -Code 'EmptyTargetFramework' -TargetFramework $TargetFramework -Message "Target-framework directory '$TargetFramework' contains no managed assemblies." + } + if (-not $SkipBuildOutputComparison.IsPresent) { + $ExpectedDlls = @(Get-ChildItem -LiteralPath $BuildTfmPath -File -Filter '*.dll' | Where-Object Name -Match '^(Azure\.|Microsoft\.|System\.)' | Select-Object -ExpandProperty Name | Sort-Object -Unique) + foreach ($Name in @($ExpectedDlls | Where-Object { $_ -notin $ActualDlls })) { + Add-DLLPickleArtifactFinding -Code 'MissingManagedAsset' -TargetFramework $TargetFramework -Message "Expected managed asset '$Name' is absent." + } + foreach ($Name in @($ActualDlls | Where-Object { $_ -notin $ExpectedDlls })) { + Add-DLLPickleArtifactFinding -Code 'UnexpectedManagedAsset' -TargetFramework $TargetFramework -Message "Managed asset '$Name' is not present in the packaging build output." + } + } + + $BuildRuntimePath = if ($BuildTfmPath) { Join-Path $BuildTfmPath 'runtimes' } else { $null } + $ArtifactRuntimePath = Join-Path $ArtifactTfmPath 'runtimes' + $ExpectedNativeFiles = if ($BuildRuntimePath -and (Test-Path -LiteralPath $BuildRuntimePath -PathType Container)) { + Get-DLLPickleRelativeFileSet -Root $BuildRuntimePath -Files @(Get-ChildItem -LiteralPath $BuildRuntimePath -File -Recurse | Where-Object FullName -Match '[\\/]native[\\/]') + } else { + @() + } + $ActualNativeFiles = if (Test-Path -LiteralPath $ArtifactRuntimePath -PathType Container) { + Get-DLLPickleRelativeFileSet -Root $ArtifactRuntimePath -Files @(Get-ChildItem -LiteralPath $ArtifactRuntimePath -File -Recurse) + } else { + @() + } + if (-not $SkipBuildOutputComparison.IsPresent) { + foreach ($Name in @($ExpectedNativeFiles | Where-Object { $_ -notin $ActualNativeFiles })) { + Add-DLLPickleArtifactFinding -Code 'MissingNativeAsset' -TargetFramework $TargetFramework -Message "Expected native asset '$Name' is absent." + } + foreach ($Name in @($ActualNativeFiles | Where-Object { $_ -notin $ExpectedNativeFiles })) { + Add-DLLPickleArtifactFinding -Code 'UnexpectedNativeAsset' -TargetFramework $TargetFramework -Message "Native asset '$Name' is not present in the packaging build output." + } + } + + [PSCustomObject]@{ + TargetFramework = $TargetFramework + ManagedAssets = @($ActualDlls) + NativeAssets = @($ActualNativeFiles) + } + } +) + +$ForbiddenPattern = '(?i)multi[-_ ]?pwsh' +$ForbiddenHits = [System.Collections.Generic.List[object]]::new() +$ArtifactFiles = @(Get-ChildItem -LiteralPath $ResolvedModulePath -File -Recurse) +foreach ($File in $ArtifactFiles) { + $RelativePath = [System.IO.Path]::GetRelativePath($ResolvedModulePath, $File.FullName).Replace('\', '/') + if ($RelativePath -match $ForbiddenPattern) { + $ForbiddenHits.Add([PSCustomObject]@{ Source = 'ArtifactPath'; Path = $RelativePath }) + } + + if ($File.Extension -in @('.ps1', '.psm1', '.psd1', '.ps1xml', '.json', '.xml', '.txt', '.md', '.config')) { + if ((Get-Content -LiteralPath $File.FullName -Raw -ErrorAction Stop) -match $ForbiddenPattern) { + $ForbiddenHits.Add([PSCustomObject]@{ Source = 'ArtifactContent'; Path = $RelativePath }) + } + } +} +foreach ($DeclarationPath in @($ProjectPath, $LockFilePath, (Join-Path $ResolvedModulePath 'DLLPickle.psd1'))) { + if ((Get-Content -LiteralPath $DeclarationPath -Raw -ErrorAction Stop) -match $ForbiddenPattern) { + $ForbiddenHits.Add([PSCustomObject]@{ Source = 'DependencyDeclaration'; Path = $DeclarationPath }) + } +} +foreach ($Hit in $ForbiddenHits) { + Add-DLLPickleArtifactFinding -Code 'ForbiddenMultiPwshReference' -Message "Forbidden multi-pwsh reference detected in $($Hit.Source): $($Hit.Path)" +} + +$Report = [PSCustomObject]@{ + SchemaVersion = 1 + GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + ModulePath = $ResolvedModulePath + ExpectedTargetFrameworks = @($ExpectedTargetFrameworks) + ActualTargetFrameworks = @($ActualTargetFrameworks) + Profiles = @($ProfileResults) + ForbiddenHits = @($ForbiddenHits) + Findings = @($Findings) + Passed = $Findings.Count -eq 0 +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +if ($Strict.IsPresent -and -not $Report.Passed) { + throw "DLLPickle artifact inspection failed: $($Findings.Message -join ' ')" +} + +$Report diff --git a/tools/Test-DLLPickleProfileConflictBaseline.ps1 b/tools/Test-DLLPickleProfileConflictBaseline.ps1 new file mode 100644 index 00000000..1bc58140 --- /dev/null +++ b/tools/Test-DLLPickleProfileConflictBaseline.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS +Fail closed when a profile-specific upstream conflict baseline is absent or has drifted. + +.PARAMETER PolicyPath +Path to the profile-aware dependency policy. + +.PARAMETER ConflictMatrixPath +Path to a current profile-keyed conflict matrix. + +.PARAMETER ScenarioEvidencePath +Path to the deterministic two-order, with/without-DLLPickle scenario report for +the same exact profile. + +.PARAMETER PassThru +Return a structured comparison result after validation. + +.PARAMETER OutputPath +Optional path for the structured result. The result is written before a missing, +unaccepted, or drifted baseline causes the command to fail closed. + +.OUTPUTS +System.Management.Automation.PSCustomObject +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter(Mandatory)] + [string]$PolicyPath, + + [Parameter(Mandatory)] + [string]$ConflictMatrixPath, + + [Parameter(Mandatory)] + [string]$ScenarioEvidencePath, + + [Parameter()] + [switch]$PassThru, + + [Parameter()] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$Policy = Get-Content -LiteralPath $PolicyPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +$Matrix = Get-Content -LiteralPath $ConflictMatrixPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +$ScenarioEvidence = Get-Content -LiteralPath $ScenarioEvidencePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +if (-not $Matrix.Profile -or [string]::IsNullOrWhiteSpace([string]$Matrix.ProfileKey)) { + throw 'The conflict matrix is not keyed to an exact runtime profile.' +} +if ([string]$ScenarioEvidence.ProfileKey -ne [string]$Matrix.ProfileKey) { + throw "Scenario evidence profile '$($ScenarioEvidence.ProfileKey)' does not match conflict profile '$($Matrix.ProfileKey)'." +} +if (-not $ScenarioEvidence.Passed -or $ScenarioEvidence.WritesPerformed -or $ScenarioEvidence.ValidationTier -ne 'deterministic-import-no-auth') { + throw "Scenario evidence for '$($Matrix.ProfileKey)' is not a passing zero-write deterministic tier." +} + +$ProfilePolicy = @($Policy.runtimeProfiles | Where-Object { + $_.powerShellLine -eq $Matrix.Profile.PowerShellLine -and + $_.targetFramework -eq $Matrix.Profile.TargetFramework + }) +if ($ProfilePolicy.Count -ne 1) { + throw "No unique dependency-policy profile matches '$($Matrix.ProfileKey)'." +} + +$Platform = [string]$Matrix.Profile.Platform +$BaselineProperty = $ProfilePolicy[0].baselines.PSObject.Properties | + Where-Object Name -eq $Platform | + Select-Object -First 1 +if (-not $BaselineProperty) { + throw "No $Platform conflict baseline is declared for '$($Matrix.ProfileKey)'." +} + +$Baseline = $BaselineProperty.Value +$BaselineFingerprint = [string]$Baseline.conflictSurfaceFingerprint +$CurrentFingerprint = [string]$Matrix.Fingerprint +$BaselineScenarioFingerprint = [string]$Baseline.scenarioFingerprint +$CurrentScenarioFingerprint = [string]$ScenarioEvidence.ScenarioFingerprint +$Status = if ( + $Baseline.status -ne 'accepted' -or + [string]::IsNullOrWhiteSpace($BaselineFingerprint) -or + [string]::IsNullOrWhiteSpace($BaselineScenarioFingerprint) +) { + 'RequiresAcceptance' +} elseif ($CurrentFingerprint -ne $BaselineFingerprint -or $CurrentScenarioFingerprint -ne $BaselineScenarioFingerprint) { + 'Drifted' +} else { + 'AcceptedUnchanged' +} +$FindingCanonicalText = '{0}|{1}|conflict:{2}>{3}|scenario:{4}>{5}' -f $Matrix.ProfileKey, $Status, $BaselineFingerprint, $CurrentFingerprint, $BaselineScenarioFingerprint, $CurrentScenarioFingerprint +$FindingBytes = [System.Text.Encoding]::UTF8.GetBytes($FindingCanonicalText) +$FindingFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FindingBytes)).Replace('-', '').ToLowerInvariant() +$Result = [pscustomobject]@{ + ProfileKey = [string]$Matrix.ProfileKey + BaselineStatus = [string]$Baseline.status + BaselineFingerprint = $BaselineFingerprint + CurrentFingerprint = $CurrentFingerprint + BaselineScenarioFingerprint = $BaselineScenarioFingerprint + CurrentScenarioFingerprint = $CurrentScenarioFingerprint + FindingFingerprint = $FindingFingerprint + Status = $Status +} + +if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputDirectory = Split-Path -Path $OutputPath -Parent + if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force + } + $Result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +} + +if ($Status -eq 'RequiresAcceptance') { + throw "The conflict baseline for '$($Matrix.ProfileKey)' is not accepted. Current status: '$($Baseline.status)'. Review the profile evidence before release or merge." +} +if ($Status -eq 'Drifted') { + throw "Upstream conflict or import-order scenario drift detected for '$($Matrix.ProfileKey)': baseline conflict '$BaselineFingerprint', current conflict '$CurrentFingerprint'; baseline scenario '$BaselineScenarioFingerprint', current scenario '$CurrentScenarioFingerprint'." +} +if ($PassThru) { $Result } diff --git a/tools/Test-DLLPickleTfmAlignment.ps1 b/tools/Test-DLLPickleTfmAlignment.ps1 index 9638f5fd..8ca9cd54 100644 --- a/tools/Test-DLLPickleTfmAlignment.ps1 +++ b/tools/Test-DLLPickleTfmAlignment.ps1 @@ -1,24 +1,18 @@ <# .SYNOPSIS - Asserts that bundled (preload) NuGet packages ship a net8.0-compatible assembly asset. + Assert that every preload package resolves an assembly asset for every supported TFM. .DESCRIPTION - Implements Step 0(b) of the tracked-dependency release lifecycle in docs/Architecture.md - section 8.2: the explicit "TFM-alignment" inspection. The Build gate (Step 0(a)) proves a - package restores and builds green under --locked-mode; this tool proves the complementary - half - that each preload package actually contains a target-framework asset net8.0 can - consume (net8.0 or a lower netX.0/netcoreapp asset, or a netstandard2.0/2.1/1.x asset), - rather than appearing to work only by luck of transitive resolution. + Implements Step 0(b) of the tracked-dependency release lifecycle. Policy mode reads NuGet's + restored project.assets.json and verifies the actual compile/runtime asset selection for every + preload package under net8.0, net9.0, and net10.0. It does not approximate NuGet compatibility + with a handwritten TFM model. Two modes: - PackageDirectory: inspect a single extracted NuGet package directory (one with a lib/ folder) and return its alignment result. - - Policy: resolve the preload set from build/dependency-policy.json, the restored versions - from packages.lock.json, locate each package under the NuGet global-packages folder, and - return an aggregate report (optionally failing in -Strict mode). - - This is a focused subset of NuGet's compatibility model sufficient for the in-scope MSAL + - IdentityModel families (all ship netstandard2.0 and/or net8.0); it is not a full resolver. + - Policy: inspect NuGet's resolved target graph and selected assets for all supported TFMs, + then return an aggregate report (optionally failing in -Strict mode). .PARAMETER PackageDirectory Path to a single extracted NuGet package directory (containing a lib/ folder) to inspect. @@ -32,9 +26,8 @@ .PARAMETER LockFilePath Path to packages.lock.json, used to resolve each preload package's restored version (Policy mode). -.PARAMETER PackagesRoot - NuGet global-packages folder that holds the restored packages. Defaults to $env:NUGET_PACKAGES, - then to ~/.nuget/packages (Policy mode). +.PARAMETER ProjectAssetsPath + Restored NuGet project.assets.json used as the authority for TFM asset selection. .PARAMETER OutputPath Optional path where the JSON alignment report is written (Policy mode). @@ -71,7 +64,8 @@ param( [string]$LockFilePath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src\DLLPickle.Build\packages.lock.json'), [Parameter(ParameterSetName = 'Policy')] - [string]$PackagesRoot, + [ValidateNotNullOrEmpty()] + [string]$ProjectAssetsPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src\DLLPickle.Build\obj\project.assets.json'), [Parameter(ParameterSetName = 'Policy')] [string]$OutputPath, @@ -223,22 +217,20 @@ function Get-DLLPickleResolvedPackageVersion { [object]$LockObject, [Parameter(Mandatory)] - [string]$Name + [string]$Name, + + [Parameter(Mandatory)] + [string]$TargetFramework ) if (-not $LockObject.dependencies) { return $null } - # Prefer the net8.0 dependency group, then any other, when reading the resolved version. - $Groups = @($LockObject.dependencies.PSObject.Properties | - Sort-Object -Property { if ($_.Name -eq 'net8.0') { 0 } else { 1 } }) - - foreach ($Group in $Groups) { + $Group = $LockObject.dependencies.PSObject.Properties | Where-Object Name -eq $TargetFramework | Select-Object -First 1 + if ($Group) { $Entry = $Group.Value.PSObject.Properties | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 - if ($Entry) { - return [string]$Entry.Value.resolved - } + if ($Entry) { return [string]$Entry.Value.resolved } } return $null @@ -250,49 +242,83 @@ if ($PSCmdlet.ParameterSetName -eq 'PackageDirectory') { return } -if ([string]::IsNullOrWhiteSpace($PackagesRoot)) { - $PackagesRoot = if (-not [string]::IsNullOrWhiteSpace($env:NUGET_PACKAGES)) { - $env:NUGET_PACKAGES - } else { - Join-Path -Path $HOME -ChildPath '.nuget' -AdditionalChildPath 'packages' - } -} - $ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path $ResolvedLockPath = (Resolve-Path -LiteralPath $LockFilePath).Path +$ResolvedProjectAssetsPath = (Resolve-Path -LiteralPath $ProjectAssetsPath).Path $Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json $Lock = Get-Content -LiteralPath $ResolvedLockPath -Raw | ConvertFrom-Json +$ProjectAssets = Get-Content -LiteralPath $ResolvedProjectAssetsPath -Raw | ConvertFrom-Json +$TargetFrameworks = if ($Policy.PSObject.Properties.Name -contains 'runtimeProfiles') { + @($Policy.runtimeProfiles.targetFramework | Sort-Object -Unique) +} else { + @($Lock.dependencies.PSObject.Properties.Name | Sort-Object -Unique) +} -$PackageResults = foreach ($Pin in @($Policy.preload)) { - $Name = [string]$Pin.packageName - $Version = Get-DLLPickleResolvedPackageVersion -LockObject $Lock -Name $Name +$PackageResults = foreach ($TargetFramework in $TargetFrameworks) { + $TargetGraphProperty = $ProjectAssets.targets.PSObject.Properties | + Where-Object Name -eq $TargetFramework | + Select-Object -First 1 + + foreach ($Pin in @($Policy.preload)) { + $Name = [string]$Pin.packageName + $Version = Get-DLLPickleResolvedPackageVersion -LockObject $Lock -Name $Name -TargetFramework $TargetFramework + $SelectedAssets = @() + $Reason = $null + + if ([string]::IsNullOrWhiteSpace($Version)) { + $Reason = "No resolved version for '$Name' was found in '$ResolvedLockPath' under '$TargetFramework'." + } elseif (-not $TargetGraphProperty) { + $Reason = "NuGet project.assets.json contains no restored target graph for '$TargetFramework'." + } else { + $PackageKey = '{0}/{1}' -f $Name, $Version + $PackageProperty = $TargetGraphProperty.Value.PSObject.Properties | + Where-Object Name -ieq $PackageKey | + Select-Object -First 1 + if (-not $PackageProperty) { + $Reason = "NuGet selected no '$PackageKey' entry for '$TargetFramework'." + } else { + $RuntimeAssets = if ($PackageProperty.Value.runtime) { + @($PackageProperty.Value.runtime.PSObject.Properties.Name | Where-Object { $_ -match '\.dll$' }) + } else { + @() + } + $CompileAssets = if ($PackageProperty.Value.compile) { + @($PackageProperty.Value.compile.PSObject.Properties.Name | Where-Object { $_ -match '\.dll$' }) + } else { + @() + } + $SelectedAssets = if ($RuntimeAssets.Count -gt 0) { $RuntimeAssets } else { $CompileAssets } + if ($SelectedAssets.Count -gt 0) { + $Reason = "NuGet selected asset(s) for ${TargetFramework}: $($SelectedAssets -join ', ')." + } else { + $Reason = "NuGet selected '$PackageKey' for '$TargetFramework' but no assembly compile/runtime asset." + } + } + } - if ([string]::IsNullOrWhiteSpace($Version)) { [PSCustomObject]@{ PackageName = $Name - ResolvedVersion = $null - PackageDirectory = $null - IsAligned = $false - CompatibleAssets = @() - AvailableAssets = @() - Reason = "No resolved version for '$Name' was found in '$ResolvedLockPath'." + ResolvedVersion = $Version + TargetFramework = $TargetFramework + IsAligned = $SelectedAssets.Count -gt 0 + SelectedAssets = @($SelectedAssets) + CompatibleAssets = @($SelectedAssets) + AvailableAssets = @($SelectedAssets) + Reason = $Reason } - continue } - - $PackagePath = Join-Path -Path $PackagesRoot -ChildPath $Name.ToLowerInvariant() -AdditionalChildPath $Version.ToLowerInvariant() - Test-DLLPickleSinglePackageAlignment -PackagePath $PackagePath -Name $Name -ResolvedVersion $Version } $PackageResultArray = @($PackageResults) -$Misaligned = @($PackageResultArray | Where-Object { -not $_.IsAligned } | ForEach-Object { $_.PackageName }) +$Misaligned = @($PackageResultArray | Where-Object { -not $_.IsAligned } | ForEach-Object { '{0}/{1}' -f $_.TargetFramework, $_.PackageName }) $IsAligned = ($PackageResultArray.Count -gt 0) -and ($Misaligned.Count -eq 0) $Report = [PSCustomObject]@{ GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') PolicyPath = $ResolvedPolicyPath LockFilePath = $ResolvedLockPath - PackagesRoot = $PackagesRoot + ProjectAssetsPath = $ResolvedProjectAssetsPath + TargetFrameworks = $TargetFrameworks IsAligned = $IsAligned Packages = $PackageResultArray Misaligned = $Misaligned @@ -307,7 +333,7 @@ if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { } if ($Strict.IsPresent -and -not $IsAligned) { - throw ("TFM alignment check failed: the following preload package(s) ship no net8.0/netstandard2.0-compatible asset: {0}." -f ($Misaligned -join ', ')) + throw ("TFM alignment check failed: NuGet selected no assembly asset for: {0}." -f ($Misaligned -join ', ')) } $Report diff --git a/tools/Update-DLLPickleDependencyPins.ps1 b/tools/Update-DLLPickleDependencyPins.ps1 index 64bd7856..2ebdb7a5 100644 --- a/tools/Update-DLLPickleDependencyPins.ps1 +++ b/tools/Update-DLLPickleDependencyPins.ps1 @@ -12,7 +12,9 @@ applied. .PARAMETER InventoryPath - Path to a JSON report produced by Get-DLLPickleUpstreamInventory.ps1. + One or more JSON reports produced by Get-DLLPickleUpstreamInventory.ps1. When + profile-aware reports are supplied, every target framework is reconciled across + its operating-system inventories before a common pin is changed. .PARAMETER PolicyPath Path to the dependency policy JSON file. @@ -37,7 +39,7 @@ param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string]$InventoryPath, + [string[]]$InventoryPath, [Parameter()] [ValidateNotNullOrEmpty()] @@ -132,11 +134,46 @@ function ConvertTo-DLLPickleUpdatedPackageReferenceContent { $UpdatedContent } -$ResolvedInventoryPath = (Resolve-Path -LiteralPath $InventoryPath).Path +function Get-DLLPicklePinTargetFramework { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Pin + ) + + $TargetFrameworks = @( + if ($Pin.PSObject.Properties.Name -contains 'targetFrameworks') { + @($Pin.targetFrameworks) + } elseif ($Pin.PSObject.Properties.Name -contains 'targetFramework') { + @($Pin.targetFramework) + } + ) | + ForEach-Object { [string]$_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + + if ($TargetFrameworks.Count -eq 0) { + throw "Dependency pin '$($Pin.packageName)' does not declare targetFrameworks." + } + + return @($TargetFrameworks) +} + +$ResolvedInventoryPaths = @($InventoryPath | ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | Sort-Object -Unique) $ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path $ResolvedProjectPath = (Resolve-Path -LiteralPath $ProjectPath).Path -$Inventory = Get-Content -LiteralPath $ResolvedInventoryPath -Raw | ConvertFrom-Json +$Inventories = @( + foreach ($ResolvedInventoryPath in $ResolvedInventoryPaths) { + $Inventory = Get-Content -LiteralPath $ResolvedInventoryPath -Raw | ConvertFrom-Json + [PSCustomObject]@{ + Path = $ResolvedInventoryPath + Data = $Inventory + ProfileKey = if ($Inventory.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Inventory.ProfileKey } else { $null } + TargetFramework = if ($Inventory.Profile -and $Inventory.Profile.PSObject.Properties.Name -contains 'TargetFramework') { [string]$Inventory.Profile.TargetFramework } else { $null } + } + } +) $Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json $ProjectContent = @(Get-Content -LiteralPath $ResolvedProjectPath) $ProjectChanged = $false @@ -145,89 +182,194 @@ $Changes = New-Object System.Collections.Generic.List[object] $Warnings = New-Object System.Collections.Generic.List[string] foreach ($Pin in @($Policy.preload)) { + $TargetFrameworks = @(Get-DLLPicklePinTargetFramework -Pin $Pin) $SourceModuleLookup = @{} foreach ($SourceModule in @($Pin.sourceModules)) { $SourceModuleLookup[[string]$SourceModule] = $true } - $CandidateAssemblies = @( - foreach ($Module in @($Inventory.Modules)) { - if (-not $SourceModuleLookup[[string]$Module.Name]) { - continue - } + $TfmCandidates = [System.Collections.Generic.List[object]]::new() + $CandidateResolutionFailed = $false + foreach ($TargetFramework in $TargetFrameworks) { + $RelevantInventories = @($Inventories | Where-Object { + [string]::IsNullOrWhiteSpace($_.TargetFramework) -or $_.TargetFramework -eq $TargetFramework + }) + if ($RelevantInventories.Count -eq 0) { + $Warnings.Add("No upstream inventory was supplied for target framework '$TargetFramework'.") + $CandidateResolutionFailed = $true + continue + } - foreach ($Assembly in @($Module.TrackedAssemblies)) { - if ([string]$Assembly.Name -eq [string]$Pin.assemblyName) { - [PSCustomObject]@{ - ModuleName = [string]$Module.Name - ModuleVersion = [string]$Module.Version - AssemblyName = [string]$Assembly.Name - AssemblyVersion = [string]$Assembly.Version - PackageVersion = ConvertTo-DLLPickleNuGetVersion -AssemblyVersion ([string]$Assembly.Version) - RelativePath = [string]$Assembly.RelativePath + $PerInventoryCandidates = @( + foreach ($InventoryRecord in $RelevantInventories) { + $Candidates = @( + foreach ($Module in @($InventoryRecord.Data.Modules)) { + if (-not $SourceModuleLookup[[string]$Module.Name]) { + continue + } + + foreach ($Assembly in @($Module.TrackedAssemblies)) { + if ([string]$Assembly.Name -eq [string]$Pin.assemblyName) { + [PSCustomObject]@{ + InventoryPath = [string]$InventoryRecord.Path + ProfileKey = [string]$InventoryRecord.ProfileKey + TargetFramework = $TargetFramework + ModuleName = [string]$Module.Name + ModuleVersion = [string]$Module.Version + AssemblyName = [string]$Assembly.Name + AssemblyVersion = [string]$Assembly.Version + PackageVersion = ConvertTo-DLLPickleNuGetVersion -AssemblyVersion ([string]$Assembly.Version) + RelativePath = [string]$Assembly.RelativePath + } + } + } } + ) + if ($Candidates.Count -eq 0) { + $Warnings.Add(("No upstream assembly '{0}' was found for target framework '{1}' in source modules: {2}" -f $Pin.assemblyName, $TargetFramework, (@($Pin.sourceModules) -join ', '))) + $CandidateResolutionFailed = $true + continue } + $Candidates | Sort-Object -Property { [version]$_.AssemblyVersion } -Descending | Select-Object -First 1 } + ) + if ($PerInventoryCandidates.Count -ne $RelevantInventories.Count) { + $CandidateResolutionFailed = $true + continue } - ) - if ($CandidateAssemblies.Count -eq 0) { - $Warnings.Add(("No upstream assembly '{0}' was found in source modules: {1}" -f $Pin.assemblyName, (@($Pin.sourceModules) -join ', '))) + $DistinctVersions = @($PerInventoryCandidates.PackageVersion | Sort-Object -Unique) + $TfmCandidates.Add([PSCustomObject]@{ + TargetFramework = $TargetFramework + PackageVersion = [string]($PerInventoryCandidates | Sort-Object -Property { [version]$_.PackageVersion } -Descending | Select-Object -First 1).PackageVersion + CrossPlatformConsistent = $DistinctVersions.Count -eq 1 + ProfileCandidates = @($PerInventoryCandidates) + }) + if ($DistinctVersions.Count -ne 1) { + $Warnings.Add(("Upstream assembly '{0}' resolves to inconsistent package versions for target framework '{1}': {2}. No automatic update was applied." -f $Pin.assemblyName, $TargetFramework, ($DistinctVersions -join ', '))) + } + } + + if ($CandidateResolutionFailed -or $TfmCandidates.Count -ne $TargetFrameworks.Count) { continue } - $TargetAssembly = $CandidateAssemblies | - Sort-Object -Property { [version]$_.AssemblyVersion } -Descending | - Select-Object -First 1 - $TargetVersion = [string]$TargetAssembly.PackageVersion $IsCapped = -not [string]::IsNullOrWhiteSpace([string]$Pin.maximumPackageVersion) if ($IsCapped) { $MaximumPackageVersion = [string]$Pin.maximumPackageVersion - if ([version]$TargetVersion -gt [version]$MaximumPackageVersion) { + $HighestCandidateVersion = @($TfmCandidates.PackageVersion | Sort-Object { [version]$_ } -Descending)[0] + $CappedTargetFrameworks = @($TfmCandidates | Where-Object { [version]$_.PackageVersion -gt [version]$MaximumPackageVersion } | ForEach-Object TargetFramework) + if ($CappedTargetFrameworks.Count -gt 0) { $Warnings.Add(( - "PackageReference '{0}' candidate '{1}' exceeds maximum '{2}' for target framework '{3}'; using maximum version." -f + "PackageReference '{0}' candidate '{1}' exceeds maximum '{2}' for target frameworks '{3}'; using maximum version." -f $Pin.packageName, - $TargetVersion, + $HighestCandidateVersion, $MaximumPackageVersion, - $Pin.targetFramework + ($CappedTargetFrameworks -join ', ') )) - $TargetVersion = $MaximumPackageVersion } } $VersionPolicy = if ($Pin.versionPolicy) { [string]$Pin.versionPolicy } else { 'exact' } - # A minorPatchFloat rule normally writes a floating 'N.*' reference. But when the entry also - # declares maximumPackageVersion, a floating reference would let restore resolve ABOVE the cap, - # so a capped entry is written as an exact '[x.y.z]' pinned at the capped target to enforce it. - $FormattedVersion = if ($VersionPolicy -eq 'minorPatchFloat' -and -not $IsCapped) { - '{0}.*' -f ([version]$TargetVersion).Major - } else { - '[{0}]' -f $TargetVersion + foreach ($TfmCandidate in $TfmCandidates) { + $TargetVersion = [string]$TfmCandidate.PackageVersion + if ($IsCapped -and [version]$TargetVersion -gt [version]$MaximumPackageVersion) { + $TargetVersion = $MaximumPackageVersion + } + # A capped float must be exact; otherwise restore could resolve above the cap. + $TfmCandidate | Add-Member -NotePropertyName FormattedVersion -NotePropertyValue $(if ($VersionPolicy -eq 'minorPatchFloat' -and -not $IsCapped) { + '{0}.*' -f ([version]$TargetVersion).Major + } else { + '[{0}]' -f $TargetVersion + }) } - $CurrentReference = Get-DLLPickleCurrentPackageReference -ProjectContent $ProjectContent -PackageName ([string]$Pin.packageName) -TargetFramework ([string]$Pin.targetFramework) - if (-not $CurrentReference) { - $Warnings.Add(("PackageReference '{0}' for target framework '{1}' was not found; no automatic insert was attempted." -f $Pin.packageName, $Pin.targetFramework)) + $CurrentReferences = @( + foreach ($TargetFramework in $TargetFrameworks) { + $Reference = Get-DLLPickleCurrentPackageReference -ProjectContent $ProjectContent -PackageName ([string]$Pin.packageName) -TargetFramework $TargetFramework + [PSCustomObject]@{ + TargetFramework = $TargetFramework + Reference = $Reference + } + } + ) + $MissingTargetFrameworks = @($CurrentReferences | Where-Object { -not $_.Reference } | Select-Object -ExpandProperty TargetFramework) + + if ($MissingTargetFrameworks.Count -gt 0) { + $Warnings.Add(("PackageReference '{0}' was not found for target frameworks '{1}'; no automatic insert or partial update was attempted." -f $Pin.packageName, ($MissingTargetFrameworks -join ', '))) continue } + $UniqueReferenceIndices = @($CurrentReferences.Reference.Index | Select-Object -Unique) + $UsesConditionalReferences = $UniqueReferenceIndices.Count -gt 1 + $DistinctCandidateVersions = @($TfmCandidates.FormattedVersion | Sort-Object -Unique) + $ConditionalPinRequired = $DistinctCandidateVersions.Count -gt 1 -and -not $UsesConditionalReferences + $CrossPlatformConsistent = @($TfmCandidates | Where-Object { -not $_.CrossPlatformConsistent }).Count -eq 0 + $TfmResults = @( + foreach ($CurrentReference in $CurrentReferences) { + $TfmCandidate = @($TfmCandidates | Where-Object TargetFramework -EQ $CurrentReference.TargetFramework)[0] + [PSCustomObject]@{ + TargetFramework = [string]$CurrentReference.TargetFramework + CurrentVersion = [string]$CurrentReference.Reference.Version + CandidateVersion = [string]$TfmCandidate.FormattedVersion + ReferenceIndex = [int]$CurrentReference.Reference.Index + CrossPlatformConsistent = [bool]$TfmCandidate.CrossPlatformConsistent + Applied = $false + } + } + ) + + $AllProfileCandidates = @($TfmCandidates.ProfileCandidates) + $TargetAssembly = $AllProfileCandidates | Sort-Object -Property { [version]$_.AssemblyVersion } -Descending | Select-Object -First 1 + $Change = [PSCustomObject]@{ PackageName = [string]$Pin.packageName - TargetFramework = [string]$Pin.targetFramework - CurrentVersion = [string]$CurrentReference.Version - CandidateVersion = $FormattedVersion + TargetFrameworks = @($TargetFrameworks) + CurrentVersions = @($CurrentReferences.Reference.Version | Select-Object -Unique) + CandidateVersion = if ($DistinctCandidateVersions.Count -eq 1) { [string]$DistinctCandidateVersions[0] } else { $null } + CandidateVersions = @($DistinctCandidateVersions) SourceModule = [string]$TargetAssembly.ModuleName SourceModuleVersion = [string]$TargetAssembly.ModuleVersion SourceAssemblyVersion = [string]$TargetAssembly.AssemblyVersion + UsesConditionalReferences = $UsesConditionalReferences + ConditionalPinRequired = $ConditionalPinRequired + CrossPlatformConsistent = $CrossPlatformConsistent + ReviewRequired = $UsesConditionalReferences -or $ConditionalPinRequired -or -not $CrossPlatformConsistent + TfmResults = @($TfmResults) Applied = $false Reason = [string]$Pin.reason } - if ([string]$CurrentReference.Version -ne $FormattedVersion) { - if ($PSCmdlet.ShouldProcess($ResolvedProjectPath, ("Update {0} {1} from {2} to {3}" -f $Pin.packageName, $Pin.targetFramework, $CurrentReference.Version, $FormattedVersion))) { - $ProjectContent = @(ConvertTo-DLLPickleUpdatedPackageReferenceContent -ProjectContent $ProjectContent -Index $CurrentReference.Index -NewVersion $FormattedVersion) + if ($ConditionalPinRequired) { + $Warnings.Add(("PackageReference '{0}' requires different versions by target framework ({1}); introducing conditional references requires maintainer review and was not automated." -f $Pin.packageName, ($DistinctCandidateVersions -join ', '))) + $Changes.Add($Change) + continue + } + if (-not $CrossPlatformConsistent) { + $Changes.Add($Change) + continue + } + + foreach ($ReferenceIndex in $UniqueReferenceIndices) { + $ReferencesAtIndex = @($CurrentReferences | Where-Object { $_.Reference.Index -eq $ReferenceIndex }) + $CurrentVersion = [string]$ReferencesAtIndex[0].Reference.Version + $ReferenceCandidateVersions = @($TfmResults | Where-Object ReferenceIndex -EQ $ReferenceIndex | ForEach-Object CandidateVersion | Sort-Object -Unique) + if ($ReferenceCandidateVersions.Count -ne 1) { + throw "PackageReference '$($Pin.packageName)' maps one project entry to incompatible target-framework candidates." + } + $FormattedVersion = [string]$ReferenceCandidateVersions[0] + if ($CurrentVersion -eq $FormattedVersion) { + continue + } + + $ReferenceTargetFrameworks = @($ReferencesAtIndex.TargetFramework) + if ($PSCmdlet.ShouldProcess($ResolvedProjectPath, ("Update {0} for {1} from {2} to {3}" -f $Pin.packageName, ($ReferenceTargetFrameworks -join ', '), $CurrentVersion, $FormattedVersion))) { + $ProjectContent = @(ConvertTo-DLLPickleUpdatedPackageReferenceContent -ProjectContent $ProjectContent -Index $ReferenceIndex -NewVersion $FormattedVersion) $ProjectChanged = $true $Change.Applied = $true + foreach ($TfmResult in @($Change.TfmResults | Where-Object { $_.ReferenceIndex -eq $ReferenceIndex })) { + $TfmResult.Applied = $true + } } } @@ -236,17 +378,21 @@ foreach ($Pin in @($Policy.preload)) { $BlockedFindings = @( foreach ($BlockedAssembly in @($Policy.blockedPreloadAssemblies)) { - foreach ($Module in @($Inventory.Modules)) { - foreach ($Assembly in @($Module.TrackedAssemblies)) { - if ([string]$Assembly.Name -eq [string]$BlockedAssembly.assemblyName) { - [PSCustomObject]@{ - AssemblyName = [string]$Assembly.Name - Version = [string]$Assembly.Version - ModuleName = [string]$Module.Name - ModuleVersion = [string]$Module.Version - RelativePath = [string]$Assembly.RelativePath - Action = [string]$BlockedAssembly.updateMode - Reason = [string]$BlockedAssembly.reason + foreach ($InventoryRecord in $Inventories) { + foreach ($Module in @($InventoryRecord.Data.Modules)) { + foreach ($Assembly in @($Module.TrackedAssemblies)) { + if ([string]$Assembly.Name -eq [string]$BlockedAssembly.assemblyName) { + [PSCustomObject]@{ + AssemblyName = [string]$Assembly.Name + Version = [string]$Assembly.Version + ModuleName = [string]$Module.Name + ModuleVersion = [string]$Module.Version + RelativePath = [string]$Assembly.RelativePath + ProfileKey = [string]$InventoryRecord.ProfileKey + TargetFrameworks = @($BlockedAssembly.targetFrameworks) + Action = [string]$BlockedAssembly.updateMode + Reason = [string]$BlockedAssembly.reason + } } } } @@ -273,10 +419,11 @@ if ($ProjectChanged) { $Report = [PSCustomObject]@{ GeneratedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') - InventoryPath = $ResolvedInventoryPath + InventoryPaths = @($ResolvedInventoryPaths) PolicyPath = $ResolvedPolicyPath ProjectPath = $ResolvedProjectPath ProjectChanged = $ProjectChanged + ReviewRequired = @($Changes | Where-Object ReviewRequired).Count -gt 0 Changes = @($Changes.ToArray()) BlockedFindings = @($BlockedFindings) Warnings = @($Warnings.ToArray()) diff --git a/tools/Update-DLLPicklePowerShellTestMatrix.ps1 b/tools/Update-DLLPicklePowerShellTestMatrix.ps1 new file mode 100644 index 00000000..9b06a4da --- /dev/null +++ b/tools/Update-DLLPicklePowerShellTestMatrix.ps1 @@ -0,0 +1,184 @@ +<# +.SYNOPSIS +Builds a validated exact-patch matrix proposal from support-update discovery evidence. + +.DESCRIPTION +Preparation mode writes a non-authoritative candidate matrix used only to provision +checksum-verified official archives. Finalization requires one runtime identity for +every declared operating-system lane of every patch update, proves the identities +agree on the bundled .NET runtime, and writes the reviewable matrix proposal. + +This command only writes local files. It never commits, pushes, opens a pull request, +or modifies an issue. +#> + +[CmdletBinding(DefaultParameterSetName = 'Finalize')] +[OutputType([pscustomobject])] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$UpdateReportPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath, + + [Parameter(Mandatory, ParameterSetName = 'Prepare')] + [switch]$PrepareCandidate, + + [Parameter(Mandatory, ParameterSetName = 'Finalize')] + [ValidateNotNullOrEmpty()] + [string[]]$RuntimeIdentityPath, + + [Parameter(ParameterSetName = 'Finalize')] + [datetime]$VerifiedAtUtc = [datetime]::UtcNow +) + +$ErrorActionPreference = 'Stop' +$IsPreparation = $PrepareCandidate.IsPresent +foreach ($Path in @($TestMatrixPath, $UpdateReportPath)) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Required PowerShell matrix update input was not found: $Path" + } +} + +$Matrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Report = Get-Content -LiteralPath $UpdateReportPath -Raw | ConvertFrom-Json -ErrorAction Stop +$PatchUpdates = @($Report.PatchUpdates) +if ($PatchUpdates.Count -eq 0) { + throw 'The support-update report contains no servicing patch proposal.' +} +if (-not $Report.MatrixOnlyUpdateAvailable -or $Report.SupportContractReviewRequired) { + throw 'A patch-only matrix proposal cannot include a new or retiring support line.' +} +if (@($PatchUpdates | Where-Object { -not $_.ChecksumsComplete }).Count -gt 0) { + throw 'Every proposed official archive must have a complete SHA-256 digest.' +} + +$IdentityRecords = @() +if (-not $IsPreparation) { + $IdentityFiles = @( + foreach ($IdentityPath in $RuntimeIdentityPath) { + if (Test-Path -LiteralPath $IdentityPath -PathType Container) { + Get-ChildItem -LiteralPath $IdentityPath -File -Filter '*.json' -Recurse + } elseif (Test-Path -LiteralPath $IdentityPath -PathType Leaf) { + Get-Item -LiteralPath $IdentityPath + } else { + throw "Runtime identity input was not found: $IdentityPath" + } + } + ) + foreach ($IdentityFile in $IdentityFiles) { + $Identity = Get-Content -LiteralPath $IdentityFile.FullName -Raw | ConvertFrom-Json -ErrorAction Stop + if ( + $Identity.PSObject.Properties.Name -contains 'PowerShellVersion' -and + $Identity.PSObject.Properties.Name -contains 'DotNetVersion' -and + $Identity.PSObject.Properties.Name -contains 'Platform' -and + $Identity.PSObject.Properties.Name -contains 'Architecture' + ) { + $IdentityRecords += $Identity + } + } +} + +$UpdatedLines = [System.Collections.Generic.List[object]]::new() +foreach ($PatchUpdate in $PatchUpdates) { + $CurrentVersion = [string]$PatchUpdate.CurrentVersion + $CandidateVersion = [string]$PatchUpdate.CandidateVersion + $MatrixProfile = @($Matrix.profiles | Where-Object powerShellVersion -EQ $CurrentVersion) + if ($MatrixProfile.Count -ne 1) { + throw "The current matrix does not contain exactly one profile for PowerShell $CurrentVersion." + } + + $CandidateArchives = @($PatchUpdate.Archives) + if ($CandidateArchives.Count -ne @($Matrix.lanes).Count) { + throw "PowerShell $CandidateVersion does not have one official archive for every declared lane." + } + foreach ($Lane in @($Matrix.lanes)) { + $CandidateArchive = @($CandidateArchives | Where-Object { + $_.Platform -eq $Lane.platform -and $_.Architecture -eq $Lane.architecture + }) + if ($CandidateArchive.Count -ne 1 -or -not $CandidateArchive[0].Complete) { + throw "PowerShell $CandidateVersion lacks one complete official archive for $($Lane.platform)/$($Lane.architecture)." + } + $ExistingArchive = @($Matrix.archiveAssets | Where-Object { + $_.powerShellVersion -eq $CurrentVersion -and + $_.platform -eq $Lane.platform -and + $_.architecture -eq $Lane.architecture + }) + if ($ExistingArchive.Count -ne 1) { + throw "The current matrix lacks one archive row for PowerShell $CurrentVersion on $($Lane.platform)/$($Lane.architecture)." + } + $ExistingArchive[0].powerShellVersion = $CandidateVersion + $ExistingArchive[0].fileName = [string]$CandidateArchive[0].FileName + $ExistingArchive[0].sha256 = ([string]$CandidateArchive[0].Sha256).ToLowerInvariant() + $ExistingArchive[0].downloadUrl = [string]$CandidateArchive[0].DownloadUrl + } + + $DotNetRuntimeVersion = [string]$MatrixProfile[0].dotnetRuntimeVersion + if (-not $IsPreparation) { + $CandidateIdentities = @($IdentityRecords | Where-Object { [string]$_.PowerShellVersion -eq $CandidateVersion }) + if ($CandidateIdentities.Count -ne @($Matrix.lanes).Count) { + throw "PowerShell $CandidateVersion requires $(@($Matrix.lanes).Count) runtime identities; found $($CandidateIdentities.Count)." + } + foreach ($Lane in @($Matrix.lanes)) { + $LaneIdentity = @($CandidateIdentities | Where-Object { + [string]$_.Platform -eq [string]$Lane.platform -and + [string]$_.Architecture -eq [string]$Lane.architecture + }) + if ($LaneIdentity.Count -ne 1) { + throw "PowerShell $CandidateVersion requires one runtime identity for $($Lane.platform)/$($Lane.architecture)." + } + if ([version]$LaneIdentity[0].PowerShellVersion -ne [version]$CandidateVersion) { + throw "A candidate runtime identity does not report the exact PowerShell patch $CandidateVersion." + } + if ([version]$LaneIdentity[0].DotNetVersion -lt [version]'1.0') { + throw "PowerShell $CandidateVersion reported an invalid .NET runtime identity." + } + } + $DotNetVersions = @($CandidateIdentities.DotNetVersion | ForEach-Object { ([version]$_).ToString() } | Sort-Object -Unique) + if ($DotNetVersions.Count -ne 1) { + throw "PowerShell $CandidateVersion archives disagree on the bundled .NET runtime: $($DotNetVersions -join ', ')." + } + $DotNetRuntimeVersion = [string]$DotNetVersions[0] + if (([version]$DotNetRuntimeVersion).Major -ne [int]$MatrixProfile[0].dotnetMajor) { + throw "PowerShell $CandidateVersion reports .NET $DotNetRuntimeVersion, outside declared CLR major $($MatrixProfile[0].dotnetMajor)." + } + } + + $MatrixProfile[0].powerShellVersion = $CandidateVersion + $MatrixProfile[0].dotnetRuntimeVersion = $DotNetRuntimeVersion + $UpdatedLines.Add([PSCustomObject]@{ + ReleaseLine = [string]$PatchUpdate.ReleaseLine + CurrentVersion = $CurrentVersion + CandidateVersion = $CandidateVersion + DotNetRuntimeVersion = $DotNetRuntimeVersion + RuntimeIdentityStatus = if ($IsPreparation) { 'pending-all-lanes' } else { 'verified-all-lanes' } + }) +} + +if ($IsPreparation) { + $Matrix | Add-Member -NotePropertyName candidateValidationPending -NotePropertyValue $true -Force +} else { + $VerifiedTimestamp = $VerifiedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) + $Matrix | Add-Member -NotePropertyName lastVerifiedUtc -NotePropertyValue $VerifiedTimestamp -Force + if ($Matrix.PSObject.Properties.Name -contains 'candidateValidationPending') { + $Matrix.PSObject.Properties.Remove('candidateValidationPending') + } +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Matrix | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +[PSCustomObject]@{ + Mode = if ($IsPreparation) { 'CandidatePreparation' } else { 'VerifiedProposal' } + OutputPath = (Resolve-Path -LiteralPath $OutputPath).Path + UpdatedLines = @($UpdatedLines) +} From ba188f5a57d97e7bc7b4681870b957c872feec0e Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:54:48 -0400 Subject: [PATCH 03/43] docs: explain multitarget support and auth gates --- CHANGELOG.md | 6 + README.md | 14 +- docs/Architecture.md | 52 ++-- docs/DEPENDENCIES.md | 52 ++-- docs/Deep-Dive.md | 44 +-- docs/Troubleshooting.md | 37 ++- docs/gaps/GAP-003-exo-teams-probe-commands.md | 32 ++- docs/gaps/README.md | 2 +- ...ntialed-authentication-test-environment.md | 258 ++++++++++++++++++ 9 files changed, 404 insertions(+), 93 deletions(-) create mode 100644 docs/plans/2026-08-09-credentialed-authentication-test-environment.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d04b87a..517c6271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documented the manual release dispatch process trap: Architecture §8.3 now spells out which changes auto-publish, which do not, and when a deliberate `workflow_dispatch` run is required, backed by new `Release-and-Publish` path/version-gate guardrail tests (GAP-006). - Documented the unresolved review-thread maintenance workflow so review threads are not resolved as a substitute for code, test, or documentation changes (GAP-010). - Added a structural gap-register guard (`tests/Unit/GapRegister.Tests.ps1`) that validates gap status values, index membership, index/frontmatter status agreement, and `resolution_pr`/`resolved_on` on resolved gaps; surfaced and fixed GAP-001's missing index row (GAP-011). +- Multi-targeted the shipped dependency payload for the exact Microsoft-supported PowerShell profiles currently in scope: 7.4 / `net8.0`, 7.5 / `net9.0`, and 7.6 / `net10.0`. The loader now selects with both PowerShell and CLR versions and fails closed on unknown, malformed, duplicate, or mismatched mappings. +- Added a canonical exact-patch/OS test matrix, checksum-verified stock-host provisioning, scheduled lifecycle enforcement, and a stable aggregate gate spanning all nine PowerShell/OS cells. Optional `multi-pwsh` provisioning remains CI-only and is explicitly rejected from the published artifact. +- Added fingerprint-deduplicated scheduled publication: validated patch-only runtime updates open one automation PR, while new/changed/retiring support lines and profile-aware compatibility findings update one review issue only when their stable evidence fingerprint changes. +- Made upstream inventory, conflict fingerprints, import-order evidence, probe commands, and baselines profile/platform aware. Deterministic no-auth and credential-dependent authenticated read-only tiers are reported separately; unexecuted credentialed probes remain release gates. +- Derived TFM compatibility from NuGet's restored `project.assets.json`, preserved common package versions across all TFMs by default, and routed conditional pins, classification changes, or material artifact growth to maintainer review. +- Added generated Microsoft support and upstream-evidence documentation, exact package-composition inspection, and deterministic per-TFM/full-artifact size reporting with a committed baseline. ## [2.2.2] - 2026-06-22 diff --git a/README.md b/README.md index 768b2322..e1391836 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A PowerShell module that helps you get un-stuck from dependency version conflict ### Prerequisites -PowerShell 7.4 or later on Linux, macOS, or Windows. +One of the Microsoft-supported PowerShell lines in the [generated support matrix](docs/generated/Support-Matrix.md) on Linux, macOS, or Windows. The current contract is PowerShell 7.4, 7.5, and 7.6, each with its matching .NET runtime bundle. ### Installation @@ -57,7 +57,7 @@ For diagnostic detail, add `-ShowLoaderExceptions -Verbose`. - `Get-ModulesWithDependency` — list installed modules that package a given dependency. - `Get-ModulesWithVersionSortedIdentityClient` — compare modules by packaged `Microsoft.Identity.Client.dll` version. -> The inspection helpers are **cross-edition**. `Import-DPLibrary` needs PowerShell 7.4+, but these helpers also scan the Windows PowerShell module roots — so a **Windows PowerShell 5.1** user can run them to find which module to load first and apply the "first one wins" fix manually. +> The inspection helpers are **cross-edition**. `Import-DPLibrary` needs a supported PowerShell/.NET profile, but these helpers also scan the Windows PowerShell module roots — so a **Windows PowerShell 5.1** user can use a supported PowerShell session to find which module to load first and apply the "first one wins" fix manually. Full syntax and examples: [docs index](docs/index.md) · [command reference](docs/DLLPickle.md). @@ -65,9 +65,13 @@ Full syntax and examples: [docs index](docs/index.md) · [command reference](doc Many PowerShell modules — Az, Exchange Online, Microsoft Graph, Teams, and more — bundle their own copy of the Microsoft Authentication Library (MSAL) and related DLLs. A single PowerShell session can only load **one** version of a given DLL, so when two modules ship different versions you hit *"an assembly with the same name is already loaded"* and authentication breaks. -DLL Pickle preloads a current, compatible set of these assemblies **first**, so the "first one wins" rule works in your favor and the modules you load afterward reuse what's already there. A new DLL Pickle release is published automatically whenever a new MSAL version ships — so keep it updated and load it first. +DLL Pickle preloads a current, compatible set of these assemblies **first**, so the "first one wins" rule works in your favor and the modules you load afterward reuse what's already there. Dependency updates are locked, tested across every supported PowerShell/OS profile, and released only after the compatibility gates pass. -For the full explanation (and the real-world issues that motivated it), read the [Deep Dive](docs/Deep-Dive.md). The supported platform is **PowerShell 7.4+ (Core, net8.0)**. Compatibility, versioning, and dependency details live in [DEPENDENCIES.md](docs/DEPENDENCIES.md). +The loader selects `net8.0`, `net9.0`, or `net10.0` from the exact PowerShell minor and CLR major and fails closed on an unknown or mismatched pair. + +For the full explanation (and the real-world issues that motivated it), read the [Deep Dive](docs/Deep-Dive.md). + +Compatibility, versioning, and dependency details live in [DEPENDENCIES.md](docs/DEPENDENCIES.md). ## 📚 Documentation Map @@ -78,5 +82,7 @@ For the full explanation (and the real-world issues that motivated it), read the - Changelog and active work: [CHANGELOG.md](CHANGELOG.md) - Architecture blueprint and planned enhancements: [docs/Architecture.md](docs/Architecture.md) - Dependency, versioning, and supply-chain policy: [DEPENDENCIES.md](docs/DEPENDENCIES.md) +- Generated Microsoft support contract: [Support Matrix](docs/generated/Support-Matrix.md) +- Generated upstream evidence and explicit gaps: [Compatibility Evidence](docs/generated/Compatibility-Evidence.md) - Contribution workflow: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) - Security vulnerability reporting: [SECURITY.md](SECURITY.md) diff --git a/docs/Architecture.md b/docs/Architecture.md index 71ac49ae..d9eeb4a2 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -13,17 +13,18 @@ DLLPickle ships two distinct kinds of capability, and they have **different runt | Tier | Functions | Runtime requirement | What it does | | --- | --- | --- | --- | -| **Preloader (automated fix)** | `Import-DPLibrary`, `Import-DPBaseProfile` | **PowerShell 7.4+ / .NET 8 (`net8.0`) only.** Depends on `AssemblyLoadContext`. | Loads the bundled `bin/net8.0` identity stack into the default ALC so later module loads reuse one coherent version. | +| **Preloader (automated fix)** | `Import-DPLibrary`, `Import-DPBaseProfile` | The exact Microsoft-supported profiles in the [generated support matrix](generated/Support-Matrix.md): PowerShell 7.4 / `net8.0`, 7.5 / `net9.0`, and 7.6 / `net10.0`. Depends on `AssemblyLoadContext`. | Selects the isolated bundle using both the PowerShell minor and CLR major, then loads it into the default ALC so later module loads reuse one coherent version. | | **Inspection / diagnostics (manual aid)** | `Find-DLLInPSModulePath`, `Get-ModuleImportCandidate`, `Get-ModulesWithDependency`, `Get-ModulesWithVersionSortedIdentityClient`, `Test-DPLibraryConflict` (plus `Get-DPConfig`/`Set-DPConfig`) | **Cross-edition by design.** Deliberately also inspects Windows PowerShell module roots, including `Documents\WindowsPowerShell\Modules`; the all-users WinPS root is auto-seeded only when actually running on 5.1. | Reports which installed module ships the newest identity DLL, so a user can decide which service to connect to first. | The inspection tier exists to serve the project charter even for environments the preloader cannot reach: a **Windows PowerShell 5.1** user who hits the same version-conflict problem can run the inspection helpers to discover which module to load first and apply that **manual** "first-one-wins" workaround. See §1.2 for the precise platform-support contract. ### 1.2 Platform-support contract (decisions 1 & 2) -- **The automated preloader targets PowerShell 7.4+ on the `net8.0` runtime profile, and only that.** This is the supported way to *fix* the conflict automatically. It is enforced top to bottom: the manifest declares `PowerShellVersion = '7.4'` and `CompatiblePSEditions = @('Core')` (`src/DLLPickle/DLLPickle.psd1`); the build sets `RequiredPSVersion = '7.4.0'` (`build/DLLPickle.Settings.ps1`); the build project is single-target `net8.0` (`src/DLLPickle.Build/DLLPickle.csproj`); and `Import-DPLibrary` hard-codes `bin/net8.0` and throws if that directory is missing. +- **The automated preloader supports exactly the declared PowerShell/CLR profiles, not a floating “7.4 or later” range.** `src/DLLPickle/SupportedRuntimeProfiles.json` is the shipped behavior map; `build/powershell-test-matrix.json` supplies exact servicing patches and lifecycle evidence for CI. The project targets `net8.0;net9.0;net10.0`, and `Get-DPRuntimeProfile` requires the PowerShell minor, CLR major, and target framework to agree before `Import-DPLibrary` loads the matching `bin/` directory. Unknown, duplicate, malformed, or mismatched profiles fail closed. +- **Servicing patches and support-contract changes have different automation.** A newer patch within a declared line is checksum- and runtime-validated across the exact OS matrix, then published once on a fingerprint-derived automation branch. A new minor line, lifecycle-date change, impending retirement, or expired line opens or updates a deduplicated review issue; it never silently adds or removes a shipped TFM. Release publication fails closed while an expired line remains claimed. - **Windows PowerShell 5.1 / .NET Framework 4.8 is not a supported runtime for the preloader, by design.** The whole self-isolation reasoning in §2 depends on `AssemblyLoadContext`, which does not exist on .NET Framework 4.8. The module manifest is `Core`-only, so DLLPickle is not intended to be *imported and run* under Windows PowerShell 5.1. -- **The inspection/diagnostic tier is deliberately cross-edition.** Its functions are written to run on both editions and to scan the **Windows PowerShell module roots** that the current session can identify (for example `Documents\WindowsPowerShell\Modules`, plus the all-users WinPS root when actually running on 5.1) as well as the PowerShell 7 roots. This is intentional: a 5.1 user who needs a *manual* solution to the version-conflict problem can use these helpers to identify which module to connect to first. The supported way to do this today is to run the helpers **from a PowerShell 7.4+ session** while they inspect a machine's installed modules, understanding that the all-users WinPS root is only auto-seeded when the helper itself runs on 5.1 (see the §10 note on the manifest edition declaration). -- **Multi-TFM (net9.0/net10.0) is a deferred goal**, not current scope (§10). The methodology is TFM-parameterizable; the runtime profile is single-target `net8.0` today. +- **The inspection/diagnostic tier is deliberately cross-edition.** Its functions are written to run on both editions and to scan the **Windows PowerShell module roots** that the current session can identify (for example `Documents\WindowsPowerShell\Modules`, plus the all-users WinPS root when actually running on 5.1) as well as the PowerShell 7 roots. This is intentional: a 5.1 user who needs a *manual* solution to the version-conflict problem can use these helpers to identify which module to connect to first. The supported way to do this today is to run the helpers **from a declared supported PowerShell session** while they inspect a machine's installed modules, understanding that the all-users WinPS root is only auto-seeded when the helper itself runs on 5.1 (see the §10 note on the manifest edition declaration). +- **Multi-targeting is a shipped isolation boundary.** `net8.0`, `net9.0`, and `net10.0` have separate physical payload directories even when individual files currently hash identically. The package-composition gate rejects missing or extra profile directories and verifies that optional CI provisioning tooling is absent. ## 2. The runtime model that drives every decision (read this first) @@ -44,7 +45,7 @@ They even run **different `Azure.Core` versions side-by-side** (Az `1.50`, Graph **Consequences:** 1. DLLPickle must **not** preload the Azure SDK stack. Preloading `Azure.Core` into the default ALC splits the identity of `Azure.Core.TokenRequestContext` across the module's private ALC boundary and breaks `Connect-AzAccount` with a `MissingMethodException` (shipped fix: 2.0.1). -2. As more modules self-isolate, **DLLPickle's necessary scope shrinks** on PS 7.4+. Its remaining value is the **MSAL + IdentityModel** stack, which default-ALC consumers (Exchange Online, Teams) still share. +2. As more modules self-isolate, **DLLPickle's necessary scope shrinks** on the supported ALC-capable profiles. Its remaining value is the **MSAL + IdentityModel** stack, which default-ALC consumers (Exchange Online, Teams) still share. > **Windows PowerShell 5.1 caveat — no ALC.** Everything above depends on `AssemblyLoadContext`, which exists only on .NET (Core) 5+. **Windows PowerShell 5.1 runs on .NET Framework 4.8, which has no ALC**, so modules **cannot** self-isolate there — every dependency lands in one shared load context. This is *why* the preloader does not support 5.1 (§1.2): the "modules manage the Azure SDK themselves" premise does not hold. If net48 / WinPS 5.1 support is ever re-added, the Azure SDK stack (`Azure.Core`, `Azure.Identity`, `Azure.Identity.Broker`, `System.ClientModel`) would need to be **preloaded again — conditionally, per-TFM (net48 only)** — exactly as #183 originally did before the 2.0 refactor over-generalized it. In other words: the `block` verdicts in §3 are correct **because** the runtime is ALC-capable; they are not universal. See §10 for the full re-introduction checklist. @@ -60,7 +61,7 @@ Every tracked assembly is classified into exactly one of: **Static narrows, runtime decides.** ALC ownership (from the runtime probe) flags *candidates*; the with/without runtime differential makes the *call*. This distinction is load-bearing: `Microsoft.Identity.Client.Extensions.Msal` is owned by Az's private ALC (a block *candidate*), yet preloading the MSAL/IdentityModel stack is **proven safe and beneficial** (2.0.1 four-module connect), so it is `preload`. Only the Azure SDK stack is `block`. -**Current classification (net8.0 profile):** +**Current classification (all three ALC-capable profiles):** | Assemblies | Class | Basis | | --- | --- | --- | @@ -74,9 +75,9 @@ Every tracked assembly is classified into exactly one of: | Component | Path | Responsibility | | --- | --- | --- | | Module source | `src/DLLPickle/` | The shipped PowerShell module (public/private functions, manifest). | -| Loader | `src/DLLPickle/Public/Import-DPLibrary.ps1` | Loads the bundled `bin/net8.0` DLLs into the default ALC with dependency-ordered, retrying loads. | +| Runtime policy and loader | `src/DLLPickle/SupportedRuntimeProfiles.json`, `src/DLLPickle/Private/Get-DPRuntimeProfile.ps1`, `src/DLLPickle/Public/Import-DPLibrary.ps1` | Maps the running PowerShell/CLR pair to one exact TFM and loads that isolated bundle with dependency-ordered, retrying loads. | | Build project | `src/DLLPickle.Build/DLLPickle.csproj` | **Realizes** the policy — preload packages are direct runtime references; blocked transitives that need suppression are direct references with `ExcludeAssets="runtime"`. `packages.lock.json` pins resolved versions. | -| Dependency policy | `build/dependency-policy.json` | **Decision source of truth** — per-assembly classification + evidence, monitored modules, target scenario, drift baseline. | +| Dependency policy | `build/dependency-policy.json` | **Decision source of truth** — per-profile and per-platform classifications, monitored modules, import orders, validation tiers, and conflict baselines. | | Analysis tools | `tools/Get-DLLPickleLoadedTrackedAssembly.ps1`, `New-DLLPickleConflictMatrix.ps1`, `Compare-DLLPickleConflictMatrix.ps1`, `Get-DLLPickleRuntimeAssemblySnapshot.ps1`, `Get-DLLPickleUpstreamInventory.ps1`, `Update-DLLPickleDependencyPins.ps1` | Inventory upstream modules, build the conflict matrix, probe runtime ALC ownership (filter sourced from `trackedAssemblies`), detect drift, and apply policy pins. | | Build script | `build/DLLPickle.Build.ps1` | Invoke-Build tasks: Analyze, AnalyzeTests, AnalyzeTools, Test, RestoreDependencies, PrepareModuleOutput, IntegrationTest. | | Release scripts | `.github/ci-scripts/Get-VersionBump.ps1` | Decides the semantic-version bump (and whether to release at all) from Conventional Commit prefixes since the last tag. The publish-decision logic behind §8.1. | @@ -92,6 +93,7 @@ Every tracked assembly is classified into exactly one of: | What is actually bundled? | `src/DLLPickle.Build/DLLPickle.csproj` (+ `packages.lock.json`) — must match the policy's `preload` set | | How does the loader behave? | `src/DLLPickle/Public/Import-DPLibrary.ps1` | | Which runtime/edition is supported? | §1.2 (platform-support contract) + `src/DLLPickle/DLLPickle.psd1` | +| What are the current exact patches and lifecycle dates? | `build/powershell-test-matrix.json` + [generated support matrix](generated/Support-Matrix.md) | | When does a merged PR publish a new version? | §8.1 + `.github/workflows/Release-and-Publish.yml` + `.github/ci-scripts/Get-VersionBump.ps1` | | How is a dependency update adjudicated and shipped? | §8.2 + `build/dependency-policy.json` + `.github/workflows/Dependabot-Auto-Approve.yml` | | What maintenance traps and follow-up gaps remain? | `docs/gaps/README.md` and the individual `docs/gaps/GAP-*.md` files | @@ -102,21 +104,23 @@ Every tracked assembly is classified into exactly one of: ## 6. Invariants (and the gate that enforces each) -- **No `block` assembly appears in `module/DLLPickle/bin/net8.0`.** → policy-driven `tests/Integration/DependencyPolicyRealization.Tests.ps1` plus scenario-specific guards in `tests/Integration/DLLPickle.IntegrationTest.Tests.ps1`. +- **No `block` assembly appears in any supported TFM payload.** → policy-driven `tests/Integration/DependencyPolicyRealization.Tests.ps1` plus scenario-specific guards in `tests/Integration/DLLPickle.IntegrationTest.Tests.ps1`. - **No preloaded assembly is loaded into two ALCs at once** in the four-module scenario. → integration ALC-split guard (runtime tier; maintainer-run with real modules). - **The bundled `preload` set equals `dependency-policy.json`'s `preload` entries.** → `tests/Integration/DependencyPolicyRealization.Tests.ps1`. - **Runtime-provided BCL assemblies are never preloaded.** Some assemblies are platform-scoped: `System.Security.Cryptography.ProtectedData` is provided by PowerShell only on Windows; on Linux/macOS it is bundled (not excluded). See `build/dependency-policy.json` for platform-specific block scopes. → policy block classification + `tests/Integration/DependencyPolicyRealization.Tests.ps1` with platform-aware filtering. - **Analysis tooling is correct.** → `tests/Unit/ConflictMatrix.Tests.ps1`, `tests/Unit/ConflictMatrixDrift.Tests.ps1`. +- **Only the three declared TFM directories ship, and multi-pwsh never ships.** → `tools/Test-DLLPicklePackageArtifact.ps1` + `tests/Unit/ArtifactPolicy.Tests.ps1`. +- **Material artifact growth requires review.** → `tools/New-DLLPickleArtifactSizeReport.ps1` against `build/artifact-size-baseline.json`. A captured baseline also fails strict validation until a maintainer sets `approvalStatus` to `accepted` with an approval timestamp; capture time is not approval. - **`tests/` and `tools/` stay analyzer-clean.** → `AnalyzeTests` / `AnalyzeTools` build tasks (throw on any finding; `tests/` excludes only `PSUseDeclaredVarsMoreThanAssignments`). ## 7. Validation gates -- **Unit + analyzer:** `Invoke-Build -Task Analyze,Test` (the PR-smoke gate). Must be green. +- **Unit + analyzer:** `tools/Invoke-DLLPickleBuild.ps1 -Task Analyze,Test` imports the exact pinned tool versions in a fresh no-profile process. Must be green. - **Issue reproduction + composition:** `Invoke-Build -Task IssueReproTest` (synthetic modules; deterministic). -- **Runtime adjudication — non-auth tier (CI-capable):** strict per-module ALC snapshots (module/probe failures are fatal) + the four-module import/composition smoke. -- **Runtime adjudication — auth tier (maintainer-run; future Stage 2b automatable via GitHub OIDC + Entra federated credential):** real `Connect-*` to a dev tenant. This is the sign-off for any change to the bundled set. +- **Runtime adjudication — deterministic no-auth tier:** nine exact stock-host cells (three PowerShell/.NET/TFM profiles × Windows/Linux/macOS), strict per-module ALC snapshots, both relevant import orders, structured runtime identity, and fail-closed profile/platform baselines. +- **Runtime adjudication — authenticated read-only tier:** maintainer-approved credentials only. The exact unexecuted commands and status are generated in [Compatibility-Evidence.md](generated/Compatibility-Evidence.md); they are release gates and are never inferred from deterministic results. - **Publish trigger:** `Release-and-Publish` publishes a merged PR only when it passes **both** the bundle-affecting **path gate** and the Conventional-Commit **version gate** — see §8.1 for the full contract (including how a Dependabot `deps:` commit maps to a **minor** release). CI-, policy-, docs-, test-, and tooling-only changes do **not** publish a new gallery version; `workflow_dispatch` is the deliberate-release escape hatch. -- **Dependency PRs (Dependabot):** the bumped *bundle* is validated by **Build Module** (full build under `--locked-mode` + the #193/Azure.Core repro guards), bounded by the csproj floating-with-cap constraints. The Upstream-Compatibility `pr-smoke` adds an upstream-latest freshness check for policy/tooling changes, and the scheduled candidate flow performs live inventory, drift detection, TFM alignment, and candidate PR generation. +- **Dependency PRs (Dependabot):** the bumped *bundle* is validated by **Build Module** (locked restore, all TFMs, exact runtime matrix, composition, size, and issue-repro guards). The Upstream-Compatibility gate records profile-aware selected assets, hashes, ALCs, import orders, deterministic probes, and conflict fingerprints. A conditional TFM pin, policy/classification edit, or material size increase routes to maintainer review. ## 8. Release & dependency-update contract @@ -149,14 +153,14 @@ A "tracked dependency" is one of the NuGet packages bundled into the preload set **Step 0 — TFM-alignment check (precondition for both paths).** Before any merge, confirm the new release aligns with the supported target framework moniker(s). "TFM-aligned" means **both**: -- **(a) Build-gate proof** — the project still restores under `--locked-mode` and builds + passes Pester/CI green on `net8.0` (the `Build gate` required check); **and** -- **(b) Explicit TFM inspection** — the package actually ships an assembly asset consumable by the supported TFM (`net8.0` today) — for example a `net8.0`, `netstandard2.0`, or `netstandard2.1` asset — rather than appearing to work only by luck of transitive resolution. Enforced by `tools/Test-DLLPickleTfmAlignment.ps1`, which inspects each preload package's `lib//` assets and runs fail-closed in the scheduled Upstream-Compatibility candidate flow. +- **(a) Build-gate proof** — the project still restores under `--locked-mode`, builds all three target frameworks, and passes Pester plus every exact runtime/OS cell (the stable `Build gate` required check); **and** +- **(b) Explicit TFM inspection** — NuGet's restored `project.assets.json` selects a managed asset for every preload package on `net8.0`, `net9.0`, and `net10.0`. Enforced by `tools/Test-DLLPickleTfmAlignment.ps1`; the report names each resolved graph and selected asset rather than approximating compatibility with a handwritten regex. A release that fails either half is not TFM-aligned and must not be merged on the automated path. **Minor / patch release** → run Step 0 (TFM alignment) + Pester + CI, then **approve and merge with a detailed PR comment** recording what moved, the alignment evidence, and the conflict-surface result. Because identity-library bumps are the module's core deliverable, a minor/patch dependency bump produces a **minor** module release: Dependabot's `deps:` commit prefix is a recognized minor release prefix (§8.1), so an auto-merged bump fires the version gate on its own. -**Major release** → still **fully tested** (Pester + CI) and **verified for architecture alignment** (Step 0, plus a re-adjudication of the §3 preload/block classifications, since a major upstream jump can move the conflict surface), **but it lands as a draft PR with fully detailed notes** — never auto-merged and never auto-published. The notes should cover: the version delta and an upstream changelog / breaking-change summary, the TFM-alignment result, the Pester/CI outcome, and the conflict-surface / `dependency-policy.json` impact. A maintainer promotes the draft to ready and merges after review; the merge then publishes a **major** module release (carried as `breaking:`). This is implemented in `Dependabot-Auto-Approve.yml` (draft conversion + the structured-notes scaffold). +**Major release** → still **fully tested** (Pester + CI) and **verified for architecture alignment** (Step 0, plus a re-adjudication of the §3 preload/block classifications), **but it lands as a draft PR with per-TFM resolved graphs, selected assets, assembly delta, conflict-surface delta, size delta, and scenario outcomes** — never auto-merged and never auto-published. A maintainer promotes the draft to ready and merges after review; the merge then publishes a **major** module release (carried as `breaking:`). This mirrors the **hard gate** in §9: bundle-set changes are behavior-changing and require the auth-tier real-environment sign-off; they are not auto-merged blindly. @@ -191,7 +195,7 @@ The path gate in §8.1 is deliberate: a merge only auto-publishes when it change When changing the preload contract, follow this loop: 1. **Inventory** the monitored modules (`Get-DLLPickleUpstreamInventory.ps1`). -2. **Build and compare the conflict matrix** (`New-DLLPickleConflictMatrix.ps1`, then `Compare-DLLPickleConflictMatrix.ps1`) to find candidates and structured new/removed conflict, version, and contributor changes. The matrix also emits the drift `Fingerprint` — a SHA-256 over each diverging assembly's name, sorted versions, **and** contributing-module set (`ShippedBy`) — which the Upstream-Compatibility gate compares to `baseline.conflictSurfaceFingerprint`. Any structured change trips drift. +2. **Build and compare the conflict matrix in every exact runtime profile** (`New-DLLPickleConflictMatrix.ps1`, then `Compare-DLLPickleConflictMatrix.ps1`) to find candidates and structured new/removed conflict, version, contributor, and ALC-owner changes. The matrix emits a profile-keyed `Fingerprint`; `Test-DLLPickleProfileConflictBaseline.ps1` compares it only with the matching PowerShell line, TFM, platform, module-set, and import-order baseline under `runtimeProfiles[].baselines`. Missing, unaccepted, or changed profile evidence fails closed. The top-level `baseline` and each `legacyUnscopedBaseline` are retained only as historical #239/#273 evidence and are not authoritative for a supported profile. 3. **Probe runtime ALC ownership** (`Get-DLLPickleRuntimeAssemblySnapshot.ps1`) — private-ALC ownership is a `block` *candidate*, not an automatic verdict. 4. **Adjudicate** with the runtime differential (does preloading help without breaking?). Record the verdict + evidence in `build/dependency-policy.json`. 5. **Realize** in `DLLPickle.csproj` (preload = bundled reference; block = excluded, incl. `ExcludeAssets` for blocked transitives), regenerate `packages.lock.json`. @@ -199,9 +203,9 @@ When changing the preload contract, follow this loop: 7. **Update this blueprint** if the contract or invariants changed. 8. **Update the gap register** if the work opens, advances, resolves, blocks, supersedes, or intentionally accepts a tracked gap. -The baseline is a reproducible snapshot, not just a hash: `baseline.conflictSurface` stores every diverging assembly's `name`, sorted `versions`, and sorted `shippedBy` contributors alongside the module versions and fingerprint. Baseline refreshes must resolve all monitored versions before downloading any module, then prove that the stored rows recompute to the recorded fingerprint. Version-only moves are material drift and require adjudication; they do not pass silently. +Each accepted profile baseline is a reproducible snapshot, not just a hash. Its evidence must preserve the selected module asset, assembly name/version/hash/path, contributor, import order, operating system, architecture, and AssemblyLoadContext alongside the profile-keyed fingerprint. Baseline refreshes resolve all monitored versions before downloading any module, then prove that the stored rows recompute to the recorded fingerprint. Version-only, contributor, selected-asset, or ALC-owner moves are material drift and require adjudication; they do not pass silently. `New-DLLPickleProfileEvidenceSummary.ps1` combines the nine comparisons into one stable finding marker so unchanged scheduled results can be deduplicated without falling back to the legacy global hash. -Issue #239 was adjudicated on 2026-06-20 against Microsoft.Graph.Authentication 2.38.0, ExchangeOnlineManagement 3.10.0, Az.Storage 9.7.0, Az.Accounts 5.5.0, and MicrosoftTeams 7.8.0. The 16 conflict names and their contributors were unchanged. Strict probes of both configured import orders, with and without DLLPickle preloading, confirmed the existing preload/block classifications, so only the structured policy baseline changed; the bundled set and public module behavior did not. +Issue #239 was adjudicated on 2026-06-20 against Microsoft.Graph.Authentication 2.38.0, ExchangeOnlineManagement 3.10.0, Az.Storage 9.7.0, Az.Accounts 5.5.0, and MicrosoftTeams 7.8.0. That snapshot remains useful historical context, but it predates the nine-profile evidence contract and cannot be carried forward as a current net8/net9/net10 or cross-platform verdict. **Hard gates (non-negotiable):** @@ -242,7 +246,7 @@ Detailed status for open and in-progress maintenance traps is tracked in the [ga | Gap | Status | Architecture note | | --- | --- | --- | | [GAP-002](gaps/GAP-002-az-resources-monitoring.md) | resolved | `Az.Resources` is now included in `monitoredModules`; policy tracking scope and dependency docs were updated with structural test coverage. | -| [GAP-003](gaps/GAP-003-exo-teams-probe-commands.md) | open | EXO/Teams ALC ownership is not yet captured because bare `Import-Module` does not eagerly load their identity assemblies. | +| [GAP-003](gaps/GAP-003-exo-teams-probe-commands.md) | in-progress | Deterministic and authenticated read-only EXO/Teams probe commands are implemented and structurally tested; exact-profile CI evidence remains unaccepted. | | [GAP-004](gaps/GAP-004-vscode-powershelleditorservices-host.md) | open | VS Code / PowerShellEditorServices host behavior is not yet modeled for issue #169. | | [GAP-005](gaps/GAP-005-odata-conflict-expectation-management.md) | resolved | OData/#174 expectation management is explicit in known-conflict data, docs, and policy tests; changes require runtime re-adjudication. | | [GAP-006](gaps/GAP-006-release-dispatch-process-trap.md) | resolved | The manual release dispatch runbook (§8.3) documents which changes auto-publish and when `workflow_dispatch` is required; guarded by `tests/Unit/WorkflowGuardrails.Tests.ps1`. | @@ -255,11 +259,11 @@ Historical/resolved notes remain below when they explain the current architectur - **Dependency bumps publish on their own — `deps → minor` (decisions 3 & 4).** *Resolved.* `Get-VersionBump.ps1` now recognizes Dependabot's NuGet `deps:` commit prefix (`.github/dependabot.yml`) as a **minor** release prefix (§8.1), so an auto-approved, squash-merged minor/patch dependency bump satisfies both the path gate and the version gate and publishes a **minor** module release. (A maintainer-promoted major dependency PR still carries `breaking:`.) Covered by `tests/Unit/GetVersionBump.Tests.ps1`. - **Major-dependency draft-PR flow.** *Resolved.* `Dependabot-Auto-Approve.yml` converts a `version-update:semver-major` PR to a **draft** (`gh pr ready --undo`) and posts structured notes (version delta, NuGet package link, TFM-alignment references, Build gate / CI links, conflict-surface / `dependency-policy.json` impact, and a maintainer checklist) instead of the former generic comment. Majors remain excluded from `gh pr merge --auto`. Guarded by `tests/Unit/WorkflowGuardrails.Tests.ps1`. -- **Explicit TFM-alignment inspection (Step 0b).** *Resolved.* `tools/Test-DLLPickleTfmAlignment.ps1` inspects each preload package's `lib//` assets (or a legacy flat `lib/`) and asserts a net8.0-consumable asset is present, including `netstandard2.0` and `netstandard2.1` where applicable, complementing the `Build gate` (Step 0a). It runs fail-closed in the scheduled Upstream-Compatibility candidate flow and is referenced from the major-dependency draft-PR notes. (`Get-DLLPickleUpstreamInventory.ps1` still captures only assembly `Name`/`Version`/`FullName`; the TFM assertion lives in the dedicated tool, which inspects the restored NuGet package layout.) Covered by `tests/Unit/TfmAlignment.Tests.ps1`. -- **Platform-support contract vs. manifest edition (decision 2).** The inspection/diagnostic tier is intended to be cross-edition (§1.2), but the manifest declares `CompatiblePSEditions = @('Core')`, so *importing* the module under Windows PowerShell 5.1 surfaces a compatibility warning. The supported manual-remediation path is therefore to run the inspection helpers **from a PowerShell 7.4+ session** while they scan the Windows PowerShell module roots — not to import DLLPickle under 5.1. Two code paths back this cross-edition intent on purpose: `Set-DPConfig` keeps a `$PSEdition`-aware encoding fallback, and `Find-DLLInPSModulePath` seeds the `WindowsPowerShell\Modules` roots (the all-users WinPS root only when actually running on 5.1). These are intentional, not residual dead code; recorded here so the contract is explicit. -- **Multi-TFM (net9.0/net10.0):** deferred; the methodology is TFM-parameterizable. net9.0/net10.0 are ALC-capable, so the `block` verdicts in §3 carry over to them. The `net8.0` bundle is confirmed to load on **PS 7.6 / .NET 10 via roll-forward** (Az.Resources import verified, no #193 regression) — a positive signal that multi-TFM is mostly a packaging exercise, not a behavioral one, on ALC-capable runtimes. When it lands, `dependency-policy.json` preload entries (currently a single `targetFramework: net8.0` each) and the `Update-DLLPickleDependencyPins` tooling will need a per-TFM representation; the `RestoreDependencies` task already parses both `TargetFramework` and `TargetFrameworks` in anticipation. +- **Explicit TFM-alignment inspection (Step 0b).** *Resolved.* `tools/Test-DLLPickleTfmAlignment.ps1` reads NuGet's restored `project.assets.json` and verifies the selected compile/runtime assets for every preload package on `net8.0`, `net9.0`, and `net10.0`, complementing the exact-runtime `Build gate` (Step 0a). Covered by `tests/Unit/TfmAlignment.Tests.ps1`. +- **Platform-support contract vs. manifest edition (decision 2).** The inspection/diagnostic tier is intended to be cross-edition (§1.2), but the manifest declares `CompatiblePSEditions = @('Core')`, so *importing* the module under Windows PowerShell 5.1 surfaces a compatibility warning. The supported manual-remediation path is therefore to run the inspection helpers **from a declared supported PowerShell session** while they scan the Windows PowerShell module roots — not to import DLLPickle under 5.1. Two code paths back this cross-edition intent on purpose: `Set-DPConfig` keeps a `$PSEdition`-aware encoding fallback, and `Find-DLLInPSModulePath` seeds the `WindowsPowerShell\Modules` roots (the all-users WinPS root only when actually running on 5.1). These are intentional, not residual dead code; recorded here so the contract is explicit. +- **Multi-TFM support:** implemented. The build emits physically isolated `net8.0`, `net9.0`, and `net10.0` payloads; the loader selects them fail-closed from the PowerShell/CLR pair; dependency decisions use `targetFrameworks`; NuGet compatibility comes from `project.assets.json`; and the pin updater preserves common versions unless reviewed evidence introduces an existing conditional reference. The exact nine-cell runtime matrix remains the acceptance authority rather than roll-forward behavior. - **Re-introducing Windows PowerShell 5.1 / net48 (no ALC) — checklist if attempted:** because net48 has no `AssemblyLoadContext`, modules cannot self-isolate and the §3 `block` verdicts for the Azure SDK stack **invert**. Re-support would require: (1) multi-targeting the build to `net48` alongside `net8.0`; (2) **conditionally preloading the Azure SDK stack** (`Azure.Core` + `Azure.Identity`/`Broker` + `System.ClientModel`) for net48 only — pinned to the highest version the WinPS-supported module set agrees on, as #183 did; (3) restoring net48-specific dependency conditions in `DLLPickle.csproj` (e.g. `Condition="'$(TargetFramework)' == 'net48'"`); (4) restoring `CompatiblePSEditions = @('Core','Desktop')` and lowering the manifest `PowerShellVersion`, plus per-edition guards in `Import-DPLibrary`; (5) adding WinPS 5.1 to the CI test matrix and re-validating the #156/#165-class scenarios. The 2.0 refactor's mistake was applying the net48-era Azure.Core preload to net8 unconditionally — any re-introduction must keep it **strictly TFM-conditional**. -- **Planned feature enhancements (backlog).** Forward-looking ideas consolidated from the former root `Roadmap.md`; not yet scheduled, order is not guaranteed, and large dependency/platform shifts may change priority. (Released and in-progress work lives in [CHANGELOG.md](../CHANGELOG.md) — for example the `Microsoft.PowerShell.PlatyPS` help-generation migration is already tracked there, and the broader PowerShell 7.4+ compatibility and supply-chain hardening work is the ongoing subject of §3, §8, and §9.) +- **Planned feature enhancements (backlog).** Forward-looking ideas consolidated from the former root `Roadmap.md`; not yet scheduled, order is not guaranteed, and large dependency/platform shifts may change priority. (Released and in-progress work lives in [CHANGELOG.md](../CHANGELOG.md) — for example the `Microsoft.PowerShell.PlatyPS` help-generation migration is already tracked there, and the broader supported-profile compatibility and supply-chain hardening work is the ongoing subject of §3, §8, and §9.) - Import a specific version of MSAL (`Microsoft.Identity.Client`) on demand, rather than only the bundled pin. - Verify a package's hash/signature against the original source (e.g. NuGet) metadata before preload — a supply-chain integrity check extending the dependency-update work in §8. - Option to preload additional common (non-identity) assemblies beyond the default identity stack. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index f1237bcd..733cb73e 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -5,16 +5,19 @@ update policies. ## Runtime Baseline -DLLPickle now targets **PowerShell 7.4+** with a single **net8.0** runtime -profile. Legacy Windows PowerShell 5.1 and .NET Framework dependency paths are -no longer supported. +DLLPickle ships three physically isolated bundles for the exact profiles in the +[generated support matrix](generated/Support-Matrix.md): PowerShell 7.4 / .NET 8 +(`net8.0`), PowerShell 7.5 / .NET 9 (`net9.0`), and PowerShell 7.6 / .NET 10 +(`net10.0`). The loader checks both the PowerShell minor and CLR major and fails +closed on an undeclared or mismatched profile. Legacy Windows PowerShell 5.1 and +.NET Framework dependency paths are not supported by the automated preloader. The *automated* fix (`Import-DPLibrary` / `Import-DPBaseProfile`) requires -PowerShell 7.4+ / .NET 8 because it depends on `AssemblyLoadContext`. The +one of those supported .NET profiles because it depends on `AssemblyLoadContext`. The *inspection / diagnostic* helpers (`Find-DLLInPSModulePath`, `Get-ModuleImportCandidate`, `Get-ModulesWithDependency`, `Get-ModulesWithVersionSortedIdentityClient`, `Test-DPLibraryConflict`) are -intentionally cross-edition — from a PowerShell 7.4+ session they still inspect +intentionally cross-edition — from a supported PowerShell session they still inspect the current-user Windows PowerShell roots (for example `Documents\WindowsPowerShell\Modules`), and when actually running on 5.1 they also auto-seed the all-users WinPS root. That lets a Windows PowerShell 5.1 @@ -35,16 +38,16 @@ For usage guidance, see [README.md](../README.md) and [docs/index.md](index.md). ## Dependency Management Strategy Every tracked-dependency release is first checked for **target-framework -alignment**: it must restore, build, and pass tests on `net8.0` under -`--locked-mode`, and ship a net8.0-consumable assembly asset (for example -`net8.0`, `netstandard2.0`, or `netstandard2.1`, as verified by -`tools/Test-DLLPickleTfmAlignment.ps1`). Only then does the severity of the -version jump decide how it ships: +alignment**: it must restore under `--locked-mode`, build and pass tests for all +three TFMs, and have NuGet select a managed asset for every preload package in +each restored `project.assets.json` target graph. `tools/Test-DLLPickleTfmAlignment.ps1` +records those selected assets rather than approximating compatibility from folder +names. Only then does the severity of the version jump decide how it ships: | Update Type | Policy | | ----------- | ------ | -| **Patch / Minor** (x.Y.Z) | After the TFM-alignment + test gate, approve and merge with a detailed PR comment. Identity-library bumps are the module's core deliverable, so they ship as a **minor** module release — Dependabot's `deps:` commit is a recognized minor release prefix, so an auto-merged bump publishes a minor release on its own. | -| **Major** (X.y.z) | Still fully tested and TFM-verified, with the conflict surface re-adjudicated, but opened as a **draft PR with fully detailed notes** — **not** auto-merged and **not** auto-published. A maintainer promotes and merges it, publishing a **major** release (`breaking:`). | +| **Patch / Minor** (x.Y.Z) | May auto-approve and register auto-merge only when the exact actor/author, file-set, complete TFM/runtime matrix, upstream-policy, build, artifact-size, and dependency-review gates pass. A conditional TFM pin, classification edit, or material size growth routes to review. Identity-library bumps ship as a **minor** module release through the `deps:` prefix. | +| **Major** (X.y.z) | Fully tested and TFM-verified, with per-TFM graph/asset/assembly/size evidence and the conflict surface re-adjudicated, but converted to a **draft PR** — **not** auto-merged and **not** auto-published. A maintainer promotes and merges it as a **major** release (`breaking:`). | | **Upstream PowerShell module drift** | Candidate PR or issue after the scheduled inventory + drift check. | > **Publish note.** A merged dependency PR publishes a new gallery version only @@ -61,8 +64,9 @@ version jump decide how it ships: The automation that supports this: Dependabot opens NuGet update PRs; the **Dependabot-Auto-Approve** workflow auto-approves and squash-merges patch/minor updates (restricted to the exact `DLLPickle.csproj` / `packages.lock.json` -allow-list, and only after the `Build gate`, `Validate upstream compatibility -tooling`, and `dependency-review` required checks pass) and excludes major + allow-list, and only after the `Build gate`, `Validate upstream compatibility + tooling`, complete exact-runtime matrix, artifact-size policy, and + `dependency-review` required checks pass) and excludes major updates from auto-merge, converting them to a reviewed **draft PR** with detailed notes instead. @@ -99,7 +103,10 @@ docs-, policy-, and tooling-only changes do not trigger a release. See Dependabot tracks NuGet package releases, but DLLPickle also tracks the DLLs bundled by upstream PowerShell modules. The scheduled **Upstream Compatibility** workflow uses `build/dependency-policy.json` and tools under `tools/` to -inventory latest PSGallery releases and propose safe candidate pin updates. +inventory the newest compatible PSGallery release independently in each exact +PowerShell profile. It records the umbrella and constituent module, selected +asset path, hash, ALC, OS, architecture, import order, and deterministic probe. +The generated status is [Compatibility Evidence](generated/Compatibility-Evidence.md). Monitored modules: @@ -119,7 +126,9 @@ candidate generation, restore, build, and issue reproduction tests pass. ## NuGet Package Dependencies Version strategy in `DLLPickle.csproj` is **major-locked floating** (`N.*`); the -lock file pins the concrete resolved version. +lock file pins the concrete resolved version for all three TFMs. Common versions +are preserved across TFMs unless profile evidence requires a reviewed conditional +pin. | Package | Version Strategy | Notes | | ------- | ---------------- | ----- | @@ -157,16 +166,15 @@ their own code owners: sessions can fail when one module binds to a lower, incompatible assembly. - Candidate pin updates are generated from upstream module inventories and still require full validation before publication. -- `Azure.Core` is intentionally **not** preloaded on the PowerShell 7.4+ - (net8.0) profile. Az.Accounts 5.x isolates its Azure SDK stack in a private +- `Azure.Core` is intentionally **not** preloaded on any supported ALC-capable + profile. Az.Accounts 5.x isolates its Azure SDK stack in a private `AssemblyLoadContext`; preloading `Azure.Core` into the default load context splits the identity of `Azure.Core.TokenRequestContext` across load contexts and breaks `Connect-AzAccount` with a `MissingMethodException` on `InteractiveBrowserCredential.AuthenticateAsync`. Graph, Exchange, and Teams - resolve a compatible `Azure.Core` themselves on .NET 8, so the preload is + resolve a compatible `Azure.Core` themselves, so the preload is unnecessary. `Azure.Core` remains report-only in policy for monitoring. The - original net48-only `Azure.Core` preload (#183) does not apply to the net8.0 - baseline. + original net48-only `Azure.Core` preload (#183) does not apply to these profiles. - OData families remain report-only in policy because preloading them by default can break compatibility when upstream modules require different OData identities. @@ -206,6 +214,8 @@ Manual review required for: - Major version upgrades - New package additions - Changes to version strategy +- A new conditional per-TFM package pin or preload/block classification change +- A material breach of `build/artifact-size-baseline.json` ## References diff --git a/docs/Deep-Dive.md b/docs/Deep-Dive.md index 7aaa02a6..fa644921 100644 --- a/docs/Deep-Dive.md +++ b/docs/Deep-Dive.md @@ -32,10 +32,16 @@ PowerShell modules: ## How DLLPickle Works -`Import-DPLibrary` loads DLLs from the module's packaged `bin` folder that -matches the supported runtime target: +`Import-DPLibrary` selects a physically isolated DLL directory using both the +PowerShell minor and CLR major: -- `bin/net8.0` for PowerShell 7.4+ +- `bin/net8.0` for PowerShell 7.4 / .NET 8 +- `bin/net9.0` for PowerShell 7.5 / .NET 9 +- `bin/net10.0` for PowerShell 7.6 / .NET 10 + +The [generated support matrix](generated/Support-Matrix.md) supplies the current +Microsoft lifecycle and exact CI patch evidence. An unknown or mismatched pair +fails closed rather than rolling forward to another bundle. To improve reliability, the loader: @@ -52,7 +58,7 @@ predictable and diagnosable. ## The inspection helpers (and Windows PowerShell 5.1) -`Import-DPLibrary` is the *automated* fix, and it needs PowerShell 7.4+ / .NET 8 +`Import-DPLibrary` is the *automated* fix, and it needs a declared supported PowerShell/.NET profile because it depends on `AssemblyLoadContext`. But DLLPickle also ships a set of **inspection helpers** that are deliberately cross-edition: @@ -69,7 +75,7 @@ because it depends on `AssemblyLoadContext`. But DLLPickle also ships a set of These exist so the project charter still helps environments the preloader cannot reach. A **Windows PowerShell 5.1** user who hits the same DLL conflict can run -these helpers from a PowerShell 7.4+ session, inspect the current-user Windows +these helpers from a supported PowerShell session, inspect the current-user Windows PowerShell module roots from there, see which installed module ships the newest identity DLL, and connect to that service *first* — the same "first one wins" idea, applied by hand. The automated preload is the convenience; the manual @@ -89,7 +95,8 @@ workflow: - `build/dependency-policy.json` declares monitored PSGallery modules, tracked assembly families, per-assembly version policies, and blocked preload families. - `tools/Get-DLLPickleUpstreamInventory.ps1` downloads and inventories the - latest monitored modules. + newest release compatible with the exact tested PowerShell profile, then + records only the assets actually selected in that stock host. - `tools/Update-DLLPickleDependencyPins.ps1` compares the inventory with the policy and applies safe candidate pin updates (major-locked floating `N.*`, or an exact pin when a `maximumPackageVersion` cap applies). - `.github/workflows/Upstream-Compatibility.yml` runs the inventory and @@ -107,10 +114,11 @@ The monitored module set currently includes: `Az.Resources` is monitored explicitly because it is the observed trigger for the #193 `Microsoft.Extensions.*` collision family. -The workflow is fail-closed. It may open a candidate PR when a policy-supported -pin changes, such as a Graph or Teams `Azure.Core` update. It does not merge or -publish changed preload behavior unless the -candidate passes restore, build, and issue reproduction validation. +The workflow is fail-closed. It may propose a candidate when a policy-supported +identity pin changes. It does not merge or publish changed preload behavior unless +the candidate passes locked restore, all-TFM build, the exact nine-cell runtime +matrix, profile/platform conflict baselines, artifact composition, size, and issue +reproduction validation. Some dependency families are deliberately report-only. For example, OData assemblies are tracked because ExchangeOnlineManagement and Az.Storage can @@ -118,13 +126,13 @@ require incompatible versions in one process, but OData is not added to the default preload set unless a future isolation strategy makes that safe. `Azure.Core` is also report-only. It is intentionally not preloaded on the -PowerShell 7.4+ profile: both `Az.Accounts` (`AzSharedAssemblyLoadContext`) and +supported ALC-capable profiles: both `Az.Accounts` (`AzSharedAssemblyLoadContext`) and `Microsoft.Graph.Authentication` (`msgraph-load-context`) isolate their Azure SDK stack in private `AssemblyLoadContext`s — they even run different `Azure.Core` versions side-by-side without conflict. Preloading `Azure.Core` into the default load context splits the identity of `Azure.Core.TokenRequestContext` across that boundary and breaks `Connect-AzAccount`. Because the modules self-manage it, the -preload is unnecessary on .NET 8. +preload is unnecessary on the supported .NET runtimes. > **Windows PowerShell 5.1 caveat:** this module self-isolation relies on > `AssemblyLoadContext`, which only exists on .NET (Core) 5+. Windows PowerShell @@ -155,11 +163,13 @@ only prepares the process and imports modules so connection commands such as `Connect-AzAccount` can run afterward using credentials and tenant choices from the caller's environment. -Live testing confirms the full base profile can connect to Exchange Online, -Microsoft Teams, Microsoft Graph, and Az.Accounts in one session. Because -DLLPickle no longer preloads `Azure.Core` on the net8.0 profile, Az.Accounts' -private `AssemblyLoadContext` resolves a single, consistent `Azure.Core`, and -`Connect-AzAccount` succeeds alongside the Graph/Exchange/Teams identity stack. +Historical authenticated testing confirmed the full base profile on the earlier +`net8.0` bundle. Current deterministic CI covers every declared PowerShell/OS +profile, but authenticated read-only checks remain explicit release gates until +approved credentials produce fresh artifacts. See +[Compatibility Evidence](generated/Compatibility-Evidence.md) for the exact +unexecuted commands. DLLPickle does not preload `Azure.Core`, so Az.Accounts' +private `AssemblyLoadContext` can resolve a consistent copy. ## Why This Helps diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 0ea75c6a..c198f8e3 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -20,13 +20,15 @@ credentials, VS Code, Azure Automation, or PSGallery module installation. ## Supported runtime (and Windows PowerShell 5.1) -The automated fix — `Import-DPLibrary` and `Import-DPBaseProfile` — requires -**PowerShell 7.4+ on .NET 8**. It relies on `AssemblyLoadContext`, which does not -exist on .NET Framework 4.8, so it does not run on **Windows PowerShell 5.1**. +The automated fix — `Import-DPLibrary` and `Import-DPBaseProfile` — requires one +of the exact PowerShell/.NET pairs in the +[generated support matrix](generated/Support-Matrix.md). It relies on +`AssemblyLoadContext`, which does not exist on .NET Framework 4.8, so it does not +run on **Windows PowerShell 5.1**. If you are on Windows PowerShell 5.1 and hitting the same conflict, you can still use DLLPickle's **inspection helpers** to solve it manually. Run them from a -PowerShell 7.4+ session — they still inspect the current-user Windows PowerShell +a supported PowerShell session — they still inspect the current-user Windows PowerShell module roots from there — to find which installed module ships the newest identity DLL, then connect to that service *first* (the "first one wins" workaround). For example: @@ -91,17 +93,28 @@ differences by name, version, and location. ## Common loader errors -### `Binary directory not found for target framework 'net8.0'` +### `Unsupported PowerShell runtime` or `CLR mismatch` -`Import-DPLibrary` loads the bundled assemblies from the module's `bin/net8.0` -folder and throws this error if that folder is missing: +DLLPickle does not use runtime roll-forward as support evidence. Confirm +`$PSVersionTable.PSVersion`, `[Environment]::Version`, `$PSHOME`, and +`[Environment]::ProcessPath` against the [generated support matrix](generated/Support-Matrix.md). +An undeclared PowerShell line or a PowerShell/CLR pair that does not match the shipped +policy is rejected before any bundled assembly loads. Install a declared stock +PowerShell runtime or update DLLPickle after a new support-contract release; do not +rename a shim or copy another TFM directory to bypass the check. + +### `Binary directory not found for target framework ''` + +`Import-DPLibrary` maps the running PowerShell minor and CLR major to `net8.0`, +`net9.0`, or `net10.0`, then loads the bundled assemblies from that directory. +It throws this error if the selected folder is missing: ```text -Binary directory not found for target framework 'net8.0' at: \bin\net8.0 +Binary directory not found for target framework '' at: \bin\ ``` This usually means the installed module is incomplete, or you are importing from a -source tree that has not been built (the `bin/net8.0` output is generated, not +source tree that has not been built (the `bin/` output is generated, not committed). Re-install the module from the PowerShell Gallery: ```powershell @@ -124,9 +137,9 @@ and friends) for the supported base profile. The issue repro tests assert that the protected import path keeps the broker/MSAL line aligned to avoid `WithBroker` missing-method failures across mixed imports. -`Azure.Core` is intentionally not preloaded on the PowerShell 7.4+ (net8.0) +`Azure.Core` is intentionally not preloaded on any supported ALC-capable profile. The original `Azure.Core` preload (#183) was scoped to Windows -PowerShell (net48), which 2.0 no longer supports. On .NET 8, Graph, Exchange, +PowerShell (net48), which 2.0 no longer supports. On the supported .NET runtimes, Graph, Exchange, and Teams resolve a compatible `Azure.Core` themselves, and preloading it breaks `Connect-AzAccount` (see the Az.Accounts note below). @@ -162,7 +175,7 @@ in the default context splits the identity of `Azure.Core.TokenRequestContext` across the two load contexts, so Az's `InteractiveBrowserCredential` method signature no longer matches its caller. -DLLPickle no longer preloads `Azure.Core` on the net8.0 profile, so Az.Accounts +DLLPickle does not preload `Azure.Core` on any supported profile, so Az.Accounts resolves a single, consistent `Azure.Core` and `Connect-AzAccount` succeeds alongside the Graph/Exchange/Teams stack. If you still see this error, confirm no other module or profile script preloaded `Azure.Core` into the session, or diff --git a/docs/gaps/GAP-003-exo-teams-probe-commands.md b/docs/gaps/GAP-003-exo-teams-probe-commands.md index 01d31168..6725502c 100644 --- a/docs/gaps/GAP-003-exo-teams-probe-commands.md +++ b/docs/gaps/GAP-003-exo-teams-probe-commands.md @@ -1,19 +1,21 @@ --- id: GAP-003 title: Add representative EXO and Teams probe commands -status: open +status: in-progress severity: high area: runtime-probes owner: maintainer created: 2026-06-23 -updated: 2026-06-23 +updated: 2026-08-08 related_issues: [] related_prs: [] related_docs: - docs/Architecture.md - build/dependency-policy.json - docs/DEPENDENCIES.md -related_tests: [] +related_tests: + - tests/Unit/DependencyPolicy.Tests.ps1 + - tests/Unit/UpstreamInventoryProfile.Tests.ps1 resolution_pr: resolved_on: --- @@ -22,7 +24,7 @@ resolved_on: ## Status -**Current status:** Open. +**Current status:** In progress. The probe-command contract and tooling are implemented; exact profile/platform evidence has not yet been accepted. ## Problem @@ -34,8 +36,10 @@ DLLPickle's preload/block classification depends on observed runtime ownership, ## Current evidence -- `docs/Architecture.md` says static narrows but runtime decides. -- `docs/Architecture.md` records that EXO/Teams ALC ownership is not yet captured because bare `Import-Module` does not eagerly load their identity assemblies. +- `build/dependency-policy.json` assigns `Get-ConnectionInformation -ErrorAction SilentlyContinue | Out-Null` and `Get-Command Get-Team | Out-Null` to the deterministic no-auth tier. +- It separately records `Get-EXOMailbox -ResultSize 1 | Out-Null` and `Get-CsTenant | Out-Null` as authenticated read-only release gates. +- `Get-DLLPickleUpstreamInventory.ps1` passes the profile-specific deterministic command into the exact stock-host snapshot process. +- Unit tests validate policy parsing, probe separation, exact-host inventory, and selected-asset evidence without service authentication. ## Desired end state @@ -43,13 +47,13 @@ The runtime probe system supports representative `-ProbeCommand` execution for E ## Acceptance criteria -- [ ] Define safe, representative probe commands for ExchangeOnlineManagement and MicrosoftTeams. -- [ ] Update the relevant runtime probe tooling to support module-specific probe commands if it does not already. -- [ ] Record the selected probe commands in `build/dependency-policy.json` or another authoritative policy/configuration file. -- [ ] Add tests for probe-command configuration parsing and invocation behavior without requiring live authentication. -- [ ] Document which probes are CI-capable and which require maintainer-run/auth-tier validation. -- [ ] Update `docs/Architecture.md` §7, §9, or §10 as needed. -- [ ] Update `docs/gaps/README.md` and this file when resolved or superseded. +- [x] Define safe, representative probe commands for ExchangeOnlineManagement and MicrosoftTeams. +- [x] Update the relevant runtime probe tooling to support module-specific probe commands if it does not already. +- [x] Record the selected probe commands in `build/dependency-policy.json` or another authoritative policy/configuration file. +- [x] Add tests for probe-command configuration parsing and invocation behavior without requiring live authentication. +- [x] Document which probes are CI-capable and which require maintainer-run/auth-tier validation. +- [x] Update `docs/Architecture.md` §7, §9, or §10 as needed. +- [ ] Accept fresh exact-profile evidence for all required platforms and then update `docs/gaps/README.md` and this file as resolved. ## Implementation notes for Codex @@ -61,4 +65,4 @@ The runtime probe system supports representative `-ProbeCommand` execution for E ## Resolution notes -Pending. +Implementation is present in the current change. Resolution remains pending until the nine profile/platform baselines contain reviewed fingerprints rather than `requires-profile-refresh` placeholders. diff --git a/docs/gaps/README.md b/docs/gaps/README.md index aef4ed4e..236f2c4c 100644 --- a/docs/gaps/README.md +++ b/docs/gaps/README.md @@ -36,7 +36,7 @@ Use this register for durable repo-local gap state. Use `docs/superpowers/specs/ | --- | --- | --- | --- | --- | | GAP-001 | resolved | dependency-policy | Add dependency policy realization guard | [GAP-001](GAP-001-dependency-policy-realization-guard.md) | | GAP-002 | resolved | dependency-policy | Track Az.Resources as a monitored collision source | [GAP-002](GAP-002-az-resources-monitoring.md) | -| GAP-003 | open | runtime-probes | Add representative EXO and Teams probe commands | [GAP-003](GAP-003-exo-teams-probe-commands.md) | +| GAP-003 | in-progress | runtime-probes | Add representative EXO and Teams probe commands | [GAP-003](GAP-003-exo-teams-probe-commands.md) | | GAP-004 | open | host-context | Model VS Code and PowerShellEditorServices host behavior | [GAP-004](GAP-004-vscode-powershelleditorservices-host.md) | | GAP-005 | resolved | known-conflicts | Strengthen OData conflict expectation management | [GAP-005](GAP-005-odata-conflict-expectation-management.md) | | GAP-006 | resolved | release-process | Document manual release dispatch process trap | [GAP-006](GAP-006-release-dispatch-process-trap.md) | diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md new file mode 100644 index 00000000..b17c244f --- /dev/null +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -0,0 +1,258 @@ +# Draft plan: credentialed read-only authentication test environment + +**Status:** Draft for security, tenant, and repository-owner review +**Date:** 2026-08-09 +**Scope:** Microsoft Graph, Exchange Online, Azure PowerShell, and Microsoft Teams authentication gates for DLLPickle +**External changes performed by this document:** None + +## 1. Objective + +Create an approval-gated environment that can run DLLPickle's authenticated compatibility tier without interactive sign-in, tenant writes, long-lived client secrets, or misleading pass results when credentials are absent. + +The environment must prove that the supported DLLPickle runtime profiles can authenticate and execute the exact read-only probes declared in `build/dependency-policy.json` after the relevant modules and DLLPickle are loaded in both monitored import orders. + +This is separate from the deterministic, zero-credential import tier. A missing credential, missing role, conditional-access block, unsupported authentication mode, or unexecuted probe remains a failed or outstanding release gate. + +## 2. Current authenticated gates + +| Module family | Current authenticated read-only probe | What it proves | +| --- | --- | --- | +| Microsoft.Graph.Authentication | `Get-MgContext \| Out-Null` | A Graph authentication context was established in the current process. | +| ExchangeOnlineManagement | `Get-EXOMailbox -ResultSize 1 \| Out-Null` | EXO app-only authentication and a limited mailbox read succeed. | +| Az.Storage | `Get-AzStorageAccount \| Select-Object -First 1 \| Out-Null` | Azure Resource Manager authentication can enumerate storage-account control-plane metadata. | +| Az.Accounts | `Get-AzContext \| Out-Null` | An Azure PowerShell process-scoped context was established. | +| MicrosoftTeams | `Get-CsTenant \| Out-Null` | Teams application authentication and a tenant read succeed. | +| Az.Resources | `Get-AzResource \| Select-Object -First 1 \| Out-Null` | Azure Resource Manager authentication can enumerate resource metadata. | + +The Graph probe currently proves context establishment, not an API read. Before calling the Graph gate complete, decide whether to retain that narrow contract or add a separately reviewed read probe such as an organization read. Do not silently broaden it in the workflow. + +## 3. Non-goals and safety invariants + +- Do not create, update, delete, assign, invite, send, publish, or consent to tenant data during a test run. +- Do not run the credentialed job for pull requests from forks or for unreviewed code. +- Do not expose access tokens, certificates, assertion tokens, tenant identifiers that are classified as secrets, or command output containing customer data. +- Do not store a client secret in the repository. Prefer short-lived OpenID Connect tokens. A certificate fallback requires explicit approval and a rotation plan. +- Do not use Global Administrator, Owner, Contributor, Exchange Administrator, or another broad role merely to make the probes pass. +- Do not enable a schedule until the manual workflow has been reviewed, exercised, and accepted. +- Do not represent skipped probes as passing compatibility evidence. + +## 4. Recommended architecture + +### 4.1 GitHub control plane + +Create a GitHub environment named `authenticated-readonly` with: + +- required reviewer approval; +- self-review prevention where the repository plan supports it; +- deployment branches restricted to `main` and explicitly approved test branches; +- environment-scoped variables and secrets only; +- no automatic execution for forked pull requests; +- a workflow with `contents: read` and `id-token: write`, and no repository-writing permissions. + +GitHub environments can withhold environment secrets until required reviewers approve a job. See [Deployments and environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). + +Start with `workflow_dispatch` only. A release workflow may call the credentialed workflow later, but only after the environment approval and evidence-redaction controls are accepted. + +### 4.2 Identity separation + +Prefer two dedicated workload identities to keep Azure and Microsoft 365 authorization independently revocable: + +1. `dllpickle-azure-readonly` + - Federated to this repository and the `authenticated-readonly` GitHub environment. + - Azure `Reader` role at a dedicated test resource group, not the subscription root. + - The resource group contains at least one storage account and one harmless ARM resource so enumeration probes exercise real code paths. + +2. `dllpickle-m365-auth-probe` + - Federated to the same protected GitHub environment if Graph, EXO, and Teams token exchange is proven to work for the pinned module versions. + - Assigned only the application permissions and service-specific RBAC needed for the approved probes. + - No delegated user credentials. + +Microsoft documents GitHub-to-Azure workload identity federation through OIDC in [Authenticate to Azure from GitHub Actions by OpenID Connect](https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect). Azure's built-in `Reader` role permits control-plane reads without changes; scope it to the dedicated resource group. See [Azure built-in roles for General](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/general). + +### 4.3 Authentication mechanism decision + +Preferred path: + +- Use GitHub OIDC to obtain short-lived workload tokens. +- Use `azure/login` with Azure PowerShell session support for Az.Accounts/Az.Resources/Az.Storage. +- Exchange the workload identity for service-specific access tokens for Graph, Exchange Online, and Teams, then pass tokens directly to the modules in process scope. + +The feasibility spike must prove all of the following before this becomes the accepted design: + +- `Connect-MgGraph -AccessToken ... -ContextScope Process` accepts the acquired Graph token under every pinned PowerShell profile. +- `Connect-ExchangeOnline -AccessToken ... -Organization ` accepts the app-only Exchange token. +- `Connect-MicrosoftTeams -AccessTokens @(, )` accepts the required token pair. +- The token audience, cloud endpoints, and module versions are recorded without logging token values. + +First-party references: + +- [Microsoft Graph PowerShell authentication commands](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands?view=graph-powershell-1.0) +- [Exchange Online app-only authentication](https://learn.microsoft.com/en-us/powershell/exchange/app-only-auth-powershell-v2?view=exchange-ps) +- [Teams PowerShell application-based authentication](https://learn.microsoft.com/en-us/microsoftteams/teams-powershell-application-authentication) + +Fallback path, requiring a separate approval: + +- Use one short-lived X.509 certificate per Microsoft 365 test identity. +- Store the encrypted PFX and its password only as protected environment secrets, import it into an ephemeral certificate store, and remove it in an `always()` cleanup step. +- Record owner, expiry, rotation date, and emergency revocation instructions before first use. + +Graph, Exchange Online, and Teams all document certificate-based application authentication. The fallback is operationally simpler but introduces a long-lived credential and therefore is not the default. + +## 5. Least-privilege authorization work + +The tenant administrator must review and execute all consent and role assignments. Repository automation must not grant its own permissions. + +### 5.1 Microsoft Graph + +- Start with only the application permission needed by the approved Graph read probe. +- If the probe remains `Get-MgContext`, document that it validates authentication context only. +- If an organization read is approved, evaluate `Organization.Read.All` application permission and grant admin consent only after review. +- Use `-ContextScope Process` and call `Disconnect-MgGraph` during cleanup. + +### 5.2 Exchange Online + +- Configure app-only Exchange authentication using the tenant's primary `.onmicrosoft.com` organization name. +- Review the `Exchange.ManageAsApp` application permission requirement from Microsoft's app-only guidance. +- Use Exchange Online application RBAC or a custom least-privilege management role that permits the specific `Get-EXOMailbox -ResultSize 1` read. Do not assign Exchange Administrator solely for this test. +- Prove that the service principal can read one mailbox object and cannot execute a selected write negative-control command. +- Call `Disconnect-ExchangeOnline -Confirm:$false` during cleanup. + +### 5.3 Azure PowerShell + +- Assign Azure `Reader` at the dedicated test resource-group scope. +- Confirm the role exposes storage-account and resource control-plane metadata but no data-plane content. +- Include at least one storage account so `Get-AzStorageAccount` cannot pass vacuously because the environment is empty. +- Use a process-scoped context and clear it during cleanup. + +### 5.4 Microsoft Teams + +- Review the Teams application-authentication permission table for the pinned MicrosoftTeams module. +- `Get-CsTenant` requires a tenant read. Evaluate `Organization.Read.All` and the narrowest supported Microsoft Entra/Teams RBAC role for this cmdlet. +- Do not copy the broad permission set documented for all non-`*-Cs` cmdlets when only `Get-CsTenant` is in scope. +- Prove both the positive read and a denied write negative control. +- Call `Disconnect-MicrosoftTeams` during cleanup. + +## 6. Environment configuration contract + +Use environment variables for non-secret identifiers and environment secrets only where a provider cannot use OIDC directly. + +Candidate environment variables: + +- `DLLPICKLE_AUTH_TENANT_ID` +- `DLLPICKLE_AUTH_AZURE_CLIENT_ID` +- `DLLPICKLE_AUTH_M365_CLIENT_ID` +- `DLLPICKLE_AUTH_SUBSCRIPTION_ID` +- `DLLPICKLE_AUTH_RESOURCE_GROUP` +- `DLLPICKLE_AUTH_EXO_ORGANIZATION` +- `DLLPICKLE_AUTH_CLOUD` with an allow-listed default such as `AzureCloud` + +Certificate fallback secrets, if explicitly approved: + +- `DLLPICKLE_AUTH_M365_PFX_BASE64` +- `DLLPICKLE_AUTH_M365_PFX_PASSWORD` + +Never accept arbitrary connection commands, scopes, resource URLs, or probe script text from workflow inputs. Workflow inputs may select a reviewed profile or scenario identifier only. + +## 7. Workflow and test-harness implementation + +### Phase A: offline contract tests + +1. Add a schema for the credentialed environment variables and approved command allow-list. +2. Add zero-network unit tests for missing values, malformed GUIDs, unsupported clouds, and forbidden commands. +3. Add a dry-run mode that prints probe identifiers and expected permissions but never token values or connection arguments containing credentials. +4. Verify the ordinary unit and integration suites remain zero-credential and zero-network. + +### Phase B: manual identity feasibility spike + +1. Configure the protected GitHub environment and federated identity manually. +2. Run one exact Windows profile with no DLLPickle import to prove each provider's authentication mode independently. +3. Run the approved read probes and negative write controls. +4. Capture only sanitized identity metadata: tenant hash or approved tenant label, client ID if non-secret, token audience, expiry time, authentication mode, module version, command name, success/error type, and workflow run ID. +5. Revoke the federated credential or certificate after the spike if the design is rejected. + +### Phase C: DLLPickle compatibility matrix + +For each supported PowerShell release line, use the exact patch from `build/powershell-test-matrix.json` and a fresh process for every scenario: + +1. Module authentication without DLLPickle. +2. DLLPickle first, then module connection and read probe. +3. Module connection first, then DLLPickle and read probe where the module supports that order. +4. Both monitored cross-module import orders from `build/dependency-policy.json`. +5. Assembly/ALC snapshot before authentication, after connection, and after the read probe. + +Begin with the three Windows profiles. Expanding credentialed execution to Linux and macOS requires a separate support and risk review because it increases the number of environments receiving tenant tokens. + +### Phase D: release integration + +Only after the manual matrix is accepted: + +- add the credentialed tier as a reusable, environment-gated workflow; +- keep `workflow_dispatch` available for focused reruns; +- make release readiness depend on a fresh successful credentialed evidence artifact or an explicit maintainer waiver; +- do not allow the job to auto-approve, merge, publish, or mutate issues; +- set a documented evidence freshness window. + +## 8. Evidence schema and redaction + +Each probe record should include: + +- PowerShell exact version, CLR version, TFM, OS, architecture, `$PSHOME`, and executable path; +- DLLPickle version/commit and selected bundle; +- module name/version and import order; +- connection method identifier, token audience identifier, and token expiry timestamp; +- probe command identifier, result, duration, and normalized error type; +- tracked assembly name/version/hash/path/ALC before and after authentication; +- `WritesPerformed: false`; +- workflow run URL and environment name; +- explicit `Executed`, `Skipped`, or `Blocked` status. + +Redact access tokens, authorization headers, certificate bytes/passwords, mailbox identities, tenant domains where required, subscription/resource names where required, and raw command output. Upload JSON evidence only after a redaction test passes. + +## 9. Negative controls + +The feasibility run is not accepted without evidence that the identity is constrained: + +- Azure: an approved harmless write attempt using `-WhatIf` where supported, plus an authorization inspection showing no write actions at the assigned scope. Do not perform a real write merely to prove denial. +- Graph: inspect granted application permissions and verify no write permission is consented. +- Exchange: inspect the assigned application RBAC role and confirm it contains only required read cmdlets/parameters. +- Teams: inspect API permissions and assigned role; do not execute a state-changing Teams cmdlet. +- Workflow: confirm forked PRs cannot obtain the environment or OIDC subject, and confirm environment approval is required before token issuance. + +## 10. Cleanup, rotation, and incident response + +Every job uses an `always()` cleanup step to disconnect providers, clear process-scoped contexts, remove temporary certificate material, and delete temporary module/token caches. Evidence upload occurs after redaction and before runner teardown. + +Before enabling the environment, document: + +- identity owners and backup owners; +- federated credential subject and audiences; +- role/permission inventory; +- certificate expiry and rotation if the fallback is used; +- normal revocation procedure; +- emergency disable procedure for the GitHub environment and Entra service principals; +- audit-log locations and review cadence. + +## 11. Decisions requiring explicit approval + +1. Which tenant and dedicated Azure resource group may be used? +2. Is GitHub OIDC the required design, or may the certificate fallback be prototyped if a module cannot consume federated access tokens? +3. Should Graph remain a context-only gate or add a real organization read? +4. What exact Exchange application RBAC role is acceptable for `Get-EXOMailbox -ResultSize 1`? +5. What exact Teams role is the least privilege supported for `Get-CsTenant`? +6. Who reviews the `authenticated-readonly` GitHub environment? +7. What evidence fields require hashing or redaction for the selected tenant? +8. What freshness window is required before release? +9. Is credentialed coverage initially Windows-only across three PowerShell profiles, or is cross-platform token exposure approved? + +## 12. Definition of done + +- The GitHub environment is protected and cannot be used by forked or unreviewed code. +- No long-lived client secret is used. +- Every permission and role is documented with scope and owner. +- All six configured authenticated read probes execute under each approved profile, or the exact blocked probe is recorded as a release gate. +- Both monitored import orders and with/without-DLLPickle scenarios produce sanitized ALC evidence. +- No test performs a tenant or Azure resource write. +- Missing credentials and denied permissions fail closed. +- Tokens and tenant data are absent from logs and artifacts. +- Revocation and cleanup are demonstrated. +- Maintainers explicitly accept the evidence before changing `not-run-no-approved-credentials` status in `build/dependency-policy.json`. From 5ed628ca318aaaa2dfbb60ad39c176b3912ea1a7 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:15:58 -0400 Subject: [PATCH 04/43] fix(ci): guard optional PowerShellGet parameters --- .github/ci-scripts/Actions_Bootstrap.ps1 | 16 ++++++++-- build/DLLPickle.Tooling.ps1 | 24 ++++++++++++--- tests/Unit/BuildTooling.Tests.ps1 | 37 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/.github/ci-scripts/Actions_Bootstrap.ps1 b/.github/ci-scripts/Actions_Bootstrap.ps1 index 782a7ca8..bdf75697 100644 --- a/.github/ci-scripts/Actions_Bootstrap.ps1 +++ b/.github/ci-scripts/Actions_Bootstrap.ps1 @@ -52,6 +52,13 @@ Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null Set-PSRepository -Name PSGallery -InstallationPolicy Trusted Write-Host '📦 Installing exact PowerShell build-tool versions' +$ModuleInstallCommandName = if ([string]::IsNullOrWhiteSpace($ModuleInstallPath)) { + 'Install-Module' +} else { + 'Save-Module' +} +$ModuleInstallCommand = Get-Command -Name $ModuleInstallCommandName -ErrorAction Stop + foreach ($Module in @($ToolPolicy.modules)) { $RequiredVersion = [version]$Module.version $InstalledModule = Get-Module -ListAvailable -Name $Module.name | @@ -66,17 +73,20 @@ foreach ($Module in @($ToolPolicy.modules)) { Force = $true ErrorAction = 'Stop' } - if ($Module.skipPublisherCheck) { + if ( + $Module.skipPublisherCheck -and + (Test-DLLPickleCommandParameter -Command $ModuleInstallCommand -ParameterName 'SkipPublisherCheck') + ) { $ModuleCommandSplat['SkipPublisherCheck'] = $true } try { if ([string]::IsNullOrWhiteSpace($ModuleInstallPath)) { $ModuleCommandSplat['Scope'] = 'CurrentUser' - Install-Module @ModuleCommandSplat + & $ModuleInstallCommand @ModuleCommandSplat } else { $ModuleCommandSplat['Path'] = $ModuleInstallPath - Save-Module @ModuleCommandSplat + & $ModuleInstallCommand @ModuleCommandSplat } } catch { Write-Host " - Failed to install $($Module.name) $RequiredVersion" diff --git a/build/DLLPickle.Tooling.ps1 b/build/DLLPickle.Tooling.ps1 index 70cd1f15..f2a5f307 100644 --- a/build/DLLPickle.Tooling.ps1 +++ b/build/DLLPickle.Tooling.ps1 @@ -53,12 +53,28 @@ function Get-DLLPickleBuildToolVersion { [string]$Name ) - $Matches = @($Policy.modules | Where-Object { $_.name -eq $Name }) - if ($Matches.Count -ne 1) { - throw "Expected exactly one build-tool policy entry for '$Name'; found $($Matches.Count)." + $MatchingModules = @($Policy.modules | Where-Object { $_.name -eq $Name }) + if ($MatchingModules.Count -ne 1) { + throw "Expected exactly one build-tool policy entry for '$Name'; found $($MatchingModules.Count)." } - return [version]$Matches[0].version + return [version]$MatchingModules[0].version +} + +function Test-DLLPickleCommandParameter { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Management.Automation.CommandInfo]$Command, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ParameterName + ) + + return $Command.Parameters.ContainsKey($ParameterName) } function Test-DLLPickleToolVersionMatch { diff --git a/tests/Unit/BuildTooling.Tests.ps1 b/tests/Unit/BuildTooling.Tests.ps1 index 1e2992c1..3aa8f3a4 100644 --- a/tests/Unit/BuildTooling.Tests.ps1 +++ b/tests/Unit/BuildTooling.Tests.ps1 @@ -56,6 +56,43 @@ Describe 'Deterministic build tooling' -Tag 'Unit' { $wrapper | Should -Not -Match 'MinimumVersion|MaximumVersion' } + It 'detects whether the active module command supports SkipPublisherCheck' { + function Test-CommandWithPublisherCheck { + param( + [Parameter()] + [switch]$SkipPublisherCheck + ) + + $null = $SkipPublisherCheck + } + + function Test-CommandWithoutPublisherCheck { + param( + [Parameter()] + [switch]$Force + ) + + $null = $Force + } + + $WithPublisherCheck = Get-Command -Name Test-CommandWithPublisherCheck + $WithoutPublisherCheck = Get-Command -Name Test-CommandWithoutPublisherCheck + + Test-DLLPickleCommandParameter -Command $WithPublisherCheck -ParameterName 'SkipPublisherCheck' | + Should -BeTrue + Test-DLLPickleCommandParameter -Command $WithoutPublisherCheck -ParameterName 'SkipPublisherCheck' | + Should -BeFalse + } + + It 'guards optional publisher-check parameters before invoking the module command' { + $bootstrap = Get-Content -LiteralPath $script:BootstrapPath -Raw + + $bootstrap | Should -Match ([regex]::Escape( + 'Test-DLLPickleCommandParameter -Command $ModuleInstallCommand -ParameterName ''SkipPublisherCheck''' + )) + $bootstrap | Should -Match ([regex]::Escape('& $ModuleInstallCommand @ModuleCommandSplat')) + } + It 'does not change StrictMode in the caller process' { $tooling = Get-Content -LiteralPath $script:ToolingScriptPath -Raw From fe8269479e57d7c0246f52ead671243931582001 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:26:47 -0400 Subject: [PATCH 05/43] fix(ci): make generated docs cross-platform --- tests/Unit/SupportDocumentation.Tests.ps1 | 12 ++++++++++++ tools/New-DLLPickleSupportDocumentation.ps1 | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/Unit/SupportDocumentation.Tests.ps1 b/tests/Unit/SupportDocumentation.Tests.ps1 index ee54b5dd..84f3eebc 100644 --- a/tests/Unit/SupportDocumentation.Tests.ps1 +++ b/tests/Unit/SupportDocumentation.Tests.ps1 @@ -12,6 +12,18 @@ Describe 'Generated support documentation' -Tag 'Unit' { { & $script:GeneratorPath -Check } | Should -Not -Throw } + It 'renders repository-canonical CRLF endings on every host' { + $OutputDirectory = Join-Path $TestDrive 'generated' + + $null = & $script:GeneratorPath -OutputDirectory $OutputDirectory + + foreach ($DocumentName in @('Support-Matrix.md', 'Compatibility-Evidence.md')) { + $Document = [System.IO.File]::ReadAllText((Join-Path $OutputDirectory $DocumentName)) + $Document | Should -Match "`r`n" + ($Document -replace "`r`n", '') | Should -Not -Match "`n" + } + } + It 'keeps the primary documentation linked to the generated support contract' { Get-Content -LiteralPath $script:ReadmePath -Raw | Should -Match 'generated/Support-Matrix\.md' Get-Content -LiteralPath $script:ArchitecturePath -Raw | Should -Match 'generated/Support-Matrix\.md' diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 index d48cf1e5..d56c9d77 100644 --- a/tools/New-DLLPickleSupportDocumentation.ps1 +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -50,7 +50,7 @@ if (Compare-Object -ReferenceObject $ShippedProfileKeys -DifferenceObject $TestP throw 'Shipped runtime profiles and documentation test profiles do not align.' } $VerifiedDate = ([System.DateTimeOffset]$TestMatrix.lastVerifiedUtc).ToString('yyyy-MM-dd') -$NewLine = [Environment]::NewLine +$NewLine = "`r`n" $SupportLines = [System.Collections.Generic.List[string]]::new() $SupportLines.Add('# Supported PowerShell runtime matrix') From ae012f48d3e4ae84978572d0d0ed85a3afec15c0 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:44:03 -0400 Subject: [PATCH 06/43] test: isolate runtime matrix fixtures --- tests/Unit/DependencyAutomation.Tests.ps1 | 31 +++++++++++++++++-- tests/Unit/RuntimeProvisioning.Tests.ps1 | 25 ++++++++++++++- tests/Unit/UpstreamInventoryProfile.Tests.ps1 | 26 ++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index 48ccfe4c..59e858d3 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -3,6 +3,28 @@ BeforeAll { $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $script:InventoryScriptPath = Join-Path $ProjectRoot 'tools\Get-DLLPickleUpstreamInventory.ps1' $script:UpdateScriptPath = Join-Path $ProjectRoot 'tools\Update-DLLPickleDependencyPins.ps1' + + function Write-DependencyAutomationRuntimeMatrixFixture { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [ordered]@{ + schemaVersion = 1 + profiles = @( + [ordered]@{ + powerShellVersion = $PSVersionTable.PSVersion.ToString() + powerShellMajor = $PSVersionTable.PSVersion.Major + powerShellMinor = $PSVersionTable.PSVersion.Minor + dotnetMajor = [Environment]::Version.Major + targetFramework = 'net{0}.0' -f [Environment]::Version.Major + } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $Path -Encoding UTF8 + + return $Path + } } Describe 'Dependency automation tooling' -Tag 'Unit' { @@ -11,6 +33,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $AssemblyName = $Assembly.GetName().Name $ModuleCachePath = Join-Path -Path $TestDrive -ChildPath 'atomic-modules' $PolicyPath = Join-Path -Path $TestDrive -ChildPath 'atomic-policy.json' + $TestMatrixPath = Write-DependencyAutomationRuntimeMatrixFixture -Path (Join-Path $TestDrive 'atomic-runtime-matrix.json') @{ monitoredModules = @( @{ name = 'Synthetic.One'; repository = 'PSGallery'; purpose = 'First synthetic module.' } @@ -44,7 +67,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { New-ModuleManifest -Path (Join-Path $ModuleRoot "$Name.psd1") -RootModule "$Name.psm1" -ModuleVersion ([string]$RequiredVersion) } - $null = & $script:InventoryScriptPath -PolicyPath $PolicyPath -ModuleCachePath $ModuleCachePath -OutputPath (Join-Path $TestDrive 'atomic-inventory.json') + $null = & $script:InventoryScriptPath -PolicyPath $PolicyPath -TestMatrixPath $TestMatrixPath -ModuleCachePath $ModuleCachePath -OutputPath (Join-Path $TestDrive 'atomic-inventory.json') $InventoryEvents = @($InventoryTestState.Events) [System.AppDomain]::CurrentDomain.SetData($InventoryTestStateKey, $null) @@ -62,6 +85,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { New-ModuleManifest -Path (Join-Path $ModuleRoot 'Synthetic.Graph.psd1') -RootModule 'Synthetic.Graph.psm1' -ModuleVersion '1.0.0' $PolicyPath = Join-Path -Path $TestDrive -ChildPath 'policy.json' + $TestMatrixPath = Write-DependencyAutomationRuntimeMatrixFixture -Path (Join-Path $TestDrive 'inventory-runtime-matrix.json') @{ monitoredModules = @( @{ @@ -75,7 +99,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { blockedPreloadAssemblies = @() } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 - $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'inventory.json') + $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -TestMatrixPath $TestMatrixPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'inventory.json') $Result.Modules | Should -HaveCount 1 $Result.Modules[0].Name | Should -Be 'Synthetic.Graph' @@ -104,6 +128,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { '@ | Set-Content -LiteralPath (Join-Path $ModuleRoot 'Synthetic.Dynamic.psd1') -Encoding UTF8 $PolicyPath = Join-Path $TestDrive 'dynamic-policy.json' + $TestMatrixPath = Write-DependencyAutomationRuntimeMatrixFixture -Path (Join-Path $TestDrive 'dynamic-runtime-matrix.json') @{ monitoredModules = @(@{ name = 'Synthetic.Dynamic'; repository = 'PSGallery'; purpose = 'Dynamic manifest regression.' }) trackedAssemblies = @($AssemblyName) @@ -111,7 +136,7 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { blockedPreloadAssemblies = @() } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 - $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'dynamic-inventory.json') + $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -TestMatrixPath $TestMatrixPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'dynamic-inventory.json') $Result.Modules[0].ManifestPowerShellVersion | Should -Be '7.0' } diff --git a/tests/Unit/RuntimeProvisioning.Tests.ps1 b/tests/Unit/RuntimeProvisioning.Tests.ps1 index 1a49ef40..b9e3e99d 100644 --- a/tests/Unit/RuntimeProvisioning.Tests.ps1 +++ b/tests/Unit/RuntimeProvisioning.Tests.ps1 @@ -2,14 +2,37 @@ BeforeAll { $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent $script:ProvisionerPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'tools/Install-DLLPickleTestPowerShell.ps1' $script:MatrixPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'build/powershell-test-matrix.json' + + function Write-RuntimeProvisioningMatrixFixture { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [ordered]@{ + schemaVersion = 1 + profiles = @( + [ordered]@{ + powerShellVersion = $PSVersionTable.PSVersion.ToString() + powerShellMajor = $PSVersionTable.PSVersion.Major + powerShellMinor = $PSVersionTable.PSVersion.Minor + dotnetMajor = [Environment]::Version.Major + targetFramework = 'net{0}.0' -f [Environment]::Version.Major + } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $Path -Encoding UTF8 + + return $Path + } } Describe 'Exact PowerShell runtime provisioning' -Tag 'Unit' { It 'validates an explicit stock executable without multi-pwsh' { $currentExecutable = (Get-Process -Id $PID).Path $currentVersion = $PSVersionTable.PSVersion.ToString() + $TestMatrixPath = Write-RuntimeProvisioningMatrixFixture -Path (Join-Path $TestDrive 'runtime-matrix.json') - $result = & $script:ProvisionerPath -PowerShellExecutable $currentExecutable -PowerShellVersion $currentVersion -PassThru + $result = & $script:ProvisionerPath -PowerShellExecutable $currentExecutable -PowerShellVersion $currentVersion -MatrixPath $TestMatrixPath -PassThru $result.Provider | Should -Be 'ExplicitExecutable' $result.PowerShellVersion | Should -Be $currentVersion diff --git a/tests/Unit/UpstreamInventoryProfile.Tests.ps1 b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 index 8ed2642f..9fe1603b 100644 --- a/tests/Unit/UpstreamInventoryProfile.Tests.ps1 +++ b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 @@ -1,7 +1,28 @@ BeforeAll { $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent $script:InventoryToolPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'tools/Get-DLLPickleUpstreamInventory.ps1' - $script:TestMatrixPath = Join-Path -Path $script:RepositoryRoot -ChildPath 'build/powershell-test-matrix.json' + + function Write-UpstreamInventoryRuntimeMatrixFixture { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [ordered]@{ + schemaVersion = 1 + profiles = @( + [ordered]@{ + powerShellVersion = $PSVersionTable.PSVersion.ToString() + powerShellMajor = $PSVersionTable.PSVersion.Major + powerShellMinor = $PSVersionTable.PSVersion.Minor + dotnetMajor = [Environment]::Version.Major + targetFramework = 'net{0}.0' -f [Environment]::Version.Major + } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $Path -Encoding UTF8 + + return $Path + } function Get-UpstreamInventoryFixture { $root = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('n')) @@ -44,9 +65,10 @@ BeforeAll { Describe 'Profile-aware upstream inventory' -Tag 'Unit' { It 'records exact runtime identity and only actually selected tracked assets' { $fixture = Get-UpstreamInventoryFixture + $TestMatrixPath = Write-UpstreamInventoryRuntimeMatrixFixture -Path (Join-Path $TestDrive 'runtime-matrix.json') $parameters = @{ PolicyPath = $fixture.PolicyPath - TestMatrixPath = $script:TestMatrixPath + TestMatrixPath = $TestMatrixPath ModuleCachePath = $fixture.ModuleCachePath OutputPath = $fixture.OutputPath PowerShellExecutable = [Environment]::ProcessPath From 8e420e9a52d2a908fa52363213fdfe2c805588c2 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:52:59 -0400 Subject: [PATCH 07/43] docs: normalize auth plan formatting --- ...08-09-credentialed-authentication-test-environment.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index b17c244f..ee0cd12d 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -1,8 +1,11 @@ # Draft plan: credentialed read-only authentication test environment -**Status:** Draft for security, tenant, and repository-owner review -**Date:** 2026-08-09 -**Scope:** Microsoft Graph, Exchange Online, Azure PowerShell, and Microsoft Teams authentication gates for DLLPickle +**Status:** Draft for security, tenant, and repository-owner review + +**Date:** 2026-08-09 + +**Scope:** Microsoft Graph, Exchange Online, Azure PowerShell, and Microsoft Teams authentication gates for DLLPickle + **External changes performed by this document:** None ## 1. Objective From b271635b5b34ad615d7b3d9753f868c3ccc35da5 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:39:23 -0400 Subject: [PATCH 08/43] chore: accept initial artifact size baseline --- build/artifact-size-baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/artifact-size-baseline.json b/build/artifact-size-baseline.json index f0b3acaf..0fd5fb87 100644 --- a/build/artifact-size-baseline.json +++ b/build/artifact-size-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "approvalStatus": "requires-maintainer-approval", + "approvalStatus": "accepted", "capturedAtUtc": "2026-08-09T03:43:28Z", - "approvedAtUtc": null, + "approvedAtUtc": "2026-08-09T17:36:05Z", "thresholds": { "maximumIncreasePercent": 10, "maximumIncreaseBytes": 2097152 From 055e6ea9c004a74ef475a5d23bd4b9def593fb38 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:39:50 -0400 Subject: [PATCH 09/43] docs: add multitargeting implementation plan --- ...ported-powershell-multitargeting-prompt.md | 56 +++ ...-08-supported-powershell-multitargeting.md | 388 ++++++++++++++++++ 2 files changed, 444 insertions(+) create mode 100644 docs/plans/2026-08-08-supported-powershell-multitargeting-prompt.md create mode 100644 docs/plans/2026-08-08-supported-powershell-multitargeting.md diff --git a/docs/plans/2026-08-08-supported-powershell-multitargeting-prompt.md b/docs/plans/2026-08-08-supported-powershell-multitargeting-prompt.md new file mode 100644 index 00000000..6d72711b --- /dev/null +++ b/docs/plans/2026-08-08-supported-powershell-multitargeting-prompt.md @@ -0,0 +1,56 @@ +# Fresh-Conversation Implementation Prompt + +Open a fresh Codex project conversation at the DLLPickle repository root and paste the prompt below. Goal mode is recommended because this is a long-running migration with a concrete validation loop and stopping condition. + +```text +/goal Implement docs/plans/2026-08-08-supported-powershell-multitargeting.md completely and safely. Continue through reviewable checkpoints until the implementation and all locally or CI-runnable validation are complete, or until a genuine authority/credential/external-state blocker requires my input. + +Repository: DLLPickle +Plan: docs/plans/2026-08-08-supported-powershell-multitargeting.md + +Read the complete plan before editing. Then inspect the current repository instructions, worktree status, documentation, workflows, open PRs/issues, and relevant history. Revalidate Microsoft's live PowerShell lifecycle and current servicing patches before relying on the plan's 2026-08-08 snapshot. If the currently supported PowerShell release-line set differs from the plan, stop before changing the settled support set and explain the exact lifecycle delta and implementation impact. + +Implement the plan in small, test-first checkpoints. Keep at most one implementation phase in progress at a time, report concise progress, and validate each phase before advancing. Preserve unrelated user changes. Do not commit, push, rebase, open or modify a PR, merge, publish, release, or change external data unless I explicitly authorize it. + +Non-negotiable requirements: + +1. Within the PowerShell 7 scope, support every and only release lines still supported by Microsoft. The initial expected set is PowerShell 7.4/net8.0, 7.5/net9.0, and 7.6/net10.0. +2. Test the exact current Microsoft-serviced patch for each supported line. Do not infer support for one line from another installed pwsh. +3. Build isolated net8.0, net9.0, and net10.0 dependency bundles while those three PowerShell lines remain supported. Select the bundle using both the running PowerShell minor line and CLR major, and fail closed on mismatches. +4. Keep multi-pwsh strictly optional and CI/test-only. It must not be shipped, added to the module manifest, referenced by production code, added as a runtime/package dependency, or required to build/install/import/use DLLPickle. +5. If multi-pwsh is used to provision tests, pin and checksum it, use a temporary isolated root, and invoke the official installed pwsh/pwsh.exe directly. Do not use multi-pwsh aliases, native host mode, venv startup hooks, or MCP mode as evidence for the stock PowerShell host. Tests must also accept explicit stock PowerShell executable paths so multi-pwsh can be removed or replaced without product changes. +6. Preserve the documented dependency contract: Dependabot patch/minor dependency updates may auto-approve and auto-merge only after every required build, test, compatibility, and dependency-review gate passes. Major dependency updates must become tested draft PRs with per-TFM evidence and must never auto-merge. +7. Make upstream conflict evidence specific to PowerShell line, TFM, OS/platform, module version, selected asset, import order, and AssemblyLoadContext. Cover Microsoft.Graph, Az.Accounts/Az.Resources/Az.Storage, ExchangeOnlineManagement, and MicrosoftTeams. Do not carry net8.0 classifications forward without fresh evidence. +8. Preserve known safety and compatibility lessons from PR #215 and issues #169, #174, #193, #242, and #273. Expected upstream incompatibilities should be documented and tested as limitations, not hidden or falsely reported as fixed. +9. Stabilize Pester/build-tool loading before interpreting dependency failures. Use fresh non-profile child processes and exact tool versions where necessary. +10. Add package inspection proving that multi-pwsh and all CI-only assets are absent from the published artifact. +11. Use natural PowerShell continuation or splatting; do not introduce backtick line continuations. +12. Ordinary unit tests must be deterministic and network-free. Keep live module discovery, external downloads, authentication, and tenant checks in explicit integration/scheduled lanes. +13. Do not use real credentials or perform tenant/external writes without explicit approval. If authenticated read-only validation cannot run, implement and test its harness, then identify the exact missing validation rather than claiming full success. + +Required implementation behavior: + +- Start by making the existing CI/Pester harness deterministic. +- Add shipped runtime-profile policy separately from non-shipped exact test-patch/tooling metadata. +- Multi-target and locked-restore net8.0, net9.0, and net10.0. +- Parameterize all child-process and scenario tooling with an exact PowerShell executable. +- Generate the PowerShell/OS matrix from canonical data and retain stable aggregate required-check names. +- Derive NuGet asset selection from restored project.assets.json rather than a handwritten TFM approximation. +- Update dependency-policy and upstream evidence per profile. +- Deduplicate unchanged drift reports by fingerprint. +- Add generated compatibility documentation and artifact-size reporting. +- Refresh documentation and changelog claims. + +Verification and stopping condition: + +- Analyzer, unit, integration, issue-reproduction, restore, build, pack, loader-selection, policy-schema, workflow-guardrail, documentation-drift, and artifact-inspection tests pass. +- Every available supported PowerShell/OS cell runs the expected stock executable and records PowerShell, CLR, TFM, process path, PSHOME, OS, architecture, selected bundle, and ALC evidence. +- The artifact contains exactly the supported TFM bundles and contains no multi-pwsh files or dependency declarations. +- Dependabot patch/minor and major paths retain their distinct automatic-versus-reviewed behavior. +- All deterministic work is complete, the diff is reviewed with git diff --check, and no unrelated files are changed. +- Credential-dependent or externally blocked validation is either completed with authorization or listed precisely as an outstanding release gate. + +Do not mark the goal complete merely because implementation is extensive or because an external validation lane is unavailable. If genuinely blocked, exhaust safe local work, preserve a clear validation handoff, and ask for the smallest specific input needed. +``` + +If `/goal` is unavailable, enable Goal mode in Codex settings or use the same text as a normal implementation prompt. A normal prompt can still implement the plan; Goal mode mainly supplies persistence and progress controls for the long-running execution. diff --git a/docs/plans/2026-08-08-supported-powershell-multitargeting.md b/docs/plans/2026-08-08-supported-powershell-multitargeting.md new file mode 100644 index 00000000..ecb2a0b6 --- /dev/null +++ b/docs/plans/2026-08-08-supported-powershell-multitargeting.md @@ -0,0 +1,388 @@ +# Microsoft-Supported PowerShell Multi-Targeting Implementation Plan + +**Status:** Ready for implementation + +**Date:** 2026-08-08 + +**Goal:** Replace DLLPickle's single optimistic PowerShell 7.4 / `net8.0` contract with explicitly built, selected, tested, and evidence-backed support for every PowerShell 7 release line that Microsoft still supports. + +**Initial supported set:** PowerShell 7.4, 7.5, and 7.6, targeting `net8.0`, `net9.0`, and `net10.0` respectively. + +**Architecture:** Build one isolated dependency bundle per supported PowerShell/.NET profile. Select the exact bundle from the running PowerShell and CLR versions. Generate CI jobs from a canonical support policy, execute each profile in its own stock `pwsh` process, and maintain conflict evidence independently per profile and operating system. + +**Tech stack:** PowerShell, Pester, Invoke-Build, .NET SDK/MSBuild, NuGet locked restore, GitHub Actions, Dependabot, and optional `multi-pwsh`-assisted CI provisioning. + +--- + +## 1. Settled decisions + +These decisions are inputs to implementation and should not be silently re-adjudicated. + +1. DLLPickle supports only PowerShell 7 release lines that Microsoft currently supports. Windows PowerShell 5.1 remains out of scope. +2. As of 2026-08-08, the supported profiles are: + + | PowerShell | Current documented patch | .NET runtime | TFM | Microsoft lifecycle state | + | --- | --- | --- | --- | --- | + | 7.4 LTS | 7.4.18 | .NET 8 | `net8.0` | Supported; retirement listed in November 2026 | + | 7.5 | 7.5.9 | .NET 9 | `net9.0` | Supported; retirement listed in November 2026 | + | 7.6 LTS | 7.6.4 | .NET 10 | `net10.0` | Supported through November 2028 | + +3. Only the latest Microsoft-serviced patch in each supported release line is an authoritative test target. Exact patch pins are updated automatically after validation. +4. Adding a new PowerShell minor line or removing a retired line changes the distributed support contract and requires maintainer review. It must not auto-merge. +5. `multi-pwsh` is optional CI/test infrastructure only: + + - It is not shipped in the DLLPickle module. + - It is not a module manifest dependency. + - It is not a NuGet runtime or build-output dependency. + - Production code does not invoke or reference it. + - Building, installing, importing, and using DLLPickle do not require it. + - CI may replace it with another official-runtime provisioning mechanism without changing DLLPickle's public or runtime contract. + +6. When `multi-pwsh` provisions a test runtime, tests invoke the installed official `pwsh`/`pwsh.exe` directly. They do not use `pwsh-7.x` aliases, `multi-pwsh host`, native host shims, MCP mode, or virtual-environment startup hooks as evidence for the stock PowerShell host. +7. Routine Dependabot patch/minor dependency PRs may auto-approve and auto-merge only after all required build, test, dependency-review, and compatibility gates pass. +8. Major dependency PRs are tested, converted to draft, documented with per-TFM evidence, and left for maintainer review. They never auto-merge. +9. Conflict classifications are evidence-backed per PowerShell/TFM profile. A `net8.0` result must not be assumed valid for `net9.0` or `net10.0`. +10. Authentication-dependent or tenant-dependent tests do not perform writes and do not use real credentials without explicit approval. Missing credentials must be reported as an outstanding validation gate, not converted into a false success claim. + +## 2. Evidence and references + +Revalidate these sources at implementation start and immediately before release: + +- [Microsoft PowerShell lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/powershell) +- [PowerShell 7.4 release documentation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-74?view=powershell-7.4) +- [PowerShell 7.5 release documentation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-75?view=powershell-7.5) +- [PowerShell 7.6 release documentation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-76?view=powershell-7.6) +- [`awakecoding/multi-pwsh` snapshot](https://github.com/awakecoding/multi-pwsh) +- [Maintained `Devolutions/multi-pwsh` repository](https://github.com/Devolutions/multi-pwsh) +- [`multi-pwsh` host and virtual-environment behavior](https://github.com/Devolutions/multi-pwsh/blob/master/docs/host-and-venv.md) + +The implementation must also preserve lessons established in the repository history: + +- [PR #215](https://github.com/SamErde/DLLPickle/pull/215): identical assembly versions loaded in different ALCs can still break authentication. +- [Issue #193](https://github.com/SamErde/DLLPickle/issues/193): preloading some `Microsoft.Extensions.*` assemblies can break downstream modules. +- [Issue #242](https://github.com/SamErde/DLLPickle/issues/242): PowerShell script-block assembly callbacks can crash the process. +- [Issue #174](https://github.com/SamErde/DLLPickle/issues/174): Az.Storage and ExchangeOnlineManagement can have a genuine OData conflict requiring process isolation. +- [Issue #169](https://github.com/SamErde/DLLPickle/issues/169): the VS Code/PowerShellEditorServices host surface remains unresolved. +- [Issue #273](https://github.com/SamErde/DLLPickle/issues/273): the upstream compatibility baseline is stale and drift reporting is noisy. + +## 3. Current-state gaps + +The current implementation is intentionally single-profile: + +- `src/DLLPickle.Build/DLLPickle.csproj` targets only `net8.0`. +- `src/DLLPickle/Public/Import-DPLibrary.ps1` defaults to `bin/net8.0`. +- `src/DLLPickle/DLLPickle.psd1` and `build/DLLPickle.Settings.ps1` require PowerShell 7.4. +- `global.json` pins a .NET 8 SDK. +- Unit and integration tests contain `net8.0` assumptions. +- `Build Module.yml` tests hosted-runner operating systems but not exact PowerShell release lines. +- `dependency-policy.json` contains one global `net8.0` policy/baseline. +- Upstream inventory scans DLLs recursively without recording the asset TFM actually selected by a given runtime. +- The runtime snapshot tool launches a generic `pwsh` rather than an explicitly supplied executable. +- The TFM-alignment tool uses a partial handwritten compatibility model instead of NuGet's resolved asset graph. +- Current CI can load conflicting Pester versions in one process, obscuring dependency-specific results. + +## 4. Target architecture + +### 4.1 Shipped runtime support map + +Add a small shipped data file such as `src/DLLPickle/SupportedRuntimeProfiles.json` containing only runtime behavior required by the module: + +```json +{ + "schemaVersion": 1, + "profiles": [ + { + "powerShellMajor": 7, + "powerShellMinor": 4, + "dotnetMajor": 8, + "targetFramework": "net8.0" + }, + { + "powerShellMajor": 7, + "powerShellMinor": 5, + "dotnetMajor": 9, + "targetFramework": "net9.0" + }, + { + "powerShellMajor": 7, + "powerShellMinor": 6, + "dotnetMajor": 10, + "targetFramework": "net10.0" + } + ] +} +``` + +Do not place `multi-pwsh` versions, URLs, paths, or test-only patch pins in this shipped file. + +The loader must match both the PowerShell minor line and CLR major. It must fail clearly when: + +- the PowerShell line is unsupported; +- the CLR major does not match the profile; +- the matching TFM directory or required bundle is missing; or +- the policy contains duplicate or malformed profiles. + +### 4.2 CI support and test matrix + +Add a non-shipped file such as `build/powershell-test-matrix.json` containing: + +- exact current patch per supported line; +- Microsoft lifecycle end date and last verification timestamp; +- expected CLR and TFM; +- supported OS/architecture lanes; +- evidence freshness threshold; +- optional pinned `multi-pwsh` version and release checksum metadata. + +Tests must verify that the shipped and CI profile sets agree, while allowing exact patch and CI-tool metadata to remain outside the package. + +### 4.3 Build outputs + +Change the build project to: + +```xml +net8.0;net9.0;net10.0 +``` + +The module artifact must contain isolated output directories for the three profiles. Do not deduplicate files across profiles merely because their hashes currently match; a shared physical load path can change assembly resolution and needs separate evidence. + +## 5. Implementation tasks + +Each task should finish with focused green tests before advancing. Preserve unrelated worktree changes. Do not commit, push, or open a PR unless the maintainer explicitly requests it. + +### Task 0: Refresh live state and establish a clean baseline + +**Primary files:** repository state, live lifecycle sources, current GitHub issues/PRs, current workflows. + +- [ ] Fetch/prune repository metadata without rewriting local work. +- [ ] Confirm the current branch, worktree cleanliness, and divergence from `origin/main`. +- [ ] Revalidate Microsoft's currently supported PowerShell lines and exact current servicing patches. +- [ ] Revalidate the maintained canonical `multi-pwsh` project and current release before pinning it. +- [ ] Inspect open dependency PRs and issue #273 so existing drift is not mistaken for a new multi-TFM regression. +- [ ] Record any change from the initial 7.4/7.5/7.6 assumption and obtain maintainer direction before changing the settled support set. + +### Task 1: Make the existing CI harness deterministic + +**Likely files:** `.github/ci-scripts/Actions_Bootstrap.ps1`, `build/DLLPickle.Build.ps1`, workflow cache definitions, focused workflow tests. + +- [ ] Pin Pester, InvokeBuild, and other build/test tools to explicit versions. +- [ ] Prevent multiple Pester assemblies from being imported into one process. +- [ ] Launch independent build/test stages in fresh `pwsh -NoProfile -NonInteractive` processes where necessary. +- [ ] Include exact tool version, OS, architecture, and PowerShell line in relevant cache keys. +- [ ] Add a regression that detects a mismatched already-loaded Pester assembly. +- [ ] Confirm current Dependabot PR failures report dependency behavior rather than bootstrap nondeterminism. + +**Gate:** Existing single-profile tests are green and reproducible before multi-targeting begins. + +### Task 2: Add support-policy data and validation + +**Create:** + +- `src/DLLPickle/SupportedRuntimeProfiles.json` +- `build/powershell-test-matrix.json` +- focused policy/schema tests under `tests/Unit/` + +**Modify:** manifest/build settings and any package-copy allowlists needed for the small runtime profile file. + +- [ ] Write failing schema, uniqueness, and cross-file-alignment tests. +- [ ] Add the shipped runtime profile and non-shipped CI matrix. +- [ ] Validate PowerShell minor, CLR major, TFM, supported OS, exact patch, lifecycle date, and evidence timestamp fields. +- [ ] Add a release-time lifecycle check that fails if the package still claims an expired Microsoft support line. +- [ ] Add a scheduled warning before an impending retirement. +- [ ] Ensure an exact patch-only CI matrix update does not alter the shipped runtime contract. + +### Task 3: Multi-target the dependency bundle and loader + +**Likely files:** + +- `src/DLLPickle.Build/DLLPickle.csproj` +- `packages.lock.json` +- `global.json` +- `build/DLLPickle.Build.ps1` +- `build/DLLPickle.Settings.ps1` +- `src/DLLPickle/DLLPickle.psd1` +- `src/DLLPickle/Public/Import-DPLibrary.ps1` +- TFM and import unit/integration tests + +- [ ] Write failing tests for all three profile mappings and mismatch cases. +- [ ] Target `net8.0`, `net9.0`, and `net10.0` with locked restore. +- [ ] Pin a suitable .NET 10 SDK capable of building all three TFMs. +- [ ] Make build/copy/pack tasks handle all declared TFMs without hardcoded `net8.0` paths. +- [ ] Make the loader select the exact profile from PowerShell and CLR versions. +- [ ] Set the manifest minimum to the oldest currently supported PowerShell line while enforcing the exact supported set in the loader. +- [ ] Keep common dependency versions initially; introduce conditional per-TFM versions only when evidence requires them. +- [ ] Verify every packaged TFM has the expected dependency set and no undeclared spillover. + +### Task 4: Add optional exact-runtime provisioning + +**Create or modify:** a CI helper such as `tools/Install-DLLPickleTestPowerShell.ps1` and focused tests. + +The helper should accept an explicit provider or executable path so tests are not coupled to `multi-pwsh`: + +```text +-PowerShellExecutable Use an already-provisioned stock executable +-Provider MultiPwsh Optionally provision through multi-pwsh +-Provider DirectArchive Provision from official PowerShell release archives +``` + +- [ ] Pin and checksum-verify any `multi-pwsh` release used in CI. +- [ ] Treat pre-1.0 `multi-pwsh` minor updates as reviewed toolchain changes. +- [ ] Install under a runner-temporary explicit root with no persistent PATH mutation. +- [ ] Derive the official executable path from the installation root/version, not from alias output. +- [ ] Invoke the real installed `pwsh`/`pwsh.exe` directly. +- [ ] Verify exact PowerShell version, CLR description/version, `$PSHOME`, process path, OS, architecture, and expected TFM before running product tests. +- [ ] Reject a `multi-pwsh` host shim or any executable path outside the expected official payload root. +- [ ] Cache official archives by provider version, OS, architecture, and exact PowerShell patch. +- [ ] Prove that the build and tests can run with an explicit executable path when `multi-pwsh` is absent. +- [ ] Add an artifact/package inspection asserting no `multi-pwsh` file, package, manifest dependency, or runtime reference is shipped. + +### Task 5: Generate the exact PowerShell/OS CI matrix + +**Likely files:** `.github/workflows/Build Module.yml`, reusable workflow/scripts, workflow guardrail tests. + +Generate nine authoritative cells from `build/powershell-test-matrix.json`: + +```text +PowerShell 7.4 × Windows, Linux, macOS +PowerShell 7.5 × Windows, Linux, macOS +PowerShell 7.6 × Windows, Linux, macOS +``` + +Each cell must: + +- [ ] provision or receive one exact stock PowerShell executable; +- [ ] use an isolated `PSModulePath` and fresh process; +- [ ] verify runtime identity before testing; +- [ ] run unit, integration, packaging, loader-selection, and known-regression tests; +- [ ] capture assembly/ALC snapshots and the selected bundle path; and +- [ ] upload a structured result artifact. + +Preserve stable required-check names: + +- `Build gate` aggregates build/runtime matrix results. +- `Validate upstream compatibility tooling` aggregates policy, evidence, drift, and freshness results. +- `dependency-review` remains required. + +The matrix must cover regressions represented by issues/PRs #34, #193, #215, and #242. Issue #174 should be represented as an expected conflict/limitation until fresh evidence proves otherwise. PowerShellEditorServices/VS Code coverage should address issue #169 or retain it as an explicit gap. + +### Task 6: Make dependency and conflict evidence profile-aware + +**Likely files:** + +- `build/dependency-policy.json` +- `tools/Get-DLLPickleUpstreamInventory.ps1` +- `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` +- `tools/Test-DLLPickleTfmAlignment.ps1` +- `tools/Update-DLLPickleDependencyPins.ps1` +- `tests/Integration/Invoke-DLLPickleScenario.ps1` +- upstream compatibility workflow and tests + +- [ ] Key preload, block, and known-conflict decisions by PowerShell line, TFM, OS/platform, module set, and import order. +- [ ] Record umbrella module version and the constituent module that actually ships each assembly. +- [ ] Record module manifest PowerShell compatibility and the newest release compatible with each profile. +- [ ] Record the asset path/TFM actually selected by the tested runtime rather than recursively mixing every DLL in the module directory. +- [ ] Record assembly name, version, hash, path, ALC, OS, architecture, and probe command. +- [ ] Parameterize child-process tooling with an exact `-PowerShellExecutable`. +- [ ] Derive NuGet compatibility from restored `project.assets.json` instead of extending the handwritten regex model. +- [ ] Complete lazy-load probe commands for ExchangeOnlineManagement and MicrosoftTeams. +- [ ] Test both relevant import orders, with and without DLLPickle. +- [ ] Separate deterministic import/no-auth evidence from credential-dependent authenticated smoke evidence. +- [ ] Re-adjudicate current issue #273 drift once per profile. +- [ ] Deduplicate drift reporting by fingerprint so an unchanged finding does not generate repeated comments. + +Initial monitored module families include: + +- Microsoft.Graph.Authentication / Microsoft.Graph +- Az.Accounts, Az.Resources, Az.Storage / Az +- ExchangeOnlineManagement +- MicrosoftTeams + +If the newest upstream release does not support a still-supported PowerShell line, test the newest compatible release and document the upstream limitation explicitly. + +### Task 7: Preserve and extend dependency automation + +**Likely files:** `.github/dependabot.yml`, `.github/workflows/Dependabot-Auto-Approve.yml`, workflow guardrail tests, dependency documentation. + +- [ ] Preserve daily NuGet patch/minor grouping and the exact Dependabot actor/author checks. +- [ ] Expand the allowed dependency-file set only as needed for multi-target project/lock files. +- [ ] Require the complete TFM/runtime matrix, upstream-policy gate, build gate, and dependency review before auto-merge completes. +- [ ] Keep patch/minor auto-approval and auto-merge registration after all checks pass. +- [ ] Keep major updates draft-only and never auto-merged. +- [ ] Attach a per-TFM major-update report containing resolved graph, selected assets, added/removed assemblies, conflict-surface delta, and scenario outcomes. +- [ ] Require review for a preload/block classification change, a new conditional TFM pin, or a material size-budget breach even if the package version is nominally minor. +- [ ] Preserve `deps:` to minor module-release behavior and `breaking:` for maintainer-approved major dependency changes. + +### Task 8: Generate documentation and enforce artifact size + +**Likely files:** `README.md`, `docs/Architecture.md`, `docs/Deep-Dive.md`, `docs/DEPENDENCIES.md`, `docs/Troubleshooting.md`, `CHANGELOG.md`, generated compatibility artifacts. + +- [ ] Replace single-`net8.0` claims with the current generated support matrix. +- [ ] Document Microsoft-supported versus upstream-module-supported combinations separately. +- [ ] Generate compatibility rows containing module/version, PowerShell, TFM, OS, selected asset, assembly/ALC result, verdict, evidence date, and run identifier. +- [ ] Document known process-isolation requirements rather than implying every module combination can coexist. +- [ ] Add documentation drift tests against the support/profile data. +- [ ] Generate unpacked and compressed size reports per TFM and for the full release artifact. +- [ ] Commit an approved size baseline and show deltas in dependency PRs. +- [ ] Route unexpected size growth to review instead of unattended merge. +- [ ] State explicitly that `multi-pwsh` is optional CI tooling and is absent from the published module. + +### Task 9: Full validation and release-readiness review + +- [ ] Run analyzer, unit tests, integration tests, issue reproductions, locked restore, complete build, and packaging checks. +- [ ] Run all available exact-runtime cells locally or through GitHub Actions. +- [ ] Confirm the package contains only `net8.0`, `net9.0`, and `net10.0` while those lines remain Microsoft-supported. +- [ ] Confirm no `multi-pwsh` executable, NuGet package, module dependency, code reference, or license payload is present in the artifact. +- [ ] Confirm every claimed runtime reports the expected PowerShell, CLR, TFM, `$PSHOME`, and process executable. +- [ ] Refresh Microsoft lifecycle state immediately before release. +- [ ] If credentials and approval are available, run the authenticated read-only validation tier; otherwise document the exact unexecuted scenarios as a release gate. +- [ ] Run `git diff --check`, review the complete diff, and verify no unrelated or generated temporary files remain. + +## 6. Automation policy + +### 6.1 PowerShell runtime patch updates + +A scheduled job discovers the newest GA patch within each declared supported line and opens a PR updating only test-matrix/evidence pins. That PR may merge automatically after all supported profiles pass. The workflow must pin the discovered exact version in the PR; required CI must not use a floating `7.4`, `7.5`, or `7.6` selector whose result can change between reruns. + +### 6.2 New or retired PowerShell lines + +Detection is automatic; support-contract changes are reviewed. + +- A new supported minor line opens a proposal PR or issue containing the PowerShell/.NET/TFM mapping, package-size estimate, build results, and initial upstream conflict evidence. +- An approaching retirement opens a warning before the lifecycle deadline. +- Removal of a retired line changes the loader, package contents, documentation, and support floor and therefore requires a reviewed release decision. +- The release workflow fails closed if the package still claims a line past the verified Microsoft support end. + +### 6.3 Dependency updates + +- Patch/minor: automatic PR, full multi-profile tests, auto-approval, auto-merge after all gates. +- Major: automatic draft PR, full multi-profile tests and structured evidence, maintainer review, no auto-merge. +- Policy/TFM/size change: manual review regardless of nominal dependency update type. + +## 7. Definition of done + +- [ ] PowerShell 7.3 and `net7.0` are absent from code, tests, artifacts, and support claims. +- [ ] The published module contains one isolated payload for every and only currently Microsoft-supported PowerShell 7 line in scope. +- [ ] The loader selects the exact TFM using both PowerShell and CLR versions and fails closed on mismatches. +- [ ] Every claimed PowerShell/OS cell executes the stock official `pwsh` binary for its exact pinned servicing patch. +- [ ] Test logs and artifacts record PowerShell, CLR, TFM, executable path, `$PSHOME`, OS, architecture, and selected bundle. +- [ ] Conflict evidence is profile-aware and does not use a TFM-blind global baseline. +- [ ] Graph, Az, ExchangeOnlineManagement, and MicrosoftTeams have current per-profile compatibility evidence or an explicit, evidenced limitation. +- [ ] Routine Dependabot patch/minor PRs merge unattended only after all required gates pass. +- [ ] Major updates remain draft until maintainer review. +- [ ] Runtime patch updates are proposed and validated automatically; new/retired support lines are reviewed. +- [ ] Documentation and artifact-size reports are generated and checked for drift. +- [ ] `multi-pwsh` is absent from the published artifact and every runtime/build dependency declaration. +- [ ] The project can build and test from explicit stock PowerShell executable paths with `multi-pwsh` unavailable. +- [ ] Any credential-dependent validation not executed is identified precisely and is not represented as passing. + +## 8. Recommended PR sequence + +1. CI tool determinism and Pester isolation. +2. Support-policy schemas and profile-alignment tests without runtime behavior changes. +3. Multi-TFM build outputs and exact loader selection. +4. Optional runtime provisioner and exact stock-executable validation. +5. Nine-cell PowerShell/OS matrix and stable aggregate gates. +6. Profile-aware upstream inventory, policy, evidence, and issue #273 re-adjudication. +7. Dependabot report/gate updates, documentation generation, and artifact-size policy. +8. Final lifecycle refresh, authenticated validation where authorized, and release preparation. + +Keep these changes reviewable and independently green. Do not combine a support-contract change with unrelated feature work. From cb5b5b5aeb78eba5769ff3f4a54fb983c5eb841a Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:48:30 -0400 Subject: [PATCH 10/43] fix: fingerprint selected assembly evidence --- tests/Unit/ConflictMatrix.Tests.ps1 | 72 +++++++++++++- tests/Unit/ConflictMatrixDrift.Tests.ps1 | 50 ++++++++-- tools/Compare-DLLPickleConflictMatrix.ps1 | 109 ++++++++++++++++++---- tools/New-DLLPickleConflictMatrix.ps1 | 78 +++++++++++----- 4 files changed, 256 insertions(+), 53 deletions(-) diff --git a/tests/Unit/ConflictMatrix.Tests.ps1 b/tests/Unit/ConflictMatrix.Tests.ps1 index 1f763856..773496d6 100644 --- a/tests/Unit/ConflictMatrix.Tests.ps1 +++ b/tests/Unit/ConflictMatrix.Tests.ps1 @@ -8,15 +8,35 @@ BeforeAll { [PSCustomObject]@{ Name = 'Az.Accounts' TrackedAssemblies = @( - [PSCustomObject]@{ Name = 'Azure.Core'; Version = '1.50.0.0' } - [PSCustomObject]@{ Name = 'Microsoft.Identity.Client'; Version = '4.84.1.0' } + [PSCustomObject]@{ + Name = 'Azure.Core' + Version = '1.50.0.0' + Sha256 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + Alc = 'AzSharedAssemblyLoadContext' + } + [PSCustomObject]@{ + Name = 'Microsoft.Identity.Client' + Version = '4.84.1.0' + Sha256 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' + Alc = 'Default' + } ) } [PSCustomObject]@{ Name = 'Microsoft.Graph.Authentication' TrackedAssemblies = @( - [PSCustomObject]@{ Name = 'Azure.Core'; Version = '1.46.0.0' } - [PSCustomObject]@{ Name = 'Microsoft.Identity.Client'; Version = '4.84.1.0' } + [PSCustomObject]@{ + Name = 'Azure.Core' + Version = '1.46.0.0' + Sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + Alc = 'msgraph-load-context' + } + [PSCustomObject]@{ + Name = 'Microsoft.Identity.Client' + Version = '4.84.1.0' + Sha256 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' + Alc = 'Default' + } ) } ) @@ -70,6 +90,50 @@ Describe 'New-DLLPickleConflictMatrix' -Tag 'Unit' { ($Matrix.Fingerprint) | Should -Be (& $ScriptPath -Inventory (Get-TestInventory)).Fingerprint } + It 'preserves selected hashes, ALC owners, and their per-module association' { + $Matrix = & $ScriptPath -Inventory (Get-TestInventory) + $AzureCore = $Matrix.Assemblies | Where-Object Name -EQ 'Azure.Core' + + @($AzureCore.Hashes) | Should -Be @( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + ) + @($AzureCore.AlcOwners) | Should -Be @('AzSharedAssemblyLoadContext', 'msgraph-load-context') + @($AzureCore.Selections) | Should -HaveCount 2 + ($AzureCore.Selections | Where-Object Module -EQ 'Az.Accounts').Sha256 | + Should -Be 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ($AzureCore.Selections | Where-Object Module -EQ 'Microsoft.Graph.Authentication').AlcOwner | + Should -Be 'msgraph-load-context' + } + + It 'changes the Fingerprint when a selected hash moves without an assembly-version change' { + $BaselineInventory = Get-TestInventory + $ChangedInventory = Get-TestInventory + ($ChangedInventory.Modules | Where-Object Name -EQ 'Az.Accounts').TrackedAssemblies | + Where-Object Name -EQ 'Microsoft.Identity.Client' | + ForEach-Object { $_.Sha256 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' } + + $Baseline = & $ScriptPath -Inventory $BaselineInventory + $Changed = & $ScriptPath -Inventory $ChangedInventory + + @($Baseline.ConflictSurface) | Should -Be @($Changed.ConflictSurface) + $Baseline.Fingerprint | Should -Not -Be $Changed.Fingerprint + } + + It 'changes the Fingerprint when selected ALC ownership moves without a version change' { + $BaselineInventory = Get-TestInventory + $ChangedInventory = Get-TestInventory + ($ChangedInventory.Modules | Where-Object Name -EQ 'Microsoft.Graph.Authentication').TrackedAssemblies | + Where-Object Name -EQ 'Azure.Core' | + ForEach-Object { $_.Alc = 'Default' } + + $Baseline = & $ScriptPath -Inventory $BaselineInventory + $Changed = & $ScriptPath -Inventory $ChangedInventory + + @($Baseline.ConflictSurface) | Should -Be @($Changed.ConflictSurface) + $Baseline.Fingerprint | Should -Not -Be $Changed.Fingerprint + } + It 'changes the Fingerprint when a surface assembly version moves but the names do not' { # Same conflict-surface NAMES (Azure.Core diverges in both), but one version differs. # A names-only hash would collide; the versions-aware fingerprint must differ. diff --git a/tests/Unit/ConflictMatrixDrift.Tests.ps1 b/tests/Unit/ConflictMatrixDrift.Tests.ps1 index cdc24403..a350617a 100644 --- a/tests/Unit/ConflictMatrixDrift.Tests.ps1 +++ b/tests/Unit/ConflictMatrixDrift.Tests.ps1 @@ -7,15 +7,19 @@ BeforeAll { $Diverges, $Alc = 'Default', $Versions = @(), - $ShippedBy = @() + $ShippedBy = @(), + $Hashes = @(), + $AlcOwners = @($Alc) ) [PSCustomObject]@{ - Name = $Name - Diverges = $Diverges - AlcOwner = $Alc - Versions = @($Versions) - ShippedBy = @($ShippedBy) + Name = $Name + Diverges = $Diverges + AlcOwner = $Alc + AlcOwners = @($AlcOwners) + Versions = @($Versions) + ShippedBy = @($ShippedBy) + Hashes = @($Hashes) } } function Get-DriftMatrix { param($Rows) [PSCustomObject]@{ Assemblies = @($Rows) } } @@ -44,6 +48,40 @@ Describe 'Compare-DLLPickleConflictMatrix' -Tag 'Unit' { $r.Findings.AlcOwnershipChanges | Should -Contain 'Azure.Core' } + It 'flags a selected-hash change even when the assembly is not version-divergent' { + $b = Get-DriftMatrix @( + Get-DriftRow 'Microsoft.Identity.Client' $false 'Default' @('4.84.1.0') @('Az.Accounts') @('aaaa') + ) + $c = Get-DriftMatrix @( + Get-DriftRow 'Microsoft.Identity.Client' $false 'Default' @('4.84.1.0') @('Az.Accounts') @('bbbb') + ) + + $r = & $ScriptPath -Baseline $b -Current $c + + $r.HasMaterialDrift | Should -BeTrue + $r.Findings.HashChanges | Should -HaveCount 1 + $r.Findings.HashChanges[0].Name | Should -Be 'Microsoft.Identity.Client' + @($r.Findings.HashChanges[0].Baseline) | Should -Be @('aaaa') + @($r.Findings.HashChanges[0].Current) | Should -Be @('bbbb') + } + + It 'flags added and removed tracked assemblies outside the version-conflict surface' { + $b = Get-DriftMatrix @( + Get-DriftRow 'Microsoft.Identity.Client' $false + Get-DriftRow 'Azure.Core' $false + ) + $c = Get-DriftMatrix @( + Get-DriftRow 'Microsoft.Identity.Client' $false + Get-DriftRow 'System.ClientModel' $false + ) + + $r = & $ScriptPath -Baseline $b -Current $c + + $r.HasMaterialDrift | Should -BeTrue + $r.Findings.NewTrackedAssemblies | Should -Contain 'System.ClientModel' + $r.Findings.RemovedTrackedAssemblies | Should -Contain 'Azure.Core' + } + It 'flags a version-set change with structured before and after values' { $b = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true 'Default' @('1.50.0.0', '1.51.1.0') @('Az.Accounts', 'Microsoft.Graph.Authentication')) $c = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true 'Default' @('1.51.1.0', '1.52.0.0') @('Az.Accounts', 'Microsoft.Graph.Authentication')) diff --git a/tools/Compare-DLLPickleConflictMatrix.ps1 b/tools/Compare-DLLPickleConflictMatrix.ps1 index 0fd64406..cec8d592 100644 --- a/tools/Compare-DLLPickleConflictMatrix.ps1 +++ b/tools/Compare-DLLPickleConflictMatrix.ps1 @@ -2,9 +2,10 @@ .SYNOPSIS Diffs two DLLPickle conflict matrices and reports material drift. .DESCRIPTION - Material drift includes new or removed conflicts, version-set changes, contributing-module-set - changes, and ALC-ownership changes. This matches the versions- and contributor-aware fingerprint - emitted by New-DLLPickleConflictMatrix.ps1 and consumed by the required PR gate. + Material drift includes new or removed tracked assemblies and conflicts, version-set changes, + contributing-module-set changes, selected-hash changes, and ALC-ownership changes. This matches + the profile evidence fingerprint emitted by New-DLLPickleConflictMatrix.ps1 and consumed by the + required PR gate. .PARAMETER Baseline The baseline conflict matrix (as produced by New-DLLPickleConflictMatrix.ps1). .PARAMETER Current @@ -46,6 +47,37 @@ function Test-DLLPickleStringSetEqual { return @($Difference).Count -eq 0 } +function Get-DLLPickleAssemblyEvidenceSet { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Assembly, + + [Parameter(Mandatory)] + [string]$PropertyName, + + [Parameter()] + [string]$LegacyPropertyName + ) + + if ($Assembly.PSObject.Properties.Name -contains $PropertyName) { + return @( + $Assembly.$PropertyName | + ForEach-Object { [string]$_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) + } + if ( + -not [string]::IsNullOrWhiteSpace($LegacyPropertyName) -and + $Assembly.PSObject.Properties.Name -contains $LegacyPropertyName -and + -not [string]::IsNullOrWhiteSpace([string]$Assembly.$LegacyPropertyName) + ) { + return @([string]$Assembly.$LegacyPropertyName) + } + return @() +} + $BaseSurface = @($Baseline.Assemblies | Where-Object Diverges | ForEach-Object Name) $CurrSurface = @($Current.Assemblies | Where-Object Diverges | ForEach-Object Name) @@ -62,9 +94,13 @@ foreach ($Assembly in $Current.Assemblies) { $CurrentByName[[string]$Assembly.Name] = $Assembly } -$CommonConflicts = @($BaseSurface | Where-Object { $_ -in $CurrSurface }) +$BaseAssemblyNames = @($BaseByName.Keys | Sort-Object) +$CurrentAssemblyNames = @($CurrentByName.Keys | Sort-Object) +$NewTrackedAssemblies = @($CurrentAssemblyNames | Where-Object { $_ -notin $BaseAssemblyNames }) +$RemovedTrackedAssemblies = @($BaseAssemblyNames | Where-Object { $_ -notin $CurrentAssemblyNames }) +$CommonAssemblies = @($BaseAssemblyNames | Where-Object { $_ -in $CurrentAssemblyNames }) $VersionChanges = @( - foreach ($Name in $CommonConflicts) { + foreach ($Name in $CommonAssemblies) { $BaselineVersions = @($BaseByName[$Name].Versions | ForEach-Object { [string]$_ } | Sort-Object -Unique) $CurrentVersions = @($CurrentByName[$Name].Versions | ForEach-Object { [string]$_ } | Sort-Object -Unique) if (-not (Test-DLLPickleStringSetEqual -Left $BaselineVersions -Right $CurrentVersions)) { @@ -78,7 +114,7 @@ $VersionChanges = @( ) $ContributorChanges = @( - foreach ($Name in $CommonConflicts) { + foreach ($Name in $CommonAssemblies) { $BaselineContributors = @($BaseByName[$Name].ShippedBy | ForEach-Object { [string]$_ } | Sort-Object -Unique) $CurrentContributors = @($CurrentByName[$Name].ShippedBy | ForEach-Object { [string]$_ } | Sort-Object -Unique) if (-not (Test-DLLPickleStringSetEqual -Left $BaselineContributors -Right $CurrentContributors)) { @@ -91,29 +127,61 @@ $ContributorChanges = @( } ) -$BaseAlc = @{} -foreach ($Assembly in $Baseline.Assemblies) { - $BaseAlc[$Assembly.Name] = [string]$Assembly.AlcOwner -} -$AlcChanges = @($Current.Assemblies | Where-Object { - $BaseAlc.ContainsKey($_.Name) -and $BaseAlc[$_.Name] -ne [string]$_.AlcOwner - } | ForEach-Object Name) +$HashChanges = @( + foreach ($Name in $CommonAssemblies) { + $BaselineHashes = @(Get-DLLPickleAssemblyEvidenceSet -Assembly $BaseByName[$Name] -PropertyName 'Hashes') + $CurrentHashes = @(Get-DLLPickleAssemblyEvidenceSet -Assembly $CurrentByName[$Name] -PropertyName 'Hashes') + if (-not (Test-DLLPickleStringSetEqual -Left $BaselineHashes -Right $CurrentHashes)) { + [PSCustomObject]@{ + Name = $Name + Baseline = $BaselineHashes + Current = $CurrentHashes + } + } + } +) + +$AlcChangeDetails = @( + foreach ($Name in $CommonAssemblies) { + $BaselineAlcOwners = @( + Get-DLLPickleAssemblyEvidenceSet -Assembly $BaseByName[$Name] -PropertyName 'AlcOwners' -LegacyPropertyName 'AlcOwner' + ) + $CurrentAlcOwners = @( + Get-DLLPickleAssemblyEvidenceSet -Assembly $CurrentByName[$Name] -PropertyName 'AlcOwners' -LegacyPropertyName 'AlcOwner' + ) + if (-not (Test-DLLPickleStringSetEqual -Left $BaselineAlcOwners -Right $CurrentAlcOwners)) { + [PSCustomObject]@{ + Name = $Name + Baseline = $BaselineAlcOwners + Current = $CurrentAlcOwners + } + } + } +) +$AlcChanges = @($AlcChangeDetails | ForEach-Object Name) $Findings = [PSCustomObject]@{ - NewConflicts = $NewConflicts - RemovedConflicts = $RemovedConflicts - VersionChanges = $VersionChanges - ContributorChanges = $ContributorChanges - AlcOwnershipChanges = $AlcChanges + NewTrackedAssemblies = $NewTrackedAssemblies + RemovedTrackedAssemblies = $RemovedTrackedAssemblies + NewConflicts = $NewConflicts + RemovedConflicts = $RemovedConflicts + VersionChanges = $VersionChanges + ContributorChanges = $ContributorChanges + HashChanges = $HashChanges + AlcOwnershipChanges = $AlcChanges + AlcOwnershipChangeDetails = $AlcChangeDetails } $FindingCanonicalText = @( "profile=$CurrentProfileKey" + "newTracked=$(@($NewTrackedAssemblies | Sort-Object) -join ',')" + "removedTracked=$(@($RemovedTrackedAssemblies | Sort-Object) -join ',')" "new=$(@($NewConflicts | Sort-Object) -join ',')" "removed=$(@($RemovedConflicts | Sort-Object) -join ',')" "versions=$(@($VersionChanges | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" "contributors=$(@($ContributorChanges | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" - "alc=$(@($AlcChanges | Sort-Object) -join ',')" + "hashes=$(@($HashChanges | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" + "alc=$(@($AlcChangeDetails | Sort-Object Name | ForEach-Object { '{0}:{1}>{2}' -f $_.Name, (@($_.Baseline) -join ','), (@($_.Current) -join ',') }) -join ';')" ) -join '|' $FindingFingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($FindingCanonicalText) $FindingFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FindingFingerprintBytes)).Replace('-', '').ToLowerInvariant() @@ -122,10 +190,13 @@ $FindingFingerprint = [System.BitConverter]::ToString([System.Security.Cryptogra ProfileKey = $CurrentProfileKey FindingFingerprint = $FindingFingerprint HasMaterialDrift = ( + $NewTrackedAssemblies.Count -gt 0 -or + $RemovedTrackedAssemblies.Count -gt 0 -or $NewConflicts.Count -gt 0 -or $RemovedConflicts.Count -gt 0 -or $VersionChanges.Count -gt 0 -or $ContributorChanges.Count -gt 0 -or + $HashChanges.Count -gt 0 -or $AlcChanges.Count -gt 0 ) Findings = $Findings diff --git a/tools/New-DLLPickleConflictMatrix.ps1 b/tools/New-DLLPickleConflictMatrix.ps1 index afd1eec0..96745634 100644 --- a/tools/New-DLLPickleConflictMatrix.ps1 +++ b/tools/New-DLLPickleConflictMatrix.ps1 @@ -4,8 +4,9 @@ .DESCRIPTION Consumes the inventory object produced by Get-DLLPickleUpstreamInventory.ps1 (or its JSON, via -InventoryPath) and computes, per tracked assembly: which modules ship it, the distinct - versions, whether those versions diverge, and a placeholder AlcOwner field that the runtime - probe fills in later. The ConflictSurface is the set of assemblies that diverge across modules. + versions, selected hashes, and runtime ALC owners. The ConflictSurface is the set of assemblies + that diverge across modules. The profile fingerprint covers every selected tracked-assembly tuple + so a content or ALC move cannot pass merely because the assembly version stayed unchanged. .PARAMETER Inventory The inventory object (as returned by Get-DLLPickleUpstreamInventory.ps1). .PARAMETER InventoryPath @@ -42,8 +43,20 @@ foreach ($Module in $Inventory.Modules) { $ByAssembly[$Assembly.Name] = [System.Collections.Generic.List[object]]::new() } $ByAssembly[$Assembly.Name].Add([PSCustomObject]@{ - Module = $Module.Name - Version = [string]$Assembly.Version + Module = [string]$Module.Name + Version = [string]$Assembly.Version + Sha256 = if ($Assembly.PSObject.Properties.Name -contains 'Sha256') { + ([string]$Assembly.Sha256).ToLowerInvariant() + } else { + $null + } + AlcOwner = if ($Assembly.PSObject.Properties.Name -contains 'Alc') { + [string]$Assembly.Alc + } elseif ($Assembly.PSObject.Properties.Name -contains 'AlcOwner') { + [string]$Assembly.AlcOwner + } else { + $null + } }) } } @@ -52,38 +65,55 @@ $AssemblyRows = foreach ($Name in ($ByAssembly.Keys | Sort-Object)) { $Entries = $ByAssembly[$Name] $DistinctVersions = @($Entries.Version | Sort-Object -Unique) $DistinctModules = @($Entries.Module | Sort-Object -Unique) + $DistinctHashes = @($Entries.Sha256 | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) + $DistinctAlcOwners = @($Entries.AlcOwner | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) + $Selections = @( + $Entries | + Sort-Object Module, Version, Sha256, AlcOwner | + ForEach-Object { + [PSCustomObject]@{ + Module = [string]$_.Module + Version = [string]$_.Version + Sha256 = [string]$_.Sha256 + AlcOwner = [string]$_.AlcOwner + } + } + ) [PSCustomObject]@{ - Name = $Name - ShippedBy = $DistinctModules - Versions = $DistinctVersions + Name = $Name + ShippedBy = $DistinctModules + Versions = $DistinctVersions + Hashes = $DistinctHashes + AlcOwners = $DistinctAlcOwners + Selections = $Selections # Diverges only when >=2 DISTINCT modules ship >=2 distinct versions. Counting distinct # modules (not raw entries) avoids a false positive when one module ships the same # assembly more than once (e.g. nested folders / multiple RIDs) at differing versions. - Diverges = ($DistinctModules.Count -ge 2 -and $DistinctVersions.Count -ge 2) - AlcOwner = $null # filled by the runtime probe / adjudication + Diverges = ($DistinctModules.Count -ge 2 -and $DistinctVersions.Count -ge 2) + # Retain the legacy scalar for older comparison consumers when ownership is unambiguous. + AlcOwner = if ($DistinctAlcOwners.Count -eq 1) { $DistinctAlcOwners[0] } else { $null } } } -# Versions- and contributor-aware fingerprint over the conflict surface. Each diverging assembly -# contributes its name, its sorted distinct versions, AND the sorted set of modules that ship it -# (ShippedBy). Including versions catches a material change where the same assemblies stay in conflict -# but their versions move (e.g. an upstream module bumps within-major). Including ShippedBy catches a -# change in WHICH modules contribute to a conflict even when the version set is unchanged (e.g. one -# module leaves a conflict as another joins at the same versions) -- the preload/block adjudication is -# module-specific, so that contributor change also needs human review. -# This is the single source of the drift fingerprint consumed by the Upstream-Compatibility workflow -# and the recorded baseline in build/dependency-policy.json. (ALC ownership is not included: it is -# null in the static inventory and is only known from the runtime probe / maintainer adjudication.) -$SurfaceRows = @( - $AssemblyRows | Where-Object Diverges | Sort-Object Name | ForEach-Object { - '{0}={1};by={2}' -f $_.Name, (@($_.Versions | Sort-Object) -join ','), (@($_.ShippedBy | Sort-Object) -join ',') +# Profile-aware evidence fingerprint over every selected tracked assembly. Per-selection tuples keep +# module, version, content hash, and runtime ALC associated instead of hashing independent sets that +# could collide when two modules swap payloads or load contexts. Absolute selected-asset paths are not +# canonical input because runner roots differ; the selected file's SHA-256 is the stable content identity. +$EvidenceRows = @( + $AssemblyRows | Sort-Object Name | ForEach-Object { + $CanonicalSelections = @( + $_.Selections | ForEach-Object { + 'module={0};version={1};sha256={2};alc={3}' -f $_.Module, $_.Version, $_.Sha256, $_.AlcOwner + } | Sort-Object + ) + '{0}|diverges={1}|{2}' -f $_.Name, ([string]$_.Diverges).ToLowerInvariant(), ($CanonicalSelections -join '|') } ) $ProfileKey = if ($Inventory.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Inventory.ProfileKey } else { $null } $FingerprintInput = if ([string]::IsNullOrWhiteSpace($ProfileKey)) { - $SurfaceRows -join '|' + $EvidenceRows -join '|' } else { - '{0}|{1}' -f $ProfileKey, ($SurfaceRows -join '|') + '{0}|{1}' -f $ProfileKey, ($EvidenceRows -join '|') } $FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($FingerprintInput) $Fingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() From c66fef5b195af9b5ba91fb05c23ff2e6e85e6473 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:52:52 -0400 Subject: [PATCH 11/43] fix(ci): gate releases on authenticated evidence --- .github/workflows/Release-and-Publish.yml | 115 +++++++++++++++++- docs/Architecture.md | 2 +- ...ntialed-authentication-test-environment.md | 5 +- tests/Unit/WorkflowGuardrails.Tests.ps1 | 12 ++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/.github/workflows/Release-and-Publish.yml b/.github/workflows/Release-and-Publish.yml index e97adec4..3e1c502a 100644 --- a/.github/workflows/Release-and-Publish.yml +++ b/.github/workflows/Release-and-Publish.yml @@ -46,12 +46,124 @@ env: MODULE_NAME: DLLPickle MODULE_DIR: ./module/DLLPickle MANIFEST_PATH: ./src/DLLPickle/DLLPickle.psd1 + AUTHENTICATED_WORKFLOW_FILE: Authenticated-Compatibility.yml + AUTHENTICATED_EVIDENCE_ARTIFACT: authenticated-compatibility-evidence jobs: + authenticated-release-gate: + name: Require Authenticated Compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + # The credentialed workflow is intentionally not created until the protected environment, + # identities, permissions, and redaction controls in the approved plan exist. Until then this + # gate fails closed before version analysis or tag creation can begin. + if: | + github.repository_owner == 'SamErde' && + ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + ) + + permissions: + actions: read + contents: read + + steps: + - name: Checkout release candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: main + + - name: Validate authenticated release policy + shell: pwsh + run: | + $Policy = Get-Content -LiteralPath './build/dependency-policy.json' -Raw | ConvertFrom-Json + $RequiredProfiles = @( + $Policy.runtimeProfiles | Where-Object { + $_.validationTiers.authenticatedReadOnly.requiredBeforeRelease -eq $true + } + ) + if ($RequiredProfiles.Count -eq 0) { + throw 'No runtime profile declares the authenticated read-only tier as required before release.' + } + + $WriteEnabledProfiles = @( + $RequiredProfiles | Where-Object { + $_.validationTiers.authenticatedReadOnly.writesAllowed -ne $false + } + ) + if ($WriteEnabledProfiles.Count -gt 0) { + throw 'A required authenticated release profile does not explicitly prohibit writes.' + } + + - name: Require successful exact-commit authenticated evidence + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + $ErrorActionPreference = 'Stop' + $EvidenceSha = if ($env:EVENT_NAME -eq 'pull_request') { + $env:PULL_REQUEST_HEAD_SHA + } else { + (git rev-parse HEAD).Trim() + } + if ($EvidenceSha -notmatch '^[a-f0-9]{40}$') { + throw "Could not resolve the exact release-candidate SHA for authenticated evidence: '$EvidenceSha'." + } + + $RunArguments = @( + 'run', 'list' + '--repo', '${{ github.repository }}' + '--workflow', $env:AUTHENTICATED_WORKFLOW_FILE + '--commit', $EvidenceSha + '--status', 'success' + '--limit', '20' + '--json', 'databaseId,headSha,conclusion,createdAt,url' + ) + $RunJson = & gh @RunArguments + if ($LASTEXITCODE -ne 0) { + throw "Release is blocked until $($env:AUTHENTICATED_WORKFLOW_FILE) exists and succeeds in the protected credentialed environment." + } + $MatchingRuns = @( + $RunJson | ConvertFrom-Json | Where-Object { + $_.headSha -eq $EvidenceSha -and $_.conclusion -eq 'success' + } + ) + if ($MatchingRuns.Count -eq 0) { + throw "No successful authenticated compatibility run exists for exact candidate SHA $EvidenceSha." + } + $EvidenceRun = $MatchingRuns | Sort-Object createdAt -Descending | Select-Object -First 1 + + $ArtifactJson = gh api "/repos/${{ github.repository }}/actions/runs/$($EvidenceRun.databaseId)/artifacts" + if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect authenticated evidence artifacts for run $($EvidenceRun.databaseId)." + } + $EvidenceArtifacts = @( + ($ArtifactJson | ConvertFrom-Json).artifacts | Where-Object { + $_.name -eq $env:AUTHENTICATED_EVIDENCE_ARTIFACT -and -not $_.expired + } + ) + if ($EvidenceArtifacts.Count -ne 1) { + throw "Authenticated run $($EvidenceRun.databaseId) must contain one unexpired '$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)' artifact." + } + + @( + '### Authenticated release evidence' + '' + "- Candidate SHA: ``$EvidenceSha``" + "- Validated run: $($EvidenceRun.url)" + "- Evidence artifact: ``$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)``" + '- Required profiles prohibit writes: True' + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + analyze: name: Analyze and Determine Version runs-on: ubuntu-latest timeout-minutes: 10 + needs: authenticated-release-gate # Only run for merged PRs to main or manual dispatch if: | github.repository_owner == 'SamErde' && @@ -781,7 +893,7 @@ jobs: name: Release Summary runs-on: ubuntu-latest timeout-minutes: 5 - needs: [analyze, update-version, build-and-test, create-release, publish, rollback-on-failure, sync-module-manifest] + needs: [authenticated-release-gate, analyze, update-version, build-and-test, create-release, publish, rollback-on-failure, sync-module-manifest] if: always() permissions: @@ -803,6 +915,7 @@ jobs: ### Workflow Status | Step | Status | |------|--------| + | Authenticated Compatibility | ${{ needs.authenticated-release-gate.result }} | | Analyze | ${{ needs.analyze.result }} | | Create Tag | ${{ needs.update-version.result }} | | Build & Test | ${{ needs.build-and-test.result }} | diff --git a/docs/Architecture.md b/docs/Architecture.md index d9eeb4a2..d17830aa 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -118,7 +118,7 @@ Every tracked assembly is classified into exactly one of: - **Unit + analyzer:** `tools/Invoke-DLLPickleBuild.ps1 -Task Analyze,Test` imports the exact pinned tool versions in a fresh no-profile process. Must be green. - **Issue reproduction + composition:** `Invoke-Build -Task IssueReproTest` (synthetic modules; deterministic). - **Runtime adjudication — deterministic no-auth tier:** nine exact stock-host cells (three PowerShell/.NET/TFM profiles × Windows/Linux/macOS), strict per-module ALC snapshots, both relevant import orders, structured runtime identity, and fail-closed profile/platform baselines. -- **Runtime adjudication — authenticated read-only tier:** maintainer-approved credentials only. The exact unexecuted commands and status are generated in [Compatibility-Evidence.md](generated/Compatibility-Evidence.md); they are release gates and are never inferred from deterministic results. +- **Runtime adjudication — authenticated read-only tier:** maintainer-approved credentials only. The exact unexecuted commands and status are generated in [Compatibility-Evidence.md](generated/Compatibility-Evidence.md); they are release gates and are never inferred from deterministic results. `Release-and-Publish` requires a successful `Authenticated-Compatibility.yml` run for the exact reviewed candidate commit and one unexpired `authenticated-compatibility-evidence` artifact before version analysis. Until that protected workflow and environment are implemented, release publication fails closed. - **Publish trigger:** `Release-and-Publish` publishes a merged PR only when it passes **both** the bundle-affecting **path gate** and the Conventional-Commit **version gate** — see §8.1 for the full contract (including how a Dependabot `deps:` commit maps to a **minor** release). CI-, policy-, docs-, test-, and tooling-only changes do **not** publish a new gallery version; `workflow_dispatch` is the deliberate-release escape hatch. - **Dependency PRs (Dependabot):** the bumped *bundle* is validated by **Build Module** (locked restore, all TFMs, exact runtime matrix, composition, size, and issue-repro guards). The Upstream-Compatibility gate records profile-aware selected assets, hashes, ALCs, import orders, deterministic probes, and conflict fingerprints. A conditional TFM pin, policy/classification edit, or material size increase routes to maintainer review. diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index ee0cd12d..38afbcba 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -189,9 +189,10 @@ Begin with the three Windows profiles. Expanding credentialed execution to Linux Only after the manual matrix is accepted: -- add the credentialed tier as a reusable, environment-gated workflow; +- add the credentialed tier as the reusable, environment-gated `.github/workflows/Authenticated-Compatibility.yml` workflow; +- upload exactly one sanitized `authenticated-compatibility-evidence` artifact after all required probes and redaction checks pass; - keep `workflow_dispatch` available for focused reruns; -- make release readiness depend on a fresh successful credentialed evidence artifact or an explicit maintainer waiver; +- preserve the existing release gate that requires the successful workflow and unexpired evidence artifact for the exact reviewed candidate commit; - do not allow the job to auto-approve, merge, publish, or mutate issues; - set a documented evidence freshness window. diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index 9f20dcce..7f6c6701 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -106,6 +106,18 @@ Describe 'Dependabot major-version draft-PR flow' -Tag 'Unit' { } Describe 'Release publish gating guardrails' -Tag 'Unit' { + It 'requires exact-commit authenticated evidence before version analysis or publication' { + $ReleaseWorkflow | Should -Match '(?ms)^ authenticated-release-gate:\s+name: Require Authenticated Compatibility' + $ReleaseWorkflow | Should -Match '(?ms)^ authenticated-release-gate:.*?permissions:\s+actions: read\s+contents: read' + $ReleaseWorkflow | Should -Match '(?m)^ needs: authenticated-release-gate$' + $ReleaseWorkflow | Should -Match ([regex]::Escape('Authenticated-Compatibility.yml')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('authenticated-compatibility-evidence')) + $ReleaseWorkflow | Should -Match ([regex]::Escape("'--commit', `$EvidenceSha")) + $ReleaseWorkflow | Should -Match ([regex]::Escape('github.event.pull_request.head.sha')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('requiredBeforeRelease')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('writesAllowed -ne $false')) + } + It 'fails closed on the runtime lifecycle policy before version analysis' { $ReleaseWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleRuntimeProfilePolicy.ps1')) $ReleaseWorkflow | Should -Match ([regex]::Escape('-Mode Release')) From b2d181040232b4b2665bc335c7c9b6557dce88b7 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:53:15 -0400 Subject: [PATCH 12/43] docs: correct supported-session guidance --- docs/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index c198f8e3..a3eb5c77 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -28,7 +28,7 @@ run on **Windows PowerShell 5.1**. If you are on Windows PowerShell 5.1 and hitting the same conflict, you can still use DLLPickle's **inspection helpers** to solve it manually. Run them from a -a supported PowerShell session — they still inspect the current-user Windows PowerShell +supported PowerShell session — they still inspect the current-user Windows PowerShell module roots from there — to find which installed module ships the newest identity DLL, then connect to that service *first* (the "first one wins" workaround). For example: From 2395bb8c543716d0cdff5d5278f0d4ae79697be1 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:59:24 -0400 Subject: [PATCH 13/43] fix: harden profile-aware compatibility tooling --- build/DLLPickle.Tooling.ps1 | 2 +- build/dependency-policy.json | 12 +-- docs/generated/Compatibility-Evidence.md | 2 +- src/DLLPickle/KnownConflicts.json | 18 ++-- .../Private/Get-DPRuntimeProfile.ps1 | 9 +- src/DLLPickle/Public/Import-DPLibrary.ps1 | 6 +- tools/Compare-DLLPickleConflictMatrix.ps1 | 9 +- .../Get-DLLPicklePowerShellSupportUpdate.ps1 | 22 +++-- tools/Get-DLLPickleUpstreamInventory.ps1 | 29 +++--- tools/Install-DLLPickleTestPowerShell.ps1 | 37 +++++--- tools/New-DLLPickleArtifactSizeReport.ps1 | 24 +++-- tools/New-DLLPickleDependencyChangeReport.ps1 | 44 ++++++++-- tools/New-DLLPicklePowerShellTestMatrix.ps1 | 5 +- tools/New-DLLPickleProfileEvidenceSummary.ps1 | 2 +- tools/New-DLLPickleRuntimeProfileEvidence.ps1 | 37 ++++++-- tools/New-DLLPickleSupportDocumentation.ps1 | 8 +- .../New-DLLPickleUpstreamScenarioEvidence.ps1 | 20 +++-- ...st-DLLPickleFindingFingerprintReported.ps1 | 5 +- tools/Test-DLLPicklePackageArtifact.ps1 | 27 +++--- tools/Test-DLLPickleRuntimeProfilePolicy.ps1 | 15 ++-- tools/Test-DLLPickleTfmAlignment.ps1 | 88 +++++++++++++------ tools/Update-DLLPickleDependencyPins.ps1 | 69 ++++++++++++--- .../Update-DLLPicklePowerShellTestMatrix.ps1 | 18 ++-- 23 files changed, 366 insertions(+), 142 deletions(-) diff --git a/build/DLLPickle.Tooling.ps1 b/build/DLLPickle.Tooling.ps1 index f2a5f307..b5b6ff69 100644 --- a/build/DLLPickle.Tooling.ps1 +++ b/build/DLLPickle.Tooling.ps1 @@ -33,7 +33,7 @@ function Get-DLLPickleBuildToolPolicy { if (-not [version]::TryParse([string]$Module.version, [ref]$ParsedVersion)) { throw "Build-tool policy contains invalid version '$($Module.version)' for '$($Module.name)': $Path" } - if ($ParsedVersion.Revision -ge 0) { + if ($ParsedVersion.Build -lt 0 -or $ParsedVersion.Revision -ge 0) { throw "Build-tool policy versions must use Major.Minor.Patch syntax; found '$($Module.version)' for '$($Module.name)'." } } diff --git a/build/dependency-policy.json b/build/dependency-policy.json index a6391bf1..a0512434 100644 --- a/build/dependency-policy.json +++ b/build/dependency-policy.json @@ -39,8 +39,8 @@ "repository": "PSGallery", "purpose": "Teams identity stack compatibility source.", "umbrellaModule": "MicrosoftTeams", - "deterministicProbeCommand": "Get-Command Get-Team | Out-Null", - "authenticatedReadOnlyProbeCommand": "Get-CsTenant | Out-Null" + "deterministicProbeCommand": "Get-Team -ErrorAction SilentlyContinue | Select-Object -First 1 | Out-Null", + "authenticatedReadOnlyProbeCommand": "Connect-MicrosoftTeams -AccessTokens @($env:DLLPICKLE_GRAPH_ACCESS_TOKEN, $env:DLLPICKLE_TEAMS_ACCESS_TOKEN) | Out-Null; try { Get-CsTenant | Out-Null } finally { Disconnect-MicrosoftTeams | Out-Null }" }, { "name": "Az.Resources", @@ -562,7 +562,7 @@ "MicrosoftTeams" ], "updateMode": "reportOnly", - "reason": "Azure.Core is intentionally not preloaded on the PowerShell 7.4+ (net8.0) profile. Az.Accounts 5.x isolates its Azure SDK stack in a private AssemblyLoadContext; preloading Azure.Core into the default load context splits Azure.Core.TokenRequestContext across load contexts and breaks Connect-AzAccount with a MissingMethodException on InteractiveBrowserCredential.AuthenticateAsync. Graph, Exchange, and Teams resolve a compatible Azure.Core themselves on .NET 8, so the preload is unnecessary for them. The original net48-only Azure.Core preload (#183) does not apply to the net8.0 baseline.", + "reason": "Azure.Core is intentionally not preloaded on any supported modern runtime profile (net8.0, net9.0, or net10.0). Az.Accounts 5.x isolates its Azure SDK stack in a private AssemblyLoadContext; preloading Azure.Core into the default load context splits Azure.Core.TokenRequestContext across load contexts and breaks Connect-AzAccount with a MissingMethodException on InteractiveBrowserCredential.AuthenticateAsync. Graph, Exchange, and Teams resolve a compatible Azure.Core themselves, so the preload is unnecessary for them. The original net48-only Azure.Core preload (#183) does not apply to the modern profiles.", "evidence": { "alcOwner": "AzSharedAssemblyLoadContext / msgraph-load-context", "basis": "Runtime probe 2026-05-31: Az.Accounts and Microsoft.Graph.Authentication both self-isolate Azure.Core in private ALCs and run different versions side-by-side (1.50 vs 1.51.1). Preloading into the default ALC splits TokenRequestContext -> Connect-AzAccount MissingMethodException. Removed from the bundle in 2.0.1.", @@ -582,7 +582,7 @@ "Az.Accounts" ], "updateMode": "reportOnly", - "reason": "Azure SDK stack self-isolated by Az.Accounts (and Graph); not preloaded on the net8.0 profile.", + "reason": "Azure SDK stack self-isolated by Az.Accounts (and Graph); not preloaded on the supported net8.0, net9.0, or net10.0 profiles.", "evidence": { "alcOwner": "AzSharedAssemblyLoadContext", "basis": "Runtime probe 2026-05-31: loaded into Az's private ALC (1.13.0). Belongs to the Azure SDK stack that breaks when preloaded (Azure.Core class). Not bundled by DLLPickle.", @@ -602,7 +602,7 @@ "Az.Accounts" ], "updateMode": "reportOnly", - "reason": "Azure SDK broker stack self-managed by Az; not preloaded on the net8.0 profile.", + "reason": "Azure SDK broker stack self-managed by Az; not preloaded on the supported net8.0, net9.0, or net10.0 profiles.", "evidence": { "alcOwner": "AzSharedAssemblyLoadContext", "basis": "In the conflict surface; part of the Az-self-isolated Azure SDK stack. Not bundled by DLLPickle.", @@ -622,7 +622,7 @@ "Microsoft.Graph.Authentication" ], "updateMode": "reportOnly", - "reason": "Azure SDK transitive (Azure.Core dependency) self-managed by Graph/Az; not preloaded on the net8.0 profile.", + "reason": "Azure SDK transitive (Azure.Core dependency) self-managed by Graph/Az; not preloaded on the supported net8.0, net9.0, or net10.0 profiles.", "evidence": { "alcOwner": "msgraph-load-context", "basis": "Runtime probe 2026-05-31: loaded into Graph's private ALC (1.9.0) alongside Azure.Core. Transitive of the blocked Azure.Core. Not bundled by DLLPickle.", diff --git a/docs/generated/Compatibility-Evidence.md b/docs/generated/Compatibility-Evidence.md index b4f01f54..dc57d25f 100644 --- a/docs/generated/Compatibility-Evidence.md +++ b/docs/generated/Compatibility-Evidence.md @@ -80,7 +80,7 @@ The deterministic no-auth tier runs in CI. The following credential-dependent co - `ExchangeOnlineManagement`: `Get-EXOMailbox -ResultSize 1 | Out-Null` - `Az.Storage`: `Get-AzStorageAccount | Select-Object -First 1 | Out-Null` - `Az.Accounts`: `Get-AzContext | Out-Null` -- `MicrosoftTeams`: `Get-CsTenant | Out-Null` +- `MicrosoftTeams`: `Connect-MicrosoftTeams -AccessTokens @($env:DLLPICKLE_GRAPH_ACCESS_TOKEN, $env:DLLPICKLE_TEAMS_ACCESS_TOKEN) | Out-Null; try { Get-CsTenant | Out-Null } finally { Disconnect-MicrosoftTeams | Out-Null }` - `Az.Resources`: `Get-AzResource | Select-Object -First 1 | Out-Null` These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it. diff --git a/src/DLLPickle/KnownConflicts.json b/src/DLLPickle/KnownConflicts.json index 1a1639e9..aa791c0b 100644 --- a/src/DLLPickle/KnownConflicts.json +++ b/src/DLLPickle/KnownConflicts.json @@ -39,7 +39,7 @@ "linux", "macos" ], - "evidenceStatus": "legacy-windows-evidence-requires-refresh" + "evidenceStatus": "stale-requires-refresh-issue-273" }, { "powerShellLine": "7.5", @@ -49,7 +49,7 @@ "linux", "macos" ], - "evidenceStatus": "pending-ci-evidence" + "evidenceStatus": "requires-ci-evidence" }, { "powerShellLine": "7.6", @@ -59,12 +59,20 @@ "linux", "macos" ], - "evidenceStatus": "pending-ci-evidence" + "evidenceStatus": "requires-ci-evidence" } ], "validationTiers": { - "deterministicImportNoAuth": "required", - "authenticatedReadOnly": "not-run-no-approved-credentials" + "deterministicImportNoAuth": { + "required": true, + "credentialsRequired": false + }, + "authenticatedReadOnly": { + "requiredBeforeRelease": true, + "credentialsRequired": true, + "writesAllowed": false, + "status": "not-run-no-approved-credentials" + } } } ] diff --git a/src/DLLPickle/Private/Get-DPRuntimeProfile.ps1 b/src/DLLPickle/Private/Get-DPRuntimeProfile.ps1 index f1fbcfef..505d75d9 100644 --- a/src/DLLPickle/Private/Get-DPRuntimeProfile.ps1 +++ b/src/DLLPickle/Private/Get-DPRuntimeProfile.ps1 @@ -35,8 +35,8 @@ function Get-DPRuntimeProfile { [int]$DotNetMajor = [Environment]::Version.Major ) + $CandidateRoots = [System.Collections.Generic.List[string]]::new() if ([string]::IsNullOrWhiteSpace($PolicyPath)) { - $CandidateRoots = [System.Collections.Generic.List[string]]::new() $ExplicitModuleRoot = Get-Variable -Name PSModuleRoot -ValueOnly -ErrorAction SilentlyContinue if (-not [string]::IsNullOrWhiteSpace($ExplicitModuleRoot)) { $CandidateRoots.Add($ExplicitModuleRoot) @@ -55,7 +55,12 @@ function Get-DPRuntimeProfile { } } - if ([string]::IsNullOrWhiteSpace($PolicyPath) -or -not (Test-Path -LiteralPath $PolicyPath -PathType Leaf)) { + if ([string]::IsNullOrWhiteSpace($PolicyPath)) { + $SearchedRoots = @($CandidateRoots | Select-Object -Unique) -join ', ' + throw "DLLPickle runtime profile policy 'SupportedRuntimeProfiles.json' was not found. Searched: $SearchedRoots. Reinstall the module from a complete package." + } + + if (-not (Test-Path -LiteralPath $PolicyPath -PathType Leaf)) { throw "DLLPickle runtime profile policy was not found at '$PolicyPath'. Reinstall the module from a complete package." } diff --git a/src/DLLPickle/Public/Import-DPLibrary.ps1 b/src/DLLPickle/Public/Import-DPLibrary.ps1 index 90657ac8..f2ec006e 100644 --- a/src/DLLPickle/Public/Import-DPLibrary.ps1 +++ b/src/DLLPickle/Public/Import-DPLibrary.ps1 @@ -78,7 +78,11 @@ $RuntimePolicyPath = Join-Path -Path $ModuleDirectory -ChildPath 'SupportedRuntimeProfiles.json' - $RuntimeProfile = Get-DPRuntimeProfile -PolicyPath $RuntimePolicyPath + $RuntimeProfile = if (Test-Path -LiteralPath $RuntimePolicyPath -PathType Leaf) { + Get-DPRuntimeProfile -PolicyPath $RuntimePolicyPath + } else { + Get-DPRuntimeProfile + } $TargetFramework = [string]$RuntimeProfile.targetFramework $BinDirectory = Join-Path -Path $ModuleDirectory -ChildPath 'bin' diff --git a/tools/Compare-DLLPickleConflictMatrix.ps1 b/tools/Compare-DLLPickleConflictMatrix.ps1 index cec8d592..8be3b9d1 100644 --- a/tools/Compare-DLLPickleConflictMatrix.ps1 +++ b/tools/Compare-DLLPickleConflictMatrix.ps1 @@ -23,9 +23,14 @@ $ErrorActionPreference = 'Stop' $BaselineProfileKey = if ($Baseline.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Baseline.ProfileKey } else { $null } $CurrentProfileKey = if ($Current.PSObject.Properties.Name -contains 'ProfileKey') { [string]$Current.ProfileKey } else { $null } +$BaselineHasProfileKey = -not [string]::IsNullOrWhiteSpace($BaselineProfileKey) +$CurrentHasProfileKey = -not [string]::IsNullOrWhiteSpace($CurrentProfileKey) +if ($BaselineHasProfileKey -ne $CurrentHasProfileKey) { + throw "Cannot compare conflict matrices when only one declares a runtime profile: '$BaselineProfileKey' and '$CurrentProfileKey'." +} if ( - -not [string]::IsNullOrWhiteSpace($BaselineProfileKey) -and - -not [string]::IsNullOrWhiteSpace($CurrentProfileKey) -and + $BaselineHasProfileKey -and + $CurrentHasProfileKey -and $BaselineProfileKey -ne $CurrentProfileKey ) { throw "Cannot compare conflict matrices from different runtime profiles: '$BaselineProfileKey' and '$CurrentProfileKey'." diff --git a/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 index dc103e22..8c2845ec 100644 --- a/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 +++ b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 @@ -27,7 +27,7 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/powershell-test-matrix.json'), [Parameter()] [string]$ReleaseDataPath, @@ -37,7 +37,7 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\lifecycle\powershell-support-update.json'), + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/lifecycle/powershell-support-update.json'), [Parameter()] [datetime]$AsOfUtc = [datetime]::UtcNow, @@ -140,7 +140,13 @@ if ($DuplicateLifecycleLines.Count -gt 0) { throw "Microsoft lifecycle data contains duplicate PowerShell lines: $($DuplicateLifecycleLines.Name -join ', ')." } $AsOfPacificDate = [System.TimeZoneInfo]::ConvertTimeFromUtc($AsOfUtc.ToUniversalTime(), $PacificTimeZone).Date -$SupportedLifecycleLines = @($LiveLifecycleRows | Where-Object { [datetime]$_.EndDate -ge $AsOfPacificDate } | ForEach-Object ReleaseLine) +$SupportedLifecycleLines = @($LiveLifecycleRows | Where-Object { + [datetime]::ParseExact( + [string]$_.EndDate, + 'yyyy-MM-dd', + [System.Globalization.CultureInfo]::InvariantCulture + ) -ge $AsOfPacificDate + } | ForEach-Object ReleaseLine) $DeclaredLines = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}' -f $_.powerShellMajor, $_.powerShellMinor }) $PatchUpdates = @( @@ -231,6 +237,12 @@ $IncompletePatchUpdates = @($PatchUpdates | Where-Object { -not $_.ChecksumsComp $LifecycleMissingLines = @($DeclaredLines | Where-Object { $_ -notin $LiveLifecycleRows.ReleaseLine }) $LifecycleDateChanges = @($LifecycleRows | Where-Object { $_.LifecycleEnd -ne $_.MatrixLifecycleEnd }) $UndeclaredSupportedLines = @($SupportedLifecycleLines | Where-Object { $_ -notin $DeclaredLines }) +$SupportContractReviewRequired = + $NewLines.Count -gt 0 -or + $UndeclaredSupportedLines.Count -gt 0 -or + $LifecycleDateChanges.Count -gt 0 -or + $LifecycleMissingLines.Count -gt 0 -or + @($LifecycleRows | Where-Object Status -IN @('RetiringSoon', 'Expired')).Count -gt 0 $PatchCanonicalLines = @( foreach ($PatchUpdate in @($PatchUpdates | Sort-Object ReleaseLine)) { 'patch|{0}|{1}|{2}' -f $PatchUpdate.ReleaseLine, $PatchUpdate.CurrentVersion, $PatchUpdate.CandidateVersion @@ -273,8 +285,8 @@ $Report = [PSCustomObject]@{ PatchProposalMarker = '' -f $PatchProposalFingerprint SupportContractFingerprint = $SupportContractFingerprint SupportContractMarker = '' -f $SupportContractFingerprint - MatrixOnlyUpdateAvailable = $PatchUpdates.Count -gt 0 -and $NewLines.Count -eq 0 -and $IncompletePatchUpdates.Count -eq 0 - SupportContractReviewRequired = $NewLines.Count -gt 0 -or $UndeclaredSupportedLines.Count -gt 0 -or $LifecycleDateChanges.Count -gt 0 -or $LifecycleMissingLines.Count -gt 0 -or @($LifecycleRows | Where-Object Status -IN @('RetiringSoon', 'Expired')).Count -gt 0 + MatrixOnlyUpdateAvailable = $PatchUpdates.Count -gt 0 -and $IncompletePatchUpdates.Count -eq 0 -and -not $SupportContractReviewRequired + SupportContractReviewRequired = $SupportContractReviewRequired ProposalPublishingStatus = 'pending-workflow-publication' } diff --git a/tools/Get-DLLPickleUpstreamInventory.ps1 b/tools/Get-DLLPickleUpstreamInventory.ps1 index e250cd0a..34a3577d 100644 --- a/tools/Get-DLLPickleUpstreamInventory.ps1 +++ b/tools/Get-DLLPickleUpstreamInventory.ps1 @@ -45,15 +45,15 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build\dependency-policy.json'), + [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build/dependency-policy.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts\upstreamCompatibility\upstream-inventory.json'), + [string]$OutputPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts/upstreamCompatibility/upstream-inventory.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$ModuleCachePath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts\upstreamCompatibility\modules'), + [string]$ModuleCachePath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts/upstreamCompatibility/modules'), [Parameter()] [ValidateNotNullOrEmpty()] @@ -71,7 +71,7 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$TestMatrixPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build\powershell-test-matrix.json') + [string]$TestMatrixPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build/powershell-test-matrix.json') ) $ErrorActionPreference = 'Stop' @@ -238,15 +238,14 @@ $ModuleResults = foreach ($PolicyModule in $PolicyModules) { $ModuleManifestPath = Get-ChildItem -LiteralPath $SavedModule.FullName -Filter "$Name.psd1" -File -Recurse | Sort-Object -Property { $_.FullName.Length } | Select-Object -First 1 - $Manifest = if ($ModuleManifestPath) { - # Real gallery manifests can contain module-manifest expressions such as a - # PSEdition-dependent RootModule. Import-PowerShellDataFile deliberately rejects - # those expressions; Test-ModuleManifest evaluates the constrained manifest grammar - # and returns the compatibility metadata PowerShell itself uses. - Test-ModuleManifest -Path $ModuleManifestPath.FullName -ErrorAction Stop - } else { - $null + if (-not $ModuleManifestPath) { + throw "Module manifest '$Name.psd1' was not found under '$($SavedModule.FullName)'." } + # Real gallery manifests can contain module-manifest expressions such as a + # PSEdition-dependent RootModule. Import-PowerShellDataFile deliberately rejects + # those expressions; Test-ModuleManifest evaluates the constrained manifest grammar + # and returns the compatibility metadata PowerShell itself uses. + $Manifest = Test-ModuleManifest -Path $ModuleManifestPath.FullName -ErrorAction Stop $OriginalPSModulePath = $env:PSModulePath try { @@ -282,8 +281,10 @@ $ModuleResults = foreach ($PolicyModule in $PolicyModules) { $FullAssemblyPath ) $RelativeToPSHome = [System.IO.Path]::GetRelativePath($FullPSHomePath, $FullAssemblyPath) - $IsWithinModuleCache = -not $RelativeToCache.StartsWith('..', [System.StringComparison]::Ordinal) - $IsWithinPSHome = -not $RelativeToPSHome.StartsWith('..', [System.StringComparison]::Ordinal) + $IsWithinModuleCache = -not $RelativeToCache.StartsWith('..', [System.StringComparison]::Ordinal) -and + -not [System.IO.Path]::IsPathRooted($RelativeToCache) + $IsWithinPSHome = -not $RelativeToPSHome.StartsWith('..', [System.StringComparison]::Ordinal) -and + -not [System.IO.Path]::IsPathRooted($RelativeToPSHome) if (-not $IsWithinModuleCache -and -not $IsWithinPSHome) { throw "Runtime evidence selected an assembly outside the isolated module cache and exact PSHOME: $FullAssemblyPath" } diff --git a/tools/Install-DLLPickleTestPowerShell.ps1 b/tools/Install-DLLPickleTestPowerShell.ps1 index 24097330..b04224ca 100644 --- a/tools/Install-DLLPickleTestPowerShell.ps1 +++ b/tools/Install-DLLPickleTestPowerShell.ps1 @@ -112,7 +112,16 @@ function Get-VerifiedDownload { Remove-Item -LiteralPath $DestinationPath -Force } - Invoke-WebRequest -Uri $Uri -OutFile $DestinationPath -UseBasicParsing + $DownloadParameters = @{ + Uri = $Uri + OutFile = $DestinationPath + UseBasicParsing = $true + ConnectionTimeoutSeconds = 60 + OperationTimeoutSeconds = 300 + MaximumRetryCount = 3 + RetryIntervalSec = 5 + } + Invoke-WebRequest @DownloadParameters $ActualHash = (Get-FileHash -LiteralPath $DestinationPath -Algorithm SHA256).Hash if ($ActualHash -ine $Sha256) { Remove-Item -LiteralPath $DestinationPath -Force @@ -130,18 +139,26 @@ function Expand-TestRuntimeArchive { ) if (Test-Path -LiteralPath $DestinationPath) { - return + Remove-Item -LiteralPath $DestinationPath -Recurse -Force } - $null = New-Item -Path $DestinationPath -ItemType Directory -Force - if ([System.IO.Path]::GetExtension($ArchivePath) -eq '.zip') { - Expand-Archive -LiteralPath $ArchivePath -DestinationPath $DestinationPath -Force - return - } + $StagingPath = '{0}.staging-{1}-{2}' -f $DestinationPath, $PID, ([System.Guid]::NewGuid().ToString('n')) + $null = New-Item -Path $StagingPath -ItemType Directory -Force + try { + if ([System.IO.Path]::GetExtension($ArchivePath) -eq '.zip') { + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $StagingPath -Force + } else { + $TarOutput = @(& tar -xzf $ArchivePath -C $StagingPath 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract '$ArchivePath': $($TarOutput -join [Environment]::NewLine)" + } + } - $TarOutput = @(& tar -xzf $ArchivePath -C $DestinationPath 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract '$ArchivePath': $($TarOutput -join [Environment]::NewLine)" + Move-Item -LiteralPath $StagingPath -Destination $DestinationPath + } finally { + if (Test-Path -LiteralPath $StagingPath) { + Remove-Item -LiteralPath $StagingPath -Recurse -Force + } } } diff --git a/tools/New-DLLPickleArtifactSizeReport.ps1 b/tools/New-DLLPickleArtifactSizeReport.ps1 index 54bb871f..3e67487a 100644 --- a/tools/New-DLLPickleArtifactSizeReport.ps1 +++ b/tools/New-DLLPickleArtifactSizeReport.ps1 @@ -15,19 +15,19 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module\DLLPickle'), + [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module/DLLPickle'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle/SupportedRuntimeProfiles.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$BaselinePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\artifact-size-baseline.json'), + [string]$BaselinePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/artifact-size-baseline.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\package\artifact-size.json'), + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/package/artifact-size.json'), [Parameter()] [switch]$Strict @@ -143,10 +143,18 @@ $Measurements = @( $TfmPath = Join-Path (Join-Path $ResolvedModulePath 'bin') $TargetFramework if (-not (Test-Path -LiteralPath $TfmPath -PathType Container)) { [PSCustomObject]@{ - Name = $TargetFramework - BaselinePresent = $false - ReviewRequired = $true - Error = "Target-framework directory was not found: $TfmPath" + Name = $TargetFramework + FileCount = 0 + UnpackedBytes = 0 + CompressedBytes = 0 + BaselinePresent = $false + BaselineUnpackedBytes = 0 + BaselineCompressedBytes = 0 + UnpackedDeltaBytes = 0 + CompressedDeltaBytes = 0 + AllowedUnpackedIncreaseBytes = 0 + ReviewRequired = $true + Error = "Target-framework directory was not found: $TfmPath" } continue } diff --git a/tools/New-DLLPickleDependencyChangeReport.ps1 b/tools/New-DLLPickleDependencyChangeReport.ps1 index 6fae535a..ae639d71 100644 --- a/tools/New-DLLPickleDependencyChangeReport.ps1 +++ b/tools/New-DLLPickleDependencyChangeReport.ps1 @@ -44,7 +44,7 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\dependency\dependency-change-report.json') + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/dependency/dependency-change-report.json') ) $ErrorActionPreference = 'Stop' @@ -59,6 +59,10 @@ function Get-DLLPickleNuGetTargetGraph { [string]$TargetFramework ) + if (-not $Assets -or $Assets.PSObject.Properties.Name -notcontains 'targets' -or -not $Assets.targets) { + throw 'NuGet project.assets.json contains no targets section; restore may be incomplete.' + } + $TargetProperty = $Assets.targets.PSObject.Properties[$TargetFramework] if (-not $TargetProperty) { return @() @@ -69,8 +73,16 @@ function Get-DLLPickleNuGetTargetGraph { $SeparatorIndex = $LibraryProperty.Name.LastIndexOf('/') $PackageName = if ($SeparatorIndex -gt 0) { $LibraryProperty.Name.Substring(0, $SeparatorIndex) } else { $LibraryProperty.Name } $PackageVersion = if ($SeparatorIndex -gt 0) { $LibraryProperty.Name.Substring($SeparatorIndex + 1) } else { $null } - $CompileAssets = @($LibraryProperty.Value.compile.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) - $RuntimeAssets = @($LibraryProperty.Value.runtime.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) + $CompileAssets = if ($LibraryProperty.Value.PSObject.Properties.Name -contains 'compile' -and $LibraryProperty.Value.compile) { + @($LibraryProperty.Value.compile.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) + } else { + @() + } + $RuntimeAssets = if ($LibraryProperty.Value.PSObject.Properties.Name -contains 'runtime' -and $LibraryProperty.Value.runtime) { + @($LibraryProperty.Value.runtime.PSObject.Properties.Name | Where-Object { $_ -ne '_._' } | Sort-Object -Unique) + } else { + @() + } [PSCustomObject]@{ PackageName = $PackageName PackageVersion = $PackageVersion @@ -151,9 +163,21 @@ function Compare-DLLPickleNamedRow { ) $BaselineByKey = @{} - foreach ($Row in $Baseline) { $BaselineByKey[[string]$Row.$KeyProperty] = $Row } + foreach ($Row in $Baseline) { + $Key = [string]$Row.$KeyProperty + if ($BaselineByKey.ContainsKey($Key)) { + throw "Baseline rows contain duplicate '$KeyProperty' value '$Key'." + } + $BaselineByKey[$Key] = $Row + } $CandidateByKey = @{} - foreach ($Row in $Candidate) { $CandidateByKey[[string]$Row.$KeyProperty] = $Row } + foreach ($Row in $Candidate) { + $Key = [string]$Row.$KeyProperty + if ($CandidateByKey.ContainsKey($Key)) { + throw "Candidate rows contain duplicate '$KeyProperty' value '$Key'." + } + $CandidateByKey[$Key] = $Row + } $Keys = @($BaselineByKey.Keys + $CandidateByKey.Keys | Sort-Object -Unique) [PSCustomObject]@{ @@ -181,6 +205,14 @@ foreach ($RequiredPath in @($BaselineProjectAssetsPath, $CandidateProjectAssetsP $BaselineAssets = Get-Content -LiteralPath $BaselineProjectAssetsPath -Raw | ConvertFrom-Json -ErrorAction Stop $CandidateAssets = Get-Content -LiteralPath $CandidateProjectAssetsPath -Raw | ConvertFrom-Json -ErrorAction Stop +foreach ($AssetsInput in @( + [PSCustomObject]@{ Name = 'Baseline'; Path = $BaselineProjectAssetsPath; Value = $BaselineAssets } + [PSCustomObject]@{ Name = 'Candidate'; Path = $CandidateProjectAssetsPath; Value = $CandidateAssets } + )) { + if ($AssetsInput.Value.PSObject.Properties.Name -notcontains 'targets' -or -not $AssetsInput.Value.targets) { + throw "$($AssetsInput.Name) NuGet assets file has no targets section: $($AssetsInput.Path)" + } +} $SupportPolicy = Get-Content -LiteralPath $SupportPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop $DependencyPolicy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop $TargetFrameworks = @($SupportPolicy.profiles.targetFramework | ForEach-Object { [string]$_ } | Sort-Object -Unique) @@ -205,7 +237,7 @@ $ProfileReports = @( $CandidateInputs = @(Get-DLLPicklePackagedAssemblyInput -BuildOutputRoot $CandidateBuildOutputRoot -TargetFramework $TargetFramework) $BaselineConflictInputs = @($BaselineInputs | Where-Object AssemblyName -IN $ConflictSensitiveAssemblyNames) $CandidateConflictInputs = @($CandidateInputs | Where-Object AssemblyName -IN $ConflictSensitiveAssemblyNames) - $ConflictDelta = Compare-DLLPickleNamedRow -Baseline $BaselineConflictInputs -Candidate $CandidateConflictInputs -KeyProperty AssemblyName -ValueProperty IdentityFingerprint + $ConflictDelta = Compare-DLLPickleNamedRow -Baseline $BaselineConflictInputs -Candidate $CandidateConflictInputs -KeyProperty RelativePath -ValueProperty IdentityFingerprint $SizeRow = if ($SizeReport) { @($SizeReport.Profiles | Where-Object Name -EQ $TargetFramework | Select-Object -First 1)[0] } else { $null } [PSCustomObject]@{ diff --git a/tools/New-DLLPicklePowerShellTestMatrix.ps1 b/tools/New-DLLPicklePowerShellTestMatrix.ps1 index 45987173..ffbc2ae6 100644 --- a/tools/New-DLLPicklePowerShellTestMatrix.ps1 +++ b/tools/New-DLLPicklePowerShellTestMatrix.ps1 @@ -46,8 +46,9 @@ $Cells = @( } ) -if ($Cells.Count -ne 9) { - throw "The authoritative DLLPickle runtime matrix must contain exactly 9 cells; found $($Cells.Count)." +$ExpectedCellCount = @($Policy.profiles).Count * @($Policy.lanes).Count +if ($ExpectedCellCount -eq 0 -or $Cells.Count -ne $ExpectedCellCount) { + throw "The authoritative DLLPickle runtime matrix must contain exactly $ExpectedCellCount cells; found $($Cells.Count)." } $CellKeys = @($Cells | ForEach-Object { '{0}|{1}|{2}' -f $_.powerShellVersion, $_.platform, $_.architecture }) diff --git a/tools/New-DLLPickleProfileEvidenceSummary.ps1 b/tools/New-DLLPickleProfileEvidenceSummary.ps1 index 15195f8c..3efe48a2 100644 --- a/tools/New-DLLPickleProfileEvidenceSummary.ps1 +++ b/tools/New-DLLPickleProfileEvidenceSummary.ps1 @@ -17,7 +17,7 @@ param ( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/powershell-test-matrix.json'), [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] diff --git a/tools/New-DLLPickleRuntimeProfileEvidence.ps1 b/tools/New-DLLPickleRuntimeProfileEvidence.ps1 index dae839c1..6be37de8 100644 --- a/tools/New-DLLPickleRuntimeProfileEvidence.ps1 +++ b/tools/New-DLLPickleRuntimeProfileEvidence.ps1 @@ -65,8 +65,12 @@ $BundleRoot = [IO.Path]::GetFullPath($Payload.selectedBundlePath) $AssemblyEvidence = @( [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { - -not [string]::IsNullOrWhiteSpace($_.Location) -and - [IO.Path]::GetFullPath($_.Location).StartsWith($BundleRoot, [StringComparison]::OrdinalIgnoreCase) + if ([string]::IsNullOrWhiteSpace($_.Location)) { + return $false + } + $RelativePath = [IO.Path]::GetRelativePath($BundleRoot, [IO.Path]::GetFullPath($_.Location)) + -not $RelativePath.StartsWith('..', [StringComparison]::Ordinal) -and + -not [IO.Path]::IsPathRooted($RelativePath) } | Sort-Object -Property FullName -Unique | ForEach-Object { @@ -107,9 +111,16 @@ $Platform = if ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runt } | ConvertTo-Json -Depth 12 -Compress '@.Replace('__PAYLOAD__', $PayloadBase64) -$ProbeOutput = @(& $ResolvedExecutable -NoLogo -NoProfile -NonInteractive -Command $EvidenceProbe 2>&1) -if ($LASTEXITCODE -ne 0) { - throw "Runtime evidence probe failed: $($ProbeOutput -join [Environment]::NewLine)" +$ProbeErrorPath = [System.IO.Path]::GetTempFileName() +try { + $ProbeOutput = @(& $ResolvedExecutable -NoLogo -NoProfile -NonInteractive -Command $EvidenceProbe 2> $ProbeErrorPath) + $ProbeExitCode = $LASTEXITCODE + $ProbeError = Get-Content -LiteralPath $ProbeErrorPath -Raw -ErrorAction SilentlyContinue +} finally { + Remove-Item -LiteralPath $ProbeErrorPath -Force -ErrorAction SilentlyContinue +} +if ($ProbeExitCode -ne 0) { + throw "Runtime evidence probe failed: $ProbeError" } try { @@ -126,6 +137,22 @@ if ($Evidence.targetFramework -ne $TargetFramework -or [int]$Evidence.dotNetMajo if (@($Evidence.importResults | Where-Object Status -eq 'Failed').Count -gt 0) { throw 'Runtime evidence captured one or more failed DLL imports.' } +if ([string]$Evidence.selectedBundlePath -cne [string]$Payload.selectedBundlePath) { + throw "Runtime evidence reported an unexpected selected bundle path: '$($Evidence.selectedBundlePath)'." +} +$ObservedAssemblies = @($Evidence.assemblies) +if ($ObservedAssemblies.Count -eq 0) { + throw "Runtime evidence observed no loaded assemblies under the expected '$TargetFramework' bundle." +} +foreach ($Assembly in $ObservedAssemblies) { + $RelativeAssemblyPath = [System.IO.Path]::GetRelativePath( + [string]$Payload.selectedBundlePath, + [System.IO.Path]::GetFullPath([string]$Assembly.path) + ) + if ($RelativeAssemblyPath.StartsWith('..', [System.StringComparison]::Ordinal) -or [System.IO.Path]::IsPathRooted($RelativeAssemblyPath)) { + throw "Runtime evidence observed an assembly outside the expected '$TargetFramework' bundle: $($Assembly.path)" + } +} $OutputDirectory = Split-Path -Path $OutputPath -Parent if (-not [string]::IsNullOrWhiteSpace($OutputDirectory) -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 index d56c9d77..c51ddaec 100644 --- a/tools/New-DLLPickleSupportDocumentation.ps1 +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -15,19 +15,19 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle/SupportedRuntimeProfiles.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/powershell-test-matrix.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$DependencyPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\dependency-policy.json'), + [string]$DependencyPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/dependency-policy.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs\generated'), + [string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs/generated'), [Parameter()] [switch]$Check diff --git a/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 index 1ebb1482..65370ba3 100644 --- a/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 +++ b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 @@ -74,11 +74,14 @@ foreach ($Module in @($Inventory.Modules)) { $ModuleSearchPath = @([string]$Inventory.ModuleCachePath, (Join-Path -Path ([string]$Inventory.Profile.PSHome) -ChildPath 'Modules')) $SnapshotScriptPath = Join-Path $PSScriptRoot 'Get-DLLPickleRuntimeAssemblySnapshot.ps1' $ScenarioDefinitions = [System.Collections.Generic.List[object]]::new() +$PolicyOrderIndex = 0 foreach ($ImportOrder in @($ProfilePolicy[0].importOrders)) { + $PolicyOrderIndex++ $ScenarioDefinitions.Add([PSCustomObject]@{ - ScenarioId = 'profile-target-scenario' + ScenarioId = 'profile-target-scenario-{0:d2}' -f $PolicyOrderIndex ImportOrder = @($ImportOrder) ExpectedLimitation = $false + ExpectedSuccess = $true }) } if (-not [string]::IsNullOrWhiteSpace($KnownConflictsPath)) { @@ -92,6 +95,7 @@ if (-not [string]::IsNullOrWhiteSpace($KnownConflictsPath)) { ScenarioId = [string]$KnownConflict.id ImportOrder = @($ImportOrder) ExpectedLimitation = [bool]$KnownConflict.requiresProcessIsolation + ExpectedSuccess = -not [bool]$KnownConflict.requiresProcessIsolation }) } } @@ -131,8 +135,10 @@ foreach ($ScenarioDefinition in $ScenarioDefinitions) { ImportOrder = @($ImportOrder) DllPicklePreloaded = $PreloadDllPickle ExpectedLimitation = [bool]$ScenarioDefinition.ExpectedLimitation + ExpectedSuccess = [bool]$ScenarioDefinition.ExpectedSuccess ProbeCommands = @($ProbeCommands) Success = $false + OutcomeMatchesExpectation = $false Assemblies = @() Error = $null } @@ -156,6 +162,7 @@ foreach ($ScenarioDefinition in $ScenarioDefinitions) { } catch { $Scenario.Error = $_.Exception.Message } + $Scenario.OutcomeMatchesExpectation = $Scenario.Success -eq $Scenario.ExpectedSuccess $ScenarioResults.Add([PSCustomObject]$Scenario) } } @@ -163,14 +170,17 @@ foreach ($ScenarioDefinition in $ScenarioDefinitions) { $CanonicalRows = @( "profile=$($Inventory.ProfileKey)" foreach ($Scenario in @($ScenarioResults | Sort-Object OrderIndex,DllPicklePreloaded)) { - 'scenario={0}|order={1}|preload={2}|expectedLimitation={3}|success={4}|modules={5}|assemblies={6}' -f ( + 'scenario={0}|order={1}|preload={2}|expectedLimitation={3}|expectedSuccess={4}|success={5}|outcomeMatches={6}|error={9}|modules={7}|assemblies={8}' -f ( $Scenario.ScenarioId, $Scenario.OrderIndex, $Scenario.DllPicklePreloaded, $Scenario.ExpectedLimitation, + $Scenario.ExpectedSuccess, $Scenario.Success, + $Scenario.OutcomeMatchesExpectation, (@($Scenario.ImportOrder) -join ','), - (@($Scenario.Assemblies | Sort-Object Name,Path | ForEach-Object { '{0},{1},{2},{3},{4}' -f $_.Name, $_.Version, $_.Sha256, $_.Path, $_.Alc }) -join ';') + (@($Scenario.Assemblies | Sort-Object Name,Path | ForEach-Object { '{0},{1},{2},{3},{4}' -f $_.Name, $_.Version, $_.Sha256, $_.Path, $_.Alc }) -join ';'), + (([string]$Scenario.Error) -replace '\s+', ' ').Trim() ) } ) @@ -185,7 +195,7 @@ $Report = [PSCustomObject]@{ WritesPerformed = $false ScenarioFingerprint = $ScenarioFingerprint Scenarios = @($ScenarioResults) - Passed = @($ScenarioResults | Where-Object { -not $_.Success }).Count -eq 0 + Passed = @($ScenarioResults | Where-Object { -not $_.OutcomeMatchesExpectation }).Count -eq 0 } $OutputDirectory = Split-Path -Path $OutputPath -Parent @@ -195,7 +205,7 @@ if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathTyp $Report | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 if ($Strict.IsPresent -and -not $Report.Passed) { - $FailedLabels = @($ScenarioResults | Where-Object { -not $_.Success } | ForEach-Object { "order $($_.OrderIndex), preload=$($_.DllPicklePreloaded)" }) + $FailedLabels = @($ScenarioResults | Where-Object { -not $_.OutcomeMatchesExpectation } | ForEach-Object { "order $($_.OrderIndex), preload=$($_.DllPicklePreloaded), expectedSuccess=$($_.ExpectedSuccess), actualSuccess=$($_.Success)" }) throw "Deterministic upstream scenarios failed for '$($Inventory.ProfileKey)': $($FailedLabels -join '; ')." } $Report diff --git a/tools/Test-DLLPickleFindingFingerprintReported.ps1 b/tools/Test-DLLPickleFindingFingerprintReported.ps1 index 5bcf3a6a..eca93588 100644 --- a/tools/Test-DLLPickleFindingFingerprintReported.ps1 +++ b/tools/Test-DLLPickleFindingFingerprintReported.ps1 @@ -21,4 +21,7 @@ param ( ) $Marker = '' -f $Fingerprint.ToLowerInvariant() -return @($Text | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_.Contains($Marker) }).Count -gt 0 +return @($Text | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + $_.Contains($Marker, [System.StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0 diff --git a/tools/Test-DLLPicklePackageArtifact.ps1 b/tools/Test-DLLPicklePackageArtifact.ps1 index ff14bdba..f83c1782 100644 --- a/tools/Test-DLLPicklePackageArtifact.ps1 +++ b/tools/Test-DLLPicklePackageArtifact.ps1 @@ -22,30 +22,30 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module\DLLPickle'), + [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'module/DLLPickle'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$BuildOutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\bin\Release'), + [string]$BuildOutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle.Build/bin/Release'), [Parameter()] [switch]$SkipBuildOutputComparison, [Parameter()] [ValidateNotNullOrEmpty()] - [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle\SupportedRuntimeProfiles.json'), + [string]$SupportPolicyPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle/SupportedRuntimeProfiles.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$ProjectPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\DLLPickle.csproj'), + [string]$ProjectPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle.Build/DLLPickle.csproj'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$LockFilePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src\DLLPickle.Build\packages.lock.json'), + [string]$LockFilePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/DLLPickle.Build/packages.lock.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\package\artifact-composition.json'), + [string]$OutputPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/package/artifact-composition.json'), [Parameter()] [switch]$Strict @@ -92,7 +92,8 @@ function Get-DLLPickleRelativeFileSet { ) | Sort-Object -Unique } -$RequiredPaths = @($ModulePath, $SupportPolicyPath, $ProjectPath, $LockFilePath) +$ModuleManifestPath = Join-Path $ModulePath 'DLLPickle.psd1' +$RequiredPaths = @($ModulePath, $SupportPolicyPath, $ProjectPath, $LockFilePath, $ModuleManifestPath) if (-not $SkipBuildOutputComparison.IsPresent) { $RequiredPaths += $BuildOutputRoot } @@ -146,10 +147,11 @@ $ProfileResults = @( } if (-not $SkipBuildOutputComparison.IsPresent) { $ExpectedDlls = @(Get-ChildItem -LiteralPath $BuildTfmPath -File -Filter '*.dll' | Where-Object Name -Match '^(Azure\.|Microsoft\.|System\.)' | Select-Object -ExpandProperty Name | Sort-Object -Unique) - foreach ($Name in @($ExpectedDlls | Where-Object { $_ -notin $ActualDlls })) { + $ActualComparedDlls = @($ActualDlls | Where-Object { $_ -match '^(Azure\.|Microsoft\.|System\.)' }) + foreach ($Name in @($ExpectedDlls | Where-Object { $_ -notin $ActualComparedDlls })) { Add-DLLPickleArtifactFinding -Code 'MissingManagedAsset' -TargetFramework $TargetFramework -Message "Expected managed asset '$Name' is absent." } - foreach ($Name in @($ActualDlls | Where-Object { $_ -notin $ExpectedDlls })) { + foreach ($Name in @($ActualComparedDlls | Where-Object { $_ -notin $ExpectedDlls })) { Add-DLLPickleArtifactFinding -Code 'UnexpectedManagedAsset' -TargetFramework $TargetFramework -Message "Managed asset '$Name' is not present in the packaging build output." } } @@ -162,7 +164,10 @@ $ProfileResults = @( @() } $ActualNativeFiles = if (Test-Path -LiteralPath $ArtifactRuntimePath -PathType Container) { - Get-DLLPickleRelativeFileSet -Root $ArtifactRuntimePath -Files @(Get-ChildItem -LiteralPath $ArtifactRuntimePath -File -Recurse) + Get-DLLPickleRelativeFileSet -Root $ArtifactRuntimePath -Files @( + Get-ChildItem -LiteralPath $ArtifactRuntimePath -File -Recurse | + Where-Object FullName -Match '[\\/]native[\\/]' + ) } else { @() } @@ -198,7 +203,7 @@ foreach ($File in $ArtifactFiles) { } } } -foreach ($DeclarationPath in @($ProjectPath, $LockFilePath, (Join-Path $ResolvedModulePath 'DLLPickle.psd1'))) { +foreach ($DeclarationPath in @($ProjectPath, $LockFilePath, $ModuleManifestPath)) { if ((Get-Content -LiteralPath $DeclarationPath -Raw -ErrorAction Stop) -match $ForbiddenPattern) { $ForbiddenHits.Add([PSCustomObject]@{ Source = 'DependencyDeclaration'; Path = $DeclarationPath }) } diff --git a/tools/Test-DLLPickleRuntimeProfilePolicy.ps1 b/tools/Test-DLLPickleRuntimeProfilePolicy.ps1 index a1281a85..bf58c953 100644 --- a/tools/Test-DLLPickleRuntimeProfilePolicy.ps1 +++ b/tools/Test-DLLPickleRuntimeProfilePolicy.ps1 @@ -52,9 +52,12 @@ param( $ErrorActionPreference = 'Stop' -foreach ($Path in @($RuntimePolicyPath, $TestMatrixPath)) { - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { - throw "Runtime profile policy file not found: $Path" +foreach ($RequiredInput in @( + [PSCustomObject]@{ Name = 'Runtime profile policy'; Path = $RuntimePolicyPath } + [PSCustomObject]@{ Name = 'PowerShell test matrix'; Path = $TestMatrixPath } + )) { + if (-not (Test-Path -LiteralPath $RequiredInput.Path -PathType Leaf)) { + throw "$($RequiredInput.Name) file not found: $($RequiredInput.Path)" } } @@ -82,7 +85,7 @@ if (@($RuntimeKeys | Sort-Object -Unique).Count -ne $RuntimeKeys.Count) { if (@($TestKeys | Sort-Object -Unique).Count -ne $TestKeys.Count) { throw 'Test matrix contains duplicate profiles.' } -if (($RuntimeKeys -join [char]0) -ne ($TestKeys -join [char]0)) { +if ((@($RuntimeKeys | Sort-Object) -join [char]0) -ne (@($TestKeys | Sort-Object) -join [char]0)) { throw 'Shipped runtime-profile policy and CI test-matrix profile sets do not align.' } @@ -110,9 +113,9 @@ foreach ($TestProfile in $TestProfiles) { [string]$TestProfile.lifecycleEndDate, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture, - [System.Globalization.DateTimeStyles]::AssumeUniversal + [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal ) - $EndExclusiveUtc = [datetime]::SpecifyKind($EndDate, [System.DateTimeKind]::Utc).AddDays(1) + $EndExclusiveUtc = $EndDate.AddDays(1) $DaysRemaining = [math]::Floor(($EndExclusiveUtc - $EvaluationUtc).TotalDays) $ReleaseLine = '{0}.{1}' -f $TestProfile.powerShellMajor, $TestProfile.powerShellMinor $Status = if ($EvaluationUtc -ge $EndExclusiveUtc) { diff --git a/tools/Test-DLLPickleTfmAlignment.ps1 b/tools/Test-DLLPickleTfmAlignment.ps1 index 8ca9cd54..13b42d09 100644 --- a/tools/Test-DLLPickleTfmAlignment.ps1 +++ b/tools/Test-DLLPickleTfmAlignment.ps1 @@ -4,13 +4,13 @@ .DESCRIPTION Implements Step 0(b) of the tracked-dependency release lifecycle. Policy mode reads NuGet's - restored project.assets.json and verifies the actual compile/runtime asset selection for every - preload package under net8.0, net9.0, and net10.0. It does not approximate NuGet compatibility - with a handwritten TFM model. + restored project.assets.json and verifies the actual runtime asset selection for every preload + package under each target framework declared by the dependency policy. It does not approximate + NuGet compatibility with a handwritten TFM model. Two modes: - PackageDirectory: inspect a single extracted NuGet package directory (one with a lib/ - folder) and return its alignment result. + folder) against a selected portable target framework and return its alignment result. - Policy: inspect NuGet's resolved target graph and selected assets for all supported TFMs, then return an aggregate report (optionally failing in -Strict mode). @@ -20,6 +20,9 @@ .PARAMETER PackageName Optional package name to report for the PackageDirectory mode. Defaults to the directory leaf. +.PARAMETER TargetFramework + Portable runtime target framework to evaluate in PackageDirectory mode. Defaults to net8.0. + .PARAMETER PolicyPath Path to the dependency policy JSON file (Policy mode). @@ -38,6 +41,9 @@ .EXAMPLE ./tools/Test-DLLPickleTfmAlignment.ps1 -PackageDirectory ~/.nuget/packages/microsoft.identity.client/4.84.1 +.EXAMPLE + ./tools/Test-DLLPickleTfmAlignment.ps1 -PackageDirectory ~/.nuget/packages/microsoft.identity.client/4.84.1 -TargetFramework net10.0 + .EXAMPLE ./tools/Test-DLLPickleTfmAlignment.ps1 -OutputPath ./artifacts/upstreamCompatibility/tfm-alignment.json -Strict @@ -55,17 +61,21 @@ param( [Parameter(ParameterSetName = 'PackageDirectory')] [string]$PackageName, + [Parameter(ParameterSetName = 'PackageDirectory')] + [ValidatePattern('^net\d+\.\d+$')] + [string]$TargetFramework = 'net8.0', + [Parameter(ParameterSetName = 'Policy')] [ValidateNotNullOrEmpty()] - [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build\dependency-policy.json'), + [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build/dependency-policy.json'), [Parameter(ParameterSetName = 'Policy')] [ValidateNotNullOrEmpty()] - [string]$LockFilePath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src\DLLPickle.Build\packages.lock.json'), + [string]$LockFilePath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src/DLLPickle.Build/packages.lock.json'), [Parameter(ParameterSetName = 'Policy')] [ValidateNotNullOrEmpty()] - [string]$ProjectAssetsPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src\DLLPickle.Build\obj\project.assets.json'), + [string]$ProjectAssetsPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src/DLLPickle.Build/obj/project.assets.json'), [Parameter(ParameterSetName = 'Policy')] [string]$OutputPath, @@ -82,39 +92,49 @@ function Test-DLLPickleTargetFrameworkCompatible { param( [Parameter(Mandatory)] [AllowEmptyString()] - [string]$TargetFramework + [string]$TargetFramework, + + [Parameter(Mandatory)] + [string]$RuntimeTargetFramework ) $Moniker = ([string]$TargetFramework).Trim().ToLowerInvariant() + $RuntimeMoniker = ([string]$RuntimeTargetFramework).Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($Moniker)) { return $false } + $RuntimeMatch = [regex]::Match($RuntimeMoniker, '^net(?\d+)\.\d+$') + if (-not $RuntimeMatch.Success) { + throw "PackageDirectory mode requires a portable .NET target framework such as net8.0: '$RuntimeTargetFramework'." + } + $RuntimeMajor = [int]$RuntimeMatch.Groups['major'].Value + # Reject OS-specific TFMs (e.g. net8.0-windows, net8.0-browser, net8.0-android). DLLPickle's - # bundle is validated as a PORTABLE net8.0 asset across Windows/Linux/macOS, so a package that - # ships only an OS-specific asset has no portable asset to preload and is not Step 0b-aligned. + # bundles are portable across Windows/Linux/macOS, so an OS-specific-only package has no + # portable asset to preload and is not Step 0b-aligned. if ($Moniker.Contains('-')) { return $false } - # .NET Standard (1.x-2.1): loadable on net8.0. + # .NET Standard (1.x-2.1): loadable on every supported modern .NET runtime. if ($Moniker -match '^netstandard\d+\.\d+$') { return $true } - # .NET Core 1.x-3.1 (netcoreapp): consumable by net8.0. + # .NET Core 1.x-3.1 (netcoreapp): consumable by every supported modern .NET runtime. if ($Moniker -match '^netcoreapp\d+\.\d+$') { return $true } - # .NET 5+ (netX.0, with a dot): consumable only up to the supported runtime major (8); - # a net9.0+ asset references a newer runtime contract and is not loadable on net8.0. + # .NET 5+ (netX.0, with a dot): consumable only when it does not exceed the selected + # portable runtime major. $NetCoreMatch = [regex]::Match($Moniker, '^net(\d+)\.\d+$') if ($NetCoreMatch.Success) { - return ([int]$NetCoreMatch.Groups[1].Value -le 8) + return ([int]$NetCoreMatch.Groups[1].Value -le $RuntimeMajor) } - # .NET Framework (net20-net48, no dot) is a different runtime, not loadable on net8.0. + # .NET Framework (net20-net48, no dot) is a different runtime, not loadable on modern .NET. # Anything else (unknown/garbage monikers) is fail-closed. return $false } @@ -164,7 +184,10 @@ function Test-DLLPickleSinglePackageAlignment { [string]$Name, [Parameter()] - [string]$ResolvedVersion + [string]$ResolvedVersion, + + [Parameter(Mandatory)] + [string]$TargetFramework ) $ResolvedName = if (-not [string]::IsNullOrWhiteSpace($Name)) { $Name } else { Split-Path -Path $PackagePath -Leaf } @@ -179,21 +202,23 @@ function Test-DLLPickleSinglePackageAlignment { $Lib = Get-DLLPickleLibTargetFramework -PackagePath $PackagePath if (-not $Lib.HasLib) { $IsAligned = $false - $Reason = 'No lib/ folder is present, so the package ships no net8.0/netstandard2.0 runtime asset.' + $Reason = "No lib/ folder is present, so the package ships no runtime asset compatible with $TargetFramework." } elseif ($Lib.IsFlatLib) { $IsAligned = $true $Available = @('lib') $Compatible = @('lib') - $Reason = 'Legacy flat lib/ layout: assemblies apply to any target framework, including net8.0.' + $Reason = "Legacy flat lib/ layout: assemblies apply to any target framework, including $TargetFramework." } else { $Available = @($Lib.TargetFrameworks) - $Compatible = @($Available | Where-Object { Test-DLLPickleTargetFrameworkCompatible -TargetFramework $_ }) + $Compatible = @($Available | Where-Object { + Test-DLLPickleTargetFrameworkCompatible -TargetFramework $_ -RuntimeTargetFramework $TargetFramework + }) if ($Compatible.Count -gt 0) { $IsAligned = $true - $Reason = "net8.0-compatible asset(s) present: $($Compatible -join ', ')." + $Reason = "$TargetFramework-compatible asset(s) present: $($Compatible -join ', ')." } else { $IsAligned = $false - $Reason = "No net8.0/netstandard2.0-compatible asset; lib/ ships only: $($Available -join ', ')." + $Reason = "No $TargetFramework-compatible runtime asset; lib/ ships only: $($Available -join ', ')." } } } @@ -201,6 +226,7 @@ function Test-DLLPickleSinglePackageAlignment { [PSCustomObject]@{ PackageName = $ResolvedName ResolvedVersion = $ResolvedVersion + TargetFramework = $TargetFramework PackageDirectory = $PackagePath IsAligned = $IsAligned CompatibleAssets = @($Compatible) @@ -238,7 +264,7 @@ function Get-DLLPickleResolvedPackageVersion { if ($PSCmdlet.ParameterSetName -eq 'PackageDirectory') { $ResolvedDirectory = (Resolve-Path -LiteralPath $PackageDirectory).Path - Test-DLLPickleSinglePackageAlignment -PackagePath $ResolvedDirectory -Name $PackageName + Test-DLLPickleSinglePackageAlignment -PackagePath $ResolvedDirectory -Name $PackageName -TargetFramework $TargetFramework return } @@ -248,11 +274,15 @@ $ResolvedProjectAssetsPath = (Resolve-Path -LiteralPath $ProjectAssetsPath).Path $Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json $Lock = Get-Content -LiteralPath $ResolvedLockPath -Raw | ConvertFrom-Json $ProjectAssets = Get-Content -LiteralPath $ResolvedProjectAssetsPath -Raw | ConvertFrom-Json -$TargetFrameworks = if ($Policy.PSObject.Properties.Name -contains 'runtimeProfiles') { - @($Policy.runtimeProfiles.targetFramework | Sort-Object -Unique) +$RuntimeProfiles = if ($Policy.PSObject.Properties.Name -contains 'runtimeProfiles') { + @($Policy.runtimeProfiles) } else { - @($Lock.dependencies.PSObject.Properties.Name | Sort-Object -Unique) + @() +} +if ($RuntimeProfiles.Count -eq 0) { + throw "Dependency policy '$PolicyPath' must declare at least one runtimeProfiles entry." } +$TargetFrameworks = @($RuntimeProfiles.targetFramework | Sort-Object -Unique) $PackageResults = foreach ($TargetFramework in $TargetFrameworks) { $TargetGraphProperty = $ProjectAssets.targets.PSObject.Properties | @@ -287,9 +317,11 @@ $PackageResults = foreach ($TargetFramework in $TargetFrameworks) { } else { @() } - $SelectedAssets = if ($RuntimeAssets.Count -gt 0) { $RuntimeAssets } else { $CompileAssets } - if ($SelectedAssets.Count -gt 0) { + $SelectedAssets = @($RuntimeAssets) + if ($RuntimeAssets.Count -gt 0) { $Reason = "NuGet selected asset(s) for ${TargetFramework}: $($SelectedAssets -join ', ')." + } elseif ($CompileAssets.Count -gt 0) { + $Reason = "NuGet selected compile-only asset(s) for ${TargetFramework}, but no runtime assembly can be copied or loaded: $($CompileAssets -join ', ')." } else { $Reason = "NuGet selected '$PackageKey' for '$TargetFramework' but no assembly compile/runtime asset." } diff --git a/tools/Update-DLLPickleDependencyPins.ps1 b/tools/Update-DLLPickleDependencyPins.ps1 index 2ebdb7a5..d017fa51 100644 --- a/tools/Update-DLLPickleDependencyPins.ps1 +++ b/tools/Update-DLLPickleDependencyPins.ps1 @@ -43,15 +43,15 @@ param( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build\dependency-policy.json'), + [string]$PolicyPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'build/dependency-policy.json'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$ProjectPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src\DLLPickle.Build\DLLPickle.csproj'), + [string]$ProjectPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'src/DLLPickle.Build/DLLPickle.csproj'), [Parameter()] [ValidateNotNullOrEmpty()] - [string]$OutputPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts\upstreamCompatibility\candidate-report.json'), + [string]$OutputPath = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'artifacts/upstreamCompatibility/candidate-report.json'), [Parameter()] [switch]$Restore @@ -88,31 +88,80 @@ function Get-DLLPickleCurrentPackageReference { [string]$TargetFramework ) + function Test-DLLPickleTargetFrameworkCondition { + param( + [Parameter()] + [AllowEmptyString()] + [string]$Condition, + + [Parameter(Mandatory)] + [string]$Framework + ) + + if ([string]::IsNullOrWhiteSpace($Condition) -or $Condition -notmatch '\$\(TargetFramework\)') { + return $true + } + + $ConditionMatch = [regex]::Match( + $Condition, + "^\s*'?\$\(TargetFramework\)'?\s*(?==|!=)\s*'(?[^']+)'\s*$" + ) + if (-not $ConditionMatch.Success) { + throw "Unsupported TargetFramework condition in PackageReference automation: $Condition" + } + + $ConditionFramework = $ConditionMatch.Groups['framework'].Value + if ($ConditionMatch.Groups['operator'].Value -eq '==') { + return $Framework -eq $ConditionFramework + } + return $Framework -ne $ConditionFramework + } + + $ResolvedReferences = [System.Collections.Generic.List[object]]::new() + $ItemGroupCondition = $null for ($Index = 0; $Index -lt $ProjectContent.Count; $Index++) { $Line = $ProjectContent[$Index] + if ($Line -match '') { + $ItemGroupCondition = $null + } + } + + if ($ResolvedReferences.Count -gt 1) { + throw "PackageReference '$PackageName' resolves ambiguously for target framework '$TargetFramework' at project lines $(@($ResolvedReferences.Index | ForEach-Object { $_ + 1 }) -join ', ')." } - return $null + return @($ResolvedReferences)[0] } function ConvertTo-DLLPickleUpdatedPackageReferenceContent { diff --git a/tools/Update-DLLPicklePowerShellTestMatrix.ps1 b/tools/Update-DLLPicklePowerShellTestMatrix.ps1 index 9b06a4da..e730b85a 100644 --- a/tools/Update-DLLPicklePowerShellTestMatrix.ps1 +++ b/tools/Update-DLLPicklePowerShellTestMatrix.ps1 @@ -17,7 +17,7 @@ or modifies an issue. param ( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build\powershell-test-matrix.json'), + [string]$TestMatrixPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'build/powershell-test-matrix.json'), [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -74,14 +74,16 @@ if (-not $IsPreparation) { ) foreach ($IdentityFile in $IdentityFiles) { $Identity = Get-Content -LiteralPath $IdentityFile.FullName -Raw | ConvertFrom-Json -ErrorAction Stop - if ( - $Identity.PSObject.Properties.Name -contains 'PowerShellVersion' -and - $Identity.PSObject.Properties.Name -contains 'DotNetVersion' -and - $Identity.PSObject.Properties.Name -contains 'Platform' -and - $Identity.PSObject.Properties.Name -contains 'Architecture' - ) { - $IdentityRecords += $Identity + $RequiredIdentityProperties = @('PowerShellVersion', 'DotNetVersion', 'Platform', 'Architecture') + $MissingIdentityProperties = @($RequiredIdentityProperties | Where-Object { + $Identity.PSObject.Properties.Name -notcontains $_ -or + [string]::IsNullOrWhiteSpace([string]$Identity.$_) + }) + if ($MissingIdentityProperties.Count -gt 0) { + Write-Warning "Runtime identity file '$($IdentityFile.FullName)' is missing required value(s): $($MissingIdentityProperties -join ', '); the file will not be used." + continue } + $IdentityRecords += $Identity } } From 9592069ecd92bf4c12bae1cb5bb17332342f2a74 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:59:48 -0400 Subject: [PATCH 14/43] fix(ci): fail closed across runtime workflows --- .github/workflows/Build Module.yml | 82 ++++++++++---- .github/workflows/Dependabot-Auto-Approve.yml | 3 +- .../PowerShell-Support-Lifecycle.yml | 63 ++++++++--- .github/workflows/Release-and-Publish.yml | 58 +++++++--- .github/workflows/Upstream-Compatibility.yml | 101 ++++++++++++++---- .github/workflows/Validate-Packages.yml | 2 +- 6 files changed, 240 insertions(+), 69 deletions(-) diff --git a/.github/workflows/Build Module.yml b/.github/workflows/Build Module.yml index 99bba14a..c41da7da 100644 --- a/.github/workflows/Build Module.yml +++ b/.github/workflows/Build Module.yml @@ -115,6 +115,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - name: Generate matrix from support policy id: generate @@ -154,12 +155,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false # Install the exact .NET SDK pinned by global.json so the build does not depend on the runner image. - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: "10.0.302" + global-json-file: global.json cache: true cache-dependency-path: "src/DLLPickle.Build/packages.lock.json" @@ -286,7 +288,7 @@ jobs: with: name: package-policy-reports path: ./artifacts/package - if-no-files-found: error + if-no-files-found: warn - name: Upload zip module archive build if: runner.os == 'Windows' @@ -320,11 +322,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: "10.0.302" + global-json-file: global.json cache: true cache-dependency-path: "src/DLLPickle.Build/packages.lock.json" @@ -343,13 +346,19 @@ jobs: - name: Provision and verify exact stock PowerShell id: runtime shell: pwsh + env: + MATRIX_PROVIDER: ${{ matrix.provider }} + MATRIX_POWERSHELL_VERSION: ${{ matrix.powerShellVersion }} + MATRIX_PLATFORM: ${{ matrix.platform }} + MATRIX_ARCHITECTURE: ${{ matrix.architecture }} + RUNTIME_INSTALL_ROOT: ${{ runner.temp }}/dllpickle-test-powershell run: | $InstallParameters = @{ - Provider = '${{ matrix.provider }}' - PowerShellVersion = '${{ matrix.powerShellVersion }}' - Platform = '${{ matrix.platform }}' - Architecture = '${{ matrix.architecture }}' - InstallRoot = '${{ runner.temp }}/dllpickle-test-powershell' + Provider = $env:MATRIX_PROVIDER + PowerShellVersion = $env:MATRIX_POWERSHELL_VERSION + Platform = $env:MATRIX_PLATFORM + Architecture = $env:MATRIX_ARCHITECTURE + InstallRoot = $env:RUNTIME_INSTALL_ROOT PassThru = $true } $Identity = ./tools/Install-DLLPickleTestPowerShell.ps1 @InstallParameters @@ -359,8 +368,10 @@ jobs: - name: Bootstrap exact tools in an isolated module path shell: pwsh + env: + CELL_MODULE_ROOT: ${{ runner.temp }}/dllpickle-psmodules/${{ matrix.powerShellVersion }} run: | - $CellModuleRoot = '${{ runner.temp }}/dllpickle-psmodules/${{ matrix.powerShellVersion }}' + $CellModuleRoot = $env:CELL_MODULE_ROOT $SystemModuleRoot = Join-Path -Path (Split-Path -Path $env:DLLPICKLE_TEST_PWSH -Parent) -ChildPath 'Modules' $IsolatedModulePath = @($CellModuleRoot, $SystemModuleRoot) -join [System.IO.Path]::PathSeparator "DLLPICKLE_TEST_PSMODULEPATH=$IsolatedModulePath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 @@ -392,16 +403,19 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Capture selected bundle and assembly load-context evidence - if: always() + if: ${{ always() && steps.runtime.outcome == 'success' }} shell: pwsh + env: + MATRIX_POWERSHELL_VERSION: ${{ matrix.powerShellVersion }} + MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }} + RUNTIME_EVIDENCE_PATH: ./artifacts/runtime-evidence-${{ matrix.powerShellVersion }}-${{ matrix.platform }}.json run: | - $EvidencePath = './artifacts/runtime-evidence-${{ matrix.powerShellVersion }}-${{ matrix.platform }}.json' $EvidenceParameters = @{ PowerShellExecutable = $env:DLLPICKLE_TEST_PWSH - PowerShellVersion = '${{ matrix.powerShellVersion }}' - TargetFramework = '${{ matrix.targetFramework }}' + PowerShellVersion = $env:MATRIX_POWERSHELL_VERSION + TargetFramework = $env:MATRIX_TARGET_FRAMEWORK ModuleManifestPath = './module/DLLPickle/DLLPickle.psd1' - OutputPath = $EvidencePath + OutputPath = $env:RUNTIME_EVIDENCE_PATH } ./tools/New-DLLPickleRuntimeProfileEvidence.ps1 @EvidenceParameters @@ -414,7 +428,7 @@ jobs: runtime-identity.json artifacts/runtime-evidence-*.json artifacts/testOutput/*.xml - if-no-files-found: error + if-no-files-found: warn retention-days: 14 dependency-change-report: @@ -434,21 +448,28 @@ jobs: steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check out pull request base uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} path: dependency-baseline + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '10.0.302' + global-json-file: global.json - name: Restore and build baseline and candidate graphs shell: pwsh run: | + $BaselineGlobalJson = './dependency-baseline/global.json' + if (Test-Path -LiteralPath $BaselineGlobalJson -PathType Leaf) { + Remove-Item -LiteralPath $BaselineGlobalJson -Force + } $Projects = @( './dependency-baseline/src/DLLPickle.Build/DLLPickle.csproj' './src/DLLPickle.Build/DLLPickle.csproj' @@ -504,14 +525,20 @@ jobs: # for status". This single, always-running job is the stable check to require instead: it passes # when the matrix build succeeded OR was skipped (no build-relevant changes) and fails when the # build failed or was cancelled. - needs: [build, runtime-tests, dependency-change-report] + needs: [changes, runtime-matrix, build, runtime-tests, dependency-change-report] if: ${{ always() }} runs-on: ubuntu-latest steps: - name: Aggregate build result shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_REF: ${{ inputs.ref }} + BUILD_RELEVANT: ${{ needs.changes.outputs.relevant }} run: | $Results = @{ + changes = '${{ needs.changes.result }}' + runtimeMatrix = '${{ needs.runtime-matrix.result }}' build = '${{ needs.build.result }}' runtimeTests = '${{ needs.runtime-tests.result }}' dependencyChangeReport = '${{ needs.dependency-change-report.result }}' @@ -519,10 +546,27 @@ jobs: $Results.GetEnumerator() | Sort-Object Name | ForEach-Object { Write-Host "$($_.Name) job result: $($_.Value)" } - $Failures = @($Results.GetEnumerator() | Where-Object Value -notin @('success', 'skipped')) + $BuildRequired = -not [string]::IsNullOrWhiteSpace($env:INPUT_REF) -or + $env:EVENT_NAME -eq 'workflow_dispatch' -or + $env:BUILD_RELEVANT -ne 'false' + Write-Host "Exact runtime validation required: $BuildRequired" + + $Failures = [System.Collections.Generic.List[string]]::new() + foreach ($Result in $Results.GetEnumerator()) { + if ($Result.Value -notin @('success', 'skipped')) { + $Failures.Add($Result.Name) + } + } + if ($BuildRequired) { + foreach ($RequiredJob in @('runtimeMatrix', 'build', 'runtimeTests')) { + if ($Results[$RequiredJob] -ne 'success' -and -not $Failures.Contains($RequiredJob)) { + $Failures.Add($RequiredJob) + } + } + } if ($Failures.Count -eq 0) { Write-Host 'Build gate passed.' exit 0 } - Write-Host "::error::Build gate failed: $($Failures.Name -join ', ')." + Write-Host "::error::Build gate failed: $(@($Failures | Sort-Object -Unique) -join ', ')." exit 1 diff --git a/.github/workflows/Dependabot-Auto-Approve.yml b/.github/workflows/Dependabot-Auto-Approve.yml index 37f51102..7fdf19c6 100644 --- a/.github/workflows/Dependabot-Auto-Approve.yml +++ b/.github/workflows/Dependabot-Auto-Approve.yml @@ -93,7 +93,8 @@ jobs: FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') MATCHING_FILES=$(printf '%s\n' "$FILES" | sed '/^$/d' | grep -E '^src/DLLPickle\.Build/(DLLPickle\.csproj|packages\.lock\.json)$' || true) UNEXPECTED_FILES=$(printf '%s\n' "$FILES" | sed '/^$/d' | grep -Ev '^src/DLLPickle\.Build/(DLLPickle\.csproj|packages\.lock\.json)$' || true) - CONDITIONAL_TFM_ADDITION=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[] | select(.filename == "src/DLLPickle.Build/DLLPickle.csproj") | (.patch // "")' | grep -E '^\+.*PackageReference.*Condition=.*TargetFramework' || true) + CSPROJ_PATCH=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[] | select(.filename == "src/DLLPickle.Build/DLLPickle.csproj") | (.patch // "")') + CONDITIONAL_TFM_ADDITION=$(printf '%s\n' "$CSPROJ_PATCH" | grep -E '^\+.*PackageReference.*Condition=.*TargetFramework' || true) if [ -n "$MATCHING_FILES" ] && [ -z "$UNEXPECTED_FILES" ] && [ -z "$CONDITIONAL_TFM_ADDITION" ]; then echo "is_exact_nuget_update=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/PowerShell-Support-Lifecycle.yml b/.github/workflows/PowerShell-Support-Lifecycle.yml index f3058bba..f024aedf 100644 --- a/.github/workflows/PowerShell-Support-Lifecycle.yml +++ b/.github/workflows/PowerShell-Support-Lifecycle.yml @@ -12,7 +12,7 @@ permissions: concurrency: group: powershell-support-lifecycle - cancel-in-progress: true + cancel-in-progress: false jobs: discover: @@ -23,13 +23,12 @@ jobs: has_patch: ${{ steps.proposal.outputs.has_patch }} runtime_matrix: ${{ steps.proposal.outputs.runtime_matrix }} support_review: ${{ steps.proposal.outputs.support_review }} + policy_current: ${{ steps.lifecycle-policy.outcome == 'success' }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Check lifecycle evidence and retirement windows - shell: pwsh - run: ./tools/Test-DLLPickleRuntimeProfilePolicy.ps1 -Mode Scheduled + with: + persist-credentials: false - name: Discover servicing and support-contract updates shell: pwsh @@ -74,6 +73,12 @@ jobs: '- Publishing status: validated findings are published once per stable fingerprint' ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + - name: Check lifecycle evidence and retirement windows + id: lifecycle-policy + continue-on-error: true + shell: pwsh + run: ./tools/Test-DLLPickleRuntimeProfilePolicy.ps1 -Mode Scheduled + - name: Upload support discovery evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -97,6 +102,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download checksum-pinned candidate matrix uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -106,18 +113,24 @@ jobs: - name: Provision and verify the proposed official archive shell: pwsh + env: + MATRIX_POWERSHELL_VERSION: ${{ matrix.powerShellVersion }} + MATRIX_PLATFORM: ${{ matrix.platform }} + MATRIX_ARCHITECTURE: ${{ matrix.architecture }} + RUNTIME_INSTALL_ROOT: ${{ runner.temp }}/dllpickle-support-candidate + RUNTIME_IDENTITY_PATH: ./runtime-identity-${{ matrix.powerShellVersion }}-${{ matrix.platform }}.json run: | $Parameters = @{ Provider = 'DirectArchive' - PowerShellVersion = '${{ matrix.powerShellVersion }}' - Platform = '${{ matrix.platform }}' - Architecture = '${{ matrix.architecture }}' - InstallRoot = '${{ runner.temp }}/dllpickle-support-candidate' + PowerShellVersion = $env:MATRIX_POWERSHELL_VERSION + Platform = $env:MATRIX_PLATFORM + Architecture = $env:MATRIX_ARCHITECTURE + InstallRoot = $env:RUNTIME_INSTALL_ROOT MatrixPath = './downloaded-support-discovery/candidate-test-matrix.json' PassThru = $true } $Identity = ./tools/Install-DLLPickleTestPowerShell.ps1 @Parameters - $Identity | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath './runtime-identity-${{ matrix.powerShellVersion }}-${{ matrix.platform }}.json' -Encoding utf8 + $Identity | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:RUNTIME_IDENTITY_PATH -Encoding utf8 - name: Upload proposed runtime identity uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -130,17 +143,25 @@ jobs: finalize-patch-proposal: name: Finalize verified PowerShell patch proposal needs: [discover, candidate-runtime] - if: ${{ always() && needs.discover.outputs.has_patch == 'true' && needs.candidate-runtime.result == 'success' }} + if: ${{ always() && needs.discover.outputs.has_patch == 'true' && needs.discover.outputs.policy_current == 'true' && needs.candidate-runtime.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write pull-requests: write steps: + - name: Create GitHub App token for servicing publication + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} - name: Download discovery evidence uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -187,7 +208,7 @@ jobs: - name: Publish one servicing-patch pull request per validated fingerprint shell: pwsh env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | $ErrorActionPreference = 'Stop' $ProposalRoot = './artifacts/lifecycle/proposal' @@ -262,6 +283,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download discovery evidence uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -334,3 +357,19 @@ jobs: } throw 'A new, changing, retiring, or expired PowerShell support line requires maintainer review; automatic contract mutation is blocked.' + + lifecycle-policy-gate: + name: Enforce current lifecycle policy after publication + needs: [discover, support-contract-warning] + if: ${{ always() }} + runs-on: ubuntu-24.04 + steps: + - name: Enforce fail-closed lifecycle result + shell: pwsh + env: + LIFECYCLE_POLICY_CURRENT: ${{ needs.discover.outputs.policy_current }} + run: | + if ($env:LIFECYCLE_POLICY_CURRENT -ne 'true') { + throw 'The declared runtime lifecycle policy is expired, stale, or otherwise not current. Discovery evidence and any support-contract finding were published before this gate failed.' + } + Write-Host 'The declared runtime lifecycle policy is current.' diff --git a/.github/workflows/Release-and-Publish.yml b/.github/workflows/Release-and-Publish.yml index 3e1c502a..020d02d6 100644 --- a/.github/workflows/Release-and-Publish.yml +++ b/.github/workflows/Release-and-Publish.yml @@ -68,12 +68,28 @@ jobs: actions: read contents: read + outputs: + release_sha: ${{ steps.release-candidate.outputs.release_sha }} + steps: - name: Checkout release candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: main + persist-credentials: false + + - name: Resolve immutable release candidate + id: release-candidate + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $ReleaseSha = (git rev-parse HEAD).Trim() + if ($ReleaseSha -notmatch '^[a-f0-9]{40}$') { + throw "Could not resolve the checked-out main commit as an immutable release candidate: '$ReleaseSha'." + } + "release_sha=$ReleaseSha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Immutable release candidate: $ReleaseSha" - name: Validate authenticated release policy shell: pwsh @@ -101,15 +117,10 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RELEASE_SHA: ${{ steps.release-candidate.outputs.release_sha }} run: | $ErrorActionPreference = 'Stop' - $EvidenceSha = if ($env:EVENT_NAME -eq 'pull_request') { - $env:PULL_REQUEST_HEAD_SHA - } else { - (git rev-parse HEAD).Trim() - } + $EvidenceSha = $env:RELEASE_SHA if ($EvidenceSha -notmatch '^[a-f0-9]{40}$') { throw "Could not resolve the exact release-candidate SHA for authenticated evidence: '$EvidenceSha'." } @@ -226,10 +237,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - # Releases always target main (create-tag, build, and publish all use main), so analyze the - # commit history on main too. This keeps the bump calculation consistent with the released - # commit even for manual workflow_dispatch runs started from a non-main branch. - ref: main + # Use the same immutable main commit accepted by the authenticated evidence gate. + ref: ${{ needs.authenticated-release-gate.outputs.release_sha }} + persist-credentials: false - name: Validate supported PowerShell lifecycle policy shell: pwsh @@ -239,6 +249,15 @@ jobs: shell: pwsh run: ./tools/Get-DLLPicklePowerShellSupportUpdate.ps1 -OutputPath ./artifacts/lifecycle/powershell-support-update.json -RequireCurrent + - name: Upload release lifecycle evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-lifecycle-evidence + path: ./artifacts/lifecycle/powershell-support-update.json + if-no-files-found: warn + retention-days: 30 + - name: Determine version and bump type id: determine_version shell: pwsh @@ -246,6 +265,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} MANUAL_BUMP_INPUT: ${{ inputs.version_bump }} MANUAL_BUMP_EVENT: ${{ github.event.inputs.version_bump }} + RELEASE_SHA: ${{ needs.authenticated-release-gate.outputs.release_sha }} run: | # Pass manual bump parameter to Get-VersionBump for centralized version logic Write-Host "Analyzing version requirements..." @@ -300,6 +320,12 @@ jobs: Write-Host "New version: $NewVersion" Write-Host "Should release: $ShouldRelease" + $CheckedOutSha = (git rev-parse HEAD).Trim() + if ($CheckedOutSha -ne $env:RELEASE_SHA) { + throw "Analyze checked out $CheckedOutSha, but authenticated evidence approved $($env:RELEASE_SHA)." + } + $ReleaseSha = $env:RELEASE_SHA + # Determine workflow trigger if ($env:EVENT_NAME -eq "workflow_dispatch") { $TriggerSource = "manual" @@ -327,12 +353,12 @@ jobs: "current_version=$($CurrentVersion.ToString())" >> $env:GITHUB_OUTPUT "previous_tag=$PreviousTag" >> $env:GITHUB_OUTPUT "trigger_source=$TriggerSource" >> $env:GITHUB_OUTPUT + "release_sha=$ReleaseSha" >> $env:GITHUB_OUTPUT exit 0 } - # Set outputs. Capture the resolved main commit so every downstream job (tag, build, - # sync, release, publish) operates on exactly this SHA, even if main advances mid-release. - $ReleaseSha = (git rev-parse HEAD).Trim() + # Set outputs. Every downstream job (tag, build, sync, release, publish) operates on the + # exact commit accepted by the authenticated release gate, even if main advances. "should_release=true" >> $env:GITHUB_OUTPUT "version_bump=$NewVersionType" >> $env:GITHUB_OUTPUT "current_version=$($CurrentVersion.ToString())" >> $env:GITHUB_OUTPUT @@ -505,6 +531,7 @@ jobs: - name: Revalidate stamped release artifact shell: pwsh run: | + $ErrorActionPreference = 'Stop' $CompositionParameters = @{ ModulePath = './module/DLLPickle' SkipBuildOutputComparison = $true @@ -521,11 +548,12 @@ jobs: ./tools/New-DLLPickleArtifactSizeReport.ps1 @SizeParameters - name: Upload stamped release policy reports + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stamped-release-policy-reports path: ./artifacts/release - if-no-files-found: error + if-no-files-found: warn - name: Upload updated module uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/Upstream-Compatibility.yml b/.github/workflows/Upstream-Compatibility.yml index be79873b..47effcda 100644 --- a/.github/workflows/Upstream-Compatibility.yml +++ b/.github/workflows/Upstream-Compatibility.yml @@ -244,13 +244,19 @@ jobs: - name: Provision exact stock PowerShell shell: pwsh + env: + MATRIX_PROVIDER: ${{ matrix.provider }} + MATRIX_POWERSHELL_VERSION: ${{ matrix.powerShellVersion }} + MATRIX_PLATFORM: ${{ matrix.platform }} + MATRIX_ARCHITECTURE: ${{ matrix.architecture }} + RUNTIME_INSTALL_ROOT: ${{ runner.temp }}/dllpickle-test-powershell run: | $InstallParameters = @{ - Provider = '${{ matrix.provider }}' - PowerShellVersion = '${{ matrix.powerShellVersion }}' - Platform = '${{ matrix.platform }}' - Architecture = '${{ matrix.architecture }}' - InstallRoot = '${{ runner.temp }}/dllpickle-test-powershell' + Provider = $env:MATRIX_PROVIDER + PowerShellVersion = $env:MATRIX_POWERSHELL_VERSION + Platform = $env:MATRIX_PLATFORM + Architecture = $env:MATRIX_ARCHITECTURE + InstallRoot = $env:RUNTIME_INSTALL_ROOT PassThru = $true } $Identity = ./tools/Install-DLLPickleTestPowerShell.ps1 @InstallParameters @@ -258,9 +264,16 @@ jobs: - name: Capture and validate profile-selected upstream evidence shell: pwsh + env: + MATRIX_POWERSHELL_VERSION: ${{ matrix.powerShellVersion }} + MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }} + MATRIX_PLATFORM: ${{ matrix.platform }} + MATRIX_ARCHITECTURE: ${{ matrix.architecture }} + PROFILE_MODULE_CACHE: ${{ runner.temp }}/dllpickle-upstream-modules/${{ matrix.powerShellVersion }} run: | - $EvidenceRoot = './artifacts/upstreamCompatibility/${{ matrix.powerShellVersion }}/${{ matrix.platform }}' - $ModuleCache = '${{ runner.temp }}/dllpickle-upstream-modules/${{ matrix.powerShellVersion }}' + $ErrorActionPreference = 'Stop' + $EvidenceRoot = Join-Path './artifacts/upstreamCompatibility' "$($env:MATRIX_POWERSHELL_VERSION)/$($env:MATRIX_PLATFORM)" + $ModuleCache = $env:PROFILE_MODULE_CACHE $InventoryPath = Join-Path -Path $EvidenceRoot -ChildPath 'upstream-inventory.json' $ConflictMatrixPath = Join-Path -Path $EvidenceRoot -ChildPath 'conflict-matrix.json' $InventoryArguments = @( @@ -290,7 +303,7 @@ jobs: $Policy = Get-Content -LiteralPath ./build/dependency-policy.json -Raw | ConvertFrom-Json $GapReport = [ordered]@{ schemaVersion = 1 - profile = '${{ matrix.powerShellVersion }}/${{ matrix.targetFramework }}/${{ matrix.platform }}/${{ matrix.architecture }}' + profile = '{0}/{1}/{2}/{3}' -f $env:MATRIX_POWERSHELL_VERSION, $env:MATRIX_TARGET_FRAMEWORK, $env:MATRIX_PLATFORM, $env:MATRIX_ARCHITECTURE deterministicImportNoAuth = 'executed-two-orders-with-and-without-dllpickle' authenticatedReadOnly = 'not-run-no-approved-credentials' writesPerformed = $false @@ -316,17 +329,31 @@ jobs: profile-evidence-gate: name: Aggregate profile-aware upstream evidence - needs: profile-evidence + needs: [pr-changes, profile-matrix, profile-evidence] if: ${{ always() }} runs-on: ubuntu-24.04 steps: - name: Report exact-profile evidence result shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + LIVE_VALIDATION: ${{ needs.pr-changes.outputs.live_validation }} run: | + $MatrixResult = '${{ needs.profile-matrix.result }}' $Result = '${{ needs.profile-evidence.result }}' - if ($Result -notin @('success', 'skipped')) { - throw "Profile-aware upstream evidence or baseline validation failed: $Result" + $ValidationRequired = $env:EVENT_NAME -ne 'pull_request' -or $env:LIVE_VALIDATION -eq 'true' + if ($ValidationRequired) { + if ($MatrixResult -ne 'success') { + throw "Required exact profile matrix generation did not succeed: $MatrixResult" + } + if ($Result -ne 'success') { + throw "Required profile-aware upstream evidence or baseline validation did not succeed: $Result" + } + } elseif ($MatrixResult -notin @('success', 'skipped') -or $Result -notin @('success', 'skipped')) { + throw "Optional profile validation ended unexpectedly: matrix=$MatrixResult, evidence=$Result" } + Write-Host "Exact profile validation required: $ValidationRequired" + Write-Host "Exact profile matrix result: $MatrixResult" Write-Host "Profile-aware upstream evidence result: $Result" scheduled-profile-evidence-report: @@ -347,7 +374,7 @@ jobs: with: pattern: upstream-* path: ./downloaded-upstream - merge-multiple: true + merge-multiple: false - name: Build one stable profile-aware finding shell: pwsh @@ -483,15 +510,24 @@ jobs: if: ${{ always() && github.event_name != 'pull_request' && needs.scheduled-profile-evidence-report.result == 'success' }} runs-on: windows-2025 permissions: + actions: read contents: write issues: write pull-requests: write steps: + - name: Create GitHub App token for candidate publication + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} - name: Download accepted exact-profile evidence summary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -499,6 +535,13 @@ jobs: name: upstream-profile-evidence-summary path: ./downloaded-profile-summary + - name: Download exact-profile inventories + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: upstream-*-*-x64 + path: ./downloaded-profile-evidence + merge-multiple: false + - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -513,9 +556,16 @@ jobs: - name: Provision exact stock PowerShell for candidate validation shell: pwsh run: | + $Matrix = Get-Content -LiteralPath './build/powershell-test-matrix.json' -Raw | ConvertFrom-Json + $Profile = @($Matrix.profiles | Where-Object { + $_.powerShellMajor -eq 7 -and $_.powerShellMinor -eq 6 -and $_.targetFramework -eq 'net10.0' + }) + if ($Profile.Count -ne 1) { + throw 'Expected exactly one PowerShell 7.6/net10.0 profile in the test matrix.' + } $InstallParameters = @{ Provider = 'DirectArchive' - PowerShellVersion = '7.6.4' + PowerShellVersion = [string]$Profile[0].powerShellVersion Platform = 'windows' Architecture = 'x64' InstallRoot = '${{ runner.temp }}/dllpickle-test-powershell' @@ -542,7 +592,7 @@ jobs: id: drift shell: pwsh env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | $ErrorActionPreference = 'Stop' ./tools/New-DLLPickleConflictMatrix.ps1 ` @@ -648,12 +698,21 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - ./tools/Update-DLLPickleDependencyPins.ps1 ` - -InventoryPath ./artifacts/upstreamCompatibility/upstream-inventory.json ` - -PolicyPath ./build/dependency-policy.json ` - -ProjectPath ./src/DLLPickle.Build/DLLPickle.csproj ` - -OutputPath ./artifacts/upstreamCompatibility/candidate-report.json ` - -Restore + $InventoryPaths = @( + Get-ChildItem -LiteralPath './downloaded-profile-evidence' -Filter 'upstream-inventory.json' -File -Recurse | + Select-Object -ExpandProperty FullName + ) + if ($InventoryPaths.Count -ne 9) { + throw "Candidate pin generation requires exactly nine profile inventories; found $($InventoryPaths.Count)." + } + $UpdateParameters = @{ + InventoryPath = $InventoryPaths + PolicyPath = './build/dependency-policy.json' + ProjectPath = './src/DLLPickle.Build/DLLPickle.csproj' + OutputPath = './artifacts/upstreamCompatibility/candidate-report.json' + Restore = $true + } + ./tools/Update-DLLPickleDependencyPins.ps1 @UpdateParameters - name: Validate candidate update if: steps.drift.outputs.drift != 'true' @@ -719,7 +778,7 @@ jobs: if: steps.drift.outputs.drift != 'true' && steps.generated_changes.outputs.has_changes == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.create_pull_request)) shell: pwsh env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | $ErrorActionPreference = 'Stop' $Fingerprint = '${{ steps.generated_changes.outputs.publication_fingerprint }}' diff --git a/.github/workflows/Validate-Packages.yml b/.github/workflows/Validate-Packages.yml index 44310b9c..2fd8628e 100644 --- a/.github/workflows/Validate-Packages.yml +++ b/.github/workflows/Validate-Packages.yml @@ -62,4 +62,4 @@ jobs: with: name: package-tfm-alignment path: ./artifacts/package/tfm-alignment.json - if-no-files-found: error + if-no-files-found: warn From 83892c6a184c81d060a7fe5605b523ec3b966c11 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:00:31 -0400 Subject: [PATCH 15/43] test: cover profile-aware policy edge cases --- .../DLLPickle.Issue34.GraphAuth.Tests.ps1 | 16 +-- .../Integration/Invoke-DLLPickleScenario.ps1 | 19 +-- tests/Unit/ArtifactPolicy.Tests.ps1 | 52 ++++++- tests/Unit/BuildTooling.Tests.ps1 | 47 +++++++ tests/Unit/ConflictMatrixDrift.Tests.ps1 | 11 ++ tests/Unit/DependencyAutomation.Tests.ps1 | 14 +- tests/Unit/DependencyPolicy.Tests.ps1 | 30 +++- tests/Unit/Import-DPLibrary.Tests.ps1 | 11 +- tests/Unit/KnownConflicts.Tests.ps1 | 9 +- tests/Unit/PowerShellSupportUpdate.Tests.ps1 | 7 +- tests/Unit/SupportDocumentation.Tests.ps1 | 15 +- tests/Unit/TfmAlignment.Tests.ps1 | 128 ++++++++++++------ tests/Unit/UpstreamInventoryProfile.Tests.ps1 | 15 +- tests/Unit/UpstreamScenarioEvidence.Tests.ps1 | 12 ++ tests/Unit/WorkflowGuardrails.Tests.ps1 | 14 +- 15 files changed, 315 insertions(+), 85 deletions(-) diff --git a/tests/Integration/DLLPickle.Issue34.GraphAuth.Tests.ps1 b/tests/Integration/DLLPickle.Issue34.GraphAuth.Tests.ps1 index 0efb7bf6..6396d45b 100644 --- a/tests/Integration/DLLPickle.Issue34.GraphAuth.Tests.ps1 +++ b/tests/Integration/DLLPickle.Issue34.GraphAuth.Tests.ps1 @@ -113,13 +113,13 @@ Describe 'Issue 34 Microsoft Graph authentication API regression' -Tag 'Integrat ($ConnectStep.Output -join [Environment]::NewLine) | Should -Match 'WithLogging\(IIdentityLogger, Boolean\)' $FinalAssemblies = @($ConnectStep.AssembliesAfter) - $MsalAssembly = $FinalAssemblies | Where-Object Name -EQ 'Microsoft.Identity.Client' | Select-Object -First 1 - $IdentityAssembly = $FinalAssemblies | Where-Object Name -EQ 'Microsoft.IdentityModel.Abstractions' | Select-Object -First 1 - $MsalAssembly | Should -Not -BeNullOrEmpty - $IdentityAssembly | Should -Not -BeNullOrEmpty - $MsalAssembly.LoadContext | Should -Be 'Default' - $IdentityAssembly.LoadContext | Should -Be 'Default' - $MsalAssembly.Location | Should -Match ([regex]::Escape($Result.Host.SelectedBundlePath)) - $IdentityAssembly.Location | Should -Match ([regex]::Escape($Result.Host.SelectedBundlePath)) + foreach ($AssemblyName in @('Microsoft.Identity.Client', 'Microsoft.IdentityModel.Abstractions')) { + $MatchingAssemblies = @($FinalAssemblies | Where-Object Name -EQ $AssemblyName) + $MatchingAssemblies | Should -HaveCount 1 + foreach ($Assembly in $MatchingAssemblies) { + $Assembly.LoadContext | Should -Be 'Default' + $Assembly.Location | Should -Match ([regex]::Escape($Result.Host.SelectedBundlePath)) + } + } } } diff --git a/tests/Integration/Invoke-DLLPickleScenario.ps1 b/tests/Integration/Invoke-DLLPickleScenario.ps1 index 19ac07c6..460681b8 100644 --- a/tests/Integration/Invoke-DLLPickleScenario.ps1 +++ b/tests/Integration/Invoke-DLLPickleScenario.ps1 @@ -232,15 +232,16 @@ $ScenarioModuleManifestPath = [string]$Payload.ModuleManifestPath $ScenarioOutputPath = [string]$Payload.OutputPath $RuntimeProfile = $null if (-not [string]::IsNullOrWhiteSpace($ScenarioModuleManifestPath)) { - $RuntimePolicyPath = Join-Path -Path (Split-Path -Path $ScenarioModuleManifestPath -Parent) -ChildPath 'SupportedRuntimeProfiles.json' - if (Test-Path -LiteralPath $RuntimePolicyPath -PathType Leaf) { - $RuntimePolicy = Get-Content -LiteralPath $RuntimePolicyPath -Raw | ConvertFrom-Json - $RuntimeProfile = @($RuntimePolicy.profiles | Where-Object { - $_.powerShellMajor -eq $PSVersionTable.PSVersion.Major -and - $_.powerShellMinor -eq $PSVersionTable.PSVersion.Minor -and - $_.dotnetMajor -eq [Environment]::Version.Major - }) | Select-Object -First 1 + $ScenarioModuleRoot = Split-Path -Path $ScenarioModuleManifestPath -Parent + $RuntimePolicyPath = Join-Path -Path $ScenarioModuleRoot -ChildPath 'SupportedRuntimeProfiles.json' + $RuntimeProfileResolverPath = Join-Path -Path $ScenarioModuleRoot -ChildPath 'Private/Get-DPRuntimeProfile.ps1' + foreach ($RequiredRuntimeProfilePath in @($RuntimePolicyPath, $RuntimeProfileResolverPath)) { + if (-not (Test-Path -LiteralPath $RequiredRuntimeProfilePath -PathType Leaf)) { + throw "Scenario runtime-profile input was not found: $RequiredRuntimeProfilePath" + } } + . $RuntimeProfileResolverPath + $RuntimeProfile = Get-DPRuntimeProfile -PolicyPath $RuntimePolicyPath -PowerShellVersion $PSVersionTable.PSVersion -DotNetMajor ([Environment]::Version.Major) } $Scenario = [ordered]@{ ScenarioName = [string]$Payload.Name @@ -254,7 +255,7 @@ $Scenario = [ordered]@{ OS = if ($PSVersionTable.ContainsKey('OS')) { $PSVersionTable.OS } else { [System.Environment]::OSVersion.VersionString } CLRVersion = if ($PSVersionTable.ContainsKey('CLRVersion')) { $PSVersionTable.CLRVersion.ToString() } else { $null } RuntimeVersion = [System.Environment]::Version.ToString() - TargetFramework = if ($RuntimeProfile) { [string]$RuntimeProfile.targetFramework } else { 'net{0}.0' -f [Environment]::Version.Major } + TargetFramework = if ($RuntimeProfile) { [string]$RuntimeProfile.targetFramework } else { $null } SelectedBundlePath = if ($RuntimeProfile) { Join-Path -Path (Split-Path -Path $ScenarioModuleManifestPath -Parent) -ChildPath (Join-Path 'bin' $RuntimeProfile.targetFramework) } else { diff --git a/tests/Unit/ArtifactPolicy.Tests.ps1 b/tests/Unit/ArtifactPolicy.Tests.ps1 index 37af7b57..d419b632 100644 --- a/tests/Unit/ArtifactPolicy.Tests.ps1 +++ b/tests/Unit/ArtifactPolicy.Tests.ps1 @@ -1,5 +1,4 @@ BeforeAll { - Set-Location -Path $PSScriptRoot $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $script:ArtifactInspectionPath = Join-Path $ProjectRoot 'tools\Test-DLLPicklePackageArtifact.ps1' $script:ArtifactSizePath = Join-Path $ProjectRoot 'tools\New-DLLPickleArtifactSizeReport.ps1' @@ -57,11 +56,62 @@ Describe 'DLLPickle package artifact policy' -Tag 'Unit' { It 'fails closed on an unexpected target framework' { $null = New-Item -Path (Join-Path $script:ModulePath 'bin\net11.0') -ItemType Directory -Force + $Report = & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'unexpected-report.json') + + $Finding = @($Report.Findings | Where-Object Code -EQ 'UnexpectedTargetFramework') + $Finding | Should -HaveCount 1 + $Finding[0].TargetFramework | Should -BeExactly 'net11.0' + $Finding[0].Message | Should -Match 'Unexpected target-framework directory' { & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'unexpected.json') -Strict } | Should -Throw '*Unexpected target-framework directory*' } + It 'fails preflight with a clear error when the module manifest is absent' { + Remove-Item -LiteralPath (Join-Path $script:ModulePath 'DLLPickle.psd1') + + { + & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'missing-manifest.json') + } | Should -Throw '*Required package-inspection path*DLLPickle.psd1*' + } + + It 'ignores non-package managed files when comparing filtered managed and native sets' { + foreach ($TargetFramework in @('net8.0', 'net9.0', 'net10.0')) { + $ArtifactTfmPath = Join-Path (Join-Path $script:ModulePath 'bin') $TargetFramework + 'module helper' | Set-Content -LiteralPath (Join-Path $ArtifactTfmPath 'Contoso.ModuleHelper.dll') -Encoding UTF8 + $ManagedRuntimePath = Join-Path $ArtifactTfmPath 'runtimes/win-x64/lib/net8.0' + $null = New-Item -Path $ManagedRuntimePath -ItemType Directory -Force + 'managed runtime asset' | Set-Content -LiteralPath (Join-Path $ManagedRuntimePath 'Contoso.RuntimeHelper.dll') -Encoding UTF8 + } + + $Report = & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'filtered-comparison.json') -Strict + + $Report.Passed | Should -BeTrue + @($Report.Findings | Where-Object Code -In @('UnexpectedManagedAsset', 'UnexpectedNativeAsset')) | Should -BeNullOrEmpty + } + + It 'reports sorted portable relative paths for native assets' { + $ArtifactTfmPath = Join-Path (Join-Path $script:ModulePath 'bin') 'net8.0' + $BuildTfmPath = Join-Path $script:BuildOutputRoot 'net8.0' + $RelativeNativePaths = @( + 'runtimes/win-x64/native/zeta.dll' + 'runtimes/linux-x64/native/alpha.so' + ) + foreach ($RelativePath in $RelativeNativePaths) { + foreach ($Root in @($ArtifactTfmPath, $BuildTfmPath)) { + $NativePath = Join-Path $Root $RelativePath + $null = New-Item -Path (Split-Path -Path $NativePath -Parent) -ItemType Directory -Force + 'native asset' | Set-Content -LiteralPath $NativePath -Encoding UTF8 + } + } + + $Report = & $script:ArtifactInspectionPath -ModulePath $script:ModulePath -BuildOutputRoot $script:BuildOutputRoot -SupportPolicyPath $script:PolicyPath -ProjectPath $script:ProjectPath -LockFilePath $script:LockPath -OutputPath (Join-Path $TestDrive 'native-paths.json') -Strict + $NativeAssets = @(($Report.Profiles | Where-Object TargetFramework -EQ 'net8.0').NativeAssets) + + $NativeAssets | Should -Be @('linux-x64/native/alpha.so', 'win-x64/native/zeta.dll') + @($NativeAssets | Where-Object { $_ -match '\\' }) | Should -BeNullOrEmpty + } + It 'fails closed when optional multi-pwsh tooling leaks into artifact content' { 'multi-pwsh must never ship here' | Set-Content -LiteralPath (Join-Path $script:ModulePath 'leak.txt') -Encoding UTF8 { diff --git a/tests/Unit/BuildTooling.Tests.ps1 b/tests/Unit/BuildTooling.Tests.ps1 index 3aa8f3a4..624d35f0 100644 --- a/tests/Unit/BuildTooling.Tests.ps1 +++ b/tests/Unit/BuildTooling.Tests.ps1 @@ -93,6 +93,53 @@ Describe 'Deterministic build tooling' -Tag 'Unit' { $bootstrap | Should -Match ([regex]::Escape('& $ModuleInstallCommand @ModuleCommandSplat')) } + It 'parses the canonical policy and resolves one exact tool version' { + $Policy = Get-DLLPickleBuildToolPolicy -Path $script:ToolPolicyPath + + $Policy.schemaVersion | Should -Be 1 + Get-DLLPickleBuildToolVersion -Policy $Policy -Name 'Pester' | Should -Be ([version]'5.7.1') + { Get-DLLPickleBuildToolVersion -Policy $Policy -Name 'Missing.Tool' } | Should -Throw '*exactly one*' + } + + It 'rejects policy versions that are not Major.Minor.Patch' { + $PolicyPath = Join-Path $TestDrive 'invalid-tool-policy.json' + @{ + schemaVersion = 1 + modules = @(@{ name = 'Fixture.Tool'; version = '5.7' }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $PolicyPath -Encoding utf8 + + { Get-DLLPickleBuildToolPolicy -Path $PolicyPath } | Should -Throw '*Major.Minor.Patch*' + } + + It 'compares tool versions by the requested precision' { + Test-DLLPickleToolVersionMatch -ActualVersion ([version]'1.2.3.4') -RequiredVersion ([version]'1.2.3') | + Should -BeTrue + Test-DLLPickleToolVersionMatch -ActualVersion ([version]'1.2.4') -RequiredVersion ([version]'1.2.3') | + Should -BeFalse + Test-DLLPickleToolVersionMatch -ActualVersion ([version]'1.2.3.5') -RequiredVersion ([version]'1.2.3.4') | + Should -BeFalse + } + + It 'imports and reuses the exact requested module version' { + $ModuleRoot = Join-Path $TestDrive 'modules/Fixture.Tool/1.2.3' + $null = New-Item -Path $ModuleRoot -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $ModuleRoot 'Fixture.Tool.psm1') -Value "function Get-FixtureTool { 'ok' }" -Encoding utf8 + New-ModuleManifest -Path (Join-Path $ModuleRoot 'Fixture.Tool.psd1') -RootModule 'Fixture.Tool.psm1' -ModuleVersion '1.2.3' -FunctionsToExport @('Get-FixtureTool') + + $OriginalModulePath = $env:PSModulePath + try { + $env:PSModulePath = (Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent) + [System.IO.Path]::PathSeparator + $OriginalModulePath + $First = Import-DLLPickleBuildTool -Name 'Fixture.Tool' -RequiredVersion ([version]'1.2.3') + $Second = Import-DLLPickleBuildTool -Name 'Fixture.Tool' -RequiredVersion ([version]'1.2.3') + + $First.Version | Should -Be ([version]'1.2.3') + $Second.Path | Should -BeExactly $First.Path + } finally { + Remove-Module -Name 'Fixture.Tool' -Force -ErrorAction SilentlyContinue + $env:PSModulePath = $OriginalModulePath + } + } + It 'does not change StrictMode in the caller process' { $tooling = Get-Content -LiteralPath $script:ToolingScriptPath -Raw diff --git a/tests/Unit/ConflictMatrixDrift.Tests.ps1 b/tests/Unit/ConflictMatrixDrift.Tests.ps1 index a350617a..5368ca37 100644 --- a/tests/Unit/ConflictMatrixDrift.Tests.ps1 +++ b/tests/Unit/ConflictMatrixDrift.Tests.ps1 @@ -124,9 +124,12 @@ Describe 'Compare-DLLPickleConflictMatrix' -Tag 'Unit' { $first = & $ScriptPath -Baseline $b -Current $c $second = & $ScriptPath -Baseline $b -Current $c + $changed = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true 'Default' @('1.52.0.0') @('Az.Accounts')) + $different = & $ScriptPath -Baseline $b -Current $changed $first.FindingFingerprint | Should -Match '^[a-f0-9]{64}$' $first.FindingFingerprint | Should -BeExactly $second.FindingFingerprint + $different.FindingFingerprint | Should -Not -BeExactly $first.FindingFingerprint } It 'rejects comparisons across different runtime profiles' { @@ -137,4 +140,12 @@ Describe 'Compare-DLLPickleConflictMatrix' -Tag 'Unit' { { & $ScriptPath -Baseline $b -Current $c } | Should -Throw '*different runtime profiles*' } + + It 'rejects a comparison when only one matrix declares a runtime profile' { + $b = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true) + $b | Add-Member -NotePropertyName ProfileKey -NotePropertyValue 'ps7.4-net8.0-windows-x64' + $c = Get-DriftMatrix @(Get-DriftRow 'Azure.Core' $true) + + { & $ScriptPath -Baseline $b -Current $c } | Should -Throw '*only one declares*' + } } diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index 59e858d3..80417f64 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -265,17 +265,21 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $Report.BlockedFindings[0].AssemblyName | Should -Be 'Microsoft.OData.Core' } - It 'flags existing per-TFM conditional pins for maintainer review' { + It 'resolves and flags package pins in framework-conditioned ItemGroups' { $ProjectPath = Join-Path -Path $TestDrive -ChildPath 'conditional.csproj' @' net8.0;net9.0;net10.0 - - - - + + + + + + + + '@ | Set-Content -LiteralPath $ProjectPath -Encoding UTF8 diff --git a/tests/Unit/DependencyPolicy.Tests.ps1 b/tests/Unit/DependencyPolicy.Tests.ps1 index 104cfb69..22a09f0c 100644 --- a/tests/Unit/DependencyPolicy.Tests.ps1 +++ b/tests/Unit/DependencyPolicy.Tests.ps1 @@ -29,6 +29,29 @@ Describe 'Dependency policy baseline' -Tag 'Unit' { } } + It 'keeps every duplicated runtime-profile list aligned with its shared policy source' { + $ExpectedModules = @($script:Policy.monitoredModules.name | Sort-Object) + $ExpectedPreloads = @($script:Policy.preload.assemblyName | Sort-Object) + $ExpectedBlocks = @($script:Policy.blockedPreloadAssemblies.assemblyName | Sort-Object) + $ExpectedTargetFrameworks = @( + @($script:Policy.preload + $script:Policy.blockedPreloadAssemblies).targetFrameworks | + Sort-Object -Unique + ) + $CanonicalImportOrders = $script:Policy.runtimeProfiles[0].importOrders | ConvertTo-Json -Depth 5 -Compress + $CanonicalKnownConflictIds = @($script:Policy.runtimeProfiles[0].knownConflictIds | Sort-Object) + $CanonicalValidationTiers = $script:Policy.runtimeProfiles[0].validationTiers | ConvertTo-Json -Depth 5 -Compress + + @($script:Policy.runtimeProfiles.targetFramework | Sort-Object) | Should -Be $ExpectedTargetFrameworks + foreach ($RuntimeProfile in @($script:Policy.runtimeProfiles)) { + @($RuntimeProfile.monitoredModuleSet | Sort-Object) | Should -Be $ExpectedModules + @($RuntimeProfile.preloadAssemblyNames | Sort-Object) | Should -Be $ExpectedPreloads + @($RuntimeProfile.blockedAssemblyNames | Sort-Object) | Should -Be $ExpectedBlocks + ($RuntimeProfile.importOrders | ConvertTo-Json -Depth 5 -Compress) | Should -BeExactly $CanonicalImportOrders + @($RuntimeProfile.knownConflictIds | Sort-Object) | Should -Be $CanonicalKnownConflictIds + ($RuntimeProfile.validationTiers | ConvertTo-Json -Depth 5 -Compress) | Should -BeExactly $CanonicalValidationTiers + } + } + It 'records deterministic and authenticated read-only probes separately' { foreach ($module in @($script:Policy.monitoredModules)) { $module.umbrellaModule | Should -Not -BeNullOrEmpty @@ -36,7 +59,12 @@ Describe 'Dependency policy baseline' -Tag 'Unit' { $module.authenticatedReadOnlyProbeCommand | Should -Not -BeNullOrEmpty } ($script:Policy.monitoredModules | Where-Object name -eq 'ExchangeOnlineManagement').authenticatedReadOnlyProbeCommand | Should -Match 'Get-EXOMailbox' - ($script:Policy.monitoredModules | Where-Object name -eq 'MicrosoftTeams').authenticatedReadOnlyProbeCommand | Should -Match 'Get-CsTenant' + $TeamsPolicy = $script:Policy.monitoredModules | Where-Object name -eq 'MicrosoftTeams' + $TeamsPolicy.deterministicProbeCommand | Should -Match '^Get-Team\b' + $TeamsPolicy.deterministicProbeCommand | Should -Not -Match 'Get-Command' + $TeamsPolicy.authenticatedReadOnlyProbeCommand | Should -Match 'Connect-MicrosoftTeams' + $TeamsPolicy.authenticatedReadOnlyProbeCommand | Should -Match 'Get-CsTenant' + $TeamsPolicy.authenticatedReadOnlyProbeCommand | Should -Match 'Disconnect-MicrosoftTeams' } It 'explicitly monitors Az.Resources as the #193 collision source' { diff --git a/tests/Unit/Import-DPLibrary.Tests.ps1 b/tests/Unit/Import-DPLibrary.Tests.ps1 index aced615f..ff38da5b 100644 --- a/tests/Unit/Import-DPLibrary.Tests.ps1 +++ b/tests/Unit/Import-DPLibrary.Tests.ps1 @@ -24,6 +24,11 @@ Describe 'Import-DPLibrary' -Tag 'Unit' { SkipLibraries = @() } } + Mock -CommandName Get-DPRuntimeProfile -MockWith { + [PSCustomObject]@{ + targetFramework = 'net8.0' + } + } } It 'Throws when the target framework directory does not exist' { @@ -293,7 +298,8 @@ if ($ConsumerType.GetMethod('GetValue').Invoke($null, @()) -ne 'resolved') { $ChildScriptPath = Join-Path -Path $TestDrive -ChildPath 'Invoke-SyntheticDependencyTest.ps1' Set-Content -LiteralPath $ChildScriptPath -Value $ChildScript -Encoding UTF8 - $ProcessOutput = @(& pwsh -NoProfile -NonInteractive -File $ChildScriptPath 2>&1) + $ChildPowerShellExecutable = [Environment]::ProcessPath + $ProcessOutput = @(& $ChildPowerShellExecutable -NoProfile -NonInteractive -File $ChildScriptPath 2>&1) $ProcessExitCode = $LASTEXITCODE $ProcessExitCode | Should -Be 0 -Because ($ProcessOutput -join [Environment]::NewLine) @@ -445,7 +451,8 @@ if ($ProbeLoadedAssemblies.Count -gt 0) { $ChildScriptPath = Join-Path -Path $TestDrive -ChildPath 'Invoke-MetadataOnlyDependencyTest.ps1' Set-Content -LiteralPath $ChildScriptPath -Value $ChildScript -Encoding UTF8 - $ProcessOutput = @(& pwsh -NoProfile -NonInteractive -File $ChildScriptPath 2>&1) + $ChildPowerShellExecutable = [Environment]::ProcessPath + $ProcessOutput = @(& $ChildPowerShellExecutable -NoProfile -NonInteractive -File $ChildScriptPath 2>&1) $ProcessExitCode = $LASTEXITCODE $ProcessOutputText = $ProcessOutput -join [Environment]::NewLine diff --git a/tests/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 index 59ac7ab0..fe8493f6 100644 --- a/tests/Unit/KnownConflicts.Tests.ps1 +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -96,7 +96,14 @@ Describe 'Shipped KnownConflicts.json source' -Tag 'Unit' { @($Odata.importOrders) | Should -HaveCount 2 @($Odata.runtimeProfiles.powerShellLine) | Should -Be @('7.4', '7.5', '7.6') @($Odata.runtimeProfiles.targetFramework) | Should -Be @('net8.0', 'net9.0', 'net10.0') - $Odata.validationTiers.authenticatedReadOnly | Should -Be 'not-run-no-approved-credentials' + $Odata.validationTiers.deterministicImportNoAuth.required | Should -BeTrue + $Odata.validationTiers.authenticatedReadOnly.requiredBeforeRelease | Should -BeTrue + $Odata.validationTiers.authenticatedReadOnly.writesAllowed | Should -BeFalse + $Odata.validationTiers.authenticatedReadOnly.status | Should -Be 'not-run-no-approved-credentials' + @($Odata.runtimeProfiles | Where-Object powerShellLine -EQ '7.4').evidenceStatus | + Should -Be 'stale-requires-refresh-issue-273' + @($Odata.runtimeProfiles | Where-Object powerShellLine -IN @('7.5', '7.6')).evidenceStatus | + Should -Be @('requires-ci-evidence', 'requires-ci-evidence') } } diff --git a/tests/Unit/PowerShellSupportUpdate.Tests.ps1 b/tests/Unit/PowerShellSupportUpdate.Tests.ps1 index c0287a32..f4e651c1 100644 --- a/tests/Unit/PowerShellSupportUpdate.Tests.ps1 +++ b/tests/Unit/PowerShellSupportUpdate.Tests.ps1 @@ -107,9 +107,14 @@ Describe 'PowerShell support update discovery' -Tag 'Unit' { TargetFramework = 'net9.0' } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $IdentityDirectory "$Platform.json") -Encoding UTF8 } + @{ PowerShellVersion = '7.5.10'; Platform = 'windows' } | + ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $IdentityDirectory 'malformed.json') -Encoding UTF8 $FinalPath = Join-Path $TestDrive 'final-matrix.json' - $Final = & $script:MatrixUpdatePath -TestMatrixPath $Fixture.MatrixPath -UpdateReportPath $ReportPath -OutputPath $FinalPath -RuntimeIdentityPath $IdentityDirectory -VerifiedAtUtc '2026-08-08T12:34:56Z' + $IdentityWarnings = @() + $Final = & $script:MatrixUpdatePath -TestMatrixPath $Fixture.MatrixPath -UpdateReportPath $ReportPath -OutputPath $FinalPath -RuntimeIdentityPath $IdentityDirectory -VerifiedAtUtc '2026-08-08T12:34:56Z' -WarningVariable IdentityWarnings $Final.Mode | Should -Be 'VerifiedProposal' + ($IdentityWarnings -join [Environment]::NewLine) | Should -Match 'malformed\.json.*DotNetVersion.*Architecture' $Updated = Get-Content -LiteralPath $FinalPath -Raw | ConvertFrom-Json @($Updated.profiles | Where-Object powerShellVersion -EQ '7.5.10') | Should -HaveCount 1 @($Updated.profiles | Where-Object powerShellVersion -EQ '7.5.10')[0].dotnetRuntimeVersion | Should -Be '9.0.19' diff --git a/tests/Unit/SupportDocumentation.Tests.ps1 b/tests/Unit/SupportDocumentation.Tests.ps1 index 84f3eebc..ff218138 100644 --- a/tests/Unit/SupportDocumentation.Tests.ps1 +++ b/tests/Unit/SupportDocumentation.Tests.ps1 @@ -1,10 +1,9 @@ BeforeAll { - Set-Location -Path $PSScriptRoot - $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - $script:GeneratorPath = Join-Path $ProjectRoot 'tools\New-DLLPickleSupportDocumentation.ps1' - $script:ReadmePath = Join-Path $ProjectRoot 'README.md' - $script:ArchitecturePath = Join-Path $ProjectRoot 'docs\Architecture.md' - $script:DependencyDocPath = Join-Path $ProjectRoot 'docs\DEPENDENCIES.md' + $script:ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:GeneratorPath = Join-Path $script:ProjectRoot 'tools\New-DLLPickleSupportDocumentation.ps1' + $script:ReadmePath = Join-Path $script:ProjectRoot 'README.md' + $script:ArchitecturePath = Join-Path $script:ProjectRoot 'docs\Architecture.md' + $script:DependencyDocPath = Join-Path $script:ProjectRoot 'docs\DEPENDENCIES.md' } Describe 'Generated support documentation' -Tag 'Unit' { @@ -31,8 +30,8 @@ Describe 'Generated support documentation' -Tag 'Unit' { } It 'separates Microsoft support, upstream evidence, and optional CI tooling claims' { - $SupportMatrix = Get-Content -LiteralPath (Join-Path $ProjectRoot 'docs\generated\Support-Matrix.md') -Raw - $Compatibility = Get-Content -LiteralPath (Join-Path $ProjectRoot 'docs\generated\Compatibility-Evidence.md') -Raw + $SupportMatrix = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'docs\generated\Support-Matrix.md') -Raw + $Compatibility = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'docs\generated\Compatibility-Evidence.md') -Raw $SupportMatrix | Should -Match 'Microsoft-supported runtime contract' $SupportMatrix | Should -Match 'multi-pwsh.*optional' $Compatibility | Should -Match 'release-gating gaps' diff --git a/tests/Unit/TfmAlignment.Tests.ps1 b/tests/Unit/TfmAlignment.Tests.ps1 index a12664eb..41ad5b2a 100644 --- a/tests/Unit/TfmAlignment.Tests.ps1 +++ b/tests/Unit/TfmAlignment.Tests.ps1 @@ -50,73 +50,82 @@ BeforeAll { $PackageDirectory } - # Builds a synthetic policy + lock file + NuGet-style packages root for the policy-driven mode. + # Builds a synthetic policy, lock file, and NuGet target graph for policy-driven mode. function Get-FixturePolicyContext { param( - [Parameter(Mandatory)] - [string[]]$AlignedLibFramework + [Parameter()] + [AllowEmptyCollection()] + [string[]]$RuntimeAssetFramework = @('net8.0'), + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$CompileAssetFramework = @(), + + [Parameter()] + [string[]]$PolicyTargetFramework = @('net8.0') ) $Context = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString('n')) - $PackagesRoot = Join-Path $Context 'packages' - $null = New-Item -Path $PackagesRoot -ItemType Directory -Force + $null = New-Item -Path $Context -ItemType Directory -Force $PolicyPath = Join-Path $Context 'policy.json' @{ preload = @( @{ packageName = 'Contoso.Fixture'; assemblyName = 'Contoso.Fixture'; classification = 'preload' } ) + runtimeProfiles = @( + foreach ($TargetFramework in $PolicyTargetFramework) { + @{ targetFramework = $TargetFramework } + } + ) } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding utf8 $LockPath = Join-Path $Context 'packages.lock.json' + $LockDependencies = [ordered]@{} + foreach ($TargetFramework in $PolicyTargetFramework) { + $LockDependencies[$TargetFramework] = @{ + 'Contoso.Fixture' = @{ type = 'Direct'; resolved = '1.2.3' } + } + } @{ version = 1 - dependencies = @{ - 'net8.0' = @{ - 'Contoso.Fixture' = @{ type = 'Direct'; resolved = '1.2.3' } - } - } + dependencies = $LockDependencies } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $LockPath -Encoding utf8 $AssetsPath = Join-Path $Context 'project.assets.json' - $HasCompatibleAsset = @($AlignedLibFramework | Where-Object { $_ -in @('net8.0', 'net6.0', 'netstandard2.0', 'netstandard2.1', 'netcoreapp3.1') }).Count -gt 0 - $TargetEntry = if ($HasCompatibleAsset) { - @{ - 'Contoso.Fixture/1.2.3' = @{ - type = 'package' - runtime = @{ 'lib/net8.0/Contoso.Fixture.dll' = @{} } - } + $Targets = [ordered]@{} + foreach ($TargetFramework in $PolicyTargetFramework) { + $RuntimeAssets = if ($TargetFramework -in $RuntimeAssetFramework) { + @{ "lib/$TargetFramework/Contoso.Fixture.dll" = @{} } + } else { + @{} } - } else { - @{ + $CompileAssets = if ($TargetFramework -in $CompileAssetFramework) { + @{ "ref/$TargetFramework/Contoso.Fixture.dll" = @{} } + } else { + @{} + } + $Targets[$TargetFramework] = @{ 'Contoso.Fixture/1.2.3' = @{ type = 'package' - runtime = @{} + runtime = $RuntimeAssets + compile = $CompileAssets } } } - @{ version = 4; targets = @{ 'net8.0' = $TargetEntry } } | + @{ version = 4; targets = $Targets } | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath $AssetsPath -Encoding utf8 - # NuGet lowercases both the package id folder and the version folder under the global cache. - $RestoredLib = Join-Path $PackagesRoot 'contoso.fixture\1.2.3\lib' - foreach ($Tfm in $AlignedLibFramework) { - $TfmDirectory = Join-Path $RestoredLib $Tfm - $null = New-Item -Path $TfmDirectory -ItemType Directory -Force - Set-Content -LiteralPath (Join-Path $TfmDirectory 'Contoso.Fixture.dll') -Value 'fixture' -Encoding utf8 - } - [PSCustomObject]@{ - PolicyPath = $PolicyPath - LockPath = $LockPath - PackagesRoot = $PackagesRoot - AssetsPath = $AssetsPath + PolicyPath = $PolicyPath + LockPath = $LockPath + AssetsPath = $AssetsPath } } } -Describe 'Test-DLLPickleTfmAlignment net8.0 compatibility decisions' -Tag 'Unit' { +Describe 'Test-DLLPickleTfmAlignment package-directory compatibility decisions' -Tag 'Unit' { It 'treats as net8.0-aligned' -ForEach @( @{ Tfm = 'net8.0' } @{ Tfm = 'net6.0' } @@ -144,6 +153,18 @@ Describe 'Test-DLLPickleTfmAlignment net8.0 compatibility decisions' -Tag 'Unit' $Result = & $script:ToolPath -PackageDirectory $Directory $Result.IsAligned | Should -BeFalse } + + It 'evaluates package assets against the selected supported target framework' -ForEach @( + @{ RuntimeTfm = 'net9.0'; AssetTfm = 'net9.0'; Expected = $true } + @{ RuntimeTfm = 'net10.0'; AssetTfm = 'net10.0'; Expected = $true } + @{ RuntimeTfm = 'net9.0'; AssetTfm = 'net10.0'; Expected = $false } + ) { + $Directory = Get-FixturePackageDirectory -LibFramework @($AssetTfm) + $Result = & $script:ToolPath -PackageDirectory $Directory -TargetFramework $RuntimeTfm + + $Result.TargetFramework | Should -BeExactly $RuntimeTfm + $Result.IsAligned | Should -Be $Expected + } } Describe 'Test-DLLPickleTfmAlignment package inspection' -Tag 'Unit' { @@ -185,7 +206,7 @@ Describe 'Test-DLLPickleTfmAlignment package inspection' -Tag 'Unit' { Describe 'Test-DLLPickleTfmAlignment policy-driven inspection' -Tag 'Unit' { It 'reports an aggregate aligned result when every preload package is aligned' { - $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net8.0', 'netstandard2.0') + $Fixture = Get-FixturePolicyContext -RuntimeAssetFramework @('net8.0') $OutputPath = Join-Path $TestDrive 'aligned-report.json' $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath -OutputPath $OutputPath @@ -198,7 +219,7 @@ Describe 'Test-DLLPickleTfmAlignment policy-driven inspection' -Tag 'Unit' { } It 'reports an aggregate misaligned result and names the offending package' { - $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net48') + $Fixture = Get-FixturePolicyContext -RuntimeAssetFramework @() $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath $Report.IsAligned | Should -BeFalse @@ -206,15 +227,42 @@ Describe 'Test-DLLPickleTfmAlignment policy-driven inspection' -Tag 'Unit' { } It 'throws in strict mode when a preload package is misaligned' { - $Fixture = Get-FixturePolicyContext -AlignedLibFramework @('net48') + $Fixture = Get-FixturePolicyContext -RuntimeAssetFramework @() { & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath -Strict } | Should -Throw '*TFM*' } It 'uses NuGet project.assets.json as the policy-mode compatibility authority' { - $Source = Get-Content -LiteralPath $script:ToolPath -Raw + $Fixture = Get-FixturePolicyContext -RuntimeAssetFramework @('net8.0') + $Assets = Get-Content -LiteralPath $Fixture.AssetsPath -Raw | ConvertFrom-Json + $Assets.targets.'net8.0' = [PSCustomObject]@{} + $Assets | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath $Fixture.AssetsPath -Encoding utf8 + + $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath + + $Report.IsAligned | Should -BeFalse + $Report.Packages[0].Reason | Should -Match "selected no 'Contoso\.Fixture/1\.2\.3' entry" + } + + It 'rejects compile-only selections because no runtime assembly can be loaded' { + $Fixture = Get-FixturePolicyContext -RuntimeAssetFramework @() -CompileAssetFramework @('net8.0') + + $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath + + $Report.IsAligned | Should -BeFalse + $Report.Packages[0].SelectedAssets | Should -BeNullOrEmpty + $Report.Packages[0].Reason | Should -Match 'compile-only' + } + + It 'validates every framework declared by a multi-framework policy' { + $Frameworks = @('net8.0', 'net9.0', 'net10.0') + $Fixture = Get-FixturePolicyContext -PolicyTargetFramework $Frameworks -RuntimeAssetFramework @('net8.0') + + $Report = & $script:ToolPath -PolicyPath $Fixture.PolicyPath -LockFilePath $Fixture.LockPath -ProjectAssetsPath $Fixture.AssetsPath - $Source | Should -Match 'ProjectAssetsPath' - $Source | Should -Match ([regex]::Escape('$ProjectAssets.targets')) + @($Report.Packages) | Should -HaveCount 3 + ($Report.Packages | Where-Object TargetFramework -EQ 'net8.0').IsAligned | Should -BeTrue + ($Report.Packages | Where-Object TargetFramework -EQ 'net9.0').IsAligned | Should -BeFalse + ($Report.Packages | Where-Object TargetFramework -EQ 'net10.0').IsAligned | Should -BeFalse } } diff --git a/tests/Unit/UpstreamInventoryProfile.Tests.ps1 b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 index 9fe1603b..aba1ffe0 100644 --- a/tests/Unit/UpstreamInventoryProfile.Tests.ps1 +++ b/tests/Unit/UpstreamInventoryProfile.Tests.ps1 @@ -30,6 +30,10 @@ BeforeAll { $moduleVersionRoot = Join-Path -Path $moduleCache -ChildPath 'Contoso.ProfileProbe/1.0.0' $null = New-Item -Path $moduleVersionRoot -ItemType Directory -Force Set-Content -LiteralPath (Join-Path $moduleVersionRoot 'Contoso.ProfileProbe.psm1') -Value '# profile probe fixture' -Encoding utf8 + $decoyDirectory = Join-Path -Path $moduleVersionRoot -ChildPath 'lib/decoy' + $null = New-Item -Path $decoyDirectory -ItemType Directory -Force + $decoyAssemblyPath = Join-Path -Path $decoyDirectory -ChildPath 'System.Management.Automation.dll' + Copy-Item -LiteralPath ([System.Management.Automation.PSObject].Assembly.Location) -Destination $decoyAssemblyPath @' @{ RootModule = 'Contoso.ProfileProbe.psm1' @@ -58,6 +62,7 @@ BeforeAll { PolicyPath = $policyPath ModuleCachePath = $moduleCache OutputPath = Join-Path -Path $root -ChildPath 'inventory.json' + DecoyAssemblyPath = $decoyAssemblyPath } } } @@ -97,12 +102,8 @@ Describe 'Profile-aware upstream inventory' -Tag 'Unit' { $row.Sha256 | Should -Match '^[a-f0-9]{64}$' $row.Alc | Should -Not -BeNullOrEmpty $row.TargetFramework | Should -Be $report.Profile.TargetFramework - } - - It 'does not recursively mix every DLL asset in a saved module' { - $source = Get-Content -LiteralPath $script:InventoryToolPath -Raw - - $source | Should -Not -Match "Get-ChildItem[^\r\n]+-Filter '\*\.dll'[^\r\n]+-Recurse" - $source | Should -Match ([regex]::Escape('Get-DLLPickleRuntimeAssemblySnapshot.ps1')) + @($module.TrackedAssemblies) | Should -HaveCount 1 + $row.SelectedAssetPath | Should -Not -BeExactly $fixture.DecoyAssemblyPath + @($module.TrackedAssemblies.SelectedAssetPath) | Should -Not -Contain $fixture.DecoyAssemblyPath } } diff --git a/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 index 7a501562..b21853fa 100644 --- a/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 +++ b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 @@ -73,6 +73,18 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { @($First.Scenarios) | Should -HaveCount 4 @($First.Scenarios | Where-Object DllPicklePreloaded) | Should -HaveCount 2 @($First.Scenarios | Where-Object { -not $_.DllPicklePreloaded }) | Should -HaveCount 2 + $ExpectedOrders = @( + 'Synthetic.One,Synthetic.Two' + 'Synthetic.Two,Synthetic.One' + ) + $ActualOrders = @($First.Scenarios | ForEach-Object { @($_.ImportOrder) -join ',' } | Sort-Object -Unique) + $ActualOrders | Should -Be $ExpectedOrders + foreach ($ExpectedOrder in $ExpectedOrders) { + $OrderScenarios = @($First.Scenarios | Where-Object { (@($_.ImportOrder) -join ',') -eq $ExpectedOrder }) + $OrderScenarios | Should -HaveCount 2 + @($OrderScenarios.DllPicklePreloaded | Sort-Object -Unique) | Should -Be @($false, $true) + @($OrderScenarios.OutcomeMatchesExpectation | Select-Object -Unique) | Should -Be @($true) + } $First.Passed | Should -BeTrue $First.WritesPerformed | Should -BeFalse $First.ScenarioFingerprint | Should -BeExactly $Second.ScenarioFingerprint diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index 7f6c6701..e83a15c4 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -113,7 +113,9 @@ Describe 'Release publish gating guardrails' -Tag 'Unit' { $ReleaseWorkflow | Should -Match ([regex]::Escape('Authenticated-Compatibility.yml')) $ReleaseWorkflow | Should -Match ([regex]::Escape('authenticated-compatibility-evidence')) $ReleaseWorkflow | Should -Match ([regex]::Escape("'--commit', `$EvidenceSha")) - $ReleaseWorkflow | Should -Match ([regex]::Escape('github.event.pull_request.head.sha')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('steps.release-candidate.outputs.release_sha')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('needs.authenticated-release-gate.outputs.release_sha')) + $ReleaseWorkflow | Should -Not -Match ([regex]::Escape('github.event.pull_request.head.sha')) $ReleaseWorkflow | Should -Match ([regex]::Escape('requiredBeforeRelease')) $ReleaseWorkflow | Should -Match ([regex]::Escape('writesAllowed -ne $false')) } @@ -260,12 +262,20 @@ Describe 'Exact PowerShell runtime matrix workflow guardrails' -Tag 'Unit' { It 'keeps Build gate stable and aggregates both hosted and exact-runtime jobs' { $BuildWorkflow | Should -Match '(?m)^\s+name: Build gate\s*$' - $BuildWorkflow | Should -Match 'needs: \[build, runtime-tests, dependency-change-report\]' + $BuildWorkflow | Should -Match 'needs: \[changes, runtime-matrix, build, runtime-tests, dependency-change-report\]' + $BuildWorkflow | Should -Match ([regex]::Escape("foreach (`$RequiredJob in @('runtimeMatrix', 'build', 'runtimeTests'))")) + $BuildWorkflow | Should -Match ([regex]::Escape('needs.runtime-matrix.result')) $BuildWorkflow | Should -Match ([regex]::Escape('runtimeTests =')) $BuildWorkflow | Should -Match ([regex]::Escape('needs.runtime-tests.result')) $BuildWorkflow | Should -Match ([regex]::Escape('needs.dependency-change-report.result')) } + It 'requires successful profile-matrix generation whenever live upstream evidence is required' { + $UpstreamWorkflow | Should -Match 'needs: \[pr-changes, profile-matrix, profile-evidence\]' + $UpstreamWorkflow | Should -Match ([regex]::Escape("if (`$MatrixResult -ne 'success')")) + $UpstreamWorkflow | Should -Match ([regex]::Escape('Required exact profile matrix generation did not succeed')) + } + It 'enforces artifact composition and material size growth in the hosted build gate' { $BuildWorkflow | Should -Match ([regex]::Escape('Test-DLLPicklePackageArtifact.ps1')) $BuildWorkflow | Should -Match ([regex]::Escape('New-DLLPickleArtifactSizeReport.ps1')) From c241d40cc04f6d748cd632b3bd3220d41dd94667 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:01:09 -0400 Subject: [PATCH 16/43] docs: clarify validation evidence policy --- build/artifact-size-baseline.json | 1 + docs/gaps/GAP-003-exo-teams-probe-commands.md | 4 ++-- ...2026-08-09-credentialed-authentication-test-environment.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/build/artifact-size-baseline.json b/build/artifact-size-baseline.json index 0fd5fb87..e1a4b47e 100644 --- a/build/artifact-size-baseline.json +++ b/build/artifact-size-baseline.json @@ -4,6 +4,7 @@ "capturedAtUtc": "2026-08-09T03:43:28Z", "approvedAtUtc": "2026-08-09T17:36:05Z", "thresholds": { + "policy": "Allowed unpacked growth is the larger of maximumIncreasePercent of the baseline or maximumIncreaseBytes; the limits are not cumulative.", "maximumIncreasePercent": 10, "maximumIncreaseBytes": 2097152 }, diff --git a/docs/gaps/GAP-003-exo-teams-probe-commands.md b/docs/gaps/GAP-003-exo-teams-probe-commands.md index 6725502c..be49ae2d 100644 --- a/docs/gaps/GAP-003-exo-teams-probe-commands.md +++ b/docs/gaps/GAP-003-exo-teams-probe-commands.md @@ -36,8 +36,8 @@ DLLPickle's preload/block classification depends on observed runtime ownership, ## Current evidence -- `build/dependency-policy.json` assigns `Get-ConnectionInformation -ErrorAction SilentlyContinue | Out-Null` and `Get-Command Get-Team | Out-Null` to the deterministic no-auth tier. -- It separately records `Get-EXOMailbox -ResultSize 1 | Out-Null` and `Get-CsTenant | Out-Null` as authenticated read-only release gates. +- `build/dependency-policy.json` assigns `Get-ConnectionInformation -ErrorAction SilentlyContinue | Out-Null` and a read-only `Get-Team` execution with errors suppressed to the deterministic no-auth tier. The Teams command executes the cmdlet surface without claiming an authenticated tenant read. +- It separately records `Get-EXOMailbox -ResultSize 1 | Out-Null` and an access-token-based `Connect-MicrosoftTeams` / `Get-CsTenant` / `Disconnect-MicrosoftTeams` sequence as authenticated read-only release gates. - `Get-DLLPickleUpstreamInventory.ps1` passes the profile-specific deterministic command into the exact stock-host snapshot process. - Unit tests validate policy parsing, probe separation, exact-host inventory, and selected-asset evidence without service authentication. diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index 38afbcba..f25bdc9d 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -216,7 +216,7 @@ Redact access tokens, authorization headers, certificate bytes/passwords, mailbo The feasibility run is not accepted without evidence that the identity is constrained: -- Azure: an approved harmless write attempt using `-WhatIf` where supported, plus an authorization inspection showing no write actions at the assigned scope. Do not perform a real write merely to prove denial. +- Azure: inspect effective roles and actions at the assigned scope and use any available non-mutating authorization query. `-WhatIf` may remain a local no-mutation safeguard, but it is not authorization evidence and does not prove that the identity lacks write permission. Do not perform a real write merely to prove denial. - Graph: inspect granted application permissions and verify no write permission is consented. - Exchange: inspect the assigned application RBAC role and confirm it contains only required read cmdlets/parameters. - Teams: inspect API permissions and assigned role; do not execute a state-changing Teams cmdlet. From f7c98d1ad33ed0082b32dbfa20a9e047c8166b32 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:20 -0400 Subject: [PATCH 17/43] fix(ci): stabilize profile evidence generation --- .github/workflows/Upstream-Compatibility.yml | 7 +++- tests/Unit/DependencyAutomation.Tests.ps1 | 37 +++++++++++++++++++ tests/Unit/UpstreamScenarioEvidence.Tests.ps1 | 21 +++++++++-- tests/Unit/WorkflowGuardrails.Tests.ps1 | 3 ++ tools/Get-DLLPickleUpstreamInventory.ps1 | 14 ++++--- .../New-DLLPickleUpstreamScenarioEvidence.ps1 | 2 +- 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.github/workflows/Upstream-Compatibility.yml b/.github/workflows/Upstream-Compatibility.yml index 47effcda..46d8534f 100644 --- a/.github/workflows/Upstream-Compatibility.yml +++ b/.github/workflows/Upstream-Compatibility.yml @@ -56,7 +56,7 @@ jobs: $Files = gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[] | .filename, (.previous_filename // empty)' # Native non-zero exits don't reliably throw in pwsh, so check explicitly -> fail-safe. if ($LASTEXITCODE -ne 0) { throw "gh api exited $LASTEXITCODE while listing PR files" } - $Patterns = @('^build/', '^src/DLLPickle\.Build/', '^tests/', '^tools/', '^\.github/workflows/Upstream-Compatibility\.yml$') + $Patterns = @('^build/', '^src/DLLPickle\.Build/', '^src/DLLPickle/', '^tests/', '^tools/', '^\.github/workflows/Upstream-Compatibility\.yml$') # Any change that can alter the accepted baseline or the fingerprint computation must # prove itself against a fresh upstream inventory. Comparator/tests/workflow-only changes # stay on the deterministic smoke path so a pre-existing external drift does not prevent @@ -67,10 +67,13 @@ jobs: '^src/DLLPickle\.Build/packages\.lock\.json$', '^build/powershell-test-matrix\.json$', '^tools/Get-DLLPickleUpstreamInventory\.ps1$', + '^tools/Get-DLLPickleLoadedTrackedAssembly\.ps1$', '^tools/Get-DLLPickleRuntimeAssemblySnapshot\.ps1$', '^tools/New-DLLPickleConflictMatrix\.ps1$', + '^tools/New-DLLPickleUpstreamScenarioEvidence\.ps1$', '^tools/Test-DLLPickleProfileConflictBaseline\.ps1$', - '^tools/Install-DLLPickleTestPowerShell\.ps1$' + '^tools/Install-DLLPickleTestPowerShell\.ps1$', + '^src/DLLPickle/' ) $Relevant = $false $LiveValidation = $false diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index 80417f64..3b9eceee 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -141,6 +141,43 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $Result.Modules[0].ManifestPowerShellVersion | Should -Be '7.0' } + It 'resolves saved RequiredModules before validating a monitored manifest' { + $Assembly = [System.String].Assembly + $AssemblyName = $Assembly.GetName().Name + $ModuleCachePath = Join-Path $TestDrive 'required-module-cache' + $RequiredRoot = Join-Path $ModuleCachePath 'Synthetic.Required\1.0.0' + $ParentRoot = Join-Path $ModuleCachePath 'Synthetic.Parent\1.0.0' + $null = New-Item -Path $RequiredRoot -ItemType Directory -Force + $null = New-Item -Path $ParentRoot -ItemType Directory -Force + + Set-Content -LiteralPath (Join-Path $RequiredRoot 'Synthetic.Required.psm1') -Value "function Get-SyntheticRequired { 'required' }" -Encoding UTF8 + New-ModuleManifest -Path (Join-Path $RequiredRoot 'Synthetic.Required.psd1') -RootModule 'Synthetic.Required.psm1' -ModuleVersion '1.0.0' -FunctionsToExport @('Get-SyntheticRequired') + Copy-Item -LiteralPath $Assembly.Location -Destination (Join-Path $ParentRoot "$AssemblyName.dll") + Set-Content -LiteralPath (Join-Path $ParentRoot 'Synthetic.Parent.psm1') -Value 'function Get-SyntheticParent { Get-SyntheticRequired }' -Encoding UTF8 + New-ModuleManifest -Path (Join-Path $ParentRoot 'Synthetic.Parent.psd1') -RootModule 'Synthetic.Parent.psm1' -ModuleVersion '1.0.0' -RequiredModules @('Synthetic.Required') -FunctionsToExport @('Get-SyntheticParent') + + $PolicyPath = Join-Path $TestDrive 'required-module-policy.json' + $TestMatrixPath = Write-DependencyAutomationRuntimeMatrixFixture -Path (Join-Path $TestDrive 'required-module-runtime-matrix.json') + @{ + monitoredModules = @( + @{ + name = 'Synthetic.Parent' + repository = 'PSGallery' + purpose = 'Required-module path regression.' + deterministicProbeCommand = 'Get-SyntheticParent | Out-Null' + } + ) + trackedAssemblies = @($AssemblyName) + preload = @() + blockedPreloadAssemblies = @() + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + + $Result = & $script:InventoryScriptPath -PolicyPath $PolicyPath -TestMatrixPath $TestMatrixPath -ModuleCachePath $ModuleCachePath -SkipDownload -OutputPath (Join-Path $TestDrive 'required-module-inventory.json') + + $Result.Modules | Should -HaveCount 1 + $Result.Modules[0].Name | Should -BeExactly 'Synthetic.Parent' + } + It 'updates exact package pins from upstream inventory and reports blocked preload findings' { $ProjectPath = Join-Path -Path $TestDrive -ChildPath 'DLLPickle.csproj' @' diff --git a/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 index b21853fa..dce39efd 100644 --- a/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 +++ b/tests/Unit/UpstreamScenarioEvidence.Tests.ps1 @@ -7,7 +7,7 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { It 'runs every configured order with and without DLLPickle under exact manifests' { $ModuleCache = Join-Path $TestDrive 'modules' $ModuleRows = @( - foreach ($Name in @('Synthetic.One', 'Synthetic.Two')) { + foreach ($Name in @('Synthetic.One', 'Synthetic.Two', 'Synthetic.Failure')) { $ModuleRoot = Join-Path $ModuleCache "$Name\1.0.0" $null = New-Item -Path $ModuleRoot -ItemType Directory -Force Set-Content -LiteralPath (Join-Path $ModuleRoot "$Name.psm1") -Value "function Get-$($Name.Replace('.', '')) { 'ok' }" -Encoding UTF8 @@ -31,6 +31,7 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { monitoredModules = @( @{ name = 'Synthetic.One'; deterministicProbeCommand = 'Get-Command Get-SyntheticOne | Out-Null' } @{ name = 'Synthetic.Two'; deterministicProbeCommand = 'Get-Command Get-SyntheticTwo | Out-Null' } + @{ name = 'Synthetic.Failure'; deterministicProbeCommand = 'throw "expected synthetic failure"' } ) runtimeProfiles = @( @{ @@ -40,9 +41,18 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { , @('Synthetic.One', 'Synthetic.Two') , @('Synthetic.Two', 'Synthetic.One') ) + knownConflictIds = @('synthetic-expected-failure') } ) } | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + $KnownConflictsPath = Join-Path $TestDrive 'known-conflicts.json' + @( + @{ + id = 'synthetic-expected-failure' + importOrders = @(, @('Synthetic.Failure')) + requiresProcessIsolation = $true + } + ) | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $KnownConflictsPath -Encoding UTF8 $InventoryPath = Join-Path $TestDrive 'inventory.json' @{ ProfileKey = $ProfileKey @@ -63,6 +73,7 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { InventoryPath = $InventoryPath PowerShellExecutable = [Environment]::ProcessPath DLLPickleManifestPath = $DllPickleManifest + KnownConflictsPath = $KnownConflictsPath OutputPath = Join-Path $TestDrive 'scenario-evidence.json' Strict = $true } @@ -70,10 +81,11 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { $Parameters.OutputPath = Join-Path $TestDrive 'scenario-evidence-second.json' $Second = & $script:ToolPath @Parameters - @($First.Scenarios) | Should -HaveCount 4 - @($First.Scenarios | Where-Object DllPicklePreloaded) | Should -HaveCount 2 - @($First.Scenarios | Where-Object { -not $_.DllPicklePreloaded }) | Should -HaveCount 2 + @($First.Scenarios) | Should -HaveCount 6 + @($First.Scenarios | Where-Object DllPicklePreloaded) | Should -HaveCount 3 + @($First.Scenarios | Where-Object { -not $_.DllPicklePreloaded }) | Should -HaveCount 3 $ExpectedOrders = @( + 'Synthetic.Failure' 'Synthetic.One,Synthetic.Two' 'Synthetic.Two,Synthetic.One' ) @@ -87,6 +99,7 @@ Describe 'Deterministic upstream import-order evidence' -Tag 'Unit' { } $First.Passed | Should -BeTrue $First.WritesPerformed | Should -BeFalse + @($First.Scenarios | Where-Object ScenarioId -EQ 'synthetic-expected-failure' | Select-Object -ExpandProperty OutcomeMatchesExpectation -Unique) | Should -Be @($true) $First.ScenarioFingerprint | Should -BeExactly $Second.ScenarioFingerprint } } diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index e83a15c4..113ada8c 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -21,7 +21,10 @@ Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { $UpstreamWorkflow | Should -Match 'live_validation' $UpstreamWorkflow | Should -Match ([regex]::Escape('build/dependency-policy.json')) $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Get-DLLPickleUpstreamInventory.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/Get-DLLPickleLoadedTrackedAssembly\.ps1$'")) $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleConflictMatrix.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleUpstreamScenarioEvidence.ps1')) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^src/DLLPickle/'")) } It 'uploads compact JSON evidence and writes a job summary' { diff --git a/tools/Get-DLLPickleUpstreamInventory.ps1 b/tools/Get-DLLPickleUpstreamInventory.ps1 index 34a3577d..1f33987b 100644 --- a/tools/Get-DLLPickleUpstreamInventory.ps1 +++ b/tools/Get-DLLPickleUpstreamInventory.ps1 @@ -241,16 +241,18 @@ $ModuleResults = foreach ($PolicyModule in $PolicyModules) { if (-not $ModuleManifestPath) { throw "Module manifest '$Name.psd1' was not found under '$($SavedModule.FullName)'." } - # Real gallery manifests can contain module-manifest expressions such as a - # PSEdition-dependent RootModule. Import-PowerShellDataFile deliberately rejects - # those expressions; Test-ModuleManifest evaluates the constrained manifest grammar - # and returns the compatibility metadata PowerShell itself uses. - $Manifest = Test-ModuleManifest -Path $ModuleManifestPath.FullName -ErrorAction Stop - $OriginalPSModulePath = $env:PSModulePath + $Manifest = $null try { $SystemModulePath = Join-Path -Path $RuntimeIdentity.psHome -ChildPath 'Modules' $env:PSModulePath = @($ModuleCachePath, $SystemModulePath) -join [System.IO.Path]::PathSeparator + # Real gallery manifests can contain module-manifest expressions such as a + # PSEdition-dependent RootModule. Import-PowerShellDataFile deliberately rejects + # those expressions; Test-ModuleManifest evaluates the constrained manifest grammar + # and returns the compatibility metadata PowerShell itself uses. Validate only after + # isolating PSModulePath so RequiredModules saved beside the monitored module resolve. + $Manifest = Test-ModuleManifest -Path $ModuleManifestPath.FullName -ErrorAction Stop + $SnapshotParameters = @{ ModuleName = @($Name) ModuleManifestPath = @($ModuleManifestPath.FullName) diff --git a/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 index 65370ba3..fe4e7bac 100644 --- a/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 +++ b/tools/New-DLLPickleUpstreamScenarioEvidence.ps1 @@ -180,7 +180,7 @@ $CanonicalRows = @( $Scenario.OutcomeMatchesExpectation, (@($Scenario.ImportOrder) -join ','), (@($Scenario.Assemblies | Sort-Object Name,Path | ForEach-Object { '{0},{1},{2},{3},{4}' -f $_.Name, $_.Version, $_.Sha256, $_.Path, $_.Alc }) -join ';'), - (([string]$Scenario.Error) -replace '\s+', ' ').Trim() + (([string]$Scenario.Error) -replace '(?i)dpp-snap-[0-9a-f]{32}\.ps1', 'dpp-snap-.ps1' -replace '\s+', ' ').Trim() ) } ) From bc9e7a3c55e812f425f021d552558561620cddac Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:35 -0400 Subject: [PATCH 18/43] fix: ship cross-platform ProtectedData support --- build/dependency-policy.json | 5 ++-- docs/Architecture.md | 8 ++--- ...001-dependency-policy-realization-guard.md | 4 +-- src/DLLPickle.Build/DLLPickle.csproj | 13 ++++----- .../Private/Get-DPRuntimeProfile.ps1 | 15 +++++++++- src/DLLPickle/Public/Import-DPLibrary.ps1 | 23 ++++++++++++--- src/DLLPickle/SupportedRuntimeProfiles.json | 27 +++++++++++++++-- .../DLLPickle.IntegrationTest.Tests.ps1 | 18 ++++++++++++ .../DependencyPolicyRealization.Tests.ps1 | 29 +++++++++++++++++-- tests/Unit/DependencyPolicy.Tests.ps1 | 2 ++ tests/Unit/RuntimeProfilePolicy.Tests.ps1 | 10 +++++++ tests/Unit/RuntimeProfileSelection.Tests.ps1 | 12 ++++++++ 12 files changed, 141 insertions(+), 25 deletions(-) diff --git a/build/dependency-policy.json b/build/dependency-policy.json index a0512434..1efb4fba 100644 --- a/build/dependency-policy.json +++ b/build/dependency-policy.json @@ -686,14 +686,15 @@ "platforms": [ "Windows" ], + "universalArtifactRequired": true, "sourceModules": [ "PowerShell" ], "updateMode": "reportOnly", - "reason": "Runtime-provided BCL assembly supplied by PowerShell on Windows; does not require bundling. On non-Windows platforms, PowerShell does not provide this assembly, so it is allowed to be bundled transitively via Microsoft.Identity.Client.Extensions.Msal without creating a conflict.", + "reason": "Runtime-provided BCL assembly supplied by PowerShell on Windows, so Import-DPLibrary must not preload the bundled copy there. PowerShell does not provide it on Linux or macOS, so the single platform-neutral release artifact must include it for the MSAL extensions dependency chain.", "evidence": { "alcOwner": "PowerShell host / Default (Windows)", - "basis": "Windows build validation on 2026-06-23 found that the MSAL extensions dependency copied NuGet System.Security.Cryptography.ProtectedData 4.5.0 into the bundle while PowerShell already supplied the runtime assembly. A direct ExcludeAssets=runtime reference keeps that host-provided assembly out of DLLPickle's preload set. On non-Windows platforms, the runtime assets are retained to satisfy the dependency chain.", + "basis": "Windows validation on 2026-06-23 established that PowerShell supplies this assembly. The release pipeline publishes one module artifact assembled on Windows, so excluding the runtime asset at build time also removed it for Linux and macOS. The universal artifact now retains the file, while the shipped runtime profile policy filters it from Windows preloading and permits it on non-Windows hosts.", "decidedOn": "2026-06-23" }, "targetFrameworks": [ diff --git a/docs/Architecture.md b/docs/Architecture.md index d17830aa..5f152e16 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -56,7 +56,7 @@ Every tracked assembly is classified into exactly one of: | Class | Meaning | What earns it | | --- | --- | --- | | **preload** | Bundled in DLLPickle; loaded early into the default ALC | Shared by ≥2 default-ALC consumers at diverging versions, **and** preloading one coherent version is observed to help without breaking a self-isolating module. | -| **block** | Never bundled | A self-isolating module owns it in a private ALC **and** preloading it is observed to break a scenario; or it is harmful to preload (OData/#174). | +| **block** | Never preloaded on the policy scope; normally excluded from the bundle | A self-isolating module owns it in a private ALC and preloading it breaks a scenario, or the host already provides it. A platform-scoped runtime dependency may remain in the single universal artifact for other operating systems, but the loader must filter it on the blocked platform. | | **ignore** | No action | No cross-module divergence, not harmful. | **Static narrows, runtime decides.** ALC ownership (from the runtime probe) flags *candidates*; the with/without runtime differential makes the *call*. This distinction is load-bearing: `Microsoft.Identity.Client.Extensions.Msal` is owned by Az's private ALC (a block *candidate*), yet preloading the MSAL/IdentityModel stack is **proven safe and beneficial** (2.0.1 four-module connect), so it is `preload`. Only the Azure SDK stack is `block`. @@ -76,7 +76,7 @@ Every tracked assembly is classified into exactly one of: | --- | --- | --- | | Module source | `src/DLLPickle/` | The shipped PowerShell module (public/private functions, manifest). | | Runtime policy and loader | `src/DLLPickle/SupportedRuntimeProfiles.json`, `src/DLLPickle/Private/Get-DPRuntimeProfile.ps1`, `src/DLLPickle/Public/Import-DPLibrary.ps1` | Maps the running PowerShell/CLR pair to one exact TFM and loads that isolated bundle with dependency-ordered, retrying loads. | -| Build project | `src/DLLPickle.Build/DLLPickle.csproj` | **Realizes** the policy — preload packages are direct runtime references; blocked transitives that need suppression are direct references with `ExcludeAssets="runtime"`. `packages.lock.json` pins resolved versions. | +| Build project | `src/DLLPickle.Build/DLLPickle.csproj` | **Realizes** the policy — preload packages are direct runtime references; blocked transitives that must never ship use `ExcludeAssets="runtime"`; platform-scoped host assemblies needed elsewhere remain in the universal payload and are filtered by `SupportedRuntimeProfiles.json`. `packages.lock.json` pins resolved versions. | | Dependency policy | `build/dependency-policy.json` | **Decision source of truth** — per-profile and per-platform classifications, monitored modules, import orders, validation tiers, and conflict baselines. | | Analysis tools | `tools/Get-DLLPickleLoadedTrackedAssembly.ps1`, `New-DLLPickleConflictMatrix.ps1`, `Compare-DLLPickleConflictMatrix.ps1`, `Get-DLLPickleRuntimeAssemblySnapshot.ps1`, `Get-DLLPickleUpstreamInventory.ps1`, `Update-DLLPickleDependencyPins.ps1` | Inventory upstream modules, build the conflict matrix, probe runtime ALC ownership (filter sourced from `trackedAssemblies`), detect drift, and apply policy pins. | | Build script | `build/DLLPickle.Build.ps1` | Invoke-Build tasks: Analyze, AnalyzeTests, AnalyzeTools, Test, RestoreDependencies, PrepareModuleOutput, IntegrationTest. | @@ -107,7 +107,7 @@ Every tracked assembly is classified into exactly one of: - **No `block` assembly appears in any supported TFM payload.** → policy-driven `tests/Integration/DependencyPolicyRealization.Tests.ps1` plus scenario-specific guards in `tests/Integration/DLLPickle.IntegrationTest.Tests.ps1`. - **No preloaded assembly is loaded into two ALCs at once** in the four-module scenario. → integration ALC-split guard (runtime tier; maintainer-run with real modules). - **The bundled `preload` set equals `dependency-policy.json`'s `preload` entries.** → `tests/Integration/DependencyPolicyRealization.Tests.ps1`. -- **Runtime-provided BCL assemblies are never preloaded.** Some assemblies are platform-scoped: `System.Security.Cryptography.ProtectedData` is provided by PowerShell only on Windows; on Linux/macOS it is bundled (not excluded). See `build/dependency-policy.json` for platform-specific block scopes. → policy block classification + `tests/Integration/DependencyPolicyRealization.Tests.ps1` with platform-aware filtering. +- **Runtime-provided BCL assemblies are never preloaded.** `System.Security.Cryptography.ProtectedData` is provided by PowerShell only on Windows, but the published module is one cross-platform artifact assembled on Windows. It is therefore bundled for Linux/macOS, while `SupportedRuntimeProfiles.json` makes the Windows loader skip the bundled copy. See `build/dependency-policy.json` for the platform-specific block scope. → policy realization + exact-runtime integration coverage. - **Analysis tooling is correct.** → `tests/Unit/ConflictMatrix.Tests.ps1`, `tests/Unit/ConflictMatrixDrift.Tests.ps1`. - **Only the three declared TFM directories ship, and multi-pwsh never ships.** → `tools/Test-DLLPicklePackageArtifact.ps1` + `tests/Unit/ArtifactPolicy.Tests.ps1`. - **Material artifact growth requires review.** → `tools/New-DLLPickleArtifactSizeReport.ps1` against `build/artifact-size-baseline.json`. A captured baseline also fails strict validation until a maintainer sets `approvalStatus` to `accepted` with an approval timestamp; capture time is not approval. @@ -198,7 +198,7 @@ When changing the preload contract, follow this loop: 2. **Build and compare the conflict matrix in every exact runtime profile** (`New-DLLPickleConflictMatrix.ps1`, then `Compare-DLLPickleConflictMatrix.ps1`) to find candidates and structured new/removed conflict, version, contributor, and ALC-owner changes. The matrix emits a profile-keyed `Fingerprint`; `Test-DLLPickleProfileConflictBaseline.ps1` compares it only with the matching PowerShell line, TFM, platform, module-set, and import-order baseline under `runtimeProfiles[].baselines`. Missing, unaccepted, or changed profile evidence fails closed. The top-level `baseline` and each `legacyUnscopedBaseline` are retained only as historical #239/#273 evidence and are not authoritative for a supported profile. 3. **Probe runtime ALC ownership** (`Get-DLLPickleRuntimeAssemblySnapshot.ps1`) — private-ALC ownership is a `block` *candidate*, not an automatic verdict. 4. **Adjudicate** with the runtime differential (does preloading help without breaking?). Record the verdict + evidence in `build/dependency-policy.json`. -5. **Realize** in `DLLPickle.csproj` (preload = bundled reference; block = excluded, incl. `ExcludeAssets` for blocked transitives), regenerate `packages.lock.json`. +5. **Realize** in `DLLPickle.csproj` (preload = bundled reference; ordinary block = excluded; a platform-scoped block needed by another OS remains bundled and is filtered by the shipped runtime policy), regenerate `packages.lock.json`. 6. **Validate** (non-auth gates always; auth tier for any bundled-set change). 7. **Update this blueprint** if the contract or invariants changed. 8. **Update the gap register** if the work opens, advances, resolves, blocks, supersedes, or intentionally accepts a tracked gap. diff --git a/docs/gaps/GAP-001-dependency-policy-realization-guard.md b/docs/gaps/GAP-001-dependency-policy-realization-guard.md index 723f6d5b..8dfeb65b 100644 --- a/docs/gaps/GAP-001-dependency-policy-realization-guard.md +++ b/docs/gaps/GAP-001-dependency-policy-realization-guard.md @@ -50,9 +50,9 @@ The repository has an automated integration test that compares the policy, proje - [x] A test exists that asserts every `preload` package is represented in `DLLPickle.csproj`. - [x] A test exists that asserts preload package references do not exclude runtime assets. -- [x] A test exists that asserts blocked package references exclude runtime assets when they are present in `DLLPickle.csproj`. +- [x] A test exists that asserts ordinary blocked package references exclude runtime assets, while explicitly universal platform-scoped dependencies retain theirs. - [x] A test exists that asserts every preload assembly appears in the built `bin/net8.0` output. -- [x] A test exists that asserts blocked assemblies do not appear in the built `bin/net8.0` output. +- [x] A test exists that asserts ordinary blocked assemblies do not appear in built TFM outputs and that universal platform-scoped dependencies are bundled but skipped on their host-provided platform. - [x] A test exists that asserts the built `bin/net8.0` output does not contain unclassified managed assemblies. - [x] PR #257 has merged. - [x] The gap frontmatter is updated to `status: resolved` after merge. diff --git a/src/DLLPickle.Build/DLLPickle.csproj b/src/DLLPickle.Build/DLLPickle.csproj index 16662559..a8e04a34 100644 --- a/src/DLLPickle.Build/DLLPickle.csproj +++ b/src/DLLPickle.Build/DLLPickle.csproj @@ -34,13 +34,12 @@ - - - + + " } + It 'does not treat a published preview with a future lifecycle start as a supported GA line' { + $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.9', '7.6.4', '7.7.0') + $Report = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'future-line.json') -AsOfUtc '2026-08-08T00:00:00Z' + + @($Report.NewLines) | Should -HaveCount 0 + @($Report.UndeclaredSupportedLines) | Should -HaveCount 0 + $Report.SupportContractReviewRequired | Should -BeFalse + } + It 'keeps a retirement-warning fingerprint stable while the remaining day count changes' { $Fixture = Get-SupportUpdateFixture -Root $TestDrive -ReleaseVersions @('7.4.18', '7.5.9', '7.6.4') $First = & $script:DiscoveryPath -TestMatrixPath $Fixture.MatrixPath -ReleaseDataPath $Fixture.ReleasePath -LifecycleDataPath $Fixture.LifecyclePath -OutputPath (Join-Path $TestDrive 'retirement-first.json') -AsOfUtc '2026-08-15T00:00:00Z' diff --git a/tests/Unit/ProfileConflictBaseline.Tests.ps1 b/tests/Unit/ProfileConflictBaseline.Tests.ps1 index 38ef774b..b6983c64 100644 --- a/tests/Unit/ProfileConflictBaseline.Tests.ps1 +++ b/tests/Unit/ProfileConflictBaseline.Tests.ps1 @@ -166,12 +166,29 @@ Describe 'Profile-specific conflict baseline enforcement' -Tag 'Unit' { It 'rejects a tampered committed snapshot even when the candidate is unchanged' { $fixture = Get-ProfileBaselineFixture + $OutputPath = Join-Path $TestDrive 'invalid-committed-comparison.json' $Committed = Get-Content -LiteralPath $fixture.CommittedEvidencePath -Raw | ConvertFrom-Json $Committed.content.marker = 'tampered-committed-evidence' $Committed | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $fixture.CommittedEvidencePath -Encoding UTF8 - { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath -NormalizedEvidencePath $fixture.NormalizedPath } | + { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath -NormalizedEvidencePath $fixture.NormalizedPath -OutputPath $OutputPath } | Should -Throw '*accepted evidence*does not recompute*policy fingerprint*' + $Result = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + $Result.Status | Should -Be 'InvalidCommittedEvidence' + $Result.FailureDetail | Should -Match 'does not recompute to the policy fingerprint' + $Result.FindingFingerprint | Should -Match '^[a-f0-9]{64}$' + } + + It 'writes a structured result before a missing accepted snapshot fails closed' { + $fixture = Get-ProfileBaselineFixture + $OutputPath = Join-Path $TestDrive 'missing-committed-comparison.json' + Remove-Item -LiteralPath $fixture.CommittedEvidencePath + + { & $script:ToolPath -PolicyPath $fixture.PolicyPath -ConflictMatrixPath $fixture.MatrixPath -ScenarioEvidencePath $fixture.ScenarioPath -NormalizedEvidencePath $fixture.NormalizedPath -OutputPath $OutputPath } | + Should -Throw '*accepted evidence*was not found*' + $Result = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + $Result.Status | Should -Be 'InvalidCommittedEvidence' + $Result.FailureDetail | Should -Match 'was not found' } It 'rejects a conflict matrix whose key does not match its profile fields' { diff --git a/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 b/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 new file mode 100644 index 00000000..846b4e71 --- /dev/null +++ b/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 @@ -0,0 +1,42 @@ +BeforeAll { + $RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + . (Join-Path $RepositoryRoot 'tools\DLLPickle.ProfileEvidence.ps1') +} + +Describe 'Profile evidence helpers' -Tag 'Unit' { + It 'recomputes the canonical UTF-8 SHA-256 content fingerprint' { + $Evidence = [pscustomobject]@{ + schemaVersion = 1 + content = [ordered]@{ + profile = 'ps7.6-net10.0-windows-x64' + values = @('z', 'a') + } + } + $CanonicalContent = $Evidence.content | ConvertTo-Json -Depth 100 -Compress + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) + $Expected = [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData($Bytes) + ).Replace('-', '').ToLowerInvariant() + + Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $Evidence | Should -BeExactly $Expected + } + + It 'rejects unsupported or content-free evidence envelopes' { + { Get-DLLPickleNormalizedEvidenceFingerprint -Evidence ([pscustomobject]@{ schemaVersion = 2; content = @{} }) } | + Should -Throw '*unsupported schema*' + { Get-DLLPickleNormalizedEvidenceFingerprint -Evidence ([pscustomobject]@{ schemaVersion = 1 }) } | + Should -Throw '*no fingerprinted content*' + } + + It 'uses ordinal ordering and uniqueness regardless of process culture' { + $OriginalCulture = [System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('tr-TR') + $Actual = @(Get-DLLPickleOrdinalSequence -InputObject @('z', $null, 'a', 'A', 'z') -Unique) + } finally { + [System.Globalization.CultureInfo]::CurrentCulture = $OriginalCulture + } + + ($Actual -join ',') | Should -BeExactly 'A,a,z' + } +} diff --git a/tools/DLLPickle.ProfileEvidence.ps1 b/tools/DLLPickle.ProfileEvidence.ps1 new file mode 100644 index 00000000..c785f1cf --- /dev/null +++ b/tools/DLLPickle.ProfileEvidence.ps1 @@ -0,0 +1,97 @@ +function Get-DLLPickleNormalizedEvidenceFingerprint { + <# + .SYNOPSIS + Recomputes the canonical fingerprint for normalized profile evidence. + + .PARAMETER Evidence + A normalized evidence envelope with schemaVersion 1 and fingerprinted content. + + .OUTPUTS + System.String + #> + + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [object]$Evidence + ) + + $ContentProperty = $Evidence.PSObject.Properties['content'] + if ([int]$Evidence.schemaVersion -ne 1 -or $null -eq $ContentProperty -or $null -eq $ContentProperty.Value) { + throw 'Normalized profile evidence has an unsupported schema or no fingerprinted content.' + } + + $CanonicalContent = $ContentProperty.Value | ConvertTo-Json -Depth 100 -Compress + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) + [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData($Bytes) + ).Replace('-', '').ToLowerInvariant() +} + +function Get-DLLPickleOrdinalSequence { + <# + .SYNOPSIS + Sorts values deterministically with ordinal string comparison. + + .PARAMETER InputObject + Values to sort. + + .PARAMETER KeySelector + Optional script block that returns the primary string sort key for each value. + + .PARAMETER Unique + Return only the first value for each ordinal primary key. + + .OUTPUTS + System.Object + #> + + [CmdletBinding()] + [OutputType([object])] + param ( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter()] + [scriptblock]$KeySelector = { param($Item) [string]$Item }, + + [Parameter()] + [switch]$Unique + ) + + $SortableValues = @($InputObject | Where-Object { $null -ne $_ }) + if ($SortableValues.Count -eq 0) { + return + } + + $Entries = [System.Collections.Generic.List[object]]::new() + foreach ($Value in $SortableValues) { + $PrimaryKey = [string](& $KeySelector $Value) + $TieBreaker = $Value | ConvertTo-Json -Depth 100 -Compress + $Entries.Add([pscustomobject]@{ + PrimaryKey = $PrimaryKey + SortKey = $PrimaryKey + [char]0 + $TieBreaker + Value = $Value + }) + } + $Entries.Sort([System.Comparison[object]]{ + param($Left, $Right) + [System.StringComparer]::Ordinal.Compare([string]$Left.SortKey, [string]$Right.SortKey) + }) + + $PreviousKey = $null + $HasPreviousKey = $false + foreach ($Entry in $Entries) { + if ($Unique -and $HasPreviousKey -and + [System.StringComparer]::Ordinal.Equals($PreviousKey, [string]$Entry.PrimaryKey)) { + continue + } + + $Entry.Value + $PreviousKey = [string]$Entry.PrimaryKey + $HasPreviousKey = $true + } +} diff --git a/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 index 15709a53..78bde536 100644 --- a/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 +++ b/tools/Get-DLLPicklePowerShellSupportUpdate.ps1 @@ -141,11 +141,17 @@ if ($DuplicateLifecycleLines.Count -gt 0) { } $AsOfPacificDate = [System.TimeZoneInfo]::ConvertTimeFromUtc($AsOfUtc.ToUniversalTime(), $PacificTimeZone).Date $SupportedLifecycleLines = @($LiveLifecycleRows | Where-Object { - [datetime]::ParseExact( + $StartDate = [datetime]::ParseExact( + [string]$_.StartDate, + 'yyyy-MM-dd', + [System.Globalization.CultureInfo]::InvariantCulture + ) + $EndDate = [datetime]::ParseExact( [string]$_.EndDate, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture - ) -ge $AsOfPacificDate + ) + $StartDate -le $AsOfPacificDate -and $EndDate -ge $AsOfPacificDate } | ForEach-Object ReleaseLine) $DeclaredLines = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}' -f $_.powerShellMajor, $_.powerShellMinor }) diff --git a/tools/New-DLLPickleNormalizedProfileEvidence.ps1 b/tools/New-DLLPickleNormalizedProfileEvidence.ps1 index 8c0ec429..44415200 100644 --- a/tools/New-DLLPickleNormalizedProfileEvidence.ps1 +++ b/tools/New-DLLPickleNormalizedProfileEvidence.ps1 @@ -77,6 +77,7 @@ param ( ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') function ConvertTo-CollapsedRelativePath { param([Parameter(Mandatory)][string]$Path) @@ -133,13 +134,6 @@ function ConvertTo-NormalizedEvidencePath { '{0}:{1}' -f $Prefix, (ConvertTo-CollapsedRelativePath -Path $RelativePath) } -function Get-Sha256Text { - param([Parameter(Mandatory)][string]$Text) - - $Bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) - [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() -} - foreach ($RequiredPath in @($InventoryPath, $ConflictMatrixPath, $ScenarioEvidencePath, $ValidationGapsPath)) { if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { throw "Required profile evidence input was not found: $RequiredPath" @@ -191,9 +185,9 @@ if ([string]::IsNullOrWhiteSpace($RuntimeRoot)) { } $NormalizedModules = @( - foreach ($Module in @($Inventory.Modules | Sort-Object Name)) { - $SelectedAssets = @( - foreach ($Assembly in @($Module.TrackedAssemblies | Sort-Object Name, Version, Sha256, SelectedAssetPath)) { + foreach ($Module in @(Get-DLLPickleOrdinalSequence -InputObject @($Inventory.Modules) -KeySelector { param($Item) [string]$Item.Name })) { + $UnsortedSelectedAssets = @( + foreach ($Assembly in @($Module.TrackedAssemblies)) { [ordered]@{ assemblyName = [string]$Assembly.Name assemblyVersion = [string]$Assembly.Version @@ -207,6 +201,12 @@ $NormalizedModules = @( } } ) + $SelectedAssets = @( + Get-DLLPickleOrdinalSequence -InputObject $UnsortedSelectedAssets -KeySelector { + param($Item) + '{0}{5}{1}{5}{2}{5}{3}{5}{4}' -f $Item.assemblyName, $Item.assemblyVersion, $Item.sha256, $Item.assemblyLoadContext, $Item.selectedAsset, [char]0 + } + ) [ordered]@{ name = [string]$Module.Name umbrellaModule = [string]$Module.UmbrellaModule @@ -215,7 +215,7 @@ $NormalizedModules = @( latestCompatibleVersion = [string]$Module.LatestCompatibleVersion repository = [string]$Module.Repository manifestPowerShellVersion = [string]$Module.ManifestPowerShellVersion - compatiblePSEditions = @($Module.CompatiblePSEditions | Sort-Object) + compatiblePSEditions = @(Get-DLLPickleOrdinalSequence -InputObject @($Module.CompatiblePSEditions)) deterministicProbeCommand = [string]$Module.DeterministicProbeCommand selectedAssets = $SelectedAssets } @@ -223,16 +223,19 @@ $NormalizedModules = @( ) $NormalizedMatrixRows = @( - foreach ($Assembly in @($Matrix.Assemblies | Sort-Object Name)) { + foreach ($Assembly in @(Get-DLLPickleOrdinalSequence -InputObject @($Matrix.Assemblies) -KeySelector { param($Item) [string]$Item.Name })) { [ordered]@{ name = [string]$Assembly.Name - shippedBy = @($Assembly.ShippedBy | Sort-Object) - versions = @($Assembly.Versions | Sort-Object) - hashes = @($Assembly.Hashes | ForEach-Object { ([string]$_).ToLowerInvariant() } | Sort-Object) - assemblyLoadContexts = @($Assembly.AlcOwners | Sort-Object) + shippedBy = @(Get-DLLPickleOrdinalSequence -InputObject @($Assembly.ShippedBy)) + versions = @(Get-DLLPickleOrdinalSequence -InputObject @($Assembly.Versions)) + hashes = @(Get-DLLPickleOrdinalSequence -InputObject @($Assembly.Hashes | ForEach-Object { ([string]$_).ToLowerInvariant() })) + assemblyLoadContexts = @(Get-DLLPickleOrdinalSequence -InputObject @($Assembly.AlcOwners)) diverges = [bool]$Assembly.Diverges selections = @( - foreach ($Selection in @($Assembly.Selections | Sort-Object Module, Version, Sha256, AlcOwner)) { + foreach ($Selection in @(Get-DLLPickleOrdinalSequence -InputObject @($Assembly.Selections) -KeySelector { + param($Item) + '{0}{4}{1}{4}{2}{4}{3}' -f $Item.Module, $Item.Version, $Item.Sha256, $Item.AlcOwner, [char]0 + })) { [ordered]@{ contributor = [string]$Selection.Module version = [string]$Selection.Version @@ -246,7 +249,10 @@ $NormalizedMatrixRows = @( ) $NormalizedScenarios = @( - foreach ($Scenario in @($ScenarioEvidence.Scenarios | Sort-Object ScenarioId, OrderIndex, DllPicklePreloaded)) { + foreach ($Scenario in @(Get-DLLPickleOrdinalSequence -InputObject @($ScenarioEvidence.Scenarios) -KeySelector { + param($Item) + '{0}{3}{1:D10}{3}{2}' -f $Item.ScenarioId, [int]$Item.OrderIndex, [bool]$Item.DllPicklePreloaded, [char]0 + })) { $FirstAssembly = @($Scenario.Assemblies | Select-Object -First 1) $ImportedModuleAssets = if ($FirstAssembly.Count -eq 1) { @( @@ -271,16 +277,22 @@ $NormalizedScenarios = @( outcomeMatchesExpectation = [bool]$Scenario.OutcomeMatchesExpectation errorObserved = -not [string]::IsNullOrWhiteSpace([string]$Scenario.Error) assemblies = @( - foreach ($Assembly in @($Scenario.Assemblies | Sort-Object Name, Version, Sha256, Alc, Path)) { - [ordered]@{ - name = [string]$Assembly.Name - version = [string]$Assembly.Version - fullName = [string]$Assembly.FullName - sha256 = ([string]$Assembly.Sha256).ToLowerInvariant() - assemblyLoadContext = [string]$Assembly.Alc - isCollectible = [bool]$Assembly.IsCollectible - selectedAsset = ConvertTo-NormalizedEvidencePath -Path ([string]$Assembly.Path) -ModuleCachePath $ModuleCachePath -RuntimeRoot $RuntimeRoot + $UnsortedScenarioAssemblies = @( + foreach ($Assembly in @($Scenario.Assemblies)) { + [ordered]@{ + name = [string]$Assembly.Name + version = [string]$Assembly.Version + fullName = [string]$Assembly.FullName + sha256 = ([string]$Assembly.Sha256).ToLowerInvariant() + assemblyLoadContext = [string]$Assembly.Alc + isCollectible = [bool]$Assembly.IsCollectible + selectedAsset = ConvertTo-NormalizedEvidencePath -Path ([string]$Assembly.Path) -ModuleCachePath $ModuleCachePath -RuntimeRoot $RuntimeRoot + } } + ) + Get-DLLPickleOrdinalSequence -InputObject $UnsortedScenarioAssemblies -KeySelector { + param($Item) + '{0}{5}{1}{5}{2}{5}{3}{5}{4}' -f $Item.name, $Item.version, $Item.sha256, $Item.assemblyLoadContext, $Item.selectedAsset, [char]0 } ) } @@ -313,19 +325,20 @@ $Content = [ordered]@{ } modules = $NormalizedModules conflictMatrix = [ordered]@{ - conflictSurface = @($Matrix.ConflictSurface | Sort-Object) + conflictSurface = @(Get-DLLPickleOrdinalSequence -InputObject @($Matrix.ConflictSurface)) assemblies = $NormalizedMatrixRows } scenarios = $NormalizedScenarios } -$CanonicalContent = $Content | ConvertTo-Json -Depth 100 -Compress -$ContentFingerprint = Get-Sha256Text -Text $CanonicalContent +$FingerprintEnvelope = [pscustomobject]@{ schemaVersion = 1; content = $Content } +$ContentFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $FingerprintEnvelope +$ObservedOperatingSystemCandidates = @( + @($Inventory.Modules.TrackedAssemblies.OS) + @($ScenarioEvidence.Scenarios.Assemblies.OS) | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } +) $ObservedOperatingSystems = @( - @($Inventory.Modules.TrackedAssemblies.OS) + - @($ScenarioEvidence.Scenarios.Assemblies.OS) | - Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | - Sort-Object -Unique + Get-DLLPickleOrdinalSequence -InputObject $ObservedOperatingSystemCandidates -Unique ) if ([string]::IsNullOrWhiteSpace($CapturedAtUtc)) { $CapturedAtUtc = if (-not [string]::IsNullOrWhiteSpace([string]$Inventory.GeneratedAtUtc)) { diff --git a/tools/Test-DLLPicklePackageReferenceUpdate.ps1 b/tools/Test-DLLPicklePackageReferenceUpdate.ps1 index eef1ff02..be5abe94 100644 --- a/tools/Test-DLLPicklePackageReferenceUpdate.ps1 +++ b/tools/Test-DLLPicklePackageReferenceUpdate.ps1 @@ -113,14 +113,14 @@ $ChangedPackages = @( } } ) -if ($ChangedPackages.Count -eq 0) { - throw 'The candidate project does not change any PackageReference Version attribute.' -} - if (-not [string]::Equals($Base.NormalizedProject, $Candidate.NormalizedProject, [System.StringComparison]::Ordinal)) { throw 'The candidate project changes content other than PackageReference Version attribute values.' } +if ($ChangedPackages.Count -eq 0) { + throw 'The candidate project does not change any PackageReference Version attribute.' +} + [pscustomobject]@{ IsVersionOnlyUpdate = $true ChangedPackages = $ChangedPackages diff --git a/tools/Test-DLLPickleProfileConflictBaseline.ps1 b/tools/Test-DLLPickleProfileConflictBaseline.ps1 index fab34213..f1c0166a 100644 --- a/tools/Test-DLLPickleProfileConflictBaseline.ps1 +++ b/tools/Test-DLLPickleProfileConflictBaseline.ps1 @@ -49,23 +49,13 @@ param ( ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') $Policy = Get-Content -LiteralPath $PolicyPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $Matrix = Get-Content -LiteralPath $ConflictMatrixPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $ScenarioEvidence = Get-Content -LiteralPath $ScenarioEvidencePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $NormalizedEvidence = Get-Content -LiteralPath $NormalizedEvidencePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop -function Get-NormalizedContentFingerprint { - param([Parameter(Mandatory)][object]$Evidence) - - if ([int]$Evidence.schemaVersion -ne 1 -or -not $Evidence.content) { - throw 'Normalized profile evidence has an unsupported schema or no fingerprinted content.' - } - $CanonicalContent = $Evidence.content | ConvertTo-Json -Depth 100 -Compress - $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) - [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() -} - -$CandidateEvidenceFingerprint = Get-NormalizedContentFingerprint -Evidence $NormalizedEvidence +$CandidateEvidenceFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $NormalizedEvidence if ([string]$NormalizedEvidence.contentFingerprint -ne $CandidateEvidenceFingerprint) { throw "Normalized profile evidence content does not recompute to '$($NormalizedEvidence.contentFingerprint)'." } @@ -177,22 +167,28 @@ $Status = if ( 'AcceptedUnchanged' } +$FailureDetail = $null if ($Status -eq 'AcceptedUnchanged') { - $ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path - $CommittedEvidencePath = Join-Path -Path (Split-Path -Path $ResolvedPolicyPath -Parent) -ChildPath $BaselineEvidencePath - if (-not (Test-Path -LiteralPath $CommittedEvidencePath -PathType Leaf)) { - throw "Accepted evidence for '$($Matrix.ProfileKey)' was not found at '$CommittedEvidencePath'." - } - $CommittedEvidence = Get-Content -LiteralPath $CommittedEvidencePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - $CommittedEvidenceFingerprint = Get-NormalizedContentFingerprint -Evidence $CommittedEvidence - if ([string]$CommittedEvidence.contentFingerprint -ne $CommittedEvidenceFingerprint -or - $CommittedEvidenceFingerprint -ne $BaselineEvidenceFingerprint) { - throw "Accepted evidence for '$($Matrix.ProfileKey)' does not recompute to the policy fingerprint '$BaselineEvidenceFingerprint'." - } - if ([string]$CommittedEvidence.content.profile.profileKey -ne [string]$Matrix.ProfileKey -or - [string]$CommittedEvidence.content.validation.deterministicImportNoAuth.conflictSurfaceFingerprint -ne $BaselineFingerprint -or - [string]$CommittedEvidence.content.validation.deterministicImportNoAuth.scenarioFingerprint -ne $BaselineScenarioFingerprint) { - throw "Accepted evidence for '$($Matrix.ProfileKey)' does not bind the policy profile, conflict, and scenario fingerprints." + try { + $ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path + $CommittedEvidencePath = Join-Path -Path (Split-Path -Path $ResolvedPolicyPath -Parent) -ChildPath $BaselineEvidencePath + if (-not (Test-Path -LiteralPath $CommittedEvidencePath -PathType Leaf)) { + throw "Accepted evidence for '$($Matrix.ProfileKey)' was not found at '$CommittedEvidencePath'." + } + $CommittedEvidence = Get-Content -LiteralPath $CommittedEvidencePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $CommittedEvidenceFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $CommittedEvidence + if ([string]$CommittedEvidence.contentFingerprint -ne $CommittedEvidenceFingerprint -or + $CommittedEvidenceFingerprint -ne $BaselineEvidenceFingerprint) { + throw "Accepted evidence for '$($Matrix.ProfileKey)' does not recompute to the policy fingerprint '$BaselineEvidenceFingerprint'." + } + if ([string]$CommittedEvidence.content.profile.profileKey -ne [string]$Matrix.ProfileKey -or + [string]$CommittedEvidence.content.validation.deterministicImportNoAuth.conflictSurfaceFingerprint -ne $BaselineFingerprint -or + [string]$CommittedEvidence.content.validation.deterministicImportNoAuth.scenarioFingerprint -ne $BaselineScenarioFingerprint) { + throw "Accepted evidence for '$($Matrix.ProfileKey)' does not bind the policy profile, conflict, and scenario fingerprints." + } + } catch { + $Status = 'InvalidCommittedEvidence' + $FailureDetail = $_.Exception.Message } } @@ -211,6 +207,7 @@ $Result = [pscustomobject]@{ BaselineEvidencePath = $BaselineEvidencePath FindingFingerprint = $FindingFingerprint Status = $Status + FailureDetail = $FailureDetail } if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { @@ -227,4 +224,7 @@ if ($Status -eq 'RequiresAcceptance') { if ($Status -eq 'Drifted') { throw "Upstream profile evidence drift detected for '$($Matrix.ProfileKey)': baseline conflict '$BaselineFingerprint', current conflict '$CurrentFingerprint'; baseline scenario '$BaselineScenarioFingerprint', current scenario '$CurrentScenarioFingerprint'; baseline evidence '$BaselineEvidenceFingerprint', current evidence '$CandidateEvidenceFingerprint'." } +if ($Status -eq 'InvalidCommittedEvidence') { + throw $FailureDetail +} if ($PassThru) { $Result } From da2fb699b4398be4363145be34bd31af81af9736 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:27:32 -0400 Subject: [PATCH 35/43] feat: add bounded authenticated evidence bridge --- .github/workflows/Release-and-Publish.yml | 87 +++-- build/authenticated-evidence/README.md | 23 ++ .../manual-transition.schema.json | 196 +++++++++++ docs/Architecture.md | 4 +- ...ntialed-authentication-test-environment.md | 80 +++++ tests/Unit/BundleSourceFingerprint.Tests.ps1 | 60 ++++ .../ManualAuthenticatedEvidence.Tests.ps1 | 283 +++++++++++++++ .../Unit/ManualAuthenticatedHarness.Tests.ps1 | 59 ++++ tests/Unit/SupportDocumentation.Tests.ps1 | 17 +- tests/Unit/WorkflowGuardrails.Tests.ps1 | 11 +- .../Get-DLLPickleBundleSourceFingerprint.ps1 | 93 +++++ ...PickleManualAuthenticatedCompatibility.ps1 | 95 +++++ ...PickleManualAuthenticatedCompatibility.ps1 | 271 ++++++++++++++ ...e-DLLPickleManualAuthenticatedScenario.ps1 | 295 ++++++++++++++++ tools/New-DLLPickleSupportDocumentation.ps1 | 33 +- ...eManualAuthenticatedEvidenceAcceptance.ps1 | 73 ++++ ...t-DLLPickleManualAuthenticatedEvidence.ps1 | 332 ++++++++++++++++++ 17 files changed, 1969 insertions(+), 43 deletions(-) create mode 100644 build/authenticated-evidence/README.md create mode 100644 build/authenticated-evidence/manual-transition.schema.json create mode 100644 tests/Unit/BundleSourceFingerprint.Tests.ps1 create mode 100644 tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 create mode 100644 tests/Unit/ManualAuthenticatedHarness.Tests.ps1 create mode 100644 tools/Get-DLLPickleBundleSourceFingerprint.ps1 create mode 100644 tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 create mode 100644 tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 create mode 100644 tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 create mode 100644 tools/Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1 create mode 100644 tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 diff --git a/.github/workflows/Release-and-Publish.yml b/.github/workflows/Release-and-Publish.yml index d25b077b..91f757f3 100644 --- a/.github/workflows/Release-and-Publish.yml +++ b/.github/workflows/Release-and-Publish.yml @@ -48,15 +48,16 @@ env: MANIFEST_PATH: ./src/DLLPickle/DLLPickle.psd1 AUTHENTICATED_WORKFLOW_FILE: Authenticated-Compatibility.yml AUTHENTICATED_EVIDENCE_ARTIFACT: authenticated-compatibility-evidence + MANUAL_AUTHENTICATED_EVIDENCE_PATH: ./build/authenticated-evidence/initial-multitarget-major.json jobs: authenticated-release-gate: name: Require Authenticated Compatibility runs-on: ubuntu-latest timeout-minutes: 10 - # The credentialed workflow is intentionally not created until the protected environment, - # identities, permissions, and redaction controls in the approved plan exist. Until then this - # gate fails closed before version analysis or tag creation can begin. + # Prefer exact-commit evidence from the future protected credentialed workflow. Until that + # environment exists, one reviewed manual transition record may authorize only the initial + # 3.0.0 bundle fingerprint and expires within 30 days. Neither route permits writes. if: | github.repository_owner == 'SamErde' && ( @@ -70,6 +71,8 @@ jobs: outputs: release_sha: ${{ steps.release-candidate.outputs.release_sha }} + evidence_mode: ${{ steps.authenticated-evidence.outputs.evidence_mode }} + allowed_release_version: ${{ steps.authenticated-evidence.outputs.allowed_release_version }} steps: - name: Checkout release candidate @@ -113,7 +116,8 @@ jobs: throw 'A required authenticated release profile does not explicitly prohibit writes.' } - - name: Require successful exact-commit authenticated evidence + - name: Require protected or bounded authenticated evidence + id: authenticated-evidence shell: pwsh env: GH_TOKEN: ${{ github.token }} @@ -134,39 +138,59 @@ jobs: '--limit', '20' '--json', 'databaseId,headSha,conclusion,createdAt,url' ) - $RunJson = & gh @RunArguments - if ($LASTEXITCODE -ne 0) { - throw "Release is blocked until $($env:AUTHENTICATED_WORKFLOW_FILE) exists and succeeds in the protected credentialed environment." - } - $MatchingRuns = @( - $RunJson | ConvertFrom-Json | Where-Object { - $_.headSha -eq $EvidenceSha -and $_.conclusion -eq 'success' + $RunJson = & gh @RunArguments 2>$null + $ProtectedEvidenceRun = $null + if ($LASTEXITCODE -eq 0) { + $MatchingRuns = @( + $RunJson | ConvertFrom-Json | Where-Object { + $_.headSha -eq $EvidenceSha -and $_.conclusion -eq 'success' + } + ) + foreach ($CandidateRun in @($MatchingRuns | Sort-Object createdAt -Descending)) { + $ArtifactJson = gh api "/repos/${{ github.repository }}/actions/runs/$($CandidateRun.databaseId)/artifacts" 2>$null + if ($LASTEXITCODE -ne 0) { continue } + $EvidenceArtifacts = @( + ($ArtifactJson | ConvertFrom-Json).artifacts | Where-Object { + $_.name -eq $env:AUTHENTICATED_EVIDENCE_ARTIFACT -and -not $_.expired + } + ) + if ($EvidenceArtifacts.Count -eq 1) { + $ProtectedEvidenceRun = $CandidateRun + break + } } - ) - if ($MatchingRuns.Count -eq 0) { - throw "No successful authenticated compatibility run exists for exact candidate SHA $EvidenceSha." } - $EvidenceRun = $MatchingRuns | Sort-Object createdAt -Descending | Select-Object -First 1 - $ArtifactJson = gh api "/repos/${{ github.repository }}/actions/runs/$($EvidenceRun.databaseId)/artifacts" - if ($LASTEXITCODE -ne 0) { - throw "Failed to inspect authenticated evidence artifacts for run $($EvidenceRun.databaseId)." - } - $EvidenceArtifacts = @( - ($ArtifactJson | ConvertFrom-Json).artifacts | Where-Object { - $_.name -eq $env:AUTHENTICATED_EVIDENCE_ARTIFACT -and -not $_.expired + if ($ProtectedEvidenceRun) { + $EvidenceMode = 'protected-exact-commit-workflow' + $AllowedReleaseVersion = '' + $EvidenceReference = $ProtectedEvidenceRun.url + } elseif (Test-Path -LiteralPath $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH -PathType Leaf) { + $ManualEvidenceParameters = @{ + EvidencePath = $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH + RepositoryRoot = '.' + TestMatrixPath = './build/powershell-test-matrix.json' + DependencyPolicyPath = './build/dependency-policy.json' + Mode = 'Release' } - ) - if ($EvidenceArtifacts.Count -ne 1) { - throw "Authenticated run $($EvidenceRun.databaseId) must contain one unexpired '$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)' artifact." + $ManualEvidence = ./tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 @ManualEvidenceParameters + $EvidenceMode = 'manual-interactive-transition' + $AllowedReleaseVersion = [string]$ManualEvidence.AllowedReleaseVersion + $EvidenceReference = "``$($env:MANUAL_AUTHENTICATED_EVIDENCE_PATH)`` (expires $($ManualEvidence.ExpiresAtUtc))" + } else { + throw "Release is blocked: no successful exact-commit protected workflow evidence exists and no accepted bounded manual transition evidence is committed." } + "evidence_mode=$EvidenceMode" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "allowed_release_version=$AllowedReleaseVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + @( '### Authenticated release evidence' '' "- Candidate SHA: ``$EvidenceSha``" - "- Validated run: $($EvidenceRun.url)" - "- Evidence artifact: ``$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)``" + "- Evidence mode: ``$EvidenceMode``" + "- Evidence reference: $EvidenceReference" + $(if ($AllowedReleaseVersion) { "- Allowed release version: ``$AllowedReleaseVersion``" }) '- Required profiles prohibit writes: True' ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY @@ -266,6 +290,8 @@ jobs: MANUAL_BUMP_INPUT: ${{ inputs.version_bump }} MANUAL_BUMP_EVENT: ${{ github.event.inputs.version_bump }} RELEASE_SHA: ${{ needs.authenticated-release-gate.outputs.release_sha }} + AUTHENTICATED_EVIDENCE_MODE: ${{ needs.authenticated-release-gate.outputs.evidence_mode }} + AUTHENTICATED_ALLOWED_RELEASE_VERSION: ${{ needs.authenticated-release-gate.outputs.allowed_release_version }} run: | # Pass manual bump parameter to Get-VersionBump for centralized version logic Write-Host "Analyzing version requirements..." @@ -314,6 +340,13 @@ jobs: $NewVersion = $VersionResult.NewVersion $CurrentVersion = [version]$VersionResult.CurrentVersion + if ($ShouldRelease -and $env:AUTHENTICATED_EVIDENCE_MODE -eq 'manual-interactive-transition') { + if ([string]::IsNullOrWhiteSpace($env:AUTHENTICATED_ALLOWED_RELEASE_VERSION) -or + [string]$NewVersion -ne $env:AUTHENTICATED_ALLOWED_RELEASE_VERSION) { + throw "Bounded manual authenticated evidence permits only version '$($env:AUTHENTICATED_ALLOWED_RELEASE_VERSION)', not '$NewVersion'." + } + } + Write-Host "`nVersion Analysis Results:" Write-Host "Current version: $($CurrentVersion.ToString())" Write-Host "Version bump type: $NewVersionType" diff --git a/build/authenticated-evidence/README.md b/build/authenticated-evidence/README.md new file mode 100644 index 00000000..25332460 --- /dev/null +++ b/build/authenticated-evidence/README.md @@ -0,0 +1,23 @@ +# Authenticated compatibility transition evidence + +This directory holds the schema and, only after an explicit maintainer review, +the sanitized manual compatibility record for the initial `3.0.0` multi-target +release. + +The temporary bridge is intentionally narrower than the future protected +credentialed workflow: + +- exact bundle-source fingerprint; +- exact release version `3.0.0`; +- exact Windows x64 PowerShell 7.4, 7.5, and 7.6 profiles; +- fixed read-only probe and import-order identifiers; +- no credential material or raw service output; +- `WritesPerformed: false` at every level; +- maximum 30-day validity; +- explicit maintainer, confidence, and acceptance timestamp; +- explicit acknowledgement that delegated interactive authentication is not + least-privilege workload-identity evidence. + +`Test-DLLPickleManualAuthenticatedEvidence.ps1` is the authoritative semantic +validator. The JSON schema is a review aid, not an authorization mechanism. No +accepted evidence file is committed until the interactive run is complete. diff --git a/build/authenticated-evidence/manual-transition.schema.json b/build/authenticated-evidence/manual-transition.schema.json new file mode 100644 index 00000000..d3269a69 --- /dev/null +++ b/build/authenticated-evidence/manual-transition.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamErde/DLLPickle/build/authenticated-evidence/manual-transition.schema.json", + "title": "DLLPickle manual authenticated compatibility transition evidence", + "type": "object", + "required": [ + "schemaVersion", + "evidenceType", + "contentFingerprint", + "provenance", + "acceptance", + "content" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "evidenceType": { "const": "manual-interactive-transition" }, + "contentFingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "provenance": { + "type": "object", + "required": ["sourceCommitSha", "captureStartedAtUtc", "captureCompletedAtUtc"], + "properties": { + "sourceCommitSha": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "captureStartedAtUtc": { "type": "string", "format": "date-time" }, + "captureCompletedAtUtc": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false + }, + "acceptance": { + "type": "object", + "required": ["status", "acceptedAtUtc", "acceptedBy", "confidence"], + "properties": { + "status": { "enum": ["pending", "accepted"] }, + "acceptedAtUtc": { "type": ["string", "null"], "format": "date-time" }, + "acceptedBy": { "type": ["string", "null"] }, + "confidence": { "enum": [null, "low", "medium", "high"] } + }, + "additionalProperties": false + }, + "content": { + "type": "object", + "required": [ + "bridge", + "bundleSourceFingerprint", + "credentialMode", + "credentialMaterialCaptured", + "authorizationBoundaryValidated", + "platformScope", + "writesPerformed", + "profiles" + ], + "properties": { + "bridge": { + "type": "object", + "required": ["id", "allowedReleaseVersion", "expiresAtUtc"], + "properties": { + "id": { "const": "initial-powershell-7.4-7.6-multitargeting-major" }, + "allowedReleaseVersion": { "const": "3.0.0" }, + "expiresAtUtc": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false + }, + "bundleSourceFingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "credentialMode": { "const": "delegated-interactive" }, + "credentialMaterialCaptured": { "const": false }, + "authorizationBoundaryValidated": { "const": false }, + "platformScope": { "const": "windows-x64-only" }, + "writesPerformed": { "const": false }, + "profiles": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "$ref": "#/$defs/profile" } + } + }, + "additionalProperties": false + } + }, + "$defs": { + "moduleVersion": { + "type": "object", + "required": ["name", "version", "manifest"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "manifest": { "type": "string", "pattern": "^upstream:" } + }, + "additionalProperties": false + }, + "assembly": { + "type": "object", + "required": ["name", "version", "sha256", "selectedAsset", "assemblyLoadContext", "isCollectible"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "selectedAsset": { "type": "string", "pattern": "^(upstream|dllpickle|runtime):" }, + "assemblyLoadContext": { "type": "string", "minLength": 1 }, + "isCollectible": { "type": "boolean" } + }, + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "required": ["stage", "assemblies"], + "properties": { + "stage": { "enum": ["before-authentication", "after-connection", "after-read-probe"] }, + "assemblies": { "type": "array", "items": { "$ref": "#/$defs/assembly" } } + }, + "additionalProperties": false + }, + "probe": { + "type": "object", + "required": ["probeId", "executed", "status", "durationMilliseconds", "writesPerformed", "errorType"], + "properties": { + "probeId": { "type": "string", "minLength": 1 }, + "executed": { "const": true }, + "status": { "const": "passed" }, + "durationMilliseconds": { "type": "integer", "minimum": 0 }, + "writesPerformed": { "const": false }, + "errorType": { "type": "null" } + }, + "additionalProperties": false + }, + "scenario": { + "type": "object", + "required": [ + "scenarioId", + "profileKey", + "powerShellVersion", + "targetFramework", + "platform", + "architecture", + "importOrder", + "dllPickleTiming", + "expectedTokenAudiences", + "status", + "writesPerformed", + "probes", + "snapshots", + "errorType" + ], + "properties": { + "scenarioId": { "type": "string", "minLength": 1 }, + "profileKey": { "type": "string", "pattern": "^ps7\\.[456]-net(?:8|9|10)\\.0-windows-x64$" }, + "powerShellVersion": { "type": "string", "pattern": "^7\\.[456]\\.[0-9]+$" }, + "targetFramework": { "enum": ["net8.0", "net9.0", "net10.0"] }, + "platform": { "const": "windows" }, + "architecture": { "const": "x64" }, + "importOrder": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "dllPickleTiming": { "enum": ["module-only", "dllpickle-first", "module-first"] }, + "expectedTokenAudiences": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "uri" } }, + "status": { "const": "passed" }, + "writesPerformed": { "const": false }, + "probes": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/probe" } }, + "snapshots": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "$ref": "#/$defs/snapshot" } }, + "errorType": { "type": "null" } + }, + "additionalProperties": false + }, + "profile": { + "type": "object", + "required": [ + "profileKey", + "powerShellVersion", + "powerShellLine", + "dotNetVersion", + "dotNetMajor", + "targetFramework", + "platform", + "architecture", + "runtimeExecutable", + "psHome", + "writesPerformed", + "moduleVersions", + "scenarios" + ], + "properties": { + "profileKey": { "type": "string", "pattern": "^ps7\\.[456]-net(?:8|9|10)\\.0-windows-x64$" }, + "powerShellVersion": { "type": "string", "pattern": "^7\\.[456]\\.[0-9]+$" }, + "powerShellLine": { "enum": ["7.4", "7.5", "7.6"] }, + "dotNetVersion": { "type": "string", "minLength": 1 }, + "dotNetMajor": { "enum": [8, 9, 10] }, + "targetFramework": { "enum": ["net8.0", "net9.0", "net10.0"] }, + "platform": { "const": "windows" }, + "architecture": { "const": "x64" }, + "runtimeExecutable": { "type": "string", "pattern": "^runtime:" }, + "psHome": { "type": "string", "pattern": "^runtime:" }, + "writesPerformed": { "const": false }, + "moduleVersions": { "type": "array", "minItems": 6, "maxItems": 6, "items": { "$ref": "#/$defs/moduleVersion" } }, + "scenarios": { "type": "array", "minItems": 14, "maxItems": 14, "items": { "$ref": "#/$defs/scenario" } } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/docs/Architecture.md b/docs/Architecture.md index 3bb6bf41..e786a5e9 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -119,7 +119,7 @@ Every tracked assembly is classified into exactly one of: - **Unit + analyzer:** `tools/Invoke-DLLPickleBuild.ps1 -Task Analyze,Test` imports the exact pinned tool versions in a fresh no-profile process. Must be green. - **Issue reproduction + composition:** `Invoke-Build -Task IssueReproTest` (synthetic modules; deterministic). - **Runtime adjudication — deterministic no-auth tier:** nine exact stock-host cells (three PowerShell/.NET/TFM profiles × Windows/Linux/macOS), strict per-module ALC snapshots, both relevant import orders, structured runtime identity, and fail-closed profile/platform baselines. -- **Runtime adjudication — authenticated read-only tier:** maintainer-approved credentials only. The exact unexecuted commands and status are generated in [Compatibility-Evidence.md](generated/Compatibility-Evidence.md); they are release gates and are never inferred from deterministic results. `Release-and-Publish` requires a successful `Authenticated-Compatibility.yml` run for the exact reviewed candidate commit and one unexpired `authenticated-compatibility-evidence` artifact before version analysis. Until that protected workflow and environment are implemented, release publication fails closed. +- **Runtime adjudication — authenticated read-only tier:** maintainer-approved credentials only. The exact unexecuted commands and status are generated in [Compatibility-Evidence.md](generated/Compatibility-Evidence.md); they are release gates and are never inferred from deterministic results. `Release-and-Publish` prefers a successful `Authenticated-Compatibility.yml` run for the exact reviewed candidate commit and one unexpired `authenticated-compatibility-evidence` artifact. Until that protected workflow and environment exist, a temporary manual record can authorize only release `3.0.0`, only for its exact bundle-source fingerprint, only after all fixed three-profile Windows read scenarios pass with zero writes, and for no more than 30 days. `Test-DLLPickleManualAuthenticatedEvidence.ps1` rejects any other release, bundle, profile set, skipped probe, expired record, missing maintainer acceptance, or credential-bearing evidence; absence of either valid route fails closed. - **Publish trigger:** `Release-and-Publish` publishes a merged PR only when it passes **both** the bundle-affecting **path gate** and the Conventional-Commit **version gate** — see §8.1 for the full contract (including how a Dependabot `deps:` commit maps to a **minor** release). CI-, policy-, docs-, test-, and tooling-only changes do **not** publish a new gallery version; `workflow_dispatch` is the deliberate-release escape hatch. - **Dependency PRs (Dependabot):** the bumped *bundle* is validated by **Build Module** (locked restore, all TFMs, exact runtime matrix, composition, size, and issue-repro guards). The Upstream-Compatibility gate records profile-aware selected assets, hashes, ALCs, import orders, deterministic probes, and conflict fingerprints. A conditional TFM pin, policy/classification edit, or material size increase routes to maintainer review. @@ -204,7 +204,7 @@ When changing the preload contract, follow this loop: 7. **Update this blueprint** if the contract or invariants changed. 8. **Update the gap register** if the work opens, advances, resolves, blocks, supersedes, or intentionally accepts a tracked gap. -Each accepted profile baseline is a reproducible snapshot, not just a hash. `New-DLLPickleNormalizedProfileEvidence.ps1` converts runner-specific paths to stable `upstream:` or `dllpickle:` asset identifiers and writes the selected module version, assembly name/version/hash/path, contributor, import order, operating system, architecture, and AssemblyLoadContext. Volatile run ID, URL, commit, timestamp, and observed OS description remain reviewable provenance but are excluded from the content fingerprint. Each `runtimeProfiles[].baselines` entry names the committed snapshot under `build/profile-evidence/` and its content fingerprint; the baseline gate recomputes that snapshot and requires current evidence to match it. Baseline refreshes resolve all monitored versions before downloading any module, then prove that the stored rows recompute to the recorded fingerprint. Version-only, contributor, selected-asset, or ALC-owner moves are material drift and require adjudication; they do not pass silently. `New-DLLPickleProfileEvidenceSummary.ps1` combines the nine comparisons into one stable finding marker so unchanged scheduled results can be deduplicated without falling back to the legacy global hash. +Each accepted profile baseline is a reproducible snapshot, not just a hash. `New-DLLPickleNormalizedProfileEvidence.ps1` converts runner-specific paths to stable `upstream:`, `dllpickle:`, or exact-host `runtime:` asset identifiers and writes the selected module version, assembly name/version/hash/path, contributor, import order, operating system, architecture, and AssemblyLoadContext. Volatile run ID, URL, commit, timestamp, and observed OS description remain reviewable provenance but are excluded from the content fingerprint. Each `runtimeProfiles[].baselines` entry names the committed snapshot under `build/profile-evidence/` and its content fingerprint; the baseline gate recomputes that snapshot and requires current evidence to match it. Baseline refreshes resolve all monitored versions before downloading any module, then prove that the stored rows recompute to the recorded fingerprint. Version-only, contributor, selected-asset, or ALC-owner moves are material drift and require adjudication; they do not pass silently. `New-DLLPickleProfileEvidenceSummary.ps1` combines the nine comparisons into one stable finding marker so unchanged scheduled results can be deduplicated without falling back to the legacy global hash. Issue #239 was adjudicated on 2026-06-20 against Microsoft.Graph.Authentication 2.38.0, ExchangeOnlineManagement 3.10.0, Az.Storage 9.7.0, Az.Accounts 5.5.0, and MicrosoftTeams 7.8.0. That snapshot remains useful historical context, but it predates the nine-profile evidence contract and cannot be carried forward as a current net8/net9/net10 or cross-platform verdict. diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index f25bdc9d..cd7dcfff 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -196,6 +196,86 @@ Only after the manual matrix is accepted: - do not allow the job to auto-approve, merge, publish, or mutate issues; - set a documented evidence freshness window. +### Temporary 3.0.0 transition bridge + +Before the protected environment is available, the initial multi-target major +release may use sanitized evidence collected by the interactive harness. This is +not an authenticated-gate waiver. The release validator requires: + +- the exact published-bundle source fingerprint recorded at capture time; +- release version `3.0.0` and no other version; +- all fixed module-only, DLLPickle-first, module-first, and cross-import-order + read scenarios under PowerShell 7.4, 7.5, and 7.6 on Windows x64; +- a real delegated Graph `/me` read in addition to the policy's context probe; +- passing EXO mailbox, Azure context/resource/storage-account, and Teams tenant + reads; +- normalized before-authentication, after-connection, and after-probe ALC + snapshots; +- zero writes and no credential material or raw provider output; +- explicit maintainer acceptance with a confidence level; and +- expiry no later than 30 days after capture. + +The record explicitly states that delegated interactive authentication is not +least-privilege workload-identity proof. Any bundle change, skipped or failed +probe, missing profile, different release version, expiry, or missing acceptance +closes the bridge. The future protected workflow remains the preferred route and +supersedes this transition mechanism. + +### Interactive transition runbook + +Run these commands in a normal interactive PowerShell terminal from a clean, +synced checkout of the reviewed PR branch. Do not paste tokens or passwords into +the command line. + +```powershell +Set-Location 'C:\Users\SamErde\Code\Public\DLLPickle' + +pwsh -NoLogo -NoProfile -File .\tools\Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 +``` + +The initialization step performs no provider authentication. It builds +DLLPickle, installs the checksum-pinned Windows x64 PowerShell executables, and +downloads the latest compatible monitored modules into the gitignored +`artifacts/manual-authenticated` directory. + +If the interactive Azure account exposes more than one subscription, set the +intended test subscription for the current terminal without committing it: + +```powershell +$env:DLLPICKLE_MANUAL_AZURE_SUBSCRIPTION_ID = '' +``` + +Then start or resume the authenticated capture: + +```powershell +pwsh -NoLogo -NoProfile -File .\tools\Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 +``` + +The harness opens only the provider sign-in experiences. Each of the 14 fixed +scenarios runs in a fresh process for each of the three exact profiles. Passing +scenario checkpoints are reused on rerun, so an authorization failure or an +interrupted session does not require repeating completed scenarios. To diagnose +one cell first, use the optional `-ProfileKey` and `-ScenarioId` filters; the +candidate remains incomplete until all 42 checkpoints exist. + +After the candidate is complete, review +`artifacts/manual-authenticated/manual-authenticated-evidence.candidate.json`. +It must contain no account, tenant, subscription, mailbox, resource, token, or +raw error/service output. Acceptance is a separate, confirmation-gated action: + +```powershell +$AcceptanceParameters = @{ + CandidateEvidencePath = '.\artifacts\manual-authenticated\manual-authenticated-evidence.candidate.json' + AcceptedBy = 'SamErde' + Confidence = 'high' +} +& .\tools\Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1 @AcceptanceParameters +``` + +This writes `build/authenticated-evidence/initial-multitarget-major.json` only +after the pending candidate validates. Commit that one sanitized file only after +review. Never commit the gitignored work directory or provider caches. + ## 8. Evidence schema and redaction Each probe record should include: diff --git a/tests/Unit/BundleSourceFingerprint.Tests.ps1 b/tests/Unit/BundleSourceFingerprint.Tests.ps1 new file mode 100644 index 00000000..fb134fe9 --- /dev/null +++ b/tests/Unit/BundleSourceFingerprint.Tests.ps1 @@ -0,0 +1,60 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:ToolPath = Join-Path $script:RepositoryRoot 'tools\Get-DLLPickleBundleSourceFingerprint.ps1' + + function Get-BundleFingerprintFixture { + param([Parameter(Mandatory)][string]$Root) + + $ModuleRoot = Join-Path $Root 'src\DLLPickle\Private' + $BuildRoot = Join-Path $Root 'src\DLLPickle.Build' + $null = New-Item -Path $ModuleRoot -ItemType Directory -Force + $null = New-Item -Path $BuildRoot -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $Root 'src\DLLPickle\DLLPickle.psd1') -Value '@{ ModuleVersion = ''0.0.0'' }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $ModuleRoot 'Get-Thing.ps1') -Value 'function Get-Thing { ''thing'' }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildRoot 'DLLPickle.csproj') -Value '' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildRoot 'packages.lock.json') -Value '{ "version": 2 }' -Encoding UTF8 + $Root + } +} + +Describe 'Published bundle source fingerprint' -Tag 'Unit' { + It 'is stable across repository roots and excludes non-bundle files' { + $FirstRoot = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'first') + $SecondRoot = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'second') + $null = New-Item -Path (Join-Path $SecondRoot 'docs') -ItemType Directory + Set-Content -LiteralPath (Join-Path $SecondRoot 'docs\note.md') -Value 'not published' -Encoding UTF8 + + $First = & $script:ToolPath -RepositoryRoot $FirstRoot + $Second = & $script:ToolPath -RepositoryRoot $SecondRoot + + $First.fingerprint | Should -BeExactly $Second.fingerprint + @($First.files.path) | Should -Be @( + 'src/DLLPickle.Build/DLLPickle.csproj' + 'src/DLLPickle.Build/packages.lock.json' + 'src/DLLPickle/DLLPickle.psd1' + 'src/DLLPickle/Private/Get-Thing.ps1' + ) + } + + It 'changes when a published source input changes' { + $Root = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'changed') + $Before = & $script:ToolPath -RepositoryRoot $Root + Set-Content -LiteralPath (Join-Path $Root 'src\DLLPickle\Private\Get-Thing.ps1') -Value 'function Get-Thing { ''changed'' }' -Encoding UTF8 + + $After = & $script:ToolPath -RepositoryRoot $Root + + $After.fingerprint | Should -Not -Be $Before.fingerprint + } + + It 'writes a self-contained file manifest when requested' { + $Root = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'report') + $OutputPath = Join-Path $TestDrive 'bundle-source-fingerprint.json' + + $Expected = & $script:ToolPath -RepositoryRoot $Root -OutputPath $OutputPath + $Actual = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + + $Actual.fingerprint | Should -BeExactly $Expected.fingerprint + @($Actual.files).Count | Should -Be 4 + Get-Content -LiteralPath $OutputPath -Raw | Should -Not -Match ([regex]::Escape($Root)) + } +} diff --git a/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 new file mode 100644 index 00000000..df842dd5 --- /dev/null +++ b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 @@ -0,0 +1,283 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:ToolPath = Join-Path $script:RepositoryRoot 'tools\Test-DLLPickleManualAuthenticatedEvidence.ps1' + $script:FingerprintToolPath = Join-Path $script:RepositoryRoot 'tools\Get-DLLPickleBundleSourceFingerprint.ps1' + $script:TestMatrixPath = Join-Path $script:RepositoryRoot 'build\powershell-test-matrix.json' + $script:DependencyPolicyPath = Join-Path $script:RepositoryRoot 'build\dependency-policy.json' + + function Get-ManualEvidenceContentFingerprint { + param([Parameter(Mandatory)][object]$Evidence) + + $CanonicalContent = $Evidence.content | ConvertTo-Json -Depth 100 -Compress + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) + [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() + } + + function Get-ManualAuthenticatedEvidenceFixture { + param( + [Parameter(Mandatory)][string]$Path, + [ValidateSet('pending', 'accepted')][string]$AcceptanceStatus = 'accepted' + ) + + $Matrix = Get-Content -LiteralPath $script:TestMatrixPath -Raw | ConvertFrom-Json + $Policy = Get-Content -LiteralPath $script:DependencyPolicyPath -Raw | ConvertFrom-Json + $Bundle = & $script:FingerprintToolPath -RepositoryRoot $script:RepositoryRoot + $ProbeMap = [ordered]@{ + graph = @('graph-context', 'graph-me-read') + exo = @('exo-mailbox-read') + az = @('az-context', 'az-resource-read', 'az-storage-account-read') + teams = @('teams-tenant-read') + cross = @('graph-context', 'graph-me-read', 'exo-mailbox-read', 'az-context', 'teams-tenant-read') + } + $AudienceMap = [ordered]@{ + graph = @('https://graph.microsoft.com') + exo = @('https://outlook.office365.com') + az = @('https://management.azure.com') + teams = @('https://api.spaces.skype.com') + cross = @('https://api.spaces.skype.com', 'https://graph.microsoft.com', 'https://management.azure.com', 'https://outlook.office365.com') + } + $Profiles = @( + foreach ($RuntimeProfile in @($Matrix.profiles)) { + $PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + $ProfilePolicy = @($Policy.runtimeProfiles | Where-Object powerShellLine -eq $PowerShellLine)[0] + $ScenarioDefinitions = @( + [pscustomobject]@{ Id = 'graph-module-only'; Provider = 'graph'; Order = @('Microsoft.Graph.Authentication') } + [pscustomobject]@{ Id = 'graph-dllpickle-first'; Provider = 'graph'; Order = @('Microsoft.Graph.Authentication') } + [pscustomobject]@{ Id = 'graph-module-first'; Provider = 'graph'; Order = @('Microsoft.Graph.Authentication') } + [pscustomobject]@{ Id = 'exo-module-only'; Provider = 'exo'; Order = @('ExchangeOnlineManagement') } + [pscustomobject]@{ Id = 'exo-dllpickle-first'; Provider = 'exo'; Order = @('ExchangeOnlineManagement') } + [pscustomobject]@{ Id = 'exo-module-first'; Provider = 'exo'; Order = @('ExchangeOnlineManagement') } + [pscustomobject]@{ Id = 'az-module-only'; Provider = 'az'; Order = @('Az.Accounts', 'Az.Resources', 'Az.Storage') } + [pscustomobject]@{ Id = 'az-dllpickle-first'; Provider = 'az'; Order = @('Az.Accounts', 'Az.Resources', 'Az.Storage') } + [pscustomobject]@{ Id = 'az-module-first'; Provider = 'az'; Order = @('Az.Accounts', 'Az.Resources', 'Az.Storage') } + [pscustomobject]@{ Id = 'teams-module-only'; Provider = 'teams'; Order = @('MicrosoftTeams') } + [pscustomobject]@{ Id = 'teams-dllpickle-first'; Provider = 'teams'; Order = @('MicrosoftTeams') } + [pscustomobject]@{ Id = 'teams-module-first'; Provider = 'teams'; Order = @('MicrosoftTeams') } + [pscustomobject]@{ Id = 'cross-import-order-1'; Provider = 'cross'; Order = @($ProfilePolicy.importOrders[0]) } + [pscustomobject]@{ Id = 'cross-import-order-2'; Provider = 'cross'; Order = @($ProfilePolicy.importOrders[1]) } + ) + $Scenarios = @( + foreach ($Definition in $ScenarioDefinitions) { + [ordered]@{ + scenarioId = $Definition.Id + profileKey = 'ps{0}-{1}-windows-x64' -f $PowerShellLine, $RuntimeProfile.targetFramework + powerShellVersion = [string]$RuntimeProfile.powerShellVersion + targetFramework = [string]$RuntimeProfile.targetFramework + platform = 'windows' + architecture = 'x64' + importOrder = @($Definition.Order) + dllPickleTiming = if ($Definition.Id -like 'cross-*' -or $Definition.Id -like '*-dllpickle-first') { + 'dllpickle-first' + } elseif ($Definition.Id -like '*-module-first') { + 'module-first' + } else { + 'module-only' + } + expectedTokenAudiences = @($AudienceMap[$Definition.Provider]) + status = 'passed' + writesPerformed = $false + errorType = $null + probes = @( + foreach ($ProbeId in @($ProbeMap[$Definition.Provider])) { + [ordered]@{ + probeId = $ProbeId + executed = $true + status = 'passed' + durationMilliseconds = 100 + writesPerformed = $false + errorType = $null + } + } + ) + snapshots = @( + foreach ($Stage in @('before-authentication', 'after-connection', 'after-read-probe')) { + [ordered]@{ + stage = $Stage + assemblies = @( + [ordered]@{ + name = 'Microsoft.Identity.Client' + version = '4.82.1.0' + sha256 = 'a' * 64 + selectedAsset = 'upstream:Synthetic/1.0/lib/Microsoft.Identity.Client.dll' + assemblyLoadContext = 'Default' + isCollectible = $false + } + ) + } + } + ) + } + } + ) + [ordered]@{ + profileKey = 'ps{0}-{1}-windows-x64' -f $PowerShellLine, $RuntimeProfile.targetFramework + powerShellVersion = [string]$RuntimeProfile.powerShellVersion + powerShellLine = $PowerShellLine + dotNetVersion = [string]$RuntimeProfile.dotnetRuntimeVersion + dotNetMajor = [int]$RuntimeProfile.dotnetMajor + targetFramework = [string]$RuntimeProfile.targetFramework + platform = 'windows' + architecture = 'x64' + runtimeExecutable = 'runtime:pwsh.exe' + psHome = 'runtime:.' + writesPerformed = $false + moduleVersions = @( + foreach ($ModuleName in @($Policy.monitoredModules.name | Sort-Object)) { + [ordered]@{ + name = [string]$ModuleName + version = '1.0.0' + manifest = "upstream:$ModuleName/1.0.0/$ModuleName.psd1" + } + } + ) + scenarios = $Scenarios + } + } + ) + $Evidence = [pscustomobject][ordered]@{ + schemaVersion = 1 + evidenceType = 'manual-interactive-transition' + contentFingerprint = $null + provenance = [ordered]@{ + sourceCommitSha = 'b' * 40 + captureStartedAtUtc = '2026-08-09T12:00:00Z' + captureCompletedAtUtc = '2026-08-09T13:00:00Z' + } + acceptance = [ordered]@{ + status = $AcceptanceStatus + acceptedAtUtc = if ($AcceptanceStatus -eq 'accepted') { '2026-08-09T14:00:00Z' } else { $null } + acceptedBy = if ($AcceptanceStatus -eq 'accepted') { 'maintainer' } else { $null } + confidence = if ($AcceptanceStatus -eq 'accepted') { 'high' } else { $null } + } + content = [ordered]@{ + bridge = [ordered]@{ + id = 'initial-powershell-7.4-7.6-multitargeting-major' + allowedReleaseVersion = '3.0.0' + expiresAtUtc = '2026-09-08T13:00:00Z' + } + bundleSourceFingerprint = [string]$Bundle.fingerprint + credentialMode = 'delegated-interactive' + credentialMaterialCaptured = $false + authorizationBoundaryValidated = $false + platformScope = 'windows-x64-only' + writesPerformed = $false + profiles = $Profiles + } + } + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $Path -Encoding UTF8 + $Evidence + } +} + +Describe 'Time-bounded manual authenticated evidence' -Tag 'Unit' { + It 'accepts only the complete exact Windows profile transition record' { + $EvidencePath = Join-Path $TestDrive 'accepted.json' + $null = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + + $Result = & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' + + $Result.AllowedReleaseVersion | Should -Be '3.0.0' + @($Result.ProfileKeys) | Should -HaveCount 3 + $Result.WritesPerformed | Should -BeFalse + } + + It 'validates a pending capture without allowing it as release evidence' { + $EvidencePath = Join-Path $TestDrive 'pending.json' + $null = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath -AcceptanceStatus pending + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -Mode Capture -NowUtc '2026-08-10T00:00:00Z' } | Should -Not -Throw + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -Mode Release -NowUtc '2026-08-10T00:00:00Z' } | Should -Throw '*not been explicitly accepted*' + } + + It 'rejects expired evidence' { + $EvidencePath = Join-Path $TestDrive 'expired.json' + $null = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + + $ExpiredAt = [System.DateTimeOffset]::Parse('2026-09-09T13:00:00Z') + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc $ExpiredAt } | + Should -Throw '*expired*' + } + + It 'rejects a different bundle even when the evidence is re-fingerprinted' { + $EvidencePath = Join-Path $TestDrive 'bundle-mismatch.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.bundleSourceFingerprint = 'f' * 64 + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*bound to bundle*current bundle*' + } + + It 'rejects missing authenticated read coverage' { + $EvidencePath = Join-Path $TestDrive 'missing-probe.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $GraphScenario = $Evidence.content.profiles[0].scenarios | Where-Object scenarioId -eq 'graph-module-only' + $GraphScenario.probes = @($GraphScenario.probes | Where-Object probeId -ne 'graph-me-read') + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*Probe coverage*does not match the required set*' + } + + It 'rejects any indication of a write' { + $EvidencePath = Join-Path $TestDrive 'write.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.writesPerformed = $true + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*zero-write*' + } + + It 'rejects a checkpoint copied from a different runtime profile' { + $EvidencePath = Join-Path $TestDrive 'wrong-scenario-profile.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.profiles[0].scenarios[0].profileKey = 'ps7.6-net10.0-windows-x64' + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*not bound to exact profile*' + } + + It 'rejects unknown fields that could carry unsanitized provider data' { + $EvidencePath = Join-Path $TestDrive 'unknown-provider-data.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.profiles[0].scenarios[0]['rawProviderResult'] = 'redacted-placeholder' + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*Authenticated scenario*properties does not match the required set*' + } + + It 'rejects content tampering before semantic checks' { + $EvidencePath = Join-Path $TestDrive 'tampered.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.bridge.allowedReleaseVersion = '3.0.1' + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*does not recompute*' + } + + It 'records explicit acceptance without changing fingerprinted evidence content' { + $CandidatePath = Join-Path $TestDrive 'candidate.json' + $AcceptedPath = Join-Path $TestDrive 'accepted-output.json' + $Candidate = Get-ManualAuthenticatedEvidenceFixture -Path $CandidatePath -AcceptanceStatus pending + $AcceptanceTool = Join-Path $script:RepositoryRoot 'tools\Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1' + + $Result = & $AcceptanceTool -CandidateEvidencePath $CandidatePath -OutputPath $AcceptedPath -AcceptedBy 'maintainer' -Confidence high -AcceptedAtUtc ([System.DateTimeOffset]::Parse('2026-08-09T14:00:00Z')) -Confirm:$false + $Accepted = Get-Content -LiteralPath $AcceptedPath -Raw | ConvertFrom-Json + + $Accepted.acceptance.status | Should -Be 'accepted' + $Accepted.acceptance.confidence | Should -Be 'high' + $Accepted.contentFingerprint | Should -BeExactly $Candidate.contentFingerprint + $Result.AllowedReleaseVersion | Should -Be '3.0.0' + $Result.WritesPerformed | Should -BeFalse + } +} diff --git a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 new file mode 100644 index 00000000..cf49a376 --- /dev/null +++ b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 @@ -0,0 +1,59 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:ScenarioHarness = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Invoke-DLLPickleManualAuthenticatedScenario.ps1') -Raw + $script:Orchestrator = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Invoke-DLLPickleManualAuthenticatedCompatibility.ps1') -Raw + $script:Initializer = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Initialize-DLLPickleManualAuthenticatedCompatibility.ps1') -Raw + $script:SchemaPath = Join-Path $script:RepositoryRoot 'build\authenticated-evidence\manual-transition.schema.json' +} + +Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { + It 'hard-codes interactive connections and real read probes without accepting command text' { + $script:ScenarioHarness | Should -Match ([regex]::Escape("Connect-MgGraph -Scopes 'User.Read' -ContextScope Process")) + $script:ScenarioHarness | Should -Match ([regex]::Escape("Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me?`$select=id'")) + $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-EXOMailbox -ResultSize 1')) + $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-AzResource -ErrorAction Stop')) + $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-AzStorageAccount -ErrorAction Stop')) + $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-CsTenant -ErrorAction Stop')) + $script:ScenarioHarness | Should -Not -Match 'Invoke-Expression|ScriptBlock|AccessToken|ClientSecret|Certificate' + } + + It 'captures only sanitized error types and zero-write results' { + $script:ScenarioHarness | Should -Match ([regex]::Escape('errorType = $_.Exception.GetType().FullName')) + $script:ScenarioHarness | Should -Not -Match 'Exception\.Message|ErrorDetails|ScriptStackTrace' + $script:ScenarioHarness | Should -Match ([regex]::Escape('writesPerformed = $false')) + $script:ScenarioHarness | Should -Match 'before-authentication' + $script:ScenarioHarness | Should -Match 'after-connection' + $script:ScenarioHarness | Should -Match 'after-read-probe' + } + + It 'runs every scenario in a fresh exact interactive process and supports safe resume' { + $script:Orchestrator | Should -Match ([regex]::Escape("'-NoLogo', '-NoProfile', '-File', `$ChildHarnessPath")) + $script:Orchestrator | Should -Not -Match ([regex]::Escape("'-NonInteractive'")) + ([regex]::Matches($script:Orchestrator, "'[a-z]+-(?:module-only|dllpickle-first|module-first)'" )).Count | Should -Be 12 + $script:Orchestrator | Should -Match 'cross-import-order-1' + $script:Orchestrator | Should -Match 'cross-import-order-2' + $script:Orchestrator | Should -Match 'Reusing passing checkpoint' + $script:Orchestrator | Should -Match ([regex]::Escape("'-ExpectedProfileKey', `$CurrentProfileKey")) + $script:ScenarioHarness | Should -Match ([regex]::Escape('profileKey = $ActualProfileKey')) + $script:Orchestrator | Should -Match "status = 'pending'" + } + + It 'prepares exact pinned runtimes and refreshes latest compatible modules without authenticating' { + $script:Initializer | Should -Match ([regex]::Escape("Provider = 'DirectArchive'")) + $script:Initializer | Should -Match ([regex]::Escape("Platform = 'windows'")) + $script:Initializer | Should -Match ([regex]::Escape('Force = $true')) + $script:Initializer | Should -Match 'AuthenticationPerformed = \$false' + $script:Initializer | Should -Not -Match 'Connect-MgGraph|Connect-ExchangeOnline|Connect-AzAccount|Connect-MicrosoftTeams' + } + + It 'ships a parseable schema fixed to one release and no credential-bearing content' { + $Schema = Get-Content -LiteralPath $script:SchemaPath -Raw | ConvertFrom-Json -ErrorAction Stop + $Schema.properties.content.properties.bridge.properties.allowedReleaseVersion.const | Should -Be '3.0.0' + $Schema.properties.content.properties.credentialMaterialCaptured.const | Should -BeFalse + $Schema.properties.content.properties.writesPerformed.const | Should -BeFalse + $Schema.properties.content.properties.platformScope.const | Should -Be 'windows-x64-only' + $Schema.'$defs'.scenario.additionalProperties | Should -BeFalse + $Schema.'$defs'.scenario.required | Should -Contain 'profileKey' + $Schema.'$defs'.profile.properties.scenarios.minItems | Should -Be 14 + } +} diff --git a/tests/Unit/SupportDocumentation.Tests.ps1 b/tests/Unit/SupportDocumentation.Tests.ps1 index 67f78460..1c73a9e1 100644 --- a/tests/Unit/SupportDocumentation.Tests.ps1 +++ b/tests/Unit/SupportDocumentation.Tests.ps1 @@ -31,10 +31,10 @@ Describe 'Generated support documentation' -Tag 'Unit' { $TestMatrixPath = Join-Path $FixtureRoot 'matrix.json' $DependencyPolicyPath = Join-Path $FixtureRoot 'dependency.json' $OutputDirectory = Join-Path $FixtureRoot 'generated' - $EvidencePath = Join-Path $EvidenceDirectory 'ps7.6-net10.0-windows-x64.json' + $EvidencePath = Join-Path $EvidenceDirectory 'ps7.6-net10.0-windows-arm64.json' $EvidenceContent = [ordered]@{ - profile = [ordered]@{ profileKey = 'ps7.6-net10.0-windows-x64' } + profile = [ordered]@{ profileKey = 'ps7.6-net10.0-windows-arm64' } modules = @( [ordered]@{ name = 'Synthetic.One' @@ -59,7 +59,7 @@ Describe 'Generated support documentation' -Tag 'Unit' { provenance = [ordered]@{ sourceRunId = '12345' sourceRunUrl = 'https://example.invalid/runs/12345' - capturedAtUtc = '2026-08-09T12:00:00Z' + capturedAtUtc = '2026-08-10T02:30:00Z' } content = $EvidenceContent } | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 @@ -88,6 +88,12 @@ Describe 'Generated support documentation' -Tag 'Unit' { lifecycleEndDate = '2026-11-10' } ) + lanes = @( + [ordered]@{ + platform = 'windows' + architecture = 'arm64' + } + ) } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $TestMatrixPath -Encoding UTF8 [ordered]@{ monitoredModules = @( @@ -104,7 +110,7 @@ Describe 'Generated support documentation' -Tag 'Unit' { baselines = [ordered]@{ windows = [ordered]@{ status = 'accepted' - evidencePath = 'profile-evidence/ps7.6-net10.0-windows-x64.json' + evidencePath = 'profile-evidence/ps7.6-net10.0-windows-arm64.json' evidenceFingerprint = $EvidenceFingerprint } } @@ -118,6 +124,7 @@ Describe 'Generated support documentation' -Tag 'Unit' { $Compatibility | Should -Match 'upstream:Synthetic\.One/1\.10\.0/lib/Microsoft\.Identity\.Client\.dll' $Compatibility | Should -Match 'Microsoft\.Identity\.Client.*4\.82\.1\.0.*Default' $Compatibility | Should -Match '\[12345\]\(https://example\.invalid/runs/12345\)' + $Compatibility | Should -Match '\| 2026-08-10 \| \[12345\]' } It 'keeps the primary documentation linked to the generated support contract' { @@ -137,5 +144,7 @@ Describe 'Generated support documentation' -Tag 'Unit' { $Compatibility | Should -Match 'PR #215' $Compatibility | Should -Match 'Issue #242' $Compatibility | Should -Match 'not executed without approved credentials' + $Compatibility | Should -Match 'manual transition record for version `3\.0\.0`' + $Compatibility | Should -Match 'not least-privilege workload-identity proof' } } diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index 61a3bec0..b44570dd 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { $DependabotWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Dependabot-Auto-Approve.yml') -Raw $DependabotConfig = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\dependabot.yml') -Raw $ReleaseWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Release-and-Publish.yml') -Raw + $ManualAuthValidator = Get-Content -LiteralPath (Join-Path $ProjectRoot 'tools\Test-DLLPickleManualAuthenticatedEvidence.ps1') -Raw $LifecycleWorkflowPath = Join-Path $ProjectRoot '.github\workflows\PowerShell-Support-Lifecycle.yml' $BuildWorkflow = Get-Content -LiteralPath (Join-Path $ProjectRoot '.github\workflows\Build Module.yml') -Raw } @@ -23,6 +24,8 @@ Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { $UpstreamWorkflow | Should -Match ([regex]::Escape("'^build/DLLPickle\.Build\.ps1$'")) $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Get-DLLPickleUpstreamInventory.ps1')) $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/Get-DLLPickleLoadedTrackedAssembly\.ps1$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/DLLPickle\.ProfileEvidence\.ps1$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/Invoke-DLLPickleBuild\.ps1$'")) $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleConflictMatrix.ps1')) $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/New-DLLPickleUpstreamScenarioEvidence.ps1')) $UpstreamWorkflow | Should -Match ([regex]::Escape("'^src/DLLPickle/'")) @@ -122,7 +125,7 @@ Describe 'Dependabot major-version draft-PR flow' -Tag 'Unit' { } Describe 'Release publish gating guardrails' -Tag 'Unit' { - It 'requires exact-commit authenticated evidence before version analysis or publication' { + It 'requires protected exact-commit or narrowly bounded manual evidence before publication' { $ReleaseWorkflow | Should -Match '(?ms)^ authenticated-release-gate:\s+name: Require Authenticated Compatibility' $ReleaseWorkflow | Should -Match '(?ms)^ authenticated-release-gate:.*?permissions:\s+actions: read\s+contents: read' $ReleaseWorkflow | Should -Match '(?m)^ needs: authenticated-release-gate\r?$' @@ -134,6 +137,12 @@ Describe 'Release publish gating guardrails' -Tag 'Unit' { $ReleaseWorkflow | Should -Not -Match ([regex]::Escape('github.event.pull_request.head.sha')) $ReleaseWorkflow | Should -Match ([regex]::Escape('requiredBeforeRelease')) $ReleaseWorkflow | Should -Match ([regex]::Escape('writesAllowed -ne $false')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleManualAuthenticatedEvidence.ps1')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('initial-multitarget-major.json')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('$EvidenceMode = ''manual-interactive-transition''')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('[string]$NewVersion -ne $env:AUTHENTICATED_ALLOWED_RELEASE_VERSION')) + $ManualAuthValidator | Should -Match ([regex]::Escape("allowedReleaseVersion -ne '3.0.0'")) + $ReleaseWorkflow | Should -Not -Match 'skipAuthentication|bypassAuthentication|allowUnauthenticated' } It 'fails closed on the runtime lifecycle policy before version analysis' { diff --git a/tools/Get-DLLPickleBundleSourceFingerprint.ps1 b/tools/Get-DLLPickleBundleSourceFingerprint.ps1 new file mode 100644 index 00000000..7b90aac3 --- /dev/null +++ b/tools/Get-DLLPickleBundleSourceFingerprint.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS +Computes a deterministic fingerprint of every published-bundle source input. + +.DESCRIPTION +Hashes the exact paths used by the release workflow's automatic bundle-change +gate: src/DLLPickle/**, DLLPickle.csproj, and packages.lock.json. Repository-root +paths and timestamps are excluded. The result can bind transitional manual +authenticated evidence to bundle content even though committing that evidence +necessarily changes the Git commit SHA. + +.PARAMETER RepositoryRoot +Repository root containing src/DLLPickle and src/DLLPickle.Build. + +.PARAMETER OutputPath +Optional JSON report path. + +.OUTPUTS +System.Management.Automation.PSCustomObject +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$RepositoryRoot = (Split-Path -Path $PSScriptRoot -Parent), + + [Parameter()] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$ResolvedRepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$ModuleSourceRoot = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle' +$BuildProjectPath = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle.Build/DLLPickle.csproj' +$LockFilePath = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle.Build/packages.lock.json' +if (-not (Test-Path -LiteralPath $ModuleSourceRoot -PathType Container)) { + throw "Published module source directory was not found: $ModuleSourceRoot" +} +foreach ($RequiredFile in @($BuildProjectPath, $LockFilePath)) { + if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { + throw "Published bundle input was not found: $RequiredFile" + } +} + +$SourceFiles = @( + Get-ChildItem -LiteralPath $ModuleSourceRoot -File -Recurse + Get-Item -LiteralPath $BuildProjectPath + Get-Item -LiteralPath $LockFilePath +) | Sort-Object FullName -Unique +if ($SourceFiles.Count -eq 0) { + throw 'No published bundle source inputs were found.' +} + +$Rows = @( + foreach ($SourceFile in $SourceFiles) { + if (($SourceFile.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Published bundle input must not be a symbolic link or reparse point: $($SourceFile.FullName)" + } + $RelativePath = [System.IO.Path]::GetRelativePath($ResolvedRepositoryRoot, $SourceFile.FullName).Replace('\', '/') + if ($RelativePath.StartsWith('../', [System.StringComparison]::Ordinal)) { + throw "Published bundle input escaped the repository root: $($SourceFile.FullName)" + } + $ContentBytes = [System.IO.File]::ReadAllBytes($SourceFile.FullName) + $Sha256 = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($ContentBytes)).Replace('-', '').ToLowerInvariant() + [ordered]@{ + path = $RelativePath + sha256 = $Sha256 + length = [long]$SourceFile.Length + } + } +) +$CanonicalRows = @( + 'schemaVersion=1' + $Rows | ForEach-Object { '{0}|{1}|{2}' -f $_.path, $_.sha256, $_.length } +) -join [char]10 +$FingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalRows) +$Fingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($FingerprintBytes)).Replace('-', '').ToLowerInvariant() +$Report = [pscustomobject][ordered]@{ + schemaVersion = 1 + fingerprint = $Fingerprint + files = $Rows +} + +if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputDirectory = Split-Path -Path $OutputPath -Parent + if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force + } + $Report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +} +$Report diff --git a/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 b/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 new file mode 100644 index 00000000..18b2eb95 --- /dev/null +++ b/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 @@ -0,0 +1,95 @@ +<# +.SYNOPSIS +Prepares exact runtimes, latest compatible modules, and DLLPickle output for manual authentication tests. + +.DESCRIPTION +Performs no authentication and no service calls. It builds the local module, +installs the checksum-pinned Windows x64 PowerShell runtimes from the canonical +matrix, and resolves/downloads the latest compatible monitored modules into an +isolated gitignored work root. +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$WorkRoot = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'artifacts/manual-authenticated'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'build/powershell-test-matrix.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$PolicyPath = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'build/dependency-policy.json') +) + +$ErrorActionPreference = 'Stop' +$RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent +$ResolvedWorkRoot = [System.IO.Path]::GetFullPath($WorkRoot) +$RuntimeInstallRoot = Join-Path $ResolvedWorkRoot 'runtimes' +$ModuleCacheParent = Join-Path $ResolvedWorkRoot 'module-cache' +$InventoryRoot = Join-Path $ResolvedWorkRoot 'inventories' +$DLLPickleManifestPath = Join-Path $RepositoryRoot 'module/DLLPickle/DLLPickle.psd1' + +& (Join-Path $RepositoryRoot 'tools/Invoke-DLLPickleBuild.ps1') -Task PrepareModuleOutput +if (-not (Test-Path -LiteralPath $DLLPickleManifestPath -PathType Leaf)) { + throw "DLLPickle build output was not created: $DLLPickleManifestPath" +} +$Matrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$PreparedProfiles = @( + foreach ($RuntimeProfile in @($Matrix.profiles)) { + $PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + $ProfileKey = 'ps{0}-{1}-windows-x64' -f $PowerShellLine, $RuntimeProfile.targetFramework + Write-Information -MessageData "Preparing $ProfileKey with PowerShell $($RuntimeProfile.powerShellVersion)..." -InformationAction Continue + $InstallParameters = @{ + Provider = 'DirectArchive' + PowerShellVersion = [string]$RuntimeProfile.powerShellVersion + Platform = 'windows' + Architecture = 'x64' + InstallRoot = $RuntimeInstallRoot + PassThru = $true + } + $Identity = & (Join-Path $RepositoryRoot 'tools/Install-DLLPickleTestPowerShell.ps1') @InstallParameters + $ProfileInventoryRoot = Join-Path $InventoryRoot $ProfileKey + $InventoryPath = Join-Path $ProfileInventoryRoot 'upstream-inventory.json' + $ModuleCachePath = Join-Path $ModuleCacheParent ([string]$RuntimeProfile.powerShellVersion) + $InventoryParameters = @{ + PolicyPath = $PolicyPath + TestMatrixPath = $TestMatrixPath + ModuleCachePath = $ModuleCachePath + OutputPath = $InventoryPath + PowerShellExecutable = [string]$Identity.ExecutablePath + Force = $true + } + $Inventory = & (Join-Path $RepositoryRoot 'tools/Get-DLLPickleUpstreamInventory.ps1') @InventoryParameters + $StaleSelections = @($Inventory.Modules | Where-Object Version -ne LatestCompatibleVersion) + if ($StaleSelections.Count -gt 0) { + throw "The prepared inventory for '$ProfileKey' did not select every latest compatible module." + } + $PreparedProfile = [pscustomobject]@{ + ProfileKey = $ProfileKey + PowerShellVersion = [string]$RuntimeProfile.powerShellVersion + TargetFramework = [string]$RuntimeProfile.targetFramework + ExecutablePath = [string]$Identity.ExecutablePath + InventoryPath = $InventoryPath + ModuleVersions = [ordered]@{} + } + foreach ($Module in @($Inventory.Modules | Sort-Object Name)) { + $PreparedProfile.ModuleVersions[[string]$Module.Name] = [string]$Module.Version + } + $PreparedProfile + } +) +$BundleFingerprint = & (Join-Path $RepositoryRoot 'tools/Get-DLLPickleBundleSourceFingerprint.ps1') -RepositoryRoot $RepositoryRoot -OutputPath (Join-Path $ResolvedWorkRoot 'bundle-source-fingerprint.json') +$Summary = [pscustomobject]@{ + WorkRoot = $ResolvedWorkRoot + DLLPickleManifestPath = $DLLPickleManifestPath + BundleSourceFingerprint = [string]$BundleFingerprint.fingerprint + Profiles = $PreparedProfiles + AuthenticationPerformed = $false + WritesPerformed = $false +} +$Summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $ResolvedWorkRoot 'preparation-summary.json') -Encoding utf8NoBOM +$Summary diff --git a/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 new file mode 100644 index 00000000..242e831c --- /dev/null +++ b/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 @@ -0,0 +1,271 @@ +<# +.SYNOPSIS +Collects resumable, sanitized interactive authentication evidence for the initial multi-target release. + +.DESCRIPTION +Runs fixed scenarios under each prepared exact Windows PowerShell executable. +Every scenario is a fresh process. Existing passing scenario checkpoints are +reused only when the source commit and bundle fingerprint match. The final +candidate remains pending until a maintainer reviews and explicitly accepts it. + +No token, tenant, account, mailbox, subscription, resource, or raw service +result is written to evidence. +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$WorkRoot = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'artifacts/manual-authenticated'), + + [Parameter()] + [string[]]$ProfileKey, + + [Parameter()] + [string[]]$ScenarioId, + + [Parameter()] + [switch]$RerunCompleted, + + [Parameter()] + [string]$AzureSubscriptionId = $env:DLLPICKLE_MANUAL_AZURE_SUBSCRIPTION_ID, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent +$ResolvedWorkRoot = [System.IO.Path]::GetFullPath($WorkRoot) +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $ResolvedWorkRoot 'manual-authenticated-evidence.candidate.json' +} +$PreparationSummaryPath = Join-Path $ResolvedWorkRoot 'preparation-summary.json' +$SessionPath = Join-Path $ResolvedWorkRoot 'capture-session.json' +$ScenarioRoot = Join-Path $ResolvedWorkRoot 'scenarios' +$MatrixPath = Join-Path $RepositoryRoot 'build/powershell-test-matrix.json' +$PolicyPath = Join-Path $RepositoryRoot 'build/dependency-policy.json' +$DLLPickleManifestPath = Join-Path $RepositoryRoot 'module/DLLPickle/DLLPickle.psd1' +$ChildHarnessPath = Join-Path $PSScriptRoot 'Invoke-DLLPickleManualAuthenticatedScenario.ps1' +$ValidatorPath = Join-Path $PSScriptRoot 'Test-DLLPickleManualAuthenticatedEvidence.ps1' + +foreach ($RequiredPath in @($PreparationSummaryPath, $MatrixPath, $PolicyPath, $DLLPickleManifestPath, $ChildHarnessPath, $ValidatorPath)) { + if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { + throw "Manual authenticated compatibility preparation is incomplete: $RequiredPath" + } +} +$Preparation = Get-Content -LiteralPath $PreparationSummaryPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Bundle = & (Join-Path $PSScriptRoot 'Get-DLLPickleBundleSourceFingerprint.ps1') -RepositoryRoot $RepositoryRoot +$SourceCommitSha = (git -C $RepositoryRoot rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or $SourceCommitSha -notmatch '^[a-f0-9]{40}$') { + throw 'Could not resolve the source commit for manual authenticated evidence.' +} +if ([string]$Preparation.BundleSourceFingerprint -ne [string]$Bundle.fingerprint) { + throw 'Prepared modules/runtimes belong to a different bundle fingerprint. Rerun Initialize-DLLPickleManualAuthenticatedCompatibility.ps1.' +} + +if (Test-Path -LiteralPath $SessionPath -PathType Leaf) { + $Session = Get-Content -LiteralPath $SessionPath -Raw | ConvertFrom-Json -ErrorAction Stop + if ([string]$Session.sourceCommitSha -ne $SourceCommitSha -or [string]$Session.bundleSourceFingerprint -ne [string]$Bundle.fingerprint) { + throw "The existing capture session belongs to another commit or bundle. Preserve it and choose a new -WorkRoot." + } +} else { + $Session = [pscustomobject][ordered]@{ + schemaVersion = 1 + sourceCommitSha = $SourceCommitSha + bundleSourceFingerprint = [string]$Bundle.fingerprint + captureStartedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') + credentialMode = 'delegated-interactive' + credentialMaterialCaptured = $false + writesPerformed = $false + } + $null = New-Item -Path $ResolvedWorkRoot -ItemType Directory -Force + $Session | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SessionPath -Encoding utf8NoBOM +} + +$AllScenarioIds = @( + 'graph-module-only', 'graph-dllpickle-first', 'graph-module-first', + 'exo-module-only', 'exo-dllpickle-first', 'exo-module-first', + 'az-module-only', 'az-dllpickle-first', 'az-module-first', + 'teams-module-only', 'teams-dllpickle-first', 'teams-module-first', + 'cross-import-order-1', 'cross-import-order-2' +) +$AllProfileKeys = @( + $Matrix.profiles | ForEach-Object { + 'ps{0}.{1}-{2}-windows-x64' -f $_.powerShellMajor, $_.powerShellMinor, $_.targetFramework + } +) +$SelectedProfileKeys = if ($ProfileKey.Count -gt 0) { @($ProfileKey) } else { $AllProfileKeys } +$SelectedScenarioIds = if ($ScenarioId.Count -gt 0) { @($ScenarioId) } else { $AllScenarioIds } +$UnknownProfiles = @($SelectedProfileKeys | Where-Object { $_ -notin $AllProfileKeys }) +$UnknownScenarios = @($SelectedScenarioIds | Where-Object { $_ -notin $AllScenarioIds }) +if ($UnknownProfiles.Count -gt 0 -or $UnknownScenarios.Count -gt 0) { + throw "Unsupported profile/scenario selection. Profiles: '$($UnknownProfiles -join ', ')'; scenarios: '$($UnknownScenarios -join ', ')'." +} + +foreach ($CurrentProfileKey in $SelectedProfileKeys) { + $PreparedProfiles = @($Preparation.Profiles | Where-Object ProfileKey -eq $CurrentProfileKey) + if ($PreparedProfiles.Count -ne 1) { throw "Preparation summary has no unique '$CurrentProfileKey' profile." } + $PreparedProfile = $PreparedProfiles[0] + foreach ($CurrentScenarioId in $SelectedScenarioIds) { + $ScenarioOutputPath = Join-Path $ScenarioRoot "$CurrentProfileKey/$CurrentScenarioId.json" + if (-not $RerunCompleted.IsPresent -and (Test-Path -LiteralPath $ScenarioOutputPath -PathType Leaf)) { + $ExistingScenario = Get-Content -LiteralPath $ScenarioOutputPath -Raw | ConvertFrom-Json -ErrorAction Stop + if ([string]$ExistingScenario.status -eq 'passed' -and + [string]$ExistingScenario.scenarioId -eq $CurrentScenarioId -and + [string]$ExistingScenario.profileKey -eq $CurrentProfileKey -and + [string]$ExistingScenario.powerShellVersion -eq [string]$PreparedProfile.PowerShellVersion -and + [string]$ExistingScenario.targetFramework -eq [string]$PreparedProfile.TargetFramework -and + [string]$ExistingScenario.platform -eq 'windows' -and + [string]$ExistingScenario.architecture -eq 'x64') { + Write-Information -MessageData "Reusing passing checkpoint: $CurrentProfileKey / $CurrentScenarioId" -InformationAction Continue + continue + } + } + + Write-Information -MessageData '' -InformationAction Continue + Write-Information -MessageData "Interactive scenario: $CurrentProfileKey / $CurrentScenarioId" -InformationAction Continue + Write-Information -MessageData 'Complete only the provider sign-in prompts shown by the fixed child harness. No raw service output is retained.' -InformationAction Continue + $ChildArguments = @( + '-NoLogo', '-NoProfile', '-File', $ChildHarnessPath, + '-ScenarioId', $CurrentScenarioId, + '-InventoryPath', [string]$PreparedProfile.InventoryPath, + '-PolicyPath', $PolicyPath, + '-DLLPickleManifestPath', $DLLPickleManifestPath, + '-OutputPath', $ScenarioOutputPath, + '-ExpectedProfileKey', $CurrentProfileKey, + '-ExpectedPowerShellVersion', [string]$PreparedProfile.PowerShellVersion, + '-ExpectedTargetFramework', [string]$PreparedProfile.TargetFramework + ) + if (-not [string]::IsNullOrWhiteSpace($AzureSubscriptionId)) { + $ChildArguments += @('-AzureSubscriptionId', $AzureSubscriptionId) + } + & ([string]$PreparedProfile.ExecutablePath) @ChildArguments + if ($LASTEXITCODE -ne 0) { + throw "Interactive scenario '$CurrentProfileKey/$CurrentScenarioId' failed. Correct the sign-in or authorization problem, then rerun; passing checkpoints will be reused." + } + } +} + +$MissingScenarioPaths = @( + foreach ($ExpectedProfileKey in $AllProfileKeys) { + foreach ($ExpectedScenarioId in $AllScenarioIds) { + $ExpectedPath = Join-Path $ScenarioRoot "$ExpectedProfileKey/$ExpectedScenarioId.json" + if (-not (Test-Path -LiteralPath $ExpectedPath -PathType Leaf)) { $ExpectedPath } + } + } +) +if ($MissingScenarioPaths.Count -gt 0) { + return [pscustomobject]@{ + Complete = $false + MissingScenarioCount = $MissingScenarioPaths.Count + CandidateEvidencePath = $null + WritesPerformed = $false + } +} + +function ConvertTo-UpstreamManifestIdentifier { + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$ModuleCachePath + ) + + $NormalizedManifest = $ManifestPath.Replace('\', '/') + $NormalizedRoot = $ModuleCachePath.Replace('\', '/').TrimEnd('/') + if (-not $NormalizedManifest.StartsWith("$NormalizedRoot/", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Module manifest '$ManifestPath' is outside its prepared module cache." + } + 'upstream:{0}' -f $NormalizedManifest.Substring($NormalizedRoot.Length + 1) +} + +$Profiles = @( + foreach ($RuntimeProfile in @($Matrix.profiles)) { + $PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + $CurrentProfileKey = 'ps{0}-{1}-windows-x64' -f $PowerShellLine, $RuntimeProfile.targetFramework + $PreparedProfile = @($Preparation.Profiles | Where-Object ProfileKey -eq $CurrentProfileKey)[0] + $Inventory = Get-Content -LiteralPath ([string]$PreparedProfile.InventoryPath) -Raw | ConvertFrom-Json -ErrorAction Stop + $ScenarioRows = @( + foreach ($ExpectedScenarioId in $AllScenarioIds) { + $ScenarioPath = Join-Path $ScenarioRoot "$CurrentProfileKey/$ExpectedScenarioId.json" + Get-Content -LiteralPath $ScenarioPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + ) + [ordered]@{ + profileKey = $CurrentProfileKey + powerShellVersion = [string]$RuntimeProfile.powerShellVersion + powerShellLine = $PowerShellLine + dotNetVersion = [string]$RuntimeProfile.dotnetRuntimeVersion + dotNetMajor = [int]$RuntimeProfile.dotnetMajor + targetFramework = [string]$RuntimeProfile.targetFramework + platform = 'windows' + architecture = 'x64' + runtimeExecutable = 'runtime:{0}' -f [System.IO.Path]::GetFileName([string]$PreparedProfile.ExecutablePath) + psHome = 'runtime:.' + writesPerformed = $false + moduleVersions = @( + foreach ($Module in @($Inventory.Modules | Sort-Object Name)) { + [ordered]@{ + name = [string]$Module.Name + version = [string]$Module.Version + manifest = ConvertTo-UpstreamManifestIdentifier -ManifestPath ([string]$Module.ModuleManifestPath) -ModuleCachePath ([string]$Inventory.ModuleCachePath) + } + } + ) + scenarios = $ScenarioRows + } + } +) +$CaptureCompletedAtUtc = [System.DateTimeOffset]::UtcNow +$Content = [ordered]@{ + bridge = [ordered]@{ + id = 'initial-powershell-7.4-7.6-multitargeting-major' + allowedReleaseVersion = '3.0.0' + expiresAtUtc = $CaptureCompletedAtUtc.AddDays(14).ToString('o') + } + bundleSourceFingerprint = [string]$Bundle.fingerprint + credentialMode = 'delegated-interactive' + credentialMaterialCaptured = $false + authorizationBoundaryValidated = $false + platformScope = 'windows-x64-only' + writesPerformed = $false + profiles = $Profiles +} +$CanonicalContent = $Content | ConvertTo-Json -Depth 100 -Compress +$ContentBytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) +$ContentFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($ContentBytes)).Replace('-', '').ToLowerInvariant() +$Evidence = [ordered]@{ + schemaVersion = 1 + evidenceType = 'manual-interactive-transition' + contentFingerprint = $ContentFingerprint + provenance = [ordered]@{ + sourceCommitSha = $SourceCommitSha + captureStartedAtUtc = [string]$Session.captureStartedAtUtc + captureCompletedAtUtc = $CaptureCompletedAtUtc.ToString('o') + } + acceptance = [ordered]@{ + status = 'pending' + acceptedAtUtc = $null + acceptedBy = $null + confidence = $null + } + content = $Content +} +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +$Validation = & $ValidatorPath -EvidencePath $OutputPath -RepositoryRoot $RepositoryRoot -TestMatrixPath $MatrixPath -DependencyPolicyPath $PolicyPath -Mode Capture +[pscustomobject]@{ + Complete = $true + CandidateEvidencePath = [System.IO.Path]::GetFullPath($OutputPath) + EvidenceFingerprint = [string]$Validation.EvidenceFingerprint + BundleSourceFingerprint = [string]$Validation.BundleSourceFingerprint + AllowedReleaseVersion = [string]$Validation.AllowedReleaseVersion + ExpiresAtUtc = [string]$Validation.ExpiresAtUtc + AcceptanceStatus = 'pending' + WritesPerformed = $false +} diff --git a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 new file mode 100644 index 00000000..ef6fbbfb --- /dev/null +++ b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 @@ -0,0 +1,295 @@ +<# +.SYNOPSIS +Runs one fixed interactive authenticated compatibility scenario in the current process. + +.DESCRIPTION +This child harness is invoked by Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 +under an exact stock PowerShell executable. Authentication commands and read probes +are hard-coded. It writes sanitized JSON only: no token, tenant, account, subscription, +mailbox, resource, or raw service result is retained. +#> + +[CmdletBinding()] +param ( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ScenarioId, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$InventoryPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$PolicyPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$DLLPickleManifestPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$OutputPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ExpectedProfileKey, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ExpectedPowerShellVersion, + [Parameter(Mandatory)][ValidatePattern('^net\d+\.0$')][string]$ExpectedTargetFramework, + [Parameter()][string]$AzureSubscriptionId = $env:DLLPICKLE_MANUAL_AZURE_SUBSCRIPTION_ID +) + +$ErrorActionPreference = 'Stop' + +function ConvertTo-CollapsedAssetPath { + param([Parameter(Mandatory)][string]$Path) + + $Segments = [System.Collections.Generic.List[string]]::new() + foreach ($Segment in @($Path -split '/')) { + if ([string]::IsNullOrWhiteSpace($Segment) -or $Segment -eq '.') { continue } + if ($Segment -eq '..') { + if ($Segments.Count -eq 0) { throw "Asset path '$Path' escapes its root." } + $Segments.RemoveAt($Segments.Count - 1) + continue + } + $Segments.Add($Segment) + } + $Segments -join '/' +} + +function ConvertTo-ManualEvidencePath { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ModuleCacheRoot, + [Parameter(Mandatory)][string]$DLLPickleRoot, + [Parameter(Mandatory)][string]$RuntimeRoot + ) + + $NormalizedPath = $Path.Replace('\', '/').TrimEnd('/') + $Roots = [ordered]@{ + upstream = $ModuleCacheRoot.Replace('\', '/').TrimEnd('/') + dllpickle = $DLLPickleRoot.Replace('\', '/').TrimEnd('/') + runtime = $RuntimeRoot.Replace('\', '/').TrimEnd('/') + } + foreach ($RootEntry in $Roots.GetEnumerator()) { + if ($NormalizedPath -eq $RootEntry.Value) { return "$($RootEntry.Key):." } + if ($NormalizedPath.StartsWith("$($RootEntry.Value)/", [System.StringComparison]::OrdinalIgnoreCase)) { + $Relative = $NormalizedPath.Substring($RootEntry.Value.Length + 1) + return '{0}:{1}' -f $RootEntry.Key, (ConvertTo-CollapsedAssetPath -Path $Relative) + } + } + throw "Authenticated evidence path '$Path' is outside the upstream, DLLPickle, and exact runtime roots." +} + +function Get-SanitizedAssemblySnapshot { + $Rows = @(& $SnapshotHelper -PolicyPath $ResolvedPolicyPath) + @( + foreach ($Row in $Rows) { + [ordered]@{ + name = [string]$Row.Name + version = [string]$Row.Version + sha256 = ([string]$Row.Sha256).ToLowerInvariant() + selectedAsset = ConvertTo-ManualEvidencePath -Path ([string]$Row.Path) -ModuleCacheRoot $ModuleCacheRoot -DLLPickleRoot $DLLPickleRoot -RuntimeRoot $PSHOME + assemblyLoadContext = [string]$Row.Alc + isCollectible = [bool]$Row.IsCollectible + } + } + ) +} + +function Import-ExactModuleSet { + param([Parameter(Mandatory)][string[]]$Names) + + foreach ($Name in $Names) { + $Rows = @($Inventory.Modules | Where-Object Name -eq $Name) + if ($Rows.Count -ne 1) { throw "Inventory does not contain exactly one '$Name' module." } + Import-Module -Name ([string]$Rows[0].ModuleManifestPath) -Force -ErrorAction Stop + } +} + +function Import-DLLPickleBundle { + Import-Module -Name $ResolvedDLLPickleManifestPath -Force -ErrorAction Stop + Import-DPLibrary -SuppressLogo -ErrorAction Stop | Out-Null +} + +function Connect-Provider { + param( + [Parameter(Mandatory)][ValidateSet('graph', 'exo', 'az', 'teams')][string]$Provider, + [Parameter()][string]$SubscriptionId + ) + + switch ($Provider) { + 'graph' { + Connect-MgGraph -Scopes 'User.Read' -ContextScope Process -NoWelcome -ErrorAction Stop | Out-Null + } + 'exo' { + Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop | Out-Null + } + 'az' { + Connect-AzAccount -Scope Process -ErrorAction Stop | Out-Null + if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { + Set-AzContext -SubscriptionId $SubscriptionId -Scope Process -ErrorAction Stop | Out-Null + } + } + 'teams' { + Connect-MicrosoftTeams -ErrorAction Stop | Out-Null + } + } +} + +function Disconnect-Provider { + param([Parameter(Mandatory)][ValidateSet('graph', 'exo', 'az', 'teams')][string]$Provider) + + try { + switch ($Provider) { + 'graph' { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } + 'exo' { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } + 'az' { Clear-AzContext -Scope Process -Force -ErrorAction SilentlyContinue | Out-Null } + 'teams' { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue | Out-Null } + } + } catch { + # Cleanup failures must not replace the sanitized scenario result. + Write-Verbose "Provider cleanup for '$Provider' did not complete." + } +} + +function Invoke-ReadProbe { + param([Parameter(Mandatory)][string]$ProbeId) + + $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + switch ($ProbeId) { + 'graph-context' { + if (-not (Get-MgContext -ErrorAction Stop)) { throw 'Graph context was not established.' } + } + 'graph-me-read' { + Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me?$select=id' -ErrorAction Stop | Out-Null + } + 'exo-mailbox-read' { + if (@(Get-EXOMailbox -ResultSize 1 -ErrorAction Stop).Count -eq 0) { throw 'Exchange mailbox read returned no object.' } + } + 'az-context' { + if (-not (Get-AzContext -ErrorAction Stop)) { throw 'Azure context was not established.' } + } + 'az-resource-read' { + if (@(Get-AzResource -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure resource read returned no object.' } + } + 'az-storage-account-read' { + if (@(Get-AzStorageAccount -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure storage-account read returned no object.' } + } + 'teams-tenant-read' { + if (-not (Get-CsTenant -ErrorAction Stop)) { throw 'Teams tenant read returned no object.' } + } + default { throw "Unsupported authenticated probe identifier '$ProbeId'." } + } + $Stopwatch.Stop() + [ordered]@{ + probeId = $ProbeId + executed = $true + status = 'passed' + durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds + writesPerformed = $false + errorType = $null + } + } catch { + $Stopwatch.Stop() + [ordered]@{ + probeId = $ProbeId + executed = $true + status = 'failed' + durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds + writesPerformed = $false + errorType = $_.Exception.GetType().FullName + } + } +} + +foreach ($RequiredPath in @($InventoryPath, $PolicyPath, $DLLPickleManifestPath)) { + if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { throw "Required scenario input was not found: $RequiredPath" } +} +$ResolvedInventoryPath = (Resolve-Path -LiteralPath $InventoryPath).Path +$ResolvedPolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path +$ResolvedDLLPickleManifestPath = (Resolve-Path -LiteralPath $DLLPickleManifestPath).Path +$DLLPickleRoot = Split-Path -Path $ResolvedDLLPickleManifestPath -Parent +$SnapshotHelper = Join-Path $PSScriptRoot 'Get-DLLPickleLoadedTrackedAssembly.ps1' +$Inventory = Get-Content -LiteralPath $ResolvedInventoryPath -Raw | ConvertFrom-Json -ErrorAction Stop +$ModuleCacheRoot = [string]$Inventory.ModuleCachePath +$ActualTargetFramework = 'net{0}.0' -f [Environment]::Version.Major +$ActualProfileKey = 'ps{0}.{1}-{2}-windows-x64' -f $PSVersionTable.PSVersion.Major, $PSVersionTable.PSVersion.Minor, $ActualTargetFramework +if ($ActualProfileKey -ne $ExpectedProfileKey -or + $PSVersionTable.PSVersion.ToString() -ne $ExpectedPowerShellVersion -or + $ActualTargetFramework -ne $ExpectedTargetFramework) { + throw "Scenario runtime mismatch: expected PowerShell $ExpectedPowerShellVersion/$ExpectedTargetFramework; observed $($PSVersionTable.PSVersion)/$ActualTargetFramework." +} +if ([string]$Inventory.Profile.Platform -ne 'windows' -or [string]$Inventory.Profile.Architecture -ne 'x64') { + throw 'Manual authenticated transition evidence is restricted to Windows x64.' +} +$env:PSModulePath = @($ModuleCacheRoot, (Join-Path $PSHOME 'Modules')) -join [System.IO.Path]::PathSeparator + +$Definitions = [ordered]@{ + 'graph-module-only' = [ordered]@{ providers = @('graph'); modules = @('Microsoft.Graph.Authentication'); timing = 'module-only'; probes = @('graph-context', 'graph-me-read') } + 'graph-dllpickle-first' = [ordered]@{ providers = @('graph'); modules = @('Microsoft.Graph.Authentication'); timing = 'dllpickle-first'; probes = @('graph-context', 'graph-me-read') } + 'graph-module-first' = [ordered]@{ providers = @('graph'); modules = @('Microsoft.Graph.Authentication'); timing = 'module-first'; probes = @('graph-context', 'graph-me-read') } + 'exo-module-only' = [ordered]@{ providers = @('exo'); modules = @('ExchangeOnlineManagement'); timing = 'module-only'; probes = @('exo-mailbox-read') } + 'exo-dllpickle-first' = [ordered]@{ providers = @('exo'); modules = @('ExchangeOnlineManagement'); timing = 'dllpickle-first'; probes = @('exo-mailbox-read') } + 'exo-module-first' = [ordered]@{ providers = @('exo'); modules = @('ExchangeOnlineManagement'); timing = 'module-first'; probes = @('exo-mailbox-read') } + 'az-module-only' = [ordered]@{ providers = @('az'); modules = @('Az.Accounts', 'Az.Resources', 'Az.Storage'); timing = 'module-only'; probes = @('az-context', 'az-resource-read', 'az-storage-account-read') } + 'az-dllpickle-first' = [ordered]@{ providers = @('az'); modules = @('Az.Accounts', 'Az.Resources', 'Az.Storage'); timing = 'dllpickle-first'; probes = @('az-context', 'az-resource-read', 'az-storage-account-read') } + 'az-module-first' = [ordered]@{ providers = @('az'); modules = @('Az.Accounts', 'Az.Resources', 'Az.Storage'); timing = 'module-first'; probes = @('az-context', 'az-resource-read', 'az-storage-account-read') } + 'teams-module-only' = [ordered]@{ providers = @('teams'); modules = @('MicrosoftTeams'); timing = 'module-only'; probes = @('teams-tenant-read') } + 'teams-dllpickle-first' = [ordered]@{ providers = @('teams'); modules = @('MicrosoftTeams'); timing = 'dllpickle-first'; probes = @('teams-tenant-read') } + 'teams-module-first' = [ordered]@{ providers = @('teams'); modules = @('MicrosoftTeams'); timing = 'module-first'; probes = @('teams-tenant-read') } +} +$Policy = Get-Content -LiteralPath $ResolvedPolicyPath -Raw | ConvertFrom-Json +$ProfilePolicy = @($Policy.runtimeProfiles | Where-Object { + $_.powerShellLine -eq [string]$Inventory.Profile.PowerShellLine -and $_.targetFramework -eq $ExpectedTargetFramework + }) +if ($ProfilePolicy.Count -ne 1) { throw 'No unique dependency policy matches the authenticated scenario runtime.' } +$Definitions['cross-import-order-1'] = [ordered]@{ providers = @('graph', 'exo', 'az', 'teams'); modules = @($ProfilePolicy[0].importOrders[0]); timing = 'dllpickle-first'; probes = @('graph-context', 'graph-me-read', 'exo-mailbox-read', 'az-context', 'teams-tenant-read') } +$Definitions['cross-import-order-2'] = [ordered]@{ providers = @('graph', 'exo', 'az', 'teams'); modules = @($ProfilePolicy[0].importOrders[1]); timing = 'dllpickle-first'; probes = @('graph-context', 'graph-me-read', 'exo-mailbox-read', 'az-context', 'teams-tenant-read') } +if (-not $Definitions.Contains($ScenarioId)) { throw "Unsupported authenticated scenario '$ScenarioId'." } +$Definition = $Definitions[$ScenarioId] + +$Result = [ordered]@{ + scenarioId = $ScenarioId + profileKey = $ActualProfileKey + powerShellVersion = $PSVersionTable.PSVersion.ToString() + targetFramework = $ActualTargetFramework + platform = 'windows' + architecture = 'x64' + importOrder = @($Definition.modules) + dllPickleTiming = [string]$Definition.timing + expectedTokenAudiences = @( + foreach ($Provider in @($Definition.providers)) { + switch ($Provider) { + 'graph' { 'https://graph.microsoft.com' } + 'exo' { 'https://outlook.office365.com' } + 'az' { 'https://management.azure.com' } + 'teams' { 'https://api.spaces.skype.com' } + } + } + ) | Sort-Object -Unique + status = 'failed' + writesPerformed = $false + probes = @() + snapshots = @() + errorType = $null +} +$ConnectedProviders = [System.Collections.Generic.List[string]]::new() +try { + if ($Definition.timing -eq 'dllpickle-first') { Import-DLLPickleBundle } + Import-ExactModuleSet -Names @($Definition.modules) + $Result.snapshots += [ordered]@{ stage = 'before-authentication'; assemblies = @(Get-SanitizedAssemblySnapshot) } + foreach ($Provider in @($Definition.providers)) { + Connect-Provider -Provider $Provider -SubscriptionId $AzureSubscriptionId + $ConnectedProviders.Add($Provider) + } + $Result.snapshots += [ordered]@{ stage = 'after-connection'; assemblies = @(Get-SanitizedAssemblySnapshot) } + if ($Definition.timing -eq 'module-first') { Import-DLLPickleBundle } + $Result.probes = @( + foreach ($ProbeId in @($Definition.probes)) { Invoke-ReadProbe -ProbeId $ProbeId } + ) + $Result.snapshots += [ordered]@{ stage = 'after-read-probe'; assemblies = @(Get-SanitizedAssemblySnapshot) } + if (@($Result.probes | Where-Object status -ne 'passed').Count -eq 0) { + $Result.status = 'passed' + } +} catch { + $Result.errorType = $_.Exception.GetType().FullName +} finally { + for ($ProviderIndex = $ConnectedProviders.Count - 1; $ProviderIndex -ge 0; $ProviderIndex--) { + Disconnect-Provider -Provider $ConnectedProviders[$ProviderIndex] + } +} + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +[pscustomobject]$Result | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +if ($Result.status -ne 'passed') { + throw "Authenticated scenario '$ScenarioId' failed. Sanitized error type: '$($Result.errorType)'. See '$OutputPath'." +} +[pscustomobject]$Result diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 index fad0a89f..802c3148 100644 --- a/tools/New-DLLPickleSupportDocumentation.ps1 +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -34,6 +34,7 @@ param( ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') foreach ($RequiredPath in @($SupportPolicyPath, $TestMatrixPath, $DependencyPolicyPath)) { if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { @@ -46,12 +47,20 @@ $TestMatrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json - $DependencyPolicy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop $DependencyPolicyDirectory = Split-Path -Path (Resolve-Path -LiteralPath $DependencyPolicyPath).Path -Parent -function Get-DLLPickleEvidenceFingerprint { - param([Parameter(Mandatory)][object]$Evidence) +function ConvertTo-DLLPickleUtcDateTimeOffset { + param([Parameter(Mandatory)][object]$Value) - $CanonicalContent = $Evidence.content | ConvertTo-Json -Depth 100 -Compress - $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) - [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() + if ($Value -is [System.DateTimeOffset]) { + return $Value.ToUniversalTime() + } + if ($Value -is [System.DateTime]) { + return ([System.DateTimeOffset]$Value).ToUniversalTime() + } + [System.DateTimeOffset]::Parse( + [string]$Value, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ).ToUniversalTime() } $ShippedProfileKeys = @($SupportPolicy.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' -f $_.powerShellMajor, $_.powerShellMinor, $_.dotnetMajor, $_.targetFramework }) @@ -59,7 +68,7 @@ $TestProfileKeys = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' - if (Compare-Object -ReferenceObject $ShippedProfileKeys -DifferenceObject $TestProfileKeys) { throw 'Shipped runtime profiles and documentation test profiles do not align.' } -$VerifiedDate = ([System.DateTimeOffset]$TestMatrix.lastVerifiedUtc).ToString('yyyy-MM-dd') +$VerifiedDate = (ConvertTo-DLLPickleUtcDateTimeOffset -Value $TestMatrix.lastVerifiedUtc).ToString('yyyy-MM-dd') $NewLine = "`r`n" $SupportLines = [System.Collections.Generic.List[string]]::new() @@ -109,16 +118,20 @@ foreach ($RuntimeProfile in @($DependencyPolicy.runtimeProfiles)) { throw "Accepted profile evidence was not found: $EvidencePath" } $Evidence = Get-Content -LiteralPath $EvidencePath -Raw | ConvertFrom-Json -ErrorAction Stop - $RecomputedEvidenceFingerprint = Get-DLLPickleEvidenceFingerprint -Evidence $Evidence + $RecomputedEvidenceFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $Evidence if ([string]$Evidence.contentFingerprint -ne $RecomputedEvidenceFingerprint -or [string]$PlatformBaseline.evidenceFingerprint -ne $RecomputedEvidenceFingerprint) { throw "Accepted profile evidence does not recompute to the policy fingerprint: $EvidencePath" } - $ExpectedProfileKey = 'ps{0}-{1}-{2}-x64' -f $RuntimeProfile.powerShellLine, $RuntimeProfile.targetFramework, $Platform + $PlatformLanes = @($TestMatrix.lanes | Where-Object platform -EQ $Platform) + if ($PlatformLanes.Count -ne 1 -or [string]::IsNullOrWhiteSpace([string]$PlatformLanes[0].architecture)) { + throw "The test matrix must declare exactly one architecture-bearing lane for '$Platform'." + } + $ExpectedProfileKey = 'ps{0}-{1}-{2}-{3}' -f $RuntimeProfile.powerShellLine, $RuntimeProfile.targetFramework, $Platform, $PlatformLanes[0].architecture if ([string]$Evidence.content.profile.profileKey -ne $ExpectedProfileKey) { throw "Accepted profile evidence '$($Evidence.content.profile.profileKey)' does not match '$ExpectedProfileKey'." } - $EvidenceDate = ([System.DateTimeOffset]$Evidence.provenance.capturedAtUtc).ToString('yyyy-MM-dd') + $EvidenceDate = (ConvertTo-DLLPickleUtcDateTimeOffset -Value $Evidence.provenance.capturedAtUtc).ToString('yyyy-MM-dd') $SourceRunId = [string]$Evidence.provenance.sourceRunId $SourceRunUrl = [string]$Evidence.provenance.sourceRunUrl $RunReference = if (-not [string]::IsNullOrWhiteSpace($SourceRunId) -and -not [string]::IsNullOrWhiteSpace($SourceRunUrl)) { @@ -179,6 +192,8 @@ foreach ($Module in @($DependencyPolicy.monitoredModules)) { } $EvidenceLines.Add('') $EvidenceLines.Add('These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it.') +$EvidenceLines.Add('') +$EvidenceLines.Add('Before the protected credentialed workflow exists, the release gate may accept one explicitly reviewed manual transition record for version `3.0.0`. That record must match the exact bundle-source fingerprint, cover the three exact Windows profiles and fixed read-only scenarios, contain no credential material or raw service output, record zero writes, and expire within 30 days. It is transitional compatibility evidence, not least-privilege workload-identity proof.') $Documents = [ordered]@{ 'Support-Matrix.md' = ($SupportLines -join $NewLine) + $NewLine diff --git a/tools/Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1 b/tools/Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1 new file mode 100644 index 00000000..a672d090 --- /dev/null +++ b/tools/Set-DLLPickleManualAuthenticatedEvidenceAcceptance.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS +Records explicit maintainer acceptance of a validated manual evidence candidate. + +.DESCRIPTION +Validates the pending candidate, adds only acceptance metadata, writes the +committable transition file, and immediately revalidates it in Release mode. +This command does not commit, push, release, or publish anything. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] +[OutputType([pscustomobject])] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$CandidateEvidencePath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'build/authenticated-evidence/initial-multitarget-major.json'), + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$')] + [string]$AcceptedBy, + + [Parameter(Mandatory)] + [ValidateSet('low', 'medium', 'high')] + [string]$Confidence, + + [Parameter()] + [System.DateTimeOffset]$AcceptedAtUtc = [System.DateTimeOffset]::UtcNow +) + +$ErrorActionPreference = 'Stop' +$RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent +$ValidatorPath = Join-Path $PSScriptRoot 'Test-DLLPickleManualAuthenticatedEvidence.ps1' +$ValidationParameters = @{ + EvidencePath = $CandidateEvidencePath + RepositoryRoot = $RepositoryRoot + TestMatrixPath = (Join-Path $RepositoryRoot 'build/powershell-test-matrix.json') + DependencyPolicyPath = (Join-Path $RepositoryRoot 'build/dependency-policy.json') + Mode = 'Capture' + NowUtc = $AcceptedAtUtc +} +$null = & $ValidatorPath @ValidationParameters +$Evidence = Get-Content -LiteralPath $CandidateEvidencePath -Raw | ConvertFrom-Json -ErrorAction Stop +$Evidence.acceptance.status = 'accepted' +$Evidence.acceptance.acceptedAtUtc = $AcceptedAtUtc.ToUniversalTime().ToString('o') +$Evidence.acceptance.acceptedBy = $AcceptedBy +$Evidence.acceptance.confidence = $Confidence + +if (-not $PSCmdlet.ShouldProcess($OutputPath, "Record accepted manual authenticated evidence for version $($Evidence.content.bridge.allowedReleaseVersion)")) { + return +} +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} +$Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +$ValidationParameters.EvidencePath = $OutputPath +$ValidationParameters.Mode = 'Release' +$Validated = & $ValidatorPath @ValidationParameters +[pscustomobject]@{ + OutputPath = [System.IO.Path]::GetFullPath($OutputPath) + AcceptedBy = $AcceptedBy + Confidence = $Confidence + AcceptedAtUtc = $AcceptedAtUtc.ToUniversalTime().ToString('o') + EvidenceFingerprint = [string]$Validated.EvidenceFingerprint + BundleSourceFingerprint = [string]$Validated.BundleSourceFingerprint + AllowedReleaseVersion = [string]$Validated.AllowedReleaseVersion + ExpiresAtUtc = [string]$Validated.ExpiresAtUtc + WritesPerformed = $false +} diff --git a/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 new file mode 100644 index 00000000..c7e90ccd --- /dev/null +++ b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 @@ -0,0 +1,332 @@ +<# +.SYNOPSIS +Validates the time-bounded manual authenticated-evidence transition record. + +.DESCRIPTION +This validator is deliberately narrow. It accepts only the initial 3.0.0 +PowerShell 7.4-7.6 multi-target release, exactly three Windows x64 profiles, +hard-coded read-only probes, and evidence bound to the current published-bundle +source fingerprint. It is not a generic authentication bypass and does not +replace the future protected credentialed workflow. + +.PARAMETER EvidencePath +Path to the sanitized manual evidence JSON. + +.PARAMETER RepositoryRoot +Repository root whose published inputs must match the evidence fingerprint. + +.PARAMETER TestMatrixPath +Exact supported PowerShell runtime matrix. + +.PARAMETER DependencyPolicyPath +Profile and monitored-module policy. + +.PARAMETER Mode +Capture validates a pending candidate. Release additionally requires maintainer +acceptance and a currently valid expiry window. + +.PARAMETER NowUtc +Clock injection for deterministic expiry tests. + +.OUTPUTS +System.Management.Automation.PSCustomObject +#> + +[CmdletBinding()] +[OutputType([pscustomobject])] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$EvidencePath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$RepositoryRoot = (Split-Path -Path $PSScriptRoot -Parent), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TestMatrixPath = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'build/powershell-test-matrix.json'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$DependencyPolicyPath = (Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'build/dependency-policy.json'), + + [Parameter()] + [ValidateSet('Capture', 'Release')] + [string]$Mode = 'Release', + + [Parameter()] + [System.DateTimeOffset]$NowUtc = [System.DateTimeOffset]::UtcNow +) + +$ErrorActionPreference = 'Stop' + +function Assert-ExactStringSet { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Actual, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Expected, + [Parameter(Mandatory)][string]$Label + ) + + $Difference = @(Compare-Object -ReferenceObject @($Expected | Sort-Object) -DifferenceObject @($Actual | Sort-Object)) + if ($Difference.Count -gt 0 -or $Actual.Count -ne $Expected.Count) { + throw "$Label does not match the required set. Expected '$($Expected -join ', ')'; actual '$($Actual -join ', ')'." + } +} + +function Assert-ExactPropertySet { + param( + [Parameter(Mandatory)][object]$InputObject, + [Parameter(Mandatory)][string[]]$Expected, + [Parameter(Mandatory)][string]$Label + ) + + Assert-ExactStringSet -Actual @($InputObject.PSObject.Properties.Name) -Expected $Expected -Label "$Label properties" +} + +function Get-ContentFingerprint { + param([Parameter(Mandatory)][object]$Content) + + $CanonicalContent = $Content | ConvertTo-Json -Depth 100 -Compress + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) + [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() +} + +function ConvertTo-UtcDateTimeOffset { + param([Parameter(Mandatory)][object]$Value) + + if ($Value -is [System.DateTimeOffset]) { + return $Value.ToUniversalTime() + } + if ($Value -is [System.DateTime]) { + return ([System.DateTimeOffset]$Value).ToUniversalTime() + } + [System.DateTimeOffset]::Parse( + [string]$Value, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ).ToUniversalTime() +} + +foreach ($RequiredPath in @($EvidencePath, $TestMatrixPath, $DependencyPolicyPath)) { + if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { + throw "Required authenticated-evidence input was not found: $RequiredPath" + } +} +$Evidence = Get-Content -LiteralPath $EvidencePath -Raw | ConvertFrom-Json -ErrorAction Stop +$TestMatrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$Policy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop + +Assert-ExactPropertySet -InputObject $Evidence -Expected @('schemaVersion', 'evidenceType', 'contentFingerprint', 'provenance', 'acceptance', 'content') -Label 'Evidence envelope' +Assert-ExactPropertySet -InputObject $Evidence.provenance -Expected @('sourceCommitSha', 'captureStartedAtUtc', 'captureCompletedAtUtc') -Label 'Evidence provenance' +Assert-ExactPropertySet -InputObject $Evidence.acceptance -Expected @('status', 'acceptedAtUtc', 'acceptedBy', 'confidence') -Label 'Evidence acceptance' +Assert-ExactPropertySet -InputObject $Evidence.content -Expected @('bridge', 'bundleSourceFingerprint', 'credentialMode', 'credentialMaterialCaptured', 'authorizationBoundaryValidated', 'platformScope', 'writesPerformed', 'profiles') -Label 'Evidence content' +Assert-ExactPropertySet -InputObject $Evidence.content.bridge -Expected @('id', 'allowedReleaseVersion', 'expiresAtUtc') -Label 'Evidence bridge' + +if ([int]$Evidence.schemaVersion -ne 1 -or + [string]$Evidence.evidenceType -ne 'manual-interactive-transition' -or + -not $Evidence.content) { + throw 'Manual authenticated evidence has an unsupported schema, type, or missing content.' +} +$RecomputedContentFingerprint = Get-ContentFingerprint -Content $Evidence.content +if ([string]$Evidence.contentFingerprint -ne $RecomputedContentFingerprint) { + throw "Manual authenticated evidence does not recompute to '$($Evidence.contentFingerprint)'." +} + +$Bridge = $Evidence.content.bridge +if ([string]$Bridge.id -ne 'initial-powershell-7.4-7.6-multitargeting-major' -or + [string]$Bridge.allowedReleaseVersion -ne '3.0.0') { + throw 'Manual authenticated evidence is not scoped exclusively to the initial 3.0.0 multi-target release.' +} +$CaptureStartedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.provenance.captureStartedAtUtc +$CapturedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.provenance.captureCompletedAtUtc +$ExpiresAtUtc = ConvertTo-UtcDateTimeOffset -Value $Bridge.expiresAtUtc +if ([string]$Evidence.provenance.sourceCommitSha -notmatch '^[a-f0-9]{40}$' -or + $CaptureStartedAtUtc -gt $CapturedAtUtc -or + $CapturedAtUtc -gt $NowUtc.ToUniversalTime()) { + throw 'Manual authenticated-evidence provenance has an invalid commit or capture window.' +} +if ($ExpiresAtUtc -le $CapturedAtUtc -or $ExpiresAtUtc -gt $CapturedAtUtc.AddDays(30)) { + throw 'The manual authenticated-evidence bridge must expire after capture and within 30 days.' +} +if ($Mode -eq 'Release') { + if ([string]$Evidence.acceptance.status -ne 'accepted' -or + [string]$Evidence.acceptance.acceptedBy -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$' -or + [string]$Evidence.acceptance.confidence -notin @('low', 'medium', 'high')) { + throw 'Manual authenticated evidence has not been explicitly accepted by a maintainer with a confidence level.' + } + $AcceptedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.acceptance.acceptedAtUtc + if ($AcceptedAtUtc -lt $CapturedAtUtc -or + $AcceptedAtUtc -ge $ExpiresAtUtc -or + $AcceptedAtUtc -gt $NowUtc.ToUniversalTime()) { + throw "Manual authenticated evidence acceptance '$($AcceptedAtUtc.ToString('o'))' is outside its capture '$($CapturedAtUtc.ToString('o'))' to expiry '$($ExpiresAtUtc.ToString('o'))' window." + } + if ($NowUtc.ToUniversalTime() -ge $ExpiresAtUtc) { + throw "Manual authenticated evidence expired at $($ExpiresAtUtc.ToString('o'))." + } +} + +if ([string]$Evidence.content.credentialMode -ne 'delegated-interactive' -or + $Evidence.content.credentialMaterialCaptured -ne $false -or + $Evidence.content.authorizationBoundaryValidated -ne $false -or + [string]$Evidence.content.platformScope -ne 'windows-x64-only' -or + $Evidence.content.writesPerformed -ne $false) { + throw 'Manual transition evidence must remain delegated-interactive, credential-free, Windows-only, zero-write, and explicitly not least-privilege proof.' +} + +$BundleFingerprintTool = Join-Path $PSScriptRoot 'Get-DLLPickleBundleSourceFingerprint.ps1' +$CurrentBundle = & $BundleFingerprintTool -RepositoryRoot $RepositoryRoot +if ([string]$Evidence.content.bundleSourceFingerprint -ne [string]$CurrentBundle.fingerprint) { + throw "Manual authenticated evidence is bound to bundle '$($Evidence.content.bundleSourceFingerprint)', but the current bundle is '$($CurrentBundle.fingerprint)'." +} + +$ExpectedProfiles = @( + foreach ($RuntimeProfile in @($TestMatrix.profiles)) { + [pscustomobject]@{ + ProfileKey = 'ps{0}.{1}-{2}-windows-x64' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor, $RuntimeProfile.targetFramework + PowerShellVersion = [string]$RuntimeProfile.powerShellVersion + PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + DotNetMajor = [int]$RuntimeProfile.dotnetMajor + TargetFramework = [string]$RuntimeProfile.targetFramework + } + } +) +Assert-ExactStringSet -Actual @($Evidence.content.profiles.profileKey) -Expected @($ExpectedProfiles.ProfileKey) -Label 'Authenticated profile coverage' + +$ExpectedModuleNames = @($Policy.monitoredModules.name | Sort-Object) +$ExpectedScenarioIds = @( + 'graph-module-only', 'graph-dllpickle-first', 'graph-module-first', + 'exo-module-only', 'exo-dllpickle-first', 'exo-module-first', + 'az-module-only', 'az-dllpickle-first', 'az-module-first', + 'teams-module-only', 'teams-dllpickle-first', 'teams-module-first', + 'cross-import-order-1', 'cross-import-order-2' +) +$ProviderProbes = [ordered]@{ + graph = @('graph-context', 'graph-me-read') + exo = @('exo-mailbox-read') + az = @('az-context', 'az-resource-read', 'az-storage-account-read') + teams = @('teams-tenant-read') + cross = @('graph-context', 'graph-me-read', 'exo-mailbox-read', 'az-context', 'teams-tenant-read') +} +$ProviderAudiences = [ordered]@{ + graph = @('https://graph.microsoft.com') + exo = @('https://outlook.office365.com') + az = @('https://management.azure.com') + teams = @('https://api.spaces.skype.com') + cross = @('https://api.spaces.skype.com', 'https://graph.microsoft.com', 'https://management.azure.com', 'https://outlook.office365.com') +} +$ProviderModuleOrders = [ordered]@{ + graph = @('Microsoft.Graph.Authentication') + exo = @('ExchangeOnlineManagement') + az = @('Az.Accounts', 'Az.Resources', 'Az.Storage') + teams = @('MicrosoftTeams') +} + +foreach ($ExpectedProfile in $ExpectedProfiles) { + $ProfileRows = @($Evidence.content.profiles | Where-Object profileKey -eq $ExpectedProfile.ProfileKey) + if ($ProfileRows.Count -ne 1) { + throw "Expected exactly one authenticated profile '$($ExpectedProfile.ProfileKey)'." + } + $EvidenceProfile = $ProfileRows[0] + Assert-ExactPropertySet -InputObject $EvidenceProfile -Expected @('profileKey', 'powerShellVersion', 'powerShellLine', 'dotNetVersion', 'dotNetMajor', 'targetFramework', 'platform', 'architecture', 'runtimeExecutable', 'psHome', 'writesPerformed', 'moduleVersions', 'scenarios') -Label "Authenticated profile '$($ExpectedProfile.ProfileKey)'" + if ([string]$EvidenceProfile.powerShellVersion -ne $ExpectedProfile.PowerShellVersion -or + [string]$EvidenceProfile.powerShellLine -ne $ExpectedProfile.PowerShellLine -or + [int]$EvidenceProfile.dotNetMajor -ne $ExpectedProfile.DotNetMajor -or + [string]$EvidenceProfile.targetFramework -ne $ExpectedProfile.TargetFramework -or + [string]$EvidenceProfile.platform -ne 'windows' -or + [string]$EvidenceProfile.architecture -ne 'x64' -or + $EvidenceProfile.writesPerformed -ne $false) { + throw "Authenticated profile '$($ExpectedProfile.ProfileKey)' does not match the exact zero-write Windows runtime contract." + } + if ([string]$EvidenceProfile.runtimeExecutable -notmatch '^runtime:' -or [string]$EvidenceProfile.psHome -notmatch '^runtime:') { + throw "Authenticated profile '$($ExpectedProfile.ProfileKey)' contains an unnormalized runtime path." + } + Assert-ExactStringSet -Actual @($EvidenceProfile.moduleVersions.name) -Expected $ExpectedModuleNames -Label "Module versions for '$($ExpectedProfile.ProfileKey)'" + foreach ($ModuleVersion in @($EvidenceProfile.moduleVersions)) { + Assert-ExactPropertySet -InputObject $ModuleVersion -Expected @('name', 'version', 'manifest') -Label "Module-version row for '$($ExpectedProfile.ProfileKey)'" + if ([string]::IsNullOrWhiteSpace([string]$ModuleVersion.version) -or + [string]$ModuleVersion.manifest -notmatch '^upstream:') { + throw "Authenticated profile '$($ExpectedProfile.ProfileKey)' has an incomplete or unnormalized module-version row." + } + } + Assert-ExactStringSet -Actual @($EvidenceProfile.scenarios.scenarioId) -Expected $ExpectedScenarioIds -Label "Scenario coverage for '$($ExpectedProfile.ProfileKey)'" + + $ProfilePolicy = @($Policy.runtimeProfiles | Where-Object { + $_.powerShellLine -eq $ExpectedProfile.PowerShellLine -and $_.targetFramework -eq $ExpectedProfile.TargetFramework + }) + if ($ProfilePolicy.Count -ne 1) { + throw "No unique dependency policy exists for '$($ExpectedProfile.ProfileKey)'." + } + foreach ($Scenario in @($EvidenceProfile.scenarios)) { + Assert-ExactPropertySet -InputObject $Scenario -Expected @('scenarioId', 'profileKey', 'powerShellVersion', 'targetFramework', 'platform', 'architecture', 'importOrder', 'dllPickleTiming', 'expectedTokenAudiences', 'status', 'writesPerformed', 'probes', 'snapshots', 'errorType') -Label "Authenticated scenario '$($Scenario.scenarioId)'" + if ([string]$Scenario.profileKey -ne $ExpectedProfile.ProfileKey -or + [string]$Scenario.powerShellVersion -ne $ExpectedProfile.PowerShellVersion -or + [string]$Scenario.targetFramework -ne $ExpectedProfile.TargetFramework -or + [string]$Scenario.platform -ne 'windows' -or + [string]$Scenario.architecture -ne 'x64') { + throw "Authenticated scenario '$($Scenario.scenarioId)' is not bound to exact profile '$($ExpectedProfile.ProfileKey)'." + } + if ([string]$Scenario.status -ne 'passed' -or $Scenario.writesPerformed -ne $false -or + -not [string]::IsNullOrWhiteSpace([string]$Scenario.errorType)) { + throw "Authenticated scenario '$($Scenario.scenarioId)' for '$($ExpectedProfile.ProfileKey)' did not pass zero-write validation." + } + $Provider = if ([string]$Scenario.scenarioId -like 'cross-*') { 'cross' } else { ([string]$Scenario.scenarioId -split '-')[0] } + $ExpectedTiming = if ($Provider -eq 'cross' -or [string]$Scenario.scenarioId -like '*-dllpickle-first') { + 'dllpickle-first' + } elseif ([string]$Scenario.scenarioId -like '*-module-first') { + 'module-first' + } else { + 'module-only' + } + if ([string]$Scenario.dllPickleTiming -ne $ExpectedTiming) { + throw "Authenticated scenario '$($Scenario.scenarioId)' has DLLPickle timing '$($Scenario.dllPickleTiming)', expected '$ExpectedTiming'." + } + if ($Provider -ne 'cross' -and (@($Scenario.importOrder) -join '|') -ne (@($ProviderModuleOrders[$Provider]) -join '|')) { + throw "Authenticated scenario '$($Scenario.scenarioId)' does not use the fixed provider module order." + } + Assert-ExactStringSet -Actual @($Scenario.probes.probeId) -Expected @($ProviderProbes[$Provider]) -Label "Probe coverage for '$($Scenario.scenarioId)'" + Assert-ExactStringSet -Actual @($Scenario.expectedTokenAudiences) -Expected @($ProviderAudiences[$Provider]) -Label "Declared token audiences for '$($Scenario.scenarioId)'" + foreach ($Probe in @($Scenario.probes)) { + Assert-ExactPropertySet -InputObject $Probe -Expected @('probeId', 'executed', 'status', 'durationMilliseconds', 'writesPerformed', 'errorType') -Label "Authenticated probe '$($Probe.probeId)'" + if ($Probe.executed -ne $true -or [string]$Probe.status -ne 'passed' -or $Probe.writesPerformed -ne $false -or + -not [string]::IsNullOrWhiteSpace([string]$Probe.errorType) -or [long]$Probe.durationMilliseconds -lt 0) { + throw "Authenticated probe '$($Probe.probeId)' in '$($Scenario.scenarioId)' is not an executed, passing, zero-write result." + } + } + Assert-ExactStringSet -Actual @($Scenario.snapshots.stage) -Expected @('before-authentication', 'after-connection', 'after-read-probe') -Label "ALC snapshots for '$($Scenario.scenarioId)'" + foreach ($Snapshot in @($Scenario.snapshots)) { + Assert-ExactPropertySet -InputObject $Snapshot -Expected @('stage', 'assemblies') -Label "ALC snapshot '$($Scenario.scenarioId)/$($Snapshot.stage)'" + foreach ($Assembly in @($Snapshot.assemblies)) { + Assert-ExactPropertySet -InputObject $Assembly -Expected @('name', 'version', 'sha256', 'selectedAsset', 'assemblyLoadContext', 'isCollectible') -Label "Assembly row in '$($Scenario.scenarioId)/$($Snapshot.stage)'" + if ([string]$Assembly.sha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$Assembly.selectedAsset -notmatch '^(upstream|dllpickle|runtime):' -or + [string]$Assembly.name -notin @($Policy.trackedAssemblies) -or + [string]::IsNullOrWhiteSpace([string]$Assembly.name) -or + [string]::IsNullOrWhiteSpace([string]$Assembly.version) -or + [string]::IsNullOrWhiteSpace([string]$Assembly.assemblyLoadContext) -or + $Assembly.isCollectible -isnot [bool]) { + throw "ALC snapshot '$($Scenario.scenarioId)/$($Snapshot.stage)' contains an incomplete or unnormalized assembly row." + } + } + } + } + + $CrossScenarios = @($EvidenceProfile.scenarios | Where-Object scenarioId -like 'cross-*' | Sort-Object scenarioId) + for ($OrderIndex = 0; $OrderIndex -lt 2; $OrderIndex++) { + $ExpectedOrder = @($ProfilePolicy[0].importOrders[$OrderIndex]) + if ((@($CrossScenarios[$OrderIndex].importOrder) -join '|') -ne ($ExpectedOrder -join '|')) { + throw "Authenticated cross-import scenario $($OrderIndex + 1) for '$($ExpectedProfile.ProfileKey)' does not match dependency policy." + } + } +} + +[pscustomobject]@{ + Mode = $Mode + EvidenceType = [string]$Evidence.evidenceType + EvidenceFingerprint = $RecomputedContentFingerprint + BundleSourceFingerprint = [string]$CurrentBundle.fingerprint + AllowedReleaseVersion = [string]$Bridge.allowedReleaseVersion + ExpiresAtUtc = $ExpiresAtUtc.ToString('o') + ProfileKeys = @($ExpectedProfiles.ProfileKey) + WritesPerformed = $false +} From 7e634c51abfe5082cfb1b013b044093a8129139e Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:27:42 -0400 Subject: [PATCH 36/43] fix: harden authenticated evidence boundaries --- .github/workflows/Release-and-Publish.yml | 22 +- build/authenticated-evidence/README.md | 5 +- .../manual-transition.schema.json | 19 + .../ps7.4-net8.0-macos-x64.json | 2 +- .../ps7.5-net9.0-macos-x64.json | 2 +- ...ntialed-authentication-test-environment.md | 18 +- tests/Unit/BundleSourceFingerprint.Tests.ps1 | 44 ++- .../ManualAuthenticatedEvidence.Tests.ps1 | 29 +- .../Unit/ManualAuthenticatedHarness.Tests.ps1 | 42 ++- .../Unit/ManualAuthenticatedHelpers.Tests.ps1 | 98 +++++ tests/Unit/ProfileConflictBaseline.Tests.ps1 | 1 + tests/Unit/ProfileEvidenceHelpers.Tests.ps1 | 5 + tests/Unit/RuntimeAssemblyProbe.Tests.ps1 | 15 + tests/Unit/WorkflowGuardrails.Tests.ps1 | 5 + .../DLLPickle.ManualAuthenticatedEvidence.ps1 | 334 ++++++++++++++++++ tools/DLLPickle.ProfileEvidence.ps1 | 40 ++- .../Get-DLLPickleBundleSourceFingerprint.ps1 | 43 ++- tools/Get-DLLPickleLoadedTrackedAssembly.ps1 | 10 +- ...PickleManualAuthenticatedCompatibility.ps1 | 11 +- ...PickleManualAuthenticatedCompatibility.ps1 | 86 +++-- ...e-DLLPickleManualAuthenticatedScenario.ps1 | 153 +++----- tools/New-DLLPickleSupportDocumentation.ps1 | 16 - ...t-DLLPickleManualAuthenticatedEvidence.ps1 | 63 ++-- 23 files changed, 822 insertions(+), 241 deletions(-) create mode 100644 tests/Unit/ManualAuthenticatedHelpers.Tests.ps1 create mode 100644 tools/DLLPickle.ManualAuthenticatedEvidence.ps1 diff --git a/.github/workflows/Release-and-Publish.yml b/.github/workflows/Release-and-Publish.yml index 91f757f3..368c3afc 100644 --- a/.github/workflows/Release-and-Publish.yml +++ b/.github/workflows/Release-and-Publish.yml @@ -131,24 +131,31 @@ jobs: $RunArguments = @( 'run', 'list' - '--repo', '${{ github.repository }}' + '--repo', $env:GITHUB_REPOSITORY '--workflow', $env:AUTHENTICATED_WORKFLOW_FILE '--commit', $EvidenceSha '--status', 'success' '--limit', '20' '--json', 'databaseId,headSha,conclusion,createdAt,url' ) - $RunJson = & gh @RunArguments 2>$null $ProtectedEvidenceRun = $null - if ($LASTEXITCODE -eq 0) { + $ProtectedWorkflowPath = Join-Path '.github/workflows' $env:AUTHENTICATED_WORKFLOW_FILE + $ProtectedWorkflowConfigured = Test-Path -LiteralPath $ProtectedWorkflowPath -PathType Leaf + if ($ProtectedWorkflowConfigured) { + $RunJson = & gh @RunArguments 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Could not query protected authenticated workflow '$($env:AUTHENTICATED_WORKFLOW_FILE)' for exact commit '$EvidenceSha'." + } $MatchingRuns = @( $RunJson | ConvertFrom-Json | Where-Object { $_.headSha -eq $EvidenceSha -and $_.conclusion -eq 'success' } ) foreach ($CandidateRun in @($MatchingRuns | Sort-Object createdAt -Descending)) { - $ArtifactJson = gh api "/repos/${{ github.repository }}/actions/runs/$($CandidateRun.databaseId)/artifacts" 2>$null - if ($LASTEXITCODE -ne 0) { continue } + $ArtifactJson = & gh api "/repos/$($env:GITHUB_REPOSITORY)/actions/runs/$($CandidateRun.databaseId)/artifacts" 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Could not query artifacts for protected authenticated workflow run '$($CandidateRun.databaseId)'." + } $EvidenceArtifacts = @( ($ArtifactJson | ConvertFrom-Json).artifacts | Where-Object { $_.name -eq $env:AUTHENTICATED_EVIDENCE_ARTIFACT -and -not $_.expired @@ -159,6 +166,11 @@ jobs: break } } + if ($MatchingRuns.Count -gt 0 -and -not $ProtectedEvidenceRun) { + throw "Successful protected authenticated workflow runs exist for '$EvidenceSha', but none has exactly one unexpired '$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)' artifact." + } + } else { + Write-Information -MessageData "Protected authenticated workflow '$($env:AUTHENTICATED_WORKFLOW_FILE)' is not configured; evaluating the bounded manual transition only." -InformationAction Continue } if ($ProtectedEvidenceRun) { diff --git a/build/authenticated-evidence/README.md b/build/authenticated-evidence/README.md index 25332460..98992195 100644 --- a/build/authenticated-evidence/README.md +++ b/build/authenticated-evidence/README.md @@ -7,13 +7,14 @@ release. The temporary bridge is intentionally narrower than the future protected credentialed workflow: -- exact bundle-source fingerprint; +- exact bundle-source fingerprint, including packaging logic and pinned build tooling; +- byte-level prepared module-inventory fingerprint per exact profile and checkpoint; - exact release version `3.0.0`; - exact Windows x64 PowerShell 7.4, 7.5, and 7.6 profiles; - fixed read-only probe and import-order identifiers; - no credential material or raw service output; - `WritesPerformed: false` at every level; -- maximum 30-day validity; +- exactly 14 days of validity from capture-session start, with no resume renewal; - explicit maintainer, confidence, and acceptance timestamp; - explicit acknowledgement that delegated interactive authentication is not least-privilege workload-identity evidence. diff --git a/build/authenticated-evidence/manual-transition.schema.json b/build/authenticated-evidence/manual-transition.schema.json index d3269a69..e23617ec 100644 --- a/build/authenticated-evidence/manual-transition.schema.json +++ b/build/authenticated-evidence/manual-transition.schema.json @@ -34,6 +34,21 @@ "acceptedBy": { "type": ["string", "null"] }, "confidence": { "enum": [null, "low", "medium", "high"] } }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "accepted" } }, + "required": ["status"] + }, + "then": { + "properties": { + "acceptedAtUtc": { "type": "string", "format": "date-time" }, + "acceptedBy": { "type": "string", "minLength": 1 }, + "confidence": { "enum": ["low", "medium", "high"] } + } + } + } + ], "additionalProperties": false }, "content": { @@ -130,6 +145,7 @@ "targetFramework", "platform", "architecture", + "inventoryFingerprint", "importOrder", "dllPickleTiming", "expectedTokenAudiences", @@ -146,6 +162,7 @@ "targetFramework": { "enum": ["net8.0", "net9.0", "net10.0"] }, "platform": { "const": "windows" }, "architecture": { "const": "x64" }, + "inventoryFingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "importOrder": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, "dllPickleTiming": { "enum": ["module-only", "dllpickle-first", "module-first"] }, "expectedTokenAudiences": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "uri" } }, @@ -171,6 +188,7 @@ "runtimeExecutable", "psHome", "writesPerformed", + "inventoryFingerprint", "moduleVersions", "scenarios" ], @@ -186,6 +204,7 @@ "runtimeExecutable": { "type": "string", "pattern": "^runtime:" }, "psHome": { "type": "string", "pattern": "^runtime:" }, "writesPerformed": { "const": false }, + "inventoryFingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "moduleVersions": { "type": "array", "minItems": 6, "maxItems": 6, "items": { "$ref": "#/$defs/moduleVersion" } }, "scenarios": { "type": "array", "minItems": 14, "maxItems": 14, "items": { "$ref": "#/$defs/scenario" } } }, diff --git a/build/profile-evidence/ps7.4-net8.0-macos-x64.json b/build/profile-evidence/ps7.4-net8.0-macos-x64.json index ead49212..c86e1856 100644 --- a/build/profile-evidence/ps7.4-net8.0-macos-x64.json +++ b/build/profile-evidence/ps7.4-net8.0-macos-x64.json @@ -7,7 +7,7 @@ "sourceCommitSha": "586b1fed1189d9a34e6b054dbffa07b62a706d97", "capturedAtUtc": "2026-08-10T02:31:39.0000000+00:00", "observedOperatingSystems": [ - "Darwin 24.6.0 Darwin Kernel Version 24.6.0: Tue Apr 21 20:17:54 PDT 2026; root:xnu-11417.140.69.710.16~1/RELEASE_X86_64" + "macOS 15.7.7" ] }, "content": { diff --git a/build/profile-evidence/ps7.5-net9.0-macos-x64.json b/build/profile-evidence/ps7.5-net9.0-macos-x64.json index 7c4cbf73..3b04a956 100644 --- a/build/profile-evidence/ps7.5-net9.0-macos-x64.json +++ b/build/profile-evidence/ps7.5-net9.0-macos-x64.json @@ -7,7 +7,7 @@ "sourceCommitSha": "586b1fed1189d9a34e6b054dbffa07b62a706d97", "capturedAtUtc": "2026-08-10T02:31:03.0000000+00:00", "observedOperatingSystems": [ - "Darwin 24.6.0 Darwin Kernel Version 24.6.0: Tue Apr 21 20:17:54 PDT 2026; root:xnu-11417.140.69.710.16~1/RELEASE_X86_64" + "macOS 15.7.7" ] }, "content": { diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index cd7dcfff..a97bcd4b 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -202,7 +202,9 @@ Before the protected environment is available, the initial multi-target major release may use sanitized evidence collected by the interactive harness. This is not an authenticated-gate waiver. The release validator requires: -- the exact published-bundle source fingerprint recorded at capture time; +- the exact published-bundle source fingerprint, including packaging logic and + pinned build tooling, recorded at capture time; +- one byte-level prepared module-inventory fingerprint per exact runtime profile; - release version `3.0.0` and no other version; - all fixed module-only, DLLPickle-first, module-first, and cross-import-order read scenarios under PowerShell 7.4, 7.5, and 7.6 on Windows x64; @@ -213,7 +215,8 @@ not an authenticated-gate waiver. The release validator requires: snapshots; - zero writes and no credential material or raw provider output; - explicit maintainer acceptance with a confidence level; and -- expiry no later than 30 days after capture. +- expiry exactly 14 days after the capture session starts, without renewal when + a partial session resumes. The record explicitly states that delegated interactive authentication is not least-privilege workload-identity proof. Any bundle change, skipped or failed @@ -228,8 +231,6 @@ synced checkout of the reviewed PR branch. Do not paste tokens or passwords into the command line. ```powershell -Set-Location 'C:\Users\SamErde\Code\Public\DLLPickle' - pwsh -NoLogo -NoProfile -File .\tools\Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 ``` @@ -253,10 +254,11 @@ pwsh -NoLogo -NoProfile -File .\tools\Invoke-DLLPickleManualAuthenticatedCompati The harness opens only the provider sign-in experiences. Each of the 14 fixed scenarios runs in a fresh process for each of the three exact profiles. Passing -scenario checkpoints are reused on rerun, so an authorization failure or an -interrupted session does not require repeating completed scenarios. To diagnose -one cell first, use the optional `-ProfileKey` and `-ScenarioId` filters; the -candidate remains incomplete until all 42 checkpoints exist. +scenario checkpoints are reused on rerun only while their complete prepared +module-inventory fingerprint still matches, so an authorization failure or an +interrupted session does not require repeating unchanged completed scenarios. +To diagnose one cell first, use the optional `-ProfileKey` and `-ScenarioId` +filters; the candidate remains incomplete until all 42 checkpoints exist. After the candidate is complete, review `artifacts/manual-authenticated/manual-authenticated-evidence.candidate.json`. diff --git a/tests/Unit/BundleSourceFingerprint.Tests.ps1 b/tests/Unit/BundleSourceFingerprint.Tests.ps1 index fb134fe9..f747a353 100644 --- a/tests/Unit/BundleSourceFingerprint.Tests.ps1 +++ b/tests/Unit/BundleSourceFingerprint.Tests.ps1 @@ -7,12 +7,22 @@ BeforeAll { $ModuleRoot = Join-Path $Root 'src\DLLPickle\Private' $BuildRoot = Join-Path $Root 'src\DLLPickle.Build' + $BuildScriptRoot = Join-Path $Root 'build' + $ToolsRoot = Join-Path $Root 'tools' $null = New-Item -Path $ModuleRoot -ItemType Directory -Force $null = New-Item -Path $BuildRoot -ItemType Directory -Force + $null = New-Item -Path $BuildScriptRoot -ItemType Directory -Force + $null = New-Item -Path $ToolsRoot -ItemType Directory -Force Set-Content -LiteralPath (Join-Path $Root 'src\DLLPickle\DLLPickle.psd1') -Value '@{ ModuleVersion = ''0.0.0'' }' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $ModuleRoot 'Get-Thing.ps1') -Value 'function Get-Thing { ''thing'' }' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $BuildRoot 'DLLPickle.csproj') -Value '' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $BuildRoot 'packages.lock.json') -Value '{ "version": 2 }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildScriptRoot 'DLLPickle.Build.ps1') -Value 'Add-BuildTask PrepareModuleOutput' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildScriptRoot 'DLLPickle.Settings.ps1') -Value '$ModuleName = ''DLLPickle''' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildScriptRoot 'DLLPickle.Tooling.ps1') -Value 'function Import-Tool {}' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $BuildScriptRoot 'build-tool-versions.json') -Value '{ "schemaVersion": 1 }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $Root 'global.json') -Value '{ "sdk": { "version": "10.0.100" } }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $ToolsRoot 'Invoke-DLLPickleBuild.ps1') -Value 'param([string[]]$Task)' -Encoding UTF8 $Root } } @@ -29,10 +39,16 @@ Describe 'Published bundle source fingerprint' -Tag 'Unit' { $First.fingerprint | Should -BeExactly $Second.fingerprint @($First.files.path) | Should -Be @( + 'build/DLLPickle.Build.ps1' + 'build/DLLPickle.Settings.ps1' + 'build/DLLPickle.Tooling.ps1' + 'build/build-tool-versions.json' + 'global.json' 'src/DLLPickle.Build/DLLPickle.csproj' 'src/DLLPickle.Build/packages.lock.json' 'src/DLLPickle/DLLPickle.psd1' 'src/DLLPickle/Private/Get-Thing.ps1' + 'tools/Invoke-DLLPickleBuild.ps1' ) } @@ -46,6 +62,32 @@ Describe 'Published bundle source fingerprint' -Tag 'Unit' { $After.fingerprint | Should -Not -Be $Before.fingerprint } + It 'changes when packaging logic changes' { + $Root = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'packaging-change') + $Before = & $script:ToolPath -RepositoryRoot $Root + Set-Content -LiteralPath (Join-Path $Root 'build\DLLPickle.Build.ps1') -Value 'Add-BuildTask PrepareModuleOutput,Archive' -Encoding UTF8 + + $After = & $script:ToolPath -RepositoryRoot $Root + + $After.fingerprint | Should -Not -Be $Before.fingerprint + } + + It 'uses ordinal path order under English and Turkish cultures' { + $Root = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'culture') + $OriginalCulture = [System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('en-US') + $English = & $script:ToolPath -RepositoryRoot $Root + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('tr-TR') + $Turkish = & $script:ToolPath -RepositoryRoot $Root + } finally { + [System.Globalization.CultureInfo]::CurrentCulture = $OriginalCulture + } + + $English.fingerprint | Should -BeExactly $Turkish.fingerprint + @($English.files.path) | Should -BeExactly @($Turkish.files.path) + } + It 'writes a self-contained file manifest when requested' { $Root = Get-BundleFingerprintFixture -Root (Join-Path $TestDrive 'report') $OutputPath = Join-Path $TestDrive 'bundle-source-fingerprint.json' @@ -54,7 +96,7 @@ Describe 'Published bundle source fingerprint' -Tag 'Unit' { $Actual = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json $Actual.fingerprint | Should -BeExactly $Expected.fingerprint - @($Actual.files).Count | Should -Be 4 + @($Actual.files).Count | Should -Be 10 Get-Content -LiteralPath $OutputPath -Raw | Should -Not -Match ([regex]::Escape($Root)) } } diff --git a/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 index df842dd5..1ca0fe10 100644 --- a/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 +++ b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 @@ -39,6 +39,7 @@ BeforeAll { $Profiles = @( foreach ($RuntimeProfile in @($Matrix.profiles)) { $PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + $InventoryFingerprint = 'c' * 64 $ProfilePolicy = @($Policy.runtimeProfiles | Where-Object powerShellLine -eq $PowerShellLine)[0] $ScenarioDefinitions = @( [pscustomobject]@{ Id = 'graph-module-only'; Provider = 'graph'; Order = @('Microsoft.Graph.Authentication') } @@ -65,6 +66,7 @@ BeforeAll { targetFramework = [string]$RuntimeProfile.targetFramework platform = 'windows' architecture = 'x64' + inventoryFingerprint = $InventoryFingerprint importOrder = @($Definition.Order) dllPickleTiming = if ($Definition.Id -like 'cross-*' -or $Definition.Id -like '*-dllpickle-first') { 'dllpickle-first' @@ -121,6 +123,7 @@ BeforeAll { runtimeExecutable = 'runtime:pwsh.exe' psHome = 'runtime:.' writesPerformed = $false + inventoryFingerprint = $InventoryFingerprint moduleVersions = @( foreach ($ModuleName in @($Policy.monitoredModules.name | Sort-Object)) { [ordered]@{ @@ -153,7 +156,7 @@ BeforeAll { bridge = [ordered]@{ id = 'initial-powershell-7.4-7.6-multitargeting-major' allowedReleaseVersion = '3.0.0' - expiresAtUtc = '2026-09-08T13:00:00Z' + expiresAtUtc = '2026-08-23T12:00:00Z' } bundleSourceFingerprint = [string]$Bundle.fingerprint credentialMode = 'delegated-interactive' @@ -194,7 +197,7 @@ Describe 'Time-bounded manual authenticated evidence' -Tag 'Unit' { $EvidencePath = Join-Path $TestDrive 'expired.json' $null = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath - $ExpiredAt = [System.DateTimeOffset]::Parse('2026-09-09T13:00:00Z') + $ExpiredAt = [System.DateTimeOffset]::Parse('2026-08-24T12:00:00Z') { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc $ExpiredAt } | Should -Throw '*expired*' } @@ -244,6 +247,28 @@ Describe 'Time-bounded manual authenticated evidence' -Tag 'Unit' { Should -Throw '*not bound to exact profile*' } + It 'rejects a scenario checkpoint from a different prepared inventory' { + $EvidencePath = Join-Path $TestDrive 'wrong-scenario-inventory.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.profiles[0].scenarios[0].inventoryFingerprint = 'd' * 64 + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*not bound to exact profile*' + } + + It 'rejects an expiry window renewed from capture completion' { + $EvidencePath = Join-Path $TestDrive 'renewed-expiry.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $Evidence.content.bridge.expiresAtUtc = '2026-08-23T13:00:00Z' + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*exactly 14 days after capture starts*' + } + It 'rejects unknown fields that could carry unsanitized provider data' { $EvidencePath = Join-Path $TestDrive 'unknown-provider-data.json' $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath diff --git a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 index cf49a376..c72f9081 100644 --- a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 +++ b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 @@ -3,23 +3,26 @@ BeforeAll { $script:ScenarioHarness = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Invoke-DLLPickleManualAuthenticatedScenario.ps1') -Raw $script:Orchestrator = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Invoke-DLLPickleManualAuthenticatedCompatibility.ps1') -Raw $script:Initializer = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\Initialize-DLLPickleManualAuthenticatedCompatibility.ps1') -Raw + $script:SharedHelpers = Get-Content -LiteralPath (Join-Path $script:RepositoryRoot 'tools\DLLPickle.ManualAuthenticatedEvidence.ps1') -Raw $script:SchemaPath = Join-Path $script:RepositoryRoot 'build\authenticated-evidence\manual-transition.schema.json' } Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { It 'hard-codes interactive connections and real read probes without accepting command text' { - $script:ScenarioHarness | Should -Match ([regex]::Escape("Connect-MgGraph -Scopes 'User.Read' -ContextScope Process")) - $script:ScenarioHarness | Should -Match ([regex]::Escape("Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me?`$select=id'")) - $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-EXOMailbox -ResultSize 1')) - $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-AzResource -ErrorAction Stop')) - $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-AzStorageAccount -ErrorAction Stop')) - $script:ScenarioHarness | Should -Match ([regex]::Escape('Get-CsTenant -ErrorAction Stop')) - $script:ScenarioHarness | Should -Not -Match 'Invoke-Expression|ScriptBlock|AccessToken|ClientSecret|Certificate' + $script:ScenarioHarness | Should -Match ([regex]::Escape("-Name 'Connect-MgGraph' -Module 'Microsoft.Graph.Authentication'")) + $script:ScenarioHarness | Should -Match ([regex]::Escape("& `$Command -Scopes 'User.Read' -ContextScope Process")) + $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Invoke-MgGraphRequest' -Module 'Microsoft.Graph.Authentication'")) + $script:SharedHelpers | Should -Match ([regex]::Escape("& `$Command -Method GET -Uri '/v1.0/me?`$select=id'")) + $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-EXOMailbox' -Module 'ExchangeOnlineManagement'")) + $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-AzResource' -Module 'Az.Resources'")) + $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-AzStorageAccount' -Module 'Az.Storage'")) + $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-CsTenant' -Module 'MicrosoftTeams'")) + ($script:ScenarioHarness + $script:SharedHelpers) | Should -Not -Match 'Invoke-Expression|ScriptBlock|AccessToken|ClientSecret|Certificate' } It 'captures only sanitized error types and zero-write results' { - $script:ScenarioHarness | Should -Match ([regex]::Escape('errorType = $_.Exception.GetType().FullName')) - $script:ScenarioHarness | Should -Not -Match 'Exception\.Message|ErrorDetails|ScriptStackTrace' + $script:SharedHelpers | Should -Match ([regex]::Escape('errorType = $_.Exception.GetType().FullName')) + ($script:ScenarioHarness + $script:SharedHelpers) | Should -Not -Match 'Exception\.Message|ErrorDetails|ScriptStackTrace' $script:ScenarioHarness | Should -Match ([regex]::Escape('writesPerformed = $false')) $script:ScenarioHarness | Should -Match 'before-authentication' $script:ScenarioHarness | Should -Match 'after-connection' @@ -34,14 +37,29 @@ Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { $script:Orchestrator | Should -Match 'cross-import-order-2' $script:Orchestrator | Should -Match 'Reusing passing checkpoint' $script:Orchestrator | Should -Match ([regex]::Escape("'-ExpectedProfileKey', `$CurrentProfileKey")) + $script:Orchestrator | Should -Match ([regex]::Escape("'-ExpectedInventoryFingerprint', [string]`$PreparedProfile.InventoryFingerprint")) $script:ScenarioHarness | Should -Match ([regex]::Escape('profileKey = $ActualProfileKey')) + $script:ScenarioHarness | Should -Match ([regex]::Escape('inventoryFingerprint = $InventoryFingerprint')) $script:Orchestrator | Should -Match "status = 'pending'" } + It 'imports DLLPickle in module-first scenarios before authentication begins' { + $ModuleFirstImport = $script:ScenarioHarness.IndexOf("if (`$Definition.timing -eq 'module-first') { Import-DLLPickleBundle }", [System.StringComparison]::Ordinal) + $BeforeAuthentication = $script:ScenarioHarness.IndexOf("stage = 'before-authentication'", [System.StringComparison]::Ordinal) + $ConnectLoop = $script:ScenarioHarness.IndexOf('Connect-Provider -Provider', [System.StringComparison]::Ordinal) + + $ModuleFirstImport | Should -BeGreaterThan -1 + $ModuleFirstImport | Should -BeLessThan $BeforeAuthentication + $ModuleFirstImport | Should -BeLessThan $ConnectLoop + } + It 'prepares exact pinned runtimes and refreshes latest compatible modules without authenticating' { $script:Initializer | Should -Match ([regex]::Escape("Provider = 'DirectArchive'")) $script:Initializer | Should -Match ([regex]::Escape("Platform = 'windows'")) $script:Initializer | Should -Match ([regex]::Escape('Force = $true')) + $script:Initializer | Should -Match ([regex]::Escape('Where-Object {')) + $script:Initializer | Should -Match ([regex]::Escape('[string]$_.Version -ne [string]$_.LatestCompatibleVersion')) + $script:Initializer | Should -Match ([regex]::Escape('Get-DLLPicklePreparedInventoryFingerprint')) $script:Initializer | Should -Match 'AuthenticationPerformed = \$false' $script:Initializer | Should -Not -Match 'Connect-MgGraph|Connect-ExchangeOnline|Connect-AzAccount|Connect-MicrosoftTeams' } @@ -54,6 +72,12 @@ Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { $Schema.properties.content.properties.platformScope.const | Should -Be 'windows-x64-only' $Schema.'$defs'.scenario.additionalProperties | Should -BeFalse $Schema.'$defs'.scenario.required | Should -Contain 'profileKey' + $Schema.'$defs'.scenario.required | Should -Contain 'inventoryFingerprint' + $Schema.'$defs'.profile.required | Should -Contain 'inventoryFingerprint' $Schema.'$defs'.profile.properties.scenarios.minItems | Should -Be 14 + $AcceptedRule = @($Schema.properties.acceptance.allOf)[0].then.properties + $AcceptedRule.acceptedAtUtc.type | Should -Be 'string' + $AcceptedRule.acceptedBy.minLength | Should -Be 1 + @($AcceptedRule.confidence.enum) | Should -Be @('low', 'medium', 'high') } } diff --git a/tests/Unit/ManualAuthenticatedHelpers.Tests.ps1 b/tests/Unit/ManualAuthenticatedHelpers.Tests.ps1 new file mode 100644 index 00000000..10a9da27 --- /dev/null +++ b/tests/Unit/ManualAuthenticatedHelpers.Tests.ps1 @@ -0,0 +1,98 @@ +BeforeAll { + $script:RepositoryRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + . (Join-Path $script:RepositoryRoot 'tools\DLLPickle.ManualAuthenticatedEvidence.ps1') +} + +Describe 'Manual authenticated evidence helpers' -Tag 'Unit' { + It 'collapses relative asset paths and rejects root escape' { + ConvertTo-DLLPickleCollapsedAssetPath -Path 'module/./lib/../bin/file.dll' | + Should -BeExactly 'module/bin/file.dll' + { ConvertTo-DLLPickleCollapsedAssetPath -Path '../outside.dll' } | + Should -Throw '*escapes its root*' + } + + It 'normalizes direct roots, mixed separators, and rejects out-of-root paths' { + $Parameters = @{ + ModuleCacheRoot = 'C:\cache\modules\' + DLLPickleRoot = 'C:\repo\module\DLLPickle\' + RuntimeRoot = 'C:\runtime\pwsh\' + } + + ConvertTo-DLLPickleManualEvidencePath -Path 'C:/cache/modules' @Parameters | + Should -BeExactly 'upstream:.' + ConvertTo-DLLPickleManualEvidencePath -Path 'C:/repo/module/DLLPickle/bin\net8.0/file.dll' @Parameters | + Should -BeExactly 'dllpickle:bin/net8.0/file.dll' + { ConvertTo-DLLPickleManualEvidencePath -Path 'C:\cache\modules\..\secret.txt' @Parameters } | + Should -Throw '*escapes its root*' + { ConvertTo-DLLPickleManualEvidencePath -Path 'C:\unrelated\file.dll' @Parameters } | + Should -Throw '*outside the upstream*' + } + + It 'normalizes only manifests beneath the prepared cache root' { + ConvertTo-DLLPickleUpstreamManifestIdentifier -ManifestPath 'C:/cache/Graph\2.0/Graph.psd1' -ModuleCachePath 'C:\cache\' | + Should -BeExactly 'upstream:Graph/2.0/Graph.psd1' + { ConvertTo-DLLPickleUpstreamManifestIdentifier -ManifestPath 'C:\cache' -ModuleCachePath 'C:\cache\' } | + Should -Throw '*outside its prepared module cache*' + { ConvertTo-DLLPickleUpstreamManifestIdentifier -ManifestPath 'C:\other\Graph.psd1' -ModuleCachePath 'C:\cache' } | + Should -Throw '*outside its prepared module cache*' + } + + It 'resolves exactly one command from the named prepared module' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-Item' -Module 'Microsoft.PowerShell.Management' + + $Command.Name | Should -BeExactly 'Get-Item' + $Command.ModuleName | Should -BeExactly 'Microsoft.PowerShell.Management' + } + + It 'returns a sanitized failing read-probe shape without retaining an error message' { + Mock Get-DLLPickleAuthenticatedCommand { + { throw [System.UnauthorizedAccessException]::new('sensitive provider detail') } + } -ParameterFilter { $Name -eq 'Get-MgContext' -and $Module -eq 'Microsoft.Graph.Authentication' } + + $Result = Invoke-DLLPickleAuthenticatedReadProbe -ProbeId 'graph-context' + + @($Result.Keys) | Should -Be @('probeId', 'executed', 'status', 'durationMilliseconds', 'writesPerformed', 'errorType') + $Result.probeId | Should -BeExactly 'graph-context' + $Result.executed | Should -BeTrue + $Result.status | Should -BeExactly 'failed' + $Result.writesPerformed | Should -BeFalse + $Result.errorType | Should -BeExactly 'System.UnauthorizedAccessException' + ($Result | ConvertTo-Json -Compress) | Should -Not -Match 'sensitive provider detail' + } + + It 'changes the prepared inventory fingerprint when selected module bytes change' { + $CacheRoot = Join-Path $TestDrive 'module-cache' + $ModuleRoot = Join-Path $CacheRoot 'Contoso.Module\1.0.0' + $null = New-Item -Path $ModuleRoot -ItemType Directory -Force + $ModuleFile = Join-Path $ModuleRoot 'Contoso.Module.psm1' + Set-Content -LiteralPath $ModuleFile -Value 'function Get-Contoso { 1 }' -Encoding UTF8 + $Inventory = [pscustomobject]@{ + ProfileKey = 'ps7.4-net8.0-windows-x64' + ModuleCachePath = $CacheRoot + Profile = [pscustomobject]@{ + PowerShellVersion = '7.4.18' + PowerShellLine = '7.4' + TargetFramework = 'net8.0' + Platform = 'windows' + Architecture = 'x64' + } + Modules = @( + [pscustomobject]@{ + Name = 'Contoso.Module' + Version = '1.0.0' + LatestCompatibleVersion = '1.0.0' + ModulePath = $ModuleRoot + ModuleManifestPath = $ModuleFile + } + ) + } + + $Before = Get-DLLPicklePreparedInventoryFingerprint -Inventory $Inventory + Set-Content -LiteralPath $ModuleFile -Value 'function Get-Contoso { 2 }' -Encoding UTF8 + $After = Get-DLLPicklePreparedInventoryFingerprint -Inventory $Inventory + + $Before | Should -Match '^[a-f0-9]{64}$' + $After | Should -Match '^[a-f0-9]{64}$' + $After | Should -Not -BeExactly $Before + } +} diff --git a/tests/Unit/ProfileConflictBaseline.Tests.ps1 b/tests/Unit/ProfileConflictBaseline.Tests.ps1 index b6983c64..d3b4901a 100644 --- a/tests/Unit/ProfileConflictBaseline.Tests.ps1 +++ b/tests/Unit/ProfileConflictBaseline.Tests.ps1 @@ -189,6 +189,7 @@ Describe 'Profile-specific conflict baseline enforcement' -Tag 'Unit' { $Result = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json $Result.Status | Should -Be 'InvalidCommittedEvidence' $Result.FailureDetail | Should -Match 'was not found' + $Result.FindingFingerprint | Should -Match '^[a-f0-9]{64}$' } It 'rejects a conflict matrix whose key does not match its profile fields' { diff --git a/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 b/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 index 846b4e71..5ed1160d 100644 --- a/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 +++ b/tests/Unit/ProfileEvidenceHelpers.Tests.ps1 @@ -28,6 +28,11 @@ Describe 'Profile evidence helpers' -Tag 'Unit' { Should -Throw '*no fingerprinted content*' } + It 'normalizes date/time inputs to UTC with invariant parsing' { + (ConvertTo-DLLPickleUtcDateTimeOffset -Value '2026-08-09T08:00:00-04:00').ToString('o') | + Should -BeExactly '2026-08-09T12:00:00.0000000+00:00' + } + It 'uses ordinal ordering and uniqueness regardless of process culture' { $OriginalCulture = [System.Globalization.CultureInfo]::CurrentCulture try { diff --git a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 index 5b18ba11..7377d9bb 100644 --- a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 +++ b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 @@ -23,6 +23,21 @@ Describe 'Get-DLLPickleLoadedTrackedAssembly' -Tag 'Unit' { $Row.Alc | Should -Not -BeNullOrEmpty $Row.Version | Should -Not -BeNullOrEmpty $Row.Platform | Should -BeIn @('windows', 'linux', 'macos') + $Row.OS | Should -Not -BeNullOrEmpty + if ($Row.Platform -eq 'macos') { + $Row.OS | Should -Match '^macOS \d+\.\d+' + } + } + + It 'uses the stable macOS product version instead of the Darwin kernel description' { + $Source = Get-Content -LiteralPath $LoadedScript -Raw + + $Source | Should -Match ([regex]::Escape('/usr/bin/sw_vers -productVersion')) + $Source | Should -Match ([regex]::Escape('$OperatingSystemDescription = "macOS $MacOSProductVersion"')) + Get-Content -LiteralPath (Join-Path $RepoRoot 'build\profile-evidence\ps7.4-net8.0-macos-x64.json') -Raw | + Should -Not -Match 'Darwin Kernel Version' + Get-Content -LiteralPath (Join-Path $RepoRoot 'build\profile-evidence\ps7.5-net9.0-macos-x64.json') -Raw | + Should -Not -Match 'Darwin Kernel Version' } It 'excludes loaded assemblies that are not in trackedAssemblies' { diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index b44570dd..36f12a91 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -132,6 +132,11 @@ Describe 'Release publish gating guardrails' -Tag 'Unit' { $ReleaseWorkflow | Should -Match ([regex]::Escape('Authenticated-Compatibility.yml')) $ReleaseWorkflow | Should -Match ([regex]::Escape('authenticated-compatibility-evidence')) $ReleaseWorkflow | Should -Match ([regex]::Escape("'--commit', `$EvidenceSha")) + $ReleaseWorkflow | Should -Match ([regex]::Escape("Test-Path -LiteralPath `$ProtectedWorkflowPath -PathType Leaf")) + $ReleaseWorkflow | Should -Match ([regex]::Escape("if (`$LASTEXITCODE -ne 0)")) + $ReleaseWorkflow | Should -Match ([regex]::Escape("none has exactly one unexpired")) + $ReleaseWorkflow | Should -Match ([regex]::Escape("/repos/`$(`$env:GITHUB_REPOSITORY)/actions/runs/")) + $ReleaseWorkflow | Should -Not -Match ([regex]::Escape("'/repos/`$\{\{ github.repository \}\}")) $ReleaseWorkflow | Should -Match ([regex]::Escape('steps.release-candidate.outputs.release_sha')) $ReleaseWorkflow | Should -Match ([regex]::Escape('needs.authenticated-release-gate.outputs.release_sha')) $ReleaseWorkflow | Should -Not -Match ([regex]::Escape('github.event.pull_request.head.sha')) diff --git a/tools/DLLPickle.ManualAuthenticatedEvidence.ps1 b/tools/DLLPickle.ManualAuthenticatedEvidence.ps1 new file mode 100644 index 00000000..7cbdae9f --- /dev/null +++ b/tools/DLLPickle.ManualAuthenticatedEvidence.ps1 @@ -0,0 +1,334 @@ +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') + +function ConvertTo-DLLPickleCollapsedAssetPath { + <# + .SYNOPSIS + Collapses a normalized relative asset path without permitting root escape. + + .PARAMETER Path + Forward- or backslash-delimited relative path. + + .OUTPUTS + System.String + #> + + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Path + ) + + $Segments = [System.Collections.Generic.List[string]]::new() + foreach ($Segment in @($Path.Replace('\', '/') -split '/')) { + if ([string]::IsNullOrWhiteSpace($Segment) -or $Segment -eq '.') { + continue + } + if ($Segment -eq '..') { + if ($Segments.Count -eq 0) { + throw "Asset path '$Path' escapes its root." + } + $Segments.RemoveAt($Segments.Count - 1) + continue + } + $Segments.Add($Segment) + } + $Segments -join '/' +} + +function ConvertTo-DLLPickleManualEvidencePath { + <# + .SYNOPSIS + Replaces an authenticated-evidence path root with a stable identifier. + + .PARAMETER Path + Path to normalize. + + .PARAMETER ModuleCacheRoot + Prepared upstream module-cache root. + + .PARAMETER DLLPickleRoot + Prepared DLLPickle module root. + + .PARAMETER RuntimeRoot + Exact PowerShell runtime root. + + .OUTPUTS + System.String + #> + + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ModuleCacheRoot, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$DLLPickleRoot, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RuntimeRoot + ) + + $NormalizedPath = $Path.Replace('\', '/').TrimEnd('/') + $Roots = [ordered]@{ + upstream = $ModuleCacheRoot.Replace('\', '/').TrimEnd('/') + dllpickle = $DLLPickleRoot.Replace('\', '/').TrimEnd('/') + runtime = $RuntimeRoot.Replace('\', '/').TrimEnd('/') + } + foreach ($RootEntry in $Roots.GetEnumerator()) { + if ($NormalizedPath -eq $RootEntry.Value) { + return "$($RootEntry.Key):." + } + if ($NormalizedPath.StartsWith("$($RootEntry.Value)/", [System.StringComparison]::OrdinalIgnoreCase)) { + $Relative = $NormalizedPath.Substring($RootEntry.Value.Length + 1) + return '{0}:{1}' -f $RootEntry.Key, (ConvertTo-DLLPickleCollapsedAssetPath -Path $Relative) + } + } + throw "Authenticated evidence path '$Path' is outside the upstream, DLLPickle, and exact runtime roots." +} + +function ConvertTo-DLLPickleUpstreamManifestIdentifier { + <# + .SYNOPSIS + Converts a prepared module manifest path to an upstream evidence identifier. + + .PARAMETER ManifestPath + Absolute selected module manifest path. + + .PARAMETER ModuleCachePath + Absolute prepared module-cache root. + + .OUTPUTS + System.String + #> + + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ManifestPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ModuleCachePath + ) + + $NormalizedManifest = $ManifestPath.Replace('\', '/').TrimEnd('/') + $NormalizedRoot = $ModuleCachePath.Replace('\', '/').TrimEnd('/') + if (-not $NormalizedManifest.StartsWith("$NormalizedRoot/", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Module manifest '$ManifestPath' is outside its prepared module cache." + } + $Relative = ConvertTo-DLLPickleCollapsedAssetPath -Path $NormalizedManifest.Substring($NormalizedRoot.Length + 1) + 'upstream:{0}' -f $Relative +} + +function Get-DLLPicklePreparedInventoryFingerprint { + <# + .SYNOPSIS + Fingerprints the exact selected profile and every prepared module file. + + .PARAMETER Inventory + Upstream inventory whose selected modules must be bound to checkpoints. + + .OUTPUTS + System.String + #> + + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object]$Inventory + ) + + $ModuleCacheRoot = [System.IO.Path]::GetFullPath([string]$Inventory.ModuleCachePath) + if (-not (Test-Path -LiteralPath $ModuleCacheRoot -PathType Container)) { + throw "Prepared module cache was not found: $ModuleCacheRoot" + } + $CanonicalRows = [System.Collections.Generic.List[string]]::new() + $CanonicalRows.Add('schemaVersion=1') + $ProfileRow = 'profile|{0}|{1}|{2}|{3}|{4}|{5}' -f @( + [string]$Inventory.Profile.PowerShellVersion, + [string]$Inventory.Profile.PowerShellLine, + [string]$Inventory.Profile.TargetFramework, + [string]$Inventory.Profile.Platform, + [string]$Inventory.Profile.Architecture, + [string]$Inventory.ProfileKey + ) + $CanonicalRows.Add($ProfileRow) + + $Modules = @(Get-DLLPickleOrdinalSequence -InputObject @($Inventory.Modules) -KeySelector { param($Module) [string]$Module.Name }) + if ($Modules.Count -eq 0) { + throw 'Prepared inventory contains no selected modules.' + } + $SeenModuleNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($Module in $Modules) { + if (-not $SeenModuleNames.Add([string]$Module.Name)) { + throw "Prepared inventory contains duplicate module '$($Module.Name)'." + } + $ModulePath = [System.IO.Path]::GetFullPath([string]$Module.ModulePath) + $RelativeModulePath = [System.IO.Path]::GetRelativePath($ModuleCacheRoot, $ModulePath).Replace('\', '/') + if ([System.IO.Path]::IsPathRooted($RelativeModulePath) -or + $RelativeModulePath -eq '..' -or + $RelativeModulePath.StartsWith('../', [System.StringComparison]::Ordinal)) { + throw "Prepared module '$($Module.Name)' is outside its module cache." + } + if (-not (Test-Path -LiteralPath $ModulePath -PathType Container)) { + throw "Prepared module '$($Module.Name)' was not found: $ModulePath" + } + $ModuleManifestPath = [System.IO.Path]::GetFullPath([string]$Module.ModuleManifestPath) + $RelativeManifestPath = [System.IO.Path]::GetRelativePath($ModulePath, $ModuleManifestPath).Replace('\', '/') + if ([System.IO.Path]::IsPathRooted($RelativeManifestPath) -or + $RelativeManifestPath -eq '..' -or + $RelativeManifestPath.StartsWith('../', [System.StringComparison]::Ordinal) -or + -not (Test-Path -LiteralPath $ModuleManifestPath -PathType Leaf)) { + throw "Prepared module '$($Module.Name)' manifest is outside its selected module root." + } + $ModuleRow = 'module|{0}|{1}|{2}|{3}|{4}' -f @( + [string]$Module.Name, + [string]$Module.Version, + [string]$Module.LatestCompatibleVersion, + (ConvertTo-DLLPickleCollapsedAssetPath -Path $RelativeModulePath), + (ConvertTo-DLLPickleCollapsedAssetPath -Path $RelativeManifestPath) + ) + $CanonicalRows.Add($ModuleRow) + + $ModuleEntries = @(Get-ChildItem -LiteralPath $ModulePath -Force -Recurse) + foreach ($ModuleEntry in $ModuleEntries) { + if (($ModuleEntry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Prepared module input must not be a symbolic link or reparse point: $($ModuleEntry.FullName)" + } + } + $ModuleFileRows = @( + foreach ($ModuleFile in @($ModuleEntries | Where-Object { -not $_.PSIsContainer })) { + [pscustomobject]@{ + RelativePath = [System.IO.Path]::GetRelativePath($ModulePath, $ModuleFile.FullName).Replace('\', '/') + FullName = [string]$ModuleFile.FullName + Length = [long]$ModuleFile.Length + Attributes = $ModuleFile.Attributes + } + } + ) + $ModuleFiles = @( + Get-DLLPickleOrdinalSequence -InputObject $ModuleFileRows -KeySelector { param($File) [string]$File.RelativePath } -Unique + ) + if ($ModuleFiles.Count -eq 0) { + throw "Prepared module '$($Module.Name)' contains no files." + } + foreach ($ModuleFile in $ModuleFiles) { + $RelativeFilePath = [string]$ModuleFile.RelativePath + $FileHash = (Get-FileHash -LiteralPath $ModuleFile.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $FileRow = 'file|{0}|{1}|{2}|{3}' -f @( + [string]$Module.Name, + $RelativeFilePath, + $FileHash, + [long]$ModuleFile.Length + ) + $CanonicalRows.Add($FileRow) + } + } + + $CanonicalText = $CanonicalRows -join [char]10 + $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalText) + [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData($Bytes) + ).Replace('-', '').ToLowerInvariant() +} + +function Get-DLLPickleAuthenticatedCommand { + <# + .SYNOPSIS + Resolves one hard-coded authenticated harness command from its exact module. + + .PARAMETER Name + Command name. + + .PARAMETER Module + Exact prepared module that must own the command. + + .OUTPUTS + System.Management.Automation.CommandInfo + #> + + [CmdletBinding()] + [OutputType([System.Management.Automation.CommandInfo])] + param ( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Name, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Module + ) + + $Commands = @(Get-Command -Name $Name -Module $Module -CommandType Function, Cmdlet -ErrorAction Stop) + if ($Commands.Count -ne 1) { + throw "Expected exactly one '$Name' command from prepared module '$Module'; found $($Commands.Count)." + } + $Commands[0] +} + +function Invoke-DLLPickleAuthenticatedReadProbe { + <# + .SYNOPSIS + Runs one hard-coded authenticated read probe and returns a sanitized result. + + .PARAMETER ProbeId + Fixed probe identifier. No command text is accepted. + + .OUTPUTS + System.Collections.Specialized.OrderedDictionary + #> + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ProbeId + ) + + $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + switch ($ProbeId) { + 'graph-context' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-MgContext' -Module 'Microsoft.Graph.Authentication' + if (-not (& $Command -ErrorAction Stop)) { throw 'Graph context was not established.' } + } + 'graph-me-read' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Invoke-MgGraphRequest' -Module 'Microsoft.Graph.Authentication' + & $Command -Method GET -Uri '/v1.0/me?$select=id' -ErrorAction Stop | Out-Null + } + 'exo-mailbox-read' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-EXOMailbox' -Module 'ExchangeOnlineManagement' + if (@(& $Command -ResultSize 1 -ErrorAction Stop).Count -eq 0) { throw 'Exchange mailbox read returned no object.' } + } + 'az-context' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-AzContext' -Module 'Az.Accounts' + if (-not (& $Command -ErrorAction Stop)) { throw 'Azure context was not established.' } + } + 'az-resource-read' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-AzResource' -Module 'Az.Resources' + if (@(& $Command -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure resource read returned no object.' } + } + 'az-storage-account-read' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-AzStorageAccount' -Module 'Az.Storage' + if (@(& $Command -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure storage-account read returned no object.' } + } + 'teams-tenant-read' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Get-CsTenant' -Module 'MicrosoftTeams' + if (-not (& $Command -ErrorAction Stop)) { throw 'Teams tenant read returned no object.' } + } + default { throw "Unsupported authenticated probe identifier '$ProbeId'." } + } + $Stopwatch.Stop() + [ordered]@{ + probeId = $ProbeId + executed = $true + status = 'passed' + durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds + writesPerformed = $false + errorType = $null + } + } catch { + $Stopwatch.Stop() + [ordered]@{ + probeId = $ProbeId + executed = $true + status = 'failed' + durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds + writesPerformed = $false + errorType = $_.Exception.GetType().FullName + } + } +} diff --git a/tools/DLLPickle.ProfileEvidence.ps1 b/tools/DLLPickle.ProfileEvidence.ps1 index c785f1cf..cf6891ab 100644 --- a/tools/DLLPickle.ProfileEvidence.ps1 +++ b/tools/DLLPickle.ProfileEvidence.ps1 @@ -17,9 +17,13 @@ function Get-DLLPickleNormalizedEvidenceFingerprint { [object]$Evidence ) + if ([int]$Evidence.schemaVersion -ne 1) { + throw 'Normalized profile evidence has an unsupported schema.' + } + $ContentProperty = $Evidence.PSObject.Properties['content'] - if ([int]$Evidence.schemaVersion -ne 1 -or $null -eq $ContentProperty -or $null -eq $ContentProperty.Value) { - throw 'Normalized profile evidence has an unsupported schema or no fingerprinted content.' + if ($null -eq $ContentProperty -or $null -eq $ContentProperty.Value) { + throw 'Normalized profile evidence has no fingerprinted content.' } $CanonicalContent = $ContentProperty.Value | ConvertTo-Json -Depth 100 -Compress @@ -29,6 +33,38 @@ function Get-DLLPickleNormalizedEvidenceFingerprint { ).Replace('-', '').ToLowerInvariant() } +function ConvertTo-DLLPickleUtcDateTimeOffset { + <# + .SYNOPSIS + Converts a date/time value to a UTC DateTimeOffset. + + .PARAMETER Value + DateTimeOffset, DateTime, or invariant date/time text to normalize. + + .OUTPUTS + System.DateTimeOffset + #> + + [CmdletBinding()] + [OutputType([System.DateTimeOffset])] + param ( + [Parameter(Mandatory)] + [object]$Value + ) + + if ($Value -is [System.DateTimeOffset]) { + return $Value.ToUniversalTime() + } + if ($Value -is [System.DateTime]) { + return ([System.DateTimeOffset]$Value).ToUniversalTime() + } + [System.DateTimeOffset]::Parse( + [string]$Value, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ).ToUniversalTime() +} + function Get-DLLPickleOrdinalSequence { <# .SYNOPSIS diff --git a/tools/Get-DLLPickleBundleSourceFingerprint.ps1 b/tools/Get-DLLPickleBundleSourceFingerprint.ps1 index 7b90aac3..f3c8579d 100644 --- a/tools/Get-DLLPickleBundleSourceFingerprint.ps1 +++ b/tools/Get-DLLPickleBundleSourceFingerprint.ps1 @@ -3,11 +3,12 @@ Computes a deterministic fingerprint of every published-bundle source input. .DESCRIPTION -Hashes the exact paths used by the release workflow's automatic bundle-change -gate: src/DLLPickle/**, DLLPickle.csproj, and packages.lock.json. Repository-root -paths and timestamps are excluded. The result can bind transitional manual -authenticated evidence to bundle content even though committing that evidence -necessarily changes the Git commit SHA. +Hashes module sources, the locked build project, and every repository input that +controls PrepareModuleOutput: the build file, settings, tooling bootstrap, pinned +tool policy, build entry point, and SDK selection. Repository-root paths and +timestamps are excluded. The result can bind transitional manual authenticated +evidence to bundle content even though committing that evidence necessarily +changes the Git commit SHA. .PARAMETER RepositoryRoot Repository root containing src/DLLPickle and src/DLLPickle.Build. @@ -31,14 +32,28 @@ param ( ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') $ResolvedRepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $ModuleSourceRoot = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle' -$BuildProjectPath = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle.Build/DLLPickle.csproj' -$LockFilePath = Join-Path $ResolvedRepositoryRoot 'src/DLLPickle.Build/packages.lock.json' if (-not (Test-Path -LiteralPath $ModuleSourceRoot -PathType Container)) { throw "Published module source directory was not found: $ModuleSourceRoot" } -foreach ($RequiredFile in @($BuildProjectPath, $LockFilePath)) { +$RequiredRelativePaths = @( + 'build/DLLPickle.Build.ps1' + 'build/DLLPickle.Settings.ps1' + 'build/DLLPickle.Tooling.ps1' + 'build/build-tool-versions.json' + 'global.json' + 'src/DLLPickle.Build/DLLPickle.csproj' + 'src/DLLPickle.Build/packages.lock.json' + 'tools/Invoke-DLLPickleBuild.ps1' +) +$RequiredFiles = @( + foreach ($RequiredRelativePath in $RequiredRelativePaths) { + Join-Path $ResolvedRepositoryRoot $RequiredRelativePath + } +) +foreach ($RequiredFile in $RequiredFiles) { if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { throw "Published bundle input was not found: $RequiredFile" } @@ -46,9 +61,10 @@ foreach ($RequiredFile in @($BuildProjectPath, $LockFilePath)) { $SourceFiles = @( Get-ChildItem -LiteralPath $ModuleSourceRoot -File -Recurse - Get-Item -LiteralPath $BuildProjectPath - Get-Item -LiteralPath $LockFilePath -) | Sort-Object FullName -Unique + foreach ($RequiredFile in $RequiredFiles) { + Get-Item -LiteralPath $RequiredFile + } +) if ($SourceFiles.Count -eq 0) { throw 'No published bundle source inputs were found.' } @@ -64,13 +80,16 @@ $Rows = @( } $ContentBytes = [System.IO.File]::ReadAllBytes($SourceFile.FullName) $Sha256 = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($ContentBytes)).Replace('-', '').ToLowerInvariant() - [ordered]@{ + [pscustomobject][ordered]@{ path = $RelativePath sha256 = $Sha256 length = [long]$SourceFile.Length } } ) +$Rows = @( + Get-DLLPickleOrdinalSequence -InputObject $Rows -KeySelector { param($Row) [string]$Row.path } -Unique +) $CanonicalRows = @( 'schemaVersion=1' $Rows | ForEach-Object { '{0}|{1}|{2}' -f $_.path, $_.sha256, $_.length } diff --git a/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 index 85b7afc3..1e50bcf1 100644 --- a/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 +++ b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 @@ -44,6 +44,14 @@ $Platform = if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatfor } else { 'unknown' } +$OperatingSystemDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription +if ($Platform -eq 'macos') { + $MacOSProductVersion = (& /usr/bin/sw_vers -productVersion).Trim() + if ($LASTEXITCODE -ne 0 -or $MacOSProductVersion -notmatch '^\d+\.\d+(?:\.\d+)?$') { + throw "Could not normalize the macOS product version returned by sw_vers: '$MacOSProductVersion'." + } + $OperatingSystemDescription = "macOS $MacOSProductVersion" +} [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $TrackedNames -contains $_.GetName().Name } | @@ -70,7 +78,7 @@ $Platform = if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatfor } else { $null } - OS = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + OS = $OperatingSystemDescription Platform = $Platform Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() } diff --git a/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 b/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 index 18b2eb95..9477e946 100644 --- a/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 +++ b/tools/Initialize-DLLPickleManualAuthenticatedCompatibility.ps1 @@ -27,6 +27,7 @@ param ( $ErrorActionPreference = 'Stop' $RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent +. (Join-Path $PSScriptRoot 'DLLPickle.ManualAuthenticatedEvidence.ps1') $ResolvedWorkRoot = [System.IO.Path]::GetFullPath($WorkRoot) $RuntimeInstallRoot = Join-Path $ResolvedWorkRoot 'runtimes' $ModuleCacheParent = Join-Path $ResolvedWorkRoot 'module-cache' @@ -64,19 +65,25 @@ $PreparedProfiles = @( Force = $true } $Inventory = & (Join-Path $RepositoryRoot 'tools/Get-DLLPickleUpstreamInventory.ps1') @InventoryParameters - $StaleSelections = @($Inventory.Modules | Where-Object Version -ne LatestCompatibleVersion) + $StaleSelections = @($Inventory.Modules | Where-Object { + [string]$_.Version -ne [string]$_.LatestCompatibleVersion + }) if ($StaleSelections.Count -gt 0) { throw "The prepared inventory for '$ProfileKey' did not select every latest compatible module." } + $InventoryFingerprint = Get-DLLPicklePreparedInventoryFingerprint -Inventory $Inventory + $Inventory | Add-Member -MemberType NoteProperty -Name InventoryFingerprint -Value $InventoryFingerprint -Force + $Inventory | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $InventoryPath -Encoding utf8NoBOM $PreparedProfile = [pscustomobject]@{ ProfileKey = $ProfileKey PowerShellVersion = [string]$RuntimeProfile.powerShellVersion TargetFramework = [string]$RuntimeProfile.targetFramework ExecutablePath = [string]$Identity.ExecutablePath InventoryPath = $InventoryPath + InventoryFingerprint = $InventoryFingerprint ModuleVersions = [ordered]@{} } - foreach ($Module in @($Inventory.Modules | Sort-Object Name)) { + foreach ($Module in @(Get-DLLPickleOrdinalSequence -InputObject @($Inventory.Modules) -KeySelector { param($Item) [string]$Item.Name } -Unique)) { $PreparedProfile.ModuleVersions[[string]$Module.Name] = [string]$Module.Version } $PreparedProfile diff --git a/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 index 242e831c..45d60a38 100644 --- a/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 +++ b/tools/Invoke-DLLPickleManualAuthenticatedCompatibility.ps1 @@ -5,8 +5,9 @@ Collects resumable, sanitized interactive authentication evidence for the initia .DESCRIPTION Runs fixed scenarios under each prepared exact Windows PowerShell executable. Every scenario is a fresh process. Existing passing scenario checkpoints are -reused only when the source commit and bundle fingerprint match. The final -candidate remains pending until a maintainer reviews and explicitly accepts it. +reused only when the source commit, bundle fingerprint, and complete prepared +module-inventory fingerprint match. The final candidate remains pending until a +maintainer reviews and explicitly accepts it. No token, tenant, account, mailbox, subscription, resource, or raw service result is written to evidence. @@ -38,6 +39,7 @@ param ( $ErrorActionPreference = 'Stop' $RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent +. (Join-Path $PSScriptRoot 'DLLPickle.ManualAuthenticatedEvidence.ps1') $ResolvedWorkRoot = [System.IO.Path]::GetFullPath($WorkRoot) if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $ResolvedWorkRoot 'manual-authenticated-evidence.candidate.json' @@ -58,6 +60,16 @@ foreach ($RequiredPath in @($PreparationSummaryPath, $MatrixPath, $PolicyPath, $ } $Preparation = Get-Content -LiteralPath $PreparationSummaryPath -Raw | ConvertFrom-Json -ErrorAction Stop $Matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json -ErrorAction Stop +$AllProfileKeys = @( + $Matrix.profiles | ForEach-Object { + 'ps{0}.{1}-{2}-windows-x64' -f $_.powerShellMajor, $_.powerShellMinor, $_.targetFramework + } +) +$PreparedProfileKeys = @($Preparation.Profiles.ProfileKey) +if ($PreparedProfileKeys.Count -ne $AllProfileKeys.Count -or + @(Compare-Object -ReferenceObject @(Get-DLLPickleOrdinalSequence -InputObject $AllProfileKeys) -DifferenceObject @(Get-DLLPickleOrdinalSequence -InputObject $PreparedProfileKeys)).Count -gt 0) { + throw 'Preparation summary does not contain the exact required Windows runtime profile set.' +} $Bundle = & (Join-Path $PSScriptRoot 'Get-DLLPickleBundleSourceFingerprint.ps1') -RepositoryRoot $RepositoryRoot $SourceCommitSha = (git -C $RepositoryRoot rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or $SourceCommitSha -notmatch '^[a-f0-9]{40}$') { @@ -66,17 +78,41 @@ if ($LASTEXITCODE -ne 0 -or $SourceCommitSha -notmatch '^[a-f0-9]{40}$') { if ([string]$Preparation.BundleSourceFingerprint -ne [string]$Bundle.fingerprint) { throw 'Prepared modules/runtimes belong to a different bundle fingerprint. Rerun Initialize-DLLPickleManualAuthenticatedCompatibility.ps1.' } +$CurrentInventoryFingerprints = [ordered]@{} +foreach ($PreparedProfile in @($Preparation.Profiles)) { + if (-not (Test-Path -LiteralPath ([string]$PreparedProfile.InventoryPath) -PathType Leaf)) { + throw "Prepared inventory was not found for '$($PreparedProfile.ProfileKey)': $($PreparedProfile.InventoryPath)" + } + $PreparedInventory = Get-Content -LiteralPath ([string]$PreparedProfile.InventoryPath) -Raw | ConvertFrom-Json -ErrorAction Stop + $CurrentInventoryFingerprint = Get-DLLPicklePreparedInventoryFingerprint -Inventory $PreparedInventory + if ([string]$PreparedProfile.InventoryFingerprint -ne $CurrentInventoryFingerprint) { + throw "Prepared module inventory changed for '$($PreparedProfile.ProfileKey)'. Rerun Initialize-DLLPickleManualAuthenticatedCompatibility.ps1." + } + $CurrentInventoryFingerprints[[string]$PreparedProfile.ProfileKey] = $CurrentInventoryFingerprint +} if (Test-Path -LiteralPath $SessionPath -PathType Leaf) { $Session = Get-Content -LiteralPath $SessionPath -Raw | ConvertFrom-Json -ErrorAction Stop if ([string]$Session.sourceCommitSha -ne $SourceCommitSha -or [string]$Session.bundleSourceFingerprint -ne [string]$Bundle.fingerprint) { throw "The existing capture session belongs to another commit or bundle. Preserve it and choose a new -WorkRoot." } + foreach ($PreparedProfile in @($Preparation.Profiles)) { + $SessionFingerprintProperty = if ($null -ne $Session.inventoryFingerprints) { + $Session.inventoryFingerprints.PSObject.Properties[[string]$PreparedProfile.ProfileKey] + } else { + $null + } + $SessionFingerprint = if ($null -ne $SessionFingerprintProperty) { $SessionFingerprintProperty.Value } else { $null } + if ([string]$SessionFingerprint -ne [string]$CurrentInventoryFingerprints[[string]$PreparedProfile.ProfileKey]) { + throw "The existing capture session belongs to another prepared module inventory for '$($PreparedProfile.ProfileKey)'. Preserve it and choose a new -WorkRoot." + } + } } else { $Session = [pscustomobject][ordered]@{ schemaVersion = 1 sourceCommitSha = $SourceCommitSha bundleSourceFingerprint = [string]$Bundle.fingerprint + inventoryFingerprints = $CurrentInventoryFingerprints captureStartedAtUtc = [System.DateTimeOffset]::UtcNow.ToString('o') credentialMode = 'delegated-interactive' credentialMaterialCaptured = $false @@ -85,6 +121,11 @@ if (Test-Path -LiteralPath $SessionPath -PathType Leaf) { $null = New-Item -Path $ResolvedWorkRoot -ItemType Directory -Force $Session | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SessionPath -Encoding utf8NoBOM } +$CaptureStartedAtUtc = ConvertTo-DLLPickleUtcDateTimeOffset -Value $Session.captureStartedAtUtc +$ExpiresAtUtc = $CaptureStartedAtUtc.AddDays(14) +if ([System.DateTimeOffset]::UtcNow -ge $ExpiresAtUtc) { + throw "Manual authenticated evidence capture expired at $($ExpiresAtUtc.ToString('o')). Preserve it and start a new work root." +} $AllScenarioIds = @( 'graph-module-only', 'graph-dllpickle-first', 'graph-module-first', @@ -93,11 +134,6 @@ $AllScenarioIds = @( 'teams-module-only', 'teams-dllpickle-first', 'teams-module-first', 'cross-import-order-1', 'cross-import-order-2' ) -$AllProfileKeys = @( - $Matrix.profiles | ForEach-Object { - 'ps{0}.{1}-{2}-windows-x64' -f $_.powerShellMajor, $_.powerShellMinor, $_.targetFramework - } -) $SelectedProfileKeys = if ($ProfileKey.Count -gt 0) { @($ProfileKey) } else { $AllProfileKeys } $SelectedScenarioIds = if ($ScenarioId.Count -gt 0) { @($ScenarioId) } else { $AllScenarioIds } $UnknownProfiles = @($SelectedProfileKeys | Where-Object { $_ -notin $AllProfileKeys }) @@ -120,7 +156,8 @@ foreach ($CurrentProfileKey in $SelectedProfileKeys) { [string]$ExistingScenario.powerShellVersion -eq [string]$PreparedProfile.PowerShellVersion -and [string]$ExistingScenario.targetFramework -eq [string]$PreparedProfile.TargetFramework -and [string]$ExistingScenario.platform -eq 'windows' -and - [string]$ExistingScenario.architecture -eq 'x64') { + [string]$ExistingScenario.architecture -eq 'x64' -and + [string]$ExistingScenario.inventoryFingerprint -eq [string]$PreparedProfile.InventoryFingerprint) { Write-Information -MessageData "Reusing passing checkpoint: $CurrentProfileKey / $CurrentScenarioId" -InformationAction Continue continue } @@ -138,7 +175,8 @@ foreach ($CurrentProfileKey in $SelectedProfileKeys) { '-OutputPath', $ScenarioOutputPath, '-ExpectedProfileKey', $CurrentProfileKey, '-ExpectedPowerShellVersion', [string]$PreparedProfile.PowerShellVersion, - '-ExpectedTargetFramework', [string]$PreparedProfile.TargetFramework + '-ExpectedTargetFramework', [string]$PreparedProfile.TargetFramework, + '-ExpectedInventoryFingerprint', [string]$PreparedProfile.InventoryFingerprint ) if (-not [string]::IsNullOrWhiteSpace($AzureSubscriptionId)) { $ChildArguments += @('-AzureSubscriptionId', $AzureSubscriptionId) @@ -167,20 +205,6 @@ if ($MissingScenarioPaths.Count -gt 0) { } } -function ConvertTo-UpstreamManifestIdentifier { - param( - [Parameter(Mandatory)][string]$ManifestPath, - [Parameter(Mandatory)][string]$ModuleCachePath - ) - - $NormalizedManifest = $ManifestPath.Replace('\', '/') - $NormalizedRoot = $ModuleCachePath.Replace('\', '/').TrimEnd('/') - if (-not $NormalizedManifest.StartsWith("$NormalizedRoot/", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Module manifest '$ManifestPath' is outside its prepared module cache." - } - 'upstream:{0}' -f $NormalizedManifest.Substring($NormalizedRoot.Length + 1) -} - $Profiles = @( foreach ($RuntimeProfile in @($Matrix.profiles)) { $PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor @@ -205,12 +229,13 @@ $Profiles = @( runtimeExecutable = 'runtime:{0}' -f [System.IO.Path]::GetFileName([string]$PreparedProfile.ExecutablePath) psHome = 'runtime:.' writesPerformed = $false + inventoryFingerprint = [string]$PreparedProfile.InventoryFingerprint moduleVersions = @( - foreach ($Module in @($Inventory.Modules | Sort-Object Name)) { + foreach ($Module in @(Get-DLLPickleOrdinalSequence -InputObject @($Inventory.Modules) -KeySelector { param($Item) [string]$Item.Name } -Unique)) { [ordered]@{ name = [string]$Module.Name version = [string]$Module.Version - manifest = ConvertTo-UpstreamManifestIdentifier -ManifestPath ([string]$Module.ModuleManifestPath) -ModuleCachePath ([string]$Inventory.ModuleCachePath) + manifest = ConvertTo-DLLPickleUpstreamManifestIdentifier -ManifestPath ([string]$Module.ModuleManifestPath) -ModuleCachePath ([string]$Inventory.ModuleCachePath) } } ) @@ -219,11 +244,14 @@ $Profiles = @( } ) $CaptureCompletedAtUtc = [System.DateTimeOffset]::UtcNow +if ($CaptureCompletedAtUtc -ge $ExpiresAtUtc) { + throw "Manual authenticated evidence capture expired at $($ExpiresAtUtc.ToString('o')). Preserve it and start a new work root." +} $Content = [ordered]@{ bridge = [ordered]@{ id = 'initial-powershell-7.4-7.6-multitargeting-major' allowedReleaseVersion = '3.0.0' - expiresAtUtc = $CaptureCompletedAtUtc.AddDays(14).ToString('o') + expiresAtUtc = $ExpiresAtUtc.ToString('o') } bundleSourceFingerprint = [string]$Bundle.fingerprint credentialMode = 'delegated-interactive' @@ -233,13 +261,10 @@ $Content = [ordered]@{ writesPerformed = $false profiles = $Profiles } -$CanonicalContent = $Content | ConvertTo-Json -Depth 100 -Compress -$ContentBytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) -$ContentFingerprint = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($ContentBytes)).Replace('-', '').ToLowerInvariant() $Evidence = [ordered]@{ schemaVersion = 1 evidenceType = 'manual-interactive-transition' - contentFingerprint = $ContentFingerprint + contentFingerprint = $null provenance = [ordered]@{ sourceCommitSha = $SourceCommitSha captureStartedAtUtc = [string]$Session.captureStartedAtUtc @@ -253,6 +278,7 @@ $Evidence = [ordered]@{ } content = $Content } +$Evidence.contentFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence ([pscustomobject]$Evidence) $OutputDirectory = Split-Path -Path $OutputPath -Parent if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { $null = New-Item -Path $OutputDirectory -ItemType Directory -Force diff --git a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 index ef6fbbfb..4cf10c19 100644 --- a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 +++ b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 @@ -19,50 +19,12 @@ param ( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ExpectedProfileKey, [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ExpectedPowerShellVersion, [Parameter(Mandatory)][ValidatePattern('^net\d+\.0$')][string]$ExpectedTargetFramework, + [Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{64}$')][string]$ExpectedInventoryFingerprint, [Parameter()][string]$AzureSubscriptionId = $env:DLLPICKLE_MANUAL_AZURE_SUBSCRIPTION_ID ) $ErrorActionPreference = 'Stop' - -function ConvertTo-CollapsedAssetPath { - param([Parameter(Mandatory)][string]$Path) - - $Segments = [System.Collections.Generic.List[string]]::new() - foreach ($Segment in @($Path -split '/')) { - if ([string]::IsNullOrWhiteSpace($Segment) -or $Segment -eq '.') { continue } - if ($Segment -eq '..') { - if ($Segments.Count -eq 0) { throw "Asset path '$Path' escapes its root." } - $Segments.RemoveAt($Segments.Count - 1) - continue - } - $Segments.Add($Segment) - } - $Segments -join '/' -} - -function ConvertTo-ManualEvidencePath { - param( - [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][string]$ModuleCacheRoot, - [Parameter(Mandatory)][string]$DLLPickleRoot, - [Parameter(Mandatory)][string]$RuntimeRoot - ) - - $NormalizedPath = $Path.Replace('\', '/').TrimEnd('/') - $Roots = [ordered]@{ - upstream = $ModuleCacheRoot.Replace('\', '/').TrimEnd('/') - dllpickle = $DLLPickleRoot.Replace('\', '/').TrimEnd('/') - runtime = $RuntimeRoot.Replace('\', '/').TrimEnd('/') - } - foreach ($RootEntry in $Roots.GetEnumerator()) { - if ($NormalizedPath -eq $RootEntry.Value) { return "$($RootEntry.Key):." } - if ($NormalizedPath.StartsWith("$($RootEntry.Value)/", [System.StringComparison]::OrdinalIgnoreCase)) { - $Relative = $NormalizedPath.Substring($RootEntry.Value.Length + 1) - return '{0}:{1}' -f $RootEntry.Key, (ConvertTo-CollapsedAssetPath -Path $Relative) - } - } - throw "Authenticated evidence path '$Path' is outside the upstream, DLLPickle, and exact runtime roots." -} +. (Join-Path $PSScriptRoot 'DLLPickle.ManualAuthenticatedEvidence.ps1') function Get-SanitizedAssemblySnapshot { $Rows = @(& $SnapshotHelper -PolicyPath $ResolvedPolicyPath) @@ -72,7 +34,7 @@ function Get-SanitizedAssemblySnapshot { name = [string]$Row.Name version = [string]$Row.Version sha256 = ([string]$Row.Sha256).ToLowerInvariant() - selectedAsset = ConvertTo-ManualEvidencePath -Path ([string]$Row.Path) -ModuleCacheRoot $ModuleCacheRoot -DLLPickleRoot $DLLPickleRoot -RuntimeRoot $PSHOME + selectedAsset = ConvertTo-DLLPickleManualEvidencePath -Path ([string]$Row.Path) -ModuleCacheRoot $ModuleCacheRoot -DLLPickleRoot $DLLPickleRoot -RuntimeRoot $PSHOME assemblyLoadContext = [string]$Row.Alc isCollectible = [bool]$Row.IsCollectible } @@ -103,19 +65,24 @@ function Connect-Provider { switch ($Provider) { 'graph' { - Connect-MgGraph -Scopes 'User.Read' -ContextScope Process -NoWelcome -ErrorAction Stop | Out-Null + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Connect-MgGraph' -Module 'Microsoft.Graph.Authentication' + & $Command -Scopes 'User.Read' -ContextScope Process -NoWelcome -ErrorAction Stop | Out-Null } 'exo' { - Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop | Out-Null + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Connect-ExchangeOnline' -Module 'ExchangeOnlineManagement' + & $Command -ShowBanner:$false -ErrorAction Stop | Out-Null } 'az' { - Connect-AzAccount -Scope Process -ErrorAction Stop | Out-Null + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Connect-AzAccount' -Module 'Az.Accounts' + & $Command -Scope Process -ErrorAction Stop | Out-Null if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { - Set-AzContext -SubscriptionId $SubscriptionId -Scope Process -ErrorAction Stop | Out-Null + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Set-AzContext' -Module 'Az.Accounts' + & $Command -SubscriptionId $SubscriptionId -Scope Process -ErrorAction Stop | Out-Null } } 'teams' { - Connect-MicrosoftTeams -ErrorAction Stop | Out-Null + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Connect-MicrosoftTeams' -Module 'MicrosoftTeams' + & $Command -ErrorAction Stop | Out-Null } } } @@ -125,65 +92,26 @@ function Disconnect-Provider { try { switch ($Provider) { - 'graph' { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } - 'exo' { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } - 'az' { Clear-AzContext -Scope Process -Force -ErrorAction SilentlyContinue | Out-Null } - 'teams' { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue | Out-Null } - } - } catch { - # Cleanup failures must not replace the sanitized scenario result. - Write-Verbose "Provider cleanup for '$Provider' did not complete." - } -} - -function Invoke-ReadProbe { - param([Parameter(Mandatory)][string]$ProbeId) - - $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - try { - switch ($ProbeId) { - 'graph-context' { - if (-not (Get-MgContext -ErrorAction Stop)) { throw 'Graph context was not established.' } + 'graph' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Disconnect-MgGraph' -Module 'Microsoft.Graph.Authentication' + & $Command -ErrorAction SilentlyContinue | Out-Null } - 'graph-me-read' { - Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me?$select=id' -ErrorAction Stop | Out-Null + 'exo' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Disconnect-ExchangeOnline' -Module 'ExchangeOnlineManagement' + & $Command -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } - 'exo-mailbox-read' { - if (@(Get-EXOMailbox -ResultSize 1 -ErrorAction Stop).Count -eq 0) { throw 'Exchange mailbox read returned no object.' } + 'az' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Clear-AzContext' -Module 'Az.Accounts' + & $Command -Scope Process -Force -ErrorAction SilentlyContinue | Out-Null } - 'az-context' { - if (-not (Get-AzContext -ErrorAction Stop)) { throw 'Azure context was not established.' } + 'teams' { + $Command = Get-DLLPickleAuthenticatedCommand -Name 'Disconnect-MicrosoftTeams' -Module 'MicrosoftTeams' + & $Command -ErrorAction SilentlyContinue | Out-Null } - 'az-resource-read' { - if (@(Get-AzResource -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure resource read returned no object.' } - } - 'az-storage-account-read' { - if (@(Get-AzStorageAccount -ErrorAction Stop | Select-Object -First 1).Count -eq 0) { throw 'Azure storage-account read returned no object.' } - } - 'teams-tenant-read' { - if (-not (Get-CsTenant -ErrorAction Stop)) { throw 'Teams tenant read returned no object.' } - } - default { throw "Unsupported authenticated probe identifier '$ProbeId'." } - } - $Stopwatch.Stop() - [ordered]@{ - probeId = $ProbeId - executed = $true - status = 'passed' - durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds - writesPerformed = $false - errorType = $null } } catch { - $Stopwatch.Stop() - [ordered]@{ - probeId = $ProbeId - executed = $true - status = 'failed' - durationMilliseconds = [long]$Stopwatch.ElapsedMilliseconds - writesPerformed = $false - errorType = $_.Exception.GetType().FullName - } + # Cleanup failures must not replace the sanitized scenario result. + Write-Verbose "Provider cleanup for '$Provider' did not complete." } } @@ -196,6 +124,10 @@ $ResolvedDLLPickleManifestPath = (Resolve-Path -LiteralPath $DLLPickleManifestPa $DLLPickleRoot = Split-Path -Path $ResolvedDLLPickleManifestPath -Parent $SnapshotHelper = Join-Path $PSScriptRoot 'Get-DLLPickleLoadedTrackedAssembly.ps1' $Inventory = Get-Content -LiteralPath $ResolvedInventoryPath -Raw | ConvertFrom-Json -ErrorAction Stop +$InventoryFingerprint = [string]$Inventory.InventoryFingerprint +if ($InventoryFingerprint -ne $ExpectedInventoryFingerprint) { + throw "Prepared module inventory fingerprint '$InventoryFingerprint' does not match expected '$ExpectedInventoryFingerprint'." +} $ModuleCacheRoot = [string]$Inventory.ModuleCachePath $ActualTargetFramework = 'net{0}.0' -f [Environment]::Version.Major $ActualProfileKey = 'ps{0}.{1}-{2}-windows-x64' -f $PSVersionTable.PSVersion.Major, $PSVersionTable.PSVersion.Minor, $ActualTargetFramework @@ -240,18 +172,21 @@ $Result = [ordered]@{ targetFramework = $ActualTargetFramework platform = 'windows' architecture = 'x64' + inventoryFingerprint = $InventoryFingerprint importOrder = @($Definition.modules) dllPickleTiming = [string]$Definition.timing expectedTokenAudiences = @( - foreach ($Provider in @($Definition.providers)) { - switch ($Provider) { - 'graph' { 'https://graph.microsoft.com' } - 'exo' { 'https://outlook.office365.com' } - 'az' { 'https://management.azure.com' } - 'teams' { 'https://api.spaces.skype.com' } + Get-DLLPickleOrdinalSequence -InputObject @( + foreach ($Provider in @($Definition.providers)) { + switch ($Provider) { + 'graph' { 'https://graph.microsoft.com' } + 'exo' { 'https://outlook.office365.com' } + 'az' { 'https://management.azure.com' } + 'teams' { 'https://api.spaces.skype.com' } + } } - } - ) | Sort-Object -Unique + ) -Unique + ) status = 'failed' writesPerformed = $false probes = @() @@ -262,15 +197,15 @@ $ConnectedProviders = [System.Collections.Generic.List[string]]::new() try { if ($Definition.timing -eq 'dllpickle-first') { Import-DLLPickleBundle } Import-ExactModuleSet -Names @($Definition.modules) + if ($Definition.timing -eq 'module-first') { Import-DLLPickleBundle } $Result.snapshots += [ordered]@{ stage = 'before-authentication'; assemblies = @(Get-SanitizedAssemblySnapshot) } foreach ($Provider in @($Definition.providers)) { Connect-Provider -Provider $Provider -SubscriptionId $AzureSubscriptionId $ConnectedProviders.Add($Provider) } $Result.snapshots += [ordered]@{ stage = 'after-connection'; assemblies = @(Get-SanitizedAssemblySnapshot) } - if ($Definition.timing -eq 'module-first') { Import-DLLPickleBundle } $Result.probes = @( - foreach ($ProbeId in @($Definition.probes)) { Invoke-ReadProbe -ProbeId $ProbeId } + foreach ($ProbeId in @($Definition.probes)) { Invoke-DLLPickleAuthenticatedReadProbe -ProbeId $ProbeId } ) $Result.snapshots += [ordered]@{ stage = 'after-read-probe'; assemblies = @(Get-SanitizedAssemblySnapshot) } if (@($Result.probes | Where-Object status -ne 'passed').Count -eq 0) { diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 index 802c3148..2bdf920f 100644 --- a/tools/New-DLLPickleSupportDocumentation.ps1 +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -47,22 +47,6 @@ $TestMatrix = Get-Content -LiteralPath $TestMatrixPath -Raw | ConvertFrom-Json - $DependencyPolicy = Get-Content -LiteralPath $DependencyPolicyPath -Raw | ConvertFrom-Json -ErrorAction Stop $DependencyPolicyDirectory = Split-Path -Path (Resolve-Path -LiteralPath $DependencyPolicyPath).Path -Parent -function ConvertTo-DLLPickleUtcDateTimeOffset { - param([Parameter(Mandatory)][object]$Value) - - if ($Value -is [System.DateTimeOffset]) { - return $Value.ToUniversalTime() - } - if ($Value -is [System.DateTime]) { - return ([System.DateTimeOffset]$Value).ToUniversalTime() - } - [System.DateTimeOffset]::Parse( - [string]$Value, - [System.Globalization.CultureInfo]::InvariantCulture, - [System.Globalization.DateTimeStyles]::AssumeUniversal - ).ToUniversalTime() -} - $ShippedProfileKeys = @($SupportPolicy.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' -f $_.powerShellMajor, $_.powerShellMinor, $_.dotnetMajor, $_.targetFramework }) $TestProfileKeys = @($TestMatrix.profiles | ForEach-Object { '{0}.{1}|{2}|{3}' -f $_.powerShellMajor, $_.powerShellMinor, $_.dotnetMajor, $_.targetFramework }) if (Compare-Object -ReferenceObject $ShippedProfileKeys -DifferenceObject $TestProfileKeys) { diff --git a/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 index c7e90ccd..a9a23b08 100644 --- a/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 +++ b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 @@ -60,6 +60,7 @@ param ( ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DLLPickle.ProfileEvidence.ps1') function Assert-ExactStringSet { param( @@ -68,7 +69,9 @@ function Assert-ExactStringSet { [Parameter(Mandatory)][string]$Label ) - $Difference = @(Compare-Object -ReferenceObject @($Expected | Sort-Object) -DifferenceObject @($Actual | Sort-Object)) + $ExpectedSorted = @(Get-DLLPickleOrdinalSequence -InputObject @($Expected)) + $ActualSorted = @(Get-DLLPickleOrdinalSequence -InputObject @($Actual)) + $Difference = @(Compare-Object -ReferenceObject $ExpectedSorted -DifferenceObject $ActualSorted) if ($Difference.Count -gt 0 -or $Actual.Count -ne $Expected.Count) { throw "$Label does not match the required set. Expected '$($Expected -join ', ')'; actual '$($Actual -join ', ')'." } @@ -84,30 +87,6 @@ function Assert-ExactPropertySet { Assert-ExactStringSet -Actual @($InputObject.PSObject.Properties.Name) -Expected $Expected -Label "$Label properties" } -function Get-ContentFingerprint { - param([Parameter(Mandatory)][object]$Content) - - $CanonicalContent = $Content | ConvertTo-Json -Depth 100 -Compress - $Bytes = [System.Text.Encoding]::UTF8.GetBytes($CanonicalContent) - [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($Bytes)).Replace('-', '').ToLowerInvariant() -} - -function ConvertTo-UtcDateTimeOffset { - param([Parameter(Mandatory)][object]$Value) - - if ($Value -is [System.DateTimeOffset]) { - return $Value.ToUniversalTime() - } - if ($Value -is [System.DateTime]) { - return ([System.DateTimeOffset]$Value).ToUniversalTime() - } - [System.DateTimeOffset]::Parse( - [string]$Value, - [System.Globalization.CultureInfo]::InvariantCulture, - [System.Globalization.DateTimeStyles]::AssumeUniversal - ).ToUniversalTime() -} - foreach ($RequiredPath in @($EvidencePath, $TestMatrixPath, $DependencyPolicyPath)) { if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { throw "Required authenticated-evidence input was not found: $RequiredPath" @@ -128,7 +107,7 @@ if ([int]$Evidence.schemaVersion -ne 1 -or -not $Evidence.content) { throw 'Manual authenticated evidence has an unsupported schema, type, or missing content.' } -$RecomputedContentFingerprint = Get-ContentFingerprint -Content $Evidence.content +$RecomputedContentFingerprint = Get-DLLPickleNormalizedEvidenceFingerprint -Evidence $Evidence if ([string]$Evidence.contentFingerprint -ne $RecomputedContentFingerprint) { throw "Manual authenticated evidence does not recompute to '$($Evidence.contentFingerprint)'." } @@ -138,16 +117,19 @@ if ([string]$Bridge.id -ne 'initial-powershell-7.4-7.6-multitargeting-major' -or [string]$Bridge.allowedReleaseVersion -ne '3.0.0') { throw 'Manual authenticated evidence is not scoped exclusively to the initial 3.0.0 multi-target release.' } -$CaptureStartedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.provenance.captureStartedAtUtc -$CapturedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.provenance.captureCompletedAtUtc -$ExpiresAtUtc = ConvertTo-UtcDateTimeOffset -Value $Bridge.expiresAtUtc +$CaptureStartedAtUtc = ConvertTo-DLLPickleUtcDateTimeOffset -Value $Evidence.provenance.captureStartedAtUtc +$CapturedAtUtc = ConvertTo-DLLPickleUtcDateTimeOffset -Value $Evidence.provenance.captureCompletedAtUtc +$ExpiresAtUtc = ConvertTo-DLLPickleUtcDateTimeOffset -Value $Bridge.expiresAtUtc if ([string]$Evidence.provenance.sourceCommitSha -notmatch '^[a-f0-9]{40}$' -or $CaptureStartedAtUtc -gt $CapturedAtUtc -or $CapturedAtUtc -gt $NowUtc.ToUniversalTime()) { throw 'Manual authenticated-evidence provenance has an invalid commit or capture window.' } -if ($ExpiresAtUtc -le $CapturedAtUtc -or $ExpiresAtUtc -gt $CapturedAtUtc.AddDays(30)) { - throw 'The manual authenticated-evidence bridge must expire after capture and within 30 days.' +if ($ExpiresAtUtc -ne $CaptureStartedAtUtc.AddDays(14) -or $CapturedAtUtc -ge $ExpiresAtUtc) { + throw 'The manual authenticated-evidence bridge must expire exactly 14 days after capture starts, after capture completes.' +} +if ($NowUtc.ToUniversalTime() -ge $ExpiresAtUtc) { + throw "Manual authenticated evidence expired at $($ExpiresAtUtc.ToString('o'))." } if ($Mode -eq 'Release') { if ([string]$Evidence.acceptance.status -ne 'accepted' -or @@ -155,15 +137,12 @@ if ($Mode -eq 'Release') { [string]$Evidence.acceptance.confidence -notin @('low', 'medium', 'high')) { throw 'Manual authenticated evidence has not been explicitly accepted by a maintainer with a confidence level.' } - $AcceptedAtUtc = ConvertTo-UtcDateTimeOffset -Value $Evidence.acceptance.acceptedAtUtc + $AcceptedAtUtc = ConvertTo-DLLPickleUtcDateTimeOffset -Value $Evidence.acceptance.acceptedAtUtc if ($AcceptedAtUtc -lt $CapturedAtUtc -or $AcceptedAtUtc -ge $ExpiresAtUtc -or $AcceptedAtUtc -gt $NowUtc.ToUniversalTime()) { throw "Manual authenticated evidence acceptance '$($AcceptedAtUtc.ToString('o'))' is outside its capture '$($CapturedAtUtc.ToString('o'))' to expiry '$($ExpiresAtUtc.ToString('o'))' window." } - if ($NowUtc.ToUniversalTime() -ge $ExpiresAtUtc) { - throw "Manual authenticated evidence expired at $($ExpiresAtUtc.ToString('o'))." - } } if ([string]$Evidence.content.credentialMode -ne 'delegated-interactive' -or @@ -193,7 +172,7 @@ $ExpectedProfiles = @( ) Assert-ExactStringSet -Actual @($Evidence.content.profiles.profileKey) -Expected @($ExpectedProfiles.ProfileKey) -Label 'Authenticated profile coverage' -$ExpectedModuleNames = @($Policy.monitoredModules.name | Sort-Object) +$ExpectedModuleNames = @(Get-DLLPickleOrdinalSequence -InputObject @($Policy.monitoredModules.name)) $ExpectedScenarioIds = @( 'graph-module-only', 'graph-dllpickle-first', 'graph-module-first', 'exo-module-only', 'exo-dllpickle-first', 'exo-module-first', @@ -228,7 +207,7 @@ foreach ($ExpectedProfile in $ExpectedProfiles) { throw "Expected exactly one authenticated profile '$($ExpectedProfile.ProfileKey)'." } $EvidenceProfile = $ProfileRows[0] - Assert-ExactPropertySet -InputObject $EvidenceProfile -Expected @('profileKey', 'powerShellVersion', 'powerShellLine', 'dotNetVersion', 'dotNetMajor', 'targetFramework', 'platform', 'architecture', 'runtimeExecutable', 'psHome', 'writesPerformed', 'moduleVersions', 'scenarios') -Label "Authenticated profile '$($ExpectedProfile.ProfileKey)'" + Assert-ExactPropertySet -InputObject $EvidenceProfile -Expected @('profileKey', 'powerShellVersion', 'powerShellLine', 'dotNetVersion', 'dotNetMajor', 'targetFramework', 'platform', 'architecture', 'runtimeExecutable', 'psHome', 'writesPerformed', 'inventoryFingerprint', 'moduleVersions', 'scenarios') -Label "Authenticated profile '$($ExpectedProfile.ProfileKey)'" if ([string]$EvidenceProfile.powerShellVersion -ne $ExpectedProfile.PowerShellVersion -or [string]$EvidenceProfile.powerShellLine -ne $ExpectedProfile.PowerShellLine -or [int]$EvidenceProfile.dotNetMajor -ne $ExpectedProfile.DotNetMajor -or @@ -241,6 +220,9 @@ foreach ($ExpectedProfile in $ExpectedProfiles) { if ([string]$EvidenceProfile.runtimeExecutable -notmatch '^runtime:' -or [string]$EvidenceProfile.psHome -notmatch '^runtime:') { throw "Authenticated profile '$($ExpectedProfile.ProfileKey)' contains an unnormalized runtime path." } + if ([string]$EvidenceProfile.inventoryFingerprint -notmatch '^[a-f0-9]{64}$') { + throw "Authenticated profile '$($ExpectedProfile.ProfileKey)' has no valid prepared module-inventory fingerprint." + } Assert-ExactStringSet -Actual @($EvidenceProfile.moduleVersions.name) -Expected $ExpectedModuleNames -Label "Module versions for '$($ExpectedProfile.ProfileKey)'" foreach ($ModuleVersion in @($EvidenceProfile.moduleVersions)) { Assert-ExactPropertySet -InputObject $ModuleVersion -Expected @('name', 'version', 'manifest') -Label "Module-version row for '$($ExpectedProfile.ProfileKey)'" @@ -258,12 +240,13 @@ foreach ($ExpectedProfile in $ExpectedProfiles) { throw "No unique dependency policy exists for '$($ExpectedProfile.ProfileKey)'." } foreach ($Scenario in @($EvidenceProfile.scenarios)) { - Assert-ExactPropertySet -InputObject $Scenario -Expected @('scenarioId', 'profileKey', 'powerShellVersion', 'targetFramework', 'platform', 'architecture', 'importOrder', 'dllPickleTiming', 'expectedTokenAudiences', 'status', 'writesPerformed', 'probes', 'snapshots', 'errorType') -Label "Authenticated scenario '$($Scenario.scenarioId)'" + Assert-ExactPropertySet -InputObject $Scenario -Expected @('scenarioId', 'profileKey', 'powerShellVersion', 'targetFramework', 'platform', 'architecture', 'inventoryFingerprint', 'importOrder', 'dllPickleTiming', 'expectedTokenAudiences', 'status', 'writesPerformed', 'probes', 'snapshots', 'errorType') -Label "Authenticated scenario '$($Scenario.scenarioId)'" if ([string]$Scenario.profileKey -ne $ExpectedProfile.ProfileKey -or [string]$Scenario.powerShellVersion -ne $ExpectedProfile.PowerShellVersion -or [string]$Scenario.targetFramework -ne $ExpectedProfile.TargetFramework -or [string]$Scenario.platform -ne 'windows' -or - [string]$Scenario.architecture -ne 'x64') { + [string]$Scenario.architecture -ne 'x64' -or + [string]$Scenario.inventoryFingerprint -ne [string]$EvidenceProfile.inventoryFingerprint) { throw "Authenticated scenario '$($Scenario.scenarioId)' is not bound to exact profile '$($ExpectedProfile.ProfileKey)'." } if ([string]$Scenario.status -ne 'passed' -or $Scenario.writesPerformed -ne $false -or @@ -311,7 +294,7 @@ foreach ($ExpectedProfile in $ExpectedProfiles) { } } - $CrossScenarios = @($EvidenceProfile.scenarios | Where-Object scenarioId -like 'cross-*' | Sort-Object scenarioId) + $CrossScenarios = @(Get-DLLPickleOrdinalSequence -InputObject @($EvidenceProfile.scenarios | Where-Object scenarioId -like 'cross-*') -KeySelector { param($Scenario) [string]$Scenario.scenarioId }) for ($OrderIndex = 0; $OrderIndex -lt 2; $OrderIndex++) { $ExpectedOrder = @($ProfilePolicy[0].importOrders[$OrderIndex]) if ((@($CrossScenarios[$OrderIndex].importOrder) -join '|') -ne ($ExpectedOrder -join '|')) { From 6dba82a1896d8a39b9a068fcfa3394e4bda54076 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:40:55 -0400 Subject: [PATCH 37/43] fix: align authenticated evidence expiry --- docs/generated/Compatibility-Evidence.md | 2 +- tools/New-DLLPickleSupportDocumentation.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/generated/Compatibility-Evidence.md b/docs/generated/Compatibility-Evidence.md index cdb18b15..219ce0a9 100644 --- a/docs/generated/Compatibility-Evidence.md +++ b/docs/generated/Compatibility-Evidence.md @@ -85,4 +85,4 @@ The deterministic no-auth tier runs in CI. The following credential-dependent co These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it. -Before the protected credentialed workflow exists, the release gate may accept one explicitly reviewed manual transition record for version `3.0.0`. That record must match the exact bundle-source fingerprint, cover the three exact Windows profiles and fixed read-only scenarios, contain no credential material or raw service output, record zero writes, and expire within 30 days. It is transitional compatibility evidence, not least-privilege workload-identity proof. +Before the protected credentialed workflow exists, the release gate may accept one explicitly reviewed manual transition record for version `3.0.0`. That record must match the exact bundle-source fingerprint, cover the three exact Windows profiles and fixed read-only scenarios, contain no credential material or raw service output, record zero writes, and expire exactly 14 days after capture starts. It is transitional compatibility evidence, not least-privilege workload-identity proof. diff --git a/tools/New-DLLPickleSupportDocumentation.ps1 b/tools/New-DLLPickleSupportDocumentation.ps1 index 2bdf920f..60ddb87d 100644 --- a/tools/New-DLLPickleSupportDocumentation.ps1 +++ b/tools/New-DLLPickleSupportDocumentation.ps1 @@ -177,7 +177,7 @@ foreach ($Module in @($DependencyPolicy.monitoredModules)) { $EvidenceLines.Add('') $EvidenceLines.Add('These probes permit reads only; writes are not part of the validation tier. PowerShellEditorServices / VS Code coverage for issue #169 also remains an explicit manual gap unless a run artifact records it.') $EvidenceLines.Add('') -$EvidenceLines.Add('Before the protected credentialed workflow exists, the release gate may accept one explicitly reviewed manual transition record for version `3.0.0`. That record must match the exact bundle-source fingerprint, cover the three exact Windows profiles and fixed read-only scenarios, contain no credential material or raw service output, record zero writes, and expire within 30 days. It is transitional compatibility evidence, not least-privilege workload-identity proof.') +$EvidenceLines.Add('Before the protected credentialed workflow exists, the release gate may accept one explicitly reviewed manual transition record for version `3.0.0`. That record must match the exact bundle-source fingerprint, cover the three exact Windows profiles and fixed read-only scenarios, contain no credential material or raw service output, record zero writes, and expire exactly 14 days after capture starts. It is transitional compatibility evidence, not least-privilege workload-identity proof.') $Documents = [ordered]@{ 'Support-Matrix.md' = ($SupportLines -join $NewLine) + $NewLine From 9eb52966db50e752fe1aad107f0ed5c7001a72aa Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:57:32 -0400 Subject: [PATCH 38/43] fix: harden dependency runtime provenance --- tests/Unit/DependencyAutomation.Tests.ps1 | 53 +++++++++++++++++++++++ tests/Unit/RuntimeProvisioning.Tests.ps1 | 2 + tools/Install-DLLPickleTestPowerShell.ps1 | 16 +++---- tools/Update-DLLPickleDependencyPins.ps1 | 46 +++++++++++++++++++- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index a5faf124..dc8559ee 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -371,6 +371,59 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $CapturedDotnetCalls[0] | Should -Match 'restore.*floating\.csproj.*--force-evaluate' } + It 'routes a floating package major transition to maintainer review without changing the project' { + $ProjectPath = Join-Path $TestDrive 'floating-major.csproj' + @' + + + net8.0 + + + + + +'@ | Set-Content -LiteralPath $ProjectPath -Encoding UTF8 + + $PolicyPath = Join-Path $TestDrive 'floating-major-policy.json' + @{ + preload = @( + @{ + packageName = 'Contoso.Library' + assemblyName = 'Contoso.Library' + targetFrameworks = @('net8.0') + versionPolicy = 'minorPatchFloat' + sourceModules = @('Contoso.Module') + reason = 'Synthetic floating-major review test.' + } + ) + blockedPreloadAssemblies = @() + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $PolicyPath -Encoding UTF8 + + $InventoryPath = Join-Path $TestDrive 'floating-major-inventory.json' + @{ + Modules = @( + @{ + Name = 'Contoso.Module' + Version = '3.0.0' + TrackedAssemblies = @( + @{ Name = 'Contoso.Library'; Version = '5.1.0.0'; RelativePath = 'Contoso.Library.dll' } + ) + } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $InventoryPath -Encoding UTF8 + + $Report = & $script:UpdateScriptPath -InventoryPath $InventoryPath -PolicyPath $PolicyPath -ProjectPath $ProjectPath -OutputPath (Join-Path $TestDrive 'floating-major-report.json') -Confirm:$false + + $Report.ProjectChanged | Should -BeFalse + $Report.RestoreRequired | Should -BeFalse + $Report.ReviewRequired | Should -BeTrue + $Report.Changes[0].MajorTransitionRequired | Should -BeTrue + $Report.Changes[0].CandidateVersion | Should -Be '5.*' + $Report.Changes[0].Applied | Should -BeFalse + $Report.Warnings | Should -Contain "PackageReference 'Contoso.Library' floating candidate crosses a package major (net8.0: 4.* -> 5.*); maintainer review is required and no automatic update was applied." + Get-Content -LiteralPath $ProjectPath -Raw | Should -Match 'Version="4\.\*"' + } + It 'resolves and flags package pins in framework-conditioned ItemGroups' { $ProjectPath = Join-Path -Path $TestDrive -ChildPath 'conditional.csproj' @' diff --git a/tests/Unit/RuntimeProvisioning.Tests.ps1 b/tests/Unit/RuntimeProvisioning.Tests.ps1 index 1afef60f..a37269fe 100644 --- a/tests/Unit/RuntimeProvisioning.Tests.ps1 +++ b/tests/Unit/RuntimeProvisioning.Tests.ps1 @@ -128,6 +128,8 @@ Describe 'Exact PowerShell runtime provisioning' -Tag 'Unit' { $source | Should -Match ([regex]::Escape("Join-Path 'multi' $ExactVersion")) $source | Should -Match ([regex]::Escape('Test-PathWithinRoot')) + $source | Should -Not -Match '(?ms)if \(-not \(Test-Path -LiteralPath \$ExpectedExecutable -PathType Leaf\)\) \{\s+Get-VerifiedDownload -Uri \$Archive\[0\]\.downloadUrl' + $source | Should -Match '(?ms)Revalidate the immutable archive.*Get-VerifiedDownload -Uri \$Archive\[0\]\.downloadUrl.*Expand-TestRuntimeArchive -ArchivePath \$CachePath' @($matrix.archiveAssets) | Should -HaveCount 9 @($matrix.provisioning.optionalProvider.assets) | Should -HaveCount 3 @($matrix.archiveAssets.sha256 + $matrix.provisioning.optionalProvider.assets.sha256 | Where-Object { $_ -notmatch '^[a-f0-9]{64}$' }) | Should -BeNullOrEmpty diff --git a/tools/Install-DLLPickleTestPowerShell.ps1 b/tools/Install-DLLPickleTestPowerShell.ps1 index a0ddd4ab..10042ff8 100644 --- a/tools/Install-DLLPickleTestPowerShell.ps1 +++ b/tools/Install-DLLPickleTestPowerShell.ps1 @@ -299,14 +299,14 @@ if ($PSCmdlet.ParameterSetName -eq 'Executable') { $ExpectedPayloadRoot = Join-Path -Path $ProviderRoot -ChildPath 'payload' $ExpectedExecutable = Join-Path -Path $ExpectedPayloadRoot -ChildPath $ExecutableName - if (-not (Test-Path -LiteralPath $ExpectedExecutable -PathType Leaf)) { - Get-VerifiedDownload -Uri $Archive[0].downloadUrl -DestinationPath $CachePath -Sha256 $Archive[0].sha256 - Expand-TestRuntimeArchive -ArchivePath $CachePath -DestinationPath $ExpectedPayloadRoot - if ($Platform -ne 'windows') { - $ChmodOutput = @(& chmod u+x $ExpectedExecutable 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Failed to mark the stock PowerShell executable as executable: $($ChmodOutput -join [Environment]::NewLine)" - } + # Revalidate the immutable archive and recreate the payload on every use. + # Runtime identity alone cannot detect a corrupted or modified cached file. + Get-VerifiedDownload -Uri $Archive[0].downloadUrl -DestinationPath $CachePath -Sha256 $Archive[0].sha256 + Expand-TestRuntimeArchive -ArchivePath $CachePath -DestinationPath $ExpectedPayloadRoot + if ($Platform -ne 'windows') { + $ChmodOutput = @(& chmod u+x $ExpectedExecutable 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Failed to mark the stock PowerShell executable as executable: $($ChmodOutput -join [Environment]::NewLine)" } } } else { diff --git a/tools/Update-DLLPickleDependencyPins.ps1 b/tools/Update-DLLPickleDependencyPins.ps1 index 5cbb09d2..766b1d85 100644 --- a/tools/Update-DLLPickleDependencyPins.ps1 +++ b/tools/Update-DLLPickleDependencyPins.ps1 @@ -184,6 +184,21 @@ function ConvertTo-DLLPickleUpdatedPackageReferenceContent { $UpdatedContent } +function Get-DLLPicklePackageReferenceMajor { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $Match = [regex]::Match($Version.Trim(), '^\[?(?\d+)(?:\.(?:\d+|\*)){1,3}\]?$') + if (-not $Match.Success) { + throw "PackageReference version '$Version' is not a supported exact or major-floating version." + } + + return [int]$Match.Groups['major'].Value +} + function Get-DLLPicklePinTargetFramework { [CmdletBinding()] param( @@ -352,6 +367,24 @@ foreach ($Pin in @($Policy.preload)) { continue } + $MajorTransitions = @( + if ($VersionPolicy -eq 'minorPatchFloat' -and -not $IsCapped) { + foreach ($CurrentReference in $CurrentReferences) { + $TfmCandidate = @($TfmCandidates | Where-Object TargetFramework -EQ $CurrentReference.TargetFramework)[0] + $CurrentMajor = Get-DLLPicklePackageReferenceMajor -Version ([string]$CurrentReference.Reference.Version) + $CandidateMajor = ([version][string]$TfmCandidate.PackageVersion).Major + if ($CurrentMajor -ne $CandidateMajor) { + [PSCustomObject]@{ + TargetFramework = [string]$CurrentReference.TargetFramework + CurrentVersion = [string]$CurrentReference.Reference.Version + CandidateVersion = '{0}.*' -f $CandidateMajor + } + } + } + } + ) + $MajorTransitionRequired = $MajorTransitions.Count -gt 0 + $UniqueReferenceIndices = @($CurrentReferences.Reference.Index | Select-Object -Unique) $UsesConditionalReferences = $UniqueReferenceIndices.Count -gt 1 $DistinctCandidateVersions = @($TfmCandidates.FormattedVersion | Sort-Object -Unique) @@ -385,8 +418,9 @@ foreach ($Pin in @($Policy.preload)) { SourceAssemblyVersion = [string]$TargetAssembly.AssemblyVersion UsesConditionalReferences = $UsesConditionalReferences ConditionalPinRequired = $ConditionalPinRequired + MajorTransitionRequired = $MajorTransitionRequired CrossPlatformConsistent = $CrossPlatformConsistent - ReviewRequired = $UsesConditionalReferences -or $ConditionalPinRequired -or -not $CrossPlatformConsistent + ReviewRequired = $UsesConditionalReferences -or $ConditionalPinRequired -or $MajorTransitionRequired -or -not $CrossPlatformConsistent TfmResults = @($TfmResults) RestoreRequired = $false Applied = $false @@ -402,6 +436,16 @@ foreach ($Pin in @($Policy.preload)) { $Changes.Add($Change) continue } + if ($MajorTransitionRequired) { + $TransitionSummary = @( + $MajorTransitions | ForEach-Object { + '{0}: {1} -> {2}' -f $_.TargetFramework, $_.CurrentVersion, $_.CandidateVersion + } + ) -join '; ' + $Warnings.Add(("PackageReference '{0}' floating candidate crosses a package major ({1}); maintainer review is required and no automatic update was applied." -f $Pin.packageName, $TransitionSummary)) + $Changes.Add($Change) + continue + } if ($VersionPolicy -eq 'minorPatchFloat' -and -not $IsCapped) { $RestoreRequired = $true $Change.RestoreRequired = $true From 611364fd167369308ed3d05188451d2e7ac4d41c Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:57:51 -0400 Subject: [PATCH 39/43] fix: enforce exact authenticated evidence --- tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 | 12 ++++++++++++ tests/Unit/ManualAuthenticatedHarness.Tests.ps1 | 7 +++++++ .../Invoke-DLLPickleManualAuthenticatedScenario.ps1 | 6 +++++- tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 | 2 ++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 index 1ca0fe10..9cafdd03 100644 --- a/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 +++ b/tests/Unit/ManualAuthenticatedEvidence.Tests.ps1 @@ -213,6 +213,18 @@ Describe 'Time-bounded manual authenticated evidence' -Tag 'Unit' { Should -Throw '*bound to bundle*current bundle*' } + It 'rejects a different .NET servicing patch even when the major matches' { + $EvidencePath = Join-Path $TestDrive 'dotnet-patch-mismatch.json' + $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath + $CurrentRuntimeVersion = [version]$Evidence.content.profiles[0].dotNetVersion + $Evidence.content.profiles[0].dotNetVersion = '{0}.{1}.{2}' -f $CurrentRuntimeVersion.Major, $CurrentRuntimeVersion.Minor, ($CurrentRuntimeVersion.Build + 1) + $Evidence.contentFingerprint = Get-ManualEvidenceContentFingerprint -Evidence $Evidence + $Evidence | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + + { & $script:ToolPath -EvidencePath $EvidencePath -RepositoryRoot $script:RepositoryRoot -TestMatrixPath $script:TestMatrixPath -DependencyPolicyPath $script:DependencyPolicyPath -NowUtc '2026-08-10T00:00:00Z' } | + Should -Throw '*does not match the exact zero-write Windows runtime contract*' + } + It 'rejects missing authenticated read coverage' { $EvidencePath = Join-Path $TestDrive 'missing-probe.json' $Evidence = Get-ManualAuthenticatedEvidenceFixture -Path $EvidencePath diff --git a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 index c72f9081..c033fdd5 100644 --- a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 +++ b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 @@ -53,6 +53,13 @@ Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { $ModuleFirstImport | Should -BeLessThan $ConnectLoop } + It 'fails authenticated scenarios when DLLPickle reports a failed preload row' { + $script:ScenarioHarness | Should -Match ([regex]::Escape('$ImportResults = @(Import-DPLibrary -SuppressLogo -ErrorAction Stop)')) + $script:ScenarioHarness | Should -Match ([regex]::Escape("Where-Object { [string]`$_.Status -eq 'Failed' }")) + $script:ScenarioHarness | Should -Match ([regex]::Escape('DLLPickle preload reported')) + $script:ScenarioHarness | Should -Not -Match ([regex]::Escape('Import-DPLibrary -SuppressLogo -ErrorAction Stop | Out-Null')) + } + It 'prepares exact pinned runtimes and refreshes latest compatible modules without authenticating' { $script:Initializer | Should -Match ([regex]::Escape("Provider = 'DirectArchive'")) $script:Initializer | Should -Match ([regex]::Escape("Platform = 'windows'")) diff --git a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 index 4cf10c19..c1a8549b 100644 --- a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 +++ b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 @@ -54,7 +54,11 @@ function Import-ExactModuleSet { function Import-DLLPickleBundle { Import-Module -Name $ResolvedDLLPickleManifestPath -Force -ErrorAction Stop - Import-DPLibrary -SuppressLogo -ErrorAction Stop | Out-Null + $ImportResults = @(Import-DPLibrary -SuppressLogo -ErrorAction Stop) + $FailedImports = @($ImportResults | Where-Object { [string]$_.Status -eq 'Failed' }) + if ($FailedImports.Count -gt 0) { + throw "DLLPickle preload reported $($FailedImports.Count) failed assembly load(s)." + } } function Connect-Provider { diff --git a/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 index a9a23b08..685efd9d 100644 --- a/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 +++ b/tools/Test-DLLPickleManualAuthenticatedEvidence.ps1 @@ -165,6 +165,7 @@ $ExpectedProfiles = @( ProfileKey = 'ps{0}.{1}-{2}-windows-x64' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor, $RuntimeProfile.targetFramework PowerShellVersion = [string]$RuntimeProfile.powerShellVersion PowerShellLine = '{0}.{1}' -f $RuntimeProfile.powerShellMajor, $RuntimeProfile.powerShellMinor + DotNetVersion = [string]$RuntimeProfile.dotnetRuntimeVersion DotNetMajor = [int]$RuntimeProfile.dotnetMajor TargetFramework = [string]$RuntimeProfile.targetFramework } @@ -210,6 +211,7 @@ foreach ($ExpectedProfile in $ExpectedProfiles) { Assert-ExactPropertySet -InputObject $EvidenceProfile -Expected @('profileKey', 'powerShellVersion', 'powerShellLine', 'dotNetVersion', 'dotNetMajor', 'targetFramework', 'platform', 'architecture', 'runtimeExecutable', 'psHome', 'writesPerformed', 'inventoryFingerprint', 'moduleVersions', 'scenarios') -Label "Authenticated profile '$($ExpectedProfile.ProfileKey)'" if ([string]$EvidenceProfile.powerShellVersion -ne $ExpectedProfile.PowerShellVersion -or [string]$EvidenceProfile.powerShellLine -ne $ExpectedProfile.PowerShellLine -or + [string]$EvidenceProfile.dotNetVersion -ne $ExpectedProfile.DotNetVersion -or [int]$EvidenceProfile.dotNetMajor -ne $ExpectedProfile.DotNetMajor -or [string]$EvidenceProfile.targetFramework -ne $ExpectedProfile.TargetFramework -or [string]$EvidenceProfile.platform -ne 'windows' -or From dab1367b29d0e055a14eb13716dd1ae4daeb7906 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:58:09 -0400 Subject: [PATCH 40/43] fix: close manual auth fallback after transition --- .github/workflows/Release-and-Publish.yml | 6 ++++-- tests/Unit/WorkflowGuardrails.Tests.ps1 | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Release-and-Publish.yml b/.github/workflows/Release-and-Publish.yml index 368c3afc..f431426b 100644 --- a/.github/workflows/Release-and-Publish.yml +++ b/.github/workflows/Release-and-Publish.yml @@ -177,7 +177,7 @@ jobs: $EvidenceMode = 'protected-exact-commit-workflow' $AllowedReleaseVersion = '' $EvidenceReference = $ProtectedEvidenceRun.url - } elseif (Test-Path -LiteralPath $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH -PathType Leaf) { + } elseif (-not $ProtectedWorkflowConfigured -and (Test-Path -LiteralPath $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH -PathType Leaf)) { $ManualEvidenceParameters = @{ EvidencePath = $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH RepositoryRoot = '.' @@ -189,8 +189,10 @@ jobs: $EvidenceMode = 'manual-interactive-transition' $AllowedReleaseVersion = [string]$ManualEvidence.AllowedReleaseVersion $EvidenceReference = "``$($env:MANUAL_AUTHENTICATED_EVIDENCE_PATH)`` (expires $($ManualEvidence.ExpiresAtUtc))" + } elseif ($ProtectedWorkflowConfigured) { + throw "Release is blocked: protected authenticated workflow '$($env:AUTHENTICATED_WORKFLOW_FILE)' is configured but has no successful exact-commit evidence with one unexpired '$($env:AUTHENTICATED_EVIDENCE_ARTIFACT)' artifact." } else { - throw "Release is blocked: no successful exact-commit protected workflow evidence exists and no accepted bounded manual transition evidence is committed." + throw 'Release is blocked: the protected authenticated workflow is not configured and no accepted bounded manual transition evidence is committed.' } "evidence_mode=$EvidenceMode" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index 36f12a91..aebbdcdb 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -144,6 +144,9 @@ Describe 'Release publish gating guardrails' -Tag 'Unit' { $ReleaseWorkflow | Should -Match ([regex]::Escape('writesAllowed -ne $false')) $ReleaseWorkflow | Should -Match ([regex]::Escape('tools/Test-DLLPickleManualAuthenticatedEvidence.ps1')) $ReleaseWorkflow | Should -Match ([regex]::Escape('initial-multitarget-major.json')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('elseif (-not $ProtectedWorkflowConfigured -and (Test-Path -LiteralPath $env:MANUAL_AUTHENTICATED_EVIDENCE_PATH -PathType Leaf))')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('protected authenticated workflow')) + $ReleaseWorkflow | Should -Match ([regex]::Escape('is configured but has no successful exact-commit evidence')) $ReleaseWorkflow | Should -Match ([regex]::Escape('$EvidenceMode = ''manual-interactive-transition''')) $ReleaseWorkflow | Should -Match ([regex]::Escape('[string]$NewVersion -ne $env:AUTHENTICATED_ALLOWED_RELEASE_VERSION')) $ManualAuthValidator | Should -Match ([regex]::Escape("allowedReleaseVersion -ne '3.0.0'")) From 97122ab5d3263cb67b0f61180f268f40892c3da0 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:58:05 -0400 Subject: [PATCH 41/43] fix: use device code for manual Az evidence --- ...2026-08-09-credentialed-authentication-test-environment.md | 3 +++ tests/Unit/ManualAuthenticatedHarness.Tests.ps1 | 2 ++ tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md index a97bcd4b..6496bc62 100644 --- a/docs/plans/2026-08-09-credentialed-authentication-test-environment.md +++ b/docs/plans/2026-08-09-credentialed-authentication-test-environment.md @@ -257,6 +257,9 @@ scenarios runs in a fresh process for each of the three exact profiles. Passing scenario checkpoints are reused on rerun only while their complete prepared module-inventory fingerprint still matches, so an authorization failure or an interrupted session does not require repeating unchanged completed scenarios. +The fixed Az scenarios use delegated device-code authentication so the capture +does not depend on the terminal host's WAM integration or change persisted Az +configuration. Follow the displayed device-login instructions in a browser. To diagnose one cell first, use the optional `-ProfileKey` and `-ScenarioId` filters; the candidate remains incomplete until all 42 checkpoints exist. diff --git a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 index c033fdd5..c678a944 100644 --- a/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 +++ b/tests/Unit/ManualAuthenticatedHarness.Tests.ps1 @@ -17,6 +17,8 @@ Describe 'Manual authenticated compatibility harness guardrails' -Tag 'Unit' { $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-AzResource' -Module 'Az.Resources'")) $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-AzStorageAccount' -Module 'Az.Storage'")) $script:SharedHelpers | Should -Match ([regex]::Escape("-Name 'Get-CsTenant' -Module 'MicrosoftTeams'")) + $script:ScenarioHarness | Should -Match ([regex]::Escape('& $Command -Scope Process -UseDeviceAuthentication -ErrorAction Stop')) + $script:ScenarioHarness | Should -Not -Match 'Update-AzConfig' ($script:ScenarioHarness + $script:SharedHelpers) | Should -Not -Match 'Invoke-Expression|ScriptBlock|AccessToken|ClientSecret|Certificate' } diff --git a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 index c1a8549b..4805d520 100644 --- a/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 +++ b/tools/Invoke-DLLPickleManualAuthenticatedScenario.ps1 @@ -78,7 +78,9 @@ function Connect-Provider { } 'az' { $Command = Get-DLLPickleAuthenticatedCommand -Name 'Connect-AzAccount' -Module 'Az.Accounts' - & $Command -Scope Process -ErrorAction Stop | Out-Null + # WAM depends on the interactive host and can fail after account selection. + # Device code remains delegated-interactive and avoids changing persisted Az config. + & $Command -Scope Process -UseDeviceAuthentication -ErrorAction Stop | Out-Null if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { $Command = Get-DLLPickleAuthenticatedCommand -Name 'Set-AzContext' -Module 'Az.Accounts' & $Command -SubscriptionId $SubscriptionId -Scope Process -ErrorAction Stop | Out-Null From 74b038b86f8aef9d8d65603a83fe7af2a8d8e762 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:06:15 -0400 Subject: [PATCH 42/43] fix: harden upstream evidence capture --- tests/Unit/DependencyAutomation.Tests.ps1 | 15 +++++--- tests/Unit/RuntimeAssemblyProbe.Tests.ps1 | 19 ++++++++++ .../Get-DLLPickleRuntimeAssemblySnapshot.ps1 | 6 ++- tools/Get-DLLPickleUpstreamInventory.ps1 | 37 ++++++++++++++----- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/tests/Unit/DependencyAutomation.Tests.ps1 b/tests/Unit/DependencyAutomation.Tests.ps1 index dc8559ee..f42c4435 100644 --- a/tests/Unit/DependencyAutomation.Tests.ps1 +++ b/tests/Unit/DependencyAutomation.Tests.ps1 @@ -28,7 +28,7 @@ BeforeAll { } Describe 'Dependency automation tooling' -Tag 'Unit' { - It 'resolves every monitored module version before downloading any module' { + It 'resolves and saves every monitored module before taking runtime snapshots' { $Assembly = [System.String].Assembly $AssemblyName = $Assembly.GetName().Name $ModuleCachePath = Join-Path -Path $TestDrive -ChildPath 'atomic-modules' @@ -44,14 +44,14 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { $InventoryTestStateKey = 'DLLPickle.DependencyAutomation.InventoryTestState' $InventoryTestState = [PSCustomObject]@{ - Events = [System.Collections.Generic.List[string]]::new() AssemblyLocation = $Assembly.Location AssemblyName = $AssemblyName + EventLogPath = Join-Path $TestDrive 'atomic-events.txt' } [System.AppDomain]::CurrentDomain.SetData($InventoryTestStateKey, $InventoryTestState) Mock Find-Module { $State = [System.AppDomain]::CurrentDomain.GetData('DLLPickle.DependencyAutomation.InventoryTestState') - $State.Events.Add("find:$Name") + Add-Content -LiteralPath $State.EventLogPath -Value "find:$Name" if ($Name -eq 'Synthetic.One') { [PSCustomObject]@{ Name = $Name; Version = '1.9.0' } [PSCustomObject]@{ Name = $Name; Version = '1.10.0' } @@ -61,23 +61,26 @@ Describe 'Dependency automation tooling' -Tag 'Unit' { } Mock Save-Module { $State = [System.AppDomain]::CurrentDomain.GetData('DLLPickle.DependencyAutomation.InventoryTestState') - $State.Events.Add("save:${Name}:$RequiredVersion") + Add-Content -LiteralPath $State.EventLogPath -Value "save:${Name}:$RequiredVersion" $ModuleRoot = Join-Path -Path $Path -ChildPath ([System.IO.Path]::Combine($Name, [string]$RequiredVersion)) $null = New-Item -Path $ModuleRoot -ItemType Directory -Force Copy-Item -LiteralPath $State.AssemblyLocation -Destination (Join-Path $ModuleRoot "$($State.AssemblyName).dll") -Force - Set-Content -LiteralPath (Join-Path $ModuleRoot "$Name.psm1") -Value '# Synthetic importable module.' -Encoding UTF8 + $EscapedEventLogPath = $State.EventLogPath.Replace("'", "''") + Set-Content -LiteralPath (Join-Path $ModuleRoot "$Name.psm1") -Value "Add-Content -LiteralPath '$EscapedEventLogPath' -Value 'probe:$Name'" -Encoding UTF8 New-ModuleManifest -Path (Join-Path $ModuleRoot "$Name.psd1") -RootModule "$Name.psm1" -ModuleVersion ([string]$RequiredVersion) } $null = & $script:InventoryScriptPath -PolicyPath $PolicyPath -TestMatrixPath $TestMatrixPath -ModuleCachePath $ModuleCachePath -OutputPath (Join-Path $TestDrive 'atomic-inventory.json') - $InventoryEvents = @($InventoryTestState.Events) + $InventoryEvents = @(Get-Content -LiteralPath $InventoryTestState.EventLogPath) [System.AppDomain]::CurrentDomain.SetData($InventoryTestStateKey, $null) $InventoryEvents | Should -Be @( 'find:Synthetic.One' 'find:Synthetic.Two' 'save:Synthetic.One:1.10.0' 'save:Synthetic.Two:4.5.6' + 'probe:Synthetic.One' + 'probe:Synthetic.Two' ) } diff --git a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 index 7377d9bb..90ba7f35 100644 --- a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 +++ b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 @@ -86,6 +86,25 @@ Describe 'Get-DLLPickleRuntimeAssemblySnapshot' -Tag 'Unit' { Should -Throw '*runtime assembly snapshot failed*' } + It 'throws in strict mode when DLLPickle reports a failed preload row' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $DllPickleRoot = Join-Path $TestDrive 'failing-dllpickle' + $null = New-Item -Path $DllPickleRoot -ItemType Directory + @' +function Import-DPLibrary { + [CmdletBinding()] + param([switch]$SuppressLogo) + + [pscustomobject]@{ Status = 'Failed' } +} +'@ | Set-Content -LiteralPath (Join-Path $DllPickleRoot 'DLLPickle.psm1') -Encoding UTF8 + $DllPickleManifest = Join-Path $DllPickleRoot 'DLLPickle.psd1' + New-ModuleManifest -Path $DllPickleManifest -RootModule 'DLLPickle.psm1' -ModuleVersion '1.0.0' -FunctionsToExport @('Import-DPLibrary') + + { & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PreloadDllPickleManifest $DllPickleManifest -PolicyPath $Policy -PowerShellExecutable ([Environment]::ProcessPath) -Strict } | + Should -Throw '*DLLPickle preload reported 1 failed assembly load*' + } + It 'imports an exact manifest under an explicitly isolated module path' { $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') $ModuleRoot = Join-Path $TestDrive 'isolated-module' diff --git a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 index a6db8a99..6619b0c6 100644 --- a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 +++ b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 @@ -112,7 +112,11 @@ if ($ExpectedTargetFramework -and $ActualTargetFramework -ne $ExpectedTargetFram if ($PreloadManifest) { if ($StrictMode) { Import-Module $PreloadManifest -Force -ErrorAction Stop - Import-DPLibrary -SuppressLogo -ErrorAction Stop | Out-Null + $ImportResults = @(Import-DPLibrary -SuppressLogo -ErrorAction Stop) + $FailedImports = @($ImportResults | Where-Object { [string]$_.Status -eq 'Failed' }) + if ($FailedImports.Count -gt 0) { + throw "DLLPickle preload reported $($FailedImports.Count) failed assembly load(s)." + } } else { Import-Module $PreloadManifest -Force Import-DPLibrary -SuppressLogo | Out-Null diff --git a/tools/Get-DLLPickleUpstreamInventory.ps1 b/tools/Get-DLLPickleUpstreamInventory.ps1 index 11db2437..62ff8363 100644 --- a/tools/Get-DLLPickleUpstreamInventory.ps1 +++ b/tools/Get-DLLPickleUpstreamInventory.ps1 @@ -204,18 +204,23 @@ if (-not $SkipDownload.IsPresent) { } $ResolvedModuleVersions[$Name] = $GalleryModule.Version } -} - -$ModuleResults = foreach ($PolicyModule in $PolicyModules) { - $Name = [string]$PolicyModule.name - $Repository = if ($PolicyModule.repository) { [string]$PolicyModule.repository } else { 'PSGallery' } - if (-not $SkipDownload.IsPresent) { - $ModuleRoot = Join-Path -Path $ModuleCachePath -ChildPath $Name - if ($Force.IsPresent -and (Test-Path -LiteralPath $ModuleRoot)) { - Remove-Item -LiteralPath $ModuleRoot -Recurse -Force + # Prepare the complete cache before taking any runtime snapshot. A monitored module can save + # another monitored module as a dependency, so removing/replacing roots during the snapshot + # loop could otherwise make earlier rows describe a transient cache state. + if ($Force.IsPresent) { + foreach ($PolicyModule in $PolicyModules) { + $Name = [string]$PolicyModule.name + $ModuleRoot = Join-Path -Path $ModuleCachePath -ChildPath $Name + if (Test-Path -LiteralPath $ModuleRoot) { + Remove-Item -LiteralPath $ModuleRoot -Recurse -Force + } } + } + foreach ($PolicyModule in $PolicyModules) { + $Name = [string]$PolicyModule.name + $Repository = if ($PolicyModule.repository) { [string]$PolicyModule.repository } else { 'PSGallery' } $SaveModuleParameters = @{ Name = $Name RequiredVersion = $ResolvedModuleVersions[$Name] @@ -229,8 +234,20 @@ $ModuleResults = foreach ($PolicyModule in $PolicyModules) { } Save-Module @SaveModuleParameters } +} + +$ModuleResults = foreach ($PolicyModule in $PolicyModules) { + $Name = [string]$PolicyModule.name + $Repository = if ($PolicyModule.repository) { [string]$PolicyModule.repository } else { 'PSGallery' } - $SavedModule = Get-DLLPickleLatestModulePath -RootPath $ModuleCachePath -Name $Name + $SavedModule = if ($SkipDownload.IsPresent) { + Get-DLLPickleLatestModulePath -RootPath $ModuleCachePath -Name $Name + } else { + $ResolvedModulePath = Join-Path -Path $ModuleCachePath -ChildPath ( + [System.IO.Path]::Combine($Name, [string]$ResolvedModuleVersions[$Name]) + ) + Get-Item -LiteralPath $ResolvedModulePath -ErrorAction SilentlyContinue + } if (-not $SavedModule) { throw "Module '$Name' was not found under '$ModuleCachePath'." } From a837b4b73f877d58da80e509b57950291170c939 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:18:51 -0400 Subject: [PATCH 43/43] fix: route bundle inputs through live validation --- .github/workflows/Upstream-Compatibility.yml | 6 +++++- tests/Unit/WorkflowGuardrails.Tests.ps1 | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Upstream-Compatibility.yml b/.github/workflows/Upstream-Compatibility.yml index 4c930ee3..9e29f3e0 100644 --- a/.github/workflows/Upstream-Compatibility.yml +++ b/.github/workflows/Upstream-Compatibility.yml @@ -56,7 +56,7 @@ jobs: $Files = gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[] | .filename, (.previous_filename // empty)' # Native non-zero exits don't reliably throw in pwsh, so check explicitly -> fail-safe. if ($LASTEXITCODE -ne 0) { throw "gh api exited $LASTEXITCODE while listing PR files" } - $Patterns = @('^build/', '^src/DLLPickle\.Build/', '^src/DLLPickle/', '^tests/', '^tools/', '^\.github/workflows/Upstream-Compatibility\.yml$') + $Patterns = @('^build/', '^src/DLLPickle\.Build/', '^src/DLLPickle/', '^tests/', '^tools/', '^global\.json$', '^\.github/workflows/Upstream-Compatibility\.yml$') # Any change that can alter the accepted baseline or the fingerprint computation must # prove itself against a fresh upstream inventory. Comparator/tests/workflow-only changes # stay on the deterministic smoke path so a pre-existing external drift does not prevent @@ -64,6 +64,10 @@ jobs: $LivePatterns = @( '^build/dependency-policy\.json$', '^build/DLLPickle\.Build\.ps1$', + '^build/DLLPickle\.Settings\.ps1$', + '^build/DLLPickle\.Tooling\.ps1$', + '^build/build-tool-versions\.json$', + '^global\.json$', '^src/DLLPickle\.Build/DLLPickle\.csproj$', '^src/DLLPickle\.Build/packages\.lock\.json$', '^build/powershell-test-matrix\.json$', diff --git a/tests/Unit/WorkflowGuardrails.Tests.ps1 b/tests/Unit/WorkflowGuardrails.Tests.ps1 index aebbdcdb..ab9f40a8 100644 --- a/tests/Unit/WorkflowGuardrails.Tests.ps1 +++ b/tests/Unit/WorkflowGuardrails.Tests.ps1 @@ -22,6 +22,11 @@ Describe 'Upstream compatibility workflow guardrails' -Tag 'Unit' { $UpstreamWorkflow | Should -Match 'live_validation' $UpstreamWorkflow | Should -Match ([regex]::Escape('build/dependency-policy.json')) $UpstreamWorkflow | Should -Match ([regex]::Escape("'^build/DLLPickle\.Build\.ps1$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^build/DLLPickle\.Settings\.ps1$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^build/DLLPickle\.Tooling\.ps1$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^build/build-tool-versions\.json$'")) + $UpstreamWorkflow | Should -Match ([regex]::Escape("'^global\.json$'")) + ([regex]::Matches($UpstreamWorkflow, [regex]::Escape("'^global\.json$'"))).Count | Should -Be 2 $UpstreamWorkflow | Should -Match ([regex]::Escape('tools/Get-DLLPickleUpstreamInventory.ps1')) $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/Get-DLLPickleLoadedTrackedAssembly\.ps1$'")) $UpstreamWorkflow | Should -Match ([regex]::Escape("'^tools/DLLPickle\.ProfileEvidence\.ps1$'"))