poe-code 4.0.11 → 4.0.13

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,72 @@
1
+ import { type PathLike } from "node:fs";
2
+ import * as nodeFsPromises from "node:fs/promises";
3
+ type FsOperationName = "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile";
4
+ type FsPassthroughName = Exclude<FsOperationName, "lstat" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "stat">;
5
+ declare const REFUSED_OPTION_REASONS: {
6
+ readonly signal: "the sandbox has no AbortController, and cancelling a run is the host's to request rather than the script's";
7
+ readonly throwIfNoEntry: "only node's synchronous stat reads it and fs/promises rejects a missing path whatever it says, so catch the ENOENT rejection instead";
8
+ readonly filter: "a closure is dropped from the digest that identifies a host call across a snapshot, so a resumed run could reconcile against a copy that took a different set of files, and under a root it would read the rewritten host paths rather than the ones the script wrote — walk the tree with readdir and copy the entries you want instead";
9
+ };
10
+ type FsOptionSurface = {
11
+ readonly argument: number;
12
+ readonly honoured: readonly string[];
13
+ readonly refused: readonly (keyof typeof REFUSED_OPTION_REASONS)[];
14
+ };
15
+ export declare const FS_OPTION_SURFACE: Record<"appendFile" | "cp" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "rm" | "rmdir" | "stat" | "writeFile", FsOptionSurface>;
16
+ declare const STAT_NUMBER_FIELDS: readonly ["dev", "mode", "nlink", "uid", "gid", "rdev", "blksize", "ino", "size", "blocks", "atimeMs", "mtimeMs", "ctimeMs", "birthtimeMs"];
17
+ declare const FILE_TYPE_PREDICATES: readonly ["isFile", "isDirectory", "isSymbolicLink", "isBlockDevice", "isCharacterDevice", "isFIFO", "isSocket"];
18
+ export type FsImplementation = Pick<typeof nodeFsPromises, FsOperationName>;
19
+ type FileTypePredicates = {
20
+ readonly [Name in (typeof FILE_TYPE_PREDICATES)[number]]: () => boolean;
21
+ };
22
+ export type SandboxStats = {
23
+ readonly [Name in (typeof STAT_NUMBER_FIELDS)[number]]: number;
24
+ } & FileTypePredicates;
25
+ export type SandboxDirent = {
26
+ readonly name: string;
27
+ readonly parentPath: string;
28
+ } & FileTypePredicates;
29
+ type StringEncoding = NodeJS.BufferEncoding;
30
+ type EncodingOptions = StringEncoding | {
31
+ encoding: StringEncoding;
32
+ };
33
+ type ReadFileOptions = StringEncoding | {
34
+ encoding: StringEncoding;
35
+ flag?: string;
36
+ };
37
+ type ReaddirOptions = {
38
+ encoding?: StringEncoding;
39
+ recursive?: boolean;
40
+ };
41
+ type StatOptions = {
42
+ bigint?: false;
43
+ };
44
+ export type FsModuleOptions = {
45
+ root?: string;
46
+ fs?: FsImplementation;
47
+ };
48
+ export type FsModule = Pick<FsImplementation, FsPassthroughName> & {
49
+ readFile(path: PathLike, options: ReadFileOptions): Promise<string>;
50
+ readlink(path: PathLike, options?: EncodingOptions): Promise<string>;
51
+ realpath(path: PathLike, options?: EncodingOptions): Promise<string>;
52
+ mkdtemp(prefix: string, options?: EncodingOptions): Promise<string>;
53
+ readdir: {
54
+ (path: PathLike, options: ReaddirOptions & {
55
+ withFileTypes: true;
56
+ }): Promise<SandboxDirent[]>;
57
+ (path: PathLike, options?: StringEncoding | (ReaddirOptions & {
58
+ withFileTypes?: false;
59
+ })): Promise<string[]>;
60
+ };
61
+ stat(path: PathLike, options?: StatOptions): Promise<SandboxStats>;
62
+ lstat(path: PathLike, options?: StatOptions): Promise<SandboxStats>;
63
+ constants: {
64
+ F_OK: number;
65
+ R_OK: number;
66
+ W_OK: number;
67
+ X_OK: number;
68
+ COPYFILE_EXCL: number;
69
+ };
70
+ };
71
+ export declare function makeFsModule(options?: FsModuleOptions): FsModule;
72
+ export {};
@@ -0,0 +1,558 @@
1
+ import { constants as nodeFsConstants } from "node:fs";
2
+ import * as nodeFsPromises from "node:fs/promises";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { getSystemErrorMap, inspect } from "node:util";
5
+ import { declareHostOperation } from "../interp/host-bridge.js";
6
+ import { containsPath, resolveCanonicalPath } from "./canonical-path.js";
7
+ // Whether node answers with a Buffer when an encoding is not given: readFile
8
+ // defaults to a Buffer, while readdir, readlink, realpath, and mkdtemp default to
9
+ // utf8 yet still answer with one for a buffer encoding.
10
+ const BUFFER_BY_DEFAULT = {
11
+ readFile: true,
12
+ readdir: false,
13
+ readlink: false,
14
+ realpath: false,
15
+ mkdtemp: false
16
+ };
17
+ // Why each option node declares cannot be honoured here, written once for every
18
+ // operation that declares it.
19
+ const REFUSED_OPTION_REASONS = {
20
+ signal: "the sandbox has no AbortController, and cancelling a run is the host's to request rather than the script's",
21
+ // @types/node declares this on fs/promises stat and lstat and types
22
+ // `throwIfNoEntry: false` as answering `Stats | undefined`, but the typings are
23
+ // ahead of node: only the synchronous API reads the option. Forwarding it would
24
+ // leave a script holding the ENOENT rejection the typings told it to expect
25
+ // undefined for, so it is refused rather than dropped.
26
+ throwIfNoEntry: "only node's synchronous stat reads it and fs/promises rejects a missing path whatever it says, so catch the ENOENT rejection instead",
27
+ // cp's filter is a callback the host would invoke, which a sandbox closure can cross the
28
+ // bridge to serve. It is refused for what happens around the call rather than during it:
29
+ // the digest that identifies a host call across a snapshot is built by stringifying the
30
+ // arguments, and a function does not survive that, so cp(filter: keep) and
31
+ // cp(filter: drop) are one call to the resume machinery.
32
+ filter: "a closure is dropped from the digest that identifies a host call across a snapshot, so a resumed run could reconcile against a copy that took a different set of files, and under a root it would read the rewritten host paths rather than the ones the script wrote — walk the tree with readdir and copy the entries you want instead"
33
+ };
34
+ const UNKNOWN_OPTION_REASON = "node declares no such option for it, and an unrecognised option is refused rather than silently ignored";
35
+ // Refused by the root rather than by the module, so it is kept apart from the refusals
36
+ // above: with no root there is no boundary to cross and node's own option is honoured.
37
+ //
38
+ // cp is the one operation that reads a whole tree in a single call, which makes it the one
39
+ // whose src and dest are not the only paths it touches. Those two are canonicalized and
40
+ // proven inside root; a symlink nested inside the tree is never seen. With dereference node
41
+ // copies what such a link points at rather than the link, landing content from outside root
42
+ // inside it under a name every later check reads as contained — so the option would hand a
43
+ // script the reads the root exists to refuse. Without dereference the link is copied as a
44
+ // link, which stays unreadable, so only this option is refused.
45
+ const ROOT_REFUSED_DEREFERENCE_REASON = "a root canonicalizes cp's src and dest but never the paths nested inside the tree, so node would copy an escaping link's target inside root where the script could read it — copy without dereference and a nested link stays a link the root still refuses to read through";
46
+ // Every operation node gives an options bag, the argument it reads the bag from,
47
+ // and each option split into the ones forwarded to node untouched and the ones
48
+ // refused by name. An option node honours and this table omits is refused as
49
+ // unknown, so a silently ignored option is not reachable; the audit in
50
+ // fs.option-surface.test.ts proves the split covers node's own typings, which is
51
+ // what keeps a handle, stream, or watcher option node adds later from passing
52
+ // through unclassified.
53
+ //
54
+ // The operations absent here are the ones node gives no options bag: access,
55
+ // chmod, copyFile, truncate, and utimes read a trailing mode, length, or time
56
+ // that node validates itself, and link, rename, and symlink take paths alone.
57
+ //
58
+ // rmdir carries maxRetries and retryDelay because node validates both (its
59
+ // recursive option is deprecated but still honoured), even though @types/node has
60
+ // already dropped rmdir's options argument for a future node that removes them.
61
+ export const FS_OPTION_SURFACE = {
62
+ appendFile: {
63
+ argument: 2,
64
+ honoured: ["encoding", "mode", "flag", "flush"],
65
+ // node's appendFile forwards to writeFile, so it honours a signal even though
66
+ // @types/node does not declare one for it.
67
+ refused: ["signal"]
68
+ },
69
+ // node reads cp's bag from a parameter it names opts rather than options, and validates
70
+ // every key it knows — while still ignoring one it does not, which is what the unknown
71
+ // refusal covers. mode is copyFile's flag set rather than a permission.
72
+ cp: {
73
+ argument: 2,
74
+ honoured: [
75
+ "dereference",
76
+ "errorOnExist",
77
+ "force",
78
+ "mode",
79
+ "preserveTimestamps",
80
+ "recursive",
81
+ "verbatimSymlinks"
82
+ ],
83
+ refused: ["filter"]
84
+ },
85
+ lstat: { argument: 1, honoured: ["bigint"], refused: ["throwIfNoEntry"] },
86
+ mkdir: { argument: 1, honoured: ["recursive", "mode"], refused: [] },
87
+ mkdtemp: { argument: 1, honoured: ["encoding"], refused: [] },
88
+ readFile: { argument: 1, honoured: ["encoding", "flag"], refused: ["signal"] },
89
+ readdir: { argument: 1, honoured: ["encoding", "withFileTypes", "recursive"], refused: [] },
90
+ readlink: { argument: 1, honoured: ["encoding"], refused: [] },
91
+ realpath: { argument: 1, honoured: ["encoding"], refused: [] },
92
+ rm: { argument: 1, honoured: ["force", "recursive", "maxRetries", "retryDelay"], refused: [] },
93
+ rmdir: { argument: 1, honoured: ["recursive", "maxRetries", "retryDelay"], refused: [] },
94
+ stat: { argument: 1, honoured: ["bigint"], refused: ["throwIfNoEntry"] },
95
+ writeFile: { argument: 2, honoured: ["encoding", "mode", "flag", "flush"], refused: ["signal"] }
96
+ };
97
+ const STAT_NUMBER_FIELDS = [
98
+ "dev",
99
+ "mode",
100
+ "nlink",
101
+ "uid",
102
+ "gid",
103
+ "rdev",
104
+ "blksize",
105
+ "ino",
106
+ "size",
107
+ "blocks",
108
+ "atimeMs",
109
+ "mtimeMs",
110
+ "ctimeMs",
111
+ "birthtimeMs"
112
+ ];
113
+ const FILE_TYPE_PREDICATES = [
114
+ "isFile",
115
+ "isDirectory",
116
+ "isSymbolicLink",
117
+ "isBlockDevice",
118
+ "isCharacterDevice",
119
+ "isFIFO",
120
+ "isSocket"
121
+ ];
122
+ // node compares this encoding name case-sensitively; any other casing is an
123
+ // invalid encoding node rejects on its own.
124
+ const BUFFER_ENCODING = "buffer";
125
+ // The syscall node blames when each operation fails, read back from node itself:
126
+ // several do not match the fs function name, so they cannot be derived from it.
127
+ const FS_SYSCALLS = {
128
+ access: "access",
129
+ appendFile: "open",
130
+ chmod: "chmod",
131
+ copyFile: "copyfile",
132
+ // node's cp is its own JavaScript layer rather than a syscall wrapper, so it blames the
133
+ // fs function by name: its ERR_FS_CP_* errors carry syscall 'cp'.
134
+ cp: "cp",
135
+ link: "link",
136
+ lstat: "lstat",
137
+ mkdir: "mkdir",
138
+ mkdtemp: "mkdtemp",
139
+ readFile: "open",
140
+ readdir: "scandir",
141
+ readlink: "readlink",
142
+ realpath: "realpath",
143
+ rename: "rename",
144
+ rm: "lstat",
145
+ rmdir: "rmdir",
146
+ stat: "stat",
147
+ symlink: "symlink",
148
+ truncate: "open",
149
+ utimes: "utime",
150
+ writeFile: "open"
151
+ };
152
+ // The name node blames when an operation's path argument is invalid, read back
153
+ // from node itself: several do not match the fs function name — readlink blames
154
+ // oldPath and mkdtemp blames prefix. Listed in node's own argument order, so the
155
+ // length also says how many leading arguments are paths, and every one of them is
156
+ // resolved against root and proven inside it.
157
+ //
158
+ // The two-path operations are the ones node reports with a dest field beside path,
159
+ // cp excepted: it raises its own ERR_FS_CP_* errors, which carry a path alone. A
160
+ // root denial for cp still names both paths, which is SafeJS's own shape rather
161
+ // than an imitation of node's — the denial reports the call that was refused, and
162
+ // node's cp has no error to copy here because it never refuses one for a root.
163
+ const FS_PATH_ARGUMENTS = {
164
+ access: ["path"],
165
+ appendFile: ["path"],
166
+ chmod: ["path"],
167
+ copyFile: ["src", "dest"],
168
+ // node blames these by the same names it uses for copyFile's two paths.
169
+ cp: ["src", "dest"],
170
+ link: ["existingPath", "newPath"],
171
+ lstat: ["path"],
172
+ mkdir: ["path"],
173
+ mkdtemp: ["prefix"],
174
+ readFile: ["path"],
175
+ readdir: ["path"],
176
+ readlink: ["oldPath"],
177
+ realpath: ["path"],
178
+ rename: ["oldPath", "newPath"],
179
+ rm: ["path"],
180
+ rmdir: ["path"],
181
+ stat: ["path"],
182
+ symlink: ["target", "path"],
183
+ truncate: ["path"],
184
+ utimes: ["path"],
185
+ writeFile: ["path"]
186
+ };
187
+ const ACCESS_DENIED_CODE = "EACCES";
188
+ const INVALID_ARGUMENT_CODE = "ERR_INVALID_ARG_VALUE";
189
+ const INVALID_ARGUMENT_TYPE_CODE = "ERR_INVALID_ARG_TYPE";
190
+ const NULL_BYTE = "\u0000";
191
+ // libuv numbers errno differently per platform, so the errno and message paired
192
+ // with EACCES come from node's own table rather than a hardcoded -13.
193
+ const [ACCESS_DENIED_ERRNO, ACCESS_DENIED_MESSAGE] = readSystemError(ACCESS_DENIED_CODE);
194
+ export function makeFsModule(options = {}) {
195
+ assertSupportedPlatform();
196
+ const implementation = options.fs ?? nodeFsPromises;
197
+ // Without a root the module is node's fs/promises untouched; a root turns every
198
+ // path argument into one that has to resolve inside it.
199
+ const fs = options.root === undefined ? implementation : makeRootedFs(implementation, options.root);
200
+ return {
201
+ access: bind(fs, "access", "re-issue"),
202
+ appendFile: bind(fs, "appendFile", "read-side-effect"),
203
+ chmod: bind(fs, "chmod", "read-side-effect"),
204
+ copyFile: bind(fs, "copyFile", "read-side-effect"),
205
+ cp: bind(fs, "cp", "read-side-effect"),
206
+ link: bind(fs, "link", "read-side-effect"),
207
+ lstat: bindStat(fs, "lstat"),
208
+ mkdir: bind(fs, "mkdir", "read-side-effect"),
209
+ // Creates a directory, so it cannot be re-issued on resume like the other
210
+ // string-result operations, but its name still crosses as a Buffer for a buffer
211
+ // encoding and so goes through the same guard.
212
+ mkdtemp: bindStringResult(fs, "mkdtemp", "read-side-effect"),
213
+ readFile: bindStringResult(fs, "readFile"),
214
+ readdir: bindReaddir(fs),
215
+ readlink: bindStringResult(fs, "readlink"),
216
+ realpath: bindStringResult(fs, "realpath"),
217
+ rename: bind(fs, "rename", "read-side-effect"),
218
+ rm: bind(fs, "rm", "read-side-effect"),
219
+ rmdir: bind(fs, "rmdir", "read-side-effect"),
220
+ stat: bindStat(fs, "stat"),
221
+ symlink: bind(fs, "symlink", "read-side-effect"),
222
+ truncate: bind(fs, "truncate", "read-side-effect"),
223
+ utimes: bind(fs, "utimes", "read-side-effect"),
224
+ writeFile: bind(fs, "writeFile", "read-side-effect"),
225
+ constants: {
226
+ F_OK: nodeFsConstants.F_OK,
227
+ R_OK: nodeFsConstants.R_OK,
228
+ W_OK: nodeFsConstants.W_OK,
229
+ X_OK: nodeFsConstants.X_OK,
230
+ COPYFILE_EXCL: nodeFsConstants.COPYFILE_EXCL
231
+ }
232
+ };
233
+ }
234
+ // The module forwards node's error rather than translating it, which makes node's answer the
235
+ // module's answer and node's answer the platform's. That holds across darwin and linux — the
236
+ // errno differs, the code and the message do not — and stops holding on win32: node answers a
237
+ // different code there, a path carries a drive letter root confinement has no rule for, and a
238
+ // symlink needs a privilege a script cannot hold. Rather than half-support it, the module
239
+ // refuses to build there, and refuses it here so an embedder is told before a script runs.
240
+ //
241
+ // Only win32 is named. Every other platform node runs on is POSIX and fails the way the
242
+ // recorded two do, and a platform whose truth nobody has recorded is reported by the
243
+ // conformance suite rather than guessed at by a guard here.
244
+ function assertSupportedPlatform() {
245
+ if (process.platform !== "win32") {
246
+ return;
247
+ }
248
+ throw new Error("SafeJS's fs module does not support win32: node's fs answers a different code there (EPERM or UNKNOWN where darwin and linux answer EISDIR or ENOTEMPTY), a path carries a drive letter that root confinement has no rule for, and a symlink needs a privilege a script cannot hold. Run on darwin or linux, or build a host module for the surface you need.");
249
+ }
250
+ function bind(fs, name, policy) {
251
+ return declare(name, policy, (...args) => invoke(fs, name, args));
252
+ }
253
+ function bindStringResult(fs, name, policy = "re-issue") {
254
+ return declare(name, policy, async (...args) => {
255
+ assertNoBufferResult(name, args[1]);
256
+ return (await invoke(fs, name, args));
257
+ });
258
+ }
259
+ function bindReaddir(fs) {
260
+ return declare("readdir", "re-issue", async (...args) => {
261
+ assertNoBufferResult("readdir", args[1]);
262
+ const entries = (await invoke(fs, "readdir", args));
263
+ return entries.map((entry) => (typeof entry === "string" ? entry : toSandboxDirent(entry)));
264
+ });
265
+ }
266
+ function bindStat(fs, name) {
267
+ return declare(name, "re-issue", async (...args) => {
268
+ assertNoBigIntResult(name, args[1]);
269
+ return toSandboxStats((await invoke(fs, name, args)));
270
+ });
271
+ }
272
+ // Every exported operation is declared here, which makes this the one place that
273
+ // can promise node's argument validation runs first: before an unsupported result
274
+ // is refused, and before a root rewrites the path node would have blamed.
275
+ function declare(name, policy, operation) {
276
+ // async so a refused argument rejects rather than throwing at the call site:
277
+ // every node:fs/promises function answers with a promise either way.
278
+ const validated = async (...args) => {
279
+ FS_PATH_ARGUMENTS[name].forEach((argument, index) => assertSupportedPath(name, argument, args[index]));
280
+ assertSupportedOptions(name, args);
281
+ return await operation(...args);
282
+ };
283
+ Object.defineProperty(validated, "name", { value: name });
284
+ declareHostOperation(validated, policy);
285
+ return validated;
286
+ }
287
+ function invoke(fs, name, args) {
288
+ return Reflect.apply(fs[name], fs, args);
289
+ }
290
+ // Wraps every operation so its path arguments resolve against root and are proven
291
+ // to stay inside it. The wrapped operations keep node's own signatures and
292
+ // results, so the bindings above are unaware a root is in play.
293
+ function makeRootedFs(fs, root) {
294
+ if (root.trim().length === 0) {
295
+ throw new Error("fs module root must be a non-empty string.");
296
+ }
297
+ const rooted = {};
298
+ for (const name of Object.keys(FS_SYSCALLS)) {
299
+ rooted[name] = async (...args) => {
300
+ assertRootCanConfineOptions(name, args);
301
+ return invoke(fs, name, await resolvePathArguments(fs, root, name, args));
302
+ };
303
+ }
304
+ return rooted;
305
+ }
306
+ // Refuses the one option a root cannot confine, before any path is resolved so a refused
307
+ // call writes nothing. node reads dereference off its own defaults with a spread and
308
+ // validates it as a boolean, so only `true` asks for the copy that escapes.
309
+ function assertRootCanConfineOptions(name, args) {
310
+ if (name !== "cp") {
311
+ return;
312
+ }
313
+ const options = args[FS_OPTION_SURFACE.cp.argument];
314
+ if (isObjectLike(options) && options.dereference === true) {
315
+ throw createUnsupportedOptionError("cp", "dereference", ROOT_REFUSED_DEREFERENCE_REASON);
316
+ }
317
+ }
318
+ async function resolvePathArguments(fs, root, name, args) {
319
+ const canonicalRoot = await resolveCanonicalPath(readCanonicalPathFs(fs), resolve(root));
320
+ const resolved = [...args];
321
+ if (name === "symlink") {
322
+ // node stores a symlink's target verbatim and resolves a relative target
323
+ // against the link's own directory, so only the link path is rewritten while
324
+ // the target is checked as the link's directory would see it.
325
+ const linkPath = readPath(canonicalRoot, args[1]);
326
+ resolved[1] = linkPath;
327
+ await assertInsideRoot(fs, canonicalRoot, name, [
328
+ readPath(dirname(linkPath), args[0]),
329
+ linkPath
330
+ ]);
331
+ return resolved;
332
+ }
333
+ const paths = FS_PATH_ARGUMENTS[name].map((_, index) => {
334
+ const path = readPath(canonicalRoot, args[index]);
335
+ resolved[index] = path;
336
+ return path;
337
+ });
338
+ await assertInsideRoot(fs, canonicalRoot, name, paths);
339
+ return resolved;
340
+ }
341
+ // paths carries the operation's path arguments in node's order, so a denial names
342
+ // the whole attempted call even when only one of them escapes.
343
+ async function assertInsideRoot(fs, canonicalRoot, name, paths) {
344
+ for (const path of paths) {
345
+ if (await escapesRoot(fs, canonicalRoot, path)) {
346
+ throw createAccessDeniedError(name, paths[0], paths[1]);
347
+ }
348
+ }
349
+ }
350
+ async function escapesRoot(fs, canonicalRoot, path) {
351
+ const canonicalPath = await resolveCanonicalPath(readCanonicalPathFs(fs), path);
352
+ return !(await containsPath(readStat(fs), canonicalRoot, canonicalPath));
353
+ }
354
+ // realpath and readlink are handed over as bound calls: the injected implementation
355
+ // may keep them as methods that need their own receiver, and node's overloads widen
356
+ // both results to Buffer, which only the no-encoding calls used here can never
357
+ // return.
358
+ function readCanonicalPathFs(fs) {
359
+ return {
360
+ realpath: async (path) => (await invoke(fs, "realpath", [path])),
361
+ readlink: async (path) => (await invoke(fs, "readlink", [path]))
362
+ };
363
+ }
364
+ // Bound for the same reason as realpath above. Only dev and ino are read: they are
365
+ // the filesystem's own answer for which file a path names.
366
+ function readStat(fs) {
367
+ return async (path) => (await invoke(fs, "stat", [path]));
368
+ }
369
+ // node accepts a string, a Buffer, or a URL, and rejects everything else before it
370
+ // touches the filesystem. The module has to raise these itself rather than leave
371
+ // them to the injected implementation: a root rewrites the path first, and an
372
+ // injected implementation is free to validate differently — memfs reads a
373
+ // NUL-bearing path as a missing file and takes an integer as a descriptor.
374
+ function assertSupportedPath(name, argument, value) {
375
+ if (typeof value === "string") {
376
+ if (value.includes(NULL_BYTE)) {
377
+ throw createNullByteError(argument, value);
378
+ }
379
+ return;
380
+ }
381
+ const form = readUnsupportedPathForm(value);
382
+ throw form === undefined
383
+ ? createInvalidPathTypeError(argument, value)
384
+ : createUnsupportedPathError(name, argument, form);
385
+ }
386
+ // The path forms node accepts and the sandbox cannot spell. An integer is not one
387
+ // of them: fs/promises has no descriptor path form, so node blames an integer's
388
+ // type like any other non-string and so does the module.
389
+ function readUnsupportedPathForm(value) {
390
+ if (value instanceof Uint8Array) {
391
+ return "a Buffer or Uint8Array";
392
+ }
393
+ return value instanceof URL ? "a URL" : undefined;
394
+ }
395
+ // Shaped exactly like node's own ERR_INVALID_ARG_VALUE for a NUL-bearing path,
396
+ // down to inspecting the offending value the way node does.
397
+ function createNullByteError(argument, value) {
398
+ const error = new TypeError(`The argument '${argument}' must be a string, Uint8Array, or URL without null bytes. Received ${inspect(value)}`);
399
+ error.code = INVALID_ARGUMENT_CODE;
400
+ return error;
401
+ }
402
+ // Shaped exactly like node's own ERR_INVALID_ARG_TYPE for a path it cannot read.
403
+ function createInvalidPathTypeError(argument, value) {
404
+ const error = new TypeError(`The "${argument}" argument must be of type string or an instance of Buffer or URL. Received ${describeReceivedValue(value)}`);
405
+ error.code = INVALID_ARGUMENT_TYPE_CODE;
406
+ return error;
407
+ }
408
+ function createUnsupportedPathError(name, argument, form) {
409
+ return new TypeError(`fs.${name} cannot accept ${form} as the '${argument}' argument inside SafeJS; pass the path as a string.`);
410
+ }
411
+ // Ports node's own determineSpecificType (lib/internal/errors.js), which shapes the
412
+ // "Received ..." tail of every ERR_INVALID_ARG_TYPE message. node spells out each
413
+ // primitive case by hand; inspect already answers with the same text for all of
414
+ // them, down to -0, NaN, and a bigint's n suffix.
415
+ function describeReceivedValue(value) {
416
+ if (value === null || value === undefined) {
417
+ return String(value);
418
+ }
419
+ if (typeof value === "function") {
420
+ return `function ${value.name}`;
421
+ }
422
+ if (typeof value === "object") {
423
+ const constructorName = value.constructor?.name;
424
+ return constructorName === undefined
425
+ ? inspect(value, { depth: -1 })
426
+ : `an instance of ${constructorName}`;
427
+ }
428
+ return `type ${typeof value} (${inspect(value)})`;
429
+ }
430
+ // Every path argument reaches this already proven to be a NUL-free string: each
431
+ // operation is validated as it is declared, before a root gets to rewrite it.
432
+ function readPath(base, value) {
433
+ const path = value;
434
+ return isAbsolute(path) ? resolve(path) : resolve(base, path);
435
+ }
436
+ // Shaped exactly like the system error node raises for a refused path, so a
437
+ // script's `error.code` branch reads the same against SafeJS and against node.
438
+ function createAccessDeniedError(name, path, dest) {
439
+ const syscall = FS_SYSCALLS[name];
440
+ const target = dest === undefined ? `'${path}'` : `'${path}' -> '${dest}'`;
441
+ const error = new Error(`${ACCESS_DENIED_CODE}: ${ACCESS_DENIED_MESSAGE}, ${syscall} ${target}`);
442
+ error.code = ACCESS_DENIED_CODE;
443
+ error.errno = ACCESS_DENIED_ERRNO;
444
+ error.syscall = syscall;
445
+ error.path = path;
446
+ if (dest !== undefined) {
447
+ error.dest = dest;
448
+ }
449
+ return error;
450
+ }
451
+ function readSystemError(code) {
452
+ for (const [errno, [name, message]] of getSystemErrorMap()) {
453
+ if (name === code) {
454
+ return [errno, message];
455
+ }
456
+ }
457
+ throw new Error(`node does not define the ${code} system error.`);
458
+ }
459
+ // node ignores an option key it does not know, which leaves a script unable to tell
460
+ // an option it honoured from one it dropped. Every key is answered for here: refused
461
+ // by name with the reason, or proven to be one node forwards.
462
+ function assertSupportedOptions(name, args) {
463
+ // Widened to every operation for the lookup: the table only holds the ones node
464
+ // gives an options bag.
465
+ const surface = FS_OPTION_SURFACE[name];
466
+ // An operation with no options bag, or an options argument node reads as an
467
+ // encoding or a mode rather than a bag: node validates the value itself.
468
+ if (surface === undefined || !isObjectLike(args[surface.argument])) {
469
+ return;
470
+ }
471
+ const options = args[surface.argument];
472
+ // `in` rather than an enumeration: node reads an option by name, so a refused one
473
+ // counts however it is spelled — own or inherited, enumerable or not. An
474
+ // enumeration would answer for the keys a script wrote and miss the ones node
475
+ // would still honour, which is the one way a refusal here could be bypassed.
476
+ for (const option of surface.refused) {
477
+ if (option in options) {
478
+ throw createUnsupportedOptionError(name, option, REFUSED_OPTION_REASONS[option]);
479
+ }
480
+ }
481
+ // Unknown keys are the ones node cannot be asked about, so they are enumerated
482
+ // instead: for...in reads the own and inherited enumerable keys a script can
483
+ // plausibly have written, which is where a typo for an option node does declare
484
+ // shows up. Walking every own key of the whole chain would reach Object.prototype
485
+ // and refuse `toString` on every options bag.
486
+ for (const option in options) {
487
+ if (!surface.honoured.includes(option)) {
488
+ throw createUnsupportedOptionError(name, option, UNKNOWN_OPTION_REASON);
489
+ }
490
+ }
491
+ }
492
+ function assertNoBufferResult(operation, options) {
493
+ if (readsBuffer(options, BUFFER_BY_DEFAULT[operation])) {
494
+ throw createUnsupportedCapabilityError(operation, "return a Buffer", 'pass a string encoding such as "utf8"');
495
+ }
496
+ }
497
+ function assertNoBigIntResult(operation, options) {
498
+ if (isObjectLike(options) && options.bigint === true) {
499
+ throw createUnsupportedCapabilityError(operation, "return BigInt fields", "omit bigint and read the *Ms timestamps instead");
500
+ }
501
+ }
502
+ function createUnsupportedOptionError(operation, option, reason) {
503
+ return createUnsupportedCapabilityError(operation, `honour the '${option}' option`, reason);
504
+ }
505
+ // The one shape every capability SafeJS cannot offer is refused with, so a script
506
+ // reads the same sentence whichever option, encoding, or field it reached for.
507
+ function createUnsupportedCapabilityError(operation, capability, remedy) {
508
+ return new TypeError(`fs.${operation} cannot ${capability} inside SafeJS; ${remedy}.`);
509
+ }
510
+ // Only reports the shapes node answers with a Buffer. Anything else — including
511
+ // an unknown encoding or a malformed options argument — passes through so node's
512
+ // own error surfaces instead of a SafeJS one.
513
+ function readsBuffer(options, bufferByDefault) {
514
+ if (typeof options === "string") {
515
+ return options === BUFFER_ENCODING;
516
+ }
517
+ if (options === undefined || options === null) {
518
+ return bufferByDefault;
519
+ }
520
+ // node reads the encoding off anything it can hold a property on, so an array or
521
+ // a function is an options object answering with a Buffer rather than a shape
522
+ // node rejects. Everything else is left to node's own ERR_INVALID_ARG_TYPE.
523
+ if (!isObjectLike(options)) {
524
+ return false;
525
+ }
526
+ const { encoding } = options;
527
+ if (encoding === BUFFER_ENCODING) {
528
+ return true;
529
+ }
530
+ return (encoding === undefined || encoding === null) && bufferByDefault;
531
+ }
532
+ function toSandboxStats(stats) {
533
+ const numbers = {};
534
+ for (const field of STAT_NUMBER_FIELDS) {
535
+ numbers[field] = stats[field];
536
+ }
537
+ return { ...numbers, ...toFileTypePredicates(stats) };
538
+ }
539
+ function toSandboxDirent(dirent) {
540
+ return {
541
+ name: dirent.name,
542
+ parentPath: dirent.parentPath,
543
+ ...toFileTypePredicates(dirent)
544
+ };
545
+ }
546
+ // node's predicates read a mode the host object already resolved, so the answers
547
+ // are captured here rather than keeping the host object alive behind a closure.
548
+ function toFileTypePredicates(source) {
549
+ const predicates = {};
550
+ for (const predicate of FILE_TYPE_PREDICATES) {
551
+ const result = source[predicate]();
552
+ predicates[predicate] = () => result;
553
+ }
554
+ return predicates;
555
+ }
556
+ function isObjectLike(value) {
557
+ return (typeof value === "object" && value !== null) || typeof value === "function";
558
+ }