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..bba73ce11 100644 --- a/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 +++ b/Assets/Tests/Demo/scripts/verify-replay-via-cli.ps1 @@ -63,6 +63,32 @@ 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 + '\"' + }) + + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -70,8 +96,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..4d91cbc0c 100644 --- a/scripts/run-windows-e2e.ps1 +++ b/scripts/run-windows-e2e.ps1 @@ -54,6 +54,32 @@ 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 + '\"' + }) + + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -61,8 +87,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..26965acea --- /dev/null +++ b/scripts/test-powershell51-native-argument-escaping.ps1 @@ -0,0 +1,104 @@ +<# +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]." + } +} + +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" +}) + +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 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 c07c537b8..a4d80232d 100644 --- a/scripts/test-simulate-mouse-demo.ps1 +++ b/scripts/test-simulate-mouse-demo.ps1 @@ -34,6 +34,32 @@ 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 + '\"' + }) + + if ($Argument -notmatch '\s') { + return $escapedArgument + } + + return [regex]::Replace($escapedArgument, '(\\*)\z', { + param($match) + + [string]$backslashes = $match.Groups[1].Value + return $backslashes + $backslashes + }) +} + function Invoke-UloopCapture { param( [string[]]$CommandArguments @@ -41,8 +67,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