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 +3 -0
- patch_cc/bun/__init__.py +6 -0
- patch_cc/bun/blob.py +256 -0
- patch_cc/bun/container.py +129 -0
- patch_cc/bun/elf.py +281 -0
- patch_cc/bun/errors.py +12 -0
- patch_cc/bun/macho.py +124 -0
- patch_cc/cache.py +86 -0
- patch_cc/cli.py +399 -0
- patch_cc/doctor.py +116 -0
- patch_cc/locate.py +117 -0
- patch_cc/menu.py +1135 -0
- patch_cc/patcher.py +244 -0
- patch_cc/patches/__init__.py +75 -0
- patch_cc/patches/agents.py +289 -0
- patch_cc/patches/base.py +200 -0
- patch_cc/patches/chrome.py +187 -0
- patch_cc/patches/output.py +173 -0
- patch_cc/patches/streaming.py +922 -0
- patch_cc/patches/thinking.py +88 -0
- patch_cc/ui.py +23 -0
- patch_cc-0.1.0.dist-info/METADATA +141 -0
- patch_cc-0.1.0.dist-info/RECORD +26 -0
- patch_cc-0.1.0.dist-info/WHEEL +4 -0
- patch_cc-0.1.0.dist-info/entry_points.txt +2 -0
- patch_cc-0.1.0.dist-info/licenses/LICENSE +21 -0
patch_cc/__init__.py
ADDED
patch_cc/bun/__init__.py
ADDED
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."""
|