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,35 @@
1
+ export declare const ZIP_LIMITS: {
2
+ readonly maxEntries: 5000;
3
+ /** Uncompressed size of any single part this reader will inflate. */
4
+ readonly maxEntryBytes: number;
5
+ readonly maxNameLength: 1024;
6
+ };
7
+ export interface ZipEntry {
8
+ readonly name: string;
9
+ readonly compressedSize: number;
10
+ readonly uncompressedSize: number;
11
+ }
12
+ export interface ZipReadOptions {
13
+ /**
14
+ * Hard ceiling on the bytes this read may produce, defaulting to
15
+ * `ZIP_LIMITS.maxEntryBytes`. Enforced *during* decompression, so a part
16
+ * that inflates past it is abandoned rather than buffered and then
17
+ * rejected. Callers who know a part should be small (a `.rels` file) should
18
+ * say so — that is the difference between refusing a bomb and hosting it.
19
+ */
20
+ maxBytes?: number;
21
+ }
22
+ export interface ZipArchive {
23
+ readonly entries: readonly ZipEntry[];
24
+ has(name: string): boolean;
25
+ /**
26
+ * The inflated bytes of one entry.
27
+ *
28
+ * @throws {VbaParseError} when the entry is absent, encrypted, compressed
29
+ * with an unsupported method, larger than the cap, or its data runs past
30
+ * the end of the file.
31
+ */
32
+ read(name: string, options?: ZipReadOptions): Promise<Uint8Array>;
33
+ }
34
+ /** Parses the central directory. Reading an entry's data stays lazy. */
35
+ export declare function openZip(bytes: Uint8Array): ZipArchive;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * A read-only ZIP reader, enough to pull named parts out of an OOXML package.
3
+ *
4
+ * A `.docm` is a ZIP whose entries are the document's parts. To reach the
5
+ * macro project we need two or three specific parts by name, never the whole
6
+ * archive — so this reader parses the central directory up front (cheap) and
7
+ * inflates only what a caller asks for.
8
+ *
9
+ * Inflation uses the platform's own `DecompressionStream` — Baseline across
10
+ * browsers since 2023, and present in Node 18+. That keeps the toolkit
11
+ * dependency-free and puts the decompression itself in audited native code
12
+ * rather than in a hand-rolled inflater. Where it is genuinely absent, the
13
+ * reader says so rather than failing obscurely. Nothing else is assumed of the
14
+ * platform: no `Blob`, no `fetch`, no `Response` — only streams, so this also
15
+ * runs under jsdom, where a host's tests live.
16
+ *
17
+ * Safety posture, because these bytes come from a file someone was sent:
18
+ *
19
+ * - Entry count and name length are capped, and the per-part size cap is
20
+ * enforced against the bytes decompression actually produces — not against
21
+ * the size the archive claims, which a decompression bomb simply lies about.
22
+ * - Encrypted and ZIP64 entries are refused explicitly rather than guessed at.
23
+ * - Nothing here writes, and no entry name is ever used as a filesystem path,
24
+ * so the classic zip-slip traversal has no surface to attack.
25
+ */
26
+ import { VbaParseError } from './errors.js';
27
+ export const ZIP_LIMITS = {
28
+ maxEntries: 5_000,
29
+ /** Uncompressed size of any single part this reader will inflate. */
30
+ maxEntryBytes: 64 * 1024 * 1024,
31
+ maxNameLength: 1_024,
32
+ };
33
+ const EOCD_SIGNATURE = 0x06054b50;
34
+ const CENTRAL_SIGNATURE = 0x02014b50;
35
+ const LOCAL_SIGNATURE = 0x04034b50;
36
+ const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
37
+ const EOCD_MIN_BYTES = 22;
38
+ /** The trailing comment a ZIP may carry is at most 64 KiB. */
39
+ const MAX_COMMENT_BYTES = 0xffff;
40
+ const ZIP64_MARKER_32 = 0xffffffff;
41
+ const ZIP64_MARKER_16 = 0xffff;
42
+ const METHOD_STORED = 0;
43
+ const METHOD_DEFLATE = 8;
44
+ /** Parses the central directory. Reading an entry's data stays lazy. */
45
+ export function openZip(bytes) {
46
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
47
+ const u16 = (offset) => {
48
+ if (offset < 0 || offset + 2 > bytes.length) {
49
+ throw new VbaParseError('malformed', 'package: read past the end of the file');
50
+ }
51
+ return view.getUint16(offset, true);
52
+ };
53
+ const u32 = (offset) => {
54
+ if (offset < 0 || offset + 4 > bytes.length) {
55
+ throw new VbaParseError('malformed', 'package: read past the end of the file');
56
+ }
57
+ return view.getUint32(offset, true);
58
+ };
59
+ if (bytes.length < EOCD_MIN_BYTES) {
60
+ throw new VbaParseError('malformed', 'package: file is too short to be a ZIP archive');
61
+ }
62
+ // The end-of-central-directory record sits at the very end, behind an
63
+ // optional comment — so it has to be found by scanning backwards.
64
+ const scanFloor = Math.max(0, bytes.length - EOCD_MIN_BYTES - MAX_COMMENT_BYTES);
65
+ let eocd = -1;
66
+ for (let at = bytes.length - EOCD_MIN_BYTES; at >= scanFloor; at -= 1) {
67
+ if (view.getUint32(at, true) === EOCD_SIGNATURE) {
68
+ eocd = at;
69
+ break;
70
+ }
71
+ }
72
+ if (eocd < 0) {
73
+ throw new VbaParseError('malformed', 'package: not a ZIP archive (no end-of-directory record)');
74
+ }
75
+ const entryCount = u16(eocd + 10);
76
+ const directorySize = u32(eocd + 12);
77
+ const directoryOffset = u32(eocd + 16);
78
+ if (entryCount === ZIP64_MARKER_16 ||
79
+ directoryOffset === ZIP64_MARKER_32 ||
80
+ directorySize === ZIP64_MARKER_32 ||
81
+ (eocd >= 20 && view.getUint32(eocd - 20, true) === ZIP64_LOCATOR_SIGNATURE)) {
82
+ throw new VbaParseError('unsupported', 'package: ZIP64 archives are not supported');
83
+ }
84
+ if (entryCount > ZIP_LIMITS.maxEntries) {
85
+ throw new VbaParseError('too-large', 'package: too many entries');
86
+ }
87
+ if (directoryOffset + directorySize > bytes.length) {
88
+ throw new VbaParseError('malformed', 'package: central directory lies outside the file');
89
+ }
90
+ const decoder = new TextDecoder('utf-8');
91
+ const byName = new Map();
92
+ const entries = [];
93
+ const directoryEnd = directoryOffset + directorySize;
94
+ let at = directoryOffset;
95
+ for (let i = 0; i < entryCount; i += 1) {
96
+ // Confined to the region the archive declared for its directory, so a
97
+ // crafted file cannot have us read "entries" out of arbitrary bytes
98
+ // elsewhere in the package.
99
+ if (at + 46 > directoryEnd) {
100
+ throw new VbaParseError('malformed', 'package: central directory is shorter than it claims');
101
+ }
102
+ if (u32(at) !== CENTRAL_SIGNATURE) {
103
+ throw new VbaParseError('malformed', 'package: damaged central directory');
104
+ }
105
+ const flags = u16(at + 8);
106
+ const method = u16(at + 10);
107
+ const compressedSize = u32(at + 20);
108
+ const uncompressedSize = u32(at + 24);
109
+ const nameLength = u16(at + 28);
110
+ const extraLength = u16(at + 30);
111
+ const commentLength = u16(at + 32);
112
+ const localOffset = u32(at + 42);
113
+ if (nameLength > ZIP_LIMITS.maxNameLength) {
114
+ throw new VbaParseError('too-large', 'package: entry name is too long');
115
+ }
116
+ if (compressedSize === ZIP64_MARKER_32 || uncompressedSize === ZIP64_MARKER_32) {
117
+ throw new VbaParseError('unsupported', 'package: ZIP64 entry sizes are not supported');
118
+ }
119
+ const nameStart = at + 46;
120
+ if (nameStart + nameLength > directoryEnd) {
121
+ throw new VbaParseError('malformed', 'package: entry name lies outside the central directory');
122
+ }
123
+ // OOXML part names are ASCII, which UTF-8 and CP437 agree on — so the
124
+ // flag-11 distinction cannot change the names we look up.
125
+ const name = decoder.decode(bytes.subarray(nameStart, nameStart + nameLength));
126
+ const entry = {
127
+ name,
128
+ method,
129
+ compressedSize,
130
+ uncompressedSize,
131
+ localOffset,
132
+ encrypted: (flags & 0x0001) !== 0,
133
+ };
134
+ entries.push({ name, compressedSize, uncompressedSize });
135
+ // First entry wins, so a duplicated name cannot shadow the part that was
136
+ // already announced in `entries`.
137
+ if (!byName.has(name))
138
+ byName.set(name, entry);
139
+ at = nameStart + nameLength + extraLength + commentLength;
140
+ }
141
+ return {
142
+ entries,
143
+ has: (name) => byName.has(name),
144
+ async read(name, options) {
145
+ const entry = byName.get(name);
146
+ if (!entry) {
147
+ throw new VbaParseError('malformed', `package: no such part (${name})`);
148
+ }
149
+ if (entry.encrypted) {
150
+ throw new VbaParseError('unsupported', `package: ${name} is encrypted`);
151
+ }
152
+ const requested = options?.maxBytes;
153
+ const cap = Math.min(typeof requested === 'number' && Number.isFinite(requested) && requested > 0
154
+ ? Math.floor(requested)
155
+ : ZIP_LIMITS.maxEntryBytes, ZIP_LIMITS.maxEntryBytes);
156
+ // The declared size is only a claim, so it is a cheap early rejection
157
+ // rather than the real defence — the real one is enforced against the
158
+ // bytes actually produced, below.
159
+ if (entry.uncompressedSize > cap) {
160
+ throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
161
+ }
162
+ if (u32(entry.localOffset) !== LOCAL_SIGNATURE) {
163
+ throw new VbaParseError('malformed', `package: ${name} has a damaged local header`);
164
+ }
165
+ const localNameLength = u16(entry.localOffset + 26);
166
+ const localExtraLength = u16(entry.localOffset + 28);
167
+ const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength;
168
+ const dataEnd = dataStart + entry.compressedSize;
169
+ if (dataEnd > bytes.length) {
170
+ throw new VbaParseError('malformed', `package: ${name} runs past the end of the file`);
171
+ }
172
+ const data = bytes.subarray(dataStart, dataEnd);
173
+ if (entry.method === METHOD_STORED) {
174
+ // A stored entry's real length is its compressed length; the declared
175
+ // uncompressed size may disagree, so the cap is applied to what we
176
+ // would actually return.
177
+ if (data.length > cap) {
178
+ throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
179
+ }
180
+ return data.slice();
181
+ }
182
+ if (entry.method !== METHOD_DEFLATE) {
183
+ throw new VbaParseError('unsupported', `package: ${name} uses compression method ${entry.method}`);
184
+ }
185
+ return inflateRaw(data, name, cap);
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * Inflates one entry, enforcing `cap` as the bytes arrive.
191
+ *
192
+ * Reading the stream chunk by chunk rather than with `Response.arrayBuffer()`
193
+ * is the whole point. Buffering first and checking the length afterwards
194
+ * means a part that declares a kilobyte and expands to a gigabyte has already
195
+ * been fully materialized by the time the limit is consulted — the classic
196
+ * decompression bomb. Here the read is abandoned the moment the total would
197
+ * exceed the cap.
198
+ */
199
+ async function inflateRaw(data, name, cap) {
200
+ if (typeof DecompressionStream !== 'function' || typeof ReadableStream !== 'function') {
201
+ throw new VbaParseError('unsupported', 'package: this environment cannot decompress ZIP entries');
202
+ }
203
+ // The bytes are wrapped in a stream directly rather than via
204
+ // `new Blob([data]).stream()`. Both work in a browser, but `Blob.stream` is
205
+ // one of the pieces jsdom does not implement — and a host's own test suite
206
+ // running in jsdom is a place this code has to work, not fall over.
207
+ // `BufferSource` and not `Uint8Array`: that is what `DecompressionStream`
208
+ // declares its writable side accepts, and `pipeThrough` matches on it.
209
+ const source = new ReadableStream({
210
+ start(controller) {
211
+ // The cast covers a modelling gap, not a real one: since TypeScript 5.7
212
+ // a bare `Uint8Array` may be backed by a `SharedArrayBuffer`, which
213
+ // `BufferSource` excludes. These bytes are a view over the caller's
214
+ // package, and the stream accepts any array-buffer view at runtime.
215
+ // Copying to prove it to the compiler would duplicate the whole part.
216
+ controller.enqueue(data);
217
+ controller.close();
218
+ },
219
+ });
220
+ const stream = source.pipeThrough(new DecompressionStream('deflate-raw'));
221
+ const reader = stream.getReader();
222
+ const chunks = [];
223
+ let total = 0;
224
+ try {
225
+ for (;;) {
226
+ const { done, value } = await reader.read();
227
+ if (done)
228
+ break;
229
+ if (!value)
230
+ continue;
231
+ total += value.byteLength;
232
+ if (total > cap) {
233
+ throw new VbaParseError('too-large', `package: ${name} inflates past the part size cap`);
234
+ }
235
+ chunks.push(value);
236
+ }
237
+ }
238
+ catch (error) {
239
+ // Release the decompressor before rethrowing, so an abandoned bomb does
240
+ // not leave a stream pulling in the background.
241
+ await reader.cancel().catch(() => undefined);
242
+ if (error instanceof VbaParseError)
243
+ throw error;
244
+ throw new VbaParseError('malformed', `package: ${name} could not be decompressed`);
245
+ }
246
+ const out = new Uint8Array(total);
247
+ let at = 0;
248
+ for (const chunk of chunks) {
249
+ out.set(chunk, at);
250
+ at += chunk.byteLength;
251
+ }
252
+ return out;
253
+ }
package/dist/index.d.ts CHANGED
@@ -10,4 +10,14 @@ export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning
10
10
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
11
11
  export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
12
12
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
13
+ /**
14
+ * Reading the VBA macros already inside a `.docm`, for review only. Separate
15
+ * from `MacroKit` on purpose: extraction touches no saved macro, binds no
16
+ * shortcut, and executes nothing — see `src/import/vba.ts`.
17
+ */
18
+ export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, type VbaExtraction, type VbaFailureReason, type VbaModule, type VbaModuleKind, type VbaProcedureRef, type VbaProject, type VbaWarning, } from './import/vba.js';
19
+ export { VbaParseError, isVbaParseError, type VbaParseErrorCode } from './import/errors.js';
20
+ export { readCfb, CFB_LIMITS, type CfbContainer, type CfbEntry, type CfbEntryType } from './import/cfb.js';
21
+ export { openZip, ZIP_LIMITS, type ZipArchive, type ZipEntry } from './import/zip.js';
22
+ export { decompressOvba, OVBA_LIMITS, type DecompressOvbaOptions } from './import/ms-ovba.js';
13
23
  export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
package/dist/index.js CHANGED
@@ -8,4 +8,14 @@ export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
8
8
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
9
9
  export { AutoText } from './snippets/autotext.js';
10
10
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
11
+ /**
12
+ * Reading the VBA macros already inside a `.docm`, for review only. Separate
13
+ * from `MacroKit` on purpose: extraction touches no saved macro, binds no
14
+ * shortcut, and executes nothing — see `src/import/vba.ts`.
15
+ */
16
+ export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, } from './import/vba.js';
17
+ export { VbaParseError, isVbaParseError } from './import/errors.js';
18
+ export { readCfb, CFB_LIMITS } from './import/cfb.js';
19
+ export { openZip, ZIP_LIMITS } from './import/zip.js';
20
+ export { decompressOvba, OVBA_LIMITS } from './import/ms-ovba.js';
11
21
  export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.7.1",
4
- "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
3
+ "version": "0.8.0",
4
+ "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, snippets with auto-text expansion, and read-only VBA import from .docm files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -34,6 +34,8 @@
34
34
  "snippets",
35
35
  "autotext",
36
36
  "docx",
37
+ "docm",
38
+ "vba",
37
39
  "editor",
38
40
  "otzaria"
39
41
  ],