@markii/bundle 0.1.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/dist/zip.js ADDED
@@ -0,0 +1,329 @@
1
+ import { inflateSync, strFromU8, zipSync } from 'fflate';
2
+ import { BundleZipError } from './errors.js';
3
+ import { normalizeBundlePath } from './paths.js';
4
+ import { normalizeOrThrow } from './storage.js';
5
+ /**
6
+ * Wraps an in-memory `Map<normalized path, bytes>` as a `BundleStorage`.
7
+ * Shared by `openZipBundle` and (via `dirToZip`/`zipToDir` in `./fs`) the
8
+ * directory <-> zip conversions, so both round trips exercise the exact
9
+ * same read/write/list/exists semantics as a "real" zip bundle.
10
+ *
11
+ * A `Map` (not a plain object) is used deliberately: `Map` keys are opaque
12
+ * key/value pairs with no magic property names, so a bundle path literally
13
+ * equal to `__proto__` is just an ordinary key here — see the "prototype
14
+ * pollution" note on `openZipBundle` below for why that distinction matters.
15
+ */
16
+ function createMapStorage(map) {
17
+ return {
18
+ read(path) {
19
+ const normalized = normalizeOrThrow(path);
20
+ return Promise.resolve(map.get(normalized));
21
+ },
22
+ write(path, data) {
23
+ const normalized = normalizeOrThrow(path);
24
+ map.set(normalized, data);
25
+ return Promise.resolve();
26
+ },
27
+ list() {
28
+ return Promise.resolve(Array.from(map.keys()).sort());
29
+ },
30
+ exists(path) {
31
+ const normalized = normalizeOrThrow(path);
32
+ return Promise.resolve(map.has(normalized));
33
+ },
34
+ };
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Minimal, hand-rolled ZIP central-directory reader.
38
+ //
39
+ // Why not just call fflate's `unzipSync`? Three reasons, all defects fixed
40
+ // in this module:
41
+ //
42
+ // 1. No decompression size guard (DEFECT 4): `unzipSync` allocates the
43
+ // *declared* uncompressed size for every entry before/while inflating,
44
+ // with no cap — a small, highly-compressible archive can claim an
45
+ // enormous uncompressed size and OOM the process just by being opened.
46
+ // Reading a bundle must always be safe (spec §10).
47
+ // 2. No CRC-32 verification (DEFECT 6): `unzipSync` never exposes or checks
48
+ // the CRC-32 each entry's header carries, so silent bit-flip corruption
49
+ // produces silently-wrong data instead of a loud error.
50
+ // 3. Prototype pollution (DEFECT 7): fflate's own `unzipSync` accumulates
51
+ // results into a plain `{}` via `results[entryName] = data`. An entry
52
+ // literally named `__proto__` doesn't become an own property on that
53
+ // object — it reassigns the object's prototype instead (verified
54
+ // empirically against fflate 0.8.3: it actually throws a TypeError
55
+ // from deep inside `zipSync`/`unzipSync`'s internals for a *top-level*
56
+ // `__proto__` entry). We can't fix fflate's internals, so we don't
57
+ // route through them for the read side at all: this reader walks the
58
+ // ZIP central directory ourselves and accumulates into a `Map`, which
59
+ // has no magic key names, so `__proto__`/`constructor`/`prototype`
60
+ // entries are handled exactly like any other name — no special-casing
61
+ // needed on open.
62
+ //
63
+ // This intentionally does NOT support ZIP64 (archives >4GB or >65535
64
+ // entries) — out of scope for a personal note-bundle format, and a
65
+ // half-correct ZIP64 implementation is worse than a loud rejection. Central
66
+ // directory entries are the source of truth for size/CRC (not local file
67
+ // header data descriptors), matching the ZIP spec's guidance that the
68
+ // central directory is authoritative.
69
+ // ---------------------------------------------------------------------------
70
+ const EOCD_SIGNATURE = 0x06054b50;
71
+ const CENTRAL_DIR_SIGNATURE = 0x02014b50;
72
+ const LOCAL_HEADER_SIGNATURE = 0x04034b50;
73
+ const ZIP64_SENTINEL = 0xffffffff;
74
+ /** Reads a little-endian uint16 at byte offset `b`. */
75
+ function readU16(d, b) {
76
+ return (d[b] ?? 0) | ((d[b + 1] ?? 0) << 8);
77
+ }
78
+ /** Reads a little-endian uint32 at byte offset `b`. */
79
+ function readU32(d, b) {
80
+ return (((d[b] ?? 0) |
81
+ ((d[b + 1] ?? 0) << 8) |
82
+ ((d[b + 2] ?? 0) << 16) |
83
+ ((d[b + 3] ?? 0) << 24)) >>>
84
+ 0);
85
+ }
86
+ /** Locates the end-of-central-directory record; mirrors fflate's own scan. */
87
+ function findEocd(data) {
88
+ for (let e = data.length - 22; e >= 0; e--) {
89
+ if (readU32(data, e) === EOCD_SIGNATURE)
90
+ return e;
91
+ if (data.length - e > 65558)
92
+ break;
93
+ }
94
+ throw new BundleZipError('zip bundle rejected: not a valid zip archive (no end-of-central-directory record found)', []);
95
+ }
96
+ /** Walks the central directory, returning one `RawZipEntry` per record. */
97
+ function readCentralDirectory(data) {
98
+ const eocd = findEocd(data);
99
+ const count = readU16(data, eocd + 8);
100
+ const cdOffset = readU32(data, eocd + 16);
101
+ if (count === 0xffff || cdOffset === ZIP64_SENTINEL) {
102
+ throw new BundleZipError('zip bundle rejected: ZIP64 archives are not supported', []);
103
+ }
104
+ const entries = [];
105
+ let o = cdOffset;
106
+ for (let i = 0; i < count; i++) {
107
+ if (o + 46 > data.length || readU32(data, o) !== CENTRAL_DIR_SIGNATURE) {
108
+ throw new BundleZipError('zip bundle rejected: malformed central directory record', []);
109
+ }
110
+ const generalFlag = readU16(data, o + 8);
111
+ const compression = readU16(data, o + 10);
112
+ const crc32Field = readU32(data, o + 16);
113
+ const compressedSize = readU32(data, o + 20);
114
+ const uncompressedSize = readU32(data, o + 24);
115
+ const nameLen = readU16(data, o + 28);
116
+ const extraLen = readU16(data, o + 30);
117
+ const commentLen = readU16(data, o + 32);
118
+ const localHeaderOffset = readU32(data, o + 42);
119
+ if (compressedSize === ZIP64_SENTINEL ||
120
+ uncompressedSize === ZIP64_SENTINEL ||
121
+ localHeaderOffset === ZIP64_SENTINEL) {
122
+ throw new BundleZipError('zip bundle rejected: ZIP64 archives are not supported', []);
123
+ }
124
+ const nameStart = o + 46;
125
+ if (nameStart + nameLen > data.length) {
126
+ throw new BundleZipError('zip bundle rejected: malformed central directory record (truncated file name)', []);
127
+ }
128
+ // Bit 11 (0x0800) of the general-purpose flag marks a UTF-8 name;
129
+ // otherwise fall back to the legacy (effectively latin1/CP437-ish)
130
+ // interpretation — matches fflate's own `zh()` decoding rule.
131
+ const isUtf8 = (generalFlag & 0x0800) !== 0;
132
+ const name = strFromU8(data.subarray(nameStart, nameStart + nameLen), !isUtf8);
133
+ entries.push({
134
+ name,
135
+ compression,
136
+ compressedSize,
137
+ uncompressedSize,
138
+ crc32: crc32Field,
139
+ localHeaderOffset,
140
+ });
141
+ o = nameStart + nameLen + extraLen + commentLen;
142
+ }
143
+ return entries;
144
+ }
145
+ /** Given a central-directory entry's local header offset, returns the byte offset where its (compressed) data begins. */
146
+ function localDataOffset(data, localHeaderOffset) {
147
+ if (localHeaderOffset + 30 > data.length ||
148
+ readU32(data, localHeaderOffset) !== LOCAL_HEADER_SIGNATURE) {
149
+ throw new BundleZipError('zip bundle rejected: malformed local file header', []);
150
+ }
151
+ const nameLen = readU16(data, localHeaderOffset + 26);
152
+ const extraLen = readU16(data, localHeaderOffset + 28);
153
+ return localHeaderOffset + 30 + nameLen + extraLen;
154
+ }
155
+ // ---- CRC-32 (ISO 3309 / ITU-T V.42, the zip/gzip/PNG polynomial) ---------
156
+ // fflate does not export a CRC-32 utility (its own is an internal, private
157
+ // closure used only when *writing* zips), so this is a small, standard,
158
+ // self-contained implementation — not a new dependency, just ~15 lines of
159
+ // well-known table-driven CRC-32, computed over bytes we already have.
160
+ let crc32Table;
161
+ function getCrc32Table() {
162
+ if (crc32Table)
163
+ return crc32Table;
164
+ const table = new Uint32Array(256);
165
+ for (let n = 0; n < 256; n++) {
166
+ let c = n;
167
+ for (let k = 0; k < 8; k++) {
168
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
169
+ }
170
+ table[n] = c >>> 0;
171
+ }
172
+ crc32Table = table;
173
+ return table;
174
+ }
175
+ function crc32(data) {
176
+ const table = getCrc32Table();
177
+ let crc = 0xffffffff;
178
+ for (let i = 0; i < data.length; i++) {
179
+ crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
180
+ }
181
+ return (crc ^ 0xffffffff) >>> 0;
182
+ }
183
+ /** Default per-entry decompressed-size cap: 256MB. */
184
+ export const DEFAULT_MAX_ZIP_ENTRY_BYTES = 256 * 1024 * 1024;
185
+ /** Default total (summed across all entries) decompressed-size cap: 256MB. */
186
+ export const DEFAULT_MAX_ZIP_TOTAL_BYTES = 256 * 1024 * 1024;
187
+ /**
188
+ * Opens the zip form of a bundle (browser-safe: `fflate` has no Node
189
+ * dependency; the ZIP container parsing here is our own and is also
190
+ * dependency-free). Directory entries (names ending in `/`) carry no data
191
+ * and are skipped — but only *after* the name has passed
192
+ * `normalizeBundlePath` (DEFECT 8: validating before the directory-entry
193
+ * skip means a malformed directory entry like `../evil/` is rejected loudly,
194
+ * the same as its non-directory counterpart `../evil`, instead of being
195
+ * silently dropped).
196
+ *
197
+ * Zip-slip protection: every file entry's name is run through
198
+ * `normalizeBundlePath`. Any entry that fails (`../`, an absolute path, a
199
+ * backslash path, a drive-letter path) is collected and, if any exist,
200
+ * the whole open is rejected with a `BundleZipError` listing every
201
+ * offending name — a tampered bundle must be loud, not silently pruned
202
+ * down to "the entries that happened to be safe."
203
+ *
204
+ * Collision protection (DEFECT 5): two distinct raw entry names that
205
+ * *normalize* to the same bundle path (e.g. `manifest.json` and
206
+ * `./manifest.json`, or `cache/x` and `cache//x`) are rejected outright
207
+ * rather than silently last-wins — a bundle could otherwise show a benign
208
+ * file to one reader and a hostile one to another depending on which
209
+ * implementation's normalization/iteration order "wins".
210
+ *
211
+ * Decompression-bomb protection (DEFECT 4): every entry's declared
212
+ * uncompressed size (read from the central directory, before any inflation
213
+ * happens) is checked against `options.maxEntryBytes`, and the running
214
+ * total across all entries against `options.maxTotalBytes`. A crafted
215
+ * high-ratio archive is rejected before its claimed size is ever allocated.
216
+ *
217
+ * CRC-32 verification (DEFECT 6): every entry's decompressed bytes are
218
+ * checked against the CRC-32 recorded in the central directory; a mismatch
219
+ * (corrupt archive, flipped bit) throws `BundleZipError` instead of
220
+ * silently returning wrong data.
221
+ */
222
+ export function openZipBundle(bytes, options = {}) {
223
+ const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ZIP_ENTRY_BYTES;
224
+ const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_ZIP_TOTAL_BYTES;
225
+ const rawEntries = readCentralDirectory(bytes);
226
+ const offending = [];
227
+ const collisionEntries = []; // raw names involved in a collision
228
+ const collisionMessages = [];
229
+ const seenBy = new Map(); // normalized path -> first raw name that claimed it
230
+ const validFileEntries = [];
231
+ for (const entry of rawEntries) {
232
+ const normalized = normalizeBundlePath(entry.name);
233
+ if (!normalized.ok) {
234
+ offending.push(entry.name);
235
+ continue;
236
+ }
237
+ if (entry.name.endsWith('/'))
238
+ continue; // well-formed directory entry: no data, skip
239
+ const prior = seenBy.get(normalized.path);
240
+ if (prior !== undefined) {
241
+ collisionEntries.push(prior, entry.name);
242
+ collisionMessages.push(`${JSON.stringify(prior)} and ${JSON.stringify(entry.name)} both normalize to ${JSON.stringify(normalized.path)}`);
243
+ continue;
244
+ }
245
+ seenBy.set(normalized.path, entry.name);
246
+ validFileEntries.push({ raw: entry, normalizedPath: normalized.path });
247
+ }
248
+ if (offending.length > 0) {
249
+ throw new BundleZipError(`zip bundle rejected: ${offending.length} ${offending.length === 1 ? 'entry has' : 'entries have'} an unsafe path: ${offending.join(', ')}`, offending);
250
+ }
251
+ if (collisionEntries.length > 0) {
252
+ throw new BundleZipError(`zip bundle rejected: colliding entry names normalize to the same bundle path: ${collisionMessages.join('; ')}`, collisionEntries);
253
+ }
254
+ const map = new Map();
255
+ let totalUncompressed = 0;
256
+ for (const { raw: entry, normalizedPath } of validFileEntries) {
257
+ if (entry.uncompressedSize > maxEntryBytes) {
258
+ throw new BundleZipError(`zip bundle rejected: entry ${JSON.stringify(entry.name)} declares ${entry.uncompressedSize} uncompressed bytes, exceeding the ${maxEntryBytes}-byte per-entry limit`, [entry.name]);
259
+ }
260
+ totalUncompressed += entry.uncompressedSize;
261
+ if (totalUncompressed > maxTotalBytes) {
262
+ throw new BundleZipError(`zip bundle rejected: total declared uncompressed size exceeds the ${maxTotalBytes}-byte budget`, [entry.name]);
263
+ }
264
+ const dataStart = localDataOffset(bytes, entry.localHeaderOffset);
265
+ const compressed = bytes.subarray(dataStart, dataStart + entry.compressedSize);
266
+ let data;
267
+ if (entry.compression === 0) {
268
+ data = compressed.slice();
269
+ }
270
+ else if (entry.compression === 8) {
271
+ try {
272
+ data = inflateSync(compressed, {
273
+ out: new Uint8Array(entry.uncompressedSize),
274
+ });
275
+ }
276
+ catch (err) {
277
+ throw new BundleZipError(`zip bundle rejected: entry ${JSON.stringify(entry.name)} failed to decompress (corrupt data): ${err instanceof Error ? err.message : String(err)}`, [entry.name]);
278
+ }
279
+ }
280
+ else {
281
+ throw new BundleZipError(`zip bundle rejected: entry ${JSON.stringify(entry.name)} uses unsupported compression method ${entry.compression}`, [entry.name]);
282
+ }
283
+ const actualCrc = crc32(data);
284
+ if (actualCrc !== entry.crc32) {
285
+ throw new BundleZipError(`zip bundle rejected: entry ${JSON.stringify(entry.name)} failed CRC-32 verification (corrupt data)`, [entry.name]);
286
+ }
287
+ map.set(normalizedPath, data);
288
+ }
289
+ return createMapStorage(map);
290
+ }
291
+ /**
292
+ * Serializes a `BundleStorage` to zip bytes. Zip metadata (unix
293
+ * permission/symlink bits) is never written — `fflate`'s `zipSync` writes
294
+ * plain file entries, so re-extracting a bundle produced here can never
295
+ * materialize a symlink.
296
+ *
297
+ * The accumulator is an `Object.create(null)` dict (DEFECT 7), not a plain
298
+ * `{}` — `fflate`'s `zipSync` flattens its input with a bracket-assignment
299
+ * loop (`t[key] = ...`) that inherits the same `__proto__` special-case
300
+ * problem when `key` is nested under a directory prefix; giving it a
301
+ * null-prototype object here means a bundle path like `cache/__proto__`
302
+ * (a *nested* `__proto__`, i.e. everything except a bare top-level path)
303
+ * round-trips correctly instead of silently corrupting our own dict.
304
+ *
305
+ * A bundle path that is *exactly* `__proto__` (or `constructor` /
306
+ * `prototype`) at the top level — no directory prefix — cannot be
307
+ * represented at all: this is a hard limitation inside `fflate` 0.8.3
308
+ * itself (its internal flattening step, `fltn`, does `t[name] = [...]` on
309
+ * its *own* plain `{}` accumulator, which we cannot reach or fix), verified
310
+ * empirically to throw a raw `TypeError` from deep inside `zipSync` rather
311
+ * than silently corrupting. We turn that into a clear, typed
312
+ * `BundleZipError` instead of letting the raw `TypeError` escape.
313
+ */
314
+ export async function exportZipBundle(storage) {
315
+ const paths = await storage.list();
316
+ const topLevelProtoLike = paths.filter((p) => p === '__proto__' || p === 'constructor' || p === 'prototype');
317
+ if (topLevelProtoLike.length > 0) {
318
+ throw new BundleZipError(`zip export rejected: bundle path(s) ${topLevelProtoLike.map((p) => JSON.stringify(p)).join(', ')} cannot be represented as a top-level zip entry ` +
319
+ `(fflate's zip writer cannot serialize a top-level entry literally named "__proto__", "constructor", or "prototype"); ` +
320
+ `nest the file under a directory (e.g. "cache/__proto__") to work around this`, topLevelProtoLike);
321
+ }
322
+ const files = Object.create(null);
323
+ for (const path of paths) {
324
+ const data = await storage.read(path);
325
+ if (data !== undefined)
326
+ files[path] = data;
327
+ }
328
+ return zipSync(files);
329
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@markii/bundle",
3
+ "version": "0.1.0",
4
+ "description": "Bundle (.mkbundle) storage and policy layer for Mark: manifest handling, the bundle-relative path-jail, zip (fflate) and Node directory storage forms, and a capability-restricted script view. No React, no parsing.",
5
+ "keywords": [
6
+ "markdown",
7
+ "mark",
8
+ "mk.md",
9
+ "bundle",
10
+ "zip",
11
+ "fflate"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "sadigaxund",
15
+ "homepage": "https://github.com/sadigaxund/markii#readme",
16
+ "bugs": "https://github.com/sadigaxund/markii/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/sadigaxund/markii.git",
20
+ "directory": "packages/markii-bundle"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./fs": {
33
+ "types": "./dist/fs.d.ts",
34
+ "import": "./dist/fs.js",
35
+ "default": "./dist/fs.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "build": "tsc --noEmit -p tsconfig.json",
47
+ "build:dist": "rm -rf dist && tsc -p tsconfig.build.json",
48
+ "lint": "eslint ."
49
+ },
50
+ "dependencies": {
51
+ "fflate": "^0.8.3"
52
+ }
53
+ }