patch-cc 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.
patch_cc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """patch-cc: an interactive patcher for the Claude Code native binary."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Bun standalone-executable container handling."""
2
+
3
+ from .container import Bundle, ContainerError, read, write
4
+ from .errors import BunError
5
+
6
+ __all__ = ["Bundle", "BunError", "ContainerError", "read", "write"]
patch_cc/bun/blob.py ADDED
@@ -0,0 +1,256 @@
1
+ """Read and rewrite the Bun standalone-executable blob.
2
+
3
+ Layout of the blob (little-endian throughout)::
4
+
5
+ [ data arena: name / contents / sourcemap / bytecode / ... payloads ]
6
+ [ module table: N records of `struct_size` bytes ]
7
+ [ compileExecArgv payload ]
8
+ [ 32-byte offsets struct ]
9
+ [ 15-byte "\\n---- Bun! ----\\n" trailer ]
10
+
11
+ Every pointer in the blob is a ``(u32 offset, u32 length)`` pair relative to the
12
+ start of the blob, and they live in exactly two places: the module table and the
13
+ offsets struct. That is what makes rewriting tractable -- move a payload, then
14
+ fix up the pointers that describe it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import struct
20
+ from dataclasses import dataclass
21
+
22
+ from .errors import BunError
23
+
24
+ TRAILER = b"\n---- Bun! ----\n"
25
+ OFFSETS_SIZE = 32
26
+
27
+ #: Module record field order. Old Bun (<1.3.7) stops after ``bytecode``.
28
+ FIELDS_NEW = (
29
+ "name",
30
+ "contents",
31
+ "sourcemap",
32
+ "bytecode",
33
+ "module_info",
34
+ "bytecode_origin_path",
35
+ )
36
+ FIELDS_OLD = FIELDS_NEW[:4]
37
+
38
+ #: Names Bun gives the Claude entrypoint module across platforms/versions.
39
+ ENTRY_NAMES = ("claude", "claude.exe", "src/entrypoints/cli.js")
40
+
41
+
42
+ class BlobError(BunError):
43
+ """The .bun payload did not look like a Bun module graph."""
44
+
45
+
46
+ def is_entry_module(name: str) -> bool:
47
+ return any(name == n or name.endswith("/" + n) for n in ENTRY_NAMES)
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class Module:
52
+ index: int
53
+ ranges: dict[str, tuple[int, int]]
54
+ trailing: bytes # encoding, loader, module_format, side
55
+
56
+ @property
57
+ def name_range(self) -> tuple[int, int]:
58
+ return self.ranges["name"]
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class Blob:
63
+ data: bytes
64
+ struct_size: int
65
+ modules: list[Module]
66
+ modules_ptr: tuple[int, int]
67
+ argv_ptr: tuple[int, int]
68
+ entry_point_id: int
69
+ flags: int
70
+ offsets_at: int
71
+
72
+ @property
73
+ def fields(self) -> tuple[str, ...]:
74
+ return FIELDS_NEW if self.struct_size == 52 else FIELDS_OLD
75
+
76
+ def payload(self, rng: tuple[int, int]) -> bytes:
77
+ off, length = rng
78
+ return self.data[off : off + length]
79
+
80
+ def module_name(self, module: Module) -> str:
81
+ return self.payload(module.name_range).decode("utf8", "replace")
82
+
83
+ def entry_module(self) -> Module:
84
+ for module in self.modules:
85
+ if is_entry_module(self.module_name(module)):
86
+ return module
87
+ raise BlobError("no Claude entrypoint module in the Bun blob")
88
+
89
+ def entry_source(self) -> bytes:
90
+ return self.payload(self.entry_module().ranges["contents"])
91
+
92
+ def bytecode_size(self) -> int:
93
+ return (
94
+ self.entry_module().ranges["bytecode"][1] if self.struct_size == 52 else 0
95
+ )
96
+
97
+
98
+ def _detect_struct_size(modules_len: int) -> int:
99
+ new_ok = modules_len % 52 == 0
100
+ old_ok = modules_len % 36 == 0
101
+ if new_ok and not old_ok:
102
+ return 52
103
+ if old_ok and not new_ok:
104
+ return 36
105
+ return 52
106
+
107
+
108
+ def parse(data: bytes) -> Blob:
109
+ """Parse a raw Bun blob (already unwrapped from its container section)."""
110
+ if len(data) < OFFSETS_SIZE + len(TRAILER):
111
+ raise BlobError("blob is too small to hold offsets and trailer")
112
+ if data[-len(TRAILER) :] != TRAILER:
113
+ raise BlobError("missing Bun trailer -- not a Bun standalone payload")
114
+
115
+ offsets_at = len(data) - len(TRAILER) - OFFSETS_SIZE
116
+ (_byte_count,) = struct.unpack_from("<Q", data, offsets_at)
117
+ modules_ptr = struct.unpack_from("<II", data, offsets_at + 8)
118
+ (entry_point_id,) = struct.unpack_from("<I", data, offsets_at + 16)
119
+ argv_ptr = struct.unpack_from("<II", data, offsets_at + 20)
120
+ (flags,) = struct.unpack_from("<I", data, offsets_at + 28)
121
+
122
+ struct_size = _detect_struct_size(modules_ptr[1])
123
+ fields = FIELDS_NEW if struct_size == 52 else FIELDS_OLD
124
+ table_off, table_len = modules_ptr
125
+ if table_off + table_len > len(data):
126
+ raise BlobError("module table runs past the end of the blob")
127
+
128
+ modules: list[Module] = []
129
+ for index in range(table_len // struct_size):
130
+ base = table_off + index * struct_size
131
+ ranges = {
132
+ field: struct.unpack_from("<II", data, base + i * 8)
133
+ for i, field in enumerate(fields)
134
+ }
135
+ trailing = data[base + len(fields) * 8 : base + len(fields) * 8 + 4]
136
+ modules.append(Module(index=index, ranges=ranges, trailing=trailing))
137
+
138
+ if not modules:
139
+ raise BlobError("Bun blob contains no modules")
140
+
141
+ return Blob(
142
+ data=data,
143
+ struct_size=struct_size,
144
+ modules=modules,
145
+ modules_ptr=modules_ptr,
146
+ argv_ptr=argv_ptr,
147
+ entry_point_id=entry_point_id,
148
+ flags=flags,
149
+ offsets_at=offsets_at,
150
+ )
151
+
152
+
153
+ def rebuild(blob: Blob, source: bytes, *, drop_bytecode: bool = True) -> bytes:
154
+ """Return a new blob with the entrypoint's source replaced.
155
+
156
+ Payloads are re-emitted in their original file order so the result stays as
157
+ close to the input layout as possible.
158
+
159
+ ``drop_bytecode`` removes the entrypoint's precompiled Bun bytecode. Editing
160
+ the source invalidates that bytecode anyway -- Bun recompiles from source --
161
+ so keeping it costs ~154 MB for no benefit. See docs/INTERNALS.md.
162
+ """
163
+ entry = blob.entry_module()
164
+ fields = blob.fields
165
+ has_bytecode = "bytecode" in fields
166
+
167
+ # Every payload, in the order it appears in the source arena.
168
+ placed: list[
169
+ tuple[int, int, int, str]
170
+ ] = [] # (offset, length, module_index, field)
171
+ for module in blob.modules:
172
+ for field in fields:
173
+ off, length = module.ranges[field]
174
+ if length:
175
+ placed.append((off, length, module.index, field))
176
+ placed.sort()
177
+
178
+ out = bytearray()
179
+ new_ranges: dict[tuple[int, str], tuple[int, int]] = {}
180
+ prev_end = 0
181
+ for off, length, mod_index, field in placed:
182
+ is_entry = mod_index == entry.index
183
+ if is_entry and field == "bytecode" and drop_bytecode:
184
+ new_ranges[(mod_index, field)] = (0, 0)
185
+ prev_end = off + length
186
+ continue
187
+
188
+ payload = (
189
+ source
190
+ if (is_entry and field == "contents")
191
+ else blob.data[off : off + length]
192
+ )
193
+ # Preserve the 1-byte separators Bun emits between payloads.
194
+ if prev_end and off > prev_end:
195
+ out += b"\0" * (off - prev_end)
196
+ new_ranges[(mod_index, field)] = (len(out), len(payload))
197
+ out += payload
198
+ prev_end = off + length
199
+
200
+ out += b"\0"
201
+ table_off = len(out)
202
+ table_len = len(blob.modules) * blob.struct_size
203
+ out += bytearray(table_len)
204
+
205
+ argv = blob.payload(blob.argv_ptr)
206
+ argv_off = len(out)
207
+ out += argv + b"\0"
208
+
209
+ offsets_at = len(out)
210
+ out += bytearray(OFFSETS_SIZE)
211
+ out += TRAILER
212
+
213
+ for module in blob.modules:
214
+ base = table_off + module.index * blob.struct_size
215
+ for i, field in enumerate(fields):
216
+ off, length = new_ranges.get((module.index, field), (0, 0))
217
+ struct.pack_into("<II", out, base + i * 8, off, length)
218
+ tail = base + len(fields) * 8
219
+ out[tail : tail + 4] = module.trailing
220
+
221
+ struct.pack_into("<Q", out, offsets_at, offsets_at)
222
+ struct.pack_into("<II", out, offsets_at + 8, table_off, table_len)
223
+ struct.pack_into("<I", out, offsets_at + 16, blob.entry_point_id)
224
+ struct.pack_into("<II", out, offsets_at + 20, argv_off, len(argv))
225
+ struct.pack_into("<I", out, offsets_at + 28, blob.flags)
226
+
227
+ if has_bytecode and drop_bytecode:
228
+ assert new_ranges[(entry.index, "bytecode")] == (0, 0)
229
+ return bytes(out)
230
+
231
+
232
+ def unwrap_section(section: bytes) -> tuple[bytes, int]:
233
+ """Strip the length prefix a container section puts in front of the blob.
234
+
235
+ Bun >=1.3.4 uses a u64 prefix; older builds use u32. Both are followed by
236
+ padding up to the section's alignment, hence the 4 KiB slack window.
237
+ """
238
+ size = len(section)
239
+ if size >= 8:
240
+ (as64,) = struct.unpack_from("<Q", section, 0)
241
+ if 8 + as64 <= size and 8 + as64 >= size - 4096:
242
+ return section[8 : 8 + as64], 8
243
+ if size >= 4:
244
+ (as32,) = struct.unpack_from("<I", section, 0)
245
+ if 4 + as32 <= size and 4 + as32 >= size - 4096:
246
+ return section[4 : 4 + as32], 4
247
+ raise BlobError("unrecognised .bun section header")
248
+
249
+
250
+ def wrap_section(blob: bytes, header_size: int) -> bytes:
251
+ prefix = (
252
+ struct.pack("<Q", len(blob))
253
+ if header_size == 8
254
+ else struct.pack("<I", len(blob))
255
+ )
256
+ return prefix + blob
@@ -0,0 +1,129 @@
1
+ """One API over the two binary containers we support: ELF and Mach-O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ from . import blob as blobmod
9
+ from . import elf, macho
10
+ from .errors import BunError
11
+
12
+ ELF_MAGIC = b"\x7fELF"
13
+ MACHO_MAGICS = {
14
+ b"\xcf\xfa\xed\xfe",
15
+ b"\xce\xfa\xed\xfe", # thin, LE
16
+ b"\xfe\xed\xfa\xcf",
17
+ b"\xfe\xed\xfa\xce", # thin, BE
18
+ b"\xca\xfe\xba\xbe",
19
+ b"\xbe\xba\xfe\xca", # fat
20
+ }
21
+
22
+
23
+ class ContainerError(BunError):
24
+ pass
25
+
26
+
27
+ def detect(path: str) -> str:
28
+ with open(path, "rb") as handle:
29
+ magic = handle.read(4)
30
+ if magic == ELF_MAGIC:
31
+ return "elf"
32
+ if magic in MACHO_MAGICS:
33
+ return "macho"
34
+ raise ContainerError(
35
+ f"{path} is neither ELF nor Mach-O. Claude Code must be the native "
36
+ "build -- reinstall with `curl -fsSL https://claude.ai/install.sh | bash`."
37
+ )
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class Bundle:
42
+ """The JS bundle plus everything needed to put it back.
43
+
44
+ ``source`` is the decoded JS text -- patches operate on ``str``. The binary
45
+ layers below work in ``bytes``; this class is the encode/decode boundary.
46
+ """
47
+
48
+ path: str
49
+ kind: str
50
+ source: str
51
+ blob: blobmod.Blob
52
+ header_size: int
53
+ binary_size: int
54
+ bytecode_size: int
55
+
56
+
57
+ def read(path: str) -> Bundle:
58
+ kind = detect(path)
59
+ if kind == "elf":
60
+ with open(path, "rb") as handle:
61
+ raw = handle.read()
62
+ section = elf.read_section(raw)
63
+ else:
64
+ section = macho.read_section(path)
65
+
66
+ payload, header_size = blobmod.unwrap_section(section)
67
+ parsed = blobmod.parse(payload)
68
+ return Bundle(
69
+ path=path,
70
+ kind=kind,
71
+ source=parsed.entry_source().decode("utf8"),
72
+ blob=parsed,
73
+ header_size=header_size,
74
+ binary_size=os.path.getsize(path),
75
+ bytecode_size=parsed.bytecode_size(),
76
+ )
77
+
78
+
79
+ def write(
80
+ bundle: Bundle, source: str, out_path: str, *, drop_bytecode: bool = True
81
+ ) -> None:
82
+ """Repack ``source`` into a copy of the binary at ``out_path``.
83
+
84
+ The patched image is staged to a temp file and verified -- re-extracted and
85
+ compared against ``source`` -- *before* it is moved into place. A rebuild
86
+ bug therefore fails without ever touching the live binary.
87
+ """
88
+ import shutil # noqa: PLC0415
89
+
90
+ new_blob = blobmod.rebuild(
91
+ bundle.blob, source.encode("utf8"), drop_bytecode=drop_bytecode
92
+ )
93
+ section = blobmod.wrap_section(new_blob, bundle.header_size)
94
+ tmp = f"{out_path}.patch-cc.tmp"
95
+
96
+ try:
97
+ if bundle.kind == "elf":
98
+ with open(bundle.path, "rb") as handle:
99
+ raw = handle.read()
100
+ patched = elf.write_section(raw, section)
101
+ with open(tmp, "wb") as handle:
102
+ handle.write(patched)
103
+ os.chmod(tmp, os.stat(bundle.path).st_mode & 0o7777)
104
+ else:
105
+ shutil.copy2(bundle.path, tmp)
106
+ macho.write_section(tmp, section)
107
+
108
+ verify(tmp, source) # raises before we commit if anything is off
109
+ os.replace(tmp, out_path)
110
+ except BaseException:
111
+ if os.path.exists(tmp):
112
+ try:
113
+ os.unlink(tmp)
114
+ except OSError:
115
+ pass
116
+ raise
117
+
118
+
119
+ def verify(path: str, expected: str) -> None:
120
+ """Re-extract from a written binary and assert it round-trips exactly."""
121
+ try:
122
+ written = read(path)
123
+ except Exception as exc: # noqa: BLE001
124
+ raise ContainerError(f"patched binary could not be re-read: {exc}") from exc
125
+ if written.source != expected:
126
+ raise ContainerError(
127
+ "patched binary did not round-trip: extracted source differs from "
128
+ f"what we wrote ({len(written.source):,} vs {len(expected):,} bytes)"
129
+ )
patch_cc/bun/elf.py ADDED
@@ -0,0 +1,281 @@
1
+ """Raw ELF64 surgery for the ``.bun`` section.
2
+
3
+ We deliberately do not use a generic ELF library to *write*. LIEF rebuilds the
4
+ binary and relocates ``.bun`` so its file offset matches its virtual address
5
+ (0x20000000), which inflates a 267 MB binary to 715 MB. Editing the bytes in
6
+ place keeps ``.bun`` where it is and grows the file by only the delta.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import struct
13
+ from dataclasses import dataclass
14
+
15
+ from .errors import BunError
16
+
17
+ SHT_NOBITS = 8
18
+ SHF_ALLOC = 0x2
19
+ PT_LOAD = 1
20
+
21
+
22
+ class ElfError(BunError):
23
+ """The binary is not an ELF64 we know how to rewrite safely."""
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Section:
28
+ index: int
29
+ name: str
30
+ type: int
31
+ flags: int
32
+ addr: int
33
+ offset: int
34
+ size: int
35
+ align: int
36
+
37
+ @property
38
+ def has_file_payload(self) -> bool:
39
+ return self.type != SHT_NOBITS and self.size > 0
40
+
41
+ @property
42
+ def is_alloc(self) -> bool:
43
+ return bool(self.flags & SHF_ALLOC)
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Segment:
48
+ index: int
49
+ type: int
50
+ offset: int
51
+ vaddr: int
52
+ filesz: int
53
+ memsz: int
54
+ align: int
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class Elf:
59
+ phoff: int
60
+ shoff: int
61
+ phentsize: int
62
+ phnum: int
63
+ shentsize: int
64
+ shnum: int
65
+ sections: list[Section]
66
+ segments: list[Segment]
67
+
68
+ def section(self, name: str) -> Section | None:
69
+ return next((s for s in self.sections if s.name == name), None)
70
+
71
+
72
+ def parse(buf: bytes) -> Elf:
73
+ if len(buf) < 64 or buf[:4] != b"\x7fELF":
74
+ raise ElfError("not an ELF file")
75
+ if buf[4] != 2:
76
+ raise ElfError("only ELF64 is supported")
77
+ if buf[5] != 1:
78
+ raise ElfError("only little-endian ELF is supported")
79
+
80
+ phoff, shoff = struct.unpack_from("<QQ", buf, 32)
81
+ phentsize, phnum, shentsize, shnum, shstrndx = struct.unpack_from("<HHHHH", buf, 54)
82
+ if not shnum or shstrndx >= shnum:
83
+ raise ElfError("ELF has no usable section header table")
84
+
85
+ strbase = shoff + shstrndx * shentsize
86
+ stroff, strsize = struct.unpack_from("<QQ", buf, strbase + 24)
87
+ strtab = buf[stroff : stroff + strsize]
88
+
89
+ sections: list[Section] = []
90
+ for index in range(shnum):
91
+ base = shoff + index * shentsize
92
+ name_off, sh_type = struct.unpack_from("<II", buf, base)
93
+ flags, addr, offset, size = struct.unpack_from("<QQQQ", buf, base + 8)
94
+ (align,) = struct.unpack_from("<Q", buf, base + 48)
95
+ end = strtab.find(b"\0", name_off)
96
+ name = strtab[name_off : end if end != -1 else None].decode("utf8", "replace")
97
+ sections.append(Section(index, name, sh_type, flags, addr, offset, size, align))
98
+
99
+ segments: list[Segment] = []
100
+ for index in range(phnum):
101
+ base = phoff + index * phentsize
102
+ (p_type,) = struct.unpack_from("<I", buf, base)
103
+ offset, vaddr, _paddr, filesz, memsz = struct.unpack_from(
104
+ "<QQQQQ", buf, base + 8
105
+ )
106
+ (align,) = struct.unpack_from("<Q", buf, base + 48)
107
+ segments.append(Segment(index, p_type, offset, vaddr, filesz, memsz, align))
108
+
109
+ return Elf(phoff, shoff, phentsize, phnum, shentsize, shnum, sections, segments)
110
+
111
+
112
+ def read_section(buf: bytes, name: str = ".bun") -> bytes:
113
+ elf = parse(buf)
114
+ section = elf.section(name)
115
+ if section is None:
116
+ raise ElfError(f"no {name} section in this ELF")
117
+ return buf[section.offset : section.offset + section.size]
118
+
119
+
120
+ def _containing_load(elf: Elf, section: Section) -> Segment | None:
121
+ file_end = section.offset + section.size
122
+ virt_end = section.addr + section.size
123
+ for seg in elf.segments:
124
+ if seg.type != PT_LOAD:
125
+ continue
126
+ if (
127
+ seg.offset <= section.offset
128
+ and file_end <= seg.offset + seg.filesz
129
+ and seg.vaddr <= section.addr
130
+ and virt_end <= seg.vaddr + seg.memsz
131
+ ):
132
+ return seg
133
+ return None
134
+
135
+
136
+ def _check_growth_is_safe(elf: Elf, bun: Section, shifted: list[Section]) -> None:
137
+ """Refuse anything that could change runtime mapping semantics."""
138
+ end = bun.offset + bun.size
139
+
140
+ overlapping = [
141
+ s
142
+ for s in elf.sections
143
+ if s.index != bun.index and s.has_file_payload and bun.offset < s.offset < end
144
+ ]
145
+ if overlapping:
146
+ names = ", ".join(s.name or f"<{s.index}>" for s in overlapping)
147
+ raise ElfError(f".bun overlaps later section payloads: {names}")
148
+
149
+ alloc = [s for s in shifted if s.is_alloc]
150
+ if alloc:
151
+ names = ", ".join(s.name or f"<{s.index}>" for s in alloc)
152
+ raise ElfError(f"cannot move allocated sections that follow .bun: {names}")
153
+
154
+ sh_end = elf.shoff + elf.shentsize * elf.shnum
155
+ if elf.shoff < end < sh_end:
156
+ raise ElfError("cannot resize .bun from inside the section header table")
157
+ ph_end = elf.phoff + elf.phentsize * elf.phnum
158
+ if elf.phoff < end < ph_end:
159
+ raise ElfError("cannot resize .bun from inside the program header table")
160
+
161
+ container = _containing_load(elf, bun)
162
+ spanning = [
163
+ seg
164
+ for seg in elf.segments
165
+ if seg.index != (container.index if container else -1)
166
+ and seg.filesz
167
+ and seg.offset < end < seg.offset + seg.filesz
168
+ ]
169
+ if spanning:
170
+ names = ", ".join(f"{s.index}:{s.type}" for s in spanning)
171
+ raise ElfError(f"cannot resize .bun inside unrelated segments: {names}")
172
+
173
+
174
+ def _alignment_step(elf: Elf, end: int, shifted: list[Section]) -> int:
175
+ """Smallest shift that preserves the alignment of everything we move."""
176
+ step = 1
177
+ for section in shifted:
178
+ step = max(step, section.align or 1)
179
+ for seg in elf.segments:
180
+ if seg.filesz and seg.offset >= end:
181
+ step = max(step, seg.align or 1)
182
+ return step
183
+
184
+
185
+ def write_section(buf: bytes, payload: bytes, name: str = ".bun") -> bytes:
186
+ """Return a new ELF image with ``name``'s contents replaced by ``payload``.
187
+
188
+ The section keeps its file offset. Everything after it shifts by a delta
189
+ rounded to a multiple of the strictest alignment among the moved pieces, and
190
+ the containing ``PT_LOAD`` grows or shrinks to match.
191
+ """
192
+ elf = parse(buf)
193
+ bun = elf.section(name)
194
+ if bun is None:
195
+ raise ElfError(f"no {name} section in this ELF")
196
+
197
+ start, end = bun.offset, bun.offset + bun.size
198
+ shifted = [
199
+ s
200
+ for s in elf.sections
201
+ if s.index != bun.index and s.has_file_payload and s.offset >= end
202
+ ]
203
+
204
+ delta = len(payload) - bun.size
205
+ if delta:
206
+ _check_growth_is_safe(elf, bun, shifted)
207
+ step = _alignment_step(elf, end, shifted)
208
+ if step > 1:
209
+ # Grow generously / shrink conservatively so offsets stay congruent.
210
+ delta = (
211
+ (delta + step - 1) // step * step
212
+ if delta > 0
213
+ else -((-delta) // step * step)
214
+ )
215
+
216
+ container = _containing_load(elf, bun)
217
+ if delta and container is None:
218
+ raise ElfError(".bun has no containing PT_LOAD segment")
219
+
220
+ for seg in elf.segments:
221
+ if (
222
+ delta
223
+ and seg.type == PT_LOAD
224
+ and seg.filesz
225
+ and seg.offset >= end
226
+ and seg.align
227
+ ):
228
+ if (seg.offset + delta) % seg.align != seg.vaddr % seg.align:
229
+ raise ElfError(
230
+ f"shifting LOAD segment {seg.index} would break its alignment"
231
+ )
232
+
233
+ new_size = bun.size + delta
234
+ body = bytearray(payload)
235
+ if len(body) < new_size:
236
+ body += b"\0" * (new_size - len(body))
237
+ elif len(body) > new_size:
238
+ raise ElfError("internal error: payload larger than the resized section")
239
+
240
+ out = bytearray(buf[:start]) + body + bytearray(buf[end:])
241
+
242
+ new_phoff = elf.phoff + delta if elf.phoff >= end else elf.phoff
243
+ new_shoff = elf.shoff + delta if elf.shoff >= end else elf.shoff
244
+ struct.pack_into("<QQ", out, 32, new_phoff, new_shoff)
245
+
246
+ for section in elf.sections:
247
+ base = new_shoff + section.index * elf.shentsize
248
+ if section.index == bun.index:
249
+ struct.pack_into("<Q", out, base + 32, new_size)
250
+ elif delta and section.has_file_payload and section.offset >= end:
251
+ struct.pack_into("<Q", out, base + 24, section.offset + delta)
252
+
253
+ for seg in elf.segments:
254
+ base = new_phoff + seg.index * elf.phentsize
255
+ if delta and container is not None and seg.index == container.index:
256
+ struct.pack_into(
257
+ "<QQ", out, base + 32, seg.filesz + delta, seg.memsz + delta
258
+ )
259
+ elif delta and seg.filesz and seg.offset >= end:
260
+ struct.pack_into("<Q", out, base + 8, seg.offset + delta)
261
+
262
+ return bytes(out)
263
+
264
+
265
+ def atomic_write(path: str, data: bytes, mode_from: str | None = None) -> None:
266
+ """Write ``data`` to ``path`` via a temp file, preserving the mode bits."""
267
+ tmp = f"{path}.patch-cc.tmp"
268
+ try:
269
+ with open(tmp, "wb") as handle:
270
+ handle.write(data)
271
+ source = mode_from or (path if os.path.exists(path) else None)
272
+ if source:
273
+ os.chmod(tmp, os.stat(source).st_mode & 0o7777)
274
+ os.replace(tmp, path)
275
+ except BaseException:
276
+ if os.path.exists(tmp):
277
+ try:
278
+ os.unlink(tmp)
279
+ except OSError:
280
+ pass
281
+ raise
patch_cc/bun/errors.py ADDED
@@ -0,0 +1,12 @@
1
+ """Shared base for binary-container errors.
2
+
3
+ Kept in its own module so blob/elf/macho/container can all subclass one type
4
+ without an import cycle. The CLI catches :class:`BunError` to turn any
5
+ container-layer failure into a clean message instead of a traceback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class BunError(RuntimeError):
12
+ """Any failure reading or rewriting the Bun-embedded binary."""