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/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
+ )