Skip to content

Commit 81933de

Browse files
authored
fix: Keep dynamic code valid in Windows PowerShell 5.1 (#1827)
1 parent 4ace5b4 commit 81933de

5 files changed

Lines changed: 193 additions & 6 deletions

File tree

.github/workflows/build-and-test.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,19 @@ jobs:
143143
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/install.ps1))
144144
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/check-release-installer.ps1))
145145
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-check-release-installer.ps1))
146+
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/run-windows-e2e.ps1))
147+
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-simulate-mouse-demo.ps1))
148+
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1))
149+
$null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-powershell51-native-argument-escaping.ps1))
146150
147151
- name: Test release installer smoke helper
148152
shell: powershell
149153
run: .\scripts\test-check-release-installer.ps1
150154

155+
- name: Test Windows PowerShell native argument escaping
156+
shell: powershell
157+
run: .\scripts\test-powershell51-native-argument-escaping.ps1
158+
151159
test-unity-package:
152160
runs-on: ubuntu-latest
153161
needs: build-cli

Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,40 @@ function Get-UloopArguments {
6363
return @($CommandArguments + @("--project-path", $ResolvedProjectPath))
6464
}
6565

66+
function ConvertTo-WindowsPowerShellNativeArgument {
67+
param(
68+
[string]$Argument
69+
)
70+
71+
# Windows argv rules treat backslashes specially only before a quote or the closing quote.
72+
# Do not double every backslash: ordinary paths such as C:\Users must stay unchanged.
73+
[string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', {
74+
param($match)
75+
76+
[string]$backslashes = $match.Groups[1].Value
77+
return $backslashes + $backslashes + '\"'
78+
})
79+
80+
if ($Argument -notmatch '\s') {
81+
return $escapedArgument
82+
}
83+
84+
return [regex]::Replace($escapedArgument, '(\\*)\z', {
85+
param($match)
86+
87+
[string]$backslashes = $match.Groups[1].Value
88+
return $backslashes + $backslashes
89+
})
90+
}
91+
6692
function Invoke-UloopCapture {
6793
param(
6894
[string[]]$CommandArguments
6995
)
7096

7197
[string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments
7298
if ($PSVersionTable.PSVersion.Major -lt 6) {
73-
# Windows PowerShell strips embedded quote characters from native arguments unless they are escaped.
74-
$arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') })
99+
$arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ })
75100
}
76101

77102
[string]$previousErrorActionPreference = $ErrorActionPreference

scripts/run-windows-e2e.ps1

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,40 @@ function Get-UloopArguments {
5454
return $arguments
5555
}
5656

57+
function ConvertTo-WindowsPowerShellNativeArgument {
58+
param(
59+
[string]$Argument
60+
)
61+
62+
# Windows argv rules treat backslashes specially only before a quote or the closing quote.
63+
# Do not double every backslash: ordinary paths such as C:\Users must stay unchanged.
64+
[string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', {
65+
param($match)
66+
67+
[string]$backslashes = $match.Groups[1].Value
68+
return $backslashes + $backslashes + '\"'
69+
})
70+
71+
if ($Argument -notmatch '\s') {
72+
return $escapedArgument
73+
}
74+
75+
return [regex]::Replace($escapedArgument, '(\\*)\z', {
76+
param($match)
77+
78+
[string]$backslashes = $match.Groups[1].Value
79+
return $backslashes + $backslashes
80+
})
81+
}
82+
5783
function Invoke-UloopCapture {
5884
param(
5985
[string[]]$CommandArguments
6086
)
6187

6288
[string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments
6389
if ($PSVersionTable.PSVersion.Major -lt 6) {
64-
# Windows PowerShell strips embedded quotes before native processes see them.
65-
$arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') })
90+
$arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ })
6691
}
6792

