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/vba.py ADDED
@@ -0,0 +1,1543 @@
1
+ """
2
+ VBA project parser and compressor.
3
+
4
+ Implements [MS-OVBA] sufficient for Level 1 read-only extraction and
5
+ Level 2 source-replacement write-back.
6
+
7
+ Stream layout inside the VBA storage (section 2.2)
8
+ ----------------------------------------------------
9
+ VBA/
10
+ _VBA_PROJECT - p-code performance cache (opaque; preserve or omit on write)
11
+ dir - compressed project/module metadata (section 2.3.4.2)
12
+ <stream_name> - compressed source for each module (section 2.3.4.3)
13
+
14
+ Critical implementation traps (guide section 31)
15
+ -------------------------------------------------
16
+ - Module stream name is MODULESTREAMNAME, not the logical module name.
17
+ - Source starts at MODULEOFFSET, not byte 0 of the stream.
18
+ - Source bytes are MS-OVBA compressed; decompress before decoding.
19
+ - Decompressed source bytes are MBCS encoded with the project code page.
20
+ - The dir stream itself is also compressed.
21
+ - MODULETYPE 0x0022 covers class, document, AND designer modules; use the
22
+ PROJECT stream to distinguish them (not implemented in this version).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import struct
28
+ from collections.abc import Iterable
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+
32
+ from pyopenvba.cfb import CFB
33
+ from pyopenvba.exceptions import VBAProjectError
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Section 2.4 — MS-OVBA compression / decompression
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def copy_token_help(decompressed_current: int, decompressed_chunk_start: int) -> tuple[int, int, int]:
40
+ """
41
+ Return (length_mask, offset_mask, bit_count) for the copy-token at the
42
+ given output-buffer position. [MS-OVBA] 2.4.1.3.6.
43
+
44
+ bit_count = number of bits for the OFFSET field = ceil_log2(difference),
45
+ minimum 4. As the decoder advances through a 4096-byte decompressed chunk,
46
+ bit_count grows (more back-reference distance available) and the length
47
+ field shrinks accordingly.
48
+ """
49
+ difference = max(1, decompressed_current - decompressed_chunk_start)
50
+ # ceil_log2 via bit_length: (n-1).bit_length() == ceil(log2(n)) for n >= 1
51
+ bit_count = max(4, (difference - 1).bit_length())
52
+ length_mask: int = 0xFFFF >> bit_count
53
+ offset_mask: int = (~length_mask) & 0xFFFF
54
+ return length_mask, offset_mask, bit_count
55
+
56
+
57
+ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
58
+ """
59
+ Decompress a VBA stream using the MS-OVBA compression algorithm.
60
+
61
+ [MS-OVBA] 2.4.1 — CompressedContainer / DecompressedChunk.
62
+
63
+ Format:
64
+ Byte 0: SignatureByte = 0x01
65
+ Bytes 1..: one or more CompressedChunks
66
+
67
+ Each CompressedChunk:
68
+ Header u16 LE:
69
+ bits 0-11: CompressedChunkSize = byte_count_of_chunk_data - 1
70
+ bits 12-14: CompressedChunkSignature = 0b011
71
+ bit 15: CompressedChunkFlag (1=compressed, 0=raw)
72
+ Data: (CompressedChunkSize + 1) bytes
73
+
74
+ Raw chunk: Data is exactly 4096 literal bytes.
75
+ Token chunk: Data is groups of (flag_byte, 0-8 tokens).
76
+
77
+ ``stream_name`` is purely contextual: it is embedded in any
78
+ :class:`VBAProjectError` raised here so callers can identify which
79
+ CFB stream a malformed chunk came from.
80
+ """
81
+
82
+ def _err(msg: str, offset: int) -> "VBAProjectError":
83
+ return VBAProjectError(
84
+ f"{msg} [stream={stream_name!r}, offset={offset}]"
85
+ )
86
+
87
+ if not data or data[0] != 0x01:
88
+ raise _err("Invalid compressed stream: missing 0x01 signature byte.", 0)
89
+
90
+ pos = 1
91
+ out = bytearray()
92
+
93
+ while pos < len(data):
94
+ if pos + 2 > len(data):
95
+ raise _err("Truncated compressed stream: missing chunk header.", pos)
96
+
97
+ header = int(struct.unpack_from("<H", data, pos)[0])
98
+ chunk_data_size = (header & 0x0FFF) + 1 # data bytes after the header
99
+ chunk_signature = (header >> 12) & 0x7
100
+ chunk_flag = (header >> 15) & 0x1
101
+ header_offset = pos
102
+ pos += 2
103
+
104
+ if chunk_signature != 0b011:
105
+ raise _err(
106
+ f"Bad compressed chunk signature: expected 0b011, "
107
+ f"got {chunk_signature:#05b}.",
108
+ header_offset,
109
+ )
110
+
111
+ chunk_end = pos + chunk_data_size
112
+ if chunk_end > len(data):
113
+ raise _err(
114
+ f"Truncated chunk: header announces {chunk_data_size} bytes "
115
+ f"but only {len(data) - pos} remain.",
116
+ header_offset,
117
+ )
118
+ decompressed_chunk_start = len(out)
119
+
120
+ if chunk_flag == 0:
121
+ # Raw (uncompressed) chunk — exactly 4096 literal bytes.
122
+ if chunk_data_size != 4096:
123
+ raise _err(
124
+ f"Raw chunk must have exactly 4096 data bytes; "
125
+ f"got {chunk_data_size}.",
126
+ header_offset,
127
+ )
128
+ if pos + 4096 > len(data):
129
+ raise _err("Truncated raw chunk.", pos)
130
+ out.extend(data[pos: pos + 4096])
131
+ pos += 4096
132
+ else:
133
+ # Token-compressed chunk.
134
+ while pos < chunk_end:
135
+ if pos >= len(data):
136
+ break
137
+ flag_byte = int(data[pos])
138
+ pos += 1
139
+
140
+ for bit in range(8):
141
+ if pos >= chunk_end or pos >= len(data):
142
+ break
143
+
144
+ if (flag_byte >> bit) & 1:
145
+ # Copy token — back-reference into already-decompressed output.
146
+ if pos + 2 > len(data):
147
+ raise _err("Truncated copy token.", pos)
148
+ token = int(struct.unpack_from("<H", data, pos)[0])
149
+ pos += 2
150
+
151
+ length_mask, offset_mask, bit_count = copy_token_help(
152
+ len(out), decompressed_chunk_start
153
+ )
154
+ length = (token & length_mask) + 3
155
+ offset = ((token & offset_mask) >> (16 - bit_count)) + 1
156
+
157
+ copy_src = len(out) - offset
158
+ if copy_src < 0:
159
+ raise _err(
160
+ "Copy token references before start of output.",
161
+ pos - 2,
162
+ )
163
+ if copy_src < decompressed_chunk_start:
164
+ raise _err(
165
+ "Copy token references before the start of the "
166
+ "current chunk.",
167
+ pos - 2,
168
+ )
169
+
170
+ # Byte-by-byte copy; overlap is intentional and required by spec.
171
+ for _ in range(length):
172
+ out.append(out[copy_src])
173
+ copy_src += 1
174
+ else:
175
+ # Literal token.
176
+ out.append(int(data[pos]))
177
+ pos += 1
178
+
179
+ return bytes(out)
180
+
181
+
182
+ def compress(data: bytes) -> bytes:
183
+ """
184
+ Compress data using the MS-OVBA compression algorithm.
185
+
186
+ [MS-OVBA] 2.4.1 — write path.
187
+
188
+ Full 4096-byte chunks are stored as raw chunks.
189
+ Partial final chunks are token-compressed via greedy LZ.
190
+ """
191
+ out = bytearray([0x01]) # SignatureByte
192
+ cursor = 0
193
+
194
+ while cursor < len(data):
195
+ chunk = data[cursor: cursor + 4096]
196
+ cursor += len(chunk)
197
+
198
+ if len(chunk) == 4096:
199
+ # Raw chunk: header flag=0, sig=0b011, size-1=0x0FFF → header=0x3FFF
200
+ out.extend(struct.pack("<H", 0x3FFF))
201
+ out.extend(chunk)
202
+ else:
203
+ encoded = _encode_token_chunk(chunk)
204
+ if len(encoded) > 4096:
205
+ raise VBAProjectError(
206
+ f"Token-compressed chunk is {len(encoded)} bytes (max 4096)."
207
+ )
208
+ # header: flag=1, sig=0b011, size-1 = len(encoded)-1
209
+ # 0x8000 | 0x3000 = 0xB000
210
+ header = 0xB000 | (len(encoded) - 1)
211
+ out.extend(struct.pack("<H", header))
212
+ out.extend(encoded)
213
+
214
+ return bytes(out)
215
+
216
+
217
+ def _encode_token_chunk(chunk: bytes) -> bytes:
218
+ n = len(chunk)
219
+ # Literal-only fits in 4096 bytes when n + ceil(n/8) <= 4096 (i.e. n <= 3640).
220
+ if n + ((n + 7) // 8) <= 4096:
221
+ return _encode_literal_only(chunk)
222
+ return _encode_lz(chunk)
223
+
224
+
225
+ def _encode_literal_only(chunk: bytes) -> bytes:
226
+ out = bytearray()
227
+ i = 0
228
+ while i < len(chunk):
229
+ group = chunk[i: i + 8]
230
+ out.append(0x00) # all-literal flag byte
231
+ out.extend(group)
232
+ i += len(group)
233
+ return bytes(out)
234
+
235
+
236
+ def _encode_lz(chunk: bytes) -> bytes:
237
+ """Greedy LZ encoder for token-compressed chunks."""
238
+ out = bytearray()
239
+ pos = 0
240
+ chunk_len = len(chunk)
241
+
242
+ while pos < chunk_len:
243
+ flag_bits = 0
244
+ tokens: list[bytes] = []
245
+
246
+ for bit in range(8):
247
+ if pos >= chunk_len:
248
+ break
249
+
250
+ length_mask, offset_mask, bit_count = copy_token_help(pos, 0)
251
+ max_length = length_mask + 3
252
+ max_offset = (offset_mask >> (16 - bit_count)) + 1
253
+ start = max(0, pos - max_offset)
254
+
255
+ best_len = 0
256
+ best_offset = 0
257
+ for candidate in range(start, pos):
258
+ match_len = 0
259
+ while (pos + match_len < chunk_len
260
+ and chunk[candidate + match_len] == chunk[pos + match_len]
261
+ and match_len < max_length):
262
+ match_len += 1
263
+ if match_len > best_len:
264
+ best_len = match_len
265
+ best_offset = pos - candidate
266
+
267
+ if best_len >= 3:
268
+ flag_bits |= (1 << bit)
269
+ offset_bits = ((best_offset - 1) << (16 - bit_count)) & offset_mask
270
+ length_bits = (best_len - 3) & length_mask
271
+ tokens.append(struct.pack("<H", offset_bits | length_bits))
272
+ pos += best_len
273
+ else:
274
+ tokens.append(bytes([chunk[pos]]))
275
+ pos += 1
276
+
277
+ out.append(flag_bits)
278
+ for tok in tokens:
279
+ out.extend(tok)
280
+
281
+ return bytes(out)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Section 2.3.4.2 — dir stream parsing
286
+ # ---------------------------------------------------------------------------
287
+
288
+ class VBAModuleKind(Enum):
289
+ standard = 0x0021 # MODULETYPE_PROCEDURAL
290
+ other = 0x0022 # class, document, or designer — use PROJECT stream to refine
291
+
292
+
293
+ @dataclass
294
+ class _ModuleInfo:
295
+ """Internal record accumulated while parsing the dir stream."""
296
+ name: str = ""
297
+ name_unicode: str = ""
298
+ stream_name: str = "" # MODULESTREAMNAME — the CFB stream to look up
299
+ stream_name_unicode: str = ""
300
+ text_offset: int = 0 # MODULEOFFSET — compressed source starts here
301
+ code_page: int = 1252
302
+ module_kind: VBAModuleKind = VBAModuleKind.standard
303
+ is_read_only: bool = False
304
+ is_private: bool = False
305
+ doc_string: str = "" # MODULEDOCSTRING (MBCS)
306
+ doc_string_unicode: str = "" # MODULEDOCSTRINGUNICODE
307
+ help_context: int = 0 # MODULEHELPCONTEXT
308
+ cookie: int = 0 # MODULECOOKIE (u16)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Reference records (Gate 10)
313
+ # ---------------------------------------------------------------------------
314
+
315
+ @dataclass
316
+ class VBAReference:
317
+ """A REFERENCED record from the dir stream's PROJECTREFERENCES section.
318
+
319
+ [MS-OVBA] 2.3.4.2.2. A reference is identified by one of several body
320
+ record IDs; the optional ``libid`` strings carry registry/path data.
321
+ """
322
+ name: str = ""
323
+ name_unicode: str = ""
324
+ kind: str = "" # "registered" | "project" | "control" | "original"
325
+ libid: str = "" # primary libid (registry path / file path)
326
+ libid_secondary: str = "" # twiddled / relative libid where applicable
327
+
328
+
329
+ def _parse_dir_stream(raw: bytes) -> tuple["_DirInfo", list[_ModuleInfo]]:
330
+ """
331
+ Parse a decompressed dir stream.
332
+
333
+ Returns (_DirInfo, list[_ModuleInfo]).
334
+
335
+ Decoding strategy: sequentially read records as (Id:u16, Size:u32, Data).
336
+ PROJECTVERSION (0x0009) is the only record that lacks a real Size field
337
+ (its Size slot is a Reserved=0x0004 marker and the payload is fixed at
338
+ 10 bytes including Reserved), so it is special-cased. MODULENAME
339
+ (0x0019) acts as a delimiter that starts a new _ModuleInfo entry once
340
+ the parser has crossed into PROJECTMODULES.
341
+ """
342
+ pos = 0
343
+
344
+ def _read_u16() -> int:
345
+ nonlocal pos
346
+ if pos + 2 > len(raw):
347
+ raise VBAProjectError("dir stream truncated while reading u16.")
348
+ v = int(struct.unpack_from("<H", raw, pos)[0])
349
+ pos += 2
350
+ return v
351
+
352
+ def _read_u32() -> int:
353
+ nonlocal pos
354
+ if pos + 4 > len(raw):
355
+ raise VBAProjectError("dir stream truncated while reading u32.")
356
+ v = int(struct.unpack_from("<I", raw, pos)[0])
357
+ pos += 4
358
+ return v
359
+
360
+ def _read(n: int) -> bytes:
361
+ nonlocal pos
362
+ chunk = raw[pos: pos + n]
363
+ pos += n
364
+ return chunk
365
+
366
+ info = _DirInfo()
367
+ modules: list[_ModuleInfo] = []
368
+ current: _ModuleInfo | None = None
369
+ in_modules = False
370
+ pending_ref: VBAReference | None = None
371
+ # After a REFERENCECONTROL TWIDDLED (0x002F) record, the spec
372
+ # ([MS-OVBA] 2.3.4.2.2.9 / 2.3.4.2.2.7) allows a NameRecordExtended
373
+ # (REFERENCENAME 0x0016 + optional 0x003E) inside the extended
374
+ # record portion. Those embedded name records must NOT be treated
375
+ # as the start of a new reference.
376
+ expecting_extended_name = False
377
+
378
+ def _commit_ref() -> None:
379
+ nonlocal pending_ref
380
+ if pending_ref is not None:
381
+ info.references.append(pending_ref)
382
+ pending_ref = None
383
+
384
+ def _enc() -> str:
385
+ try:
386
+ "".encode(f"cp{info.code_page}")
387
+ return f"cp{info.code_page}"
388
+ except LookupError:
389
+ return "latin-1"
390
+
391
+ while pos + 2 <= len(raw):
392
+ record_id = _read_u16()
393
+
394
+ # Module terminator (only meaningful after PROJECTMODULES).
395
+ if record_id == 0x0010: # dir terminator
396
+ if pos + 4 <= len(raw):
397
+ pos += 4 # consume trailing reserved u32
398
+ break
399
+
400
+ # PROJECTVERSION — special, no Size field.
401
+ if record_id == 0x0009:
402
+ if pos + 10 > len(raw):
403
+ break
404
+ _ = _read_u32() # Reserved=0x0004
405
+ info.version_major = _read_u32()
406
+ info.version_minor = _read_u16()
407
+ continue
408
+
409
+ # MODULENAME — starts a new module record.
410
+ if record_id == 0x0019:
411
+ in_modules = True
412
+ _commit_ref()
413
+ if current is not None:
414
+ modules.append(current)
415
+ current = _ModuleInfo(code_page=info.code_page)
416
+ size = _read_u32()
417
+ current.name = _read(size).decode(_enc(), errors="replace")
418
+ continue
419
+
420
+ if pos + 4 > len(raw):
421
+ break
422
+ size = _read_u32()
423
+ data = _read(size)
424
+
425
+ # ------------- PROJECTINFORMATION records -----------------
426
+ if record_id == 0x0001: # PROJECTSYSKIND
427
+ if len(data) >= 4:
428
+ info.sys_kind = int(struct.unpack_from("<I", data, 0)[0])
429
+ elif record_id == 0x004A: # PROJECTCOMPATVERSION
430
+ if len(data) >= 4:
431
+ info.compat_version = int(struct.unpack_from("<I", data, 0)[0])
432
+ elif record_id == 0x0002: # PROJECTLCID
433
+ if len(data) >= 4:
434
+ info.lcid = int(struct.unpack_from("<I", data, 0)[0])
435
+ elif record_id == 0x0014: # PROJECTLCIDINVOKE
436
+ if len(data) >= 4:
437
+ info.lcid_invoke = int(struct.unpack_from("<I", data, 0)[0])
438
+ elif record_id == 0x0003: # PROJECTCODEPAGE
439
+ if len(data) >= 2:
440
+ info.code_page = int(struct.unpack_from("<H", data, 0)[0])
441
+ elif record_id == 0x0004: # PROJECTNAME
442
+ info.name = data.decode(_enc(), errors="replace")
443
+ elif record_id == 0x0005: # PROJECTDOCSTRING (MBCS)
444
+ info.doc_string = data.decode(_enc(), errors="replace")
445
+ elif record_id == 0x0040: # reserved partner: PROJECTDOCSTRINGUNICODE
446
+ info.doc_string_unicode = data.decode("utf-16-le", errors="replace")
447
+ elif record_id == 0x0006: # PROJECTHELPFILEPATH (MBCS)
448
+ info.help_file = data.decode(_enc(), errors="replace")
449
+ elif record_id == 0x003D: # reserved partner: PROJECTHELPFILEPATH2
450
+ pass # duplicate of HelpFile, ignore
451
+ elif record_id == 0x0007: # PROJECTHELPCONTEXT
452
+ if len(data) >= 4:
453
+ info.help_context = int(struct.unpack_from("<I", data, 0)[0])
454
+ elif record_id == 0x0008: # PROJECTLIBFLAGS
455
+ if len(data) >= 4:
456
+ info.lib_flags = int(struct.unpack_from("<I", data, 0)[0])
457
+ elif record_id == 0x000C: # PROJECTCONSTANTS (MBCS)
458
+ info.constants = data.decode(_enc(), errors="replace")
459
+ elif record_id == 0x003C: # reserved partner: PROJECTCONSTANTSUNICODE
460
+ info.constants_unicode = data.decode("utf-16-le", errors="replace")
461
+ elif record_id == 0x000F: # PROJECTMODULES (count)
462
+ if len(data) >= 2:
463
+ info.module_count = int(struct.unpack_from("<H", data, 0)[0])
464
+ # First record of the PROJECTMODULES section — capture its absolute
465
+ # start so save() can splice in a freshly serialized modules block.
466
+ if info.modules_section_offset == -1:
467
+ info.modules_section_offset = pos - 6 - len(data)
468
+ elif record_id == 0x0013: # PROJECTCOOKIE
469
+ if len(data) >= 2:
470
+ info.project_cookie = int(struct.unpack_from("<H", data, 0)[0])
471
+
472
+ # ------------- PROJECTREFERENCES records ------------------
473
+ elif record_id == 0x0016: # REFERENCENAME (MBCS)
474
+ if expecting_extended_name and pending_ref is not None:
475
+ # Embedded name inside REFERENCECONTROL extended record.
476
+ # Keep current pending_ref intact; do not start a new ref.
477
+ pass
478
+ else:
479
+ _commit_ref()
480
+ pending_ref = VBAReference(name=data.decode(_enc(), errors="replace"))
481
+ elif record_id == 0x003E: # reserved partner: REFERENCENAME unicode
482
+ if pending_ref is not None and not expecting_extended_name:
483
+ pending_ref.name_unicode = data.decode("utf-16-le", errors="replace")
484
+ elif record_id == 0x000D: # REFERENCEREGISTERED
485
+ if pending_ref is None:
486
+ pending_ref = VBAReference()
487
+ pending_ref.kind = "registered"
488
+ if len(data) >= 4:
489
+ lib_size = int(struct.unpack_from("<I", data, 0)[0])
490
+ pending_ref.libid = bytes(
491
+ data[4: 4 + lib_size]
492
+ ).decode("latin-1", errors="replace")
493
+ _commit_ref()
494
+ elif record_id == 0x000E: # REFERENCEPROJECT
495
+ if pending_ref is None:
496
+ pending_ref = VBAReference()
497
+ pending_ref.kind = "project"
498
+ if len(data) >= 4:
499
+ abs_size = int(struct.unpack_from("<I", data, 0)[0])
500
+ pending_ref.libid = bytes(
501
+ data[4: 4 + abs_size]
502
+ ).decode("latin-1", errors="replace")
503
+ rel_off = 4 + abs_size
504
+ if len(data) >= rel_off + 4:
505
+ rel_size = int(struct.unpack_from("<I", data, rel_off)[0])
506
+ pending_ref.libid_secondary = bytes(
507
+ data[rel_off + 4: rel_off + 4 + rel_size]
508
+ ).decode("latin-1", errors="replace")
509
+ _commit_ref()
510
+ elif record_id == 0x002F: # REFERENCECONTROL (twiddled libid)
511
+ if pending_ref is None:
512
+ pending_ref = VBAReference()
513
+ pending_ref.kind = "control"
514
+ if len(data) >= 4:
515
+ tw_size = int(struct.unpack_from("<I", data, 0)[0])
516
+ pending_ref.libid_secondary = bytes(
517
+ data[4: 4 + tw_size]
518
+ ).decode("latin-1", errors="replace")
519
+ # The TWIDDLED record is followed by an Extended record
520
+ # ([MS-OVBA] 2.3.4.2.2.9) whose preamble is a NameRecordExtended
521
+ # (REFERENCENAME + optional REFERENCENAME unicode partner).
522
+ # Mark those as belonging to the current pending_ref.
523
+ expecting_extended_name = True
524
+ # do NOT commit yet: 0x0030 follow-up may contribute primary libid
525
+ elif record_id == 0x0030: # REFERENCECONTROL extended (primary libid)
526
+ if pending_ref is not None and len(data) >= 4:
527
+ ex_size = int(struct.unpack_from("<I", data, 0)[0])
528
+ pending_ref.libid = bytes(
529
+ data[4: 4 + ex_size]
530
+ ).decode("latin-1", errors="replace")
531
+ expecting_extended_name = False
532
+ _commit_ref()
533
+ elif record_id == 0x0033: # REFERENCEORIGINAL
534
+ if pending_ref is None:
535
+ pending_ref = VBAReference()
536
+ pending_ref.kind = "original"
537
+ pending_ref.libid = data.decode("latin-1", errors="replace")
538
+ # Do not commit: REFERENCEORIGINAL is always followed by REFERENCECONTROL.
539
+
540
+ # ------------- MODULE records -----------------------------
541
+ elif record_id == 0x0047 and current is not None: # MODULENAMEUNICODE
542
+ current.name_unicode = data.decode("utf-16-le", errors="replace")
543
+ elif record_id == 0x001A and current is not None: # MODULESTREAMNAME
544
+ current.stream_name = data.decode(_enc(), errors="replace")
545
+ elif record_id == 0x0032 and current is not None: # MODULESTREAMNAME unicode
546
+ current.stream_name_unicode = data.decode("utf-16-le", errors="replace")
547
+ elif record_id == 0x001C and current is not None: # MODULEDOCSTRING (MBCS)
548
+ current.doc_string = data.decode(_enc(), errors="replace")
549
+ elif record_id == 0x0048 and current is not None: # MODULEDOCSTRINGUNICODE
550
+ current.doc_string_unicode = data.decode("utf-16-le", errors="replace")
551
+ elif record_id == 0x0031 and current is not None: # MODULEOFFSET
552
+ if len(data) >= 4:
553
+ current.text_offset = int(struct.unpack_from("<I", data, 0)[0])
554
+ elif record_id == 0x001E and current is not None: # MODULEHELPCONTEXT
555
+ if len(data) >= 4:
556
+ current.help_context = int(struct.unpack_from("<I", data, 0)[0])
557
+ elif record_id == 0x002C and current is not None: # MODULECOOKIE
558
+ if len(data) >= 2:
559
+ current.cookie = int(struct.unpack_from("<H", data, 0)[0])
560
+ elif record_id == 0x0021 and current is not None: # MODULETYPE standard
561
+ current.module_kind = VBAModuleKind.standard
562
+ elif record_id == 0x0022 and current is not None: # MODULETYPE other
563
+ current.module_kind = VBAModuleKind.other
564
+ elif record_id == 0x0025 and current is not None: # MODULEREADONLY
565
+ current.is_read_only = True
566
+ elif record_id == 0x0028 and current is not None: # MODULEPRIVATE
567
+ current.is_private = True
568
+ elif record_id == 0x002B and current is not None: # Module terminator
569
+ modules.append(current)
570
+ current = None
571
+ else:
572
+ pass # unknown record — already consumed via _read(size)
573
+
574
+ _ = in_modules
575
+
576
+ _commit_ref()
577
+ if current is not None and current.name:
578
+ modules.append(current)
579
+
580
+ return info, modules
581
+
582
+
583
+ @dataclass
584
+ class _DirInfo:
585
+ """Project-level fields recovered from the dir stream."""
586
+ code_page: int = 1252
587
+ name: str = ""
588
+ sys_kind: int = 0
589
+ compat_version: int = 0
590
+ lcid: int = 0
591
+ lcid_invoke: int = 0
592
+ help_context: int = 0
593
+ lib_flags: int = 0
594
+ doc_string: str = ""
595
+ doc_string_unicode: str = ""
596
+ help_file: str = ""
597
+ constants: str = ""
598
+ constants_unicode: str = ""
599
+ version_major: int = 0
600
+ version_minor: int = 0
601
+ module_count: int = 0
602
+ project_cookie: int = 0
603
+ # Absolute byte offset of the first PROJECTMODULES (0x000F) record within
604
+ # the decompressed dir stream; -1 if the dir stream had no modules section.
605
+ # Used by the writer to splice in a freshly serialized modules block while
606
+ # leaving the project-information + references prefix verbatim.
607
+ modules_section_offset: int = -1
608
+ references: list["VBAReference"] = field(default_factory=lambda: [])
609
+
610
+
611
+ # ---------------------------------------------------------------------------
612
+ # Public data model
613
+ # ---------------------------------------------------------------------------
614
+
615
+ @dataclass
616
+ class VBAModule:
617
+ """A parsed VBA module with its source code."""
618
+ name: str
619
+ stream_name: str
620
+ source: str
621
+ kind: VBAModuleKind = VBAModuleKind.standard
622
+ text_offset: int = 0
623
+ is_read_only: bool = False
624
+ is_private: bool = False
625
+ # Dir-stream sub-records preserved for round-trip serialization.
626
+ name_unicode: str = ""
627
+ stream_name_unicode: str = ""
628
+ doc_string: str = ""
629
+ doc_string_unicode: str = ""
630
+ help_context: int = 0
631
+ cookie: int = 0
632
+ # Original bytes 0..text_offset of the module stream (performance cache
633
+ # / version-dependent prefix). Preserved across write-back so that
634
+ # Office's cache invalidation logic operates the same way as it would
635
+ # for an untouched stream.
636
+ prefix_bytes: bytes = field(default=b"", repr=False)
637
+ # Whether the source has been edited and needs to be recompressed on save.
638
+ dirty: bool = field(default=False, repr=False)
639
+
640
+
641
+ @dataclass
642
+ class VBAProject:
643
+ """Represents a parsed VBA project."""
644
+ modules: list[VBAModule] = field(default_factory=lambda: [])
645
+ code_page: int = 1252
646
+ name: str = ""
647
+ sys_kind: int = 0
648
+ references: list[VBAReference] = field(default_factory=lambda: [])
649
+ protection: "ProjectProtection | None" = None
650
+ # Dir-stream PROJECTMODULES coupling fields.
651
+ project_cookie: int = 0
652
+ # Raw decompressed dir bytes captured at parse time, kept so that
653
+ # save() can re-emit the project-information + references prefix verbatim
654
+ # while regenerating only the modules section. ``dir_modules_offset``
655
+ # marks the start of the first 0x000F record within ``dir_raw``.
656
+ # These fields are technically internal but are exposed without a leading
657
+ # underscore so that pyopenvba.excel can drive the save path without
658
+ # triggering pyright's reportPrivateUsage.
659
+ dir_raw: bytes = field(default=b"", repr=False)
660
+ dir_modules_offset: int = field(default=-1, repr=False)
661
+ # Pending logical renames (old_name -> new_name); flushed by save().
662
+ pending_renames: dict[str, str] = field(default_factory=lambda: {}, repr=False)
663
+ # Pending newly-added stream names; flushed by save() into the CFB.
664
+ pending_adds: set[str] = field(default_factory=lambda: set(), repr=False)
665
+ # Pending stream names to delete from the CFB; flushed by save().
666
+ pending_deletes: set[str] = field(default_factory=lambda: set(), repr=False)
667
+ # Set whenever the module list's identity changes (add/rename/delete).
668
+ dir_structure_dirty: bool = field(default=False, repr=False)
669
+
670
+ def get_module(self, name: str) -> VBAModule:
671
+ needle = name.casefold()
672
+ for m in self.modules:
673
+ if m.name.casefold() == needle:
674
+ return m
675
+ raise KeyError(f"Module not found: {name!r}")
676
+
677
+ def module_names(self) -> list[str]:
678
+ return [m.name for m in self.modules]
679
+
680
+ # ------------------------------------------------------------------
681
+ # Mutation API (Gate 13 / Gate 24)
682
+ #
683
+ # NOTE: these methods only update the in-memory project model. The
684
+ # write-back path (write_back_modules / save) emits only existing
685
+ # module streams' source text — full dir/PROJECT stream regeneration
686
+ # for added/renamed/deleted modules is not yet implemented; see
687
+ # docs/roadmap.md.
688
+ # ------------------------------------------------------------------
689
+
690
+ def add_module(
691
+ self,
692
+ name: str,
693
+ source: str,
694
+ *,
695
+ kind: VBAModuleKind = VBAModuleKind.standard,
696
+ stream_name: str | None = None,
697
+ ) -> VBAModule:
698
+ """Append a new module to the project (in-memory only).
699
+
700
+ Raises ``ValueError`` if a module with that name already exists.
701
+ """
702
+ needle = name.casefold()
703
+ if any(m.name.casefold() == needle for m in self.modules):
704
+ raise ValueError(f"Module already exists: {name!r}")
705
+ module = VBAModule(
706
+ name=name,
707
+ stream_name=stream_name if stream_name is not None else name,
708
+ source=source,
709
+ kind=kind,
710
+ text_offset=0,
711
+ name_unicode=name,
712
+ stream_name_unicode=(stream_name if stream_name is not None else name),
713
+ dirty=True,
714
+ )
715
+ self.modules.append(module)
716
+ self.dir_structure_dirty = True
717
+ self.pending_adds.add(module.stream_name)
718
+ # If the same stream name was queued for deletion (uncommon but
719
+ # possible: delete-then-readd), cancel the deletion instead.
720
+ self.pending_deletes.discard(module.stream_name)
721
+ return module
722
+
723
+ def rename_module(self, old_name: str, new_name: str) -> VBAModule:
724
+ """Rename a module by logical name.
725
+
726
+ Updates the in-memory project model and queues a CFB stream rename plus
727
+ a dir/PROJECT stream rewrite that ``ExcelFile.save()`` will apply.
728
+ """
729
+ module = self.get_module(old_name)
730
+ if module.name == new_name:
731
+ return module
732
+ needle = new_name.casefold()
733
+ if needle != module.name.casefold() and any(
734
+ m.name.casefold() == needle for m in self.modules
735
+ ):
736
+ raise ValueError(f"Module already exists: {new_name!r}")
737
+ old_stream = module.stream_name
738
+ old_logical = module.name
739
+ module.name = new_name
740
+ module.name_unicode = new_name
741
+ # Keep stream name aligned with module name (matches Office behavior).
742
+ module.stream_name = new_name
743
+ module.stream_name_unicode = new_name
744
+ module.dirty = True
745
+ self.dir_structure_dirty = True
746
+ # Queue CFB stream rename only if the stream name actually changed.
747
+ if old_stream != module.stream_name:
748
+ # If this stream was queued as a pending add (never saved yet),
749
+ # just retarget the add — no rename needs to hit the CFB.
750
+ if old_stream in self.pending_adds:
751
+ self.pending_adds.discard(old_stream)
752
+ self.pending_adds.add(module.stream_name)
753
+ else:
754
+ # Compose with any prior pending rename so the chain collapses.
755
+ existing_src = next(
756
+ (k for k, v in self.pending_renames.items() if v == old_stream),
757
+ None,
758
+ )
759
+ if existing_src is not None:
760
+ self.pending_renames[existing_src] = module.stream_name
761
+ else:
762
+ self.pending_renames[old_stream] = module.stream_name
763
+ # Also remember the logical-name change for PROJECT stream rewriting.
764
+ # (We use the same dict keyed by logical names when stream==logical,
765
+ # which is the common Excel case.)
766
+ _ = old_logical
767
+ return module
768
+
769
+ def delete_module(self, name: str) -> None:
770
+ """Remove a module from the project (in-memory only)."""
771
+ module = self.get_module(name)
772
+ self.modules.remove(module)
773
+ self.dir_structure_dirty = True
774
+ # If this was a never-saved module (still queued as an add), just
775
+ # cancel the add; nothing exists on disk yet.
776
+ if module.stream_name in self.pending_adds:
777
+ self.pending_adds.discard(module.stream_name)
778
+ else:
779
+ self.pending_deletes.add(module.stream_name)
780
+ # If a rename had been queued targeting this stream, drop it.
781
+ for old, new in list(self.pending_renames.items()):
782
+ if new == module.stream_name or old == module.stream_name:
783
+ self.pending_renames.pop(old, None)
784
+
785
+ # ------------------------------------------------------------------
786
+ # Validation (Gate 19)
787
+ # ------------------------------------------------------------------
788
+
789
+ def validate(self, cfb: "CFB | None" = None) -> list[str]:
790
+ """
791
+ Return a list of cross-structure inconsistency messages.
792
+
793
+ An empty list means the in-memory project is internally consistent.
794
+ Pass the originating CFB to additionally validate that every
795
+ ``MODULESTREAMNAME`` resolves to a real stream inside ``VBA/``.
796
+ """
797
+ problems: list[str] = []
798
+ seen_names: set[str] = set()
799
+ seen_streams: set[str] = set()
800
+ for m in self.modules:
801
+ key = m.name.casefold()
802
+ if key in seen_names:
803
+ problems.append(f"duplicate module name: {m.name!r}")
804
+ seen_names.add(key)
805
+ skey = m.stream_name.casefold()
806
+ if skey in seen_streams:
807
+ problems.append(f"duplicate module stream name: {m.stream_name!r}")
808
+ seen_streams.add(skey)
809
+ if not m.stream_name:
810
+ problems.append(f"module {m.name!r} has empty MODULESTREAMNAME")
811
+ if cfb is not None:
812
+ try:
813
+ vba_streams = {
814
+ n.casefold() for n in cfb.list_streams_in_storage("VBA")
815
+ }
816
+ except KeyError:
817
+ problems.append("CFB has no VBA storage")
818
+ return problems
819
+ for m in self.modules:
820
+ if m.stream_name.casefold() not in vba_streams:
821
+ problems.append(
822
+ f"module {m.name!r} references missing stream "
823
+ f"VBA/{m.stream_name!r}"
824
+ )
825
+ return problems
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # Write-back helpers
830
+ # ---------------------------------------------------------------------------
831
+
832
+ def _encoding_for_codepage(code_page: int) -> str:
833
+ try:
834
+ encoding = f"cp{code_page}"
835
+ "".encode(encoding)
836
+ except LookupError:
837
+ encoding = "latin-1"
838
+ return encoding
839
+
840
+
841
+ def rebuild_module_stream(module: VBAModule, code_page: int) -> bytes:
842
+ """
843
+ Rebuild a module stream by preserving the original ``[0:text_offset]``
844
+ performance-cache prefix and replacing ``[text_offset:]`` with a freshly
845
+ compressed copy of ``module.source``.
846
+
847
+ The replacement is byte-exact in the prefix region, so any cache-
848
+ invalidation logic that Office performs on the prefix remains valid.
849
+ """
850
+ encoding = _encoding_for_codepage(code_page)
851
+ source_bytes = module.source.encode(encoding, errors="replace")
852
+ compressed = compress(source_bytes)
853
+ return module.prefix_bytes + compressed
854
+
855
+
856
+ def write_back_modules(cfb: CFB, project: VBAProject) -> None:
857
+ """
858
+ Push every dirty module's source back into ``cfb`` via
859
+ :meth:`CFB.write_stream_in_storage`. Clean modules are left untouched.
860
+ """
861
+ for m in project.modules:
862
+ if not m.dirty:
863
+ continue
864
+ new_stream = rebuild_module_stream(m, project.code_page)
865
+ cfb.write_stream_in_storage("VBA", m.stream_name, new_stream)
866
+ m.dirty = False
867
+
868
+
869
+ # ---------------------------------------------------------------------------
870
+ # dir-stream writer (Gate 13 persistence)
871
+ # ---------------------------------------------------------------------------
872
+
873
+ def _pack_record(rid: int, data: bytes) -> bytes:
874
+ """[MS-OVBA] generic record encoding: Id(u16) + Size(u32) + Data."""
875
+ return struct.pack("<HI", rid, len(data)) + data
876
+
877
+
878
+ def serialize_dir_modules_section(project: VBAProject) -> bytes:
879
+ """
880
+ Serialize the PROJECTMODULES section of a dir stream from ``project``.
881
+
882
+ Output layout, per [MS-OVBA] 2.3.4.2.3:
883
+ 0x000F PROJECTMODULES Count:u16
884
+ 0x0013 PROJECTCOOKIE Cookie:u16
885
+ Module* per-module record block
886
+ 0x0010 Terminator (followed by Reserved:u32=0)
887
+
888
+ Each per-module block emits:
889
+ MODULENAME, MODULENAMEUNICODE, MODULESTREAMNAME, MODULESTREAMNAMEUNICODE,
890
+ MODULEDOCSTRING, MODULEDOCSTRINGUNICODE, MODULEOFFSET, MODULEHELPCONTEXT,
891
+ MODULECOOKIE, MODULETYPE, MODULEREADONLY?, MODULEPRIVATE?, Terminator.
892
+ """
893
+ enc = _encoding_for_codepage(project.code_page)
894
+ out = bytearray()
895
+ out += _pack_record(0x000F, struct.pack("<H", len(project.modules)))
896
+ out += _pack_record(0x0013, struct.pack("<H", project.project_cookie & 0xFFFF))
897
+
898
+ for m in project.modules:
899
+ name = m.name
900
+ name_u = m.name_unicode or m.name
901
+ stream = m.stream_name or m.name
902
+ stream_u = m.stream_name_unicode or stream
903
+ out += _pack_record(0x0019, name.encode(enc, errors="replace"))
904
+ out += _pack_record(0x0047, name_u.encode("utf-16-le"))
905
+ out += _pack_record(0x001A, stream.encode(enc, errors="replace"))
906
+ out += _pack_record(0x0032, stream_u.encode("utf-16-le"))
907
+ out += _pack_record(0x001C, m.doc_string.encode(enc, errors="replace"))
908
+ out += _pack_record(0x0048, m.doc_string_unicode.encode("utf-16-le"))
909
+ out += _pack_record(0x0031, struct.pack("<I", m.text_offset))
910
+ out += _pack_record(0x001E, struct.pack("<I", m.help_context))
911
+ out += _pack_record(0x002C, struct.pack("<H", m.cookie & 0xFFFF))
912
+ # MODULETYPE: 0x0021 standard / 0x0022 other (data is empty per spec).
913
+ out += _pack_record(
914
+ 0x0021 if m.kind == VBAModuleKind.standard else 0x0022, b""
915
+ )
916
+ if m.is_read_only:
917
+ out += _pack_record(0x0025, b"")
918
+ if m.is_private:
919
+ out += _pack_record(0x0028, b"")
920
+ # Module terminator.
921
+ out += _pack_record(0x002B, b"")
922
+
923
+ # dir-stream terminator: 0x0010 with size 0, followed by Reserved:u32 = 0.
924
+ out += _pack_record(0x0010, b"") + b"\x00\x00\x00\x00"
925
+ return bytes(out)
926
+
927
+
928
+ def serialize_dir_stream(project: VBAProject) -> bytes:
929
+ """
930
+ Build a fresh decompressed dir stream from ``project``.
931
+
932
+ Splice strategy: the project-information + references prefix is reused
933
+ verbatim from ``project._dir_raw`` (captured at parse time), and only the
934
+ PROJECTMODULES section is regenerated. This avoids round-trip drift in
935
+ records the parser does not fully decode (e.g. extended REFERENCECONTROL
936
+ cookies).
937
+ """
938
+ if project.dir_raw and project.dir_modules_offset >= 0:
939
+ prefix = project.dir_raw[: project.dir_modules_offset]
940
+ else:
941
+ # Synthesized projects have no captured prefix; the caller is
942
+ # responsible for supplying a valid PROJECTINFORMATION + REFERENCES
943
+ # prefix in some other way before save.
944
+ raise VBAProjectError(
945
+ "serialize_dir_stream() requires dir_raw + dir_modules_offset; "
946
+ "use a parsed VBAProject."
947
+ )
948
+ return prefix + serialize_dir_modules_section(project)
949
+
950
+
951
+ # ---------------------------------------------------------------------------
952
+ # PROJECT-stream writer (Gate 6 persistence)
953
+ # ---------------------------------------------------------------------------
954
+
955
+ def serialize_project_stream(
956
+ raw: bytes,
957
+ rename_map: dict[str, str],
958
+ *,
959
+ add_modules: "list[tuple[str, str]] | None" = None,
960
+ delete_names: "set[str] | None" = None,
961
+ ) -> bytes:
962
+ """
963
+ Rewrite a PROJECT stream's plain-text body to apply pending mutations.
964
+
965
+ ``rename_map``:
966
+ Old-logical-name to new-logical-name. Rewrites the right-hand side of
967
+ ``Module=`` / ``Class=`` / ``BaseClass=`` / ``Document=NAME/&H...``
968
+ and keys inside ``[Workspace]``.
969
+ ``add_modules``:
970
+ Sequence of ``(name, declaration_key)`` pairs where ``declaration_key``
971
+ is either ``"Module"`` (standard) or ``"Class"`` (class / other).
972
+ Each pair is appended as ``"<key>=<name>"`` after the existing
973
+ declarations and a matching ``[Workspace]`` entry is appended.
974
+ ``delete_names``:
975
+ Logical names to remove. Any ``Module=NAME`` / ``Class=NAME`` /
976
+ ``BaseClass=NAME`` / ``Document=NAME/...`` line with a matching name
977
+ is dropped, as is any ``[Workspace]`` key with that name.
978
+
979
+ Everything else (``ID``, ``Name``, ``CMG``, ``DPB``, ``GC``,
980
+ ``[Host Extender Info]``) is preserved byte-for-byte except for the
981
+ targeted substitutions.
982
+
983
+ Returns cp1252-encoded bytes with CRLF line endings.
984
+ """
985
+ add_modules = add_modules or []
986
+ delete_names = delete_names or set()
987
+ if not rename_map and not add_modules and not delete_names:
988
+ return raw
989
+ try:
990
+ text = raw.decode("cp1252", errors="replace")
991
+ except LookupError:
992
+ text = raw.decode("latin-1", errors="replace")
993
+
994
+ rename_ci: dict[str, str] = {k.casefold(): v for k, v in rename_map.items()}
995
+ delete_ci: set[str] = {n.casefold() for n in delete_names}
996
+
997
+ out_lines: list[str] = []
998
+ in_workspace = False
999
+ # Track the last index where a module-declaration line was emitted, so
1000
+ # we can splice newly-added Module=/Class= lines right after them.
1001
+ last_decl_idx = -1
1002
+ # Track where [Workspace] block sits so we can append new entries to it.
1003
+ workspace_idx = -1
1004
+ workspace_end_idx = -1
1005
+
1006
+ for line in text.splitlines():
1007
+ stripped = line.strip()
1008
+ if stripped.lower() == "[workspace]":
1009
+ in_workspace = True
1010
+ workspace_idx = len(out_lines)
1011
+ out_lines.append(line)
1012
+ continue
1013
+ if stripped.startswith("[") and stripped.endswith("]"):
1014
+ if in_workspace:
1015
+ workspace_end_idx = len(out_lines)
1016
+ in_workspace = False
1017
+ out_lines.append(line)
1018
+ continue
1019
+ if "=" not in stripped:
1020
+ out_lines.append(line)
1021
+ continue
1022
+ key, _, value = stripped.partition("=")
1023
+ key_s = key.strip()
1024
+ if in_workspace:
1025
+ if key_s.casefold() in delete_ci:
1026
+ continue
1027
+ new_key = rename_ci.get(key_s.casefold(), key_s)
1028
+ out_lines.append(f"{new_key}={value.strip()}")
1029
+ continue
1030
+ if key_s in ("Module", "Class", "BaseClass"):
1031
+ v = value.strip()
1032
+ if v.casefold() in delete_ci:
1033
+ continue
1034
+ new_val = rename_ci.get(v.casefold(), v)
1035
+ out_lines.append(f"{key_s}={new_val}")
1036
+ last_decl_idx = len(out_lines) - 1
1037
+ continue
1038
+ if key_s == "Document":
1039
+ name_part, sep, id_part = value.strip().partition("/")
1040
+ if name_part.casefold() in delete_ci:
1041
+ continue
1042
+ new_name = rename_ci.get(name_part.casefold(), name_part)
1043
+ out_lines.append(f"Document={new_name}{sep}{id_part}")
1044
+ last_decl_idx = len(out_lines) - 1
1045
+ continue
1046
+ out_lines.append(line)
1047
+
1048
+ # Splice in any newly-added module declarations after the last existing
1049
+ # one; if there were none, append at the project-section end (immediately
1050
+ # before the first '[' header, or at the very end).
1051
+ if add_modules:
1052
+ insert_at = last_decl_idx + 1 if last_decl_idx >= 0 else _project_section_end(out_lines)
1053
+ new_decl_lines = [f"{decl_key}={name}" for name, decl_key in add_modules]
1054
+ out_lines[insert_at:insert_at] = new_decl_lines
1055
+ # Shift workspace markers if they were after the insertion point.
1056
+ if workspace_idx >= insert_at:
1057
+ workspace_idx += len(new_decl_lines)
1058
+ if workspace_end_idx >= insert_at:
1059
+ workspace_end_idx += len(new_decl_lines)
1060
+
1061
+ # Append matching [Workspace] entries. Real Office writes
1062
+ # "<Name>=0, 0, 0, 0, C" by default; replicate that.
1063
+ if workspace_idx >= 0:
1064
+ ws_insert = workspace_end_idx if workspace_end_idx > workspace_idx else len(out_lines)
1065
+ ws_lines = [f"{name}=0, 0, 0, 0, C" for name, _ in add_modules]
1066
+ out_lines[ws_insert:ws_insert] = ws_lines
1067
+
1068
+ return ("\r\n".join(out_lines) + "\r\n").encode("cp1252", errors="replace")
1069
+
1070
+
1071
+ def _project_section_end(lines: list[str]) -> int:
1072
+ """Return the index of the first ``[...]`` header line, or ``len(lines)``."""
1073
+ for i, line in enumerate(lines):
1074
+ s = line.strip()
1075
+ if s.startswith("[") and s.endswith("]"):
1076
+ return i
1077
+ return len(lines)
1078
+
1079
+
1080
+ # ---------------------------------------------------------------------------
1081
+ # Public factory
1082
+ # ---------------------------------------------------------------------------
1083
+
1084
+ def parse_vba_project(cfb: CFB) -> VBAProject:
1085
+ """
1086
+ Extract and decompress all VBA module sources from a parsed CFB.
1087
+
1088
+ The CFB must be the vbaProject.bin from an xlsm/xlsb, or the whole
1089
+ file for an xls workbook.
1090
+ """
1091
+ try:
1092
+ dir_compressed = cfb.get_stream_in_storage("VBA", "dir")
1093
+ except KeyError:
1094
+ try:
1095
+ dir_compressed = cfb.get_stream("dir")
1096
+ except KeyError as exc:
1097
+ raise VBAProjectError(
1098
+ "No 'dir' stream found; not a valid VBA project."
1099
+ ) from exc
1100
+
1101
+ dir_raw = decompress(dir_compressed, stream_name="VBA/dir")
1102
+ dir_info, module_infos = _parse_dir_stream(dir_raw)
1103
+ code_page = dir_info.code_page
1104
+ encoding = _encoding_for_codepage(code_page)
1105
+
1106
+ modules: list[VBAModule] = []
1107
+ for info in module_infos:
1108
+ # CRITICAL: look up by MODULESTREAMNAME, not the logical module name.
1109
+ stream_name = info.stream_name or info.name
1110
+ try:
1111
+ stream_compressed = cfb.get_stream_in_storage("VBA", stream_name)
1112
+ except KeyError:
1113
+ try:
1114
+ stream_compressed = cfb.get_stream(stream_name)
1115
+ except KeyError:
1116
+ continue
1117
+
1118
+ if info.text_offset > len(stream_compressed):
1119
+ raise VBAProjectError(
1120
+ f"MODULEOFFSET {info.text_offset} exceeds stream length "
1121
+ f"{len(stream_compressed)} for module {info.name!r}."
1122
+ )
1123
+
1124
+ compressed_source = stream_compressed[info.text_offset:]
1125
+ source_bytes = decompress(
1126
+ compressed_source, stream_name=f"VBA/{stream_name}"
1127
+ )
1128
+ source = source_bytes.decode(encoding, errors="replace")
1129
+
1130
+ modules.append(VBAModule(
1131
+ name=info.name,
1132
+ stream_name=stream_name,
1133
+ source=source,
1134
+ kind=info.module_kind,
1135
+ text_offset=info.text_offset,
1136
+ is_read_only=info.is_read_only,
1137
+ is_private=info.is_private,
1138
+ name_unicode=info.name_unicode,
1139
+ stream_name_unicode=info.stream_name_unicode,
1140
+ doc_string=info.doc_string,
1141
+ doc_string_unicode=info.doc_string_unicode,
1142
+ help_context=info.help_context,
1143
+ cookie=info.cookie,
1144
+ prefix_bytes=stream_compressed[: info.text_offset],
1145
+ ))
1146
+
1147
+ project = VBAProject(modules=modules, code_page=code_page)
1148
+ project.name = dir_info.name
1149
+ project.sys_kind = dir_info.sys_kind
1150
+ project.references = dir_info.references
1151
+ project.project_cookie = dir_info.project_cookie
1152
+ project.dir_raw = dir_raw
1153
+ project.dir_modules_offset = dir_info.modules_section_offset
1154
+
1155
+ # Attach protection state from the PROJECT stream if present.
1156
+ try:
1157
+ project_stream_raw = cfb.get_stream("PROJECT")
1158
+ except KeyError:
1159
+ project_stream_raw = None
1160
+ if project_stream_raw is not None:
1161
+ try:
1162
+ ps = parse_project_stream(project_stream_raw)
1163
+ project.protection = ps.protection
1164
+ except VBAProjectError:
1165
+ pass
1166
+
1167
+ return project
1168
+
1169
+
1170
+ # ---------------------------------------------------------------------------
1171
+ # PROJECT stream parser ([MS-OVBA] 2.3.1)
1172
+ # ---------------------------------------------------------------------------
1173
+
1174
+ @dataclass
1175
+ class ProjectProtection:
1176
+ """State of the [MS-OVBA] 2.3.1 PROJECT-stream protection records.
1177
+
1178
+ The actual ``CMG``/``DPB``/``GC`` values are XOR-obfuscated, so we
1179
+ only expose presence and the raw obfuscated hex strings here. Code
1180
+ that needs to read the underlying password hash must do the XOR
1181
+ de-obfuscation itself.
1182
+ """
1183
+ has_password: bool = False
1184
+ cmg: str = "" # raw obfuscated DocLockProj/HostProj/VbeProj bits
1185
+ dpb: str = "" # raw obfuscated password hash
1186
+ gc: str = "" # raw obfuscated DocLockProj-visible bit
1187
+
1188
+
1189
+ @dataclass
1190
+ class ProjectStream:
1191
+ """Decoded view of the plain-text PROJECT stream."""
1192
+ items: list[tuple[str, str]] = field(default_factory=lambda: [])
1193
+ workspace: dict[str, str] = field(default_factory=lambda: {})
1194
+ standard_modules: list[str] = field(default_factory=lambda: [])
1195
+ class_modules: list[str] = field(default_factory=lambda: [])
1196
+ document_modules: list[tuple[str, str]] = field(default_factory=lambda: []) # (name, &HID)
1197
+ base_classes: list[str] = field(default_factory=lambda: [])
1198
+ name: str = ""
1199
+ id: str = ""
1200
+ protection: ProjectProtection = field(default_factory=lambda: ProjectProtection())
1201
+ host_extender_info: list[str] = field(default_factory=lambda: [])
1202
+
1203
+
1204
+ def parse_project_stream(raw: bytes) -> ProjectStream:
1205
+ """Parse the plain-text PROJECT stream.
1206
+
1207
+ The PROJECT stream is Windows-1252 encoded key=value text terminated
1208
+ by CRLF. Empty lines separate the project information block, the
1209
+ ``[Host Extender Info]`` block, and the ``[Workspace]`` block.
1210
+ """
1211
+ try:
1212
+ text = raw.decode("cp1252", errors="replace")
1213
+ except LookupError:
1214
+ text = raw.decode("latin-1", errors="replace")
1215
+
1216
+ out = ProjectStream()
1217
+ section = "project" # "project" | "host_extender" | "workspace"
1218
+
1219
+ for line in text.splitlines():
1220
+ stripped = line.strip()
1221
+ if not stripped:
1222
+ continue
1223
+ if stripped.lower() == "[host extender info]":
1224
+ section = "host_extender"
1225
+ continue
1226
+ if stripped.lower() == "[workspace]":
1227
+ section = "workspace"
1228
+ continue
1229
+ if section == "host_extender":
1230
+ out.host_extender_info.append(stripped)
1231
+ continue
1232
+
1233
+ if "=" not in stripped:
1234
+ continue
1235
+ key, _, value = stripped.partition("=")
1236
+ key = key.strip()
1237
+ value = value.strip()
1238
+ # Strip surrounding quotes when present.
1239
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
1240
+ value = value[1:-1]
1241
+
1242
+ if section == "workspace":
1243
+ out.workspace[key] = value
1244
+ continue
1245
+
1246
+ out.items.append((key, value))
1247
+
1248
+ # Module classification keys.
1249
+ if key == "Module":
1250
+ out.standard_modules.append(value)
1251
+ elif key == "Class":
1252
+ out.class_modules.append(value)
1253
+ elif key == "BaseClass":
1254
+ out.base_classes.append(value)
1255
+ elif key == "Document":
1256
+ # Value form is "Name/&H00000000..."
1257
+ name_part, _, id_part = value.partition("/")
1258
+ out.document_modules.append((name_part, id_part))
1259
+ elif key == "Name":
1260
+ out.name = value
1261
+ elif key == "ID":
1262
+ out.id = value
1263
+ elif key == "CMG":
1264
+ out.protection.cmg = value
1265
+ elif key == "DPB":
1266
+ out.protection.dpb = value
1267
+ # Per MS-OVBA 2.3.1.16, DPB is present even on unprotected projects
1268
+ # but the de-obfuscated payload's first byte indicates protection
1269
+ # state. Treat any DPB longer than a trivial placeholder as
1270
+ # password-bearing. A trivial empty/placeholder hash decodes to
1271
+ # ~30 hex chars; real passwords push DPB above ~60 chars.
1272
+ out.protection.has_password = len(value) >= 60
1273
+ elif key == "GC":
1274
+ out.protection.gc = value
1275
+
1276
+ return out
1277
+
1278
+
1279
+ # ---------------------------------------------------------------------------
1280
+ # PROJECTwm stream parser ([MS-OVBA] 2.3.4.4)
1281
+ # ---------------------------------------------------------------------------
1282
+
1283
+ def parse_projectwm(raw: bytes) -> list[tuple[str, str]]:
1284
+ """Parse the PROJECTwm stream into (mbcs_name, unicode_name) pairs.
1285
+
1286
+ Format:
1287
+ loop:
1288
+ ModuleName_MBCS : null-terminated bytes
1289
+ if empty: break (single 0x00 terminator)
1290
+ ModuleName_Unicode : null-terminated UTF-16-LE (terminator u16=0)
1291
+ """
1292
+ out: list[tuple[str, str]] = []
1293
+ pos = 0
1294
+ n = len(raw)
1295
+ while pos < n:
1296
+ # Find next null byte to delimit the MBCS name.
1297
+ nul = raw.find(b"\x00", pos)
1298
+ if nul == -1:
1299
+ break
1300
+ mbcs = raw[pos:nul]
1301
+ pos = nul + 1
1302
+ if not mbcs:
1303
+ # Terminator entry.
1304
+ break
1305
+ # Read UTF-16-LE name terminated by a zero u16.
1306
+ start = pos
1307
+ while pos + 1 < n:
1308
+ if raw[pos] == 0 and raw[pos + 1] == 0:
1309
+ break
1310
+ pos += 2
1311
+ unicode_name = raw[start:pos].decode("utf-16-le", errors="replace")
1312
+ pos += 2 # skip the u16=0 terminator
1313
+ try:
1314
+ mbcs_name = mbcs.decode("cp1252", errors="replace")
1315
+ except LookupError:
1316
+ mbcs_name = mbcs.decode("latin-1", errors="replace")
1317
+ out.append((mbcs_name, unicode_name))
1318
+ return out
1319
+
1320
+
1321
+ def serialize_projectwm(
1322
+ pairs: Iterable[tuple[str, str]], *, code_page: int = 1252
1323
+ ) -> bytes:
1324
+ """Serialize ``(mbcs_name, unicode_name)`` pairs to a PROJECTwm stream.
1325
+
1326
+ The stream format ([MS-OVBA] 2.3.4.4) is a sequence of records each
1327
+ consisting of a null-terminated MBCS module name followed by a
1328
+ null-terminated (u16=0) UTF-16-LE module name. The stream is
1329
+ terminated by a single empty MBCS record (one 0x00 byte).
1330
+
1331
+ ``code_page`` selects the MBCS code page used to encode the first
1332
+ name in each pair; unencodable characters are replaced.
1333
+ """
1334
+ enc = _encoding_for_codepage(code_page)
1335
+ buf = bytearray()
1336
+ for mbcs, unicode_name in pairs:
1337
+ if not mbcs:
1338
+ # An empty MBCS name would terminate the stream prematurely.
1339
+ continue
1340
+ buf += mbcs.encode(enc, errors="replace")
1341
+ buf += b"\x00"
1342
+ buf += unicode_name.encode("utf-16-le", errors="replace")
1343
+ buf += b"\x00\x00"
1344
+ # Final terminator per [MS-OVBA] 2.3.4.4: empty MBCS name (single 0x00)
1345
+ # plus a trailing alignment byte (Excel always emits two zero bytes).
1346
+ buf += b"\x00\x00"
1347
+ return bytes(buf)
1348
+
1349
+
1350
+ # ---------------------------------------------------------------------------
1351
+ # PROJECTlk stream parser ([MS-OVBA] 2.3.4.5)
1352
+ # ---------------------------------------------------------------------------
1353
+
1354
+ @dataclass
1355
+ class LicenseRecord:
1356
+ """A LicenseInfoRecord from the PROJECTlk stream."""
1357
+ lic_key: bytes = b""
1358
+ libid: str = ""
1359
+ classid: bytes = b"" # 16-byte GUID
1360
+ cookie: int = 0
1361
+
1362
+
1363
+ def parse_projectlk(raw: bytes) -> list[LicenseRecord]:
1364
+ """Parse the PROJECTlk stream into LicenseRecord entries.
1365
+
1366
+ Each LicenseInfoRecord:
1367
+ Id:u16 = 0x0001
1368
+ Size:u32
1369
+ SizeOfLicKey:u32
1370
+ LicKey:bytes[SizeOfLicKey]
1371
+ LibidSize:u32
1372
+ Libid:bytes[LibidSize]
1373
+ ClassID:bytes[16]
1374
+ Cookie:u32
1375
+ """
1376
+ out: list[LicenseRecord] = []
1377
+ pos = 0
1378
+ n = len(raw)
1379
+ while pos + 6 <= n:
1380
+ rec_id = int(struct.unpack_from("<H", raw, pos)[0])
1381
+ size = int(struct.unpack_from("<I", raw, pos + 2)[0])
1382
+ pos += 6
1383
+ if rec_id != 0x0001 or pos + size > n:
1384
+ break
1385
+ body = raw[pos: pos + size]
1386
+ pos += size
1387
+
1388
+ rec = LicenseRecord()
1389
+ bpos = 0
1390
+ if bpos + 4 <= len(body):
1391
+ lic_size = int(struct.unpack_from("<I", body, bpos)[0])
1392
+ bpos += 4
1393
+ if bpos + lic_size <= len(body):
1394
+ rec.lic_key = bytes(body[bpos: bpos + lic_size])
1395
+ bpos += lic_size
1396
+ if bpos + 4 <= len(body):
1397
+ lib_size = int(struct.unpack_from("<I", body, bpos)[0])
1398
+ bpos += 4
1399
+ if bpos + lib_size <= len(body):
1400
+ rec.libid = bytes(body[bpos: bpos + lib_size]).decode(
1401
+ "latin-1", errors="replace"
1402
+ )
1403
+ bpos += lib_size
1404
+ if bpos + 16 <= len(body):
1405
+ rec.classid = bytes(body[bpos: bpos + 16])
1406
+ bpos += 16
1407
+ if bpos + 4 <= len(body):
1408
+ rec.cookie = int(struct.unpack_from("<I", body, bpos)[0])
1409
+ out.append(rec)
1410
+ return out
1411
+
1412
+
1413
+ def serialize_projectlk(records: Iterable[LicenseRecord]) -> bytes:
1414
+ """Serialize ``LicenseRecord`` entries to a PROJECTlk stream.
1415
+
1416
+ Inverse of :func:`parse_projectlk`. Each record is emitted as a
1417
+ LicenseInfoRecord (Id=0x0001) per [MS-OVBA] 2.3.4.5. ClassID is
1418
+ zero-padded or truncated to 16 bytes.
1419
+ """
1420
+ out = bytearray()
1421
+ for rec in records:
1422
+ classid = rec.classid[:16].ljust(16, b"\x00")
1423
+ libid_bytes = rec.libid.encode("latin-1", errors="replace")
1424
+ body = bytearray()
1425
+ body += struct.pack("<I", len(rec.lic_key))
1426
+ body += rec.lic_key
1427
+ body += struct.pack("<I", len(libid_bytes))
1428
+ body += libid_bytes
1429
+ body += classid
1430
+ body += struct.pack("<I", rec.cookie & 0xFFFFFFFF)
1431
+ out += struct.pack("<HI", 0x0001, len(body))
1432
+ out += body
1433
+ return bytes(out)
1434
+
1435
+
1436
+ # ---------------------------------------------------------------------------
1437
+ # V3 content hash (Gate 15)
1438
+ # ---------------------------------------------------------------------------
1439
+
1440
+ def compute_v3_content_hash(project: VBAProject) -> bytes:
1441
+ """Compute a stable SHA-1 digest over the project's source content.
1442
+
1443
+ NOTE: this is a pyOpenVBA-internal canonical digest, not the exact
1444
+ Office VBE V3 content hash used for digital signatures (which depends
1445
+ on host-specific normalization rules and tokenization that are
1446
+ outside the MS-OVBA spec). It is stable across read-modify-write
1447
+ round trips that don't change source text.
1448
+ """
1449
+ import hashlib
1450
+ h = hashlib.sha1()
1451
+ h.update(struct.pack("<I", project.code_page))
1452
+ for m in sorted(project.modules, key=lambda x: x.name.casefold()):
1453
+ h.update(m.name.casefold().encode("utf-8"))
1454
+ h.update(b"\0")
1455
+ # Normalize line endings so CRLF/LF round trips are stable.
1456
+ normalized = m.source.replace("\r\n", "\n").replace("\r", "\n")
1457
+ h.update(normalized.encode("utf-8", errors="replace"))
1458
+ h.update(b"\0")
1459
+ return h.digest()
1460
+
1461
+
1462
+ # ---------------------------------------------------------------------------
1463
+ # Signature detection (Gate 17)
1464
+ # ---------------------------------------------------------------------------
1465
+
1466
+ @dataclass
1467
+ class SignatureInfo:
1468
+ """Summary of digital-signature streams found inside a VBA CFB."""
1469
+ present: bool = False
1470
+ kinds: list[str] = field(default_factory=lambda: []) # "legacy" | "agile" | "v3"
1471
+
1472
+
1473
+ _SIGNATURE_STREAM_NAMES = {
1474
+ "_VBA_PROJECT_SIGNATURE": "legacy",
1475
+ "_VBA_PROJECT_SIGNATURE_AGILE": "agile",
1476
+ "_VBA_PROJECT_SIGNATURE_V3": "v3",
1477
+ }
1478
+
1479
+
1480
+ def invalidate_vba_project_cache(cfb: CFB) -> bool:
1481
+ """Invalidate the ``_VBA_PROJECT`` performance cache.
1482
+
1483
+ Per [MS-OVBA] 2.3.4.1 the ``_VBA_PROJECT`` stream is laid out as::
1484
+
1485
+ Reserved1 (2 bytes) = 0x61CC
1486
+ Version (2 bytes) = host-defined cookie
1487
+ Reserved2 (1 byte) = 0x00
1488
+ PerformanceCache (variable bytes; "undefined and MUST be ignored on read")
1489
+
1490
+ The PerformanceCache may reference offsets / module identities that
1491
+ become stale after a mutating save (add / rename / delete / source
1492
+ edit). Office regenerates the cache on next open, but if the cache
1493
+ body still parses, some Office builds have been observed to honor
1494
+ its stale contents and either re-prompt for repair or surface
1495
+ incorrect p-code on first open.
1496
+
1497
+ This helper preserves the 5-byte header (so the host still
1498
+ recognises the stream) and zeroes the cache body in place. Returns
1499
+ ``True`` if the stream was found and invalidated, ``False`` if the
1500
+ stream is absent or too short to carry a header.
1501
+ """
1502
+ try:
1503
+ stream = cfb.get_stream_in_storage("VBA", "_VBA_PROJECT")
1504
+ except KeyError:
1505
+ return False
1506
+ if len(stream) < 5:
1507
+ return False
1508
+ if stream[0] != 0xCC or stream[1] != 0x61:
1509
+ # Not the documented header; do not touch.
1510
+ return False
1511
+ if stream[4] != 0x00:
1512
+ return False
1513
+ if len(stream) == 5:
1514
+ return False # Already minimal; nothing to invalidate.
1515
+ body = bytes(stream[:5]) + bytes(len(stream) - 5)
1516
+ cfb.write_stream_in_storage("VBA", "_VBA_PROJECT", body)
1517
+ return True
1518
+
1519
+
1520
+ def detect_signature(cfb: CFB) -> SignatureInfo:
1521
+ """Detect VBA digital-signature streams in the given CFB.
1522
+
1523
+ Returns a :class:`SignatureInfo` whose ``present`` is True if any of
1524
+ the three known signature streams are present inside the
1525
+ ``_VBA_PROJECT_CUR/VBA`` storage path (xlsm/xlsb) or the root
1526
+ (legacy xls embedding).
1527
+ """
1528
+ info = SignatureInfo()
1529
+ candidates: list[str] = []
1530
+ try:
1531
+ candidates.extend(cfb.list_streams_in_storage("VBA"))
1532
+ except KeyError:
1533
+ pass
1534
+ try:
1535
+ candidates.extend(cfb.list_streams())
1536
+ except KeyError:
1537
+ pass
1538
+ for name in candidates:
1539
+ kind = _SIGNATURE_STREAM_NAMES.get(name)
1540
+ if kind is not None and kind not in info.kinds:
1541
+ info.kinds.append(kind)
1542
+ info.present = True
1543
+ return info