Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ 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
$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: |
Expand Down
102 changes: 95 additions & 7 deletions scripts/update-minimum-device.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -371,16 +371,104 @@ function Parse-ApkSignerOutput {
return $digests
}

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) {
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 {
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($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
} 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()
}
return [pscustomobject]@{ ExitCode=$exitCode; Stdout=$stdout; Stderr=$stderr }
}

function Get-ApkSignerDigests {
param([Parameter(Mandatory)][string]$ApkPath)
$apksigner = Resolve-ApkSigner
$previous = $ErrorActionPreference
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()
$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."
}
Expand Down
60 changes: 55 additions & 5 deletions tests/update-minimum-device.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,43 @@ 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 "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"
Expand Down Expand Up @@ -192,13 +229,26 @@ 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"
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"
Expand Down
Loading