Files
LibreMail/.github/scripts/test_setup_android_sdk.py
JMR-dev 5c00a8da70 ci(e2e): route matrix emulator + system-image install through the #389-hardened installer
The E2E (33) matrix leg deadlocked the merge queue for ~2h when
android-emulator-runner's un-guarded "Create AVD and generate snapshot" step
died with "Error on ZipFile unknown archive" installing a corrupt Android
Emulator SDK zip. #389 hardened the platform/build-tools install (SHA-verify ->
reject-corrupt -> purge -> re-download) but left the emulator + system-image
install to the action, un-guarded.

Pre-install "emulator" + "system-images;android-<api>;google_apis;x86_64"
through setup_android_sdk.py before the emulator-runner steps, so a corrupt zip
is self-healed here and the action then finds both packages already installed
and skips its fragile fetch. Runs on both AVD-cache hit and miss (the emulator
binary + image live under the SDK root, not the ~/.android AVD-snapshot cache,
so they must be present for even a cached AVD to boot). Not added to the
android-sdk-v1 cache (kept small); re-install is a fast sdkmanager no-op when
already present.

The e2e-preview (API 37) job already routes emulator + system image through the
hardened installer, so no change there. setup_android_sdk.py already handles
these package ids generically; add a unit assertion pinning the matrix's
google_apis/x86_64 id to the correct purge path.

Closes #443
2026-07-08 10:39:38 -05:00

