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.
- package/dist/cli/commands/harness.js +41 -3
- package/dist/cli/commands/harness.js.map +1 -1
- package/dist/index.js +1050 -420
- package/dist/index.js.map +4 -4
- package/dist/metafile.json +1 -1
- package/package.json +2 -1
- package/packages/safejs/dist/cli.js +24 -1
- package/packages/safejs/dist/index.d.ts +2 -0
- package/packages/safejs/dist/index.js +1 -0
- package/packages/safejs/dist/interp/cancel.js +4 -0
- package/packages/safejs/dist/interp/host-bridge.js +32 -1
- package/packages/safejs/dist/interp/promise.d.ts +1 -0
- package/packages/safejs/dist/interp/promise.js +10 -2
- package/packages/safejs/dist/modules/canonical-path.d.ts +15 -0
- package/packages/safejs/dist/modules/canonical-path.js +161 -0
- package/packages/safejs/dist/modules/fs.conformance-cases.d.ts +99 -0
- package/packages/safejs/dist/modules/fs.conformance-cases.js +1293 -0
- package/packages/safejs/dist/modules/fs.d.ts +72 -0
- package/packages/safejs/dist/modules/fs.js +558 -0
- package/packages/safejs/dist/modules/git.js +13 -33
- package/packages/safejs/dist/run.js +2 -4
|
@@ -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
|
+
}
|