ipa-forge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipa_forge/__init__.py +1 -0
- ipa_forge/altstore/__init__.py +1 -0
- ipa_forge/altstore/source.py +44 -0
- ipa_forge/analysis/__init__.py +18 -0
- ipa_forge/analysis/classdump.py +103 -0
- ipa_forge/analysis/diff.py +127 -0
- ipa_forge/analysis/security.py +72 -0
- ipa_forge/analysis/strings.py +40 -0
- ipa_forge/analysis/symbols.py +42 -0
- ipa_forge/analysis/type_encoding.py +164 -0
- ipa_forge/bundle/__init__.py +1 -0
- ipa_forge/bundle/inventory.py +96 -0
- ipa_forge/bundle/ipa.py +81 -0
- ipa_forge/bundle/models.py +58 -0
- ipa_forge/bundle/plist.py +18 -0
- ipa_forge/cli/__init__.py +1 -0
- ipa_forge/cli/analysis.py +225 -0
- ipa_forge/cli/common.py +41 -0
- ipa_forge/cli/hooks.py +357 -0
- ipa_forge/cli/main.py +170 -0
- ipa_forge/gui/__init__.py +1 -0
- ipa_forge/gui/analysis.html +229 -0
- ipa_forge/gui/app.py +304 -0
- ipa_forge/gui/index.html +244 -0
- ipa_forge/hooks/__init__.py +0 -0
- ipa_forge/hooks/scan.py +81 -0
- ipa_forge/hooks/verify.py +348 -0
- ipa_forge/machO/__init__.py +1 -0
- ipa_forge/machO/arch.py +94 -0
- ipa_forge/machO/detect.py +50 -0
- ipa_forge/machO/injector.py +99 -0
- ipa_forge/machO/objc.py +534 -0
- ipa_forge/manifest.py +74 -0
- ipa_forge/patch/__init__.py +1 -0
- ipa_forge/patch/base.py +48 -0
- ipa_forge/patch/binary.py +131 -0
- ipa_forge/patch/dylib.py +59 -0
- ipa_forge/patch/engine.py +48 -0
- ipa_forge/patch/loader.py +88 -0
- ipa_forge/patch/paths.py +19 -0
- ipa_forge/patch/plist.py +61 -0
- ipa_forge/patch/resolver.py +37 -0
- ipa_forge/patch/resource.py +135 -0
- ipa_forge/patch/schema.py +118 -0
- ipa_forge/patch/version.py +25 -0
- ipa_forge/patches.py +76 -0
- ipa_forge/pipeline.py +235 -0
- ipa_forge/signing/__init__.py +1 -0
- ipa_forge/signing/backend.py +111 -0
- ipa_forge/signing/pipeline.py +99 -0
- ipa_forge/signing/profile.py +131 -0
- ipa_forge/signing/provider.py +113 -0
- ipa_forge/signing/reconcile.py +27 -0
- ipa_forge/validators/__init__.py +1 -0
- ipa_forge/validators/archive_validator.py +25 -0
- ipa_forge/validators/bundle_validator.py +27 -0
- ipa_forge/validators/ipa_validator.py +39 -0
- ipa_forge-0.1.0.dist-info/METADATA +914 -0
- ipa_forge-0.1.0.dist-info/RECORD +62 -0
- ipa_forge-0.1.0.dist-info/WHEEL +4 -0
- ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
- ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Deterministic byte-pattern binary patching, bounded to a specific arch slice."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ipa_forge.machO.arch import (
|
|
10
|
+
AmbiguousArchError,
|
|
11
|
+
ArchNotFoundError,
|
|
12
|
+
NotMachOError,
|
|
13
|
+
slice_byte_range,
|
|
14
|
+
)
|
|
15
|
+
from ipa_forge.patch.base import PatchContext, PatchResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PatternError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_hex_pattern(pattern: str) -> tuple[bytes, bytes]:
|
|
23
|
+
"""Parse a space-separated hex byte pattern with `??` as a wildcard byte.
|
|
24
|
+
|
|
25
|
+
Returns (bytes, mask) where mask[i] == 0xFF means that byte must match
|
|
26
|
+
exactly and mask[i] == 0x00 means "don't care".
|
|
27
|
+
"""
|
|
28
|
+
tokens = pattern.split()
|
|
29
|
+
if not tokens:
|
|
30
|
+
raise PatternError("empty pattern")
|
|
31
|
+
data = bytearray()
|
|
32
|
+
mask = bytearray()
|
|
33
|
+
for tok in tokens:
|
|
34
|
+
if tok == "??":
|
|
35
|
+
data.append(0)
|
|
36
|
+
mask.append(0)
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
value = int(tok, 16)
|
|
40
|
+
except ValueError as e:
|
|
41
|
+
raise PatternError(f"invalid hex byte token '{tok}'") from e
|
|
42
|
+
if not 0 <= value <= 0xFF:
|
|
43
|
+
raise PatternError(f"byte token '{tok}' out of range")
|
|
44
|
+
data.append(value)
|
|
45
|
+
mask.append(0xFF)
|
|
46
|
+
return bytes(data), bytes(mask)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_matches(haystack: bytes, pattern: bytes, mask: bytes, start: int = 0, end: int | None = None) -> list[int]:
|
|
50
|
+
"""Return absolute offsets in `haystack` where `pattern` (respecting `mask`)
|
|
51
|
+
matches, restricted to the [start, end) window."""
|
|
52
|
+
end = len(haystack) if end is None else end
|
|
53
|
+
plen = len(pattern)
|
|
54
|
+
offsets = []
|
|
55
|
+
for i in range(start, end - plen + 1):
|
|
56
|
+
window = haystack[i : i + plen]
|
|
57
|
+
if all((b & m) == (p & m) for b, p, m in zip(window, pattern, mask, strict=True)):
|
|
58
|
+
offsets.append(i)
|
|
59
|
+
return offsets
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class BinaryReplaceOp:
|
|
64
|
+
op_id: str
|
|
65
|
+
executable: str
|
|
66
|
+
pattern: str
|
|
67
|
+
replacement: str
|
|
68
|
+
expected_matches: int = 1
|
|
69
|
+
arch: str | None = None
|
|
70
|
+
|
|
71
|
+
def _resolve_target(self, ctx: PatchContext) -> Path:
|
|
72
|
+
for target in ctx.bundle.executables:
|
|
73
|
+
if Path(target.bundle_relative).name == self.executable:
|
|
74
|
+
return target.path
|
|
75
|
+
raise FileNotFoundError(f"executable '{self.executable}' not found in bundle inventory")
|
|
76
|
+
|
|
77
|
+
def _plan(self, ctx: PatchContext) -> tuple[Path, bytes, bytes, bytes, list[int]]:
|
|
78
|
+
target_path = self._resolve_target(ctx)
|
|
79
|
+
start, end = slice_byte_range(target_path, self.arch)
|
|
80
|
+
data = target_path.read_bytes()
|
|
81
|
+
pattern, mask = parse_hex_pattern(self.pattern)
|
|
82
|
+
offsets = find_matches(data, pattern, mask, start=start, end=end)
|
|
83
|
+
return target_path, data, pattern, mask, offsets
|
|
84
|
+
|
|
85
|
+
def _check_match_count(self, offsets: list[int]) -> str | None:
|
|
86
|
+
if len(offsets) != self.expected_matches:
|
|
87
|
+
return f"expected {self.expected_matches} match(es), found {len(offsets)} at offsets {offsets}"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
91
|
+
try:
|
|
92
|
+
_, _, _, _, offsets = self._plan(ctx)
|
|
93
|
+
except (FileNotFoundError, PatternError, NotMachOError, AmbiguousArchError, ArchNotFoundError) as e:
|
|
94
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
95
|
+
|
|
96
|
+
error = self._check_match_count(offsets)
|
|
97
|
+
if error:
|
|
98
|
+
return PatchResult(op_id=self.op_id, status="failed", message=error)
|
|
99
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
100
|
+
|
|
101
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
102
|
+
try:
|
|
103
|
+
target_path, data, pattern, _, offsets = self._plan(ctx)
|
|
104
|
+
except (FileNotFoundError, PatternError, NotMachOError, AmbiguousArchError, ArchNotFoundError) as e:
|
|
105
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
106
|
+
|
|
107
|
+
error = self._check_match_count(offsets)
|
|
108
|
+
if error:
|
|
109
|
+
return PatchResult(op_id=self.op_id, status="failed", message=error)
|
|
110
|
+
|
|
111
|
+
replacement, _ = parse_hex_pattern(self.replacement)
|
|
112
|
+
if len(replacement) != len(pattern):
|
|
113
|
+
return PatchResult(
|
|
114
|
+
op_id=self.op_id,
|
|
115
|
+
status="failed",
|
|
116
|
+
message=f"replacement length ({len(replacement)}) must equal pattern length ({len(pattern)})",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
buf = bytearray(data)
|
|
120
|
+
for offset in offsets:
|
|
121
|
+
buf[offset : offset + len(replacement)] = replacement
|
|
122
|
+
target_path.write_bytes(bytes(buf))
|
|
123
|
+
|
|
124
|
+
return PatchResult(
|
|
125
|
+
op_id=self.op_id,
|
|
126
|
+
status="applied",
|
|
127
|
+
message=f"replaced {len(offsets)} match(es) at {offsets}",
|
|
128
|
+
files_touched=[target_path],
|
|
129
|
+
macho_modified=True,
|
|
130
|
+
category="modified",
|
|
131
|
+
)
|
ipa_forge/patch/dylib.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Dylib load-command injection patch operation.
|
|
3
|
+
|
|
4
|
+
Assumes the dylib file is already present in the bundle (placed there by a
|
|
5
|
+
separate resource_add operation, or shipped in the original app) -- this
|
|
6
|
+
operation's only job is the Mach-O load-command edit itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ipa_forge.machO.injector import InjectionStatus, inject_dylib
|
|
15
|
+
from ipa_forge.patch.base import PatchContext, PatchResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DylibInjectOp:
|
|
20
|
+
op_id: str
|
|
21
|
+
executable: str
|
|
22
|
+
install_name: str
|
|
23
|
+
arch: str | None = None
|
|
24
|
+
load_command: str = "LC_LOAD_DYLIB"
|
|
25
|
+
|
|
26
|
+
def _resolve_target(self, ctx: PatchContext) -> Path:
|
|
27
|
+
for target in ctx.bundle.executables:
|
|
28
|
+
if Path(target.bundle_relative).name == self.executable:
|
|
29
|
+
return target.path
|
|
30
|
+
raise FileNotFoundError(f"executable '{self.executable}' not found in bundle inventory")
|
|
31
|
+
|
|
32
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
33
|
+
try:
|
|
34
|
+
self._resolve_target(ctx)
|
|
35
|
+
except FileNotFoundError as e:
|
|
36
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
37
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
38
|
+
|
|
39
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
40
|
+
try:
|
|
41
|
+
target_path = self._resolve_target(ctx)
|
|
42
|
+
except FileNotFoundError as e:
|
|
43
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
44
|
+
|
|
45
|
+
result = inject_dylib(target_path, self.install_name, arch=self.arch, load_command=self.load_command)
|
|
46
|
+
|
|
47
|
+
if result.status == InjectionStatus.INJECTED:
|
|
48
|
+
return PatchResult(
|
|
49
|
+
op_id=self.op_id,
|
|
50
|
+
status="applied",
|
|
51
|
+
message=result.message,
|
|
52
|
+
files_touched=[target_path],
|
|
53
|
+
macho_modified=True,
|
|
54
|
+
category="modified",
|
|
55
|
+
)
|
|
56
|
+
if result.status == InjectionStatus.INJECTION_SKIPPED:
|
|
57
|
+
return PatchResult(op_id=self.op_id, status="skipped", message=result.message)
|
|
58
|
+
# INJECTION_UNSUPPORTED and INJECTION_FAILED both mean the patch did not apply.
|
|
59
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"{result.status.value}: {result.message}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Dry-run gate + ordered apply for a resolved set of patch operations.
|
|
3
|
+
|
|
4
|
+
Enforces pipeline stages 5-8: every operation must report dry_run_ok before
|
|
5
|
+
anything mutates, then mutations run resource/plist ops -> binary ops ->
|
|
6
|
+
dylib injection last (LIEF load-command rewrites can shift byte offsets
|
|
7
|
+
binary patches depend on, so injection must never run before binary
|
|
8
|
+
patching). Resource and plist edits share a rank: neither touches Mach-O
|
|
9
|
+
layout, so their relative order carries no correctness constraint.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ipa_forge.patch.base import PatchContext, PatchOperation, PatchResult
|
|
15
|
+
from ipa_forge.patch.binary import BinaryReplaceOp
|
|
16
|
+
from ipa_forge.patch.dylib import DylibInjectOp
|
|
17
|
+
from ipa_forge.patch.plist import PlistEditOp
|
|
18
|
+
from ipa_forge.patch.resource import ResourceAddOp, ResourceRemoveOp, ResourceReplaceOp
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PatchValidationError(Exception):
|
|
22
|
+
def __init__(self, results: list[PatchResult]):
|
|
23
|
+
self.results = results
|
|
24
|
+
failures = "; ".join(f"{r.op_id}: {r.message}" for r in results if r.status == "failed")
|
|
25
|
+
super().__init__(f"patch dry-run validation failed: {failures}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dry_run_all(ops: list[PatchOperation], ctx: PatchContext) -> list[PatchResult]:
|
|
29
|
+
return [op.dry_run(ctx) for op in ops]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _apply_order_rank(op: PatchOperation) -> int:
|
|
33
|
+
if isinstance(op, (ResourceReplaceOp, ResourceAddOp, ResourceRemoveOp, PlistEditOp)):
|
|
34
|
+
return 0
|
|
35
|
+
if isinstance(op, BinaryReplaceOp):
|
|
36
|
+
return 1
|
|
37
|
+
if isinstance(op, DylibInjectOp):
|
|
38
|
+
return 2
|
|
39
|
+
return 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def apply_all(ops: list[PatchOperation], ctx: PatchContext) -> list[PatchResult]:
|
|
43
|
+
dry_results = dry_run_all(ops, ctx)
|
|
44
|
+
if any(r.status != "dry_run_ok" for r in dry_results):
|
|
45
|
+
raise PatchValidationError(dry_results)
|
|
46
|
+
|
|
47
|
+
ordered = sorted(ops, key=_apply_order_rank)
|
|
48
|
+
return [op.apply(ctx) for op in ordered]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Loads patch-definition YAML/JSON files and builds concrete PatchOperation instances."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ipa_forge.patch.base import PatchOperation
|
|
12
|
+
from ipa_forge.patch.binary import BinaryReplaceOp
|
|
13
|
+
from ipa_forge.patch.dylib import DylibInjectOp
|
|
14
|
+
from ipa_forge.patch.plist import PlistEditOp
|
|
15
|
+
from ipa_forge.patch.resource import ResourceAddOp, ResourceRemoveOp, ResourceReplaceOp
|
|
16
|
+
from ipa_forge.patch.schema import (
|
|
17
|
+
BinaryReplaceSpec,
|
|
18
|
+
DylibInjectSpec,
|
|
19
|
+
PatchDefinition,
|
|
20
|
+
PlistEditSpec,
|
|
21
|
+
ResourceAddSpec,
|
|
22
|
+
ResourceRemoveSpec,
|
|
23
|
+
ResourceReplaceSpec,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PatchLoadError(Exception):
|
|
28
|
+
"""Raised when the patch-definition file cannot be read, parsed as YAML, or validated."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validation_error_details(error: ValidationError) -> str:
|
|
32
|
+
"""Compact single-line summary of a pydantic ValidationError."""
|
|
33
|
+
return "; ".join(f"{'.'.join(map(str, err['loc'])) or '<root>'}: {err['msg']}" for err in error.errors())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_patch_definition(path: Path) -> PatchDefinition:
|
|
37
|
+
try:
|
|
38
|
+
with open(path) as f:
|
|
39
|
+
raw = yaml.safe_load(f)
|
|
40
|
+
except OSError as e:
|
|
41
|
+
raise PatchLoadError(f"cannot read patch definition '{path}': {e}") from e
|
|
42
|
+
except yaml.YAMLError as e:
|
|
43
|
+
raise PatchLoadError(f"patch definition '{path}' is not valid YAML: {e}") from e
|
|
44
|
+
try:
|
|
45
|
+
return PatchDefinition.model_validate(raw)
|
|
46
|
+
except ValidationError as e:
|
|
47
|
+
# Surface schema violations as a single-line actionable error rather
|
|
48
|
+
# than letting pydantic's multi-line ValidationError escape as a
|
|
49
|
+
# traceback (F2).
|
|
50
|
+
raise PatchLoadError(f"patch definition '{path}' is invalid: {_validation_error_details(e)}") from e
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_operations(definition: PatchDefinition) -> list[PatchOperation]:
|
|
54
|
+
ops: list[PatchOperation] = []
|
|
55
|
+
for spec in definition.patches:
|
|
56
|
+
if isinstance(spec, BinaryReplaceSpec):
|
|
57
|
+
ops.append(
|
|
58
|
+
BinaryReplaceOp(
|
|
59
|
+
op_id=spec.id,
|
|
60
|
+
executable=spec.executable,
|
|
61
|
+
pattern=spec.pattern,
|
|
62
|
+
replacement=spec.replacement,
|
|
63
|
+
expected_matches=spec.expected_matches,
|
|
64
|
+
arch=spec.arch,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
elif isinstance(spec, ResourceReplaceSpec):
|
|
68
|
+
ops.append(ResourceReplaceOp(op_id=spec.id, path=spec.path, source=spec.source))
|
|
69
|
+
elif isinstance(spec, ResourceAddSpec):
|
|
70
|
+
ops.append(ResourceAddOp(op_id=spec.id, path=spec.path, source=spec.source))
|
|
71
|
+
elif isinstance(spec, ResourceRemoveSpec):
|
|
72
|
+
ops.append(ResourceRemoveOp(op_id=spec.id, path=spec.path))
|
|
73
|
+
elif isinstance(spec, DylibInjectSpec):
|
|
74
|
+
ops.append(
|
|
75
|
+
DylibInjectOp(
|
|
76
|
+
op_id=spec.id,
|
|
77
|
+
executable=spec.executable,
|
|
78
|
+
install_name=spec.install_name,
|
|
79
|
+
arch=spec.arch,
|
|
80
|
+
load_command=spec.load_command,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
elif isinstance(spec, PlistEditSpec):
|
|
84
|
+
ops.append(PlistEditOp(op_id=spec.id, action=spec.action, key=spec.key, value=spec.value, path=spec.path))
|
|
85
|
+
else:
|
|
86
|
+
# Unreachable via the pydantic discriminated union in patch/schema.py
|
|
87
|
+
raise TypeError(f"unknown patch spec type: {spec!r}")
|
|
88
|
+
return ops
|
ipa_forge/patch/paths.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Bundle-relative path resolution shared by patch operations that touch
|
|
3
|
+
files inside the app bundle (resource_replace/add/remove, plist_edit)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BundlePathError(Exception):
|
|
11
|
+
"""Raised when a bundle-relative path would escape the app bundle root."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_bundle_path(bundle_root: Path, relative: str) -> Path:
|
|
15
|
+
bundle_root = bundle_root.resolve()
|
|
16
|
+
candidate = (bundle_root / relative).resolve()
|
|
17
|
+
if candidate != bundle_root and bundle_root not in candidate.parents:
|
|
18
|
+
raise BundlePathError(f"destination '{relative}' escapes the app bundle root")
|
|
19
|
+
return candidate
|
ipa_forge/patch/plist.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Info.plist key set/remove operation, following the same single-file,
|
|
3
|
+
single-responsibility pattern as the resource operations."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from ipa_forge.bundle.plist import read_plist, write_plist
|
|
12
|
+
from ipa_forge.patch.base import PatchContext, PatchResult
|
|
13
|
+
from ipa_forge.patch.paths import BundlePathError, resolve_bundle_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PlistEditOp:
|
|
18
|
+
op_id: str
|
|
19
|
+
action: Literal["set", "remove"]
|
|
20
|
+
key: str
|
|
21
|
+
value: Any = None
|
|
22
|
+
path: str = "Info.plist"
|
|
23
|
+
|
|
24
|
+
def _resolve(self, ctx: PatchContext) -> Path:
|
|
25
|
+
return resolve_bundle_path(ctx.bundle.root, self.path)
|
|
26
|
+
|
|
27
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
28
|
+
try:
|
|
29
|
+
target = self._resolve(ctx)
|
|
30
|
+
except BundlePathError as e:
|
|
31
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
32
|
+
if not target.is_file():
|
|
33
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"plist '{target}' does not exist")
|
|
34
|
+
try:
|
|
35
|
+
plist = read_plist(target)
|
|
36
|
+
except (OSError, ValueError) as e: # plistlib can only raise these on malformed input
|
|
37
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"failed to parse '{target}': {e}")
|
|
38
|
+
if self.action == "remove" and self.key not in plist:
|
|
39
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"key '{self.key}' not present in {target}")
|
|
40
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
41
|
+
|
|
42
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
43
|
+
dry = self.dry_run(ctx)
|
|
44
|
+
if dry.status != "dry_run_ok":
|
|
45
|
+
return dry
|
|
46
|
+
|
|
47
|
+
target = self._resolve(ctx)
|
|
48
|
+
plist = read_plist(target)
|
|
49
|
+
if self.action == "set":
|
|
50
|
+
plist[self.key] = self.value
|
|
51
|
+
else:
|
|
52
|
+
del plist[self.key]
|
|
53
|
+
write_plist(target, plist)
|
|
54
|
+
|
|
55
|
+
return PatchResult(
|
|
56
|
+
op_id=self.op_id,
|
|
57
|
+
status="applied",
|
|
58
|
+
message=f"{self.action} '{self.key}' in {target}",
|
|
59
|
+
files_touched=[target],
|
|
60
|
+
category="modified",
|
|
61
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Selects which patch definitions apply to a given bundle, by bundle_id + version."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ipa_forge.bundle.models import AppBundle
|
|
7
|
+
from ipa_forge.patch.schema import PatchDefinition, VersionExact
|
|
8
|
+
from ipa_forge.patch.version import parse_version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def target_matches(bundle: AppBundle, definition: PatchDefinition) -> bool:
|
|
12
|
+
if bundle.bundle_id != definition.target.bundle_id:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
version_spec = definition.target.version
|
|
16
|
+
if isinstance(version_spec, VersionExact):
|
|
17
|
+
return bundle.version == version_spec.exact
|
|
18
|
+
|
|
19
|
+
v = parse_version(bundle.version)
|
|
20
|
+
if version_spec.min is not None and v < parse_version(version_spec.min):
|
|
21
|
+
return False
|
|
22
|
+
return version_spec.max is None or v < parse_version(version_spec.max)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_definitions(
|
|
26
|
+
bundle: AppBundle, definitions: list[PatchDefinition], *, ignore_version: bool = False
|
|
27
|
+
) -> list[PatchDefinition]:
|
|
28
|
+
"""Select definitions whose bundle id matches; version must match unless
|
|
29
|
+
``ignore_version`` (used by the GUI's non-blocking mismatch path — the
|
|
30
|
+
hooks gate still guards what actually attaches)."""
|
|
31
|
+
result = []
|
|
32
|
+
for d in definitions:
|
|
33
|
+
if bundle.bundle_id != d.target.bundle_id:
|
|
34
|
+
continue
|
|
35
|
+
if ignore_version or target_matches(bundle, d):
|
|
36
|
+
result.append(d)
|
|
37
|
+
return result
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Resource file replace/add/remove operations, sandboxed to the app bundle root.
|
|
3
|
+
|
|
4
|
+
Trusted-input model: `source:` paths resolve relative to the patch definition
|
|
5
|
+
file and are intentionally *not* sandboxed -- the definition, the IPA, and the
|
|
6
|
+
signing credentials are all supplied by the same trusted user, so a definition
|
|
7
|
+
may reference an absolute path or sibling directory (F9). Revisit if the GUI
|
|
8
|
+
ever serves untrusted/multi-user uploads.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ipa_forge.patch.base import PatchContext, PatchResult
|
|
18
|
+
from ipa_forge.patch.paths import BundlePathError, resolve_bundle_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _SourceCopyOp:
|
|
23
|
+
"""Shared fields + resolution for ops that copy a patch-source-relative
|
|
24
|
+
file/dir into the bundle. `path` is sandboxed to the bundle root; `source`
|
|
25
|
+
resolves against the patch set dir (trusted-input model, see F9)."""
|
|
26
|
+
|
|
27
|
+
op_id: str
|
|
28
|
+
path: str
|
|
29
|
+
source: str
|
|
30
|
+
|
|
31
|
+
def _resolve(self, ctx: PatchContext) -> tuple[Path, Path]:
|
|
32
|
+
dest = resolve_bundle_path(ctx.bundle.root, self.path)
|
|
33
|
+
src = (ctx.patch_source_dir / self.source).resolve()
|
|
34
|
+
return dest, src
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ResourceReplaceOp(_SourceCopyOp):
|
|
39
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
40
|
+
try:
|
|
41
|
+
dest, src = self._resolve(ctx)
|
|
42
|
+
except BundlePathError as e:
|
|
43
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
44
|
+
if not src.is_file():
|
|
45
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"source '{src}' does not exist")
|
|
46
|
+
if not dest.is_file():
|
|
47
|
+
return PatchResult(
|
|
48
|
+
op_id=self.op_id,
|
|
49
|
+
status="failed",
|
|
50
|
+
message=f"destination '{dest}' does not exist (use resource_add to create new files)",
|
|
51
|
+
)
|
|
52
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
53
|
+
|
|
54
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
55
|
+
dry = self.dry_run(ctx)
|
|
56
|
+
if dry.status != "dry_run_ok":
|
|
57
|
+
return dry
|
|
58
|
+
dest, src = self._resolve(ctx)
|
|
59
|
+
try:
|
|
60
|
+
dest.write_bytes(src.read_bytes())
|
|
61
|
+
except OSError as e:
|
|
62
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"failed to replace '{dest}': {e}")
|
|
63
|
+
return PatchResult(
|
|
64
|
+
op_id=self.op_id, status="applied", message=f"replaced {dest}", files_touched=[dest], category="modified"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ResourceAddOp(_SourceCopyOp):
|
|
70
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
71
|
+
try:
|
|
72
|
+
dest, src = self._resolve(ctx)
|
|
73
|
+
except BundlePathError as e:
|
|
74
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
75
|
+
if not src.exists():
|
|
76
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"source '{src}' does not exist")
|
|
77
|
+
if dest.exists():
|
|
78
|
+
return PatchResult(
|
|
79
|
+
op_id=self.op_id,
|
|
80
|
+
status="failed",
|
|
81
|
+
message=f"destination '{dest}' already exists (use resource_replace to overwrite)",
|
|
82
|
+
)
|
|
83
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
84
|
+
|
|
85
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
86
|
+
dry = self.dry_run(ctx)
|
|
87
|
+
if dry.status != "dry_run_ok":
|
|
88
|
+
return dry
|
|
89
|
+
dest, src = self._resolve(ctx)
|
|
90
|
+
try:
|
|
91
|
+
if src.is_dir():
|
|
92
|
+
# Whole-directory staging (e.g. a .framework): copy recursively.
|
|
93
|
+
shutil.copytree(src, dest, symlinks=True)
|
|
94
|
+
else:
|
|
95
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
dest.write_bytes(src.read_bytes())
|
|
97
|
+
except OSError as e:
|
|
98
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"failed to add '{dest}': {e}")
|
|
99
|
+
return PatchResult(
|
|
100
|
+
op_id=self.op_id, status="applied", message=f"added {dest}", files_touched=[dest], category="added"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ResourceRemoveOp:
|
|
106
|
+
op_id: str
|
|
107
|
+
path: str
|
|
108
|
+
|
|
109
|
+
def _resolve(self, ctx: PatchContext) -> Path:
|
|
110
|
+
return resolve_bundle_path(ctx.bundle.root, self.path)
|
|
111
|
+
|
|
112
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
113
|
+
try:
|
|
114
|
+
dest = self._resolve(ctx)
|
|
115
|
+
except BundlePathError as e:
|
|
116
|
+
return PatchResult(op_id=self.op_id, status="failed", message=str(e))
|
|
117
|
+
if not dest.exists():
|
|
118
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"destination '{dest}' does not exist")
|
|
119
|
+
return PatchResult(op_id=self.op_id, status="dry_run_ok")
|
|
120
|
+
|
|
121
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
122
|
+
dry = self.dry_run(ctx)
|
|
123
|
+
if dry.status != "dry_run_ok":
|
|
124
|
+
return dry
|
|
125
|
+
dest = self._resolve(ctx)
|
|
126
|
+
try:
|
|
127
|
+
if dest.is_dir():
|
|
128
|
+
shutil.rmtree(dest)
|
|
129
|
+
else:
|
|
130
|
+
dest.unlink()
|
|
131
|
+
except OSError as e:
|
|
132
|
+
return PatchResult(op_id=self.op_id, status="failed", message=f"failed to remove '{dest}': {e}")
|
|
133
|
+
return PatchResult(
|
|
134
|
+
op_id=self.op_id, status="applied", message=f"removed {dest}", files_touched=[dest], category="removed"
|
|
135
|
+
)
|