6893
[string]$previousErrorActionPreference = $ErrorActionPreference
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<#
2+
Verifies that Windows PowerShell native argument escaping preserves backslashes
3+
while protecting embedded quotation marks for execute-dynamic-code requests.
4+
#>
5+
6+
Set-StrictMode -Version Latest
7+
$ErrorActionPreference = "Stop"
8+
9+
[string[]]$ScriptPaths = @(
10+
"scripts/run-windows-e2e.ps1",
11+
"scripts/test-simulate-mouse-demo.ps1",
12+
"Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1"
13+
)
14+
15+
function Get-FunctionDefinition {
16+
param(
17+
[string]$ScriptPath,
18+
[string]$FunctionName
19+
)
20+
21+
[string]$source = Get-Content -LiteralPath $ScriptPath -Raw -Encoding UTF8
22+
[string]$marker = "function $FunctionName"
23+
[int]$start = $source.IndexOf($marker, [System.StringComparison]::Ordinal)
24+
if ($start -lt 0) {
25+
throw "${ScriptPath} does not define ${FunctionName}."
26+
}
27+
28+
[int]$openBrace = $source.IndexOf("{", $start, [System.StringComparison]::Ordinal)
29+
[int]$depth = 0
30+
for ([int]$index = $openBrace; $index -lt $source.Length; $index++) {
31+
if ($source[$index] -eq "{") {
32+
$depth++
33+
}
34+
elseif ($source[$index] -eq "}") {
35+
$depth--
36+
if ($depth -eq 0) {
37+
return $source.Substring($start, $index - $start + 1)
38+
}
39+
}
40+
}
41+
42+
throw "${ScriptPath} has an unclosed ${FunctionName} definition."
43+
}
44+
45+
function Assert-Equal {
46+
param(
47+
[string]$Actual,
48+
[string]$Expected,
49+
[string]$Context
50+
)
51+
52+
if ($Actual -ne $Expected) {
53+
throw "${Context}: expected [$Expected], actual [$Actual]."
54+
}
55+
}
56+
57+
function Invoke-WindowsPowerShellNativeArgumentRoundTrip {
58+
param(
59+
[string]$Argument
60+
)
61+
62+
[string]$childScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("uloop-native-argument-round-trip-" + [Guid]::NewGuid().ToString("N") + ".ps1")
63+
Set-Content -LiteralPath $childScriptPath -Value '[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($args[0])))' -Encoding UTF8
64+
[string[]]$childArguments = @("-NoProfile", "-File", $childScriptPath, $Argument)
65+
[string[]]$escapedArguments = @($childArguments | ForEach-Object {
66+
ConvertTo-WindowsPowerShellNativeArgument -Argument $_
67+
})
68+
[string]$encodedOutput = & powershell.exe @escapedArguments
69+
if ($LASTEXITCODE -ne 0) {
70+
Remove-Item -LiteralPath $childScriptPath
71+
throw "Windows PowerShell native argument round trip failed."
72+
}
73+
74+
Remove-Item -LiteralPath $childScriptPath
75+
return [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedOutput))
76+
}
77+
78+
[string[]]$definitions = @($ScriptPaths | ForEach-Object {
79+
Get-FunctionDefinition -ScriptPath $_ -FunctionName "ConvertTo-WindowsPowerShellNativeArgument"
80+
})
81+
82+
foreach ($definition in @($definitions | Select-Object -Skip 1)) {
83+
Assert-Equal -Actual $definition -Expected $definitions[0] -Context "All E2E scripts must use the same native argument escaping implementation"
84+
}
85+
86+
[string]$temporaryFunctionPath = Join-Path ([System.IO.Path]::GetTempPath()) "uloop-native-argument-escaping.ps1"
87+
Set-Content -LiteralPath $temporaryFunctionPath -Value $definitions[0] -Encoding UTF8
88+
. $temporaryFunctionPath
89+
Remove-Item -LiteralPath $temporaryFunctionPath
90+
91+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\"') -Expected '\\\"' -Context "Existing escaped quote"
92+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example Name\') -Expected 'C:\Users\Example Name\\' -Context "Trailing backslash in quoted argument"
93+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\\\"') -Expected '\\\\\\\"' -Context "Consecutive backslashes before quote"
94+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\Project') -Expected 'C:\Users\Example\Project' -Context "Ordinary Windows path"
95+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '日本語\パス"値') -Expected '日本語\パス\"値' -Context "Japanese text"
96+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\' -Context "Unquoted path with trailing backslash"
97+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\') -Expected '\' -Context "Unquoted standalone backslash"
98+
Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument "C:\Users\Example\`n") -Expected "C:\Users\Example\`n" -Context "Trailing backslash before line feed"
99+
if ($PSVersionTable.PSVersion.Major -lt 6) {
100+
Assert-Equal -Actual (Invoke-WindowsPowerShellNativeArgumentRoundTrip -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\' -Context "Native process receives unquoted path with trailing backslash"
101+
Assert-Equal -Actual (Invoke-WindowsPowerShellNativeArgumentRoundTrip -Argument 'C:\Users\Example Name\') -Expected 'C:\Users\Example Name\' -Context "Native process receives quoted path with trailing backslash"
102+
}
103+
104+
Write-Host "PowerShell native argument escaping tests passed."

scripts/test-simulate-mouse-demo.ps1

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,40 @@ function Get-UloopArguments {
3434
return @($CommandArguments + @("--project-path", $ProjectPath))
3535
}
3636

37+
function ConvertTo-WindowsPowerShellNativeArgument {
38+
param(
39+
[string]$Argument
40+
)
41+
42+
# Windows argv rules treat backslashes specially only before a quote or the closing quote.
43+
# Do not double every backslash: ordinary paths such as C:\Users must stay unchanged.
44+
[string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', {
45+
param($match)
46+
47+
[string]$backslashes = $match.Groups[1].Value
48+
return $backslashes + $backslashes + '\"'
49+
})
50+
51+
if ($Argument -notmatch '\s') {
52+
return $escapedArgument
53+
}
54+
55+
return [regex]::Replace($escapedArgument, '(\\*)\z', {
56+
param($match)
57+
58+
[string]$backslashes = $match.Groups[1].Value
59+
return $backslashes + $backslashes
60+
})
61+
}
62+
3763
function Invoke-UloopCapture {
3864
param(
3965
[string[]]$CommandArguments
4066
)
4167

4268
[string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments
4369
if ($PSVersionTable.PSVersion.Major -lt 6) {
44-
# Windows PowerShell strips embedded quote characters from native arguments unless they are escaped.
45-
$arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') })
70+
$arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ })
4671
}
4772

4873
[string]$previousErrorActionPreference = $ErrorActionPreference

0 commit comments

Comments
 (0)