From 2bbde77015d57d9fdd8f2336b115bc585860a439 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 28 Sep 2026 21:49:10 +0200 Subject: [PATCH 1/4] build: Drop shell dependencies from healthchecks and the IPA test The container healthchecks used CMD-SHELL and the IPA integration test shelled out to unzip. Neither exists in a distroless image. Use exec-form healthchecks that probe the health file with python3, and read the generated IPA with zipfile. Behaviour is unchanged on the current image. Refs SEC-1090 Co-Authored-By: Claude --- devservices/config.yml | 2 +- docker-compose.e2e.yml | 2 +- tests/integration/test_ipa.py | 13 ++++--------- 3 files changed, 6 insertions(+), 11 deletions(-) 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/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")) From 635a361d64ed11fcce0e50882b1285e8a533cac8 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 28 Sep 2026 21:49:42 +0200 Subject: [PATCH 2/4] ref(android): Invoke the bundletool and apksigner jars directly Both tools were launched through the shell wrappers that ship with them: a generated /bin/sh shim for bundletool and the upstream /bin/bash launcher for apksigner. A distroless image has no shell, so run the jars with java -jar instead and stop installing the wrappers. apksigner keeps the -Xmx1024M its launcher passed. keytool is resolved next to the java we picked. Refs SEC-1090 Co-Authored-By: Claude --- scripts/deps | 7 --- src/launchpad/utils/android/apksigner.py | 15 +++--- src/launchpad/utils/android/bundletool.py | 16 +++---- src/launchpad/utils/java.py | 53 +++++++++++++++++++++ tests/unit/utils/android/test_bundletool.py | 3 +- tests/unit/utils/test_java.py | 36 ++++++++++++++ 6 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/launchpad/utils/java.py create mode 100644 tests/unit/utils/test_java.py 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/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/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") From a99b4aa69defed569e816ad5cf4b64ec6caa398f Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 28 Sep 2026 21:49:42 +0200 Subject: [PATCH 3/4] ref(size): Generate IPAs and detect file types without shelling out IPA generation ran `zip -r -y` and extensionless files were typed with file(1); neither binary is present in a distroless image. Add zip_directory(), which reproduces what Info-ZIP wrote for us: symlink entries, unix permissions and explicit directory entries. Detect file types from magic bytes instead, covering the categories the previous mapping recognised (Mach-O, ELF and shebangs, Hermes bytecode, text, empty, symlink, directory). Anything else is reported as unknown, where before libmagic's free-text description was passed through; nothing downstream matched on that text. Refs SEC-1090 Co-Authored-By: Claude --- .../artifacts/apple/zipped_xcarchive.py | 27 ++----- src/launchpad/parsers/apple/macho_parser.py | 22 +++--- src/launchpad/size/utils/file_analysis.py | 57 +++++++++------ src/launchpad/utils/zip_utils.py | 70 +++++++++++++++++++ tests/unit/size/utils/test_file_analysis.py | 15 ++-- tests/unit/utils/test_zip_utils.py | 56 +++++++++++++++ 6 files changed, 189 insertions(+), 58 deletions(-) create mode 100644 src/launchpad/utils/zip_utils.py create mode 100644 tests/unit/utils/test_zip_utils.py 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/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/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/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/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 From ac2248d7b605fddcdeb449fef72c614d3b7c2425 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 28 Sep 2026 21:49:42 +0200 Subject: [PATCH 4/4] ref(size): Estimate zip metadata size without zip and unzip The Apple download size estimate zipped the bundle with `zip -q -r` and parsed the totals out of `unzip -v` output. Neither binary exists in a distroless image, so build the archive with zip_directory() and sum the compressed sizes from the central directory. Info-ZIP also writes UT and ux extra fields into every header, 52 bytes per entry across the local and central records. Those bytes counted as metadata before, so add them back explicitly: reported download sizes stay identical and size comparisons spanning this change do not shift. Refs SEC-1090 Co-Authored-By: Claude --- src/launchpad/size/utils/apple_bundle_size.py | 68 +++++-------------- 1 file changed, 17 insertions(+), 51 deletions(-) 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()