diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 305cc7c..d770eae 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] os: [ ubuntu-latest, macos-latest, windows-latest ] steps: diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 31034a1..de3a545 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b39781..5b1f798 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,9 @@ repos: - - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.9.3 - hooks: - - id: isort - args: [ '-m', 'HANGING_INDENT', '-l', '120','--check-only' ] - files: \.py$ - - - repo: https://github.com/pycqa/flake8 - rev: "7.0.0" - hooks: - - id: flake8 - args: [ '--count', '--select=E9,F63,F7,F82,F401,E741', '--show-source', '--statistics', '--max-complexity=10', '--max-line-length=127' ] - files: \.py$ \ No newline at end of file +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.14.2 + hooks: + # Run the linter. + - id: ruff-check + # Run the formatter. + - id: ruff-format \ No newline at end of file diff --git a/ipsw_parser/__main__.py b/ipsw_parser/__main__.py index a0a284d..7b08c42 100644 --- a/ipsw_parser/__main__.py +++ b/ipsw_parser/__main__.py @@ -12,58 +12,61 @@ coloredlogs.install(level=logging.DEBUG) -logging.getLogger('asyncio').disabled = True -logging.getLogger('parso.cache').disabled = True -logging.getLogger('parso.cache.pickle').disabled = True -logging.getLogger('parso.python.diff').disabled = True -logging.getLogger('humanfriendly.prompts').disabled = True -logging.getLogger('blib2to3.pgen2.driver').disabled = True -logging.getLogger('urllib3.connectionpool').disabled = True +logging.getLogger("asyncio").disabled = True +logging.getLogger("parso.cache").disabled = True +logging.getLogger("parso.cache.pickle").disabled = True +logging.getLogger("parso.python.diff").disabled = True +logging.getLogger("humanfriendly.prompts").disabled = True +logging.getLogger("blib2to3.pgen2.driver").disabled = True +logging.getLogger("urllib3.connectionpool").disabled = True logger = logging.getLogger(__name__) -PEM_DB_ENV_VAR = 'IPSW_PARSER_PEM_DB' +PEM_DB_ENV_VAR = "IPSW_PARSER_PEM_DB" def handle_ipsw_argument(ctx: click.Context, param: click.Argument, value: str) -> IPSW: - if value.startswith('http://') or value.startswith('https://'): + if value.startswith("http://") or value.startswith("https://"): return IPSW(RemoteZip(value)) return IPSW(ZipFile(Path(value).expanduser())) -ipsw_argument = click.argument('ipsw', callback=handle_ipsw_argument) -pem_db_option = click.option('--pem-db', envvar=PEM_DB_ENV_VAR, - help='Path DB file url (can be either a filesystem path or an HTTP URL). ' - 'Alternatively, use the IPSW_PARSER_PEM_DB envvar.') +ipsw_argument = click.argument("ipsw", callback=handle_ipsw_argument) +pem_db_option = click.option( + "--pem-db", + envvar=PEM_DB_ENV_VAR, + help="Path DB file url (can be either a filesystem path or an HTTP URL). " + "Alternatively, use the IPSW_PARSER_PEM_DB envvar.", +) @click.group() def cli() -> None: - """ CLI utility for extracting info from IPSW files """ + """CLI utility for extracting info from IPSW files""" pass -@cli.command('info') +@cli.command("info") @ipsw_argument def info(ipsw) -> None: - """ Parse given .ipsw basic info """ - print(f'SupportedProductTypes: {ipsw.build_manifest.supported_product_types}') - print(f'ProductVersion: {ipsw.build_manifest.product_version}') - print(f'ProductBuildVersion: {ipsw.build_manifest.product_build_version}') + """Parse given .ipsw basic info""" + print(f"SupportedProductTypes: {ipsw.build_manifest.supported_product_types}") + print(f"ProductVersion: {ipsw.build_manifest.product_version}") + print(f"ProductBuildVersion: {ipsw.build_manifest.product_build_version}") development_files = ipsw.get_development_files() if development_files: - print('DevelopmentFiles:') + print("DevelopmentFiles:") for file in development_files: - print(f'- {file}') + print(f"- {file}") -@cli.command('extract') +@cli.command("extract") @ipsw_argument -@click.argument('output', type=click.Path(exists=False)) +@click.argument("output", type=click.Path(exists=False)) @pem_db_option def extract(ipsw: IPSW, output: str, pem_db: Optional[str]) -> None: - """ Extract .ipsw into filesystem layout """ + """Extract .ipsw into filesystem layout""" output = Path(output) if not output.exists(): @@ -71,25 +74,26 @@ def extract(ipsw: IPSW, output: str, pem_db: Optional[str]) -> None: ipsw.build_manifest.build_identities[0].extract(output, pem_db=pem_db) ipsw.archive.extractall( - path=output, members=[f for f in ipsw.archive.filelist if f.filename.startswith('Firmware')]) + path=output, members=[f for f in ipsw.archive.filelist if f.filename.startswith("Firmware")] + ) -@cli.command('extract-kernel') +@cli.command("extract-kernel") @ipsw_argument -@click.argument('output', type=click.Path(exists=False)) -@click.option('--arch', help='Arch name to extract using lipo') +@click.argument("output", type=click.Path(exists=False)) +@click.option("--arch", help="Arch name to extract using lipo") def extract_kernel(ipsw: IPSW, output: str, arch: Optional[str]) -> None: - """ Extract kernelcache from given .ipsw into given output filename """ + """Extract kernelcache from given .ipsw into given output filename""" Path(output).write_bytes(ipsw.build_manifest.build_identities[0].get_kernelcache_payload(arch=arch)) -@cli.command('device-support') +@cli.command("device-support") @ipsw_argument @pem_db_option def device_support(ipsw: IPSW, pem_db: Optional[str]) -> None: - """ Create DeviceSupport directory """ + """Create DeviceSupport directory""" ipsw.create_device_support(pem_db=pem_db) -if __name__ == '__main__': +if __name__ == "__main__": cli() diff --git a/ipsw_parser/build_identity.py b/ipsw_parser/build_identity.py index c78a0c9..0f81fc7 100644 --- a/ipsw_parser/build_identity.py +++ b/ipsw_parser/build_identity.py @@ -13,74 +13,56 @@ from pyimg4 import IM4P from ipsw_parser.component import Component +from ipsw_parser.dsc import split_dsc logger = logging.getLogger(__name__) -AEA_MAGIC = b'AEA1' +AEA_MAGIC = b"AEA1" def extract_as(zipf: ZipFile, member_name, output_path, chunk_size=64 * 1024): """Extract a single member from a ZIP archive to a custom output path""" os.makedirs(os.path.dirname(output_path), exist_ok=True) - with zipf.open(member_name) as source, open(output_path, 'wb') as target: + with zipf.open(member_name) as source, open(output_path, "wb") as target: shutil.copyfileobj(source, target, length=chunk_size) def _extract_dmg(dmg: Path, output: Path, sub_path: Optional[Path] = None, pem_db: Optional[str] = None) -> None: - ipsw = local['ipsw'] - hdiutil = local['hdiutil'] + ipsw = local["ipsw"] + hdiutil = local["hdiutil"] # darwin system statistically have problems cleaning up after detaching the mountpoint with TemporaryDirectory() as temp_dir: temp_dir = Path(temp_dir) - mnt = temp_dir / 'mnt' + mnt = temp_dir / "mnt" mnt.mkdir() - with dmg.open('rb') as f: + with dmg.open("rb") as f: magic = f.read(len(AEA_MAGIC)) if magic == AEA_MAGIC: - logger.debug('Found Apple Encrypted Archive. Decrypting...') - aea_dmg = str(dmg.absolute()) + '.aea' + logger.debug("Found Apple Encrypted Archive. Decrypting...") + aea_dmg = str(dmg.absolute()) + ".aea" dmg.rename(aea_dmg) - dmg = temp_dir / Path(aea_dmg).name.rsplit('.', 1)[0] - args = ['fw', 'aea', aea_dmg, '-o', temp_dir] + dmg = temp_dir / Path(aea_dmg).name.rsplit(".", 1)[0] + args = ["fw", "aea", aea_dmg, "-o", temp_dir] if pem_db is not None: - if '://' in pem_db: + if "://" in pem_db: # create a local file containing it - temp_pem_db = temp_dir / 'pem-db.json' + temp_pem_db = temp_dir / "pem-db.json" temp_pem_db.write_text(requests.get(pem_db, verify=False).text) pem_db = temp_pem_db - args += ['--pem-db', pem_db] + args += ["--pem-db", pem_db] ipsw(args) - hdiutil('attach', '-mountpoint', mnt, dmg) + hdiutil("attach", "-mountpoint", mnt, dmg) try: - if sub_path is None: - src = mnt - else: - src = mnt / sub_path + src = mnt if sub_path is None else mnt / sub_path shutil.copytree(src, output, symlinks=True, dirs_exist_ok=True) except shutil.Error: # when overwriting the same files, some of them don't contain write permissions pass - hdiutil('detach', '-force', mnt) - - -def _split_dsc(root: Path) -> None: - ipsw = local['ipsw'] - dsc_paths = [ - root / 'System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64', - root / 'System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e', - root / 'private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64', - root / 'private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e'] - - for dsc in dsc_paths: - if not dsc.exists(): - continue - - logger.info(f'splitting DSC: {dsc}') - ipsw('dyld', 'split', dsc, '-o', root) + hdiutil("detach", "-force", mnt) class BuildIdentity(UserDict): @@ -90,30 +72,30 @@ def __init__(self, build_manifest, data): @cached_property def device_class(self) -> str: - return self['Info']['DeviceClass'].lower() + return self["Info"]["DeviceClass"].lower() @cached_property def restore_behavior(self) -> str: - return self['Info'].get('RestoreBehavior') + return self["Info"].get("RestoreBehavior") @cached_property def variant(self): - return self['Info'].get('Variant') + return self["Info"].get("Variant") @cached_property def macos_variant(self) -> str: - return self['Info'].get('MacOSVariant') + return self["Info"].get("MacOSVariant") @cached_property def manifest(self) -> dict: - return self['Manifest'] + return self["Manifest"] @cached_property def minimum_system_partition(self): - return self['Info'].get('MinimumSystemPartition') + return self["Info"].get("MinimumSystemPartition") def get_component_path(self, component: str) -> str: - return self.manifest[component]['Info']['Path'] + return self.manifest[component]["Info"]["Path"] def has_component(self, name: str) -> bool: return name in self.manifest @@ -121,79 +103,79 @@ def has_component(self, name: str) -> bool: def get_component(self, name: str, **args) -> Component: return Component(self, name, **args) - def extract_dsc(self, output: Path, pem_db: Optional[str] = None) -> None: + def extract_dsc(self, output: Path, pem_db: Optional[str] = None, split: bool = True) -> None: build_identity = self.build_manifest.build_identities[0] - if not build_identity.has_component('Cryptex1,SystemOS'): + if not build_identity.has_component("Cryptex1,SystemOS"): return - device_support_symbols_path = output / 'private/preboot/Cryptexes/OS/System' + device_support_symbols_path = output / "private/preboot/Cryptexes/OS/System" device_support_symbols_path.mkdir(parents=True, exist_ok=True) with TemporaryDirectory() as temp_dir: - system_os = Path(temp_dir) / 'system_os.dmg' - system_os_component_path = build_identity.get_component_path('Cryptex1,SystemOS') + system_os = Path(temp_dir) / "system_os.dmg" + system_os_component_path = build_identity.get_component_path("Cryptex1,SystemOS") extract_as(self.build_manifest.ipsw.archive, system_os_component_path, system_os) - _extract_dmg(system_os, device_support_symbols_path, - sub_path=Path('System'), pem_db=pem_db) - _split_dsc(output) + _extract_dmg(system_os, device_support_symbols_path, sub_path=Path("System"), pem_db=pem_db) + if split: + split_dsc(output) def get_kernelcache_payload(self, arch: Optional[str] = None) -> bytes: - im4p = IM4P(self.build_manifest.build_identities[0].get_component('KernelCache').data) + im4p = IM4P(self.build_manifest.build_identities[0].get_component("KernelCache").data) im4p.payload.decompress() payload = im4p.payload.output().data if arch is None: return payload with TemporaryDirectory() as temp_dir: - kernel_output = Path(temp_dir) / 'kernel' - local['ipsw']('macho', 'lipo', '-a', arch, kernel_output) - return Path(next(kernel_output.parent.glob(f'*.{arch}'))).read_bytes() + kernel_output = Path(temp_dir) / "kernel" + local["ipsw"]("macho", "lipo", "-a", arch, kernel_output) + return Path(next(kernel_output.parent.glob(f"*.{arch}"))).read_bytes() def extract(self, output: Path, pem_db: Optional[str] = None) -> None: - logger.info(f'extracting into: {output}') + logger.info(f"extracting into: {output}") build_identity = self.build_manifest.build_identities[0] - logger.info(f'extracting OS into: {output}') + logger.info(f"extracting OS into: {output}") with TemporaryDirectory() as temp_dir: - os_dmg = Path(temp_dir) / 'os.dmg' - os_component_path = build_identity.get_component_path('OS') + os_dmg = Path(temp_dir) / "os.dmg" + os_component_path = build_identity.get_component_path("OS") extract_as(self.build_manifest.ipsw.archive, os_component_path, os_dmg) _extract_dmg(os_dmg, output, pem_db=pem_db) - kernel_component = build_identity.get_component('KernelCache') + kernel_component = build_identity.get_component("KernelCache") kernel_path = Path(kernel_component.path) - kernel_output = output / 'System/Library/Caches/com.apple.kernelcaches' / kernel_path.parts[-1] + kernel_output = output / "System/Library/Caches/com.apple.kernelcaches" / kernel_path.parts[-1] kernel_output.parent.mkdir(parents=True, exist_ok=True) - logger.info(f'extracting kernel into: {kernel_output}') + logger.info(f"extracting kernel into: {kernel_output}") im4p = IM4P(kernel_component.data) im4p.payload.decompress() kernel_output.write_bytes(im4p.payload.output().data) try: # In case the kernel is a FAT image, extract the arm64 macho - local['ipsw']('macho', 'lipo', '-a', 'arm64', kernel_output) - list(kernel_output.parent.glob('*.arm64'))[0].rename(kernel_output) + local["ipsw"]("macho", "lipo", "-a", "arm64", kernel_output) + next(iter(kernel_output.parent.glob("*.arm64"))).rename(kernel_output) except ProcessExecutionError: pass - for cryptex in ('App', 'OS'): + for cryptex in ("App", "OS"): name = { - 'App': 'Cryptex1,AppOS', - 'OS': 'Cryptex1,SystemOS', + "App": "Cryptex1,AppOS", + "OS": "Cryptex1,SystemOS", }[cryptex] if not build_identity.has_component(name): continue - cryptex_path = output / 'private/preboot/Cryptexes' / cryptex + cryptex_path = output / "private/preboot/Cryptexes" / cryptex cryptex_path.mkdir(parents=True, exist_ok=True) - logger.info(f'extracting {name} into: {cryptex_path}') + logger.info(f"extracting {name} into: {cryptex_path}") with TemporaryDirectory() as temp_dir: - temp_component = (Path(temp_dir) / name).with_suffix('.dmg') + temp_component = (Path(temp_dir) / name).with_suffix(".dmg") component_path = build_identity.get_component_path(name) extract_as(self.build_manifest.ipsw.archive, component_path, temp_component) _extract_dmg(temp_component, cryptex_path, pem_db=pem_db) - _split_dsc(output) + split_dsc(output) diff --git a/ipsw_parser/build_manifest.py b/ipsw_parser/build_manifest.py index 74caef6..4a813db 100644 --- a/ipsw_parser/build_manifest.py +++ b/ipsw_parser/build_manifest.py @@ -1,4 +1,5 @@ import plistlib +from typing import Optional from cached_property import cached_property @@ -14,8 +15,8 @@ def __init__(self, ipsw, manifest: bytes): @cached_property def build_major(self) -> int: - build_major = '' - for i in self._manifest['ProductBuildVersion']: + build_major = "" + for i in self._manifest["ProductBuildVersion"]: if i.isdigit(): build_major += i else: @@ -25,45 +26,45 @@ def build_major(self) -> int: @cached_property def supported_product_types(self) -> list[str]: - return self._manifest['SupportedProductTypes'] + return self._manifest["SupportedProductTypes"] @cached_property def supported_product_types_family(self) -> str: product = self.supported_product_types[0] - if product.startswith('iBridge'): - return 'iBridge' - elif product.startswith('iPhone'): - return 'iPhone' - elif 'Mac' in product: - return 'Mac' + if product.startswith("iBridge"): + return "iBridge" + elif product.startswith("iPhone"): + return "iPhone" + elif "Mac" in product: + return "Mac" else: raise ValueError() @cached_property def product_version(self) -> str: - return self._manifest['ProductVersion'] + return self._manifest["ProductVersion"] @cached_property def product_build_version(self) -> str: - return self._manifest['ProductBuildVersion'] + return self._manifest["ProductBuildVersion"] - def get_build_identity(self, device_class: str, restore_behavior: str = None, variant: str = None) -> BuildIdentity: + def get_build_identity( + self, device_class: str, restore_behavior: Optional[str] = None, variant: Optional[str] = None + ) -> BuildIdentity: for build_identity in self.build_identities: - if variant is not None: - if variant not in build_identity.variant: - continue + if variant is not None and variant not in build_identity.variant: + continue if build_identity.device_class != device_class: continue - if restore_behavior is not None: - if build_identity.restore_behavior != restore_behavior: - continue + if restore_behavior is not None and build_identity.restore_behavior != restore_behavior: + continue return build_identity - raise NoSuchBuildIdentityError('failed to find the correct BuildIdentity from the BuildManifest') + raise NoSuchBuildIdentityError("failed to find the correct BuildIdentity from the BuildManifest") def _parse_build_identities(self) -> None: self.build_identities = [] - for build_identity in self._manifest['BuildIdentities']: + for build_identity in self._manifest["BuildIdentities"]: self.build_identities.append(BuildIdentity(self, build_identity)) diff --git a/ipsw_parser/component.py b/ipsw_parser/component.py index ed1566f..a1a0c99 100644 --- a/ipsw_parser/component.py +++ b/ipsw_parser/component.py @@ -1,5 +1,6 @@ import logging import os.path +from typing import Optional from cached_property import cached_property @@ -9,27 +10,34 @@ class TSSResponse(dict): @property def ap_img4_ticket(self): - ticket = self.get('ApImg4Ticket') + ticket = self.get("ApImg4Ticket") if ticket is None: - raise IpswException('TSS response doesn\'t contain a ApImg4Ticket') + raise IpswException("TSS response doesn't contain a ApImg4Ticket") return ticket @property def bb_ticket(self): - return self.get('BBTicket') + return self.get("BBTicket") def get_path_by_entry(self, component: str): node = self.get(component) if node is not None: - return node.get('Path') + return node.get("Path") return None class Component: - def __init__(self, build_identity, name: str, tss: TSSResponse = None, data: bytes = None, path: str = None): + def __init__( + self, + build_identity, + name: str, + tss: TSSResponse = None, + data: Optional[bytes] = None, + path: Optional[str] = None, + ): self.logger = logging.getLogger(__name__) self._tss = tss self.build_identity = build_identity @@ -47,19 +55,19 @@ def path(self) -> str: path = self._tss.get_path_by_entry(self.name) if path is None: - self.logger.debug(f'NOTE: No path for component {self.name} in TSS, will fetch from build_identity') + self.logger.debug(f"NOTE: No path for component {self.name} in TSS, will fetch from build_identity") if path is None: path = self.build_identity.get_component_path(self.name) if path is None: - raise IpswException(f'Failed to find component path for: {self.name}') + raise IpswException(f"Failed to find component path for: {self.name}") return path @cached_property def data(self) -> bytes: if self._data is None: - self.logger.debug(f'Extracting {os.path.basename(self.path)} ({self.path})') + self.logger.debug(f"Extracting {os.path.basename(self.path)} ({self.path})") return self.build_identity.build_manifest.ipsw.read(self.path) return self._data diff --git a/ipsw_parser/dsc.py b/ipsw_parser/dsc.py new file mode 100644 index 0000000..f2c121b --- /dev/null +++ b/ipsw_parser/dsc.py @@ -0,0 +1,83 @@ +import logging +import plistlib +from datetime import datetime +from pathlib import Path + +from plumbum import local + +logger = logging.getLogger(__name__) + + +def split_dsc(root: Path) -> None: + ipsw = local["ipsw"] + dsc_paths = [ + root / "System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64", + root / "System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e", + root / "private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64", + root / "private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e", + ] + + for dsc in dsc_paths: + if not dsc.exists(): + continue + + logger.info(f"splitting DSC: {dsc}") + ipsw("dyld", "split", dsc, "-o", root) + + +def get_device_support_path(product_type: str, product_version: str, product_build_version: str) -> Path: + """ + Construct the device support directory path. + + Args: + product_type: Product type (e.g., 'iPhone15,2') + product_version: Product version (e.g., '16.0') + product_build_version: Product build version (e.g., '20A362') + + Returns: + Path to the device support directory + """ + device_support_path = Path("~/Library/Developer/Xcode/iOS DeviceSupport").expanduser() + device_support_path /= f"{product_type} {product_version} ({product_build_version})" + return device_support_path + + +def create_device_support_layout( + product_type: str, product_version: str, product_build_version: str, root_path: Path +) -> Path: + """ + Split DSC and create the "device support" directory layout. + + Args: + product_type: Product type (e.g., 'iPhone15,2') + product_version: Product version (e.g., '16.0') + product_build_version: Product build version (e.g., '20A362') + root_path: System root path containing the extracted DSC symbols + + Returns: + Path to the created device support directory + """ + device_support_path = get_device_support_path(product_type, product_version, product_build_version) + + # Split DSC files + split_dsc(root_path) + + # Clean up the cryptex DSC files after splitting + cryptex_dsc_dir = root_path / "private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld" + if cryptex_dsc_dir.exists(): + for file in cryptex_dsc_dir.iterdir(): + file.unlink() + + # Create the device support metadata files + (device_support_path / "Info.plist").write_bytes( + plistlib.dumps({ + "DSC Extractor Version": "1228.0.0.0.0", + "DateCollected": datetime.now(), + "Version": "16.0", + }) + ) + (device_support_path / ".finalized").write_bytes(plistlib.dumps({})) + (device_support_path / ".processed_dyld_shared_cache_arm64e").touch() + (device_support_path / ".processing_lock").touch() + + return device_support_path diff --git a/ipsw_parser/firmware.py b/ipsw_parser/firmware.py index da5d4d2..ed6c42b 100644 --- a/ipsw_parser/firmware.py +++ b/ipsw_parser/firmware.py @@ -2,15 +2,15 @@ class Firmware: def __init__(self, firmware_path: str, ipsw): self._ipsw = ipsw self._firmware_path = firmware_path - self._manifest_data = self._ipsw.read(self.get_relative_path('manifest')) + self._manifest_data = self._ipsw.read(self.get_relative_path("manifest")) self._firmware_files = {} for filename in self._manifest_data.splitlines(): filename = filename.strip() component_name = self.get_component_name(filename) - self._firmware_files[component_name] = f'{firmware_path}/{filename}' + self._firmware_files[component_name] = f"{firmware_path}/{filename}" def get_relative_path(self, path: str): - return f'{self._firmware_path}/{path}' + return f"{self._firmware_path}/{path}" def get_files(self): return self._firmware_files @@ -18,22 +18,22 @@ def get_files(self): @staticmethod def get_component_name(filename): names = { - 'LLB': 'LLB', - 'iBoot': 'iBoot', - 'DeviceTree': 'DeviceTree', - 'applelogo': 'AppleLogo', - 'liquiddetect': 'Liquid', - 'lowpowermode': 'LowPowerWallet0', - 'recoverymode': 'RecoveryMode', - 'batterylow0': 'BatteryLow0', - 'batterylow1': 'BatteryLow1', - 'glyphcharging': 'BatteryCharging', - 'glyphplugin': 'BatteryPlugin', - 'batterycharging0': 'BatteryCharging0', - 'batterycharging1': 'BatteryCharging1', - 'batteryfull': 'BatteryFull', - 'needservice': 'NeedService', - 'SCAB': 'SCAB', - 'sep-firmware': 'RestoreSEP', + "LLB": "LLB", + "iBoot": "iBoot", + "DeviceTree": "DeviceTree", + "applelogo": "AppleLogo", + "liquiddetect": "Liquid", + "lowpowermode": "LowPowerWallet0", + "recoverymode": "RecoveryMode", + "batterylow0": "BatteryLow0", + "batterylow1": "BatteryLow1", + "glyphcharging": "BatteryCharging", + "glyphplugin": "BatteryPlugin", + "batterycharging0": "BatteryCharging0", + "batterycharging1": "BatteryCharging1", + "batteryfull": "BatteryFull", + "needservice": "NeedService", + "SCAB": "SCAB", + "sep-firmware": "RestoreSEP", } return names.get(filename) diff --git a/ipsw_parser/ipsw.py b/ipsw_parser/ipsw.py index 490ac84..f29963f 100644 --- a/ipsw_parser/ipsw.py +++ b/ipsw_parser/ipsw.py @@ -1,8 +1,6 @@ import logging -import plistlib import zipfile from contextlib import contextmanager -from datetime import datetime from pathlib import Path from typing import Optional @@ -10,22 +8,27 @@ from construct import Const, Default, PaddedString, Struct from ipsw_parser.build_manifest import BuildManifest +from ipsw_parser.dsc import create_device_support_layout from ipsw_parser.firmware import Firmware logger = logging.getLogger(__name__) cpio_odc_header = Struct( - 'c_magic' / Const('070707', PaddedString(6, 'utf8')), - 'c_dev' / Default(PaddedString(6, 'utf8'), '0' * 6, ), - 'c_ino' / PaddedString(6, 'utf8'), - 'c_mode' / PaddedString(6, 'utf8'), - 'c_uid' / Default(PaddedString(6, 'utf8'), '0' * 6), - 'c_gid' / Default(PaddedString(6, 'utf8'), '0' * 6), - 'c_nlink' / PaddedString(6, 'utf8'), - 'c_rdev' / Default(PaddedString(6, 'utf8'), '0' * 6), - 'c_mtime' / Default(PaddedString(11, 'utf8'), '0' * 11), - 'c_namesize' / PaddedString(6, 'utf8'), - 'c_filesize' / Default(PaddedString(11, 'utf8'), '0' * 11), + "c_magic" / Const("070707", PaddedString(6, "utf8")), + "c_dev" + / Default( + PaddedString(6, "utf8"), + "0" * 6, + ), + "c_ino" / PaddedString(6, "utf8"), + "c_mode" / PaddedString(6, "utf8"), + "c_uid" / Default(PaddedString(6, "utf8"), "0" * 6), + "c_gid" / Default(PaddedString(6, "utf8"), "0" * 6), + "c_nlink" / PaddedString(6, "utf8"), + "c_rdev" / Default(PaddedString(6, "utf8"), "0" * 6), + "c_mtime" / Default(PaddedString(11, "utf8"), "0" * 11), + "c_namesize" / PaddedString(6, "utf8"), + "c_filesize" / Default(PaddedString(11, "utf8"), "0" * 11), ) @@ -33,16 +36,20 @@ class IPSW: def __init__(self, archive: zipfile.ZipFile): self.archive = archive self._logger = logging.getLogger(__file__) - self.build_manifest = BuildManifest(self, self.archive.read( - next(f for f in self.archive.namelist() if f.startswith('BuildManifest') and f.endswith('.plist')))) + self.build_manifest = BuildManifest( + self, + self.archive.read( + next(f for f in self.archive.namelist() if f.startswith("BuildManifest") and f.endswith(".plist")) + ), + ) @cached_property def restore_version(self) -> bytes: - return self.read('RestoreVersion.plist') + return self.read("RestoreVersion.plist") @cached_property def system_version(self) -> bytes: - return self.read('SystemVersion.plist') + return self.read("SystemVersion.plist") @cached_property def filelist(self) -> list[zipfile.ZipInfo]: @@ -58,27 +65,31 @@ def open_path(self, path: str): @property def bootability(self) -> bytes: - result = b'' - prefix = 'BootabilityBundle/Restore/Bootability/' + result = b"" + prefix = "BootabilityBundle/Restore/Bootability/" inode = 1 nlink = 1 for e in self.filelist: - if e.filename == 'BootabilityBundle/Restore/Firmware/Bootability.dmg.trustcache': - subpath = 'Bootability.trustcache' + if e.filename == "BootabilityBundle/Restore/Firmware/Bootability.dmg.trustcache": + subpath = "Bootability.trustcache" elif not e.filename.startswith(prefix): continue else: - subpath = e.filename[len(prefix):] + subpath = e.filename[len(prefix) :] - self._logger.debug(f'BootabilityBundle: adding {subpath}') + self._logger.debug(f"BootabilityBundle: adding {subpath}") filename = subpath - filename = f'{filename}\0'.encode() + filename = f"{filename}\0".encode() mode = e.external_attr >> 16 result += cpio_odc_header.build({ - 'c_ino': f'{inode:06o}', 'c_nlink': f'{nlink:06o}', 'c_mode': f'{mode:06o}', - 'c_namesize': f'{len(filename):06o}', 'c_filesize': f'{e.file_size:011o}'}) + "c_ino": f"{inode:06o}", + "c_nlink": f"{nlink:06o}", + "c_mode": f"{mode:06o}", + "c_namesize": f"{len(filename):06o}", + "c_filesize": f"{e.file_size:011o}", + }) inode += 1 result += filename if not e.file_size: @@ -87,19 +98,25 @@ def bootability(self) -> bytes: with self.open_path(e.filename) as f: result += f.read() - filename = b'TRAILER!!!\0' + filename = b"TRAILER!!!\0" inode = 0 mode = 0 - result += cpio_odc_header.build( - {'c_ino': f'{inode:06o}', 'c_mode': f'{mode:06o}', 'c_nlink': f'{nlink:06o}', - 'c_namesize': f'{len(filename):06o}'}) + filename + result += ( + cpio_odc_header.build({ + "c_ino": f"{inode:06o}", + "c_mode": f"{mode:06o}", + "c_nlink": f"{nlink:06o}", + "c_namesize": f"{len(filename):06o}", + }) + + filename + ) return result def read(self, path: str) -> bytes: return self.archive.read(path) def get_global_manifest(self, macos_variant: str, device_class: str) -> bytes: - manifest_path = f'Firmware/Manifests/restore/{macos_variant}/apticket.{device_class}.im4m' + manifest_path = f"Firmware/Manifests/restore/{macos_variant}/apticket.{device_class}.im4m" return self.read(manifest_path) def get_firmware(self, firmware_path: str) -> Firmware: @@ -108,25 +125,23 @@ def get_firmware(self, firmware_path: str) -> Firmware: def get_development_files(self) -> list[str]: result = [] for entry in self.archive.namelist(): - for release in ('devel', 'kasan', 'research'): + for release in ("devel", "kasan", "research"): if release in entry.lower(): result.append(entry) return result def create_device_support(self, pem_db: Optional[str] = None) -> None: - device_support_path = Path('~/Library/Developer/Xcode/iOS DeviceSupport').expanduser() - device_support_path /= (f'{self.build_manifest.supported_product_types[0]} ' - f'{self.build_manifest.product_version} ({self.build_manifest.product_build_version})') + device_support_path = Path("~/Library/Developer/Xcode/iOS DeviceSupport").expanduser() + device_support_path /= ( + f"{self.build_manifest.supported_product_types[0]} " + f"{self.build_manifest.product_version} ({self.build_manifest.product_build_version})" + ) build_identity = self.build_manifest.build_identities[0] - symbols_path = device_support_path / 'Symbols' - build_identity.extract_dsc(symbols_path, pem_db=pem_db) - for file in (symbols_path / 'private/preboot/Cryptexes/OS/System/Library/Caches/com.apple.dyld').iterdir(): - file.unlink() - (device_support_path / 'Info.plist').write_bytes(plistlib.dumps({ - 'DSC Extractor Version': '1228.0.0.0.0', - 'DateCollected': datetime.now(), - 'Version': '16.0', - })) - (device_support_path / '.finalized').write_bytes(plistlib.dumps({})) - (device_support_path / '.processed_dyld_shared_cache_arm64e').touch() - (device_support_path / '.processing_lock').touch() + symbols_path = device_support_path / "Symbols" + build_identity.extract_dsc(symbols_path, pem_db=pem_db, split=False) + create_device_support_layout( + self.build_manifest.supported_product_types[0], + self.build_manifest.product_version, + self.build_manifest.product_build_version, + symbols_path, + ) diff --git a/pyproject.toml b/pyproject.toml index 5885f98..47fea61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", ] dynamic = ["dependencies", "version"] @@ -47,3 +48,52 @@ version_file = "ipsw_parser/_version.py" [build-system] requires = ["setuptools>=43.0.0", "setuptools_scm>=8", "wheel"] build-backend = "setuptools.build_meta" + +[tool.ruff] +target-version = "py39" +line-length = 120 +fix = true + +[tool.ruff.lint] +select = [ + # flake8-2020 + "YTT", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-comprehensions + "C4", + # flake8-debugger + "T10", + # flake8-simplify + "SIM", + # isort + "I", + # pycodestyle + "E", + "W", + # pyflakes + "F", + # pygrep-hooks + "PGH", + # pyupgrade + "UP", + # ruff + "RUF", + # tryceratops + "TRY", +] +ignore = [ + # LineTooLong + "E501", + # Custom error classes + "TRY003", + # Use `logging.exception` instead of `logging.error` + "TRY400", + # Abstract `raise` to an inner function + "TRY301" +] + +[tool.ruff.format] +preview = true