superdoc-macros 0.7.1 → 0.8.0

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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * [MS-OVBA] 2.4.1 — the run-length/LZ hybrid VBA sources are stored under.
3
+ *
4
+ * VBA module text inside `vbaProject.bin` is never plain text: each stream
5
+ * holds a *CompressedContainer* built from 4096-byte windows, where each
6
+ * window is either raw or a token stream of literals and back-references.
7
+ * Decompressing it is the whole difference between "we found a macro" and
8
+ * "we can show the user their macro".
9
+ *
10
+ * Two properties matter here beyond correctness:
11
+ *
12
+ * - **Bounded output.** A back-reference can repeat data, so a small input
13
+ * can describe a large output. Every call carries a hard output cap; the
14
+ * decoder fails closed rather than growing until the tab dies.
15
+ * - **No trust in offsets.** Every read is bounds-checked and raises
16
+ * `VbaParseError`, so a truncated or hostile file cannot produce a stray
17
+ * `RangeError` from deep inside a loop.
18
+ */
19
+ import { VbaParseError } from './errors.js';
20
+ export const OVBA_LIMITS = {
21
+ /**
22
+ * Cap on the decompressed size of a single stream. Real VBA modules are
23
+ * kilobytes; megabytes already means something is wrong.
24
+ */
25
+ maxDecompressedBytes: 20_000_000,
26
+ };
27
+ /** A decompressed window is fixed at 4096 bytes by the format. */
28
+ const CHUNK_DECOMPRESSED_SIZE = 4096;
29
+ const SIGNATURE_BYTE = 0x01;
30
+ /** Bits 12-14 of a chunk header are a fixed 0b011. */
31
+ const CHUNK_SIGNATURE = 0x3;
32
+ /**
33
+ * A growable output buffer that enforces the cap while it grows and supports
34
+ * the overlapping self-copy a back-reference needs (copying byte by byte, so
35
+ * a reference may legitimately read bytes this same copy just wrote).
36
+ */
37
+ class ByteSink {
38
+ max;
39
+ buffer;
40
+ length = 0;
41
+ constructor(max) {
42
+ this.max = max;
43
+ this.buffer = new Uint8Array(Math.min(4096, Math.max(max, 1)));
44
+ }
45
+ get size() {
46
+ return this.length;
47
+ }
48
+ push(byte) {
49
+ this.reserve(1);
50
+ this.buffer[this.length] = byte;
51
+ this.length += 1;
52
+ }
53
+ /**
54
+ * Copies `count` bytes starting `distance` bytes back from the end — the
55
+ * back-reference primitive. Byte-at-a-time on purpose: when
56
+ * `count > distance` the copy reads its own fresh output, which is how the
57
+ * format expresses a repeating run.
58
+ *
59
+ * `windowStart` is the floor the format imposes: a reference may not reach
60
+ * back past the start of its own 4096-byte window. Enforcing that turns a
61
+ * desynchronized decode into a clean error instead of plausible-looking
62
+ * garbage stitched together from an earlier window.
63
+ */
64
+ copyBack(distance, count, windowStart) {
65
+ if (distance <= 0 || distance > this.length - windowStart) {
66
+ throw new VbaParseError('malformed', 'VBA stream: back-reference points before the start of the window');
67
+ }
68
+ this.reserve(count);
69
+ let from = this.length - distance;
70
+ for (let written = 0; written < count; written += 1) {
71
+ // Bounded by construction: `from` starts inside the buffer and only
72
+ // ever trails `this.length`, which `reserve` already made room for.
73
+ this.buffer[this.length] = this.buffer[from];
74
+ this.length += 1;
75
+ from += 1;
76
+ }
77
+ }
78
+ /** A copy, not a view — the caller keeps it after the sink is discarded. */
79
+ toBytes() {
80
+ return this.buffer.slice(0, this.length);
81
+ }
82
+ reserve(extra) {
83
+ const needed = this.length + extra;
84
+ if (needed > this.max) {
85
+ throw new VbaParseError('too-large', `VBA stream: decompressed output exceeds the ${this.max}-byte cap`);
86
+ }
87
+ if (needed <= this.buffer.length)
88
+ return;
89
+ let capacity = this.buffer.length;
90
+ while (capacity < needed)
91
+ capacity *= 2;
92
+ const grown = new Uint8Array(Math.min(capacity, this.max));
93
+ grown.set(this.buffer.subarray(0, this.length));
94
+ this.buffer = grown;
95
+ }
96
+ }
97
+ /**
98
+ * Decompresses one CompressedContainer.
99
+ *
100
+ * @throws {VbaParseError} on a malformed container or one whose output would
101
+ * exceed the cap. Never throws anything else.
102
+ */
103
+ export function decompressOvba(source, options = {}) {
104
+ const start = options.offset ?? 0;
105
+ // Sanitized rather than trusted: a host computing this from a setting can
106
+ // hand us NaN, and `NaN > max` is false, so an unsanitized cap would
107
+ // disable itself and then hang the growth loop.
108
+ const requested = options.maxOutput;
109
+ const max = typeof requested === 'number' && Number.isFinite(requested) && requested > 0
110
+ ? Math.floor(requested)
111
+ : OVBA_LIMITS.maxDecompressedBytes;
112
+ if (!Number.isInteger(start) || start < 0 || start >= source.length) {
113
+ throw new VbaParseError('malformed', 'VBA stream: compressed data starts past the end of the stream');
114
+ }
115
+ const byteAt = (index) => {
116
+ const byte = source[index];
117
+ if (byte === undefined) {
118
+ throw new VbaParseError('malformed', 'VBA stream: truncated compressed data');
119
+ }
120
+ return byte;
121
+ };
122
+ if (byteAt(start) !== SIGNATURE_BYTE) {
123
+ throw new VbaParseError('malformed', 'VBA stream: missing the 0x01 compressed-container signature');
124
+ }
125
+ const sink = new ByteSink(max);
126
+ let cursor = start + 1;
127
+ while (cursor < source.length) {
128
+ // A trailing single byte cannot be a chunk header. Treat it as padding
129
+ // rather than a hard failure: real files pad, and the content we already
130
+ // decoded is intact and useful.
131
+ if (cursor + 1 >= source.length)
132
+ break;
133
+ const chunkStart = cursor;
134
+ const header = byteAt(cursor) | (byteAt(cursor + 1) << 8);
135
+ cursor += 2;
136
+ const signature = (header >> 12) & 0x7;
137
+ if (signature !== CHUNK_SIGNATURE) {
138
+ // Deliberately fatal, even though bytes have already been decoded.
139
+ // Returning the partial text would hand the caller a module whose tail
140
+ // is missing with nothing to say so — and this text exists to be read
141
+ // and ported by a human, who would not notice. A module reported as
142
+ // unreadable is a worse result but an honest one.
143
+ throw new VbaParseError('malformed', 'VBA stream: bad chunk signature');
144
+ }
145
+ // The 12-bit size field stores the whole chunk length (header included)
146
+ // minus 3.
147
+ const chunkLength = (header & 0x0fff) + 3;
148
+ const chunkEnd = Math.min(chunkStart + chunkLength, source.length);
149
+ const compressed = (header & 0x8000) !== 0;
150
+ if (!compressed) {
151
+ for (let i = 0; i < CHUNK_DECOMPRESSED_SIZE && cursor < chunkEnd; i += 1) {
152
+ sink.push(byteAt(cursor));
153
+ cursor += 1;
154
+ }
155
+ cursor = chunkEnd;
156
+ continue;
157
+ }
158
+ // Back-reference distances are relative to the start of *this* window,
159
+ // and the split between length and distance bits depends on how much of
160
+ // the window has been produced so far — so the window origin must be
161
+ // tracked, not assumed to be the start of the output.
162
+ const windowStart = sink.size;
163
+ while (cursor < chunkEnd) {
164
+ const flags = byteAt(cursor);
165
+ cursor += 1;
166
+ for (let bit = 0; bit < 8 && cursor < chunkEnd; bit += 1) {
167
+ const isReference = (flags & (1 << bit)) !== 0;
168
+ if (!isReference) {
169
+ sink.push(byteAt(cursor));
170
+ cursor += 1;
171
+ continue;
172
+ }
173
+ // Both bytes are read unconditionally: [MS-OVBA] 2.4.1.3.19 bounds
174
+ // the *token sequence*, not the token, so a token whose second byte
175
+ // sits on the chunk boundary is still a token. Only running off the
176
+ // end of the stream is an error, which `byteAt` reports.
177
+ const token = byteAt(cursor) | (byteAt(cursor + 1) << 8);
178
+ cursor += 2;
179
+ // [MS-OVBA] 2.4.1.3.19.1: the field split is driven by how far into
180
+ // the window we are — the fewer bytes produced, the fewer bits a
181
+ // distance needs, and the more are left for the length.
182
+ const produced = sink.size - windowStart;
183
+ if (produced > CHUNK_DECOMPRESSED_SIZE) {
184
+ // A window cannot exceed 4096 bytes, so the distance field cannot
185
+ // need more than 12 bits. Past that the split is undefined and any
186
+ // answer would be a guess — fail closed instead.
187
+ throw new VbaParseError('malformed', 'VBA stream: window grew past its 4096-byte limit');
188
+ }
189
+ let distanceBits = 4;
190
+ while (distanceBits < 12 && 1 << distanceBits < produced)
191
+ distanceBits += 1;
192
+ const lengthMask = 0xffff >> distanceBits;
193
+ const length = (token & lengthMask) + 3;
194
+ const distance = (token >>> (16 - distanceBits)) + 1;
195
+ sink.copyBack(distance, length, windowStart);
196
+ }
197
+ }
198
+ cursor = chunkEnd;
199
+ }
200
+ return sink.toBytes();
201
+ }
@@ -0,0 +1,132 @@
1
+ export declare const VBA_LIMITS: {
2
+ readonly maxModules: 500;
3
+ /** One module's source text, in characters. Longer sources are truncated. */
4
+ readonly maxSourceLength: 500000;
5
+ /** All modules together, in characters. */
6
+ readonly maxTotalSourceLength: 5000000;
7
+ };
8
+ export type VbaModuleKind =
9
+ /** A plain code module — where recorded Word macros land. */
10
+ 'standard'
11
+ /** A class module. */
12
+ | 'class'
13
+ /** A document-bound module such as `ThisDocument`. */
14
+ | 'document'
15
+ /** A UserForm's code-behind. */
16
+ | 'form' | 'unknown';
17
+ export interface VbaModule {
18
+ /** The module's name as the VBA editor shows it, e.g. `Module1`. */
19
+ readonly name: string;
20
+ readonly kind: VbaModuleKind;
21
+ /**
22
+ * The module's source text, decoded but otherwise untouched: original line
23
+ * endings, leading `Attribute` lines included.
24
+ *
25
+ * Data, not code. See this module's header — never hand it to a runner.
26
+ */
27
+ readonly source: string;
28
+ /** Whether `source` was cut short at `VBA_LIMITS.maxSourceLength`. */
29
+ readonly truncated: boolean;
30
+ }
31
+ export interface VbaWarning {
32
+ /** Stable identifier — the key a host localizes on. */
33
+ readonly code: 'no-modules' | 'incomplete-directory' | 'declared-modules-missing' | 'module-unreadable' | 'module-truncated' | 'module-limit' | 'total-size-limit' | 'unknown-code-page' | 'auto-run-macros';
34
+ /** English text. Hosts with a localized UI should switch on `code`. */
35
+ readonly message: string;
36
+ }
37
+ export interface VbaProcedureRef {
38
+ readonly module: string;
39
+ readonly procedure: string;
40
+ }
41
+ export interface VbaProject {
42
+ readonly modules: readonly VbaModule[];
43
+ /** The code page the sources were decoded with. */
44
+ readonly codePage: number;
45
+ /**
46
+ * Auto-run entry points found in the sources. Informational: a host should
47
+ * warn the user that the original document ran these on open. Nothing in
48
+ * the toolkit acts on them.
49
+ */
50
+ readonly autoRunProcedures: readonly VbaProcedureRef[];
51
+ /** Package path the project was read from, when it came from a package. */
52
+ readonly partName?: string;
53
+ readonly warnings: readonly VbaWarning[];
54
+ }
55
+ export type VbaFailureReason =
56
+ /** Not a readable OOXML/ZIP package. */
57
+ 'not-a-package'
58
+ /** A valid package that carries no macro project. */
59
+ | 'no-macros'
60
+ /** A macro part that is not a readable VBA project. */
61
+ | 'not-a-vba-project'
62
+ /** Structurally valid but past a safety cap. */
63
+ | 'too-large'
64
+ /** A format feature the readers decline to guess at (ZIP64, encryption). */
65
+ | 'unsupported'
66
+ /** Damaged or truncated beyond recovery. */
67
+ | 'unreadable';
68
+ export type VbaExtraction = {
69
+ readonly ok: true;
70
+ readonly project: VbaProject;
71
+ } | {
72
+ readonly ok: false;
73
+ readonly reason: VbaFailureReason;
74
+ readonly message: string;
75
+ /**
76
+ * The macro part's path, when one was found but could not be read.
77
+ *
78
+ * This is the difference between "this document has no macros" and "this
79
+ * document has macros we could not decode", and a host needs it: a
80
+ * package carrying a macro part must still be saved as `.docm`, whether
81
+ * or not anything could be shown to the user.
82
+ */
83
+ readonly partName?: string;
84
+ };
85
+ /**
86
+ * The auto-run entry points declared in a VBA source text, in the order they
87
+ * appear, spelled as the source spells them.
88
+ *
89
+ * Exported because the warning matters more than where the text came from: a
90
+ * host showing VBA a user pasted in, or read from a `.bas` file, wants the
91
+ * same "Word would have run this on open" notice this module attaches to an
92
+ * extracted project.
93
+ *
94
+ * Names are matched case-insensitively, since VBA identifiers are.
95
+ */
96
+ export declare function scanForAutoRunProcedures(source: string): string[];
97
+ /**
98
+ * Reads a VBA project from the bytes of a `vbaProject.bin` part.
99
+ *
100
+ * Never throws. A partially readable project comes back as `ok: true` with
101
+ * the modules that could be read and a warning for each that could not.
102
+ *
103
+ * @param bin The macro part's bytes.
104
+ * @param partName Where the part came from, recorded on the result. Passed by
105
+ * `extractVbaFromDocx`; a caller reading a loose `.bin` has nothing to give.
106
+ */
107
+ export declare function extractVbaProject(bin: Uint8Array, partName?: string): VbaExtraction;
108
+ /**
109
+ * Whether a Word package carries a macro project, and where.
110
+ *
111
+ * Returns the part's path inside the package, or `null`. Only the
112
+ * relationship and content-type parts are read — nothing is decompressed from
113
+ * the macro project itself — so this is the cheap question to ask when the
114
+ * answer decides something other than what to display.
115
+ *
116
+ * The reason it exists separately: a document carrying a macro project must be
117
+ * saved as `.docm`, and that decision has to hold even for a project too
118
+ * damaged to read. Asking "did extraction succeed" would answer a different
119
+ * question and quietly strip a user's macros on save.
120
+ *
121
+ * Never throws.
122
+ */
123
+ export declare function findVbaPart(docx: Uint8Array): Promise<string | null>;
124
+ /**
125
+ * Reads the VBA project out of a Word package (`.docm`, `.dotm`, or a `.docx`
126
+ * that happens to carry one).
127
+ *
128
+ * Never throws; `reason: 'no-macros'` is the ordinary answer for a document
129
+ * without macros. Any other failure still reports `partName` when a macro part
130
+ * was located, so a caller can tell "no macros" from "macros we cannot read".
131
+ */
132
+ export declare function extractVbaFromDocx(docx: Uint8Array): Promise<VbaExtraction>;