From e47127864387e310fe0f806ad1b1d153dc9be2ad Mon Sep 17 00:00:00 2001 From: Julio Rios <31230801+jsilverdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:54:36 -0500 Subject: [PATCH] Harden PowerShell runtime deployment --- scripts/windows/deploy-pwsh.ps1 | 85 +++++++++++++++++++++++++++--- tests/windows/assert-allsigned.ps1 | 4 ++ tests/windows/assert-state.ps1 | 14 +++++ tests/windows/prepare-ci.ps1 | 16 ++++++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/scripts/windows/deploy-pwsh.ps1 b/scripts/windows/deploy-pwsh.ps1 index 8e0a406..9a6fba5 100644 --- a/scripts/windows/deploy-pwsh.ps1 +++ b/scripts/windows/deploy-pwsh.ps1 @@ -6,6 +6,82 @@ param( $ErrorActionPreference = "Stop" +function Get-DirectoryEntry { + param([Parameter(Mandatory)][string]$Path) + + $parent = [IO.Path]::GetDirectoryName($Path) + $leaf = [IO.Path]::GetFileName($Path) + if ([string]::IsNullOrWhiteSpace($parent) -or [string]::IsNullOrWhiteSpace($leaf)) { + return $null + } + + $directory = [IO.DirectoryInfo]::new($parent) + if (-not $directory.Exists) { + return $null + } + + return @($directory.EnumerateFileSystemInfos() | Where-Object Name -eq $leaf | Select-Object -First 1) +} + +function Ensure-DestinationDirectory { + param([Parameter(Mandatory)][string]$Destination) + + $parent = [IO.Path]::GetDirectoryName($Destination) + if ([string]::IsNullOrWhiteSpace($parent)) { + throw "Destination has no parent directory: $Destination" + } + + try { + [IO.Directory]::CreateDirectory($parent) | Out-Null + } + catch { + throw "Unable to create destination directory '$parent': $($_.Exception.Message)" + } + + if (-not [IO.Directory]::Exists($parent)) { + throw "Destination directory is unavailable after creation: $parent" + } +} + +function Remove-LegacyDestinationLink { + param([Parameter(Mandatory)][string]$Destination) + + $entry = Get-DirectoryEntry -Path $Destination + if ($null -eq $entry -or ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + return + } + + Write-Host "Replacing legacy PowerShell reparse point: $Destination" -ForegroundColor Yellow + + if (($entry.Attributes -band [IO.FileAttributes]::Directory) -ne 0) { + [IO.Directory]::Delete($Destination) + } + else { + [IO.File]::Delete($Destination) + } +} + +function Copy-RuntimeFile { + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination + ) + + if (-not [IO.File]::Exists($Source)) { + throw "PowerShell source file not found: $Source" + } + + Ensure-DestinationDirectory -Destination $Destination + Remove-LegacyDestinationLink -Destination $Destination + + try { + [IO.File]::Copy($Source, $Destination, $true) + } + catch { + throw "Unable to deploy PowerShell runtime file '$Source' to '$Destination': $($_.Exception.Message)" + } +} + $repo = (Resolve-Path -LiteralPath $RepoRoot).Path $sourceRoot = Join-Path $repo "home\.chezmoitemplates\pwsh" $destinationRoot = Join-Path $HOME ".config\pwsh" @@ -17,14 +93,7 @@ $files = @( ) foreach ($file in $files) { - $source = Join-Path $sourceRoot $file.Source - if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { - throw "PowerShell source file not found: $source" - } - - New-Item -ItemType Directory -Path (Split-Path -Parent $file.Destination) -Force | Out-Null - - Copy-Item -LiteralPath $source -Destination $file.Destination -Force + Copy-RuntimeFile -Source (Join-Path $sourceRoot $file.Source) -Destination $file.Destination } if ($env:DOTFILES_SIGNING_REQUIRED -eq "1" -or (Get-ExecutionPolicy) -eq "AllSigned") { diff --git a/tests/windows/assert-allsigned.ps1 b/tests/windows/assert-allsigned.ps1 index 7811e75..274c408 100644 --- a/tests/windows/assert-allsigned.ps1 +++ b/tests/windows/assert-allsigned.ps1 @@ -236,6 +236,10 @@ $runtimeFiles = @( ) foreach ($path in $runtimeFiles) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "runtime PowerShell file is missing: $path" } + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail "runtime PowerShell file remained a reparse point after deployment: $path" + } $signatureFiles.Add($path) } diff --git a/tests/windows/assert-state.ps1 b/tests/windows/assert-state.ps1 index e348df2..f68d1e9 100644 --- a/tests/windows/assert-state.ps1 +++ b/tests/windows/assert-state.ps1 @@ -59,6 +59,20 @@ foreach ($path in $requiredFiles) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "expected deployed file is missing: $path" } } +$runtimeFiles = @( + (Join-Path $HOME ".config\pwsh\env.ps1"), + (Join-Path $HOME ".config\pwsh\lib\helpers.ps1"), + (Join-Path $HOME ".config\pwsh\lib\aliases.ps1"), + $PROFILE.CurrentUserAllHosts +) +foreach ($path in $runtimeFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "PowerShell runtime file is missing: $path" } + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail "PowerShell runtime file remained a reparse point after deployment: $path" + } +} + if ($ExpectedCommit) { $sourceCommit = ((git -C $sourcePath rev-parse HEAD) -join "").Trim() if ($LASTEXITCODE -ne 0 -or $sourceCommit -ne $ExpectedCommit) { Fail "source commit $sourceCommit is not expected commit $ExpectedCommit" } diff --git a/tests/windows/prepare-ci.ps1 b/tests/windows/prepare-ci.ps1 index 6b0c118..cf01e5c 100644 --- a/tests/windows/prepare-ci.ps1 +++ b/tests/windows/prepare-ci.ps1 @@ -72,3 +72,19 @@ function New-BrokenDirectorySymlinkFixture { New-BrokenFileSymlinkFixture -Path (Join-Path $HOME ".fdignore") -Target (Join-Path $env:RUNNER_TEMP "legacy-fdignore") New-BrokenDirectorySymlinkFixture -Path (Join-Path $HOME ".config\starship") -Target (Join-Path $env:RUNNER_TEMP "legacy-starship") New-BrokenFileSymlinkFixture -Path (Join-Path $HOME ".dotfiles-ci-unrelated-broken-link") -Target (Join-Path $env:RUNNER_TEMP "unrelated-link") + + +function New-BrokenRuntimeSymlinkFixture { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name + ) + + $target = Join-Path $env:RUNNER_TEMP "legacy-pwsh-runtime\$Name" + New-BrokenFileSymlinkFixture -Path $Path -Target $target +} + +New-BrokenRuntimeSymlinkFixture -Path (Join-Path $HOME ".config\pwsh\env.ps1") -Name "env.ps1" +New-BrokenRuntimeSymlinkFixture -Path (Join-Path $HOME ".config\pwsh\lib\aliases.ps1") -Name "aliases.ps1" +New-BrokenRuntimeSymlinkFixture -Path (Join-Path $HOME ".config\pwsh\lib\helpers.ps1") -Name "helpers.ps1" +New-BrokenRuntimeSymlinkFixture -Path $PROFILE.CurrentUserAllHosts -Name "profile.ps1"