From c59e5043e655437671eb9e443e5c323bc03b9aab Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:13:04 +0700 Subject: [PATCH 1/2] Capture apksigner output without PowerShell wrapping --- .github/workflows/release-apk.yml | 1 + scripts/update-minimum-device.ps1 | 59 ++++++++++++++++++++++++--- tests/update-minimum-device.Tests.ps1 | 22 +++++++++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 11252ce3..46a52dec 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -148,6 +148,7 @@ jobs: throw "Updater failed PowerShell AST parsing." } ./tools/verify-cellular-policy.ps1 + $env:MINIMUM_TEST_SIGNED_APK = (Resolve-Path 'app/build/outputs/apk/foss/release/mumla-foss-release.apk').Path ./tests/update-minimum-device.Tests.ps1 - name: Build temporary Wi-Fi provisioner run: | diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index f48eac10..ae32ec3e 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -374,13 +374,60 @@ function Parse-ApkSignerOutput { function Get-ApkSignerDigests { param([Parameter(Mandatory)][string]$ApkPath) $apksigner = Resolve-ApkSigner - $previous = $ErrorActionPreference + # Do not use PowerShell's native-command stream redirection here. On Linux, + # pwsh can wrap output from the extensionless apksigner launcher as error + # records, losing the certificate lines when those records are stringified. + # Process captures the launcher's raw stdout/stderr on every supported host. + if ($ApkPath.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0 -or + $apksigner.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the APK or apksigner path." + } + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + if ($apksigner.EndsWith(".bat", [StringComparison]::OrdinalIgnoreCase)) { + if (-not $env:ComSpec) { + Throw-UpdateError "APKSIGNER_MISSING" "The Windows command processor required for apksigner.bat is unavailable." + } + # cmd expands percent variables even inside quotes. Refuse percent rather + # than allow either path to be rewritten before the reviewed tool runs. + if ($ApkPath.Contains('%') -or $apksigner.Contains('%')) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the Windows APK or apksigner path." + } + $start.FileName = $env:ComSpec + $start.Arguments = "/d /s /v:off /c `"`"$apksigner`" verify --verbose --print-certs `"$ApkPath`"`"" + } else { + $start.FileName = $apksigner + $start.Arguments = "verify --verbose --print-certs `"$ApkPath`"" + } + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $start try { - $ErrorActionPreference = "Continue" - $output = @(& $apksigner verify --verbose --print-certs $ApkPath 2>&1) - $exitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previous } - $text = (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() + if (-not $process.Start()) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner could not be started." + } + # Drain both pipes concurrently so a noisy rejected file cannot fill one + # pipe and deadlock while the other is read. Signature verification is + # local and bounded; a hung tool is killed and refused after 30 seconds. + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + try { $process.Kill() } catch { } + $process.WaitForExit() + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner timed out; no installation was attempted." + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + } catch { + if ($_.Exception.Message -match '^\[APK_SIGNATURE_INVALID\]') { throw } + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner could not be executed; no installation was attempted." + } finally { + $process.Dispose() + } + $text = (($stdout, $stderr) -join "`n").Trim() if ($exitCode -ne 0) { Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner rejected the APK signature; no installation was attempted." } diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index f2f1d91d..005ce418 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -117,6 +117,20 @@ Test-Case "apksigner output parser requires verified signer digest" { Assert-ThrowsCode { Parse-ApkSignerOutput "DOES NOT VERIFY" } "APK_SIGNATURE_INVALID" "missing signer digest" } +Test-Case "apksigner process rejects command-stream injection characters" { + $oldResolver = (Get-Item Function:\Resolve-ApkSigner).ScriptBlock + try { + Set-Item Function:\Resolve-ApkSigner { "C:\safe\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests 'C:\release\bad%PATH%.apk' } "APK_SIGNATURE_INVALID" "cmd expansion refused" + Set-Item Function:\Resolve-ApkSigner { "C:\bad`"tool\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests 'C:\release\minimum.apk' } "APK_SIGNATURE_INVALID" "quote refused" + Set-Item Function:\Resolve-ApkSigner { "C:\safe\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests "C:\release\minimum.apk`nextra" } "APK_SIGNATURE_INVALID" "newline refused" + } finally { + Set-Item Function:\Resolve-ApkSigner $oldResolver + } +} + Test-Case "migration dispatch is exact, versioned, and T56-only" { $migration = [pscustomobject]@{ id="CELLULAR_POLICY_V1_T56"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false } Assert-Equal 1 @(Get-RequiredMigrations @($migration) 3070300 3070301 "T56").Count "T56 migration" @@ -192,9 +206,13 @@ Test-Case "bundle allowlist and checksum reject tampering" { } } -$realApkPath = Join-Path $PSScriptRoot "..\app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +$realApkPath = if ($env:MINIMUM_TEST_SIGNED_APK) { + $env:MINIMUM_TEST_SIGNED_APK +} else { + Join-Path $PSScriptRoot "..\app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +} if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { - Test-Case "real built APK identity and signer parsing" { + Test-Case "real built APK identity and raw apksigner process parsing" { $identity = Get-ApkManifestIdentity -ApkPath $realApkPath Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package" Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" From 8477e92e0c1cd4a0e8ae7035e6214a9c6265ce35 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:19:22 +0700 Subject: [PATCH 2/2] Bound apksigner process cleanup and release fixture --- .github/workflows/release-apk.yml | 3 ++ scripts/update-minimum-device.ps1 | 63 ++++++++++++++++++++++----- tests/update-minimum-device.Tests.ps1 | 38 ++++++++++++++-- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 46a52dec..86281cf7 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -149,6 +149,9 @@ jobs: } ./tools/verify-cellular-policy.ps1 $env:MINIMUM_TEST_SIGNED_APK = (Resolve-Path 'app/build/outputs/apk/foss/release/mumla-foss-release.apk').Path + $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID = '${{ vars.MINIMUM_RELEASE_APPLICATION_ID }}' + $env:MINIMUM_TEST_EXPECTED_VERSION_CODE = '${{ inputs.expected_version_code }}' + $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = '${{ inputs.tag }}' ./tests/update-minimum-device.Tests.ps1 - name: Build temporary Wi-Fi provisioner run: | diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index ae32ec3e..700248f8 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -371,15 +371,42 @@ function Parse-ApkSignerOutput { return $digests } -function Get-ApkSignerDigests { - param([Parameter(Mandatory)][string]$ApkPath) - $apksigner = Resolve-ApkSigner +function Stop-ApkSignerProcess { + param([Parameter(Mandatory)]$Process) + try { + $killTree = $Process.GetType().GetMethod("Kill", [type[]]@([bool])) + if ($killTree) { $killTree.Invoke($Process, @($true)) | Out-Null } + elseif ($env:OS -ceq "Windows_NT") { + # .NET Framework (Windows PowerShell 5.1) lacks Kill(Boolean). + # taskkill's numeric PID argument avoids shell parsing and terminates + # cmd.exe plus any batch-launched Java descendants holding our pipes. + $stop = New-Object System.Diagnostics.ProcessStartInfo + $stop.FileName = Join-Path $env:SystemRoot "System32\taskkill.exe" + $stop.Arguments = "/PID $($Process.Id) /T /F" + $stop.UseShellExecute = $false + $stop.CreateNoWindow = $true + $killer = [Diagnostics.Process]::Start($stop) + if ($killer) { + $killer.WaitForExit(2000) | Out-Null + $killer.Dispose() + } + } else { $Process.Kill() } + } catch { } +} + +function Invoke-ApkSignerProcess { + param( + [Parameter(Mandatory)][string]$ApkSigner, + [Parameter(Mandatory)][string]$ApkPath, + [int]$TimeoutMilliseconds = 30000, + [int]$DrainTimeoutMilliseconds = 2000 + ) # Do not use PowerShell's native-command stream redirection here. On Linux, # pwsh can wrap output from the extensionless apksigner launcher as error # records, losing the certificate lines when those records are stringified. # Process captures the launcher's raw stdout/stderr on every supported host. if ($ApkPath.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0 -or - $apksigner.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0) { + $ApkSigner.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0) { Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the APK or apksigner path." } $start = New-Object System.Diagnostics.ProcessStartInfo @@ -387,19 +414,19 @@ function Get-ApkSignerDigests { $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true - if ($apksigner.EndsWith(".bat", [StringComparison]::OrdinalIgnoreCase)) { + if ($ApkSigner.EndsWith(".bat", [StringComparison]::OrdinalIgnoreCase)) { if (-not $env:ComSpec) { Throw-UpdateError "APKSIGNER_MISSING" "The Windows command processor required for apksigner.bat is unavailable." } # cmd expands percent variables even inside quotes. Refuse percent rather # than allow either path to be rewritten before the reviewed tool runs. - if ($ApkPath.Contains('%') -or $apksigner.Contains('%')) { + if ($ApkPath.Contains('%') -or $ApkSigner.Contains('%')) { Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the Windows APK or apksigner path." } $start.FileName = $env:ComSpec - $start.Arguments = "/d /s /v:off /c `"`"$apksigner`" verify --verbose --print-certs `"$ApkPath`"`"" + $start.Arguments = "/d /s /v:off /c `"`"$ApkSigner`" verify --verbose --print-certs `"$ApkPath`"`"" } else { - $start.FileName = $apksigner + $start.FileName = $ApkSigner $start.Arguments = "verify --verbose --print-certs `"$ApkPath`"" } $process = New-Object System.Diagnostics.Process @@ -413,11 +440,15 @@ function Get-ApkSignerDigests { # local and bounded; a hung tool is killed and refused after 30 seconds. $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit(30000)) { - try { $process.Kill() } catch { } - $process.WaitForExit() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + Stop-ApkSignerProcess $process Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner timed out; no installation was attempted." } + $tasks = [Threading.Tasks.Task[]]@($stdoutTask, $stderrTask) + if (-not [Threading.Tasks.Task]::WaitAll($tasks, $DrainTimeoutMilliseconds)) { + Stop-ApkSignerProcess $process + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner output pipes did not close; no installation was attempted." + } $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() $exitCode = $process.ExitCode @@ -427,6 +458,16 @@ function Get-ApkSignerDigests { } finally { $process.Dispose() } + return [pscustomobject]@{ ExitCode=$exitCode; Stdout=$stdout; Stderr=$stderr } +} + +function Get-ApkSignerDigests { + param([Parameter(Mandatory)][string]$ApkPath) + $apksigner = Resolve-ApkSigner + $result = Invoke-ApkSignerProcess -ApkSigner $apksigner -ApkPath $ApkPath + $exitCode = $result.ExitCode + $stdout = $result.Stdout + $stderr = $result.Stderr $text = (($stdout, $stderr) -join "`n").Trim() if ($exitCode -ne 0) { Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner rejected the APK signature; no installation was attempted." diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 005ce418..092c6c66 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -131,6 +131,29 @@ Test-Case "apksigner process rejects command-stream injection characters" { } } +Test-Case "apksigner timeout has bounded cleanup" { + $root = Join-Path ([IO.Path]::GetTempPath()) ("minimum-apksigner-timeout-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + try { + $fakeApk = Join-Path $root "fixture.apk" + Set-Content -LiteralPath $fakeApk -Value "fixture" -NoNewline + if ($env:ComSpec) { + $fakeSigner = Join-Path $root "apksigner.bat" + Set-Content -LiteralPath $fakeSigner -Value "@ping -n 6 127.0.0.1 >nul" -Encoding ASCII + } else { + $fakeSigner = Join-Path $root "apksigner" + Set-Content -LiteralPath $fakeSigner -Value "#!/bin/sh`nsleep 5" -Encoding ASCII + & chmod +x $fakeSigner + } + $timer = [Diagnostics.Stopwatch]::StartNew() + Assert-ThrowsCode { Invoke-ApkSignerProcess $fakeSigner $fakeApk 100 100 } "APK_SIGNATURE_INVALID" "timeout refused" + $timer.Stop() + Assert-True ($timer.ElapsedMilliseconds -lt 3000) "timeout cleanup remained bounded" + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + Test-Case "migration dispatch is exact, versioned, and T56-only" { $migration = [pscustomobject]@{ id="CELLULAR_POLICY_V1_T56"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false } Assert-Equal 1 @(Get-RequiredMigrations @($migration) 3070300 3070301 "T56").Count "T56 migration" @@ -214,9 +237,18 @@ $realApkPath = if ($env:MINIMUM_TEST_SIGNED_APK) { if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { Test-Case "real built APK identity and raw apksigner process parsing" { $identity = Get-ApkManifestIdentity -ApkPath $realApkPath - Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package" - Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" - Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" + if ($env:MINIMUM_TEST_SIGNED_APK) { + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_APPLICATION_ID)) "release expected package supplied" + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_VERSION_CODE)) "release expected version code supplied" + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_VERSION_NAME)) "release expected version name supplied" + Assert-Equal $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID $identity.ApplicationId "release APK package" + Assert-Equal ([long]$env:MINIMUM_TEST_EXPECTED_VERSION_CODE) $identity.VersionCode "release APK version code" + Assert-Equal $env:MINIMUM_TEST_EXPECTED_VERSION_NAME $identity.VersionName "release APK version name" + } else { + Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package" + Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" + Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" + } $signers = @(Get-ApkSignerDigests -ApkPath $realApkPath) Assert-True ($signers.Count -ge 1) "real APK signer count" Assert-True (@($signers | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -eq 0) "real APK signer format"