From f1010f7178f57807e16be7fc5a5bde9c5aa3c22b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 18 Jul 2026 09:18:35 +0900 Subject: [PATCH 1/2] Fix PowerShell 5.1 native argument escaping Encode backslashes according to Windows argv rules so existing escaped C# quotes remain valid while ordinary Windows paths are preserved. Add a Windows PowerShell regression test and run it in CI to keep the three E2E scripts synchronized. --- .github/workflows/build-and-test.yml | 8 ++ .../Demo/scripts/verify-replay-via-cli.ps1 | 25 +++++- scripts/run-windows-e2e.ps1 | 25 +++++- ...-powershell51-native-argument-escaping.ps1 | 76 +++++++++++++++++++ scripts/test-simulate-mouse-demo.ps1 | 25 +++++- 5 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 scripts/test-powershell51-native-argument-escaping.ps1 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 8cd715abe..49a658bf0 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -143,11 +143,19 @@ jobs: $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/install.ps1)) $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/check-release-installer.ps1)) $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-check-release-installer.ps1)) + $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/run-windows-e2e.ps1)) + $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-simulate-mouse-demo.ps1)) + $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1)) + $null = [scriptblock]::Create((Get-Content -Raw -Encoding UTF8 scripts/test-powershell51-native-argument-escaping.ps1)) - name: Test release installer smoke helper shell: powershell run: .\scripts\test-check-release-installer.ps1 + - name: Test Windows PowerShell native argument escaping + shell: powershell + run: .\scripts\test-powershell51-native-argument-escaping.ps1 + test-unity-package: runs-on: ubuntu-latest needs: build-cli diff --git a/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 b/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 index 81f41b813..36fdab9be 100644 --- a/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 +++ b/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 @@ -63,6 +63,28 @@ function Get-UloopArguments { return @($CommandArguments + @("--project-path", $ResolvedProjectPath)) } +function ConvertTo-WindowsPowerShellNativeArgument { + param( + [string]$Argument + ) + + # Windows argv rules treat backslashes specially only before a quote or the closing quote. + # Do not double every backslash: ordinary paths such as C:\Users must stay unchanged. + [string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + '\"' + }) + + return [regex]::Replace($escapedArgument, '(\\*)$', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -70,8 +92,7 @@ function Invoke-UloopCapture { [string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments if ($PSVersionTable.PSVersion.Major -lt 6) { - # Windows PowerShell strips embedded quote characters from native arguments unless they are escaped. - $arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') }) + $arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ }) } [string]$previousErrorActionPreference = $ErrorActionPreference diff --git a/scripts/run-windows-e2e.ps1 b/scripts/run-windows-e2e.ps1 index 5664c7ac5..f3a7934c8 100644 --- a/scripts/run-windows-e2e.ps1 +++ b/scripts/run-windows-e2e.ps1 @@ -54,6 +54,28 @@ function Get-UloopArguments { return $arguments } +function ConvertTo-WindowsPowerShellNativeArgument { + param( + [string]$Argument + ) + + # Windows argv rules treat backslashes specially only before a quote or the closing quote. + # Do not double every backslash: ordinary paths such as C:\Users must stay unchanged. + [string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + '\"' + }) + + return [regex]::Replace($escapedArgument, '(\\*)$', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -61,8 +83,7 @@ function Invoke-UloopCapture { [string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments if ($PSVersionTable.PSVersion.Major -lt 6) { - # Windows PowerShell strips embedded quotes before native processes see them. - $arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') }) + $arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ }) } [string]$previousErrorActionPreference = $ErrorActionPreference diff --git a/scripts/test-powershell51-native-argument-escaping.ps1 b/scripts/test-powershell51-native-argument-escaping.ps1 new file mode 100644 index 000000000..cb78e9c94 --- /dev/null +++ b/scripts/test-powershell51-native-argument-escaping.ps1 @@ -0,0 +1,76 @@ +<# +Verifies that Windows PowerShell native argument escaping preserves backslashes +while protecting embedded quotation marks for execute-dynamic-code requests. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +[string[]]$ScriptPaths = @( + "scripts/run-windows-e2e.ps1", + "scripts/test-simulate-mouse-demo.ps1", + "Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1" +) + +function Get-FunctionDefinition { + param( + [string]$ScriptPath, + [string]$FunctionName + ) + + [string]$source = Get-Content -LiteralPath $ScriptPath -Raw -Encoding UTF8 + [string]$marker = "function $FunctionName" + [int]$start = $source.IndexOf($marker, [System.StringComparison]::Ordinal) + if ($start -lt 0) { + throw "${ScriptPath} does not define ${FunctionName}." + } + + [int]$openBrace = $source.IndexOf("{", $start, [System.StringComparison]::Ordinal) + [int]$depth = 0 + for ([int]$index = $openBrace; $index -lt $source.Length; $index++) { + if ($source[$index] -eq "{") { + $depth++ + } + elseif ($source[$index] -eq "}") { + $depth-- + if ($depth -eq 0) { + return $source.Substring($start, $index - $start + 1) + } + } + } + + throw "${ScriptPath} has an unclosed ${FunctionName} definition." +} + +function Assert-Equal { + param( + [string]$Actual, + [string]$Expected, + [string]$Context + ) + + if ($Actual -ne $Expected) { + throw "${Context}: expected [$Expected], actual [$Actual]." + } +} + +[string[]]$definitions = @($ScriptPaths | ForEach-Object { + Get-FunctionDefinition -ScriptPath $_ -FunctionName "ConvertTo-WindowsPowerShellNativeArgument" +}) + +foreach ($definition in @($definitions | Select-Object -Skip 1)) { + Assert-Equal -Actual $definition -Expected $definitions[0] -Context "All E2E scripts must use the same native argument escaping implementation" +} + +[string]$temporaryFunctionPath = Join-Path ([System.IO.Path]::GetTempPath()) "uloop-native-argument-escaping.ps1" +Set-Content -LiteralPath $temporaryFunctionPath -Value $definitions[0] -Encoding UTF8 +. $temporaryFunctionPath +Remove-Item -LiteralPath $temporaryFunctionPath + +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\"') -Expected '\\\"' -Context "Existing escaped quote" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\\' -Context "Trailing backslash" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\\\"') -Expected '\\\\\\\"' -Context "Consecutive backslashes before quote" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\Project') -Expected 'C:\Users\Example\Project' -Context "Ordinary Windows path" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '日本語\パス"値') -Expected '日本語\パス\"値' -Context "Japanese text" + +Write-Host "PowerShell native argument escaping tests passed." diff --git a/scripts/test-simulate-mouse-demo.ps1 b/scripts/test-simulate-mouse-demo.ps1 index c07c537b8..c761c9eea 100644 --- a/scripts/test-simulate-mouse-demo.ps1 +++ b/scripts/test-simulate-mouse-demo.ps1 @@ -34,6 +34,28 @@ function Get-UloopArguments { return @($CommandArguments + @("--project-path", $ProjectPath)) } +function ConvertTo-WindowsPowerShellNativeArgument { + param( + [string]$Argument + ) + + # Windows argv rules treat backslashes specially only before a quote or the closing quote. + # Do not double every backslash: ordinary paths such as C:\Users must stay unchanged. + [string]$escapedArgument = [regex]::Replace($Argument, '(\\*)"', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + '\"' + }) + + return [regex]::Replace($escapedArgument, '(\\*)$', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -41,8 +63,7 @@ function Invoke-UloopCapture { [string[]]$arguments = Get-UloopArguments -CommandArguments $CommandArguments if ($PSVersionTable.PSVersion.Major -lt 6) { - # Windows PowerShell strips embedded quote characters from native arguments unless they are escaped. - $arguments = @($arguments | ForEach-Object { $_.Replace('"', '\"') }) + $arguments = @($arguments | ForEach-Object { ConvertTo-WindowsPowerShellNativeArgument -Argument $_ }) } [string]$previousErrorActionPreference = $ErrorActionPreference From 914a7100f4f61ff74ca4f4f51dae184c0c23ee9b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 18 Jul 2026 09:36:23 +0900 Subject: [PATCH 2/2] Preserve unquoted PowerShell 5.1 trailing backslashes Only double a trailing backslash when Windows PowerShell will quote an argument, and use the absolute regex end anchor so a final line feed is not treated as the string end. Cover the three regressions and verify actual child-process argument delivery. --- .../Demo/scripts/verify-replay-via-cli.ps1 | 6 +++- scripts/run-windows-e2e.ps1 | 6 +++- ...-powershell51-native-argument-escaping.ps1 | 30 ++++++++++++++++++- scripts/test-simulate-mouse-demo.ps1 | 6 +++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 b/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 index 36fdab9be..bba73ce11 100644 --- a/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 +++ b/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 @@ -77,7 +77,11 @@ function ConvertTo-WindowsPowerShellNativeArgument { return $backslashes + $backslashes + '\"' }) - return [regex]::Replace($escapedArgument, '(\\*)$', { + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { param($match) [string]$backslashes = $match.Groups[1].Value diff --git a/scripts/run-windows-e2e.ps1 b/scripts/run-windows-e2e.ps1 index f3a7934c8..4d91cbc0c 100644 --- a/scripts/run-windows-e2e.ps1 +++ b/scripts/run-windows-e2e.ps1 @@ -68,7 +68,11 @@ function ConvertTo-WindowsPowerShellNativeArgument { return $backslashes + $backslashes + '\"' }) - return [regex]::Replace($escapedArgument, '(\\*)$', { + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { param($match) [string]$backslashes = $match.Groups[1].Value diff --git a/scripts/test-powershell51-native-argument-escaping.ps1 b/scripts/test-powershell51-native-argument-escaping.ps1 index cb78e9c94..26965acea 100644 --- a/scripts/test-powershell51-native-argument-escaping.ps1 +++ b/scripts/test-powershell51-native-argument-escaping.ps1 @@ -54,6 +54,27 @@ function Assert-Equal { } } +function Invoke-WindowsPowerShellNativeArgumentRoundTrip { + param( + [string]$Argument + ) + + [string]$childScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("uloop-native-argument-round-trip-" + [Guid]::NewGuid().ToString("N") + ".ps1") + Set-Content -LiteralPath $childScriptPath -Value '[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($args[0])))' -Encoding UTF8 + [string[]]$childArguments = @("-NoProfile", "-File", $childScriptPath, $Argument) + [string[]]$escapedArguments = @($childArguments | ForEach-Object { + ConvertTo-WindowsPowerShellNativeArgument -Argument $_ + }) + [string]$encodedOutput = & powershell.exe @escapedArguments + if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $childScriptPath + throw "Windows PowerShell native argument round trip failed." + } + + Remove-Item -LiteralPath $childScriptPath + return [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedOutput)) +} + [string[]]$definitions = @($ScriptPaths | ForEach-Object { Get-FunctionDefinition -ScriptPath $_ -FunctionName "ConvertTo-WindowsPowerShellNativeArgument" }) @@ -68,9 +89,16 @@ Set-Content -LiteralPath $temporaryFunctionPath -Value $definitions[0] -Encoding Remove-Item -LiteralPath $temporaryFunctionPath Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\"') -Expected '\\\"' -Context "Existing escaped quote" -Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\\' -Context "Trailing backslash" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example Name\') -Expected 'C:\Users\Example Name\\' -Context "Trailing backslash in quoted argument" Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\\\"') -Expected '\\\\\\\"' -Context "Consecutive backslashes before quote" Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\Project') -Expected 'C:\Users\Example\Project' -Context "Ordinary Windows path" Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '日本語\パス"値') -Expected '日本語\パス\"値' -Context "Japanese text" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\' -Context "Unquoted path with trailing backslash" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument '\') -Expected '\' -Context "Unquoted standalone backslash" +Assert-Equal -Actual (ConvertTo-WindowsPowerShellNativeArgument -Argument "C:\Users\Example\`n") -Expected "C:\Users\Example\`n" -Context "Trailing backslash before line feed" +if ($PSVersionTable.PSVersion.Major -lt 6) { + Assert-Equal -Actual (Invoke-WindowsPowerShellNativeArgumentRoundTrip -Argument 'C:\Users\Example\') -Expected 'C:\Users\Example\' -Context "Native process receives unquoted path with trailing backslash" + Assert-Equal -Actual (Invoke-WindowsPowerShellNativeArgumentRoundTrip -Argument 'C:\Users\Example Name\') -Expected 'C:\Users\Example Name\' -Context "Native process receives quoted path with trailing backslash" +} Write-Host "PowerShell native argument escaping tests passed." diff --git a/scripts/test-simulate-mouse-demo.ps1 b/scripts/test-simulate-mouse-demo.ps1 index c761c9eea..a4d80232d 100644 --- a/scripts/test-simulate-mouse-demo.ps1 +++ b/scripts/test-simulate-mouse-demo.ps1 @@ -48,7 +48,11 @@ function ConvertTo-WindowsPowerShellNativeArgument { return $backslashes + $backslashes + '\"' }) - return [regex]::Replace($escapedArgument, '(\\*)$', { + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { param($match) [string]$backslashes = $match.Groups[1].Value