@pooriaarab/foxden 0.0.0-stage → 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,47 @@
1
+ // The messages between the page that opens a den (the host) and the
2
+ // sandbox page. Both sides parse what they receive with these functions and
3
+ // ignore anything that does not match exactly.
4
+ export const PROTOCOL_VERSION = 1;
5
+ const ERROR_KINDS = ["python", "timeout", "crashed", "storage"];
6
+ const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
7
+ const isCount = (x) => Number.isSafeInteger(x) && x >= 0;
8
+ const isTime = (x) => typeof x === "number" && Number.isFinite(x) && x >= 0;
9
+ const isFile = (x, allowNull) => Array.isArray(x) && x.length === 2 && typeof x[0] === "string" && (x[1] instanceof Uint8Array || (allowNull && x[1] === null));
10
+ const isFiles = (x, allowNull) => Array.isArray(x) && x.every((f) => isFile(f, allowNull));
11
+ function isReply(r) {
12
+ if (!isObj(r))
13
+ return false;
14
+ const error = r.error;
15
+ const errorOk = error === null || (isObj(error) && ERROR_KINDS.includes(error.kind) && typeof error.message === "string");
16
+ return (isCount(r.id) &&
17
+ typeof r.stdout === "string" &&
18
+ typeof r.stderr === "string" &&
19
+ (r.result === null || typeof r.result === "string") &&
20
+ errorOk &&
21
+ typeof r.truncated === "boolean" &&
22
+ isFiles(r.files, true) &&
23
+ isTime(r.durationMs));
24
+ }
25
+ /** Parse a message from the sandbox page. Returns null for anything unexpected. */
26
+ export function parseDenMessage(data) {
27
+ if (!isObj(data) || data.v !== PROTOCOL_VERSION)
28
+ return null;
29
+ switch (data.t) {
30
+ case "ready":
31
+ return typeof data.origin === "string" && typeof data.extensionApi === "boolean" && isTime(data.loadMs) ? data : null;
32
+ case "load-error":
33
+ return typeof data.message === "string" ? data : null;
34
+ case "reply":
35
+ return isReply(data.reply) ? data : null;
36
+ default:
37
+ return null;
38
+ }
39
+ }
40
+ /** Parse a message from the host. Returns null for anything unexpected. */
41
+ export function parseHostMessage(data) {
42
+ if (!isObj(data) || data.v !== PROTOCOL_VERSION || data.t !== "run" || !isObj(data.request))
43
+ return null;
44
+ const r = data.request;
45
+ const ok = isCount(r.id) && typeof r.code === "string" && isFiles(r.files, false) && isTime(r.timeoutMs) && isCount(r.maxOutputBytes);
46
+ return ok ? data : null;
47
+ }
@@ -0,0 +1,16 @@
1
+ export interface ShellResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ /** 0 = success, 1 = a file or match problem, 2 = bad usage, 127 = unknown command. */
5
+ code: number;
6
+ /** True when stdout or stderr was cut at maxOutputBytes. */
7
+ truncated: boolean;
8
+ }
9
+ export interface ShellOptions {
10
+ /** The most characters of stdout (and of stderr) to keep. Default 1 MiB. */
11
+ maxOutputBytes?: number;
12
+ }
13
+ type Files = ReadonlyMap<string, Uint8Array>;
14
+ /** Run one command line over the den files. It never throws for bad input. */
15
+ export declare function runShell(line: string, files: Files, opts?: ShellOptions): ShellResult;
16
+ export {};
package/dist/shell.js ADDED
@@ -0,0 +1,208 @@
1
+ // A small shell-like command language over den files: ls, cat, head, wc,
2
+ // grep and echo, joined by |. It is not a real shell. It never runs user
3
+ // text as code, and grep matches plain text only, so a pattern cannot hang
4
+ // the page that calls it.
5
+ import { normalizePath } from "./paths.js";
6
+ class Usage extends Error {
7
+ }
8
+ const UNSUPPORTED = /[;&<>$`*?(){}]/;
9
+ const decode = (bytes) => new TextDecoder().decode(bytes);
10
+ function tokenize(line) {
11
+ const commands = [[]];
12
+ let word = null;
13
+ let quote = null;
14
+ for (const ch of line) {
15
+ if (quote) {
16
+ if (ch === quote)
17
+ quote = null;
18
+ else
19
+ word += ch;
20
+ }
21
+ else if (ch === "'" || ch === '"') {
22
+ quote = ch;
23
+ word ??= "";
24
+ }
25
+ else if (/\s/.test(ch) || ch === "|") {
26
+ if (word !== null)
27
+ commands.at(-1).push(word);
28
+ word = null;
29
+ if (ch === "|")
30
+ commands.push([]);
31
+ }
32
+ else if (UNSUPPORTED.test(ch)) {
33
+ throw new Usage(`foxden sh: ${ch} is not supported. foxden sh has only ls, cat, head, wc, grep, echo and |. Quote special characters.`);
34
+ }
35
+ else {
36
+ word = (word ?? "") + ch;
37
+ }
38
+ }
39
+ if (quote)
40
+ throw new Usage("foxden sh: a quote is not closed.");
41
+ if (word !== null)
42
+ commands.at(-1).push(word);
43
+ if (commands.some((c) => c.length === 0))
44
+ throw new Usage("foxden sh: an empty command is not supported.");
45
+ return commands;
46
+ }
47
+ // Split leading options from the other arguments. `spec` maps each allowed
48
+ // flag to true when it takes a value.
49
+ function options(name, args, spec) {
50
+ const flags = new Map();
51
+ let i = 0;
52
+ while (i < args.length && /^-./.test(args[i])) {
53
+ const flag = args[i];
54
+ if (!(flag in spec))
55
+ throw new Usage(`${name}: unknown option ${flag}`);
56
+ if (spec[flag]) {
57
+ const value = args[i + 1];
58
+ if (value === undefined)
59
+ throw new Usage(`${name}: ${flag} needs a value`);
60
+ flags.set(flag, value);
61
+ i += 2;
62
+ }
63
+ else {
64
+ flags.set(flag, "");
65
+ i += 1;
66
+ }
67
+ }
68
+ return { flags, rest: args.slice(i) };
69
+ }
70
+ // Read each named file, or stdin when there are none. Missing and bad paths
71
+ // go to stderr and set code 1; the other files still count.
72
+ function inputs(name, paths, files, stdin) {
73
+ if (paths.length === 0)
74
+ return { texts: [["", stdin]], err: "" };
75
+ const texts = [];
76
+ let err = "";
77
+ for (const raw of paths) {
78
+ try {
79
+ const path = normalizePath(raw);
80
+ const body = files.get(path);
81
+ if (body)
82
+ texts.push([path, decode(body)]);
83
+ else
84
+ err += `${name}: ${path}: No such file\n`;
85
+ }
86
+ catch (error) {
87
+ err += `${name}: ${error.message}\n`;
88
+ }
89
+ }
90
+ return { texts, err };
91
+ }
92
+ const lines = (text) => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
93
+ function ls(args, files) {
94
+ if (args.length === 0)
95
+ return { out: "drop/\nout/\nwork/\n", err: "", code: 0 };
96
+ let out = "";
97
+ let err = "";
98
+ for (const raw of args) {
99
+ if (files.has(raw)) {
100
+ out += `${raw}\n`;
101
+ continue;
102
+ }
103
+ let folder;
104
+ try {
105
+ folder = normalizePath(`${raw.replace(/\/+$/, "")}/_`).slice(0, -1);
106
+ }
107
+ catch (error) {
108
+ err += `ls: ${error.message}\n`;
109
+ continue;
110
+ }
111
+ const names = new Set();
112
+ for (const path of files.keys()) {
113
+ if (!path.startsWith(folder))
114
+ continue;
115
+ const [first, ...more] = path.slice(folder.length).split("/");
116
+ names.add(more.length > 0 ? `${first}/` : first);
117
+ }
118
+ out += [...names].toSorted().map((n) => `${n}\n`).join("");
119
+ }
120
+ return { out, err, code: err ? 1 : 0 };
121
+ }
122
+ function head(args, files, stdin) {
123
+ const { flags, rest } = options("head", args, { "-n": true });
124
+ const count = Number(flags.get("-n") ?? "10");
125
+ if (!Number.isInteger(count) || count < 0)
126
+ throw new Usage(`head: ${flags.get("-n")} is not a line count`);
127
+ const { texts, err } = inputs("head", rest, files, stdin);
128
+ const out = texts.map(([, t]) => lines(t).slice(0, count).map((l) => `${l}\n`).join("")).join("");
129
+ return { out, err, code: err ? 1 : 0 };
130
+ }
131
+ function wc(args, files, stdin) {
132
+ const { flags, rest } = options("wc", args, { "-l": false, "-w": false, "-c": false });
133
+ const { texts, err } = inputs("wc", rest, files, stdin);
134
+ const out = texts
135
+ .map(([path, text]) => {
136
+ const counts = { "-l": lines(text).length, "-w": text.split(/\s+/).filter(Boolean).length, "-c": new TextEncoder().encode(text).length };
137
+ const shown = flags.size > 0 ? [...flags.keys()].map((f) => counts[f]) : Object.values(counts);
138
+ return `${[...shown, ...(path ? [path] : [])].join(" ")}\n`;
139
+ })
140
+ .join("");
141
+ return { out, err, code: err ? 1 : 0 };
142
+ }
143
+ function grep(args, files, stdin) {
144
+ const { flags, rest } = options("grep", args, { "-i": false, "-v": false, "-c": false, "-n": false });
145
+ const [pattern, ...paths] = rest;
146
+ if (pattern === undefined)
147
+ throw new Usage("grep: a pattern is needed");
148
+ const fold = (s) => (flags.has("-i") ? s.toLowerCase() : s);
149
+ const needle = fold(pattern);
150
+ const { texts, err } = inputs("grep", paths, files, stdin);
151
+ let out = "";
152
+ let matched = 0;
153
+ for (const [path, text] of texts) {
154
+ const prefix = texts.length > 1 ? `${path}:` : "";
155
+ const hits = lines(text)
156
+ .map((line, i) => ({ line, n: i + 1 }))
157
+ .filter(({ line }) => fold(line).includes(needle) !== flags.has("-v"));
158
+ matched += hits.length;
159
+ if (flags.has("-c"))
160
+ out += `${prefix}${hits.length}\n`;
161
+ else
162
+ out += hits.map(({ line, n }) => `${prefix}${flags.has("-n") ? `${n}:` : ""}${line}\n`).join("");
163
+ }
164
+ return { out, err, code: err || matched === 0 ? 1 : 0 };
165
+ }
166
+ function step(command, files, stdin) {
167
+ const [name, ...args] = command;
168
+ switch (name) {
169
+ case "ls":
170
+ return ls(args, files);
171
+ case "cat": {
172
+ const { texts, err } = inputs("cat", args, files, stdin);
173
+ return { out: texts.map(([, t]) => t).join(""), err, code: err ? 1 : 0 };
174
+ }
175
+ case "head":
176
+ return head(args, files, stdin);
177
+ case "wc":
178
+ return wc(args, files, stdin);
179
+ case "grep":
180
+ return grep(args, files, stdin);
181
+ case "echo":
182
+ return { out: `${args.join(" ")}\n`, err: "", code: 0 };
183
+ default:
184
+ return { out: "", err: `foxden sh: ${name}: command not found\n`, code: 127 };
185
+ }
186
+ }
187
+ /** Run one command line over the den files. It never throws for bad input. */
188
+ export function runShell(line, files, opts = {}) {
189
+ const max = opts.maxOutputBytes ?? 1024 * 1024;
190
+ let stdout = "";
191
+ let stderr = "";
192
+ let code = 0;
193
+ try {
194
+ for (const command of tokenize(line)) {
195
+ const result = step(command, files, stdout);
196
+ stdout = result.out;
197
+ stderr += result.err;
198
+ code = result.code;
199
+ }
200
+ }
201
+ catch (error) {
202
+ if (!(error instanceof Usage))
203
+ throw error;
204
+ return { stdout: "", stderr: `${error.message}\n`, code: 2, truncated: false };
205
+ }
206
+ const truncated = stdout.length > max || stderr.length > max;
207
+ return { stdout: stdout.slice(0, max), stderr: stderr.slice(0, max), code, truncated };
208
+ }
@@ -0,0 +1,7 @@
1
+ /** Pack the files of a den into snapshot bytes. */
2
+ export declare function encodeSnapshot(name: string, files: ReadonlyMap<string, Uint8Array>): Promise<Uint8Array>;
3
+ /** Read snapshot bytes back into a name and a file map, or throw SnapshotError. */
4
+ export declare function decodeSnapshot(bytes: Uint8Array): Promise<{
5
+ name: string;
6
+ files: Map<string, Uint8Array>;
7
+ }>;
@@ -0,0 +1,99 @@
1
+ // The snapshot file format. A snapshot holds the files of one den.
2
+ //
3
+ // bytes 0-7 "FDNSNAP1"
4
+ // bytes 8-11 header length N, unsigned 32-bit big-endian
5
+ // next N bytes header, UTF-8 JSON: { v: 1, name, files: [{ path, size, sha256 }] }
6
+ // the rest the file bodies, in header order, with no gaps
7
+ //
8
+ // decodeSnapshot checks every length before it reads, checks each SHA-256,
9
+ // and runs each path through normalizePath. Python globals are not saved.
10
+ import { PathError, SnapshotError } from "./errors.js";
11
+ import { normalizePath } from "./paths.js";
12
+ const MAGIC = "FDNSNAP1";
13
+ const MAX_HEADER = 16 * 1024 * 1024;
14
+ async function sha256(bytes) {
15
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
16
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
17
+ }
18
+ /** Pack the files of a den into snapshot bytes. */
19
+ export async function encodeSnapshot(name, files) {
20
+ const entries = [];
21
+ for (const [path, body] of files)
22
+ entries.push({ path: normalizePath(path), size: body.length, sha256: await sha256(body) });
23
+ const header = new TextEncoder().encode(JSON.stringify({ v: 1, name, files: entries }));
24
+ const total = entries.reduce((sum, e) => sum + e.size, 12 + header.length);
25
+ const out = new Uint8Array(total);
26
+ out.set(new TextEncoder().encode(MAGIC), 0);
27
+ new DataView(out.buffer).setUint32(8, header.length);
28
+ out.set(header, 12);
29
+ let at = 12 + header.length;
30
+ for (const body of files.values()) {
31
+ out.set(body, at);
32
+ at += body.length;
33
+ }
34
+ return out;
35
+ }
36
+ function parseHeader(text) {
37
+ let header;
38
+ try {
39
+ header = JSON.parse(text);
40
+ }
41
+ catch {
42
+ throw new SnapshotError("The snapshot header is not valid JSON.");
43
+ }
44
+ if (typeof header !== "object" || header === null)
45
+ throw new SnapshotError("The snapshot header is not an object.");
46
+ const { v, name, files } = header;
47
+ if (v !== 1)
48
+ throw new SnapshotError(`The snapshot version is ${String(v)}. This foxden reads version 1.`);
49
+ if (typeof name !== "string" || !Array.isArray(files))
50
+ throw new SnapshotError("The snapshot header has no name or no file list.");
51
+ for (const e of files) {
52
+ const { path, size, sha256: hash } = (e ?? {});
53
+ if (typeof path !== "string" || typeof hash !== "string" || !Number.isSafeInteger(size) || size < 0) {
54
+ throw new SnapshotError("A snapshot file entry has a bad path, size or hash.");
55
+ }
56
+ }
57
+ return { name, files: files };
58
+ }
59
+ /** Read snapshot bytes back into a name and a file map, or throw SnapshotError. */
60
+ export async function decodeSnapshot(bytes) {
61
+ if (!(bytes instanceof Uint8Array) || bytes.length < 12)
62
+ throw new SnapshotError("The bytes are too short to be a snapshot.");
63
+ if (new TextDecoder().decode(bytes.subarray(0, 8)) !== MAGIC)
64
+ throw new SnapshotError("The bytes are not a foxden snapshot.");
65
+ const headerLength = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(8);
66
+ if (headerLength > MAX_HEADER || 12 + headerLength > bytes.length)
67
+ throw new SnapshotError("The snapshot header length is larger than the snapshot.");
68
+ let text;
69
+ try {
70
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(12, 12 + headerLength));
71
+ }
72
+ catch {
73
+ throw new SnapshotError("The snapshot header is not valid UTF-8.");
74
+ }
75
+ const header = parseHeader(text);
76
+ const files = new Map();
77
+ let at = 12 + headerLength;
78
+ for (const entry of header.files) {
79
+ let path;
80
+ try {
81
+ path = normalizePath(entry.path);
82
+ }
83
+ catch (error) {
84
+ throw new SnapshotError(`The snapshot holds a bad path: ${error.message}`);
85
+ }
86
+ if (files.has(path))
87
+ throw new SnapshotError(`The snapshot lists ${path} two times.`);
88
+ if (entry.size > bytes.length - at)
89
+ throw new SnapshotError(`The snapshot is cut short inside ${path}.`);
90
+ const body = bytes.slice(at, at + entry.size);
91
+ at += entry.size;
92
+ if ((await sha256(body)) !== entry.sha256)
93
+ throw new SnapshotError(`The bytes of ${path} do not match their SHA-256 hash.`);
94
+ files.set(path, body);
95
+ }
96
+ if (at !== bytes.length)
97
+ throw new SnapshotError(`The snapshot has ${bytes.length - at} extra bytes after the last file.`);
98
+ return { name: header.name, files };
99
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@pooriaarab/foxden",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A sandbox in a browser tab that runs Python and shell with no network.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Pooria Arab",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pooriaarab/foxden.git"
11
+ },
12
+ "homepage": "https://github.com/pooriaarab/foxden#readme",
13
+ "packageManager": "pnpm@11.10.0",
14
+ "engines": {
15
+ "node": ">=24"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json && node scripts/build-den.mjs",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "oxlint --deny-warnings",
30
+ "test": "vitest run --passWithNoTests",
31
+ "ci:local": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm build:ext && pnpm lint:ext",
32
+ "build:ext": "node scripts/build-ext.mjs",
33
+ "lint:ext": "node scripts/lint-ext.mjs",
34
+ "e2e": "pnpm build && pnpm build:ext && node e2e/run.mjs",
35
+ "e2e:web": "pnpm build && pnpm build:ext && node e2e/web.mjs"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^24.19.1",
39
+ "oxlint": "^1.87.0",
40
+ "typescript": "^7.0.2",
41
+ "vitest": "^5.0.3",
42
+ "create-foxkit": "^0.1.0",
43
+ "esbuild": "^0.28.2",
44
+ "web-ext": "^10.7.0"
45
+ },
46
+ "dependencies": {
47
+ "pyodide": "314.0.7"
48
+ }
49
+ }