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
+
+
+
+
+
+
+ Kernel-Agnostic Native
+ ARM64 Dylib Embedder — host-side
+ Mach-O patching for IPA-Patch tweaks.
+
@@ -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" },