Public/Runner/Start-ProcessAsAdmin.ps1
|
function Start-ProcessAsAdmin { <# .SYNOPSIS Runs an executable with elevated (administrator) privileges. .DESCRIPTION Launches an external process, optionally requesting UAC elevation via the "RunAs" verb. When the target executable is 'powershell' or 'pwsh', the supplied statements are Base64-encoded and forwarded as an -EncodedCommand so that quoting and special characters are preserved. Standard output and standard error are captured asynchronously (using the ReadToEndAsync pattern) to prevent the deadlock that can occur when mixing synchronous reads with WaitForExit on Windows. .INPUTS None .OUTPUTS System.Int32 The process exit code. .PARAMETER Statements Arguments to pass to ExeToRun, or a PowerShell script block expressed as a string when ExeToRun is 'powershell' / 'pwsh'. .PARAMETER ExeToRun The executable to launch. Defaults to 'powershell'. Pass 'pwsh' to target PowerShell 7+. .PARAMETER Elevated When specified, the "RunAs" verb is set on the ProcessStartInfo, prompting UAC elevation. Has no effect if the current session is already elevated. .PARAMETER Minimized Start the process window in a minimised state. .PARAMETER NoSleep For PowerShell targets only – omits the post-execution Start-Sleep so the spawned window closes immediately. .PARAMETER ValidExitCodes Exit codes that are treated as success. Defaults to @(0). .PARAMETER WorkingDirectory Working directory for the launched process. Defaults to the current FileSystem provider path, falling back to $env:TEMP for UNC paths. .PARAMETER SensitiveStatements Additional arguments appended to the command line that must not be logged. .PARAMETER IgnoredArguments Catch-all for splatted arguments that do not apply to this cmdlet. .EXAMPLE Start-ProcessAsAdmin -Statements '/i "setup.msi" /qn' -ExeToRun 'msiexec' .EXAMPLE Start-ProcessAsAdmin -Statements '/S' -ExeToRun 'C:\installers\app.exe' -ValidExitCodes @(0, 3010) .EXAMPLE # Run a PowerShell script block with elevation $psFile = Join-Path $PSScriptRoot 'someInstall.ps1' Start-ProcessAsAdmin "& '$psFile'" .EXAMPLE # cmd.exe with spaces in path $appPath = "$env:ProgramFiles\myapp" Start-ProcessAsAdmin -Statements "/c `"$appPath\bin\install.bat`"" -ExeToRun 'cmd' .LINK https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.Int32])] param( [Parameter(Mandatory = $false, Position = 0)] [string[]] $Statements, [Parameter(Mandatory = $false, Position = 1)] [string] $ExeToRun = 'powershell', [Parameter(Mandatory = $false)] [switch] $Elevated, [Parameter(Mandatory = $false)] [switch] $Minimized, [Parameter(Mandatory = $false)] [switch] $NoSleep, [Parameter(Mandatory = $false)] [int[]] $ValidExitCodes = @(0), [Parameter(Mandatory = $false)] [string] $WorkingDirectory, [Parameter(Mandatory = $false)] [string] $SensitiveStatements = '', # Catch-all so callers can safely splat extra parameters. [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] [object[]] $IgnoredArguments ) process { $fxn = $MyInvocation.MyCommand.Name #region Resolve working directory if ([string]::IsNullOrEmpty($WorkingDirectory)) { $fsLocation = Get-Location -PSProvider FileSystem -ErrorAction SilentlyContinue if ($null -ne $fsLocation -and -not [string]::IsNullOrEmpty($fsLocation.ProviderPath)) { $WorkingDirectory = $fsLocation.ProviderPath } else { Write-Debug "$fxn : Current location is not a FileSystem path. Falling back to TEMP." $WorkingDirectory = $env:TEMP } } #endregion #region Elevation check $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) if (-not $isAdmin -and -not $Elevated) { Write-Error "`n$fxn : The current session is not elevated and -Elevated was not specified. `nRe-run as administrator or pass -Elevated to request UAC elevation." return } #endregion #region Sanitise inputs – strip null characters that can break argument passing try { if ($null -ne $ExeToRun) { $ExeToRun = $ExeToRun -replace "`0", '' } $Statements = $Statements | Where-Object { $null -ne $_ } | ForEach-Object { $_ -replace "`0", '' } } catch { Write-Debug "$fxn : Null-character removal failed – $($_.Exception.Message)" } if ($null -ne $ExeToRun) { $ExeToRun = $ExeToRun.Trim().Trim("'").Trim('"') } #endregion #region Resolve executable $isPowerShell = $ExeToRun -in @('powershell', 'pwsh') if (-not $isPowerShell) { # Expand common shorthand names if ($ExeToRun -in @('msiexec', 'msiexec.exe')) { $ExeToRun = Join-Path $env:SystemRoot 'System32\msiexec.exe' } if (-not [System.IO.File]::Exists($ExeToRun)) { # Try to locate via PATH before warning $resolved = Get-Command $ExeToRun -ErrorAction SilentlyContinue if ($resolved) { $ExeToRun = $resolved.Source } else { Write-Warning "$fxn : Cannot verify '$ExeToRun' exists. Ensure the full path is supplied." } } # Reject disguised text files $isTextMarker = [System.IO.Path]::GetFullPath($ExeToRun) + '.istext' if ([System.IO.File]::Exists($isTextMarker)) { throw "$fxn : '$ExeToRun' appears to be a text file masquerading as an executable." } } #endregion #region Build final arguments $joinedStatements = ($Statements -join ' ') $wrappedStatements = $joinedStatements $dbMessagePrepend = if ($Elevated) { 'Elevating permissions and running' } else { 'Running' } if ($isPowerShell) { # Prefer pwsh (PS 7+) when asked, or fall back to Windows PowerShell if ($ExeToRun -eq 'pwsh') { $resolvedPwsh = Get-Command 'pwsh' -ErrorAction SilentlyContinue $ExeToRun = if ($resolvedPwsh) { $resolvedPwsh.Source } else { Write-Warning "$fxn : 'pwsh' not found on PATH; falling back to Windows PowerShell." Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' } } else { $ExeToRun = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' } $sleepOnSuccess = if ($NoSleep) { '' } else { 'Start-Sleep -Seconds 6' } $sleepOnError = if ($NoSleep) { '' } else { 'Start-Sleep -Seconds 8' } $block = @" `$ProgressPreference = 'SilentlyContinue' try { $joinedStatements $sleepOnSuccess } catch { $sleepOnError throw } "@ $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($block)) $wrappedStatements = "-NoLogo -NonInteractive -NoProfile -ExecutionPolicy Bypass -InputFormat Text -OutputFormat Text -EncodedCommand $encoded" Write-Debug @" $dbMessagePrepend PowerShell block: $block "@ } else { Write-Debug "$dbMessagePrepend [`"$ExeToRun`" $wrappedStatements]" } #endregion #region Configure ProcessStartInfo $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = $ExeToRun $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.RedirectStandardInput = $true # prevents the child from blocking on stdin $psi.CreateNoWindow = $false $psi.WorkingDirectory = $WorkingDirectory # UTF-8 to avoid mojibake from tools that emit non-ASCII characters $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 # Argument passing – prefer ArgumentList (PS 6.1+ / .NET Core 2.1+) to avoid # manual shell-quoting bugs; fall back to the Arguments string otherwise. $allArgs = @() if (-not [string]::IsNullOrEmpty($wrappedStatements)) { $allArgs += $wrappedStatements } if (-not [string]::IsNullOrEmpty($SensitiveStatements)) { $allArgs += $SensitiveStatements # not logged above } if ($psi.ArgumentList -is [System.Collections.Generic.ICollection[string]]) { # Structured argument list avoids double-quoting issues on .NET Core+ foreach ($arg in $allArgs) { $psi.ArgumentList.Add($arg) } } else { # Legacy path – escape and join $escapedArgs = $allArgs | ForEach-Object { $s = $_ -replace '(\\+)"', '$1$1"' # escaped backslash before quote $s = $s -replace '(\\+)$', '$1$1' # trailing backslashes $s = $s -replace '"', '\"' # literal double-quotes "`"$s`"" } $psi.Arguments = $escapedArgs -join ' ' } # Elevation if ($Elevated -and -not $isAdmin -and [Environment]::OSVersion.Version -ge [Version]'6.0') { Write-Debug "$fxn : Setting RunAs verb for UAC elevation." $psi.Verb = 'RunAs' # RunAs requires ShellExecute; adjust accordingly $psi.UseShellExecute = $true $psi.RedirectStandardOutput = $false $psi.RedirectStandardError = $false $psi.RedirectStandardInput = $false } if ($Minimized) { $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Minimized } #endregion #region Launch and wait if (-not $PSCmdlet.ShouldProcess("$ExeToRun $wrappedStatements", 'Start process')) { return } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $psi try { [void]$process.Start() } catch { throw "$fxn : Failed to start '$ExeToRun'. $_" } $exitCode = 0 if ($psi.RedirectStandardOutput) { # ReadToEndAsync avoids the deadlock that can occur when mixing # synchronous reads with WaitForExit on Windows (output/error buffers fill # up and the child blocks waiting for us to drain them). $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() [void]$process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult().Trim() $stderr = $stderrTask.GetAwaiter().GetResult().Trim() if (-not [string]::IsNullOrEmpty($stdout)) { Write-Verbose $stdout } if (-not [string]::IsNullOrEmpty($stderr)) { # Write to the error stream but don't throw – callers use ValidExitCodes $Host.UI.WriteErrorLine($stderr) } } else { # ShellExecute / elevated path: no stream redirection available [void]$process.WaitForExit() } $exitCode = $process.ExitCode $process.Dispose() Write-Debug "$fxn : [`"$ExeToRun`" $wrappedStatements] exited with '$exitCode'." #endregion #region Exit code interpretation # Map well-known installer exit codes to human-readable messages. # Sources: # NSIS – http://nsis.sourceforge.net/Docs/AppendixD.html # InnoSetup – https://jrsoftware.org/ishelp/index.php?topic=setupexitcodes # MSI – https://learn.microsoft.com/en-us/windows/win32/msi/error-codes $knownExitMessages = @{ 2 = 'Setup was cancelled.' 3 = 'A fatal error occurred when preparing or moving to next install phase. Ensure sufficient memory is available and retry.' 4 = 'A fatal error occurred during the installation process.' 5 = 'User cancelled the installation.' 6 = 'Setup process was forcefully terminated by a debugger.' 7 = 'Setup determined it cannot proceed with the installation. Verify system requirements.' 8 = 'Setup requires a system restart before proceeding. Reboot and retry.' 1602 = 'User cancelled the installation (MSI).' 1603 = 'Generic MSI error. A pending reboot may be required, or the same version is already installed.' 1618 = 'Another installation is currently in progress. Retry later.' 1619 = 'MSI package could not be found or is corrupt.' 1620 = 'MSI package could not be opened or is corrupt.' 1622 = 'Invalid log file path specified in install arguments.' 1623 = 'This MSI does not support the system locale.' 1625 = 'Installation forbidden by system policy.' 1632 = 'Installation not supported on this platform or architecture.' 1633 = 'Installation not supported on this platform or architecture.' 1638 = 'A different version of this product is already installed; uninstall it first.' 1639 = 'Invalid command-line arguments passed to the MSI.' 1640 = 'Cannot install MSI from a Remote Desktop (terminal services) session.' 1645 = 'Cannot install MSI from a Remote Desktop (terminal services) session.' } $exitErrorMessage = $knownExitMessages[$exitCode] if ($exitErrorMessage) { Write-Warning "$fxn : Exit code $exitCode – $exitErrorMessage" } if ($ValidExitCodes -notcontains $exitCode) { $detail = if ($exitErrorMessage) { "Exit code indicates: $exitErrorMessage" } else { 'See verbose/error output for details.' } throw "$fxn : Process [`"$ExeToRun`"] exited with code '$exitCode'. $detail" } #endregion Write-Debug "$fxn : Completed successfully." return $exitCode } } |