186 lines
7.2 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Unit tests for the pure helpers of setup_android_sdk.py (no network, no SDK).
Covers the bits whose correctness is load-bearing for the hardening in #389:
the package-id -> purge-path mapping (a wrong mapping would purge the wrong dir),
the size/SHA-256 integrity gate (verify -> reject), the pinned-constant
self-consistency, the backoff schedule, sdkmanager discovery, and that extraction
restores the executable bit that sdkmanager needs. The full download/install path
is exercised by CI itself."""
from __future__ import annotations
import hashlib
import os
import stat
import tempfile
import unittest
import zipfile
import setup_android_sdk as sdk
class PackageDirTests(unittest.TestCase):
def test_semicolon_ids_map_to_nested_dirs(self):
root = os.path.join("opt", "sdk")
self.assertEqual(
sdk.package_dir(root, "platforms;android-37.0"),
os.path.join(root, "platforms", "android-37.0"),
)
self.assertEqual(
sdk.package_dir(root, "build-tools;37.0.0"),
os.path.join(root, "build-tools", "37.0.0"),
)
self.assertEqual(
sdk.package_dir(root, "system-images;android-37.0;google_apis_ps16k;x86_64"),
os.path.join(root, "system-images", "android-37.0", "google_apis_ps16k", "x86_64"),
)
# The matrix e2e legs (#443) pre-install the google_apis/x86_64 image for their API level
# through this same installer, so a corrupt emulator/system-image zip is purged from the
# right dir on retry — assert that (non-ps16k) id maps correctly too.
self.assertEqual(
sdk.package_dir(root, "system-images;android-33;google_apis;x86_64"),
os.path.join(root, "system-images", "android-33", "google_apis", "x86_64"),
)
def test_flat_ids_map_to_single_dir(self):
root = os.path.join("opt", "sdk")
self.assertEqual(sdk.package_dir(root, "emulator"), os.path.join(root, "emulator"))
self.assertEqual(
sdk.package_dir(root, "platform-tools"), os.path.join(root, "platform-tools")
)
def test_purge_target_stays_under_root(self):
# The purge path must never escape the SDK root (no absolute/`..` package ids).
root = os.path.abspath(os.path.join("opt", "sdk"))
target = os.path.abspath(sdk.package_dir(root, "platforms;android-37.0"))
self.assertTrue(target.startswith(root + os.sep))
class VerifyDownloadTests(unittest.TestCase):
def _write(self, data: bytes) -> str:
fd, path = tempfile.mkstemp()
with os.fdopen(fd, "wb") as fh:
fh.write(data)
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
return path
def test_accepts_matching_size_and_hash(self):
data = b"correct-cmdline-tools-bytes"
path = self._write(data)
ok, detail = sdk.verify_download(path, len(data), hashlib.sha256(data).hexdigest())
self.assertTrue(ok, detail)
self.assertEqual(detail, "ok")
def test_rejects_wrong_size_before_hashing(self):
data = b"truncated"
path = self._write(data)
ok, detail = sdk.verify_download(path, len(data) + 1, hashlib.sha256(data).hexdigest())
self.assertFalse(ok)
self.assertIn("size", detail)
def test_rejects_corrupt_bytes_with_right_size(self):
good = b"aaaaaaaa"
corrupt = b"aaaaaaab" # same length, different content (silent corruption)
path = self._write(corrupt)
ok, detail = sdk.verify_download(path, len(good), hashlib.sha256(good).hexdigest())
self.assertFalse(ok)
self.assertIn("sha256", detail)
def test_rejects_missing_file(self):
ok, detail = sdk.verify_download(
os.path.join(tempfile.gettempdir(), "does-not-exist-clt.zip"), 1, "0" * 64
)
self.assertFalse(ok)
class PinnedConstantsTests(unittest.TestCase):
def test_url_embeds_the_pinned_build_number(self):
self.assertIn(sdk.CLT_VERSION_LONG, sdk.CLT_URL)
self.assertTrue(sdk.CLT_URL.startswith("https://"))
self.assertTrue(sdk.CLT_URL.endswith("_latest.zip"))
def test_sha256_is_a_full_hex_digest(self):
self.assertEqual(len(sdk.CLT_SHA256), 64)
int(sdk.CLT_SHA256, 16) # raises if not hex
self.assertEqual(sdk.CLT_SHA256, sdk.CLT_SHA256.lower())
def test_size_is_positive(self):
self.assertGreater(sdk.CLT_SIZE, 0)
class BackoffTests(unittest.TestCase):
def test_backoff_is_linear_and_increasing(self):
seq = [sdk.backoff_seconds(a) for a in range(1, sdk.MAX_ATTEMPTS + 1)]
self.assertEqual(seq, [10, 20, 30, 40][: sdk.MAX_ATTEMPTS])
self.assertEqual(seq, sorted(seq))
class SdkRootTests(unittest.TestCase):
def test_prefers_android_sdk_root_over_home(self):
with _env(ANDROID_SDK_ROOT="/a/sdk-root", ANDROID_HOME="/b/home"):
self.assertEqual(sdk.sdk_root(), "/a/sdk-root")
def test_falls_back_to_android_home(self):
with _env(ANDROID_SDK_ROOT=None, ANDROID_HOME="/b/home"):
self.assertEqual(sdk.sdk_root(), "/b/home")
class FindSdkManagerTests(unittest.TestCase):
def test_prefers_pinned_revision_dir(self):
with tempfile.TemporaryDirectory() as root:
pinned = os.path.join(root, "cmdline-tools", sdk.CLT_VERSION_SHORT, "bin")
latest = os.path.join(root, "cmdline-tools", "latest", "bin")
for d in (pinned, latest):
os.makedirs(d)
open(os.path.join(d, "sdkmanager"), "w").close()
self.assertEqual(
sdk.find_sdkmanager(root),
os.path.join(pinned, "sdkmanager"),
)
class ExtractPermsTests(unittest.TestCase):
@unittest.skipUnless(os.name == "posix", "unix exec bit only meaningful on POSIX")
def test_executable_bit_is_restored(self):
with tempfile.TemporaryDirectory() as work:
zip_path = os.path.join(work, "clt.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
info = zipfile.ZipInfo("cmdline-tools/bin/sdkmanager")
info.external_attr = 0o755 << 16 # -rwxr-xr-x, as Google's zip stores it
zf.writestr(info, "#!/bin/sh\n")
out = os.path.join(work, "out")
sdk._extract_preserving_perms(zip_path, out)
mode = os.stat(os.path.join(out, "cmdline-tools", "bin", "sdkmanager")).st_mode
self.assertTrue(mode & stat.S_IXUSR, "sdkmanager must be executable after extract")
class _env:
"""Context manager to set/clear env vars for a test, restoring them after."""
def __init__(self, **values):
self._values = values
self._saved = {}
def __enter__(self):
for key, value in self._values.items():
self._saved[key] = os.environ.get(key)
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
return self
def __exit__(self, *exc):
for key, previous in self._saved.items():
if previous is None:
os.environ.pop(key, None)
else:
os.environ[key] = previous
return False
if __name__ == "__main__":
unittest.main()