pyOpenVBA 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyopenvba/__init__.py +80 -0
- pyopenvba/__main__.py +68 -0
- pyopenvba/cfb.py +931 -0
- pyopenvba/excel.py +487 -0
- pyopenvba/exceptions.py +17 -0
- pyopenvba/vba.py +1543 -0
- pyopenvba-1.0.0.dist-info/METADATA +285 -0
- pyopenvba-1.0.0.dist-info/RECORD +11 -0
- pyopenvba-1.0.0.dist-info/WHEEL +4 -0
- pyopenvba-1.0.0.dist-info/entry_points.txt +2 -0
- pyopenvba-1.0.0.dist-info/licenses/LICENSE.md +21 -0
pyopenvba/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
|