diff --git a/README.md b/README.md index 2829c27..ce0cc69 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -# Kanade — Kernel-Agnostic Native ARM64 Dylib Embedder +

Kanade

+ +

+ Kanade icon +

+ +

+ Kernel-Agnostic Native + ARM64 Dylib Embedder — host-side + Mach-O patching for IPA-Patch tweaks. +

license @@ -72,6 +82,35 @@ assets/*/dump.cs.index.json .cache/ ``` +## The input IPA must be decrypted + +`build_patched_ipa.sh --input` (and `verify_sites --ipa`) both expect a +**decrypted** IPA — the recipe pipeline cannot operate on anything else. + +Every App Store app ships **FairPlay-encrypted**: the main executable and its +frameworks are stored as ciphertext on disk, and the kernel only decrypts +them in memory at launch, on the device the purchase is tied to. Kanade +patches the target Mach-O **on disk, before signing** — it locates each hook +site by its machine-code prologue and overwrites it with `B `. Against +an encrypted binary those bytes are meaningless ciphertext, so the recipe can +neither find nor patch them. The input **must be decrypted first**. + +A *decrypted* IPA is one whose Mach-O slices have had the FairPlay layer +stripped — the `cryptid` flag cleared and the encrypted pages dumped in their +plaintext form. You make one **once per app version**, on a device that can +already run the target app: + +- **[TrollDecrypt](https://github.com/donato-fiore/TrollDecrypt)** (TrollStore) + — dumps a decrypted `.ipa` straight to the Files app. Easiest. +- **[palera1n](https://palera.in/) + Filza** — jailbreak, then pull the + decrypted bundle off disk. +- Any on-device FairPlay dumper works; the only requirement is that the + resulting `.ipa` is decrypted. + +Drop the result at `assets//AppName-.ipa` (gitignored). +Kanade ships only the tooling — never the target app — so you always supply +your own legally-obtained, decrypted copy. + ## Usage Add both submodules to your consumer project: diff --git a/icon.webp b/icon.webp new file mode 100644 index 0000000..52060a6 Binary files /dev/null and b/icon.webp differ diff --git a/pyproject.toml b/pyproject.toml index 488fc3c..4ad0f87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ipa-patch-shared" -version = "0.1.1" +version = "0.1.2" description = "Kanade — static-patch tooling for IPA-Patch projects (arm64 encoder, Mach-O ops, IPA pipeline)." readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_recipeload.py b/tests/test_recipeload.py new file mode 100644 index 0000000..a6bf00c --- /dev/null +++ b/tests/test_recipeload.py @@ -0,0 +1,87 @@ +"""Tests for ``tools.recipeload.load_recipe``. + +The loader resolves a ``--recipe`` value to a module. Its contract has +three branches that every patch/verify tool depends on, plus one safety +property that previously bit us: + + * a dotted name imports as-is, + * a bare name resolves ``recipes.`` first, + * a bare name that is itself a package (``recipes``) falls back to the + bare import, and + * a ModuleNotFoundError raised *inside* an existing recipe is NOT + swallowed as "recipe not found" — the real cause must surface. +""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest + +from tools.recipeload import load_recipe + + +def _write(pkg_root: Path, relpath: str, body: str) -> None: + target = pkg_root / relpath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(textwrap.dedent(body)) + + +@pytest.fixture +def recipes_on_path(tmp_path, monkeypatch): + """Build a throwaway ``recipes/`` package on a fresh sys.path entry.""" + monkeypatch.syspath_prepend(str(tmp_path)) + _write(tmp_path, "recipes/__init__.py", 'DYLIB_PATH = "from-package"\n') + _write(tmp_path, "recipes/kioutest.py", 'DYLIB_PATH = "from-submodule"\n') + # A recipe that exists but imports a module that does not. + _write( + tmp_path, + "recipes/broken.py", + "import a_module_that_does_not_exist # noqa: F401\n", + ) + # Drop any cached imports from a previous test run. + for mod in [m for m in sys.modules if m == "recipes" or m.startswith("recipes.")]: + monkeypatch.delitem(sys.modules, mod, raising=False) + return tmp_path + + +def test_dotted_name_imports_as_is(recipes_on_path): + mod = load_recipe("recipes.kioutest") + assert mod.DYLIB_PATH == "from-submodule" + + +def test_bare_name_prefers_recipes_submodule(recipes_on_path): + mod = load_recipe("kioutest") + assert mod.DYLIB_PATH == "from-submodule" + + +def test_bare_package_name_falls_back_to_direct_import(recipes_on_path): + # "recipes" -> "recipes.recipes" (absent) -> "recipes" (the package) + mod = load_recipe("recipes") + assert mod.DYLIB_PATH == "from-package" + + +def test_inner_import_error_is_not_masked(recipes_on_path): + # The recipe exists; the missing module is inside it. We must see the + # real ModuleNotFoundError, not a misleading "recipe not found". + with pytest.raises(ModuleNotFoundError) as exc: + load_recipe("recipes.broken") + assert exc.value.name == "a_module_that_does_not_exist" + + +def test_missing_recipe_raises_systemexit(recipes_on_path): + with pytest.raises(SystemExit) as exc: + load_recipe("does_not_exist_anywhere") + assert "does_not_exist_anywhere" in str(exc.value) + + +def test_dotted_name_missing_intermediate_package_is_systemexit(recipes_on_path): + # A multi-dot path whose intermediate package is absent raises + # ModuleNotFoundError(name="recipes.missing"); that ancestor of the + # candidate must be treated as "recipe not found" (clean SystemExit), + # not re-raised as a traceback. + with pytest.raises(SystemExit) as exc: + load_recipe("recipes.missing.sub") + assert "recipes.missing.sub" in str(exc.value) diff --git a/tools/patch_macho.py b/tools/patch_macho.py index 5229744..c436bd4 100644 --- a/tools/patch_macho.py +++ b/tools/patch_macho.py @@ -29,36 +29,12 @@ from __future__ import annotations import argparse -import importlib import os import sys from tools.caves import apply_patches from tools.machoops import add_lc_load_dylib, assert_slot_in_bss, reserve_hook_slot - - -def _load_recipe(name: str): - """Import a recipe module by short name (e.g. ``kioukifexporter``) - or fully-qualified module path (e.g. ``recipes.kioukifexporter``). - - Bare names without a dot are tried in two orders: - 1. ``recipes.`` — the common case (a sub-module of the - consumer's ``recipes/`` package, e.g. ``recipes.kioukifexporter``) - 2. ```` directly — handles the case where the consumer exposes - the entire recipe surface through a package ``__init__.py`` - (e.g. ``--recipe recipes`` maps to the ``recipes`` package itself) - Fully-qualified names (containing a dot) are imported as-is. - """ - candidates = [f"recipes.{name}", name] if "." not in name else [name] - last_exc: ImportError | None = None - for candidate in candidates: - try: - return importlib.import_module(candidate) - except ImportError as e: - last_exc = e - raise SystemExit( - f"error: failed to import recipe {name!r}: {last_exc}" - ) from last_exc +from tools.recipeload import load_recipe def main() -> int: @@ -95,7 +71,7 @@ def main() -> int: print(f"error: not a file: {args.target}", file=sys.stderr) return 2 - recipe = _load_recipe(args.recipe) + recipe = load_recipe(args.recipe) target_basename = getattr(recipe, "TARGET_BASENAME", None) if target_basename and not args.skip_target_check: if os.path.basename(args.target) != target_basename: diff --git a/tools/patch_plist.py b/tools/patch_plist.py index a0ec461..4e6d2b5 100644 --- a/tools/patch_plist.py +++ b/tools/patch_plist.py @@ -22,10 +22,11 @@ from __future__ import annotations import argparse -import importlib import plistlib import sys +from tools.recipeload import load_recipe + def _detect_format(raw: bytes) -> plistlib.PlistFormat: if raw.startswith(b"bplist"): @@ -51,12 +52,7 @@ def _parse_kv(s: str) -> tuple[str, object]: def _load_recipe_keys(name: str) -> dict: - if "." not in name: - name = f"recipes.{name}" - try: - mod = importlib.import_module(name) - except ImportError as e: - raise SystemExit(f"error: failed to import recipe {name!r}: {e}") from e + mod = load_recipe(name) keys = getattr(mod, "PLIST_KEYS", None) if not isinstance(keys, dict): raise SystemExit( diff --git a/tools/recipeload.py b/tools/recipeload.py new file mode 100644 index 0000000..2ff95b4 --- /dev/null +++ b/tools/recipeload.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Shared recipe-module loader for the patch/verify tools. + +A recipe is a Python module on ``PYTHONPATH`` describing what to patch. +Every tool that takes ``--recipe`` resolves the name the same way through +``load_recipe`` so the behaviour can never drift between tools again. + +Resolution order for a ``--recipe`` value: + * a fully-qualified module path (contains a dot) is imported as-is, e.g. + ``recipes.kioukifexporter``; + * a bare name is tried as ``recipes.`` first (the common case — a + sub-module of the consumer's ``recipes/`` package), then as ```` + directly, which handles consumers that expose the whole recipe surface + through a ``recipes`` package ``__init__.py`` (``--recipe recipes``). + +Only a genuinely-absent candidate triggers the fallback: a +``ModuleNotFoundError`` raised from *inside* a recipe that does exist (a +typo'd import within it) is re-raised unchanged, so a real error is never +masked as "recipe not found". +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + + +def load_recipe(name: str) -> ModuleType: + candidates = [name] if "." in name else [f"recipes.{name}", name] + last_missing: ModuleNotFoundError | None = None + for candidate in candidates: + try: + return importlib.import_module(candidate) + except ModuleNotFoundError as e: + # Swallow only "this candidate does not exist", which means the + # missing module is the candidate itself or one of its ancestor + # packages (e.g. candidate "recipes.foo.bar" with "recipes.foo" + # absent). If e.name is a descendant or unrelated module, the + # candidate imported but something *inside* it is missing — re-raise + # so the real cause is not hidden as "recipe not found". + missing = e.name or "" + if candidate == missing or candidate.startswith(f"{missing}."): + last_missing = e + continue + raise + raise SystemExit(f"error: failed to import recipe {name!r}: {last_missing}") diff --git a/tools/verify_lc_load.py b/tools/verify_lc_load.py index 22603d4..bc517fd 100644 --- a/tools/verify_lc_load.py +++ b/tools/verify_lc_load.py @@ -19,17 +19,13 @@ from __future__ import annotations import argparse -import importlib import sys +from tools.recipeload import load_recipe + def _needle_from_recipe(name: str) -> str: - if "." not in name: - name = f"recipes.{name}" - try: - mod = importlib.import_module(name) - except ImportError as e: - raise SystemExit(f"error: failed to import recipe {name!r}: {e}") from e + mod = load_recipe(name) needle = getattr(mod, "DYLIB_PATH", None) if not needle: raise SystemExit(f"error: recipe {name!r} does not define DYLIB_PATH") diff --git a/tools/verify_sites.py b/tools/verify_sites.py index abdad1f..1685c60 100644 --- a/tools/verify_sites.py +++ b/tools/verify_sites.py @@ -28,13 +28,14 @@ from __future__ import annotations import argparse -import importlib import json import os import sys import zipfile from typing import Iterable +from tools.recipeload import load_recipe + # --------------------------------------------------------------------------- # Site label parsing. # @@ -210,22 +211,8 @@ def read_prologue(args: argparse.Namespace, offset: int) -> bytes | None: # --------------------------------------------------------------------------- -def _load_recipe(name: str): - # If the caller passed a bare package name (e.g. "recipes") or a short - # name without a dot (e.g. "kiouenginebridge"), try importing it directly - # first; if that fails, fall back to prefixing "recipes." so that short - # names like "kiouenginebridge" still resolve to "recipes.kiouenginebridge". - candidates = [name] if "." in name else [name, f"recipes.{name}"] - for candidate in candidates: - try: - return importlib.import_module(candidate) - except ImportError: - continue - raise SystemExit(f"error: failed to import recipe {name!r}") - - def verify(args: argparse.Namespace) -> int: - recipe = _load_recipe(args.recipe) + recipe = load_recipe(args.recipe) # Recipes across IPA-Patch siblings use slightly different row shapes: # * KiouEditor / KiouKifExporter: 4-tuple (slot, off, prologue, label) # * KiouForge: 6-tuple (slot, off, prologue, kind, diff --git a/uv.lock b/uv.lock index 55b31f3..739b9d2 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "ipa-patch-shared" -version = "0.1.0" +version = "0.1.2" source = { virtual = "." } dependencies = [ { name = "lief" },