@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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +49 -0
- package/dist/fs.d.ts +25 -0
- package/dist/fs.js +249 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/manifest.d.ts +56 -0
- package/dist/manifest.js +146 -0
- package/dist/paths.d.ts +54 -0
- package/dist/paths.js +84 -0
- package/dist/script-view.d.ts +57 -0
- package/dist/script-view.js +71 -0
- package/dist/storage.d.ts +31 -0
- package/dist/storage.js +14 -0
- package/dist/zip.d.ts +80 -0
- package/dist/zip.js +329 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sadigaxund
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @markii/bundle
|
|
2
|
+
|
|
3
|
+
Bundle (`.mkbundle`) storage and policy handling for [Mark](https://github.com/sadigaxund/markii): manifest parsing/validation, a bundle-relative path-jail, the zip storage form (via `fflate`), a Node-only directory storage form (`./fs` subpath), and a capability-restricted script view. No React, no markdown parsing.
|
|
4
|
+
|
|
5
|
+
See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a `BundleStorage` implementation when a path fails the
|
|
3
|
+
* path-jail (`normalizeBundlePath`), or — for the directory form (`./fs`)
|
|
4
|
+
* — when the real filesystem doesn't behave like the jailed logical path
|
|
5
|
+
* promised: a symlink anywhere along the path (`resolveInsideRoot`'s
|
|
6
|
+
* lstat-per-component walk), a resolved path that diverges from the
|
|
7
|
+
* requested one, or an existing file with more than one hard link
|
|
8
|
+
* (`writeExistingFileNoHardlink`, refusing to write through a path that
|
|
9
|
+
* aliases some other file — possibly outside the bundle entirely). All of
|
|
10
|
+
* these are the same class of violation from the caller's perspective:
|
|
11
|
+
* "this path does not stay inside the bundle."
|
|
12
|
+
*/
|
|
13
|
+
export declare class BundlePathError extends Error {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
constructor(path: string, reason: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Thrown by `openZipBundle` when the archive contains one or more entries
|
|
20
|
+
* whose names fail the path-jail (zip-slip candidates: `../`, absolute
|
|
21
|
+
* paths, backslash paths, drive letters). Deliberately loud — a tampered
|
|
22
|
+
* bundle is rejected outright, never silently pruned to "the safe entries."
|
|
23
|
+
*/
|
|
24
|
+
export declare class BundleZipError extends Error {
|
|
25
|
+
readonly entries: readonly string[];
|
|
26
|
+
constructor(message: string, entries: readonly string[]);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Thrown by a `ScriptView` (see `./script-view`) when the manifest does not
|
|
30
|
+
* grant the capability a call requires. Distinct from `BundlePathError`:
|
|
31
|
+
* this is a permissions failure, not a malformed-path failure, and a future
|
|
32
|
+
* script runtime should be able to tell the two apart (e.g. to show "needs
|
|
33
|
+
* permission" vs. "invalid path" to the note author).
|
|
34
|
+
*/
|
|
35
|
+
export declare class ScriptCapabilityError extends Error {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a `BundleStorage` implementation when a path fails the
|
|
3
|
+
* path-jail (`normalizeBundlePath`), or — for the directory form (`./fs`)
|
|
4
|
+
* — when the real filesystem doesn't behave like the jailed logical path
|
|
5
|
+
* promised: a symlink anywhere along the path (`resolveInsideRoot`'s
|
|
6
|
+
* lstat-per-component walk), a resolved path that diverges from the
|
|
7
|
+
* requested one, or an existing file with more than one hard link
|
|
8
|
+
* (`writeExistingFileNoHardlink`, refusing to write through a path that
|
|
9
|
+
* aliases some other file — possibly outside the bundle entirely). All of
|
|
10
|
+
* these are the same class of violation from the caller's perspective:
|
|
11
|
+
* "this path does not stay inside the bundle."
|
|
12
|
+
*/
|
|
13
|
+
export class BundlePathError extends Error {
|
|
14
|
+
path;
|
|
15
|
+
reason;
|
|
16
|
+
constructor(path, reason) {
|
|
17
|
+
super(`invalid bundle path ${JSON.stringify(path)}: ${reason}`);
|
|
18
|
+
this.name = 'BundlePathError';
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Thrown by `openZipBundle` when the archive contains one or more entries
|
|
25
|
+
* whose names fail the path-jail (zip-slip candidates: `../`, absolute
|
|
26
|
+
* paths, backslash paths, drive letters). Deliberately loud — a tampered
|
|
27
|
+
* bundle is rejected outright, never silently pruned to "the safe entries."
|
|
28
|
+
*/
|
|
29
|
+
export class BundleZipError extends Error {
|
|
30
|
+
entries;
|
|
31
|
+
constructor(message, entries) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'BundleZipError';
|
|
34
|
+
this.entries = entries;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Thrown by a `ScriptView` (see `./script-view`) when the manifest does not
|
|
39
|
+
* grant the capability a call requires. Distinct from `BundlePathError`:
|
|
40
|
+
* this is a permissions failure, not a malformed-path failure, and a future
|
|
41
|
+
* script runtime should be able to tell the two apart (e.g. to show "needs
|
|
42
|
+
* permission" vs. "invalid path" to the note author).
|
|
43
|
+
*/
|
|
44
|
+
export class ScriptCapabilityError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'ScriptCapabilityError';
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/fs.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BundleStorage } from './storage.js';
|
|
2
|
+
import { type OpenZipBundleOptions } from './zip.js';
|
|
3
|
+
/**
|
|
4
|
+
* Opens the directory form of a bundle: reads/writes go straight to disk
|
|
5
|
+
* under `rootDir`, with every operation re-verified against symlink escape
|
|
6
|
+
* (see `resolveInsideRoot`) and, on write, against hard-link aliasing (see
|
|
7
|
+
* `writeExistingFileNoHardlink`). `rootDir` must already exist.
|
|
8
|
+
*/
|
|
9
|
+
export declare function openDirBundle(rootDir: string): BundleStorage;
|
|
10
|
+
/**
|
|
11
|
+
* Scaffolds a brand-new bundle directory: `dir/note.mk.md` with `smdText` as
|
|
12
|
+
* its content, plus a default `dir/manifest.json` (no permissions granted,
|
|
13
|
+
* no packs declared). Creates `dir` if it doesn't already exist.
|
|
14
|
+
*/
|
|
15
|
+
export declare function promoteToBundle(smdText: string, dir: string, specVersion?: string): Promise<void>;
|
|
16
|
+
/** Zips up an existing bundle directory. Reuses `openDirBundle` + `exportZipBundle` from `./zip`. */
|
|
17
|
+
export declare function dirToZip(rootDir: string): Promise<Uint8Array>;
|
|
18
|
+
/**
|
|
19
|
+
* Extracts a zip bundle into `destDir` (created if missing). Inherits
|
|
20
|
+
* `openZipBundle`'s zip-slip/collision/decompression-bomb/CRC rejection —
|
|
21
|
+
* a tampered or malformed zip throws `BundleZipError` before anything is
|
|
22
|
+
* written to disk. `options` is forwarded to `openZipBundle` (see there for
|
|
23
|
+
* the decompression-size-budget knobs).
|
|
24
|
+
*/
|
|
25
|
+
export declare function zipToDir(bytes: Uint8Array, destDir: string, options?: OpenZipBundleOptions): Promise<void>;
|
package/dist/fs.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Node-only: exported solely via the "./fs" subpath (see package.json),
|
|
2
|
+
// mirroring @markii/core's "./corpus" split — a browser bundler resolving
|
|
3
|
+
// this package's main entry never has to reason about `node:fs`.
|
|
4
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, stat, writeFile, } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { BundlePathError } from './errors.js';
|
|
7
|
+
import { createDefaultManifest, CURRENT_SPEC_VERSION } from './manifest.js';
|
|
8
|
+
import { normalizeOrThrow } from './storage.js';
|
|
9
|
+
import { exportZipBundle, openZipBundle, } from './zip.js';
|
|
10
|
+
function isErrnoException(err) {
|
|
11
|
+
return err instanceof Error && 'code' in err;
|
|
12
|
+
}
|
|
13
|
+
function isEnoent(err) {
|
|
14
|
+
return isErrnoException(err) && err.code === 'ENOENT';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
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
|
|
20
|
+
* "doesn't exist yet" by the lstat walk below, deferring the actual clear
|
|
21
|
+
* error to the subsequent `mkdir`/`writeFile`/`open` call.
|
|
22
|
+
*/
|
|
23
|
+
function isEnoentLike(err) {
|
|
24
|
+
return (isErrnoException(err) && (err.code === 'ENOENT' || err.code === 'ENOTDIR'));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolves `rootAbs/relPath` (both already-safe: `relPath` has passed
|
|
28
|
+
* `normalizeOrThrow`) against the real filesystem, refusing to follow any
|
|
29
|
+
* symlink anywhere along the way — this is the storage-layer half of
|
|
30
|
+
* closing ESCAPE 1/2/3 from the independent security review: the path-jail
|
|
31
|
+
* in `./paths` only reasons about the *logical* path string, but the actual
|
|
32
|
+
* filesystem write/read *follows symlinks*, so a symlink planted anywhere
|
|
33
|
+
* inside the bundle (even one that resolves to somewhere still nominally
|
|
34
|
+
* "inside the bundle root", like `cache/pwn -> ../manifest.json`) can
|
|
35
|
+
* silently retarget an otherwise-innocuous-looking write.
|
|
36
|
+
*
|
|
37
|
+
* Two layers, in order:
|
|
38
|
+
*
|
|
39
|
+
* 1. **Primary defense — lstat every path component.** Walk `relPath`
|
|
40
|
+
* segment by segment from `rootAbs`, `lstat`-ing each one (never
|
|
41
|
+
* `stat`, which would itself follow a symlink). The moment any
|
|
42
|
+
* *existing* component is a symlink — file or directory, leaf or
|
|
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`
|
|
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
|
|
48
|
+
* before we ever ask whether its target exists.
|
|
49
|
+
* 2. **Defense in depth — resolved-path re-check.** Once the walk finds
|
|
50
|
+
* the nearest existing ancestor (the leaf itself may not exist yet,
|
|
51
|
+
* e.g. a fresh write), that ancestor is symlink-free by construction
|
|
52
|
+
* (step 1 proved it), so its `realpath` is expected to equal its literal
|
|
53
|
+
* path exactly. We compute that anyway and assert it — effectively
|
|
54
|
+
* "re-run the write policy on the resolved path": since resolved path
|
|
55
|
+
* and logical path are now provably identical, whatever policy check a
|
|
56
|
+
* caller (e.g. `ScriptView.write`, gated on `isWriteAllowed`) already
|
|
57
|
+
* ran against the logical path remains valid for what actually gets
|
|
58
|
+
* touched on disk. A mismatch here (mount points, case-insensitive
|
|
59
|
+
* filesystems, or a bug in step 1) is rejected rather than trusted.
|
|
60
|
+
*
|
|
61
|
+
* TOCTOU note: there is an unavoidable gap between this check and the
|
|
62
|
+
* `open`/`readFile`/`writeFile` call that follows it (and a second, smaller
|
|
63
|
+
* gap inside `write` between opening the file and hard-link-checking it —
|
|
64
|
+
* see there). Closing that gap completely would require doing everything
|
|
65
|
+
* through `O_NOFOLLOW`-opened file descriptors end-to-end, which Node's
|
|
66
|
+
* high-level `fs/promises` API does not expose uniformly across platforms.
|
|
67
|
+
* The threat model here is a *hostile script running inside this process*
|
|
68
|
+
* (§10) manipulating the bundle's own files between our checks — not a
|
|
69
|
+
* concurrent, privileged local-filesystem attacker racing the filesystem
|
|
70
|
+
* from *outside* the process, which is out of scope (same assumption the
|
|
71
|
+
* rest of this package makes: a bundle's own directory isn't being
|
|
72
|
+
* concurrently rewritten by an unrelated actor while we operate on it).
|
|
73
|
+
*
|
|
74
|
+
* Returns the plain (non-realpath) `rootAbs`-joined path for the caller to
|
|
75
|
+
* actually operate on, plus whether a filesystem entry currently exists at
|
|
76
|
+
* that path (so `write` can decide between "fresh file" and "must check
|
|
77
|
+
* hard-link count before touching an existing one").
|
|
78
|
+
*/
|
|
79
|
+
async function resolveInsideRoot(rootAbs, relPath) {
|
|
80
|
+
const rootReal = await realpath(rootAbs);
|
|
81
|
+
const segments = relPath.split('/');
|
|
82
|
+
let current = rootAbs;
|
|
83
|
+
let consumed = 0;
|
|
84
|
+
for (; consumed < segments.length; consumed++) {
|
|
85
|
+
const segment = segments[consumed];
|
|
86
|
+
const next = join(current, segment);
|
|
87
|
+
let st;
|
|
88
|
+
try {
|
|
89
|
+
st = await lstat(next);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (isEnoentLike(err))
|
|
93
|
+
break;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
if (st.isSymbolicLink()) {
|
|
97
|
+
throw new BundlePathError(relPath, `path component ${JSON.stringify(segments.slice(0, consumed + 1).join('/'))} is a symlink — symlinks inside a bundle are never followed`);
|
|
98
|
+
}
|
|
99
|
+
current = next;
|
|
100
|
+
}
|
|
101
|
+
const exists = consumed === segments.length;
|
|
102
|
+
// Defense-in-depth resolved-path re-check (see doc comment above).
|
|
103
|
+
const existingReal = current === rootAbs ? rootReal : await realpath(current);
|
|
104
|
+
const rel = relative(rootReal, existingReal);
|
|
105
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
106
|
+
throw new BundlePathError(relPath, 'resolved path escapes the bundle root');
|
|
107
|
+
}
|
|
108
|
+
const relNormalized = rel.length === 0 ? '' : rel.split(sep).join('/');
|
|
109
|
+
const expectedPrefix = segments.slice(0, consumed).join('/');
|
|
110
|
+
if (relNormalized !== expectedPrefix) {
|
|
111
|
+
throw new BundlePathError(relPath, 'resolved path does not match the requested bundle-relative path (possible symlink or mount-point redirection)');
|
|
112
|
+
}
|
|
113
|
+
return { target: join(rootAbs, relPath), exists };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Writes `data` to an already symlink-checked `target` that is known to
|
|
117
|
+
* exist, refusing to write through a hard link (ESCAPE 3: `st_nlink > 1`
|
|
118
|
+
* means some other path — possibly outside the bundle entirely, e.g.
|
|
119
|
+
* `ln b.mkbundle/../victim.txt b.mkbundle/cache/hard` — refers to the exact same
|
|
120
|
+
* inode, so writing here would also silently modify that other path).
|
|
121
|
+
*
|
|
122
|
+
* Opens the file first *without* truncating (`'r+'`), `fstat`s the open
|
|
123
|
+
* file descriptor (not a separate `stat(path)` call — checking the fd
|
|
124
|
+
* avoids a second TOCTOU window between "check nlink" and "open the file
|
|
125
|
+
* we checked"), and only truncates + writes once the link count is
|
|
126
|
+
* confirmed to be exactly 1.
|
|
127
|
+
*/
|
|
128
|
+
async function writeExistingFileNoHardlink(target, relPath, data) {
|
|
129
|
+
const handle = await open(target, 'r+');
|
|
130
|
+
try {
|
|
131
|
+
const st = await handle.stat();
|
|
132
|
+
if (st.nlink > 1) {
|
|
133
|
+
throw new BundlePathError(relPath, `refusing to write through a hard-linked file (${st.nlink} links) — writing would also modify the other link target(s)`);
|
|
134
|
+
}
|
|
135
|
+
await handle.truncate(0);
|
|
136
|
+
if (data.length > 0) {
|
|
137
|
+
await handle.write(data, 0, data.length, 0);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await handle.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Opens the directory form of a bundle: reads/writes go straight to disk
|
|
146
|
+
* under `rootDir`, with every operation re-verified against symlink escape
|
|
147
|
+
* (see `resolveInsideRoot`) and, on write, against hard-link aliasing (see
|
|
148
|
+
* `writeExistingFileNoHardlink`). `rootDir` must already exist.
|
|
149
|
+
*/
|
|
150
|
+
export function openDirBundle(rootDir) {
|
|
151
|
+
const rootAbs = resolve(rootDir);
|
|
152
|
+
return {
|
|
153
|
+
async read(path) {
|
|
154
|
+
const relPath = normalizeOrThrow(path);
|
|
155
|
+
const { target } = await resolveInsideRoot(rootAbs, relPath);
|
|
156
|
+
try {
|
|
157
|
+
const buf = await readFile(target);
|
|
158
|
+
// `readFile` resolves a Node `Buffer` (a `Uint8Array` subclass).
|
|
159
|
+
// Re-view it as a plain `Uint8Array` so callers get the type this
|
|
160
|
+
// interface promises, not an implementation detail that happens to
|
|
161
|
+
// compare unequal to a literal `Uint8Array` under deep-equality.
|
|
162
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
if (isEnoent(err))
|
|
166
|
+
return undefined;
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
async write(path, data) {
|
|
171
|
+
const relPath = normalizeOrThrow(path);
|
|
172
|
+
const { target, exists } = await resolveInsideRoot(rootAbs, relPath);
|
|
173
|
+
if (exists) {
|
|
174
|
+
await writeExistingFileNoHardlink(target, relPath, data);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await mkdir(dirname(target), { recursive: true });
|
|
178
|
+
await writeFile(target, data);
|
|
179
|
+
},
|
|
180
|
+
async list() {
|
|
181
|
+
const results = [];
|
|
182
|
+
const walk = async (dirAbs, prefix) => {
|
|
183
|
+
const entries = await readdir(dirAbs, { withFileTypes: true });
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
// Never follow symlinked entries during enumeration: a symlink
|
|
186
|
+
// planted inside the bundle dir could otherwise leak the
|
|
187
|
+
// directory structure (or contents) of wherever it points.
|
|
188
|
+
if (entry.isSymbolicLink())
|
|
189
|
+
continue;
|
|
190
|
+
const entryRelPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
191
|
+
if (entry.isDirectory()) {
|
|
192
|
+
await walk(join(dirAbs, entry.name), entryRelPath);
|
|
193
|
+
}
|
|
194
|
+
else if (entry.isFile()) {
|
|
195
|
+
results.push(entryRelPath);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
await walk(rootAbs, '');
|
|
200
|
+
return results.sort();
|
|
201
|
+
},
|
|
202
|
+
async exists(path) {
|
|
203
|
+
const relPath = normalizeOrThrow(path);
|
|
204
|
+
const { target } = await resolveInsideRoot(rootAbs, relPath);
|
|
205
|
+
try {
|
|
206
|
+
const info = await stat(target);
|
|
207
|
+
return info.isFile();
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (isEnoent(err))
|
|
211
|
+
return false;
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Scaffolds a brand-new bundle directory: `dir/note.mk.md` with `smdText` as
|
|
219
|
+
* its content, plus a default `dir/manifest.json` (no permissions granted,
|
|
220
|
+
* no packs declared). Creates `dir` if it doesn't already exist.
|
|
221
|
+
*/
|
|
222
|
+
export async function promoteToBundle(smdText, dir, specVersion = CURRENT_SPEC_VERSION) {
|
|
223
|
+
const dirAbs = resolve(dir);
|
|
224
|
+
await mkdir(dirAbs, { recursive: true });
|
|
225
|
+
await writeFile(join(dirAbs, 'note.mk.md'), smdText, 'utf8');
|
|
226
|
+
const manifest = createDefaultManifest(specVersion);
|
|
227
|
+
await writeFile(join(dirAbs, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
228
|
+
}
|
|
229
|
+
/** Zips up an existing bundle directory. Reuses `openDirBundle` + `exportZipBundle` from `./zip`. */
|
|
230
|
+
export async function dirToZip(rootDir) {
|
|
231
|
+
return exportZipBundle(openDirBundle(rootDir));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Extracts a zip bundle into `destDir` (created if missing). Inherits
|
|
235
|
+
* `openZipBundle`'s zip-slip/collision/decompression-bomb/CRC rejection —
|
|
236
|
+
* a tampered or malformed zip throws `BundleZipError` before anything is
|
|
237
|
+
* written to disk. `options` is forwarded to `openZipBundle` (see there for
|
|
238
|
+
* the decompression-size-budget knobs).
|
|
239
|
+
*/
|
|
240
|
+
export async function zipToDir(bytes, destDir, options) {
|
|
241
|
+
const src = openZipBundle(bytes, options); // throws BundleZipError on unsafe/oversized/corrupt entries
|
|
242
|
+
await mkdir(resolve(destDir), { recursive: true });
|
|
243
|
+
const dest = openDirBundle(destDir);
|
|
244
|
+
for (const path of await src.list()) {
|
|
245
|
+
const data = await src.read(path);
|
|
246
|
+
if (data !== undefined)
|
|
247
|
+
await dest.write(path, data);
|
|
248
|
+
}
|
|
249
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { BundleManifest, BundlePermissions, ManifestParseResult, } from './manifest.js';
|
|
2
|
+
export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './manifest.js';
|
|
3
|
+
export type { BundleFsGrant, BundleWritePolicy, NormalizePathResult, } from './paths.js';
|
|
4
|
+
export { isWriteAllowed, normalizeBundlePath } from './paths.js';
|
|
5
|
+
export type { BundleStorage } from './storage.js';
|
|
6
|
+
export { normalizeOrThrow } from './storage.js';
|
|
7
|
+
export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
|
|
8
|
+
export type { OpenZipBundleOptions } from './zip.js';
|
|
9
|
+
export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
|
|
10
|
+
export type { ScriptView } from './script-view.js';
|
|
11
|
+
export { createScriptView, grantAllDeclaredPermissions, } from './script-view.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// This is @markii/bundle's browser-safe entry point: manifest handling, the
|
|
2
|
+
// path-jail, the zip storage form (fflate has no Node dependency), and the
|
|
3
|
+
// script capability view. The directory storage form touches `node:fs` and
|
|
4
|
+
// lives at the `@markii/bundle/fs` subpath instead (mirroring @markii/core's
|
|
5
|
+
// `./corpus` split), so a browser bundler consuming this package's main
|
|
6
|
+
// entry never has to reason about Node builtins reachable from it.
|
|
7
|
+
export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './manifest.js';
|
|
8
|
+
export { isWriteAllowed, normalizeBundlePath } from './paths.js';
|
|
9
|
+
export { normalizeOrThrow } from './storage.js';
|
|
10
|
+
export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
|
|
11
|
+
export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
|
|
12
|
+
export { createScriptView, grantAllDeclaredPermissions, } from './script-view.js';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BundleFsGrant } from './paths.js';
|
|
2
|
+
/**
|
|
3
|
+
* `manifest.json`'s contract (spec §9–§11). `mark` is the only required
|
|
4
|
+
* field. The index signature keeps unrecognized top-level keys typed as
|
|
5
|
+
* `unknown` rather than dropped — `parseManifest` preserves them verbatim
|
|
6
|
+
* (see the "forward compatibility" note there) so a manifest written by a
|
|
7
|
+
* newer spec version round-trips through an older implementation intact.
|
|
8
|
+
*/
|
|
9
|
+
export interface BundleManifest {
|
|
10
|
+
/** Spec semver this bundle was authored against, e.g. `"0.1.0"`. */
|
|
11
|
+
mark: string;
|
|
12
|
+
permissions?: BundlePermissions;
|
|
13
|
+
uses?: string[];
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface BundlePermissions {
|
|
17
|
+
net?: {
|
|
18
|
+
/** Bare hostnames (no scheme/port/path/wildcard) allowed for GET. */
|
|
19
|
+
get?: string[];
|
|
20
|
+
/** Bare hostnames allowed for POST. */
|
|
21
|
+
post?: string[];
|
|
22
|
+
};
|
|
23
|
+
/** Bundle-filesystem grants; see `isWriteAllowed` in `./paths`. */
|
|
24
|
+
bundle?: BundleFsGrant[];
|
|
25
|
+
}
|
|
26
|
+
export type ManifestParseResult = {
|
|
27
|
+
ok: true;
|
|
28
|
+
manifest: BundleManifest;
|
|
29
|
+
warnings: string[];
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
errors: string[];
|
|
33
|
+
};
|
|
34
|
+
/** The current spec version this package's default manifests declare. */
|
|
35
|
+
export declare const CURRENT_SPEC_VERSION = "0.1.0";
|
|
36
|
+
/**
|
|
37
|
+
* Hand-rolled `manifest.json` validation (no schema library — see CLAUDE.md
|
|
38
|
+
* dependency policy). Never throws: malformed JSON, a non-object root, or
|
|
39
|
+
* any other malformed input all come back as `{ ok: false, errors }`.
|
|
40
|
+
*
|
|
41
|
+
* Unknown top-level keys are forward-compatible: they produce warnings, not
|
|
42
|
+
* errors, and are preserved on the returned `manifest` object untouched, so
|
|
43
|
+
* a manifest field introduced by a future spec version doesn't break an
|
|
44
|
+
* older implementation and isn't silently discarded if that manifest is
|
|
45
|
+
* later re-serialized.
|
|
46
|
+
*
|
|
47
|
+
* Judgment call: this forward-compatibility guarantee is *top-level only*,
|
|
48
|
+
* matching the task contract literally. An unrecognized key nested inside
|
|
49
|
+
* `permissions` (e.g. a future `permissions.fs`) is currently dropped
|
|
50
|
+
* rather than preserved, since `permissions` itself is a known, normalized
|
|
51
|
+
* key. Extending preservation to arbitrary nesting depth would be a
|
|
52
|
+
* reasonable follow-up but wasn't asked for here.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseManifest(json: string): ManifestParseResult;
|
|
55
|
+
/** A minimal, valid manifest for a freshly promoted bundle: no permissions granted, no packs declared. */
|
|
56
|
+
export declare function createDefaultManifest(specVersion?: string): BundleManifest;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** The current spec version this package's default manifests declare. */
|
|
2
|
+
export const CURRENT_SPEC_VERSION = '0.1.0';
|
|
3
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set(['mark', 'permissions', 'uses']);
|
|
4
|
+
const KNOWN_FS_GRANTS = new Set(['read', 'write:cache/']);
|
|
5
|
+
// Simplified but structurally correct semver: MAJOR.MINOR.PATCH with
|
|
6
|
+
// optional prerelease/build metadata. Good enough for a "shape" check —
|
|
7
|
+
// this package does not need to compare or range-match versions.
|
|
8
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
9
|
+
// Bare hostname only: letters/digits/hyphens in dot-separated labels, no
|
|
10
|
+
// scheme (`https://`), no port (`:8080`), no path (`/x`), no wildcard (`*`).
|
|
11
|
+
const HOSTNAME_RE = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
|
|
12
|
+
function isStringArray(value) {
|
|
13
|
+
return (Array.isArray(value) && value.every((item) => typeof item === 'string'));
|
|
14
|
+
}
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Hand-rolled `manifest.json` validation (no schema library — see CLAUDE.md
|
|
20
|
+
* dependency policy). Never throws: malformed JSON, a non-object root, or
|
|
21
|
+
* any other malformed input all come back as `{ ok: false, errors }`.
|
|
22
|
+
*
|
|
23
|
+
* Unknown top-level keys are forward-compatible: they produce warnings, not
|
|
24
|
+
* errors, and are preserved on the returned `manifest` object untouched, so
|
|
25
|
+
* a manifest field introduced by a future spec version doesn't break an
|
|
26
|
+
* older implementation and isn't silently discarded if that manifest is
|
|
27
|
+
* later re-serialized.
|
|
28
|
+
*
|
|
29
|
+
* Judgment call: this forward-compatibility guarantee is *top-level only*,
|
|
30
|
+
* matching the task contract literally. An unrecognized key nested inside
|
|
31
|
+
* `permissions` (e.g. a future `permissions.fs`) is currently dropped
|
|
32
|
+
* rather than preserved, since `permissions` itself is a known, normalized
|
|
33
|
+
* key. Extending preservation to arbitrary nesting depth would be a
|
|
34
|
+
* reasonable follow-up but wasn't asked for here.
|
|
35
|
+
*/
|
|
36
|
+
export function parseManifest(json) {
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(json);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
errors: [
|
|
45
|
+
`malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(raw)) {
|
|
50
|
+
return { ok: false, errors: ['manifest must be a JSON object'] };
|
|
51
|
+
}
|
|
52
|
+
const obj = raw;
|
|
53
|
+
const errors = [];
|
|
54
|
+
const warnings = [];
|
|
55
|
+
// --- mark (required) ---
|
|
56
|
+
const markRaw = obj.mark;
|
|
57
|
+
if (typeof markRaw !== 'string') {
|
|
58
|
+
errors.push('"mark" is required and must be a string');
|
|
59
|
+
}
|
|
60
|
+
else if (!SEMVER_RE.test(markRaw)) {
|
|
61
|
+
errors.push(`"mark" must be a semver string (got ${JSON.stringify(markRaw)})`);
|
|
62
|
+
}
|
|
63
|
+
// --- permissions (optional) ---
|
|
64
|
+
let permissions;
|
|
65
|
+
if (obj.permissions !== undefined) {
|
|
66
|
+
if (!isPlainObject(obj.permissions)) {
|
|
67
|
+
errors.push('"permissions" must be an object');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const permsObj = obj.permissions;
|
|
71
|
+
const perms = {};
|
|
72
|
+
if (permsObj.net !== undefined) {
|
|
73
|
+
if (!isPlainObject(permsObj.net)) {
|
|
74
|
+
errors.push('"permissions.net" must be an object');
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const netObj = permsObj.net;
|
|
78
|
+
const net = {};
|
|
79
|
+
for (const method of ['get', 'post']) {
|
|
80
|
+
const hosts = netObj[method];
|
|
81
|
+
if (hosts === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
if (!isStringArray(hosts)) {
|
|
84
|
+
errors.push(`"permissions.net.${method}" must be an array of strings`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const badHosts = hosts.filter((host) => !HOSTNAME_RE.test(host));
|
|
88
|
+
if (badHosts.length > 0) {
|
|
89
|
+
errors.push(`"permissions.net.${method}" must list bare hostnames only ` +
|
|
90
|
+
`(no scheme, port, path, or wildcard) — invalid: ${badHosts.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
net[method] = hosts;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
perms.net = net;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (permsObj.bundle !== undefined) {
|
|
100
|
+
if (!isStringArray(permsObj.bundle)) {
|
|
101
|
+
errors.push('"permissions.bundle" must be an array of strings');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const badGrants = permsObj.bundle.filter((grant) => !KNOWN_FS_GRANTS.has(grant));
|
|
105
|
+
if (badGrants.length > 0) {
|
|
106
|
+
errors.push(`"permissions.bundle" contains invalid grant(s): ${badGrants.join(', ')} ` +
|
|
107
|
+
`(expected "read" or "write:cache/")`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
perms.bundle = permsObj.bundle;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
permissions = perms;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// --- uses (optional) ---
|
|
118
|
+
let uses;
|
|
119
|
+
if (obj.uses !== undefined) {
|
|
120
|
+
if (!isStringArray(obj.uses)) {
|
|
121
|
+
errors.push('"uses" must be an array of strings');
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
uses = obj.uses;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// --- unknown top-level keys: forward-compat warning, not an error ---
|
|
128
|
+
for (const key of Object.keys(obj)) {
|
|
129
|
+
if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
|
|
130
|
+
warnings.push(`unknown manifest key "${key}" (ignored by this implementation)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (errors.length > 0) {
|
|
134
|
+
return { ok: false, errors };
|
|
135
|
+
}
|
|
136
|
+
const manifest = { ...obj, mark: markRaw };
|
|
137
|
+
if (permissions !== undefined)
|
|
138
|
+
manifest.permissions = permissions;
|
|
139
|
+
if (uses !== undefined)
|
|
140
|
+
manifest.uses = uses;
|
|
141
|
+
return { ok: true, manifest, warnings };
|
|
142
|
+
}
|
|
143
|
+
/** A minimal, valid manifest for a freshly promoted bundle: no permissions granted, no packs declared. */
|
|
144
|
+
export function createDefaultManifest(specVersion = CURRENT_SPEC_VERSION) {
|
|
145
|
+
return { mark: specVersion };
|
|
146
|
+
}
|