@markii/bundle 0.12.0 → 0.13.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/fs.js CHANGED
@@ -15,8 +15,8 @@ function isEnoent(err) {
15
15
  }
16
16
  /**
17
17
  * `ENOTDIR` shows up when an *earlier* path component turns out to be a
18
- * plain file instead of a directory (e.g. writing `cache/data.json/sub`
19
- * when `cache/data.json` already exists as a file) — treated the same as
18
+ * plain file instead of a directory (e.g. writing `.cache/data.json/sub`
19
+ * when `.cache/data.json` already exists as a file) — treated the same as
20
20
  * "doesn't exist yet" by the lstat walk below, deferring the actual clear
21
21
  * error to the subsequent `mkdir`/`writeFile`/`open` call.
22
22
  */
@@ -31,7 +31,7 @@ function isEnoentLike(err) {
31
31
  * in `./paths` only reasons about the *logical* path string, but the actual
32
32
  * filesystem write/read *follows symlinks*, so a symlink planted anywhere
33
33
  * inside the bundle (even one that resolves to somewhere still nominally
34
- * "inside the bundle root", like `cache/pwn -> ../manifest.json`) can
34
+ * "inside the bundle root", like `.cache/pwn -> ../manifest.json`) can
35
35
  * silently retarget an otherwise-innocuous-looking write.
36
36
  *
37
37
  * Two layers, in order:
@@ -41,10 +41,10 @@ function isEnoentLike(err) {
41
41
  * `stat`, which would itself follow a symlink). The moment any
42
42
  * *existing* component is a symlink — file or directory, leaf or
43
43
  * ancestor, whether or not it resolves inside or outside the root — the
