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.
Files changed (62) hide show
  1. ipa_forge/__init__.py +1 -0
  2. ipa_forge/altstore/__init__.py +1 -0
  3. ipa_forge/altstore/source.py +44 -0
  4. ipa_forge/analysis/__init__.py +18 -0
  5. ipa_forge/analysis/classdump.py +103 -0
  6. ipa_forge/analysis/diff.py +127 -0
  7. ipa_forge/analysis/security.py +72 -0
  8. ipa_forge/analysis/strings.py +40 -0
  9. ipa_forge/analysis/symbols.py +42 -0
  10. ipa_forge/analysis/type_encoding.py +164 -0
  11. ipa_forge/bundle/__init__.py +1 -0
  12. ipa_forge/bundle/inventory.py +96 -0
  13. ipa_forge/bundle/ipa.py +81 -0
  14. ipa_forge/bundle/models.py +58 -0
  15. ipa_forge/bundle/plist.py +18 -0
  16. ipa_forge/cli/__init__.py +1 -0
  17. ipa_forge/cli/analysis.py +225 -0
  18. ipa_forge/cli/common.py +41 -0
  19. ipa_forge/cli/hooks.py +357 -0
  20. ipa_forge/cli/main.py +170 -0
  21. ipa_forge/gui/__init__.py +1 -0
  22. ipa_forge/gui/analysis.html +229 -0
  23. ipa_forge/gui/app.py +304 -0
  24. ipa_forge/gui/index.html +244 -0
  25. ipa_forge/hooks/__init__.py +0 -0
  26. ipa_forge/hooks/scan.py +81 -0
  27. ipa_forge/hooks/verify.py +348 -0
  28. ipa_forge/machO/__init__.py +1 -0
  29. ipa_forge/machO/arch.py +94 -0
  30. ipa_forge/machO/detect.py +50 -0
  31. ipa_forge/machO/injector.py +99 -0
  32. ipa_forge/machO/objc.py +534 -0
  33. ipa_forge/manifest.py +74 -0
  34. ipa_forge/patch/__init__.py +1 -0
  35. ipa_forge/patch/base.py +48 -0
  36. ipa_forge/patch/binary.py +131 -0
  37. ipa_forge/patch/dylib.py +59 -0
  38. ipa_forge/patch/engine.py +48 -0
  39. ipa_forge/patch/loader.py +88 -0
  40. ipa_forge/patch/paths.py +19 -0
  41. ipa_forge/patch/plist.py +61 -0
  42. ipa_forge/patch/resolver.py +37 -0
  43. ipa_forge/patch/resource.py +135 -0
  44. ipa_forge/patch/schema.py +118 -0
  45. ipa_forge/patch/version.py +25 -0
  46. ipa_forge/patches.py +76 -0
  47. ipa_forge/pipeline.py +235 -0
  48. ipa_forge/signing/__init__.py +1 -0
  49. ipa_forge/signing/backend.py +111 -0
  50. ipa_forge/signing/pipeline.py +99 -0
  51. ipa_forge/signing/profile.py +131 -0
  52. ipa_forge/signing/provider.py +113 -0
  53. ipa_forge/signing/reconcile.py +27 -0
  54. ipa_forge/validators/__init__.py +1 -0
  55. ipa_forge/validators/archive_validator.py +25 -0
  56. ipa_forge/validators/bundle_validator.py +27 -0
  57. ipa_forge/validators/ipa_validator.py +39 -0
  58. ipa_forge-0.1.0.dist-info/METADATA +914 -0
  59. ipa_forge-0.1.0.dist-info/RECORD +62 -0
  60. ipa_forge-0.1.0.dist-info/WHEEL +4 -0
  61. ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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
+ )
@@ -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
@@ -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
@@ -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
+ )