superdoc-macros 0.7.0 → 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.
- package/README.md +97 -3
- package/dist/import/cfb.d.ts +44 -0
- package/dist/import/cfb.js +378 -0
- package/dist/import/errors.d.ts +21 -0
- package/dist/import/errors.js +20 -0
- package/dist/import/ms-ovba.d.ts +20 -0
- package/dist/import/ms-ovba.js +201 -0
- package/dist/import/vba.d.ts +132 -0
- package/dist/import/vba.js +785 -0
- package/dist/import/zip.d.ts +35 -0
- package/dist/import/zip.js +253 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +7 -1
- package/dist/manager.js +21 -6
- package/dist/messages.d.ts +6 -0
- package/dist/messages.js +2 -0
- package/dist/recorder/recorder.d.ts +7 -0
- package/dist/recorder/recorder.js +24 -9
- package/package.json +4 -2
|
@@ -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>;
|