pyOpenVBA 1.0.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.
- pyopenvba/__init__.py +80 -0
- pyopenvba/__main__.py +68 -0
- pyopenvba/cfb.py +931 -0
- pyopenvba/excel.py +487 -0
- pyopenvba/exceptions.py +17 -0
- pyopenvba/vba.py +1543 -0
- pyopenvba-1.0.0.dist-info/METADATA +285 -0
- pyopenvba-1.0.0.dist-info/RECORD +11 -0
- pyopenvba-1.0.0.dist-info/WHEEL +4 -0
- pyopenvba-1.0.0.dist-info/entry_points.txt +2 -0
- pyopenvba-1.0.0.dist-info/licenses/LICENSE.md +21 -0
pyopenvba/cfb.py
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compound File Binary (CFB / OLE2) parser.
|
|
3
|
+
|
|
4
|
+
Implements a subset of [MS-CFB] sufficient to locate and extract
|
|
5
|
+
VBA project streams from vbaProject.bin.
|
|
6
|
+
|
|
7
|
+
Structure overview
|
|
8
|
+
------------------
|
|
9
|
+
Offset 0 : 512-byte header
|
|
10
|
+
- Magic bytes (8)
|
|
11
|
+
- Sector size (power-of-two exponent, usually 9 -> 512 bytes)
|
|
12
|
+
- Mini-stream cutoff size
|
|
13
|
+
- Number of FAT sectors, root entry start sector, ...
|
|
14
|
+
Sectors : fixed-size blocks that follow the header
|
|
15
|
+
- FAT (File Allocation Table) chains describe how data spans sectors
|
|
16
|
+
- Mini-FAT chains describe how small streams span mini-sectors
|
|
17
|
+
- Directory entries (128 bytes each) live in the directory sector chain
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import struct
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import BinaryIO, Callable
|
|
25
|
+
from pyopenvba.exceptions import CFBError
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Constants from the MS-CFB specification
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
32
|
+
|
|
33
|
+
_FREESECT: int = 0xFFFF_FFFF # unused sector
|
|
34
|
+
_ENDOFCHAIN: int = 0xFFFF_FFFE # end of a FAT chain
|
|
35
|
+
_FATSECT: int = 0xFFFF_FFFD # sector is part of the FAT
|
|
36
|
+
_DIFSECT: int = 0xFFFF_FFFC # sector is part of the DIFAT
|
|
37
|
+
_NOSTREAM: int = 0xFFFF_FFFF # no sibling / no child
|
|
38
|
+
|
|
39
|
+
# Directory entry object types
|
|
40
|
+
_OBJTYPE_EMPTY: int = 0
|
|
41
|
+
_OBJTYPE_STORAGE: int = 1
|
|
42
|
+
_OBJTYPE_STREAM: int = 2
|
|
43
|
+
_OBJTYPE_ROOT: int = 5
|
|
44
|
+
|
|
45
|
+
# Spec field order: sig(8), CLSID(16), minorVer(2), majorVer(2), byteOrder(2),
|
|
46
|
+
# sectorShift(2), miniSectorShift(2), reserved(6), then nine DWORDs, then DIFAT(436)
|
|
47
|
+
_HEADER_FMT = "<8s16sHHHHH6sIIIIIIIII436s"
|
|
48
|
+
_HEADER_SIZE = struct.calcsize(_HEADER_FMT) # == 512
|
|
49
|
+
|
|
50
|
+
# name(64), nameLen(2), type(1), color(1), left(4), right(4), child(4),
|
|
51
|
+
# CLSID(16), state(4), created(8), modified(8), startSector(4), sizeLow(4), sizeHigh(4)
|
|
52
|
+
_DIR_ENTRY_FMT = "<64sHBBIII16sIQQIII"
|
|
53
|
+
_DIR_ENTRY_SIZE = struct.calcsize(_DIR_ENTRY_FMT) # == 128
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Data classes
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DirEntry:
|
|
62
|
+
name: str
|
|
63
|
+
obj_type: int
|
|
64
|
+
child_id: int
|
|
65
|
+
left_sibling_id: int
|
|
66
|
+
right_sibling_id: int
|
|
67
|
+
start_sector: int
|
|
68
|
+
size: int
|
|
69
|
+
is_mini: bool = False # True when size < mini-stream cutoff
|
|
70
|
+
# Preserved verbatim for round-trip writes (CLSID, state, timestamps, color, etc.)
|
|
71
|
+
raw: bytes = b""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CFB:
|
|
75
|
+
"""Parsed Compound File Binary."""
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Constructor: build via from_bytes() / from_file()
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def __init__(self, data: bytes) -> None:
|
|
82
|
+
self._data: bytes = data
|
|
83
|
+
self.sector_size: int = 512
|
|
84
|
+
self.mini_sector_size: int = 64
|
|
85
|
+
self.mini_stream_cutoff: int = 4096
|
|
86
|
+
self._fat: list[int] = []
|
|
87
|
+
self._minifat: list[int] = []
|
|
88
|
+
self._directory: list[DirEntry] = []
|
|
89
|
+
self._mini_stream: bytes = b""
|
|
90
|
+
# Pending overrides for write-back; key = directory index, value = new stream bytes.
|
|
91
|
+
self._stream_overrides: dict[int, bytes] = {}
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Factory
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_bytes(cls, data: bytes) -> "CFB":
|
|
99
|
+
cfb = cls(data)
|
|
100
|
+
cfb._parse()
|
|
101
|
+
return cfb
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_file(cls, fh: BinaryIO) -> "CFB":
|
|
105
|
+
return cls.from_bytes(fh.read())
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Public API
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def list_streams(self) -> list[str]:
|
|
112
|
+
"""Return the names of all stream entries (not storages)."""
|
|
113
|
+
return [e.name for e in self._directory if e.obj_type == _OBJTYPE_STREAM]
|
|
114
|
+
|
|
115
|
+
def get_stream(self, name: str) -> bytes:
|
|
116
|
+
"""Return the raw bytes of a stream by name (case-insensitive)."""
|
|
117
|
+
needle = name.casefold()
|
|
118
|
+
for entry in self._directory:
|
|
119
|
+
if entry.obj_type == _OBJTYPE_STREAM and entry.name.casefold() == needle:
|
|
120
|
+
return self._read_stream(entry)
|
|
121
|
+
raise KeyError(f"Stream not found: {name!r}")
|
|
122
|
+
|
|
123
|
+
def get_stream_in_storage(self, storage: str, name: str) -> bytes:
|
|
124
|
+
"""Return the raw bytes of a stream nested inside a named storage."""
|
|
125
|
+
needle_s = storage.casefold()
|
|
126
|
+
needle_n = name.casefold()
|
|
127
|
+
# Find the storage entry
|
|
128
|
+
storage_entry: DirEntry | None = None
|
|
129
|
+
for entry in self._directory:
|
|
130
|
+
if entry.obj_type == _OBJTYPE_STORAGE and entry.name.casefold() == needle_s:
|
|
131
|
+
storage_entry = entry
|
|
132
|
+
break
|
|
133
|
+
if storage_entry is None:
|
|
134
|
+
raise KeyError(f"Storage not found: {storage!r}")
|
|
135
|
+
# Walk children of that storage
|
|
136
|
+
for entry in self._directory:
|
|
137
|
+
if entry.obj_type == _OBJTYPE_STREAM and entry.name.casefold() == needle_n:
|
|
138
|
+
return self._read_stream(entry)
|
|
139
|
+
raise KeyError(f"Stream {name!r} not found in storage {storage!r}")
|
|
140
|
+
|
|
141
|
+
def list_storages(self) -> list[str]:
|
|
142
|
+
"""Return the names of all storage entries (excluding the root)."""
|
|
143
|
+
return [
|
|
144
|
+
e.name for e in self._directory
|
|
145
|
+
if e.obj_type == _OBJTYPE_STORAGE
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def list_streams_in_storage(self, storage: str) -> list[str]:
|
|
149
|
+
"""
|
|
150
|
+
Return the names of all streams whose case-folded parent storage matches
|
|
151
|
+
``storage``.
|
|
152
|
+
|
|
153
|
+
.. note::
|
|
154
|
+
The current scan is linear over the directory and does not walk the
|
|
155
|
+
red-black sibling tree. For Excel-produced ``vbaProject.bin`` this
|
|
156
|
+
is sufficient because storage names are unique.
|
|
157
|
+
"""
|
|
158
|
+
needle = storage.casefold()
|
|
159
|
+
found_storage = False
|
|
160
|
+
for e in self._directory:
|
|
161
|
+
if e.obj_type == _OBJTYPE_STORAGE and e.name.casefold() == needle:
|
|
162
|
+
found_storage = True
|
|
163
|
+
break
|
|
164
|
+
if not found_storage:
|
|
165
|
+
raise KeyError(f"Storage not found: {storage!r}")
|
|
166
|
+
return [
|
|
167
|
+
e.name for e in self._directory
|
|
168
|
+
if e.obj_type == _OBJTYPE_STREAM
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Internal parsing
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _parse(self) -> None:
|
|
176
|
+
data = self._data
|
|
177
|
+
if len(data) < _HEADER_SIZE:
|
|
178
|
+
raise CFBError("File too small to be a valid CFB.")
|
|
179
|
+
if data[:8] != _MAGIC:
|
|
180
|
+
raise CFBError("Not a Compound File Binary (magic bytes mismatch).")
|
|
181
|
+
|
|
182
|
+
(
|
|
183
|
+
_magic,
|
|
184
|
+
_clsid,
|
|
185
|
+
_minor_ver,
|
|
186
|
+
major_ver,
|
|
187
|
+
_byte_order,
|
|
188
|
+
sector_size_pow,
|
|
189
|
+
mini_sector_size_pow,
|
|
190
|
+
_reserved,
|
|
191
|
+
_num_dir_sectors,
|
|
192
|
+
_num_fat_sectors,
|
|
193
|
+
root_dir_start,
|
|
194
|
+
_sig,
|
|
195
|
+
mini_cutoff,
|
|
196
|
+
minifat_start,
|
|
197
|
+
_num_minifat_sectors,
|
|
198
|
+
difat_start,
|
|
199
|
+
num_difat_sectors,
|
|
200
|
+
difat_array_raw,
|
|
201
|
+
) = struct.unpack_from(_HEADER_FMT, data, 0)
|
|
202
|
+
|
|
203
|
+
if major_ver not in (3, 4):
|
|
204
|
+
raise CFBError(f"Unsupported CFB major version: {major_ver}")
|
|
205
|
+
|
|
206
|
+
self.sector_size = 1 << sector_size_pow # 512 or 4096
|
|
207
|
+
self.mini_sector_size = 1 << mini_sector_size_pow # 64
|
|
208
|
+
self.mini_stream_cutoff = mini_cutoff # usually 4096
|
|
209
|
+
|
|
210
|
+
# Parse the initial DIFAT array from the header (up to 109 entries)
|
|
211
|
+
difat: list[int] = [int(x) for x in struct.unpack_from("<109I", difat_array_raw)]
|
|
212
|
+
|
|
213
|
+
# Follow DIFAT chain for large files (> 109 FAT sectors)
|
|
214
|
+
if difat_start != _ENDOFCHAIN and num_difat_sectors > 0:
|
|
215
|
+
sector = difat_start
|
|
216
|
+
for _ in range(num_difat_sectors):
|
|
217
|
+
if sector in (_ENDOFCHAIN, _FREESECT):
|
|
218
|
+
break
|
|
219
|
+
sector_data = self._sector(sector)
|
|
220
|
+
entries_per_difat = (self.sector_size // 4) - 1
|
|
221
|
+
extra = [int(x) for x in struct.unpack_from(f"<{entries_per_difat}I", sector_data)]
|
|
222
|
+
difat.extend(extra)
|
|
223
|
+
sector = struct.unpack_from("<I", sector_data, self.sector_size - 4)[0]
|
|
224
|
+
|
|
225
|
+
# Build FAT from the sectors listed in the DIFAT
|
|
226
|
+
fat_raw = bytearray()
|
|
227
|
+
for sect in difat:
|
|
228
|
+
if sect in (_FREESECT, _ENDOFCHAIN, _FATSECT, _DIFSECT):
|
|
229
|
+
break
|
|
230
|
+
fat_raw.extend(self._sector(sect))
|
|
231
|
+
fat_count = len(fat_raw) // 4
|
|
232
|
+
self._fat = [int(x) for x in struct.unpack_from(f"<{fat_count}I", fat_raw)]
|
|
233
|
+
|
|
234
|
+
# Build mini-FAT
|
|
235
|
+
if minifat_start != _ENDOFCHAIN:
|
|
236
|
+
minifat_raw = bytearray()
|
|
237
|
+
for sect in self._chain(minifat_start):
|
|
238
|
+
minifat_raw.extend(self._sector(sect))
|
|
239
|
+
mf_count = len(minifat_raw) // 4
|
|
240
|
+
self._minifat = [int(x) for x in struct.unpack_from(f"<{mf_count}I", minifat_raw)]
|
|
241
|
+
|
|
242
|
+
# Parse directory entries
|
|
243
|
+
dir_raw = bytearray()
|
|
244
|
+
for sect in self._chain(root_dir_start):
|
|
245
|
+
dir_raw.extend(self._sector(sect))
|
|
246
|
+
entries_count = len(dir_raw) // _DIR_ENTRY_SIZE
|
|
247
|
+
self._directory = []
|
|
248
|
+
for i in range(entries_count):
|
|
249
|
+
self._directory.append(self._parse_dir_entry(dir_raw, i))
|
|
250
|
+
|
|
251
|
+
# Build mini-stream from the root entry's stream data
|
|
252
|
+
if self._directory:
|
|
253
|
+
root = self._directory[0]
|
|
254
|
+
if root.start_sector not in (_ENDOFCHAIN, _FREESECT):
|
|
255
|
+
ms_raw = bytearray()
|
|
256
|
+
for sect in self._chain(root.start_sector):
|
|
257
|
+
ms_raw.extend(self._sector(sect))
|
|
258
|
+
self._mini_stream = bytes(ms_raw)
|
|
259
|
+
|
|
260
|
+
def _sector(self, index: int) -> bytes:
|
|
261
|
+
offset = _HEADER_SIZE + index * self.sector_size
|
|
262
|
+
return self._data[offset: offset + self.sector_size]
|
|
263
|
+
|
|
264
|
+
def _chain(self, start: int) -> list[int]:
|
|
265
|
+
sectors: list[int] = []
|
|
266
|
+
current = start
|
|
267
|
+
seen: set[int] = set()
|
|
268
|
+
while current not in (_ENDOFCHAIN, _FREESECT):
|
|
269
|
+
if current in seen or current >= len(self._fat):
|
|
270
|
+
raise CFBError(f"Cycle or out-of-range sector in FAT chain at {current}.")
|
|
271
|
+
seen.add(current)
|
|
272
|
+
sectors.append(current)
|
|
273
|
+
current = self._fat[current]
|
|
274
|
+
return sectors
|
|
275
|
+
|
|
276
|
+
def _mini_chain(self, start: int) -> list[int]:
|
|
277
|
+
sectors: list[int] = []
|
|
278
|
+
current = start
|
|
279
|
+
seen: set[int] = set()
|
|
280
|
+
while current not in (_ENDOFCHAIN, _FREESECT):
|
|
281
|
+
if current in seen or current >= len(self._minifat):
|
|
282
|
+
raise CFBError(f"Cycle or out-of-range sector in mini-FAT chain at {current}.")
|
|
283
|
+
seen.add(current)
|
|
284
|
+
sectors.append(current)
|
|
285
|
+
current = self._minifat[current]
|
|
286
|
+
return sectors
|
|
287
|
+
|
|
288
|
+
def _parse_dir_entry(self, raw: bytes | bytearray, index: int) -> DirEntry:
|
|
289
|
+
offset = index * _DIR_ENTRY_SIZE
|
|
290
|
+
entry_raw = bytes(raw[offset: offset + _DIR_ENTRY_SIZE])
|
|
291
|
+
(
|
|
292
|
+
name_raw,
|
|
293
|
+
name_len,
|
|
294
|
+
obj_type,
|
|
295
|
+
_color,
|
|
296
|
+
left_id,
|
|
297
|
+
right_id,
|
|
298
|
+
child_id,
|
|
299
|
+
_clsid,
|
|
300
|
+
_state,
|
|
301
|
+
_created,
|
|
302
|
+
_modified,
|
|
303
|
+
start_sector,
|
|
304
|
+
size,
|
|
305
|
+
_size_high,
|
|
306
|
+
) = struct.unpack_from(_DIR_ENTRY_FMT, raw, offset)
|
|
307
|
+
|
|
308
|
+
# Name is UTF-16LE; name_len includes the null terminator
|
|
309
|
+
name_bytes = name_raw[: max(0, name_len - 2)]
|
|
310
|
+
name = name_bytes.decode("utf-16-le", errors="replace")
|
|
311
|
+
|
|
312
|
+
return DirEntry(
|
|
313
|
+
name=name,
|
|
314
|
+
obj_type=obj_type,
|
|
315
|
+
child_id=child_id,
|
|
316
|
+
left_sibling_id=left_id,
|
|
317
|
+
right_sibling_id=right_id,
|
|
318
|
+
start_sector=start_sector,
|
|
319
|
+
size=size,
|
|
320
|
+
raw=entry_raw,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _read_stream(self, entry: DirEntry) -> bytes:
|
|
324
|
+
# Honor pending write-back overrides.
|
|
325
|
+
for idx, e in enumerate(self._directory):
|
|
326
|
+
if e is entry and idx in self._stream_overrides:
|
|
327
|
+
return self._stream_overrides[idx]
|
|
328
|
+
if entry.size < self.mini_stream_cutoff and entry.obj_type != _OBJTYPE_ROOT:
|
|
329
|
+
# Read from mini-stream
|
|
330
|
+
raw = bytearray()
|
|
331
|
+
for sect in self._mini_chain(entry.start_sector):
|
|
332
|
+
offset = sect * self.mini_sector_size
|
|
333
|
+
raw.extend(self._mini_stream[offset: offset + self.mini_sector_size])
|
|
334
|
+
return bytes(raw[: entry.size])
|
|
335
|
+
else:
|
|
336
|
+
# Read from regular stream
|
|
337
|
+
raw = bytearray()
|
|
338
|
+
for sect in self._chain(entry.start_sector):
|
|
339
|
+
raw.extend(self._sector(sect))
|
|
340
|
+
return bytes(raw[: entry.size])
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Mutation API
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def write_stream(self, name: str, data: bytes) -> None:
|
|
347
|
+
"""
|
|
348
|
+
Replace the bytes of an existing top-level stream by name.
|
|
349
|
+
|
|
350
|
+
The change is held in memory until :meth:`to_bytes` is called.
|
|
351
|
+
"""
|
|
352
|
+
needle = name.casefold()
|
|
353
|
+
for idx, entry in enumerate(self._directory):
|
|
354
|
+
if entry.obj_type == _OBJTYPE_STREAM and entry.name.casefold() == needle:
|
|
355
|
+
self._stream_overrides[idx] = bytes(data)
|
|
356
|
+
return
|
|
357
|
+
raise KeyError(f"Stream not found: {name!r}")
|
|
358
|
+
|
|
359
|
+
def write_stream_in_storage(self, storage: str, name: str, data: bytes) -> None:
|
|
360
|
+
"""
|
|
361
|
+
Replace the bytes of an existing stream nested inside a storage.
|
|
362
|
+
|
|
363
|
+
Currently scans the directory linearly; the first matching stream wins.
|
|
364
|
+
"""
|
|
365
|
+
needle_s = storage.casefold()
|
|
366
|
+
needle_n = name.casefold()
|
|
367
|
+
if not any(
|
|
368
|
+
e.obj_type == _OBJTYPE_STORAGE and e.name.casefold() == needle_s
|
|
369
|
+
for e in self._directory
|
|
370
|
+
):
|
|
371
|
+
raise KeyError(f"Storage not found: {storage!r}")
|
|
372
|
+
for idx, entry in enumerate(self._directory):
|
|
373
|
+
if entry.obj_type == _OBJTYPE_STREAM and entry.name.casefold() == needle_n:
|
|
374
|
+
self._stream_overrides[idx] = bytes(data)
|
|
375
|
+
return
|
|
376
|
+
raise KeyError(f"Stream {name!r} not found in storage {storage!r}")
|
|
377
|
+
|
|
378
|
+
def add_stream_to_storage(self, storage: str, name: str, data: bytes) -> None:
|
|
379
|
+
"""
|
|
380
|
+
Create a new stream as a child of ``storage`` and seed it with ``data``.
|
|
381
|
+
|
|
382
|
+
A new :class:`DirEntry` is appended to the directory (re-using an
|
|
383
|
+
existing empty slot when one is available) and inserted into the
|
|
384
|
+
storage's child subtree. Raises ``KeyError`` if the storage does not
|
|
385
|
+
exist and ``ValueError`` if a child stream of that name already exists.
|
|
386
|
+
"""
|
|
387
|
+
if not name:
|
|
388
|
+
raise ValueError("stream name must be non-empty")
|
|
389
|
+
parent_idx = self._find_storage_index(storage)
|
|
390
|
+
if self._find_child_stream_index(parent_idx, name) is not None:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"Stream {name!r} already exists in storage {storage!r}"
|
|
393
|
+
)
|
|
394
|
+
# Prefer to recycle an existing EMPTY slot so the directory does not
|
|
395
|
+
# grow unnecessarily; otherwise append a fresh entry.
|
|
396
|
+
target_idx: int | None = None
|
|
397
|
+
for i, e in enumerate(self._directory):
|
|
398
|
+
if e.obj_type == _OBJTYPE_EMPTY:
|
|
399
|
+
target_idx = i
|
|
400
|
+
break
|
|
401
|
+
if target_idx is None:
|
|
402
|
+
self._directory.append(DirEntry(
|
|
403
|
+
name=name,
|
|
404
|
+
obj_type=_OBJTYPE_STREAM,
|
|
405
|
+
child_id=_NOSTREAM,
|
|
406
|
+
left_sibling_id=_NOSTREAM,
|
|
407
|
+
right_sibling_id=_NOSTREAM,
|
|
408
|
+
start_sector=_ENDOFCHAIN,
|
|
409
|
+
size=0,
|
|
410
|
+
raw=b"",
|
|
411
|
+
))
|
|
412
|
+
target_idx = len(self._directory) - 1
|
|
413
|
+
else:
|
|
414
|
+
slot = self._directory[target_idx]
|
|
415
|
+
slot.name = name
|
|
416
|
+
slot.obj_type = _OBJTYPE_STREAM
|
|
417
|
+
slot.child_id = _NOSTREAM
|
|
418
|
+
slot.left_sibling_id = _NOSTREAM
|
|
419
|
+
slot.right_sibling_id = _NOSTREAM
|
|
420
|
+
slot.start_sector = _ENDOFCHAIN
|
|
421
|
+
slot.size = 0
|
|
422
|
+
slot.raw = b""
|
|
423
|
+
self._stream_overrides[target_idx] = bytes(data)
|
|
424
|
+
# Splice the new entry into the parent's child subtree and rebalance.
|
|
425
|
+
siblings = self._collect_subtree(self._directory[parent_idx].child_id)
|
|
426
|
+
siblings.append(target_idx)
|
|
427
|
+
self._directory[parent_idx].child_id = self._rebuild_balanced_subtree(siblings)
|
|
428
|
+
|
|
429
|
+
def add_substorage(self, parent: str, name: str) -> None:
|
|
430
|
+
"""
|
|
431
|
+
Create a new storage as a child of ``parent``.
|
|
432
|
+
|
|
433
|
+
Use :meth:`add_stream_to_storage` afterwards to place child streams
|
|
434
|
+
inside the new sub-storage. Raises ``KeyError`` if ``parent`` does
|
|
435
|
+
not exist and ``ValueError`` if a sibling storage already uses ``name``.
|
|
436
|
+
"""
|
|
437
|
+
if not name:
|
|
438
|
+
raise ValueError("storage name must be non-empty")
|
|
439
|
+
parent_idx = self._find_storage_index(parent)
|
|
440
|
+
# Refuse duplicate sibling storages (case-insensitive).
|
|
441
|
+
needle = name.casefold()
|
|
442
|
+
for child_idx in self._collect_subtree(self._directory[parent_idx].child_id):
|
|
443
|
+
sib = self._directory[child_idx]
|
|
444
|
+
if sib.obj_type == _OBJTYPE_STORAGE and sib.name.casefold() == needle:
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"Storage {name!r} already exists under {parent!r}"
|
|
447
|
+
)
|
|
448
|
+
target_idx: int | None = None
|
|
449
|
+
for i, e in enumerate(self._directory):
|
|
450
|
+
if e.obj_type == _OBJTYPE_EMPTY:
|
|
451
|
+
target_idx = i
|
|
452
|
+
break
|
|
453
|
+
if target_idx is None:
|
|
454
|
+
self._directory.append(DirEntry(
|
|
455
|
+
name=name,
|
|
456
|
+
obj_type=_OBJTYPE_STORAGE,
|
|
457
|
+
child_id=_NOSTREAM,
|
|
458
|
+
left_sibling_id=_NOSTREAM,
|
|
459
|
+
right_sibling_id=_NOSTREAM,
|
|
460
|
+
start_sector=_ENDOFCHAIN,
|
|
461
|
+
size=0,
|
|
462
|
+
raw=b"",
|
|
463
|
+
))
|
|
464
|
+
target_idx = len(self._directory) - 1
|
|
465
|
+
else:
|
|
466
|
+
slot = self._directory[target_idx]
|
|
467
|
+
slot.name = name
|
|
468
|
+
slot.obj_type = _OBJTYPE_STORAGE
|
|
469
|
+
slot.child_id = _NOSTREAM
|
|
470
|
+
slot.left_sibling_id = _NOSTREAM
|
|
471
|
+
slot.right_sibling_id = _NOSTREAM
|
|
472
|
+
slot.start_sector = _ENDOFCHAIN
|
|
473
|
+
slot.size = 0
|
|
474
|
+
slot.raw = b""
|
|
475
|
+
siblings = self._collect_subtree(self._directory[parent_idx].child_id)
|
|
476
|
+
siblings.append(target_idx)
|
|
477
|
+
self._directory[parent_idx].child_id = self._rebuild_balanced_subtree(siblings)
|
|
478
|
+
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
# Directory mutation (stream removal / drop)
|
|
481
|
+
# ------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
def remove_stream_in_storage(self, storage: str, name: str) -> None:
|
|
484
|
+
"""
|
|
485
|
+
Remove a stream from a named storage.
|
|
486
|
+
|
|
487
|
+
The stream's directory entry is marked empty and unlinked from the
|
|
488
|
+
storage's red-black sibling tree. The change is held in memory until
|
|
489
|
+
:meth:`to_bytes` is called.
|
|
490
|
+
"""
|
|
491
|
+
parent_idx = self._find_storage_index(storage)
|
|
492
|
+
target_idx = self._find_child_stream_index(parent_idx, name)
|
|
493
|
+
if target_idx is None:
|
|
494
|
+
raise KeyError(f"Stream {name!r} not found in storage {storage!r}")
|
|
495
|
+
self._unlink_and_clear(parent_idx, target_idx)
|
|
496
|
+
|
|
497
|
+
def remove_stream(self, name: str) -> None:
|
|
498
|
+
"""Remove a top-level stream (child of the root entry)."""
|
|
499
|
+
target_idx = self._find_child_stream_index(0, name)
|
|
500
|
+
if target_idx is None:
|
|
501
|
+
raise KeyError(f"Stream not found: {name!r}")
|
|
502
|
+
self._unlink_and_clear(0, target_idx)
|
|
503
|
+
|
|
504
|
+
def rename_stream_in_storage(self, storage: str, old: str, new: str) -> None:
|
|
505
|
+
"""
|
|
506
|
+
Rename a stream within a storage.
|
|
507
|
+
|
|
508
|
+
The entry's name is replaced and the storage's child subtree is rebuilt
|
|
509
|
+
to keep BST ordering ([MS-CFB] 2.6.4) valid. Raises ``KeyError`` if
|
|
510
|
+
the source stream is missing and ``ValueError`` if a sibling already
|
|
511
|
+
uses ``new``.
|
|
512
|
+
"""
|
|
513
|
+
if not new:
|
|
514
|
+
raise ValueError("new stream name must be non-empty")
|
|
515
|
+
if old.casefold() == new.casefold():
|
|
516
|
+
# Same name (case-insensitive): just patch the displayed casing.
|
|
517
|
+
parent_idx = self._find_storage_index(storage)
|
|
518
|
+
target_idx = self._find_child_stream_index(parent_idx, old)
|
|
519
|
+
if target_idx is None:
|
|
520
|
+
raise KeyError(f"Stream {old!r} not found in storage {storage!r}")
|
|
521
|
+
self._directory[target_idx].name = new
|
|
522
|
+
return
|
|
523
|
+
parent_idx = self._find_storage_index(storage)
|
|
524
|
+
target_idx = self._find_child_stream_index(parent_idx, old)
|
|
525
|
+
if target_idx is None:
|
|
526
|
+
raise KeyError(f"Stream {old!r} not found in storage {storage!r}")
|
|
527
|
+
if self._find_child_stream_index(parent_idx, new) is not None:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"Stream {new!r} already exists in storage {storage!r}"
|
|
530
|
+
)
|
|
531
|
+
self._directory[target_idx].name = new
|
|
532
|
+
# Rebuild the parent's subtree so BST ordering reflects the new name.
|
|
533
|
+
siblings = self._collect_subtree(self._directory[parent_idx].child_id)
|
|
534
|
+
self._directory[parent_idx].child_id = self._rebuild_balanced_subtree(siblings)
|
|
535
|
+
|
|
536
|
+
def drop_streams_in_storage(
|
|
537
|
+
self, storage: str, predicate: "Callable[[str], bool]"
|
|
538
|
+
) -> list[str]:
|
|
539
|
+
"""
|
|
540
|
+
Remove every stream child of ``storage`` whose name satisfies ``predicate``.
|
|
541
|
+
|
|
542
|
+
Returns the names of removed streams (in directory order). Raises
|
|
543
|
+
:class:`KeyError` if the storage does not exist.
|
|
544
|
+
"""
|
|
545
|
+
parent_idx = self._find_storage_index(storage)
|
|
546
|
+
children = self._collect_subtree(self._directory[parent_idx].child_id)
|
|
547
|
+
removed: list[str] = []
|
|
548
|
+
for child_idx in children:
|
|
549
|
+
entry = self._directory[child_idx]
|
|
550
|
+
if entry.obj_type == _OBJTYPE_STREAM and predicate(entry.name):
|
|
551
|
+
removed.append(entry.name)
|
|
552
|
+
for name in removed:
|
|
553
|
+
self.remove_stream_in_storage(storage, name)
|
|
554
|
+
return removed
|
|
555
|
+
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
# Directory tree helpers
|
|
558
|
+
# ------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
def _find_storage_index(self, storage: str) -> int:
|
|
561
|
+
needle = storage.casefold()
|
|
562
|
+
for idx, entry in enumerate(self._directory):
|
|
563
|
+
if entry.obj_type == _OBJTYPE_STORAGE and entry.name.casefold() == needle:
|
|
564
|
+
return idx
|
|
565
|
+
raise KeyError(f"Storage not found: {storage!r}")
|
|
566
|
+
|
|
567
|
+
def _find_child_stream_index(self, parent_idx: int, name: str) -> int | None:
|
|
568
|
+
needle = name.casefold()
|
|
569
|
+
for child_idx in self._collect_subtree(self._directory[parent_idx].child_id):
|
|
570
|
+
entry = self._directory[child_idx]
|
|
571
|
+
if entry.obj_type == _OBJTYPE_STREAM and entry.name.casefold() == needle:
|
|
572
|
+
return child_idx
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
def _collect_subtree(self, root_id: int) -> list[int]:
|
|
576
|
+
"""Walk the sibling subtree rooted at ``root_id`` and return all node indices."""
|
|
577
|
+
out: list[int] = []
|
|
578
|
+
seen: set[int] = set()
|
|
579
|
+
stack: list[int] = []
|
|
580
|
+
if root_id != _NOSTREAM and root_id < len(self._directory):
|
|
581
|
+
stack.append(root_id)
|
|
582
|
+
while stack:
|
|
583
|
+
node = stack.pop()
|
|
584
|
+
if node in seen or node == _NOSTREAM or node >= len(self._directory):
|
|
585
|
+
continue
|
|
586
|
+
seen.add(node)
|
|
587
|
+
out.append(node)
|
|
588
|
+
entry = self._directory[node]
|
|
589
|
+
if entry.left_sibling_id != _NOSTREAM:
|
|
590
|
+
stack.append(entry.left_sibling_id)
|
|
591
|
+
if entry.right_sibling_id != _NOSTREAM:
|
|
592
|
+
stack.append(entry.right_sibling_id)
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
def _rebuild_balanced_subtree(self, indices: list[int]) -> int:
|
|
596
|
+
"""
|
|
597
|
+
Build a balanced BST from ``indices`` sorted by ``([MS-CFB] 2.6.4)``
|
|
598
|
+
ordering: ``(len(name), upper(name))``. Returns the new root id, or
|
|
599
|
+
``_NOSTREAM`` if empty. Side-effect: rewrites the participating
|
|
600
|
+
entries' left/right sibling pointers.
|
|
601
|
+
"""
|
|
602
|
+
if not indices:
|
|
603
|
+
return _NOSTREAM
|
|
604
|
+
ordered = sorted(
|
|
605
|
+
indices,
|
|
606
|
+
key=lambda i: (len(self._directory[i].name), self._directory[i].name.upper()),
|
|
607
|
+
)
|
|
608
|
+
return self._build_from_sorted(ordered)
|
|
609
|
+
|
|
610
|
+
def _build_from_sorted(self, ordered: list[int]) -> int:
|
|
611
|
+
if not ordered:
|
|
612
|
+
return _NOSTREAM
|
|
613
|
+
mid = len(ordered) // 2
|
|
614
|
+
root = ordered[mid]
|
|
615
|
+
self._directory[root].left_sibling_id = self._build_from_sorted(ordered[:mid])
|
|
616
|
+
self._directory[root].right_sibling_id = self._build_from_sorted(ordered[mid + 1:])
|
|
617
|
+
return root
|
|
618
|
+
|
|
619
|
+
def _unlink_and_clear(self, parent_idx: int, target_idx: int) -> None:
|
|
620
|
+
parent = self._directory[parent_idx]
|
|
621
|
+
siblings = [i for i in self._collect_subtree(parent.child_id) if i != target_idx]
|
|
622
|
+
parent.child_id = self._rebuild_balanced_subtree(siblings)
|
|
623
|
+
# Clear the removed entry so the serializer emits an empty slot.
|
|
624
|
+
dead = self._directory[target_idx]
|
|
625
|
+
dead.name = ""
|
|
626
|
+
dead.obj_type = _OBJTYPE_EMPTY
|
|
627
|
+
dead.left_sibling_id = _NOSTREAM
|
|
628
|
+
dead.right_sibling_id = _NOSTREAM
|
|
629
|
+
dead.child_id = _NOSTREAM
|
|
630
|
+
dead.start_sector = _FREESECT
|
|
631
|
+
dead.size = 0
|
|
632
|
+
dead.raw = b""
|
|
633
|
+
# Drop any pending override for the removed stream.
|
|
634
|
+
self._stream_overrides.pop(target_idx, None)
|
|
635
|
+
|
|
636
|
+
# ------------------------------------------------------------------
|
|
637
|
+
# Serializer ([MS-CFB] write-path)
|
|
638
|
+
# ------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
def to_bytes(self) -> bytes:
|
|
641
|
+
"""
|
|
642
|
+
Serialize this CFB back to bytes, honoring any pending stream overrides.
|
|
643
|
+
|
|
644
|
+
Output uses major version 3 (512-byte sectors, 64-byte mini-sectors,
|
|
645
|
+
4096-byte mini-stream cutoff). Preserves the original directory-tree
|
|
646
|
+
topology and per-entry metadata (CLSID, state, color, timestamps).
|
|
647
|
+
|
|
648
|
+
Layout, in sector order:
|
|
649
|
+
[mini-stream data] [regular stream data] [directory] [mini-FAT] [FAT]
|
|
650
|
+
|
|
651
|
+
Files large enough to require a DIFAT chain (more than 109 FAT sectors,
|
|
652
|
+
about 27 MB of total payload) are rejected — vbaProject.bin is always
|
|
653
|
+
well under this limit.
|
|
654
|
+
"""
|
|
655
|
+
SECTOR = 512
|
|
656
|
+
MINI = 64
|
|
657
|
+
CUTOFF = 4096
|
|
658
|
+
ENTRIES_PER_SECTOR = SECTOR // 4 # 128 FAT entries / sector
|
|
659
|
+
DIR_ENTRIES_PER_SECTOR = SECTOR // _DIR_ENTRY_SIZE # 4 dir entries / sector
|
|
660
|
+
|
|
661
|
+
# ---- 1. Snapshot every directory entry's stream data ----
|
|
662
|
+
stream_bytes: list[bytes] = []
|
|
663
|
+
for i, entry in enumerate(self._directory):
|
|
664
|
+
if entry.obj_type == _OBJTYPE_STREAM:
|
|
665
|
+
if i in self._stream_overrides:
|
|
666
|
+
stream_bytes.append(self._stream_overrides[i])
|
|
667
|
+
else:
|
|
668
|
+
stream_bytes.append(self._read_stream(entry))
|
|
669
|
+
else:
|
|
670
|
+
stream_bytes.append(b"")
|
|
671
|
+
|
|
672
|
+
# ---- 2. Pack mini-eligible streams into the mini-stream + mini-FAT ----
|
|
673
|
+
minifat: list[int] = []
|
|
674
|
+
mini_stream = bytearray()
|
|
675
|
+
mini_starts: dict[int, int] = {} # dir_idx -> first mini-sector
|
|
676
|
+
for i, entry in enumerate(self._directory):
|
|
677
|
+
if entry.obj_type != _OBJTYPE_STREAM:
|
|
678
|
+
continue
|
|
679
|
+
data = stream_bytes[i]
|
|
680
|
+
if len(data) == 0 or len(data) >= CUTOFF:
|
|
681
|
+
continue
|
|
682
|
+
n = (len(data) + MINI - 1) // MINI
|
|
683
|
+
first = len(minifat)
|
|
684
|
+
for k in range(n):
|
|
685
|
+
minifat.append(first + k + 1 if k + 1 < n else _ENDOFCHAIN)
|
|
686
|
+
mini_stream.extend(data)
|
|
687
|
+
if len(data) % MINI != 0:
|
|
688
|
+
mini_stream.extend(b"\x00" * (MINI - len(data) % MINI))
|
|
689
|
+
mini_starts[i] = first
|
|
690
|
+
|
|
691
|
+
mini_stream_used_bytes = len(minifat) * MINI # logical length for Root.size
|
|
692
|
+
|
|
693
|
+
# Pad mini-stream to a 512-byte sector boundary.
|
|
694
|
+
if len(mini_stream) % SECTOR != 0:
|
|
695
|
+
mini_stream.extend(b"\x00" * (SECTOR - len(mini_stream) % SECTOR))
|
|
696
|
+
|
|
697
|
+
# Pad mini-FAT array to a full sector.
|
|
698
|
+
while len(minifat) % ENTRIES_PER_SECTOR != 0:
|
|
699
|
+
minifat.append(_FREESECT)
|
|
700
|
+
|
|
701
|
+
# ---- 3. Assign sector indices ----
|
|
702
|
+
sector_payloads: list[bytes] = []
|
|
703
|
+
|
|
704
|
+
# 3a. Mini-stream sectors
|
|
705
|
+
n_mini_stream_sectors = len(mini_stream) // SECTOR
|
|
706
|
+
if n_mini_stream_sectors > 0:
|
|
707
|
+
mini_stream_first = len(sector_payloads)
|
|
708
|
+
for k in range(n_mini_stream_sectors):
|
|
709
|
+
sector_payloads.append(bytes(mini_stream[k * SECTOR:(k + 1) * SECTOR]))
|
|
710
|
+
else:
|
|
711
|
+
mini_stream_first = _ENDOFCHAIN
|
|
712
|
+
|
|
713
|
+
# 3b. Regular stream sectors
|
|
714
|
+
reg_starts: dict[int, int] = {}
|
|
715
|
+
for i, entry in enumerate(self._directory):
|
|
716
|
+
if entry.obj_type != _OBJTYPE_STREAM:
|
|
717
|
+
continue
|
|
718
|
+
data = stream_bytes[i]
|
|
719
|
+
if len(data) < CUTOFF:
|
|
720
|
+
continue
|
|
721
|
+
n = (len(data) + SECTOR - 1) // SECTOR
|
|
722
|
+
first = len(sector_payloads)
|
|
723
|
+
padded = data + b"\x00" * (n * SECTOR - len(data))
|
|
724
|
+
for k in range(n):
|
|
725
|
+
sector_payloads.append(padded[k * SECTOR:(k + 1) * SECTOR])
|
|
726
|
+
reg_starts[i] = first
|
|
727
|
+
|
|
728
|
+
# 3c. Directory sectors
|
|
729
|
+
n_dir_entries = len(self._directory)
|
|
730
|
+
n_dir_sectors = max(
|
|
731
|
+
1,
|
|
732
|
+
(n_dir_entries + DIR_ENTRIES_PER_SECTOR - 1) // DIR_ENTRIES_PER_SECTOR,
|
|
733
|
+
)
|
|
734
|
+
dir_buf = bytearray()
|
|
735
|
+
for i, entry in enumerate(self._directory):
|
|
736
|
+
dir_buf.extend(self._build_dir_entry_bytes(
|
|
737
|
+
entry=entry,
|
|
738
|
+
data_size=len(stream_bytes[i]),
|
|
739
|
+
mini_starts=mini_starts,
|
|
740
|
+
reg_starts=reg_starts,
|
|
741
|
+
mini_stream_first=mini_stream_first,
|
|
742
|
+
mini_stream_used_bytes=mini_stream_used_bytes,
|
|
743
|
+
idx=i,
|
|
744
|
+
))
|
|
745
|
+
# Pad remaining entries in the last dir sector with empty entries.
|
|
746
|
+
empty_entry = self._empty_dir_entry_bytes()
|
|
747
|
+
while len(dir_buf) < n_dir_sectors * SECTOR:
|
|
748
|
+
dir_buf.extend(empty_entry)
|
|
749
|
+
|
|
750
|
+
dir_first = len(sector_payloads)
|
|
751
|
+
for k in range(n_dir_sectors):
|
|
752
|
+
sector_payloads.append(bytes(dir_buf[k * SECTOR:(k + 1) * SECTOR]))
|
|
753
|
+
|
|
754
|
+
# 3d. Mini-FAT sectors
|
|
755
|
+
if minifat:
|
|
756
|
+
minifat_first = len(sector_payloads)
|
|
757
|
+
minifat_bytes = struct.pack(f"<{len(minifat)}I", *minifat)
|
|
758
|
+
n_minifat_sectors = len(minifat_bytes) // SECTOR
|
|
759
|
+
for k in range(n_minifat_sectors):
|
|
760
|
+
sector_payloads.append(minifat_bytes[k * SECTOR:(k + 1) * SECTOR])
|
|
761
|
+
else:
|
|
762
|
+
minifat_first = _ENDOFCHAIN
|
|
763
|
+
n_minifat_sectors = 0
|
|
764
|
+
|
|
765
|
+
# ---- 4. Decide how many FAT sectors we need (fixed-point) ----
|
|
766
|
+
n_data = len(sector_payloads)
|
|
767
|
+
n_fat = 1
|
|
768
|
+
while True:
|
|
769
|
+
needed = ((n_data + n_fat) + ENTRIES_PER_SECTOR - 1) // ENTRIES_PER_SECTOR
|
|
770
|
+
if needed <= n_fat:
|
|
771
|
+
break
|
|
772
|
+
n_fat = needed
|
|
773
|
+
if n_fat > 109:
|
|
774
|
+
raise CFBError(
|
|
775
|
+
"CFB writer requires DIFAT chain support for files this large; "
|
|
776
|
+
"not implemented."
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
fat_first = n_data
|
|
780
|
+
fat_sector_ids = list(range(fat_first, fat_first + n_fat))
|
|
781
|
+
|
|
782
|
+
# ---- 5. Build the FAT ----
|
|
783
|
+
fat = [_FREESECT] * (n_fat * ENTRIES_PER_SECTOR)
|
|
784
|
+
|
|
785
|
+
def chain(start: int, n: int) -> None:
|
|
786
|
+
for k in range(n):
|
|
787
|
+
fat[start + k] = (start + k + 1) if k + 1 < n else _ENDOFCHAIN
|
|
788
|
+
|
|
789
|
+
if n_mini_stream_sectors > 0:
|
|
790
|
+
chain(mini_stream_first, n_mini_stream_sectors)
|
|
791
|
+
for i, entry in enumerate(self._directory):
|
|
792
|
+
if entry.obj_type == _OBJTYPE_STREAM:
|
|
793
|
+
data = stream_bytes[i]
|
|
794
|
+
if len(data) >= CUTOFF:
|
|
795
|
+
n_s = (len(data) + SECTOR - 1) // SECTOR
|
|
796
|
+
chain(reg_starts[i], n_s)
|
|
797
|
+
chain(dir_first, n_dir_sectors)
|
|
798
|
+
if n_minifat_sectors > 0:
|
|
799
|
+
chain(minifat_first, n_minifat_sectors)
|
|
800
|
+
for s in fat_sector_ids:
|
|
801
|
+
fat[s] = _FATSECT
|
|
802
|
+
|
|
803
|
+
fat_bytes = struct.pack(f"<{len(fat)}I", *fat)
|
|
804
|
+
for k in range(n_fat):
|
|
805
|
+
sector_payloads.append(fat_bytes[k * SECTOR:(k + 1) * SECTOR])
|
|
806
|
+
|
|
807
|
+
# ---- 6. Build the header ----
|
|
808
|
+
difat = list(fat_sector_ids) + [_FREESECT] * (109 - len(fat_sector_ids))
|
|
809
|
+
difat_bytes = struct.pack("<109I", *difat)
|
|
810
|
+
|
|
811
|
+
header = struct.pack(
|
|
812
|
+
_HEADER_FMT,
|
|
813
|
+
_MAGIC,
|
|
814
|
+
b"\x00" * 16, # CLSID — must be zero per spec
|
|
815
|
+
0x003E, # minor version
|
|
816
|
+
3, # major version
|
|
817
|
+
0xFFFE, # byte-order mark (little-endian)
|
|
818
|
+
9, # sector shift: 2^9 = 512
|
|
819
|
+
6, # mini-sector shift: 2^6 = 64
|
|
820
|
+
b"\x00" * 6, # reserved
|
|
821
|
+
0, # num_dir_sectors (must be 0 for v3)
|
|
822
|
+
n_fat, # num_fat_sectors
|
|
823
|
+
dir_first, # first_dir_sector
|
|
824
|
+
0, # transaction signature
|
|
825
|
+
CUTOFF, # mini-stream cutoff size
|
|
826
|
+
minifat_first, # first mini-FAT sector
|
|
827
|
+
n_minifat_sectors, # num mini-FAT sectors
|
|
828
|
+
_ENDOFCHAIN, # first DIFAT sector (none)
|
|
829
|
+
0, # num DIFAT sectors
|
|
830
|
+
difat_bytes, # 436-byte DIFAT array
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
out = bytearray(header)
|
|
834
|
+
for s in sector_payloads:
|
|
835
|
+
out.extend(s)
|
|
836
|
+
return bytes(out)
|
|
837
|
+
|
|
838
|
+
# ------------------------------------------------------------------
|
|
839
|
+
# Serializer helpers
|
|
840
|
+
# ------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
def _build_dir_entry_bytes(
|
|
843
|
+
self,
|
|
844
|
+
entry: DirEntry,
|
|
845
|
+
data_size: int,
|
|
846
|
+
mini_starts: dict[int, int],
|
|
847
|
+
reg_starts: dict[int, int],
|
|
848
|
+
mini_stream_first: int,
|
|
849
|
+
mini_stream_used_bytes: int,
|
|
850
|
+
idx: int,
|
|
851
|
+
) -> bytes:
|
|
852
|
+
"""
|
|
853
|
+
Re-emit a 128-byte directory entry, patching in updated start_sector and
|
|
854
|
+
size while preserving the original CLSID, color, state, and timestamps.
|
|
855
|
+
"""
|
|
856
|
+
CUTOFF = 4096
|
|
857
|
+
|
|
858
|
+
if entry.obj_type == _OBJTYPE_EMPTY:
|
|
859
|
+
return self._empty_dir_entry_bytes()
|
|
860
|
+
|
|
861
|
+
if entry.obj_type == _OBJTYPE_ROOT:
|
|
862
|
+
start_sector = mini_stream_first
|
|
863
|
+
size = mini_stream_used_bytes
|
|
864
|
+
elif entry.obj_type == _OBJTYPE_STREAM:
|
|
865
|
+
if data_size == 0:
|
|
866
|
+
start_sector = _ENDOFCHAIN
|
|
867
|
+
size = 0
|
|
868
|
+
elif data_size < CUTOFF:
|
|
869
|
+
start_sector = mini_starts[idx]
|
|
870
|
+
size = data_size
|
|
871
|
+
else:
|
|
872
|
+
start_sector = reg_starts[idx]
|
|
873
|
+
size = data_size
|
|
874
|
+
else:
|
|
875
|
+
# Storage entry: no stream data.
|
|
876
|
+
start_sector = _ENDOFCHAIN
|
|
877
|
+
size = 0
|
|
878
|
+
|
|
879
|
+
# Start from the preserved raw entry (or a synthesised blank if missing).
|
|
880
|
+
if len(entry.raw) == _DIR_ENTRY_SIZE:
|
|
881
|
+
buf = bytearray(entry.raw)
|
|
882
|
+
else:
|
|
883
|
+
buf = bytearray(self._synth_dir_entry_bytes(entry))
|
|
884
|
+
|
|
885
|
+
# Patch sibling/child pointers and name in case they have been edited.
|
|
886
|
+
name_utf16 = entry.name.encode("utf-16-le") + b"\x00\x00"
|
|
887
|
+
if len(name_utf16) > 64:
|
|
888
|
+
raise CFBError(f"Directory entry name too long: {entry.name!r}")
|
|
889
|
+
name_bytes = name_utf16 + b"\x00" * (64 - len(name_utf16))
|
|
890
|
+
struct.pack_into("<64sHBB", buf, 0,
|
|
891
|
+
name_bytes, len(name_utf16), entry.obj_type, buf[67])
|
|
892
|
+
struct.pack_into("<III", buf, 68,
|
|
893
|
+
entry.left_sibling_id,
|
|
894
|
+
entry.right_sibling_id,
|
|
895
|
+
entry.child_id)
|
|
896
|
+
# start_sector at offset 116, size_low at 120, size_high at 124.
|
|
897
|
+
struct.pack_into("<III", buf, 116,
|
|
898
|
+
start_sector & 0xFFFFFFFF,
|
|
899
|
+
size & 0xFFFFFFFF,
|
|
900
|
+
(size >> 32) & 0xFFFFFFFF)
|
|
901
|
+
return bytes(buf)
|
|
902
|
+
|
|
903
|
+
def _synth_dir_entry_bytes(self, entry: DirEntry) -> bytes:
|
|
904
|
+
"""Build a fresh directory entry from scratch when no preserved raw exists."""
|
|
905
|
+
return struct.pack(
|
|
906
|
+
_DIR_ENTRY_FMT,
|
|
907
|
+
b"\x00" * 64, # name (patched in by caller)
|
|
908
|
+
0, # name_len
|
|
909
|
+
entry.obj_type,
|
|
910
|
+
0, # color (red)
|
|
911
|
+
entry.left_sibling_id,
|
|
912
|
+
entry.right_sibling_id,
|
|
913
|
+
entry.child_id,
|
|
914
|
+
b"\x00" * 16, # CLSID
|
|
915
|
+
0, # state
|
|
916
|
+
0, # created
|
|
917
|
+
0, # modified
|
|
918
|
+
_ENDOFCHAIN, # start_sector (patched in by caller)
|
|
919
|
+
0, # size_low (patched in by caller)
|
|
920
|
+
0, # size_high
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
def _empty_dir_entry_bytes(self) -> bytes:
|
|
924
|
+
"""A fully-empty directory entry slot ([MS-CFB] 2.6.4)."""
|
|
925
|
+
return struct.pack(
|
|
926
|
+
_DIR_ENTRY_FMT,
|
|
927
|
+
b"\x00" * 64, 0, _OBJTYPE_EMPTY, 0,
|
|
928
|
+
_NOSTREAM, _NOSTREAM, _NOSTREAM,
|
|
929
|
+
b"\x00" * 16, 0, 0, 0,
|
|
930
|
+
_FREESECT, 0, 0,
|
|
931
|
+
)
|