Skip to content
Draft
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
2 changes: 1 addition & 1 deletion devservices/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions scripts/deps
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,13 @@ 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",
"https://github.com/google/bundletool/releases/download/1.18.1/bundletool-all-1.18.1.jar",
"675786493983787ffa11550bdb7c0715679a44e1643f3ff980a529e9c822595c",
binaries=[
Bin(source="bundletool.jar", target="bundletool.jar"),
Bin(text=BUNDLETOOL_SHIM, target="bundletool"),
],
),
Dep(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
27 changes: 7 additions & 20 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json
import plistlib
import shutil
import subprocess
import tempfile
import uuid

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 13 additions & 9 deletions src/launchpad/parsers/apple/macho_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
68 changes: 17 additions & 51 deletions src/launchpad/size/utils/apple_bundle_size.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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)

@oioki oioki Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This _INFOZIP_EXTRA_FIELD_BYTES_PER_ENTRY * len(infos) is a correction term to keep the stats intact after moving from zip binary to pythonic zipfile module. Maybe we could drop it and allow one-time drift for more correct reporting?


if metadata_size < 0:
logger.warning(
Expand All @@ -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()
57 changes: 36 additions & 21 deletions src/launchpad/size/utils/file_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]],
Expand Down
15 changes: 8 additions & 7 deletions src/launchpad/utils/android/apksigner.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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.
Expand All @@ -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))

Expand Down
Loading
Loading