diff --git a/devservices/config.yml b/devservices/config.yml index bfb8cdb6..0f40912d 100644 --- a/devservices/config.yml +++ b/devservices/config.yml @@ -35,7 +35,7 @@ services: LAUNCHPAD_WORKER_RPC_HOST: "host.docker.internal:50051" LAUNCHPAD_WORKER_CONCURRENCY: "1" healthcheck: - test: ["CMD-SHELL", "[ -f /tmp/health ]"] + test: ["CMD", "python3", "-c", "open('/tmp/health')"] interval: 10s timeout: 5s retries: 3 diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 23c2c5d5..7ebc2f7d 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -83,7 +83,7 @@ services: TASKWORKER_TOPIC: "taskworker-launchpad" SENTRY_DSN: "" healthcheck: - test: ["CMD-SHELL", "[ -f /tmp/health ]"] + test: ["CMD", "python3", "-c", "open('/tmp/health')"] interval: 10s timeout: 5s retries: 5 diff --git a/scripts/deps b/scripts/deps index fa6ab575..4db739d1 100755 --- a/scripts/deps +++ b/scripts/deps @@ -105,10 +105,6 @@ class Dep: return self.architecture == architecture and self.system == system -BUNDLETOOL_SHIM = """#!/bin/sh -java -jar $(dirname "$(realpath $0)")/bundletool.jar "$@" -""" - DEPS = [ Dep( "bundletool.jar", @@ -116,7 +112,6 @@ DEPS = [ "675786493983787ffa11550bdb7c0715679a44e1643f3ff980a529e9c822595c", binaries=[ Bin(source="bundletool.jar", target="bundletool.jar"), - Bin(text=BUNDLETOOL_SHIM, target="bundletool"), ], ), Dep( @@ -127,7 +122,6 @@ DEPS = [ system="darwin", binaries=[ Bin(source="android-release-tools/lib/apksigner.jar", target="apksigner.jar"), - Bin(source="android-release-tools/bin/apksigner", target="apksigner"), ], ), Dep( @@ -138,7 +132,6 @@ DEPS = [ system="linux", binaries=[ Bin(source="android-release-tools/lib/apksigner.jar", target="apksigner.jar"), - Bin(source="android-release-tools/bin/apksigner", target="apksigner"), ], ), Dep( diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index d531ed7a..c9ea2222 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -1,7 +1,6 @@ import json import plistlib import shutil -import subprocess import tempfile import uuid @@ -14,6 +13,7 @@ from launchpad.parsers.apple.crushed_png import decode_crushed_png from launchpad.utils.logging import get_logger +from launchpad.utils.zip_utils import zip_directory from ..artifact import AppleArtifact from ..providers.exceptions import UnsafePathError @@ -232,27 +232,14 @@ def generate_ipa(self, output_path: Path): # Copy the .app bundle into Payload (preserve symlinks, permissions, etc.) shutil.copytree(app_bundle_path, dest_app_path, symlinks=True) - # Create the IPA file using zip to preserve symlinks and metadata + # Zip the Payload directory, preserving symlinks and permissions try: - subprocess.run( - [ - "zip", - "-r", - "-y", - "-q", - str(output_path), - "Payload", - ], - cwd=temp_dir_path, - check=True, - ) + zip_directory(payload_dir, output_path, preserve_symlinks=True) + except OSError as e: + raise RuntimeError("Failed to generate IPA file") from e - logger.info(f"IPA file generated successfully: {output_path}") - return output_path - except subprocess.CalledProcessError as e: - raise RuntimeError("Failed to generate IPA file with zip") from e - except FileNotFoundError: - raise RuntimeError("zip command not found. This tool is required for IPA generation.") + logger.info(f"IPA file generated successfully: {output_path}") + return output_path @sentry_sdk.trace def get_provisioning_profile(self) -> dict[str, Any] | None: diff --git a/src/launchpad/parsers/apple/macho_parser.py b/src/launchpad/parsers/apple/macho_parser.py index 5d71f8a1..bcd09a84 100644 --- a/src/launchpad/parsers/apple/macho_parser.py +++ b/src/launchpad/parsers/apple/macho_parser.py @@ -21,6 +21,18 @@ logger = get_logger(__name__) +# The four bytes every Mach-O starts with, as they appear on disk (mach-o/loader.h, +# mach-o/fat.h). The CIGAM ("MAGIC" backwards) spellings are the byte-swapped forms: a +# reader hitting one knows the file's endianness is the opposite of its own. +MACHO_MAGICS = ( + b"\xfe\xed\xfa\xce", # MH_MAGIC, 32-bit, big-endian + b"\xce\xfa\xed\xfe", # MH_CIGAM, 32-bit, little-endian + b"\xfe\xed\xfa\xcf", # MH_MAGIC_64, 64-bit, big-endian + b"\xcf\xfa\xed\xfe", # MH_CIGAM_64, 64-bit, little-endian - what Apple ships today + b"\xca\xfe\xba\xbe", # FAT_MAGIC, universal binary wrapping several slices + b"\xbe\xba\xfe\xca", # FAT_CIGAM +) + # Mach-O CPU type constants CPU_TYPE_NAMES: Dict[int, str] = { 0x0000000C: "arm", @@ -54,15 +66,7 @@ def __init__(self, binary: lief.MachO.Binary) -> None: def is_macho_binary(file_path: Path) -> bool: try: with open(file_path, "rb") as f: - magic = f.read(4) - return magic in [ - b"\xfe\xed\xfa\xce", # MH_MAGIC - b"\xce\xfa\xed\xfe", # MH_CIGAM - b"\xfe\xed\xfa\xcf", # MH_MAGIC_64 - b"\xcf\xfa\xed\xfe", # MH_CIGAM_64 - b"\xca\xfe\xba\xbe", # FAT_MAGIC - b"\xbe\xba\xfe\xca", # FAT_CIGAM - ] + return f.read(4) in MACHO_MAGICS except Exception: return False diff --git a/src/launchpad/size/utils/apple_bundle_size.py b/src/launchpad/size/utils/apple_bundle_size.py index b0eedc80..e2dd2855 100644 --- a/src/launchpad/size/utils/apple_bundle_size.py +++ b/src/launchpad/size/utils/apple_bundle_size.py @@ -1,8 +1,8 @@ import os import plistlib -import subprocess import tempfile import uuid +import zipfile from pathlib import Path from typing import List, NamedTuple @@ -13,6 +13,7 @@ from launchpad.size.models.common import AppComponent, ComponentType from launchpad.utils.file_utils import get_file_size, to_nearest_block_size from launchpad.utils.logging import get_logger +from launchpad.utils.zip_utils import zip_directory logger = get_logger(__name__) @@ -365,62 +366,29 @@ def _calculate_install_size(path: Path) -> int: return total_size +# We used to build this zip with Info-ZIP's `zip`, which quietly adds a UT (timestamps) and a ux +# (uid/gid) extra field to every entry: 28 bytes in the local header, 24 in the central directory. +# Python's zipfile writes neither, so add them back and the sizes we report stay where they were. +_INFOZIP_EXTRA_FIELD_BYTES_PER_ENTRY = 52 + + def _zip_metadata_size_for_bundle(bundle_url: Path) -> int: - temp_dir = Path(tempfile.gettempdir()) - zip_file_path = temp_dir / f"{uuid.uuid4()}.zip" - zip_info_file_path = temp_dir / f"{uuid.uuid4()}.txt" - bundle_dir = bundle_url.parent - bundle_name = bundle_url.name + zip_file_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.zip" try: - logger.debug(f"Creating ZIP file: zip -r {zip_file_path} {bundle_name}") - result = subprocess.run( - ["zip", "-q", "-r", str(zip_file_path), str(bundle_name)], - shell=False, - capture_output=True, - text=True, - cwd=str(bundle_dir), - ) - if result.returncode != 0: - logger.error(f"ZIP command failed: {result.stderr}") - return 0 + logger.debug(f"Creating ZIP file: {zip_file_path} from {bundle_url.name}") + zip_directory(bundle_url, zip_file_path, preserve_symlinks=False) - logger.debug(f"Getting ZIP info: unzip -v {zip_file_path}") - with open(zip_info_file_path, "w") as zip_info_file: - result = subprocess.run( - ["unzip", "-v", str(zip_file_path)], - shell=False, - stdout=zip_info_file, - stderr=subprocess.PIPE, - text=True, - ) - if result.returncode != 0: - logger.error(f"Unzip command failed: {result.stderr}") - return 0 - - with open(zip_info_file_path, "r", encoding="utf-8", errors="replace") as f: - zip_info = f.read() - - # Parse the last line which contains total sizes - lines = zip_info.strip().split("\n") - last_line = lines[-1] - # Format is typically: "-------- ------- --- -------" - # followed by: "12345678 12345678 0% 123 files" - # The columns are: uncompressed_size compressed_size ratio file_count - parts = last_line.split() - if len(parts) >= 2: - # total_uncompressed = int(parts[0]) - total_compressed = int(parts[1]) - else: - logger.error("Could not parse ZIP info, using fallback") - return 0 + with zipfile.ZipFile(zip_file_path) as zf: + infos = zf.infolist() + total_compressed = sum(info.compress_size for info in infos) # Get actual ZIP file size total_zip_size = os.path.getsize(zip_file_path) - # Metadata size is the difference between ZIP file size and compressed content size - # ZIP file = compressed content + metadata (headers, directory structure, etc.) - metadata_size = total_zip_size - total_compressed + # Metadata is everything that isn't payload: headers, names, central directory, EOCD. + # Compressed bytes sit in both terms and cancel, so the compression level can't skew this. + metadata_size = total_zip_size - total_compressed + _INFOZIP_EXTRA_FIELD_BYTES_PER_ENTRY * len(infos) if metadata_size < 0: logger.warning( @@ -437,5 +405,3 @@ def _zip_metadata_size_for_bundle(bundle_url: Path) -> int: finally: if zip_file_path.exists(): zip_file_path.unlink() - if zip_info_file_path.exists(): - zip_info_file_path.unlink() diff --git a/src/launchpad/size/utils/file_analysis.py b/src/launchpad/size/utils/file_analysis.py index be3d4526..b6928c23 100644 --- a/src/launchpad/size/utils/file_analysis.py +++ b/src/launchpad/size/utils/file_analysis.py @@ -8,6 +8,7 @@ import sentry_sdk from launchpad.artifacts.apple.zipped_xcarchive import ZippedXCArchive +from launchpad.parsers.apple.macho_parser import MACHO_MAGICS from launchpad.size.constants import APPLE_FILESYSTEM_BLOCK_SIZE from launchpad.size.models.common import FileAnalysis, FileInfo from launchpad.size.models.treemap import FILE_TYPE_TO_TREEMAP_TYPE, TreemapType @@ -301,40 +302,54 @@ def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> L return result +_ELF_MAGIC = b"\x7fELF" +_HERMES_MAGIC = b"\xc6\x1f\xbc\x03\xc1\x03\x19\x1f" +_TEXT_BOMS = (b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff") + + @sentry_sdk.trace def _detect_file_type(file_path: Path) -> str: - """Best-effort file type detection via `file` as a fallback.""" - import subprocess + """Best-effort file type detection from magic bytes, for files without an extension. + Pure Python so it works in the distroless image, where ``file(1)`` is not available. + """ try: - result = subprocess.run(["file", str(file_path)], capture_output=True, text=True, check=True) - file_type = result.stdout.split(":", 1)[1].strip().lower() - logger.debug("Detected file type for %s: %s", file_path, file_type) + if file_path.is_symlink(): + return "symlink" + if file_path.is_dir(): + return "directory" - if "mach-o" in file_type: + with open(file_path, "rb") as f: + head = f.read(8192) + + if not head: + return "empty" + if head[:4] in MACHO_MAGICS: return "macho" - if "executable" in file_type: + if head.startswith(_HERMES_MAGIC): + return "hermes" + if head.startswith(_ELF_MAGIC) or head.startswith(b"#!"): return "executable" - if "text" in file_type: + if head.startswith(_TEXT_BOMS) or (b"\x00" not in head and _is_utf8_text(head)): return "text" - if "directory" in file_type: - return "directory" - if "symbolic link" in file_type: - return "symlink" - if "hermes javascript bytecode" in file_type: - return "hermes" - if "empty" in file_type: - return "empty" - return file_type - except subprocess.CalledProcessError as e: - logger.warning("Failed to detect file type for %s: %s", file_path, e) return "unknown" - except Exception as e: # pragma: no cover – defensive - logger.warning("Unexpected error detecting file type for %s: %s", file_path, e) + except OSError as e: + logger.warning("Failed to detect file type for %s: %s", file_path, e) return "unknown" +def _is_utf8_text(data: bytes) -> bool: + # The sample may end mid-codepoint, so tolerate a truncated trailing sequence + for trim in range(4): + try: + data[: len(data) - trim].decode("utf-8") + return True + except UnicodeDecodeError: + continue + return False + + def _dir_size_aggregate( root: Path, seen_dirs: Set[Tuple[int, int]], diff --git a/src/launchpad/utils/android/apksigner.py b/src/launchpad/utils/android/apksigner.py index 04f094c4..4b81479a 100644 --- a/src/launchpad/utils/android/apksigner.py +++ b/src/launchpad/utils/android/apksigner.py @@ -1,8 +1,8 @@ -import shutil import subprocess from pathlib import Path +from ..java import find_jar, find_java from ..logging import get_logger logger = get_logger(__name__) @@ -21,17 +21,17 @@ def __init__(self, message: str, returncode: int, stdout: str, stderr: str) -> N class Apksigner: """Wrapper around Android's apksigner CLI utility.""" - apksigner_path: str + java_path: str + apksigner_jar: str def __init__(self) -> None: """Initialize apksigner wrapper. Raises: - AssertionError: If apksigner cannot be found on PATH + FileNotFoundError: If java or apksigner.jar cannot be found """ - apksigner_path = shutil.which("apksigner") - assert apksigner_path is not None - self.apksigner_path = apksigner_path + self.java_path = find_java() + self.apksigner_jar = find_jar("apksigner.jar") def get_certs(self, apk_path: Path) -> str: """Get certificates for an APK. @@ -42,7 +42,8 @@ def get_certs(self, apk_path: Path) -> str: Returns: String containing certificate information """ - cmd = [self.apksigner_path, "verify", "--print-certs", str(apk_path)] + # Same JVM options as the apksigner launcher script we no longer use + cmd = [self.java_path, "-Xmx1024M", "-jar", self.apksigner_jar, "verify", "--print-certs", str(apk_path)] logger.debug("Running apksigner command: %s", " ".join(cmd)) diff --git a/src/launchpad/utils/android/bundletool.py b/src/launchpad/utils/android/bundletool.py index bb419bff..33b7cc25 100644 --- a/src/launchpad/utils/android/bundletool.py +++ b/src/launchpad/utils/android/bundletool.py @@ -4,7 +4,6 @@ import json import secrets -import shutil import string import subprocess import tempfile @@ -14,6 +13,7 @@ from pydantic import BaseModel, Field +from ..java import find_jar, find_java, find_keytool from ..logging import get_logger logger = get_logger(__name__) @@ -35,17 +35,17 @@ def __str__(self): class Bundletool: """Wrapper around Android's bundletool CLI utility.""" - bundletool_path: str + java_path: str + bundletool_jar: str def __init__(self) -> None: """Initialize bundletool wrapper. Raises: - AssertionError: If bundletool cannot be found on PATH + FileNotFoundError: If java or bundletool.jar cannot be found """ - bundletool_path = shutil.which("bundletool") - assert bundletool_path is not None - self.bundletool_path = bundletool_path + self.java_path = find_java() + self.bundletool_jar = find_jar("bundletool.jar") def _run_command(self, command: list[str], **kwargs: Any) -> tuple[int, str, str]: """Run a bundletool command. @@ -60,7 +60,7 @@ def _run_command(self, command: list[str], **kwargs: Any) -> tuple[int, str, str Raises: BundletoolError: If command fails """ - cmd = [str(self.bundletool_path)] + command + cmd = [self.java_path, "-jar", self.bundletool_jar, *command] logger.debug("Running bundletool command: %s", " ".join(cmd)) try: @@ -107,7 +107,7 @@ def _generate_keystore(self, keystore_path: Path) -> tuple[str, str]: # Keytool command to generate keystore keytool_cmd = [ - "keytool", + find_keytool(), "-genkeypair", "-v", "-keystore", diff --git a/src/launchpad/utils/java.py b/src/launchpad/utils/java.py new file mode 100644 index 00000000..da2a2edf --- /dev/null +++ b/src/launchpad/utils/java.py @@ -0,0 +1,53 @@ +"""Locating the Java runtime and the jars launchpad shells out to. + +The jars are invoked directly with ``java -jar`` rather than through the shell launchers +that ship with them, so they work in the distroless image where there is no shell. +""" + +import os +import shutil + +from pathlib import Path + + +def find_java() -> str: + """Return the path to the ``java`` executable. + + Honors ``JAVA_HOME`` first, then falls back to ``PATH``. + """ + java_home = os.environ.get("JAVA_HOME") + if java_home: + candidate = Path(java_home) / "bin" / "java" + if candidate.is_file(): + return str(candidate) + + java = shutil.which("java") + if java is None: + raise FileNotFoundError("java not found in JAVA_HOME or PATH") + return java + + +def find_keytool() -> str: + """Return the path to ``keytool``, preferring the one next to the resolved ``java``.""" + sibling = Path(find_java()).with_name("keytool") + if sibling.is_file(): + return str(sibling) + + keytool = shutil.which("keytool") + if keytool is None: + raise FileNotFoundError("keytool not found next to java or in PATH") + return keytool + + +def find_jar(name: str) -> str: + """Return the path to ``name`` (e.g. ``bundletool.jar``) by searching the ``PATH`` directories. + + ``scripts/deps`` installs the jars into a ``bin`` directory that is on ``PATH``. + """ + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory: + continue + candidate = Path(directory) / name + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError(f"{name} not found in any PATH directory; run scripts/deps") diff --git a/src/launchpad/utils/zip_utils.py b/src/launchpad/utils/zip_utils.py new file mode 100644 index 00000000..5aa2f468 --- /dev/null +++ b/src/launchpad/utils/zip_utils.py @@ -0,0 +1,70 @@ +"""Pure-Python zip archive creation. + +Replaces shelling out to the Info-ZIP ``zip`` binary, which is not available in the +distroless runtime image. +""" + +import os +import stat +import time +import zipfile + +from pathlib import Path + +# zipfile can't represent timestamps before 1980, so clamp anything older. +_MIN_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) + + +def _zip_info_for(arcname: str, st: os.stat_result) -> zipfile.ZipInfo: + date_time = time.localtime(st.st_mtime)[:6] + info = zipfile.ZipInfo(arcname, date_time=max(date_time, _MIN_ZIP_TIMESTAMP)) + # Preserve unix permissions the same way Info-ZIP does (mode in the high 16 bits). + info.external_attr = (stat.S_IMODE(st.st_mode) | stat.S_IFMT(st.st_mode)) << 16 + return info + + +def zip_directory(source_dir: Path, output_path: Path, *, preserve_symlinks: bool = True) -> None: + """Recursively zip ``source_dir`` into ``output_path``. + + Mirrors ``zip -r`` (or ``zip -r -y`` when ``preserve_symlinks`` is set): entries are + named relative to the parent of ``source_dir`` so the archive root is ``source_dir.name``, + directory entries are included, and unix permissions are preserved. + + Args: + source_dir: Directory to archive + output_path: Destination ``.zip`` path (overwritten if it exists) + preserve_symlinks: Store symlinks as symlink entries instead of following them + """ + source_dir = Path(source_dir) + base = source_dir.parent + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + _add_path(zf, source_dir, base, preserve_symlinks) + + +def _add_path(zf: zipfile.ZipFile, path: Path, base: Path, preserve_symlinks: bool) -> None: + try: + st = path.lstat() if preserve_symlinks else path.stat() + except FileNotFoundError: + # Dangling symlink while following links; Info-ZIP skips these too + return + arcname = path.relative_to(base).as_posix() + + if preserve_symlinks and stat.S_ISLNK(st.st_mode): + info = _zip_info_for(arcname, st) + zf.writestr(info, os.readlink(path)) + return + + if stat.S_ISDIR(st.st_mode): + info = _zip_info_for(arcname + "/", st) + info.external_attr |= 0x10 # MS-DOS directory flag, as Info-ZIP sets it + zf.writestr(info, b"") + for child in sorted(path.iterdir()): + _add_path(zf, child, base, preserve_symlinks) + return + + info = _zip_info_for(arcname, st) + info.compress_type = zipfile.ZIP_DEFLATED + with open(path, "rb") as src, zf.open(info, "w") as dst: + while chunk := src.read(1024 * 1024): + dst.write(chunk) diff --git a/tests/integration/test_ipa.py b/tests/integration/test_ipa.py index 6138ed4d..f29c008b 100644 --- a/tests/integration/test_ipa.py +++ b/tests/integration/test_ipa.py @@ -1,5 +1,5 @@ -import subprocess import tempfile +import zipfile from pathlib import Path @@ -19,14 +19,9 @@ def test_ipa_generation(self, hackernews_xcarchive: Path): extract_dir = temp_dir / "extracted_ipa" extract_dir.mkdir() - try: - subprocess.run( - ["unzip", "-q", str(ipa_path), "-d", str(extract_dir)], - check=True, - capture_output=True, # Quiet mode - ) - except subprocess.CalledProcessError as e: - raise AssertionError(f"Failed to extract IPA: {e.stderr}") + with zipfile.ZipFile(ipa_path) as zf: + assert zf.testzip() is None + zf.extractall(extract_dir) payload_dir = extract_dir / "Payload" app_bundles = list(payload_dir.glob("*.app")) diff --git a/tests/unit/size/utils/test_file_analysis.py b/tests/unit/size/utils/test_file_analysis.py index 479489cf..0e5c80bf 100644 --- a/tests/unit/size/utils/test_file_analysis.py +++ b/tests/unit/size/utils/test_file_analysis.py @@ -1,6 +1,5 @@ """Tests for file analysis functionality.""" -import subprocess import tempfile from pathlib import Path @@ -310,18 +309,18 @@ def test_empty_bundle(self, mock_xcarchive): assert root_dir.size == 4096 assert root_dir.size == to_nearest_block_size(empty_bundle.stat().st_size, APPLE_FILESYSTEM_BLOCK_SIZE) - @patch("subprocess.run") - def test_file_type_detection_fallback(self, mock_subprocess, mock_xcarchive, temp_app_bundle): - """Test file type detection fallback when file command fails.""" + def test_file_type_detection_fallback(self, mock_xcarchive, temp_app_bundle): + """Extensionless files get their type sniffed from magic bytes.""" mock_xcarchive.get_app_bundle_path.return_value = temp_app_bundle mock_xcarchive.get_asset_catalog_details.return_value = [] - unknown_file = temp_app_bundle / "unknown_file" - unknown_file.write_bytes(b"some binary data") - - mock_subprocess.side_effect = subprocess.CalledProcessError(1, "file") + (temp_app_bundle / "unknown_file").write_bytes(b"\x00\x01some binary data\xff") + (temp_app_bundle / "macho_file").write_bytes(b"\xcf\xfa\xed\xfe" + b"\x00" * 28) + (temp_app_bundle / "text_file").write_text("hello world\n") result = analyze_apple_files(mock_xcarchive) files = {f.path: f for f in result.files} assert files["unknown_file"].file_type == "unknown" + assert files["macho_file"].file_type == "macho" + assert files["text_file"].file_type == "text" diff --git a/tests/unit/utils/android/test_bundletool.py b/tests/unit/utils/android/test_bundletool.py index 5afd2f02..44610a29 100644 --- a/tests/unit/utils/android/test_bundletool.py +++ b/tests/unit/utils/android/test_bundletool.py @@ -7,7 +7,8 @@ @pytest.fixture def bundletool(mocker) -> Bundletool: - mocker.patch("launchpad.utils.android.bundletool.shutil.which", return_value="/usr/local/bin/bundletool") + mocker.patch("launchpad.utils.android.bundletool.find_java", return_value="/usr/bin/java") + mocker.patch("launchpad.utils.android.bundletool.find_jar", return_value="/usr/local/bin/bundletool.jar") tool = Bundletool() mocker.patch.object(tool, "_generate_keystore", return_value=("password", "alias")) mocker.patch.object(tool, "_run_command") diff --git a/tests/unit/utils/test_java.py b/tests/unit/utils/test_java.py new file mode 100644 index 00000000..20cda72d --- /dev/null +++ b/tests/unit/utils/test_java.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest + +from launchpad.utils.java import find_jar, find_java, find_keytool + + +def test_find_java_prefers_java_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + java = tmp_path / "bin" / "java" + java.parent.mkdir() + java.touch() + keytool = java.with_name("keytool") + keytool.touch() + monkeypatch.setenv("JAVA_HOME", str(tmp_path)) + + assert find_java() == str(java) + assert find_keytool() == str(keytool) + + +def test_find_java_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("JAVA_HOME", raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + + with pytest.raises(FileNotFoundError): + find_java() + + +def test_find_jar_searches_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + jar = tmp_path / "bin" / "bundletool.jar" + jar.parent.mkdir() + jar.touch() + monkeypatch.setenv("PATH", f"/nonexistent:{jar.parent}") + + assert find_jar("bundletool.jar") == str(jar) + with pytest.raises(FileNotFoundError): + find_jar("missing.jar") diff --git a/tests/unit/utils/test_zip_utils.py b/tests/unit/utils/test_zip_utils.py new file mode 100644 index 00000000..8036962f --- /dev/null +++ b/tests/unit/utils/test_zip_utils.py @@ -0,0 +1,56 @@ +import os +import stat +import zipfile + +from pathlib import Path + +from launchpad.utils.zip_utils import zip_directory + + +def _make_bundle(tmp_path: Path) -> Path: + bundle = tmp_path / "Test.app" + (bundle / "Frameworks" / "Foo.framework" / "Versions" / "A").mkdir(parents=True) + (bundle / "Frameworks" / "Foo.framework" / "Versions" / "A" / "Foo").write_bytes(b"binary" * 100) + (bundle / "Info.plist").write_text("") + executable = bundle / "Test" + executable.write_bytes(b"\xcf\xfa\xed\xfe") + executable.chmod(0o755) + os.symlink("Versions/A/Foo", bundle / "Frameworks" / "Foo.framework" / "Foo") + return bundle + + +def test_zip_directory_preserves_symlinks_and_permissions(tmp_path: Path) -> None: + bundle = _make_bundle(tmp_path) + out = tmp_path / "out.zip" + + zip_directory(bundle, out, preserve_symlinks=True) + + with zipfile.ZipFile(out) as zf: + assert zf.testzip() is None + infos = {i.filename: i for i in zf.infolist()} + + # Entries are rooted at the bundle name, and directories are present, like `zip -r` + assert "Test.app/" in infos + assert infos["Test.app/"].is_dir() + assert infos["Test.app/Info.plist"].compress_type == zipfile.ZIP_DEFLATED + + link = infos["Test.app/Frameworks/Foo.framework/Foo"] + assert stat.S_ISLNK(link.external_attr >> 16) + assert zf.read(link) == b"Versions/A/Foo" + + assert stat.S_IMODE(infos["Test.app/Test"].external_attr >> 16) == 0o755 + + +def test_zip_directory_follows_symlinks_when_requested(tmp_path: Path) -> None: + bundle = _make_bundle(tmp_path) + os.symlink("does-not-exist", bundle / "dangling") + out = tmp_path / "out.zip" + + zip_directory(bundle, out, preserve_symlinks=False) + + with zipfile.ZipFile(out) as zf: + infos = {i.filename: i for i in zf.infolist()} + link = infos["Test.app/Frameworks/Foo.framework/Foo"] + assert stat.S_ISREG(link.external_attr >> 16) + assert zf.read(link) == b"binary" * 100 + assert "Test.app/dangling" not in infos