44
- * whole operation is rejected. This alone defeats ESCAPE 1 (`cache/pwn`
44
+ * whole operation is rejected. This alone defeats ESCAPE 1 (`.cache/pwn`
45
45
  * symlinked to `../manifest.json`) and its directory variant
46
- * (`cache/up` symlinked to `..`, then `cache/up/manifest.json` or even
47
- * `cache/up/not-yet-created.txt`): the symlinked component is caught
46
+ * (`.cache/up` symlinked to `..`, then `.cache/up/manifest.json` or even
47
+ * `.cache/up/not-yet-created.txt`): the symlinked component is caught
48
48
  * before we ever ask whether its target exists.
49
49
  * 2. **Defense in depth — resolved-path re-check.** Once the walk finds
50
50
  * the nearest existing ancestor (the leaf itself may not exist yet,
@@ -116,7 +116,7 @@ async function resolveInsideRoot(rootAbs, relPath) {
116
116
  * Writes `data` to an already symlink-checked `target` that is known to
117
117
  * exist, refusing to write through a hard link (ESCAPE 3: `st_nlink > 1`
118
118
  * means some other path — possibly outside the bundle entirely, e.g.
119
- * `ln b.mkz/../victim.txt b.mkz/cache/hard` — refers to the exact same
119
+ * `ln b.mkz/../victim.txt b.mkz/.cache/hard` — refers to the exact same
120
120
  * inode, so writing here would also silently modify that other path).
121
121
  *
122
122
  * Opens the file first *without* truncating (`'r+'`), `fstat`s the open
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './m
3
3
  export type { BundleFsGrant, BundleWritePolicy, NormalizePathResult, } from './paths.js';
4
4
  export { isWriteAllowed, normalizeBundlePath } from './paths.js';
5
5
  export type { BundleStorage } from './storage.js';
6
- export { normalizeOrThrow } from './storage.js';
6
+ export { createMemoryBundleStorage, normalizeOrThrow } from './storage.js';
7
7
  export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
8
8
  export type { OpenZipBundleOptions } from './zip.js';
9
9
  export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // entry never has to reason about Node builtins reachable from it.
7
7
  export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './manifest.js';
8
8
  export { isWriteAllowed, normalizeBundlePath } from './paths.js';
9
- export { normalizeOrThrow } from './storage.js';
9
+ export { createMemoryBundleStorage, normalizeOrThrow } from './storage.js';
10
10
  export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
11
11
  export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
12
12
  export { createScriptView, grantAllDeclaredPermissions, } from './script-view.js';
package/dist/manifest.js CHANGED
@@ -7,7 +7,13 @@ const KNOWN_TOP_LEVEL_KEYS = new Set([
7
7
  'uses',
8
8
  'document',
9
9
  ]);
10
- const KNOWN_FS_GRANTS = new Set(['read', 'write:cache/']);
10
+ const KNOWN_FS_GRANTS = new Set(['read', 'write:.cache/']);
11
+ /**
12
+ * The retired, undotted spelling of the write grant. Never accepted as an
13
+ * alias: no released bundle declares it, so a manifest naming it gets a
14
+ * diagnostic pointing at the correct token instead of a silent rewrite.
15
+ */
16
+ const RETIRED_WRITE_GRANT = 'write:cache/';
11
17
  // Simplified but structurally correct semver: MAJOR.MINOR.PATCH with
12
18
  // optional prerelease/build metadata. Good enough for a "shape" check —
13
19
  // this package does not need to compare or range-match versions.
@@ -145,8 +151,16 @@ export function parseManifest(json) {
145
151
  else {
146
152
  const badGrants = permsObj.bundle.filter((grant) => !KNOWN_FS_GRANTS.has(grant));
147
153
  if (badGrants.length > 0) {
148
- errors.push(`"permissions.bundle" contains invalid grant(s): ${badGrants.join(', ')} ` +
149
- `(expected "read" or "write:cache/")`);
154
+ const retiredGrants = badGrants.filter((grant) => grant === RETIRED_WRITE_GRANT);
155
+ const otherGrants = badGrants.filter((grant) => grant !== RETIRED_WRITE_GRANT);
156
+ if (retiredGrants.length > 0) {
157
+ errors.push(`"permissions.bundle" uses the retired grant "write:cache/"; ` +
158
+ `the writable directory is "write:.cache/"`);
159
+ }
160
+ if (otherGrants.length > 0) {
161
+ errors.push(`"permissions.bundle" contains invalid grant(s): ${otherGrants.join(', ')} ` +
162
+ `(expected "read" or "write:.cache/")`);
163
+ }
150
164
  }
151
165
  else {
152
166
  perms.bundle = permsObj.bundle;
package/dist/paths.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * module exists to prevent.
14
14
  */
15
15
  /** The two write grants a manifest may declare under `permissions.bundle`. */
16
- export type BundleFsGrant = 'read' | 'write:cache/';
16
+ export type BundleFsGrant = 'read' | 'write:.cache/';
17
17
  export type NormalizePathResult = {
18
18
  ok: true;
19
19
  path: string;
@@ -48,7 +48,11 @@ export interface BundleWritePolicy {
48
48
  * `policy` — this is load-bearing, not a default: a script that could edit
49
49
  * the manifest could grant itself further permissions, and a script that
50
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/`.
51
+ * explicitly rules out. Every other path requires the `write:.cache/` grant
52
+ * and must normalize to a path under `.cache/`. The dot prefix is the one
53
+ * spelling: `normalizeBundlePath` collapses a bare `.` segment away, so
54
+ * `.cache/x` normalizes to a first segment of `.cache`, an ordinary
55
+ * dot-prefixed directory name rather than a traversal segment (only a
56
+ * literal `..` segment is rejected).
53
57
  */
54
58
  export declare function isWriteAllowed(path: string, policy: BundleWritePolicy): boolean;
package/dist/paths.js CHANGED
@@ -67,8 +67,12 @@ export function normalizeBundlePath(path) {
67
67
  * `policy` — this is load-bearing, not a default: a script that could edit
68
68
  * the manifest could grant itself further permissions, and a script that
69
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/`.
70
+ * explicitly rules out. Every other path requires the `write:.cache/` grant
71
+ * and must normalize to a path under `.cache/`. The dot prefix is the one
72
+ * spelling: `normalizeBundlePath` collapses a bare `.` segment away, so
73
+ * `.cache/x` normalizes to a first segment of `.cache`, an ordinary
74
+ * dot-prefixed directory name rather than a traversal segment (only a
75
+ * literal `..` segment is rejected).
72
76
  */
73
77
  export function isWriteAllowed(path, policy) {
74
78
  const normalized = normalizeBundlePath(path);
@@ -78,7 +82,7 @@ export function isWriteAllowed(path, policy) {
78
82
  // Unconditional denial: no policy input can override this.
79
83
  if (p === 'note.mk.md' || p === 'manifest.json')
80
84
  return false;
81
- if (!policy.grants.includes('write:cache/'))
85
+ if (!policy.grants.includes('write:.cache/'))
82
86
  return false;
83
- return p.startsWith('cache/') && p.length > 'cache/'.length;
87
+ return p.startsWith('.cache/') && p.length > '.cache/'.length;
84
88
  }
@@ -48,9 +48,9 @@ export declare function grantAllDeclaredPermissions(manifest: BundleManifest): B
48
48
  *
49
49
  * - No effective bundle grants at all: every call throws `ScriptCapabilityError`.
50
50
  * - `'read'` in the intersection: `read`/`exists` work bundle-wide; `write` still fails.
51
- * - `'write:cache/'` in the intersection: `write` works, but only for paths
52
- * `isWriteAllowed` accepts — `cache/` only. Critically, this holds even
53
- * if both the manifest and the granted set include `'write:cache/'` and
51
+ * - `'write:.cache/'` in the intersection: `write` works, but only for paths
52
+ * `isWriteAllowed` accepts — `.cache/` only. Critically, this holds even
53
+ * if both the manifest and the granted set include `'write:.cache/'` and
54
54
  * the script asks for `manifest.json` or `note.mk.md`: `isWriteAllowed`
55
55
  * denies those two paths unconditionally, regardless of what's granted
56
56
  * or declared (see `./paths`).
@@ -35,9 +35,9 @@ export function grantAllDeclaredPermissions(manifest) {
35
35
  *
36
36
  * - No effective bundle grants at all: every call throws `ScriptCapabilityError`.
37
37
  * - `'read'` in the intersection: `read`/`exists` work bundle-wide; `write` still fails.
38
- * - `'write:cache/'` in the intersection: `write` works, but only for paths
39
- * `isWriteAllowed` accepts — `cache/` only. Critically, this holds even
40
- * if both the manifest and the granted set include `'write:cache/'` and
38
+ * - `'write:.cache/'` in the intersection: `write` works, but only for paths
39
+ * `isWriteAllowed` accepts — `.cache/` only. Critically, this holds even
40
+ * if both the manifest and the granted set include `'write:.cache/'` and
41
41
  * the script asks for `manifest.json` or `note.mk.md`: `isWriteAllowed`
42
42
  * denies those two paths unconditionally, regardless of what's granted
43
43
  * or declared (see `./paths`).
package/dist/storage.d.ts CHANGED
@@ -41,3 +41,31 @@ export interface BundleStorage {
41
41
  * `BundlePathError` describing why it was rejected.
42
42
  */
43
43
  export declare function normalizeOrThrow(path: string): string;
44
+ /**
45
+ * A third `BundleStorage` form, alongside the zip form (`./zip`) and the
46
+ * Node-only directory form (`./fs`): a plain in-memory bundle, backed by
47
+ * nothing more than a `Map`. No zip bytes to parse, no real filesystem to
48
+ * guard against symlinks or hard links — this form exists for a host that
49
+ * already has a bundle's files as plain values (a browser tab building a
50
+ * bundle from scratch, a test harness) and wants the same `BundleStorage`
51
+ * contract without a Node dependency, so it lives on the browser-safe main
52
+ * entry (`./index`) rather than the `./fs` subpath.
53
+ *
54
+ * `files` maps a bundle-relative path to its content, either already as
55
+ * bytes or as a string encoded here as UTF-8 — the same encoding every
56
+ * other `BundleStorage` form uses for text. Every key is routed through the
57
+ * exact same `normalizeOrThrow` choke point every other form uses (never a
58
+ * reimplemented jail): an individually invalid path throws `BundlePathError`
59
+ * immediately, and two distinct keys that normalize to the same path (e.g.
60
+ * `"note.mk.md"` and `"./note.mk.md"`) are rejected the same way `openZipBundle`
61
+ * rejects colliding zip entries, rather than silently letting one shadow the
62
+ * other.
63
+ *
64
+ * Like the zip and directory forms, this storage enforces only the
65
+ * structural path-jail — never `isWriteAllowed`. Confining writes to
66
+ * `.cache/` is `ScriptView`'s job (`./script-view`), which wraps whatever
67
+ * `BundleStorage` it is given; a caller wanting that policy applied wraps
68
+ * this storage in `createScriptView` exactly as it would the zip or
69
+ * directory form.
70
+ */
71
+ export declare function createMemoryBundleStorage(files?: Readonly<Record<string, string | Uint8Array>>): BundleStorage;
package/dist/storage.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BundlePathError } from './errors.js';
2
2
  import { normalizeBundlePath } from './paths.js';
3
+ const utf8Encoder = new TextEncoder();
3
4
  /**
4
5
  * The shared choke point every `BundleStorage` implementation calls before
5
6
  * touching an archive or the filesystem: normalizes `path` or throws a
@@ -12,3 +13,59 @@ export function normalizeOrThrow(path) {
12
13
  }
13
14
  return result.path;
14
15
  }
16
+ /**
17
+ * A third `BundleStorage` form, alongside the zip form (`./zip`) and the
18
+ * Node-only directory form (`./fs`): a plain in-memory bundle, backed by
19
+ * nothing more than a `Map`. No zip bytes to parse, no real filesystem to
20
+ * guard against symlinks or hard links — this form exists for a host that
21
+ * already has a bundle's files as plain values (a browser tab building a
22
+ * bundle from scratch, a test harness) and wants the same `BundleStorage`
23
+ * contract without a Node dependency, so it lives on the browser-safe main
24
+ * entry (`./index`) rather than the `./fs` subpath.
25
+ *
26
+ * `files` maps a bundle-relative path to its content, either already as
27
+ * bytes or as a string encoded here as UTF-8 — the same encoding every
28
+ * other `BundleStorage` form uses for text. Every key is routed through the
29
+ * exact same `normalizeOrThrow` choke point every other form uses (never a
30
+ * reimplemented jail): an individually invalid path throws `BundlePathError`
31
+ * immediately, and two distinct keys that normalize to the same path (e.g.
32
+ * `"note.mk.md"` and `"./note.mk.md"`) are rejected the same way `openZipBundle`
33
+ * rejects colliding zip entries, rather than silently letting one shadow the
34
+ * other.
35
+ *
36
+ * Like the zip and directory forms, this storage enforces only the
37
+ * structural path-jail — never `isWriteAllowed`. Confining writes to
38
+ * `.cache/` is `ScriptView`'s job (`./script-view`), which wraps whatever
39
+ * `BundleStorage` it is given; a caller wanting that policy applied wraps
40
+ * this storage in `createScriptView` exactly as it would the zip or
41
+ * directory form.
42
+ */
43
+ export function createMemoryBundleStorage(files = {}) {
44
+ const map = new Map();
45
+ for (const [rawPath, content] of Object.entries(files)) {
46
+ const normalized = normalizeOrThrow(rawPath);
47
+ if (map.has(normalized)) {
48
+ throw new BundlePathError(rawPath, `collides with another entry after normalization (both resolve to ${JSON.stringify(normalized)})`);
49
+ }
50
+ map.set(normalized, typeof content === 'string' ? utf8Encoder.encode(content) : content);
51
+ }
52
+ return {
53
+ read(path) {
54
+ return Promise.resolve(map.get(normalizeOrThrow(path)));
55
+ },
56
+ write(path, data) {
57
+ map.set(normalizeOrThrow(path), data);
58
+ return Promise.resolve();
59
+ },
60
+ list() {
61
+ return Promise.resolve(Array.from(map.keys()).sort());
62
+ },
63
+ exists(path) {
64
+ return Promise.resolve(map.has(normalizeOrThrow(path)));
65
+ },
66
+ size(path) {
67
+ const data = map.get(normalizeOrThrow(path));
68
+ return Promise.resolve(data === undefined ? undefined : data.length);
69
+ },
70
+ };
71
+ }
package/dist/zip.d.ts CHANGED
@@ -37,7 +37,7 @@ export declare const DEFAULT_MAX_ZIP_TOTAL_BYTES: number;
37
37
  *
38
38
  * Collision protection (DEFECT 5): two distinct raw entry names that
39
39
  * *normalize* to the same bundle path (e.g. `manifest.json` and
40
- * `./manifest.json`, or `cache/x` and `cache//x`) are rejected outright
40
+ * `./manifest.json`, or `.cache/x` and `.cache//x`) are rejected outright
41
41
  * rather than silently last-wins — a bundle could otherwise show a benign
42
42
  * file to one reader and a hostile one to another depending on which
43
43
  * implementation's normalization/iteration order "wins".
@@ -64,7 +64,7 @@ export declare function openZipBundle(bytes: Uint8Array, options?: OpenZipBundle
64
64
  * `{}` — `fflate`'s `zipSync` flattens its input with a bracket-assignment
65
65
  * loop (`t[key] = ...`) that inherits the same `__proto__` special-case
66
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__`
67
+ * null-prototype object here means a bundle path like `.cache/__proto__`
68
68
  * (a *nested* `__proto__`, i.e. everything except a bare top-level path)
69
69
  * round-trips correctly instead of silently corrupting our own dict.
70
70
  *
package/dist/zip.js CHANGED
@@ -213,7 +213,7 @@ export const DEFAULT_MAX_ZIP_TOTAL_BYTES = 256 * 1024 * 1024;
213
213
  *
214
214
  * Collision protection (DEFECT 5): two distinct raw entry names that
215
215
  * *normalize* to the same bundle path (e.g. `manifest.json` and
216
- * `./manifest.json`, or `cache/x` and `cache//x`) are rejected outright
216
+ * `./manifest.json`, or `.cache/x` and `.cache//x`) are rejected outright
217
217
  * rather than silently last-wins — a bundle could otherwise show a benign
218
218
  * file to one reader and a hostile one to another depending on which
219
219
  * implementation's normalization/iteration order "wins".
@@ -308,7 +308,7 @@ export function openZipBundle(bytes, options = {}) {
308
308
  * `{}` — `fflate`'s `zipSync` flattens its input with a bracket-assignment
309
309
  * loop (`t[key] = ...`) that inherits the same `__proto__` special-case
310
310
  * problem when `key` is nested under a directory prefix; giving it a
311
- * null-prototype object here means a bundle path like `cache/__proto__`
311
+ * null-prototype object here means a bundle path like `.cache/__proto__`
312
312
  * (a *nested* `__proto__`, i.e. everything except a bare top-level path)
313
313
  * round-trips correctly instead of silently corrupting our own dict.
314
314
  *
@@ -327,7 +327,7 @@ export async function exportZipBundle(storage) {
327
327
  if (topLevelProtoLike.length > 0) {
328
328
  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 ` +
329
329
  `(fflate's zip writer cannot serialize a top-level entry literally named "__proto__", "constructor", or "prototype"); ` +
330
- `nest the file under a directory (e.g. "cache/__proto__") to work around this`, topLevelProtoLike);
330
+ `nest the file under a directory (e.g. ".cache/__proto__") to work around this`, topLevelProtoLike);
331
331
  }
332
332
  const files = Object.create(null);
333
333
  for (const path of paths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/bundle",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Bundle (.mkz, formerly .mkbundle) storage and policy layer for Markii: 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
5
  "keywords": [
6
6
  "markdown",