mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-10-15 18:22:01 +00:00
fix(launcher): quote argument values passed on by Get.ps1 (#793)
This commit is contained in:
+25
-8
@@ -196,15 +196,32 @@ if (Test-Path "$backupDir") {
|
||||
Remove-Item "$backupDir" -Recurse -Force
|
||||
}
|
||||
|
||||
# Make list of arguments to pass on to the script (exclude the -Dev switch, which only affects this launcher)
|
||||
$arguments = $($PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -ne 'Dev' } | ForEach-Object {
|
||||
if ($_.Value -eq $true) {
|
||||
"-$($_.Key)"
|
||||
}
|
||||
else {
|
||||
"-$($_.Key) ""$($_.Value)"""
|
||||
# Quote a value so it survives CommandLineToArgvW parsing in the child process: double any backslash run that
|
||||
# precedes a quote or the closing quote, and escape embedded quotes, before wrapping the value in quotes.
|
||||
function Format-LauncherArg([string]$Value) {
|
||||
$escaped = $Value -replace '(\\*)"', '$1$1\"'
|
||||
$escaped = $escaped -replace '(\\+)$', '$1$1'
|
||||
return '"' + $escaped + '"'
|
||||
}
|
||||
|
||||
# Build the list of arguments to pass on to the script (excludes the -Dev switch, which only affects this launcher).
|
||||
# Takes the bound parameters as input so it can be tested without running the launcher.
|
||||
function Get-LauncherArgumentList([System.Collections.IDictionary]$BoundParameters) {
|
||||
foreach ($entry in $BoundParameters.GetEnumerator()) {
|
||||
if ($entry.Key -eq 'Dev') { continue }
|
||||
|
||||
if ($entry.Value -is [System.Management.Automation.SwitchParameter]) {
|
||||
if ($entry.Value.IsPresent) {
|
||||
"-$($entry.Key)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
"-$($entry.Key) $(Format-LauncherArg ([string]$entry.Value))"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$arguments = @(Get-LauncherArgumentList $PSBoundParameters)
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "> Launching Win11Debloat..."
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
BeforeAll {
|
||||
# Splits a command line the way the Windows C runtime and CommandLineToArgvW do for the
|
||||
# strings Format-LauncherArg produces (documented in "Parsing C++ command-line arguments").
|
||||
# It is written in plain PowerShell on purpose: loading shell32 through Add-Type compiles code at
|
||||
# run time, which antivirus products tend to flag.
|
||||
function Split-WindowsCommandLine {
|
||||
param([string]$CommandLine)
|
||||
|
||||
$result = [System.Collections.Generic.List[string]]::new()
|
||||
$length = $CommandLine.Length
|
||||
$i = 0
|
||||
while ($i -lt $length) {
|
||||
while ($i -lt $length -and ($CommandLine[$i] -eq ' ' -or $CommandLine[$i] -eq "`t")) { $i++ }
|
||||
if ($i -ge $length) { break }
|
||||
|
||||
$current = [System.Text.StringBuilder]::new()
|
||||
$inQuotes = $false
|
||||
while ($i -lt $length) {
|
||||
$char = $CommandLine[$i]
|
||||
if ($char -eq '\') {
|
||||
$backslashes = 0
|
||||
while ($i -lt $length -and $CommandLine[$i] -eq '\') { $backslashes++; $i++ }
|
||||
if ($i -lt $length -and $CommandLine[$i] -eq '"') {
|
||||
# 2n backslashes before a quote give n backslashes and the quote toggles quoting;
|
||||
# 2n+1 give n backslashes and a literal quote.
|
||||
[void]$current.Append([string]::new([char]'\', [int][math]::Floor($backslashes / 2)))
|
||||
if ($backslashes % 2 -eq 1) { [void]$current.Append('"'); $i++ }
|
||||
}
|
||||
else {
|
||||
[void]$current.Append([string]::new([char]'\', $backslashes))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($char -eq '"') {
|
||||
# CommandLineToArgvW: inside quotes, a doubled quote is one literal quote and ends quoting.
|
||||
if ($inQuotes -and ($i + 1) -lt $length -and $CommandLine[$i + 1] -eq '"') {
|
||||
[void]$current.Append('"')
|
||||
$inQuotes = $false
|
||||
$i += 2
|
||||
}
|
||||
else {
|
||||
$inQuotes = -not $inQuotes
|
||||
$i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (-not $inQuotes -and ($char -eq ' ' -or $char -eq "`t")) { break }
|
||||
[void]$current.Append($char)
|
||||
$i++
|
||||
}
|
||||
$result.Add($current.ToString())
|
||||
}
|
||||
|
||||
, $result.ToArray()
|
||||
}
|
||||
|
||||
# Load only the argument functions from the launcher; dot-sourcing Get.ps1 would download and run the script.
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '..\Scripts\Get.ps1'), [ref]$tokens, [ref]$parseErrors)
|
||||
$parseErrors | Should -BeNullOrEmpty
|
||||
foreach ($functionName in 'Format-LauncherArg', 'Get-LauncherArgumentList') {
|
||||
$function = $ast.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName
|
||||
}, $true)
|
||||
$function | Should -Not -BeNullOrEmpty
|
||||
. ([scriptblock]::Create($function.Extent.Text))
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Split-WindowsCommandLine (test helper)' {
|
||||
# Examples from Microsoft's "Parsing C++ command-line arguments" page. They pin the helper to the
|
||||
# documented rules, so the round-trip tests below are not just checking the helper against itself.
|
||||
It 'parses <CommandLine> as documented' -ForEach @(
|
||||
@{ CommandLine = '"abc" d e'; Expected = @('abc', 'd', 'e') }
|
||||
@{ CommandLine = 'a\\\b d"e f"g h'; Expected = @('a\\\b', 'de fg', 'h') }
|
||||
@{ CommandLine = 'a\\\"b c d'; Expected = @('a\"b', 'c', 'd') }
|
||||
@{ CommandLine = 'a\\\\"b c" d e'; Expected = @('a\\b c', 'd', 'e') }
|
||||
) {
|
||||
$parsed = Split-WindowsCommandLine $CommandLine
|
||||
|
||||
$parsed | Should -HaveCount $Expected.Count
|
||||
for ($index = 0; $index -lt $Expected.Count; $index++) {
|
||||
$parsed[$index] | Should -BeExactly $Expected[$index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Format-LauncherArg' {
|
||||
It 'round-trips <Value> as a single argument' -ForEach @(
|
||||
@{ Value = 'plain' }
|
||||
@{ Value = 'C:\Log Files\' }
|
||||
@{ Value = 'C:\Log Files\\' }
|
||||
@{ Value = 'say "hi"' }
|
||||
@{ Value = 'Microsoft.A, "B"' }
|
||||
@{ Value = 'tail\"quote' }
|
||||
@{ Value = 'C:\a b\c d' }
|
||||
@{ Value = '' }
|
||||
) {
|
||||
$parsed = Split-WindowsCommandLine ('launcher.exe -Param ' + (Format-LauncherArg $Value))
|
||||
|
||||
$parsed | Should -HaveCount 3
|
||||
$parsed[1] | Should -BeExactly '-Param'
|
||||
$parsed[2] | Should -BeExactly $Value
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-LauncherArgumentList' {
|
||||
It 'quotes string values, forwards present switches and drops absent switches and -Dev' {
|
||||
$arguments = @(Get-LauncherArgumentList ([ordered]@{
|
||||
LogPath = 'C:\Log Files\'
|
||||
Apps = 'Microsoft.A, "B"'
|
||||
Silent = [switch]$true
|
||||
DisableTelemetry = [switch]$false
|
||||
Dev = [switch]$true
|
||||
}))
|
||||
|
||||
$arguments | Should -HaveCount 3
|
||||
$parsed = Split-WindowsCommandLine ('launcher.exe ' + ($arguments -join ' '))
|
||||
$parsed | Should -HaveCount 6
|
||||
$parsed[0] | Should -BeExactly 'launcher.exe'
|
||||
$parsed[1] | Should -BeExactly '-LogPath'
|
||||
$parsed[2] | Should -BeExactly 'C:\Log Files\'
|
||||
$parsed[3] | Should -BeExactly '-Apps'
|
||||
$parsed[4] | Should -BeExactly 'Microsoft.A, "B"'
|
||||
$parsed[5] | Should -BeExactly '-Silent'
|
||||
($arguments -join ' ') | Should -Not -Match 'DisableTelemetry|Dev'
|
||||
}
|
||||
|
||||
It 'returns nothing when there are no parameters' {
|
||||
@(Get-LauncherArgumentList @{}) | Should -HaveCount 0
|
||||
}
|
||||
|
||||
It 'keeps <Case> as a string value alongside a switch' -ForEach @(
|
||||
@{ Case = 'True'; Value = 'True' }
|
||||
@{ Case = 'False'; Value = 'False' }
|
||||
@{ Case = 'Unicode'; Value = ([string][char]0x65E5 + [char]0x672C) }
|
||||
@{ Case = 'a quoted switch-like value'; Value = 'app" -DisableTelemetry "name' }
|
||||
) {
|
||||
$arguments = @(Get-LauncherArgumentList ([ordered]@{
|
||||
Apps = $Value
|
||||
Silent = [switch]$true
|
||||
}))
|
||||
|
||||
$arguments | Should -HaveCount 2
|
||||
$parsed = Split-WindowsCommandLine ('launcher.exe ' + ($arguments -join ' '))
|
||||
$parsed | Should -HaveCount 4
|
||||
$parsed[1] | Should -BeExactly '-Apps'
|
||||
$parsed[2] | Should -BeExactly $Value
|
||||
$parsed[3] | Should -BeExactly '-Silent'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Launcher arguments through powershell.exe -File' {
|
||||
BeforeAll {
|
||||
# Run only a harmless capture script in TestDrive, never the downloading/elevating launcher.
|
||||
$captureScriptPath = Join-Path $TestDrive 'Capture launcher arguments.ps1'
|
||||
@'
|
||||
param(
|
||||
[string]$LogPath,
|
||||
[string]$Apps,
|
||||
[string]$User,
|
||||
[string]$Language,
|
||||
[string]$Config,
|
||||
[switch]$Silent,
|
||||
[switch]$DisableTelemetry
|
||||
)
|
||||
$captured = [ordered]@{
|
||||
LogPath = $LogPath
|
||||
Apps = $Apps
|
||||
User = $User
|
||||
Language = $Language
|
||||
Config = $Config
|
||||
Silent = $Silent.IsPresent
|
||||
Keys = @($PSBoundParameters.Keys)
|
||||
Extra = @($args)
|
||||
}
|
||||
# Base64 keeps Unicode independent of the child console's output encoding.
|
||||
[Console]::WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($captured | ConvertTo-Json -Compress))))
|
||||
'@ | Set-Content -LiteralPath $captureScriptPath -Encoding UTF8
|
||||
}
|
||||
|
||||
It 'preserves strings and switch boundaries when User is <BooleanText>' -ForEach @(
|
||||
@{ BooleanText = 'True' }
|
||||
@{ BooleanText = 'False' }
|
||||
) {
|
||||
$boundParameters = [ordered]@{
|
||||
LogPath = 'C:\Log Files\\'
|
||||
Apps = 'app\" -DisableTelemetry "name'
|
||||
User = $BooleanText
|
||||
Language = ([string][char]0x65E5 + [char]0x672C)
|
||||
Config = ''
|
||||
Silent = [switch]$true
|
||||
DisableTelemetry = [switch]$false
|
||||
Dev = [switch]$true
|
||||
}
|
||||
$arguments = @(Get-LauncherArgumentList $boundParameters)
|
||||
$process = [System.Diagnostics.Process]::new()
|
||||
$process.StartInfo.FileName = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
# Match the launcher's array interpolation at the real native command-line boundary.
|
||||
$process.StartInfo.Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File $(Format-LauncherArg $captureScriptPath) $arguments"
|
||||
$process.StartInfo.UseShellExecute = $false
|
||||
$process.StartInfo.CreateNoWindow = $true
|
||||
$process.StartInfo.RedirectStandardOutput = $true
|
||||
$process.StartInfo.RedirectStandardError = $true
|
||||
try {
|
||||
$process.Start() | Should -BeTrue
|
||||
if (-not $process.WaitForExit(15000)) {
|
||||
$process.Kill()
|
||||
throw 'Launcher argument capture timed out.'
|
||||
}
|
||||
$stdout = $process.StandardOutput.ReadToEnd().Trim()
|
||||
$stderr = $process.StandardError.ReadToEnd()
|
||||
$process.ExitCode | Should -Be 0 -Because $stderr
|
||||
$stderr | Should -BeNullOrEmpty
|
||||
$stdout | Should -Not -BeNullOrEmpty
|
||||
$captured = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($stdout)) | ConvertFrom-Json
|
||||
|
||||
foreach ($parameterName in 'LogPath', 'Apps', 'User', 'Language', 'Config') {
|
||||
$captured.$parameterName | Should -BeExactly $boundParameters[$parameterName]
|
||||
$captured.Keys | Should -Contain $parameterName
|
||||
}
|
||||
$captured.Silent | Should -BeTrue
|
||||
$captured.Keys | Should -Contain 'Silent'
|
||||
$captured.Keys | Should -HaveCount 6
|
||||
$captured.Keys | Should -Not -Contain 'DisableTelemetry'
|
||||
$captured.Keys | Should -Not -Contain 'Dev'
|
||||
$captured.Extra | Should -BeNullOrEmpty
|
||||
}
|
||||
finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user