tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

pkg.py (10011B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      4 
      5 import concurrent.futures
      6 import lzma
      7 import plistlib
      8 import struct
      9 import subprocess
     10 from pathlib import Path
     11 from string import Template
     12 from urllib.parse import quote
     13 
     14 import mozfile
     15 
     16 from mozbuild.util import cpu_count
     17 
     18 TEMPLATE_DIRECTORY = Path(__file__).parent / "apple_pkg"
     19 PBZX_CHUNK_SIZE = 16 * 1024 * 1024  # 16MB chunks
     20 
     21 
     22 def get_apple_template(name: str) -> Template:
     23    """
     24    Given <name>, open file at <TEMPLATE_DIRECTORY>/<name>, read contents and
     25        return as a Template
     26 
     27    Args:
     28        name: str, Filename for the template
     29 
     30    Returns:
     31        Template, loaded from file
     32    """
     33    tmpl_path = TEMPLATE_DIRECTORY / name
     34    if not tmpl_path.is_file():
     35        raise Exception(f"Could not find template: {tmpl_path}")
     36    with tmpl_path.open("r") as tmpl:
     37        contents = tmpl.read()
     38    return Template(contents)
     39 
     40 
     41 def save_text_file(content: str, destination: Path):
     42    """
     43    Saves a text file to <destination> with provided <content>
     44    Note: Overwrites contents
     45 
     46    Args:
     47        content: str, The desired contents of the file
     48        destination: Path, The file path
     49    """
     50    with destination.open("w") as out_fd:
     51        out_fd.write(content)
     52    print(f"Created text file at {destination}")
     53    print(f"Created text file size: {destination.stat().st_size} bytes")
     54 
     55 
     56 def get_app_info_plist(app_path: Path) -> dict:
     57    """
     58    Retrieve most information from Info.plist file of an app.
     59    The Info.plist file should be located in ?.app/Contents/Info.plist
     60 
     61    Note: Ignores properties that are not <string> type
     62 
     63    Args:
     64        app_path: Path, the .app file/directory path
     65 
     66    Returns:
     67        dict, the dictionary of properties found in Info.plist
     68    """
     69    info_plist = app_path / "Contents/Info.plist"
     70    if not info_plist.is_file():
     71        raise Exception(f"Could not find Info.plist in {info_plist}")
     72 
     73    print(f"Reading app Info.plist from: {info_plist}")
     74 
     75    with info_plist.open("rb") as plist_fd:
     76        data = plistlib.load(plist_fd)
     77 
     78    return data
     79 
     80 
     81 def create_payload(destination: Path, root_path: Path, cpio_tool: str):
     82    """
     83    Creates a payload at <destination> based on <root_path>
     84 
     85    Args:
     86        destination: Path, the destination Path
     87        root_path: Path, the root directory Path
     88        cpio_tool: str,
     89    """
     90    # Files to be cpio'd are root folder + contents
     91    file_list = ["./"] + get_relative_glob_list(root_path, "**/*")
     92 
     93    with mozfile.TemporaryDirectory() as tmp_dir:
     94        tmp_payload_path = Path(tmp_dir) / "Payload"
     95        print(f"Creating Payload with cpio from {root_path} to {tmp_payload_path}")
     96        print(f"Found {len(file_list)} files")
     97        with tmp_payload_path.open("wb") as tmp_payload:
     98            process = subprocess.run(
     99                [
    100                    cpio_tool,
    101                    "-o",  # copy-out mode
    102                    "--format",
    103                    "odc",  # old POSIX .1 portable format
    104                    "--owner",
    105                    "0:80",  # clean ownership
    106                ],
    107                check=False,
    108                stdout=tmp_payload,
    109                stderr=subprocess.PIPE,
    110                input="\n".join(file_list) + "\n",
    111                encoding="ascii",
    112                cwd=root_path,
    113            )
    114        # cpio outputs number of blocks to stderr
    115        print(f"[CPIO]: {process.stderr}")
    116        if process.returncode:
    117            raise Exception(f"CPIO error {process.returncode}")
    118 
    119        tmp_payload_size = tmp_payload_path.stat().st_size
    120        print(f"Uncompressed Payload size: {tmp_payload_size // 1024}kb")
    121 
    122        def compress_chunk(chunk):
    123            compressed_chunk = lzma.compress(chunk)
    124            return len(chunk), compressed_chunk
    125 
    126        def chunker(fileobj, chunk_size):
    127            while True:
    128                chunk = fileobj.read(chunk_size)
    129                if not chunk:
    130                    break
    131                yield chunk
    132 
    133        with tmp_payload_path.open("rb") as f_in, destination.open(
    134            "wb"
    135        ) as f_out, concurrent.futures.ThreadPoolExecutor(
    136            max_workers=cpu_count()
    137        ) as executor:
    138            f_out.write(b"pbzx")
    139            f_out.write(struct.pack(">Q", PBZX_CHUNK_SIZE))
    140            chunks = chunker(f_in, PBZX_CHUNK_SIZE)
    141            for uncompressed_size, compressed_chunk in executor.map(
    142                compress_chunk, chunks
    143            ):
    144                f_out.write(struct.pack(">Q", uncompressed_size))
    145                if len(compressed_chunk) < uncompressed_size:
    146                    f_out.write(struct.pack(">Q", len(compressed_chunk)))
    147                    f_out.write(compressed_chunk)
    148                else:
    149                    # Considering how unlikely this is, we prefer to just decompress
    150                    # here than to keep the original uncompressed chunk around
    151                    f_out.write(struct.pack(">Q", uncompressed_size))
    152                    f_out.write(lzma.decompress(compressed_chunk))
    153 
    154        print(f"Compressed Payload file to {destination}")
    155        print(f"Compressed Payload size: {destination.stat().st_size // 1024}kb")
    156 
    157 
    158 def create_bom(bom_path: Path, root_path: Path, mkbom_tool: Path):
    159    """
    160    Creates a Bill Of Materials file at <bom_path> based on <root_path>
    161 
    162    Args:
    163        bom_path: Path, destination Path for the BOM file
    164        root_path: Path, root directory Path
    165        mkbom_tool: Path, mkbom tool Path
    166    """
    167    print(f"Creating BOM file from {root_path} to {bom_path}")
    168    subprocess.check_call([
    169        mkbom_tool,
    170        "-u",
    171        "0",
    172        "-g",
    173        "80",
    174        str(root_path),
    175        str(bom_path),
    176    ])
    177    print(f"Created BOM File size: {bom_path.stat().st_size // 1024}kb")
    178 
    179 
    180 def get_relative_glob_list(source: Path, glob: str) -> list[str]:
    181    """
    182    Given a source path, return a list of relative path based on glob
    183 
    184    Args:
    185        source: Path, source directory Path
    186        glob: str, unix style glob
    187 
    188    Returns:
    189        list[str], paths found in source directory
    190    """
    191    return [f"./{c.relative_to(source)}" for c in source.glob(glob)]
    192 
    193 
    194 def xar_package_folder(source_path: Path, destination: Path, xar_tool: Path):
    195    """
    196    Create a pkg from <source_path> to <destination>
    197    The command is issued with <source_path> as cwd
    198 
    199    Args:
    200        source_path: Path, source absolute Path
    201        destination: Path, destination absolute Path
    202        xar_tool: Path, xar tool Path
    203    """
    204    if not source_path.is_absolute() or not destination.is_absolute():
    205        raise Exception("Source and destination should be absolute.")
    206 
    207    print(f"Creating pkg from {source_path} to {destination}")
    208    # Create a list of ./<file> - noting xar takes care of <file>/**
    209    file_list = get_relative_glob_list(source_path, "*")
    210 
    211    subprocess.check_call(
    212        [
    213            xar_tool,
    214            "--compression",
    215            "none",
    216            "-vcf",
    217            destination,
    218            *file_list,
    219        ],
    220        cwd=source_path,
    221    )
    222    print(f"Created PKG file to {destination}")
    223    print(f"Created PKG size: {destination.stat().st_size // 1024}kb")
    224 
    225 
    226 def create_pkg(
    227    source_app: Path,
    228    output_pkg: Path,
    229    mkbom_tool: Path,
    230    xar_tool: Path,
    231    cpio_tool: Path,
    232 ):
    233    """
    234    Create a mac PKG installer from <source_app> to <output_pkg>
    235 
    236    Args:
    237        source_app: Path, source .app file/directory Path
    238        output_pkg: Path, destination .pkg file
    239        mkbom_tool: Path, mkbom tool Path
    240        xar_tool: Path, xar tool Path
    241        cpio: Path, cpio tool Path
    242    """
    243 
    244    app_name = source_app.name.rsplit(".", maxsplit=1)[0]
    245 
    246    with mozfile.TemporaryDirectory() as tmpdir:
    247        root_path = Path(tmpdir) / "darwin/root"
    248        flat_path = Path(tmpdir) / "darwin/flat"
    249 
    250        # Create required directories
    251        # TODO: Investigate Resources folder contents for other lproj?
    252        (flat_path / "Resources/en.lproj").mkdir(parents=True, exist_ok=True)
    253        (flat_path / f"{app_name}.pkg").mkdir(parents=True, exist_ok=True)
    254        root_path.mkdir(parents=True, exist_ok=True)
    255 
    256        # Copy files over
    257        subprocess.check_call([
    258            "cp",
    259            "-R",
    260            str(source_app),
    261            str(root_path),
    262        ])
    263 
    264        # Count all files (innards + itself)
    265        file_count = len(list(source_app.glob("**/*"))) + 1
    266        print(f"Calculated source files count: {file_count}")
    267        # Get package contents size
    268        package_size = sum(f.stat().st_size for f in source_app.glob("**/*")) // 1024
    269        print(f"Calculated source package size: {package_size}kb")
    270 
    271        app_info = get_app_info_plist(source_app)
    272        app_info["numberOfFiles"] = file_count
    273        app_info["installKBytes"] = package_size
    274        app_info["app_name"] = app_name
    275        app_info["app_name_url_encoded"] = quote(app_name)
    276 
    277        # This seems arbitrary, there might be another way of doing it,
    278        #   but Info.plist doesn't provide the simple version we need
    279        major_version = app_info["CFBundleShortVersionString"].split(".")[0]
    280        app_info["simple_version"] = f"{major_version}.0.0"
    281 
    282        pkg_info_tmpl = get_apple_template("PackageInfo.template")
    283        pkg_info = pkg_info_tmpl.substitute(app_info)
    284        save_text_file(pkg_info, flat_path / f"{app_name}.pkg/PackageInfo")
    285 
    286        distribution_tmp = get_apple_template("Distribution.template")
    287        distribution = distribution_tmp.substitute(app_info)
    288        save_text_file(distribution, flat_path / "Distribution")
    289 
    290        payload_path = flat_path / f"{app_name}.pkg/Payload"
    291        create_payload(payload_path, root_path, cpio_tool)
    292 
    293        bom_path = flat_path / f"{app_name}.pkg/Bom"
    294        create_bom(bom_path, root_path, mkbom_tool)
    295 
    296        xar_package_folder(flat_path, output_pkg, xar_tool)