@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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The bundle path-jail (spec §11): pure functions, zero dependencies (not
3
+ * even on Node builtins), so they're trivially unit-testable and reusable
4
+ * from both the browser-safe zip storage and the Node-only directory
5
+ * storage without either pulling in the other's runtime.
6
+ *
7
+ * IMPORTANT: percent-decoding is deliberately NOT applied anywhere in this
8
+ * module. A path segment like `..%2F` is treated as a literal filename
9
+ * (the three characters `.`, `.`, `%2F`... i.e. the six characters
10
+ * `..%2F`), never decoded into `../`. Bundle paths are opaque strings, not
11
+ * URLs — decoding them would let a percent-encoded traversal sequence slip
12
+ * past the `..`-segment check below, which is exactly the class of bug this
13
+ * module exists to prevent.
14
+ */
15
+ /** The two write grants a manifest may declare under `permissions.bundle`. */
16
+ export type BundleFsGrant = 'read' | 'write:cache/';
17
+ export type NormalizePathResult = {
18
+ ok: true;
19
+ path: string;
20
+ } | {
21
+ ok: false;
22
+ reason: string;
23
+ };
24
+ /**
25
+ * Normalizes a bundle-relative path and rejects anything that could escape
26
+ * the bundle root or address the filesystem outside it. This is the single
27
+ * choke point every `BundleStorage` implementation must route through
28
+ * before touching disk or an in-memory archive.
29
+ *
30
+ * Accepts: relative paths using `/` separators, with `.` segments and
31
+ * repeated `/` collapsed, and a leading `./` stripped.
32
+ *
33
+ * Rejects: empty paths, null bytes, any backslash (so a literal `\` is
34
+ * never mistaken for a path separator on a platform that treats it as one),
35
+ * absolute paths (leading `/`), Windows drive-letter paths (`C:...`), and
36
+ * any `..` segment, wherever it appears (start, middle, or end).
37
+ */
38
+ export declare function normalizeBundlePath(path: string): NormalizePathResult;
39
+ /** The write-access policy an untrusted script is evaluated against. */
40
+ export interface BundleWritePolicy {
41
+ /** The `permissions.bundle` grants declared in the note's manifest. */
42
+ grants: readonly BundleFsGrant[];
43
+ }
44
+ /**
45
+ * Implements the write half of spec §11's bundle-scoped filesystem.
46
+ *
47
+ * `note.mk.md` and `manifest.json` are denied unconditionally, regardless of
48
+ * `policy` — this is load-bearing, not a default: a script that could edit
49
+ * the manifest could grant itself further permissions, and a script that
50
+ * could edit `note.mk.md` would make the document self-modifying, which §8
51
+ * explicitly rules out. Every other path requires the `write:cache/` grant
52
+ * and must normalize to a path under `cache/`.
53
+ */
54
+ export declare function isWriteAllowed(path: string, policy: BundleWritePolicy): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The bundle path-jail (spec §11): pure functions, zero dependencies (not
3
+ * even on Node builtins), so they're trivially unit-testable and reusable
4
+ * from both the browser-safe zip storage and the Node-only directory
5
+ * storage without either pulling in the other's runtime.
6
+ *
7
+ * IMPORTANT: percent-decoding is deliberately NOT applied anywhere in this
8
+ * module. A path segment like `..%2F` is treated as a literal filename
9
+ * (the three characters `.`, `.`, `%2F`... i.e. the six characters
10
+ * `..%2F`), never decoded into `../`. Bundle paths are opaque strings, not
11
+ * URLs — decoding them would let a percent-encoded traversal sequence slip
12
+ * past the `..`-segment check below, which is exactly the class of bug this
13
+ * module exists to prevent.
14
+ */
15
+ /** Matches a leading Windows drive letter, e.g. `C:` at the start of a path. */
16
+ const DRIVE_LETTER_RE = /^[A-Za-z]:/;
17
+ /**
18
+ * Normalizes a bundle-relative path and rejects anything that could escape
19
+ * the bundle root or address the filesystem outside it. This is the single
20
+ * choke point every `BundleStorage` implementation must route through
21
+ * before touching disk or an in-memory archive.
22
+ *
23
+ * Accepts: relative paths using `/` separators, with `.` segments and
24
+ * repeated `/` collapsed, and a leading `./` stripped.
25
+ *
26
+ * Rejects: empty paths, null bytes, any backslash (so a literal `\` is
27
+ * never mistaken for a path separator on a platform that treats it as one),
28
+ * absolute paths (leading `/`), Windows drive-letter paths (`C:...`), and
29
+ * any `..` segment, wherever it appears (start, middle, or end).
30
+ */
31
+ export function normalizeBundlePath(path) {
32
+ if (path.length === 0) {
33
+ return { ok: false, reason: 'path is empty' };
34
+ }
35
+ if (path.includes('\0')) {
36
+ return { ok: false, reason: 'path contains a null byte' };
37
+ }
38
+ if (path.includes('\\')) {
39
+ return { ok: false, reason: 'backslashes are not allowed in bundle paths' };
40
+ }
41
+ if (path.startsWith('/')) {
42
+ return { ok: false, reason: 'absolute paths are not allowed' };
43
+ }
44
+ if (DRIVE_LETTER_RE.test(path)) {
45
+ return { ok: false, reason: 'drive-letter paths are not allowed' };
46
+ }
47
+ const segments = [];
48
+ for (const segment of path.split('/')) {
49
+ // Empty segments (repeated `/` or a trailing `/`) and `.` segments
50
+ // collapse away silently — they carry no traversal meaning.
51
+ if (segment === '' || segment === '.')
52
+ continue;
53
+ if (segment === '..') {
54
+ return { ok: false, reason: '".." path segments are not allowed' };
55
+ }
56
+ segments.push(segment);
57
+ }
58
+ if (segments.length === 0) {
59
+ return { ok: false, reason: 'path has no meaningful segments' };
60
+ }
61
+ return { ok: true, path: segments.join('/') };
62
+ }
63
+ /**
64
+ * Implements the write half of spec §11's bundle-scoped filesystem.
65
+ *
66
+ * `note.mk.md` and `manifest.json` are denied unconditionally, regardless of
67
+ * `policy` — this is load-bearing, not a default: a script that could edit
68
+ * the manifest could grant itself further permissions, and a script that
69
+ * could edit `note.mk.md` would make the document self-modifying, which §8
70
+ * explicitly rules out. Every other path requires the `write:cache/` grant
71
+ * and must normalize to a path under `cache/`.
72
+ */
73
+ export function isWriteAllowed(path, policy) {
74
+ const normalized = normalizeBundlePath(path);
75
+ if (!normalized.ok)
76
+ return false;
77
+ const { path: p } = normalized;
78
+ // Unconditional denial: no policy input can override this.
79
+ if (p === 'note.mk.md' || p === 'manifest.json')
80
+ return false;
81
+ if (!policy.grants.includes('write:cache/'))
82
+ return false;
83
+ return p.startsWith('cache/') && p.length > 'cache/'.length;
84
+ }
@@ -0,0 +1,57 @@
1
+ import type { BundleManifest, BundlePermissions } from './manifest.js';
2
+ import type { BundleStorage } from './storage.js';
3
+ /**
4
+ * The capability-restricted view of a bundle a future Lua runtime (§8, §10,
5
+ * §11) will actually receive — never the raw `BundleStorage`. Deliberately
6
+ * exposes only `read` / `write` / `exists`, no `list`: directory
7
+ * enumeration stays host-side. An untrusted script that can already read
8
+ * `assets/photo.png` by name doesn't need the ability to *discover* every
9
+ * other file in the bundle; keeping `list` off the script-facing surface
10
+ * minimizes what a script can learn about a bundle it wasn't specifically
11
+ * pointed at (e.g. other cached datasets, other scripts' outputs).
12
+ */
13
+ export interface ScriptView {
14
+ read(path: string): Promise<Uint8Array | undefined>;
15
+ write(path: string, data: Uint8Array): Promise<void>;
16
+ exists(path: string): Promise<boolean>;
17
+ }
18
+ /**
19
+ * The fully-trusted convenience case: grants a `ScriptView` exactly what
20
+ * `manifest.permissions` *declares*, nothing more. This is a legitimate
21
+ * pattern for a note the user has already explicitly decided to fully
22
+ * trust (their own note, a dev/test harness) — but it must always be an
23
+ * explicit, named opt-in at the call site, never `createScriptView`'s
24
+ * default. See the DEFECT-10 note on `createScriptView` for why: an
25
+ * untrusted `.mkbundle` opened from elsewhere must never be able to grant
26
+ * itself capabilities merely by declaring them in its own manifest.
27
+ */
28
+ export declare function grantAllDeclaredPermissions(manifest: BundleManifest): BundlePermissions;
29
+ /**
30
+ * Builds a `ScriptView` over `storage`.
31
+ *
32
+ * DEFECT 10 / spec §10: "Capabilities are declared in the manifest, granted
33
+ * by the user" — declaring is not granting. `manifest.permissions` is
34
+ * whatever the (possibly untrusted) `.mkbundle` *asks for*; `grantedPermissions`
35
+ * is whatever the user has actually *approved* for this note (e.g. via a
36
+ * permission-prompt UI, remembered per note and re-prompted if scripts
37
+ * change — see §10). The capability this view actually exposes is the
38
+ * INTERSECTION of the two: the manifest can only ever narrow what the user
39
+ * granted, never expand it, and the user's grant can only ever narrow what
40
+ * the manifest declared wanting. Neither side alone is authoritative.
41
+ *
42
+ * `grantedPermissions` defaults to `{}` (zero grants) — deliberately not to
43
+ * "everything the manifest asks for". An untrusted note opened from
44
+ * elsewhere must start with zero grants and still render fully (§10); a
45
+ * caller that wants the old fully-trusted behavior must opt in explicitly,
46
+ * e.g. `createScriptView(storage, manifest, grantAllDeclaredPermissions(manifest))`.
47
+ *
48
+ * - No effective bundle grants at all: every call throws `ScriptCapabilityError`.
49
+ * - `'read'` in the intersection: `read`/`exists` work bundle-wide; `write` still fails.
50
+ * - `'write:cache/'` in the intersection: `write` works, but only for paths
51
+ * `isWriteAllowed` accepts — `cache/` only. Critically, this holds even
52
+ * if both the manifest and the granted set include `'write:cache/'` and
53
+ * the script asks for `manifest.json` or `note.mk.md`: `isWriteAllowed`
54
+ * denies those two paths unconditionally, regardless of what's granted
55
+ * or declared (see `./paths`).
56
+ */
57
+ export declare function createScriptView(storage: BundleStorage, manifest: BundleManifest, grantedPermissions?: BundlePermissions): ScriptView;
@@ -0,0 +1,71 @@
1
+ import { ScriptCapabilityError } from './errors.js';
2
+ import { isWriteAllowed } from './paths.js';
3
+ /**
4
+ * The fully-trusted convenience case: grants a `ScriptView` exactly what
5
+ * `manifest.permissions` *declares*, nothing more. This is a legitimate
6
+ * pattern for a note the user has already explicitly decided to fully
7
+ * trust (their own note, a dev/test harness) — but it must always be an
8
+ * explicit, named opt-in at the call site, never `createScriptView`'s
9
+ * default. See the DEFECT-10 note on `createScriptView` for why: an
10
+ * untrusted `.mkbundle` opened from elsewhere must never be able to grant
11
+ * itself capabilities merely by declaring them in its own manifest.
12
+ */
13
+ export function grantAllDeclaredPermissions(manifest) {
14
+ return manifest.permissions ?? {};
15
+ }
16
+ /**
17
+ * Builds a `ScriptView` over `storage`.
18
+ *
19
+ * DEFECT 10 / spec §10: "Capabilities are declared in the manifest, granted
20
+ * by the user" — declaring is not granting. `manifest.permissions` is
21
+ * whatever the (possibly untrusted) `.mkbundle` *asks for*; `grantedPermissions`
22
+ * is whatever the user has actually *approved* for this note (e.g. via a
23
+ * permission-prompt UI, remembered per note and re-prompted if scripts
24
+ * change — see §10). The capability this view actually exposes is the
25
+ * INTERSECTION of the two: the manifest can only ever narrow what the user
26
+ * granted, never expand it, and the user's grant can only ever narrow what
27
+ * the manifest declared wanting. Neither side alone is authoritative.
28
+ *
29
+ * `grantedPermissions` defaults to `{}` (zero grants) — deliberately not to
30
+ * "everything the manifest asks for". An untrusted note opened from
31
+ * elsewhere must start with zero grants and still render fully (§10); a
32
+ * caller that wants the old fully-trusted behavior must opt in explicitly,
33
+ * e.g. `createScriptView(storage, manifest, grantAllDeclaredPermissions(manifest))`.
34
+ *
35
+ * - No effective bundle grants at all: every call throws `ScriptCapabilityError`.
36
+ * - `'read'` in the intersection: `read`/`exists` work bundle-wide; `write` still fails.
37
+ * - `'write:cache/'` in the intersection: `write` works, but only for paths
38
+ * `isWriteAllowed` accepts — `cache/` only. Critically, this holds even
39
+ * if both the manifest and the granted set include `'write:cache/'` and
40
+ * the script asks for `manifest.json` or `note.mk.md`: `isWriteAllowed`
41
+ * denies those two paths unconditionally, regardless of what's granted
42
+ * or declared (see `./paths`).
43
+ */
44
+ export function createScriptView(storage, manifest, grantedPermissions = {}) {
45
+ const declared = new Set(manifest.permissions?.bundle ?? []);
46
+ const granted = grantedPermissions.bundle ?? [];
47
+ // Effective capability = declared ∩ granted. See the doc comment above —
48
+ // this is the load-bearing line for DEFECT 10.
49
+ const grants = granted.filter((grant) => declared.has(grant));
50
+ const canRead = grants.includes('read');
51
+ return {
52
+ async read(path) {
53
+ if (!canRead) {
54
+ throw new ScriptCapabilityError(`script has no "read" bundle permission (requested "${path}")`);
55
+ }
56
+ return storage.read(path);
57
+ },
58
+ async write(path, data) {
59
+ if (!isWriteAllowed(path, { grants })) {
60
+ throw new ScriptCapabilityError(`script may not write "${path}"`);
61
+ }
62
+ await storage.write(path, data);
63
+ },
64
+ async exists(path) {
65
+ if (!canRead) {
66
+ throw new ScriptCapabilityError(`script has no "read" bundle permission (requested "${path}")`);
67
+ }
68
+ return storage.exists(path);
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Common shape both bundle forms (zip, directory) implement. Every method
3
+ * takes/returns bundle-relative paths and routes through
4
+ * `normalizeBundlePath` before touching storage — see `normalizeOrThrow`.
5
+ *
6
+ * IMPORTANT for implementers: every method MUST route its `path` argument
7
+ * through `normalizeOrThrow` (or an equivalent check) before touching disk
8
+ * or an in-memory archive. `ScriptView` (`./script-view`) delegates *all*
9
+ * path validation to whatever `BundleStorage` it's given — it does not
10
+ * re-normalize paths itself. A storage implementation that skips this
11
+ * choke point unjails every `ScriptView` built on top of it, no matter how
12
+ * carefully `isWriteAllowed`/`normalizeBundlePath` are enforced elsewhere.
13
+ * The directory form (`./fs`) additionally must not follow symlinks or
14
+ * hard links when resolving a path to a physical file — see
15
+ * `resolveInsideRoot` and `writeExistingFileNoHardlink` there for why a
16
+ * *logical* path-jail alone is not sufficient once real files are involved.
17
+ */
18
+ export interface BundleStorage {
19
+ /** Returns the file's bytes, or `undefined` if no such path exists. */
20
+ read(path: string): Promise<Uint8Array | undefined>;
21
+ write(path: string, data: Uint8Array): Promise<void>;
22
+ /** All file paths currently in the bundle, bundle-relative, sorted. */
23
+ list(): Promise<string[]>;
24
+ exists(path: string): Promise<boolean>;
25
+ }
26
+ /**
27
+ * The shared choke point every `BundleStorage` implementation calls before
28
+ * touching an archive or the filesystem: normalizes `path` or throws a
29
+ * `BundlePathError` describing why it was rejected.
30
+ */
31
+ export declare function normalizeOrThrow(path: string): string;
@@ -0,0 +1,14 @@
1
+ import { BundlePathError } from './errors.js';
2
+ import { normalizeBundlePath } from './paths.js';
3
+ /**
4
+ * The shared choke point every `BundleStorage` implementation calls before
5
+ * touching an archive or the filesystem: normalizes `path` or throws a
6
+ * `BundlePathError` describing why it was rejected.
7
+ */
8
+ export function normalizeOrThrow(path) {
9
+ const result = normalizeBundlePath(path);
10
+ if (!result.ok) {
11
+ throw new BundlePathError(path, result.reason);
12
+ }
13
+ return result.path;
14
+ }
package/dist/zip.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { BundleStorage } from './storage.js';
2
+ /** Options for `openZipBundle`'s decompression-bomb guard (DEFECT 4). */
3
+ export interface OpenZipBundleOptions {
4
+ /**
5
+ * Reject an entry whose *declared* (header) uncompressed size exceeds
6
+ * this many bytes, before ever allocating a decompression buffer for it.
7
+ * Defaults to `DEFAULT_MAX_ZIP_ENTRY_BYTES`.
8
+ */
9
+ maxEntryBytes?: number;
10
+ /**
11
+ * Reject the archive once the running sum of every processed entry's
12
+ * declared uncompressed size exceeds this many bytes. Defaults to
13
+ * `DEFAULT_MAX_ZIP_TOTAL_BYTES`.
14
+ */
15
+ maxTotalBytes?: number;
16
+ }
17
+ /** Default per-entry decompressed-size cap: 256MB. */
18
+ export declare const DEFAULT_MAX_ZIP_ENTRY_BYTES: number;
19
+ /** Default total (summed across all entries) decompressed-size cap: 256MB. */
20
+ export declare const DEFAULT_MAX_ZIP_TOTAL_BYTES: number;
21
+ /**
22
+ * Opens the zip form of a bundle (browser-safe: `fflate` has no Node
23
+ * dependency; the ZIP container parsing here is our own and is also
24
+ * dependency-free). Directory entries (names ending in `/`) carry no data
25
+ * and are skipped — but only *after* the name has passed
26
+ * `normalizeBundlePath` (DEFECT 8: validating before the directory-entry
27
+ * skip means a malformed directory entry like `../evil/` is rejected loudly,
28
+ * the same as its non-directory counterpart `../evil`, instead of being
29
+ * silently dropped).
30
+ *
31
+ * Zip-slip protection: every file entry's name is run through
32
+ * `normalizeBundlePath`. Any entry that fails (`../`, an absolute path, a
33
+ * backslash path, a drive-letter path) is collected and, if any exist,
34
+ * the whole open is rejected with a `BundleZipError` listing every
35
+ * offending name — a tampered bundle must be loud, not silently pruned
36
+ * down to "the entries that happened to be safe."
37
+ *
38
+ * Collision protection (DEFECT 5): two distinct raw entry names that
39
+ * *normalize* to the same bundle path (e.g. `manifest.json` and
40
+ * `./manifest.json`, or `cache/x` and `cache//x`) are rejected outright
41
+ * rather than silently last-wins — a bundle could otherwise show a benign
42
+ * file to one reader and a hostile one to another depending on which
43
+ * implementation's normalization/iteration order "wins".
44
+ *
45
+ * Decompression-bomb protection (DEFECT 4): every entry's declared
46
+ * uncompressed size (read from the central directory, before any inflation
47
+ * happens) is checked against `options.maxEntryBytes`, and the running
48
+ * total across all entries against `options.maxTotalBytes`. A crafted
49
+ * high-ratio archive is rejected before its claimed size is ever allocated.
50
+ *
51
+ * CRC-32 verification (DEFECT 6): every entry's decompressed bytes are
52
+ * checked against the CRC-32 recorded in the central directory; a mismatch
53
+ * (corrupt archive, flipped bit) throws `BundleZipError` instead of
54
+ * silently returning wrong data.
55
+ */
56
+ export declare function openZipBundle(bytes: Uint8Array, options?: OpenZipBundleOptions): BundleStorage;
57
+ /**
58
+ * Serializes a `BundleStorage` to zip bytes. Zip metadata (unix
59
+ * permission/symlink bits) is never written — `fflate`'s `zipSync` writes
60
+ * plain file entries, so re-extracting a bundle produced here can never
61
+ * materialize a symlink.
62
+ *
63
+ * The accumulator is an `Object.create(null)` dict (DEFECT 7), not a plain
64
+ * `{}` — `fflate`'s `zipSync` flattens its input with a bracket-assignment
65
+ * loop (`t[key] = ...`) that inherits the same `__proto__` special-case
66
+ * problem when `key` is nested under a directory prefix; giving it a
67
+ * null-prototype object here means a bundle path like `cache/__proto__`
68
+ * (a *nested* `__proto__`, i.e. everything except a bare top-level path)
69
+ * round-trips correctly instead of silently corrupting our own dict.
70
+ *
71
+ * A bundle path that is *exactly* `__proto__` (or `constructor` /
72
+ * `prototype`) at the top level — no directory prefix — cannot be
73
+ * represented at all: this is a hard limitation inside `fflate` 0.8.3
74
+ * itself (its internal flattening step, `fltn`, does `t[name] = [...]` on
75
+ * its *own* plain `{}` accumulator, which we cannot reach or fix), verified
76
+ * empirically to throw a raw `TypeError` from deep inside `zipSync` rather
77
+ * than silently corrupting. We turn that into a clear, typed
78
+ * `BundleZipError` instead of letting the raw `TypeError` escape.
79
+ */
80
+ export declare function exportZipBundle(storage: BundleStorage): Promise<Uint8Array>;