diff --git a/README.md b/README.md index ede9300..1824adf 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ Get-Help .\Windows-Path-Repair-Full.ps1 -Detailed Double-click `Run-PATH-Repair-Admin.bat` to open an elevated PowerShell session with a **preview** of the canonical script. It does not apply changes automatically. Review the result, then rerun the command in that elevated window with `-Apply` if it is correct. +The batch file hands the script location to [`Start-PATH-Repair-Elevated.ps1`](Start-PATH-Repair-Elevated.ps1) as a bound `-ScriptPath` parameter, and that helper passes the path to the elevated process as a single argument instead of inserting it into PowerShell command text. This keeps the launcher working when the repository folder contains spaces, apostrophes, ampersands, parentheses, or non-ASCII characters, for example `C:\Qoder Test\O'Brien & 工具 (x64)`. Keep `Start-PATH-Repair-Elevated.ps1` in the same folder as the batch file. + +If the canonical script is missing or the elevated session cannot be started, such as when the UAC prompt is dismissed, the launcher reports the reason and exits with a non-zero exit code. + ## Backups and recovery Every apply operation creates a timestamped JSON backup under `%USERPROFILE%\PathBackups` before modifying `PATH`. Keep the backup until you have opened a new terminal and confirmed that your required commands resolve correctly. The backup contains the exact original User and Machine values, and the script automatically rolls back changes from the current run if a later write or read-back verification fails. diff --git a/Run-PATH-Repair-Admin.bat b/Run-PATH-Repair-Admin.bat index 38923ce..e021be9 100644 --- a/Run-PATH-Repair-Admin.bat +++ b/Run-PATH-Repair-Admin.bat @@ -3,9 +3,18 @@ setlocal DisableDelayedExpansion echo Windows PATH Repair Tool echo Opening an elevated preview. No PATH changes will be made unless you rerun with -Apply. set "repair_script=%~dp0Windows-Path-Repair-Full.ps1" +set "launcher_script=%~dp0Start-PATH-Repair-Elevated.ps1" if not exist "%repair_script%" ( echo ERROR: Canonical repair script was not found. exit /b 1 ) -PowerShell -NoProfile -Command "$scriptPath = $args[0]; $arguments = '-NoProfile -NoExit -File \"{0}\"' -f $scriptPath; Start-Process -FilePath 'PowerShell.exe' -Verb RunAs -ArgumentList $arguments" "%repair_script%" -endlocal +if not exist "%launcher_script%" ( + echo ERROR: Elevation launcher script was not found. + exit /b 1 +) +PowerShell -NoProfile -ExecutionPolicy Bypass -File "%launcher_script%" -ScriptPath "%repair_script%" +set "launch_exit=%ERRORLEVEL%" +if not "%launch_exit%"=="0" ( + echo ERROR: The elevated preview could not be started. +) +endlocal & exit /b %launch_exit% diff --git a/Start-PATH-Repair-Elevated.ps1 b/Start-PATH-Repair-Elevated.ps1 new file mode 100644 index 0000000..a41e95e --- /dev/null +++ b/Start-PATH-Repair-Elevated.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Start an elevated, preview-only run of Windows-Path-Repair-Full.ps1. + +.DESCRIPTION + Receives the canonical script location as a bound -ScriptPath parameter and + forwards it to the elevated PowerShell.exe process as a discrete argv token. + The path is never concatenated into PowerShell command text, so a repository + directory containing spaces, apostrophes, ampersands, parentheses, or + non-ASCII characters is passed through unchanged. + + Elevation uses -Verb RunAs so the standard UAC prompt stays visible, and the + elevated session opens the canonical script in preview mode. -Apply is never + added automatically. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ScriptPath, + + [scriptblock]$StartProcessAction = { + param($FilePath, $ArgumentList) + Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Verb RunAs | Out-Null + }, + + [switch]$NoExecute +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function ConvertTo-ArgumentToken { + <# + Quotes one value using the Windows CommandLineToArgvW rules so the child + process recovers the original string exactly. + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + if ($Value.Contains('"')) { + throw "A Windows file path cannot contain a double quote character: $Value" + } + + # Backslashes are literal unless they immediately precede the closing quote, + # where each one must be doubled. + $trailingBackslashes = 0 + for ($i = $Value.Length - 1; $i -ge 0 -and $Value[$i] -eq '\'; $i--) { + $trailingBackslashes++ + } + + return '"' + $Value + ('\' * $trailingBackslashes) + '"' +} + +function Get-ElevatedLaunchArgumentLine { + <# + Builds the argv line for the elevated PowerShell.exe process. The result + contains only fixed switches plus the quoted script path; it is never + evaluated as PowerShell command text. + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$ScriptPath) + + $tokens = @('-NoProfile', '-NoExit', '-File', (ConvertTo-ArgumentToken -Value $ScriptPath)) + return ($tokens -join ' ') +} + +function Get-ElevatedPowerShellPath { + [CmdletBinding()] + param() + + $candidate = Join-Path -Path $PSHOME -ChildPath 'PowerShell.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + + return 'PowerShell.exe' +} + +function Start-ElevatedPathRepair { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$ScriptPath, + [Parameter(Mandatory = $true)][scriptblock]$StartProcessAction + ) + + if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { + throw "Canonical repair script was not found: $ScriptPath" + } + + $resolvedPath = (Get-Item -LiteralPath $ScriptPath).FullName + $filePath = Get-ElevatedPowerShellPath + $argumentLine = Get-ElevatedLaunchArgumentLine -ScriptPath $resolvedPath + + try { + & $StartProcessAction $filePath $argumentLine + } + catch { + throw "Could not start the elevated PowerShell session: $($_.Exception.Message)" + } + + return [pscustomobject]@{ + FilePath = $filePath + ArgumentList = $argumentLine + ScriptPath = $resolvedPath + } +} + +if ($NoExecute) { + return +} + +try { + $launch = Start-ElevatedPathRepair -ScriptPath $ScriptPath -StartProcessAction $StartProcessAction + Write-Host "Elevated preview requested for: $($launch.ScriptPath)" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/tests/Run-PATH-Repair-Admin.Tests.ps1 b/tests/Run-PATH-Repair-Admin.Tests.ps1 new file mode 100644 index 0000000..d073bb4 --- /dev/null +++ b/tests/Run-PATH-Repair-Admin.Tests.ps1 @@ -0,0 +1,266 @@ +$launcherScript = Join-Path $PSScriptRoot '..\Start-PATH-Repair-Elevated.ps1' +$batchLauncher = Join-Path $PSScriptRoot '..\Run-PATH-Repair-Admin.bat' + +# The hostile repository name from the reported issue: spaces, an apostrophe, an +# ampersand, parentheses, and non-ASCII text. The Chinese characters are built +# from code points so this test file stays ASCII and cannot be corrupted by the +# Windows PowerShell 5.1 ANSI default for BOM-less .ps1 files. +$hostileFolderName = "Qoder Test\O'Brien & " + + [string][char]0x5DE5 + [string][char]0x5177 + " (x64)" + +if (-not ('PathFix.Native' -as [type])) { + Add-Type -Namespace 'PathFix' -Name 'Native' -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("shell32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] +public static extern System.IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + +[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] +public static extern System.IntPtr LocalFree(System.IntPtr hMem); +'@ +} + +function Get-ArgvToken { + <# + Splits a command line with the same Win32 CommandLineToArgvW parser that + PowerShell.exe uses, so these tests verify real argv recovery instead of + re-implementing the quoting rules they are meant to check. + #> + param([Parameter(Mandatory = $true)][string]$CommandLine) + + # CommandLineToArgvW applies a different, program-name rule to argv[0], so a + # placeholder is prepended and dropped to parse the line as pure arguments. + $count = 0 + $buffer = [PathFix.Native]::CommandLineToArgvW("placeholder.exe $CommandLine", [ref]$count) + if ($buffer -eq [System.IntPtr]::Zero) { + throw "CommandLineToArgvW failed for: $CommandLine" + } + + try { + $tokens = @() + for ($i = 1; $i -lt $count; $i++) { + $pointer = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($buffer, $i * [System.IntPtr]::Size) + $tokens += [System.Runtime.InteropServices.Marshal]::PtrToStringUni($pointer) + } + return $tokens + } + finally { + [void][PathFix.Native]::LocalFree($buffer) + } +} + +function Invoke-BatchLauncher { + <# + Runs a .bat through `cmd /d /c call`, which keeps a hostile path intact. + Handing the quoted path straight to `cmd /c` makes CMD split it on the + first space. + #> + param([Parameter(Mandatory = $true)][string]$BatchPath) + + $output = & cmd.exe /d /c call "$BatchPath" 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($output -join "`n") + } +} + +function New-HostileRepository { + <# + Creates the hostile repository directory under a per-test label, because + Pester 3 shares one TestDrive across every It block in a Describe and a + reused directory would leak the canonical script between tests. + #> + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Label, + [switch]$IncludeCanonicalScript + ) + + $repoDirectory = Join-Path (Join-Path $Root $Label) $hostileFolderName + New-Item -ItemType Directory -Path $repoDirectory -Force | Out-Null + + if ($IncludeCanonicalScript) { + Set-Content -LiteralPath (Join-Path $repoDirectory 'Windows-Path-Repair-Full.ps1') ` + -Value 'param([switch]$Apply)' -Encoding UTF8 + } + + return $repoDirectory +} + +Describe 'elevated launcher argument preservation' { + BeforeEach { + . $launcherScript -ScriptPath $launcherScript -NoExecute + } + + It 'round-trips a path with spaces, apostrophe, ampersand, parentheses, and Chinese characters' { + $target = Join-Path (New-HostileRepository -Root $TestDrive -Label 'argv-roundtrip' -IncludeCanonicalScript) 'Windows-Path-Repair-Full.ps1' + + $tokens = @(Get-ArgvToken -CommandLine (Get-ElevatedLaunchArgumentLine -ScriptPath $target)) + + $tokens.Count | Should Be 4 + $tokens[0] | Should Be '-NoProfile' + $tokens[1] | Should Be '-NoExit' + $tokens[2] | Should Be '-File' + $tokens[3] | Should Be $target + } + + It 'hands the resolved path to Start-Process as a single argv token, not as command text' { + $target = Join-Path (New-HostileRepository -Root $TestDrive -Label 'argv-token' -IncludeCanonicalScript) 'Windows-Path-Repair-Full.ps1' + + $launch = Start-ElevatedPathRepair -ScriptPath $target -StartProcessAction { + param($FilePath, $ArgumentList) + } + + $launch.ScriptPath | Should Be $target + $launch.FilePath | Should Match 'PowerShell\.exe$' + + $tokens = @(Get-ArgvToken -CommandLine $launch.ArgumentList) + $tokens[-1] | Should Be $target + ($tokens -contains '-File') | Should Be $true + + # -Command would let the path be evaluated as PowerShell code. + ($tokens -contains '-Command') | Should Be $false + } + + It 'never adds -Apply automatically and keeps the elevated session open for review' { + $target = Join-Path (New-HostileRepository -Root $TestDrive -Label 'preview-switches' -IncludeCanonicalScript) 'Windows-Path-Repair-Full.ps1' + + $argumentLine = Get-ElevatedLaunchArgumentLine -ScriptPath $target + $tokens = @(Get-ArgvToken -CommandLine $argumentLine) + + # Checked per token, not against the raw line, because a repository + # directory name may legitimately contain the text "-Apply". + ($tokens -contains '-Apply') | Should Be $false + ($tokens -contains '-NoExit') | Should Be $true + ($tokens -contains '-NoProfile') | Should Be $true + } + + It 'quotes a trailing backslash so the closing quote is not escaped' { + $token = ConvertTo-ArgumentToken -Value 'C:\Qoder Test\tools\' + + $token | Should Be '"C:\Qoder Test\tools\\"' + @(Get-ArgvToken -CommandLine $token)[0] | Should Be 'C:\Qoder Test\tools\' + } + + It 'rejects a path containing a double quote instead of emitting a broken argv line' { + $rejected = $false + try { ConvertTo-ArgumentToken -Value 'C:\bad"path' } catch { $rejected = $true } + + $rejected | Should Be $true + } + + It 'delivers the hostile path to a real PowerShell.exe -File launch without showing UAC' { + $repoDirectory = New-HostileRepository -Root $TestDrive -Label 'real-file-launch' + $reportPath = Join-Path $TestDrive 'argv-report.txt' + $probe = Join-Path $repoDirectory 'Argv-Probe.ps1' + Set-Content -LiteralPath $probe -Encoding UTF8 -Value @( + 'param([string]$ReportPath)', + 'Set-Content -LiteralPath $ReportPath -Value $PSCommandPath -Encoding UTF8' + ) + + # The launcher's own quoting, replayed without -Verb RunAs so the + # elevation prompt is never displayed. + $argumentLine = (Get-ElevatedLaunchArgumentLine -ScriptPath $probe) -replace '-NoExit ', '' + $argumentLine += ' -ReportPath ' + (ConvertTo-ArgumentToken -Value $reportPath) + + Start-Process -FilePath (Get-ElevatedPowerShellPath) -ArgumentList $argumentLine -Wait -WindowStyle Hidden + + Test-Path -LiteralPath $reportPath | Should Be $true + (Get-Content -LiteralPath $reportPath -Encoding UTF8 | Select-Object -First 1) | Should Be $probe + } +} + +Describe 'elevated launcher failure handling' { + BeforeEach { + . $launcherScript -ScriptPath $launcherScript -NoExecute + } + + It 'reports a useful error when the target script is missing' { + $missing = Join-Path (New-HostileRepository -Root $TestDrive -Label 'missing-target') 'Windows-Path-Repair-Full.ps1' + + $message = $null + try { + Start-ElevatedPathRepair -ScriptPath $missing -StartProcessAction { param($FilePath, $ArgumentList) } + } + catch { $message = $_.Exception.Message } + + $message | Should Match 'Canonical repair script was not found' + $message | Should Match ([regex]::Escape($missing)) + } + + It 'reports a useful error when elevation cannot be started' { + $target = Join-Path (New-HostileRepository -Root $TestDrive -Label 'elevation-failure' -IncludeCanonicalScript) 'Windows-Path-Repair-Full.ps1' + + $message = $null + try { + Start-ElevatedPathRepair -ScriptPath $target -StartProcessAction { + param($FilePath, $ArgumentList) + throw 'The operation was canceled by the user.' + } + } + catch { $message = $_.Exception.Message } + + $message | Should Match 'Could not start the elevated PowerShell session' + $message | Should Match 'canceled by the user' + } + + It 'exits non-zero from the hostile path when the target script is missing' { + $missing = Join-Path (New-HostileRepository -Root $TestDrive -Label 'missing-exit-code') 'Windows-Path-Repair-Full.ps1' + + $output = & (Get-ElevatedPowerShellPath) -NoProfile -ExecutionPolicy Bypass ` + -File $launcherScript -ScriptPath $missing 2>&1 + $exitCode = $LASTEXITCODE + + $exitCode | Should Be 1 + ($output -join "`n") | Should Match 'Canonical repair script was not found' + } +} + +Describe 'Run-PATH-Repair-Admin.bat elevation handoff' { + BeforeEach { + . $launcherScript -ScriptPath $launcherScript -NoExecute + } + + It 'passes the script path as a bound parameter instead of PowerShell command text' { + $batch = Get-Content -LiteralPath $batchLauncher -Raw + # The informational banner legitimately mentions -Apply, so the switch + # assertion below looks only at executable lines. + $executableLines = (Get-Content -LiteralPath $batchLauncher | + Where-Object { $_ -notmatch '^\s*(echo|rem)\b' }) -join "`n" + + ($batch -match '(?i)-File\s+"%launcher_script%"') | Should Be $true + ($batch -match '(?i)-ScriptPath\s+"%repair_script%"') | Should Be $true + ($batch -match '(?i)PowerShell\s+-NoProfile\s+-Command') | Should Be $false + ($batch -match '(?i)Start-Process') | Should Be $false + ($executableLines -match '(?i)-Apply') | Should Be $false + ($batch -match '(?i)exit /b %launch_exit%') | Should Be $true + } + + It 'delivers the exact hostile repository path from CMD to the PowerShell launcher' { + $repoDirectory = New-HostileRepository -Root $TestDrive -Label 'cmd-handoff' -IncludeCanonicalScript + Copy-Item -LiteralPath $batchLauncher -Destination $repoDirectory + + # Stand in for the real launcher so the CMD hop is measured on its own and + # no UAC prompt is displayed. + $reportPath = Join-Path $TestDrive 'cmd-handoff-report.txt' + Set-Content -LiteralPath (Join-Path $repoDirectory 'Start-PATH-Repair-Elevated.ps1') -Encoding UTF8 -Value @( + 'param([string]$ScriptPath, [scriptblock]$StartProcessAction, [switch]$NoExecute)', + ('Set-Content -LiteralPath ' + (ConvertTo-ArgumentToken -Value $reportPath) + ' -Value $ScriptPath -Encoding UTF8') + ) + + $result = Invoke-BatchLauncher -BatchPath (Join-Path $repoDirectory 'Run-PATH-Repair-Admin.bat') + + $result.ExitCode | Should Be 0 + Test-Path -LiteralPath $reportPath | Should Be $true + (Get-Content -LiteralPath $reportPath -Encoding UTF8 | Select-Object -First 1) | + Should Be (Join-Path $repoDirectory 'Windows-Path-Repair-Full.ps1') + } + + It 'exits non-zero from the hostile path when the canonical script is missing' { + $repoDirectory = New-HostileRepository -Root $TestDrive -Label 'cmd-missing' + Copy-Item -LiteralPath $batchLauncher -Destination $repoDirectory + + $result = Invoke-BatchLauncher -BatchPath (Join-Path $repoDirectory 'Run-PATH-Repair-Admin.bat') + + $result.ExitCode | Should Be 1 + $result.Output | Should Match 'Canonical repair script was not found' + } +}