@volter/tabnode 0.5.4 → 0.5.6
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/assets/{runtime-worker-DBtzjAkn.js → runtime-worker-Bt4uLPDa.js} +105 -22
- package/dist/index.d.ts +6 -2
- package/dist/index.mjs +155 -28
- package/dist/native-stream-owner.d.ts +8 -1
- package/dist/process-registry.d.ts +7 -0
- package/dist/process-tokens.d.ts +15 -2
- package/dist/virtual-fs.d.ts +25 -1
- package/package.json +1 -1
- package/src/index.ts +21 -4
- package/src/native-stream-owner.ts +10 -1
- package/src/node-lib/binding/fs.ts +1 -1
- package/src/process-registry.ts +22 -2
- package/src/process-tokens.ts +30 -3
- package/src/runtime.ts +33 -10
- package/src/shims/child_process.ts +4 -3
- package/src/virtual-fs.ts +77 -5
|
@@ -40,7 +40,7 @@ import { errname } from './uv';
|
|
|
40
40
|
* the same shape here rather than widened there.
|
|
41
41
|
*/
|
|
42
42
|
function createNodeError(code: string, syscall: string, path: string): Error {
|
|
43
|
-
const known = ['EEXIST', 'EINVAL', 'EISDIR', 'ENOENT', 'ENOTDIR', 'ENOTEMPTY', 'ELOOP'];
|
|
43
|
+
const known = ['EEXIST', 'EINVAL', 'EISDIR', 'ENOENT', 'ENOTDIR', 'ENOTEMPTY', 'ELOOP', 'EROFS'];
|
|
44
44
|
if (known.includes(code)) return vfsError(code as 'ENOENT', syscall, path);
|
|
45
45
|
return Object.assign(new Error(`${code}: ${syscall} '${path}'`), { code, syscall, path, errno: -9 });
|
|
46
46
|
}
|
package/src/process-registry.ts
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
export interface ProcessIdentity {
|
|
3
3
|
readonly pid: number;
|
|
4
4
|
readonly ppid: number;
|
|
5
|
+
/** What the process was started as, where its starter said: `/proc/<pid>/cmdline`. */
|
|
6
|
+
readonly argv?: readonly string[];
|
|
7
|
+
/** The directory it was started in: `/proc/<pid>/cwd`. */
|
|
8
|
+
readonly cwd?: string;
|
|
9
|
+
/** When it started, in milliseconds since the epoch. */
|
|
10
|
+
readonly startedAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The parts of an identity a starter may add, checked before they are kept. */
|
|
14
|
+
function describedIdentity(identity: ProcessIdentity): ProcessIdentity {
|
|
15
|
+
const { pid, ppid, argv, cwd, startedAt } = identity;
|
|
16
|
+
const described: { pid: number; ppid: number; argv?: readonly string[]; cwd?: string; startedAt?: number } = { pid, ppid };
|
|
17
|
+
if (Array.isArray(argv) && argv.length <= 4096 && argv.every(arg => typeof arg === 'string')) described.argv = Object.freeze([...argv]);
|
|
18
|
+
if (typeof cwd === 'string' && cwd.startsWith('/')) described.cwd = cwd;
|
|
19
|
+
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) described.startedAt = startedAt;
|
|
20
|
+
return Object.freeze(described);
|
|
5
21
|
}
|
|
6
22
|
|
|
7
23
|
/** A run already admitted by the container owner into this realm's scope. */
|
|
@@ -38,7 +54,7 @@ export interface ProcessRegistryScope extends ProcessRegistry {
|
|
|
38
54
|
* so identical local run tokens cannot overwrite or release another realm's
|
|
39
55
|
* process. Allocation alone does not announce a live process.
|
|
40
56
|
*/
|
|
41
|
-
export function createProcessRegistryOwner(): { createScope(): ProcessRegistryScope } {
|
|
57
|
+
export function createProcessRegistryOwner(): { createScope(): ProcessRegistryScope; table(): ProcessIdentity[] } {
|
|
42
58
|
let nextPid = 1000 + Math.floor(Math.random() * 30000);
|
|
43
59
|
const live = new Map<number, ProcessIdentity>();
|
|
44
60
|
// Which realm's scope answers for each live pid, including after handoff.
|
|
@@ -104,7 +120,7 @@ export function createProcessRegistryOwner(): { createScope(): ProcessRegistrySc
|
|
|
104
120
|
if (identity.ppid !== 0 && !Array.from(runs.values()).some(parent => parent.pid === identity.ppid)) {
|
|
105
121
|
throw new Error('Parent process is not owned by this realm.');
|
|
106
122
|
}
|
|
107
|
-
const entry =
|
|
123
|
+
const entry = describedIdentity(identity);
|
|
108
124
|
runs.set(token, entry);
|
|
109
125
|
live.set(entry.pid, entry);
|
|
110
126
|
receivers.set(entry.pid, receiver);
|
|
@@ -140,5 +156,9 @@ export function createProcessRegistryOwner(): { createScope(): ProcessRegistrySc
|
|
|
140
156
|
scopes.set(scope, { allocated, runs, active });
|
|
141
157
|
return scope;
|
|
142
158
|
},
|
|
159
|
+
/** Every live process of the container, in pid order: what `/proc` lists. */
|
|
160
|
+
table() {
|
|
161
|
+
return [...live.values()].sort((a, b) => a.pid - b.pid);
|
|
162
|
+
},
|
|
143
163
|
};
|
|
144
164
|
}
|
package/src/process-tokens.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { Process } from './shims/process';
|
|
19
19
|
import { AsyncLocalStorage } from './shims/async_hooks';
|
|
20
|
-
import { createProcessRegistryOwner, type ProcessRegistry, type ProcessRegistryScope, type InitialProcessRegistration } from './process-registry';
|
|
20
|
+
import { createProcessRegistryOwner, type ProcessIdentity, type ProcessRegistry, type ProcessRegistryScope, type InitialProcessRegistration } from './process-registry';
|
|
21
21
|
|
|
22
22
|
/** Whatever the embedding runtime uses to name one guest process. */
|
|
23
23
|
export type ProcessToken = string;
|
|
@@ -113,6 +113,27 @@ export function __stopOwnedProcess(token: ProcessToken): void {
|
|
|
113
113
|
runs.get(token)?.stopTimers();
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Another process's `kill(pid, signal)`, delivered to a named run's guest.
|
|
118
|
+
* On a machine the target's handlers run, or its default action ends it; the
|
|
119
|
+
* guest's own `process.kill(process.pid, signal)` is exactly that, so the
|
|
120
|
+
* signal is raised through it, as the run. False when the run is gone or the
|
|
121
|
+
* signal is not one Node names.
|
|
122
|
+
*/
|
|
123
|
+
export function __signalOwnedProcess(token: ProcessToken, signal: string): boolean {
|
|
124
|
+
const run = runs.get(token);
|
|
125
|
+
if (!run) return false;
|
|
126
|
+
const guest = run.process as unknown as { pid: number; kill(pid: number, signal: string): unknown };
|
|
127
|
+
try { enterRun(token, () => guest.kill(guest.pid, signal)); }
|
|
128
|
+
catch (error) {
|
|
129
|
+
// A default action ends the process by `exit`, which a synchronous guest
|
|
130
|
+
// frame reports by throwing; that is the signal delivered.
|
|
131
|
+
if (error instanceof Error && error.message.startsWith("Process exited with code")) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
/**
|
|
117
138
|
* A process's own number.
|
|
118
139
|
*
|
|
@@ -147,6 +168,12 @@ export function createProcessRegistryScope(): ProcessRegistryScope {
|
|
|
147
168
|
return processRegistryOwner.createScope();
|
|
148
169
|
}
|
|
149
170
|
|
|
171
|
+
/** Trusted container owner only: every live process of the container, from every realm. */
|
|
172
|
+
export function ownerProcessTable(): ProcessIdentity[] {
|
|
173
|
+
if (registryInstalled) throw new Error('This realm is a process registry client, not the container owner.');
|
|
174
|
+
return processRegistryOwner.table();
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
/** Trusted container owner only; worker clients cannot expose their authority. */
|
|
151
178
|
export function ownerProcessRegistryScope(): ProcessRegistryScope {
|
|
152
179
|
if (registryInstalled) throw new Error('This realm is a process registry client, not the container owner.');
|
|
@@ -177,9 +204,9 @@ export function mintPid(): number {
|
|
|
177
204
|
}
|
|
178
205
|
|
|
179
206
|
/** Record the numbers a named run was started with, for the run to read back. */
|
|
180
|
-
export function setRunPid(token: ProcessToken, pid: number, ppid: number): void {
|
|
207
|
+
export function setRunPid(token: ProcessToken, pid: number, ppid: number, started?: { argv?: readonly string[]; cwd?: string }): void {
|
|
181
208
|
registryUsed = true;
|
|
182
|
-
processRegistry.publish(token, { pid, ppid });
|
|
209
|
+
processRegistry.publish(token, { pid, ppid, ...started, startedAt: Date.now() });
|
|
183
210
|
pidsOfRuns.set(token, { pid, ppid });
|
|
184
211
|
}
|
|
185
212
|
|
package/src/runtime.ts
CHANGED
|
@@ -343,23 +343,49 @@ function transformDynamicImportsRegex(code: string): string {
|
|
|
343
343
|
* saw no import at all.
|
|
344
344
|
*/
|
|
345
345
|
function __substrateRewriteDynamicImportsInScript(code: string): string {
|
|
346
|
-
if (!/(?<![.$\w#])import\s*\(/.test(code)) return code;
|
|
346
|
+
if (!/(?<![.$\w#])(?:import|eval)\s*\(/.test(code)) return code;
|
|
347
347
|
let ast;
|
|
348
348
|
try {
|
|
349
349
|
ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", allowReturnOutsideFunction: true, allowHashBang: true, allowAwaitOutsideFunction: true });
|
|
350
350
|
} catch {
|
|
351
351
|
return code;
|
|
352
352
|
}
|
|
353
|
-
const replacements: Array<[number, number]> = [];
|
|
353
|
+
const replacements: Array<[number, number, string]> = [];
|
|
354
354
|
walkAst(ast, (node: any) => {
|
|
355
|
-
if (node.type === "ImportExpression") replacements.push([node.start, node.start + 6]);
|
|
355
|
+
if (node.type === "ImportExpression") replacements.push([node.start, node.start + 6, "__dynamicImport"]);
|
|
356
|
+
collectDirectEval(node, replacements);
|
|
356
357
|
});
|
|
357
358
|
if (replacements.length === 0) return code;
|
|
358
|
-
|
|
359
|
+
return applyReplacements(code, replacements);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A direct `eval` runs source the module builds at run time, and Node runs an
|
|
364
|
+
* `import()` in it through the module's loader: Playwright loads an ES config
|
|
365
|
+
* with ``eval(`import(${url})`)``. The call stays a direct eval, so the
|
|
366
|
+
* source keeps the module's scope, `__dynamicImport` included; only the
|
|
367
|
+
* source it is given is rewritten the way the module's own was.
|
|
368
|
+
*/
|
|
369
|
+
function collectDirectEval(node: any, replacements: Array<[number, number, string]>): void {
|
|
370
|
+
if (node.type !== "CallExpression" || node.optional || node.callee?.type !== "Identifier" || node.callee.name !== "eval") return;
|
|
371
|
+
const source = node.arguments?.[0];
|
|
372
|
+
if (!source || source.type === "SpreadElement") return;
|
|
373
|
+
replacements.push([source.start, source.start, "__substrateEvalSource("]);
|
|
374
|
+
replacements.push([source.end, source.end, ")"]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Applies insertions and replacements from the end, so earlier offsets hold. */
|
|
378
|
+
function applyReplacements(code: string, replacements: Array<[number, number, string]>): string {
|
|
379
|
+
replacements.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
|
|
359
380
|
let out = code;
|
|
360
|
-
for (const [start, end] of replacements) out = out.slice(0, start) +
|
|
381
|
+
for (const [start, end, text] of replacements) out = out.slice(0, start) + text + out.slice(end);
|
|
361
382
|
return out;
|
|
362
383
|
}
|
|
384
|
+
|
|
385
|
+
Object.defineProperty(globalThis, "__substrateEvalSource", {
|
|
386
|
+
configurable: true,
|
|
387
|
+
value: (source: unknown): unknown => typeof source === "string" ? __substrateRewriteDynamicImportsInScript(source) : source,
|
|
388
|
+
});
|
|
363
389
|
function transformEsmToCjs(code: string, filename: string): string {
|
|
364
390
|
// Quick check: does the code have any ESM-like patterns?
|
|
365
391
|
const maybeEsm = __substrateHasEsmSyntax(code);
|
|
@@ -401,6 +427,7 @@ function transformEsmToCjsAst(code: string, filename: string): string {
|
|
|
401
427
|
// Replace just the 'import' keyword, preserving the (...) part
|
|
402
428
|
deepReplacements.push([node.start, node.start + 6, '__dynamicImport']);
|
|
403
429
|
}
|
|
430
|
+
collectDirectEval(node, deepReplacements);
|
|
404
431
|
});
|
|
405
432
|
|
|
406
433
|
// Check for actual import/export declarations
|
|
@@ -408,11 +435,7 @@ function transformEsmToCjsAst(code: string, filename: string): string {
|
|
|
408
435
|
const hasExportDecl = ast.body.some((n: any) => n.type?.startsWith('Export'));
|
|
409
436
|
|
|
410
437
|
// Apply deep replacements from end to start (preserves earlier positions)
|
|
411
|
-
let transformed = code;
|
|
412
|
-
deepReplacements.sort((a, b) => b[0] - a[0]);
|
|
413
|
-
for (const [start, end, replacement] of deepReplacements) {
|
|
414
|
-
transformed = transformed.slice(0, start) + replacement + transformed.slice(end);
|
|
415
|
-
}
|
|
438
|
+
let transformed = applyReplacements(code, deepReplacements);
|
|
416
439
|
|
|
417
440
|
// Transform import/export declarations (re-parses the modified code)
|
|
418
441
|
if (hasImportDecl || hasExportDecl) {
|
|
@@ -199,7 +199,7 @@ const _runStreams = new Map<ProcessToken, RunStreams>();
|
|
|
199
199
|
/** Submit before returning to a spawning parent; completion remains async. */
|
|
200
200
|
async function runHostedNode(host: NodeProcessHost, launch: Omit<NodeProcessLaunch, 'identity' | 'inherited' | 'stdinStream'>): Promise<CommandOutcome> {
|
|
201
201
|
const { token, streams } = launch;
|
|
202
|
-
if (!runPid(token)) setRunPid(token, mintPid(), 0);
|
|
202
|
+
if (!runPid(token)) setRunPid(token, mintPid(), 0, { argv: ['node', ...launch.argv], cwd: launch.cwd });
|
|
203
203
|
let input: ReturnType<typeof nodeProcessInput> | undefined;
|
|
204
204
|
try {
|
|
205
205
|
streams?.signal?.throwIfAborted();
|
|
@@ -578,7 +578,7 @@ export function initChildProcess(vfs: VirtualFS): void {
|
|
|
578
578
|
// This run's numbers, recorded under its name: a child it spawns reads
|
|
579
579
|
// them for its own `ppid`, and `process.kill(pid, 0)` asks this registry
|
|
580
580
|
// whether a pid is a live process.
|
|
581
|
-
if (runToken !== null) setRunPid(runToken, proc.pid, proc.ppid);
|
|
581
|
+
if (runToken !== null) setRunPid(runToken, proc.pid, proc.ppid, { argv: ['node', ...args], cwd: ctx.cwd });
|
|
582
582
|
const releaseRun = runToken === null ? null : __recordRun(runToken, {
|
|
583
583
|
process: proc,
|
|
584
584
|
stdout: appendStdout,
|
|
@@ -1680,7 +1680,8 @@ function startChildRun(request: RunRequest): StartedRun {
|
|
|
1680
1680
|
const pid = mintPid();
|
|
1681
1681
|
// In a process realm the admitted guest is always the spawning parent.
|
|
1682
1682
|
// Async bookkeeping for a routed child must not make that child its parent.
|
|
1683
|
-
setRunPid(token, pid, runPid(nodeProcessRealmToken() ?? __currentProcessToken() ?? __lastLaunchedToken)?.pid ?? 0
|
|
1683
|
+
setRunPid(token, pid, runPid(nodeProcessRealmToken() ?? __currentProcessToken() ?? __lastLaunchedToken)?.pid ?? 0,
|
|
1684
|
+
{ argv: request.args.length ? request.args : [request.file], ...(request.cwd ? { cwd: request.cwd } : {}) });
|
|
1684
1685
|
const controller = new AbortController();
|
|
1685
1686
|
const pendingStdin: Array<Uint8Array | null> = [];
|
|
1686
1687
|
// A host terminal consumes input incrementally. The old string-only
|
package/src/virtual-fs.ts
CHANGED
|
@@ -44,6 +44,23 @@ export interface FSNode {
|
|
|
44
44
|
* apart). Absent until something sets it, which is the write time.
|
|
45
45
|
*/
|
|
46
46
|
atime?: number;
|
|
47
|
+
/**
|
|
48
|
+
* A tree mounted here: the paths below this node are the tree's, answered
|
|
49
|
+
* when they are read, and never this filesystem's to write. Linux's
|
|
50
|
+
* `/proc` is one.
|
|
51
|
+
*/
|
|
52
|
+
mount?: MountedTree;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A read-only tree another party answers, mounted at a directory: `node`
|
|
57
|
+
* returns what is at a path relative to the mount point (`/` is the mount
|
|
58
|
+
* point itself), computed when it is asked, or undefined where nothing is.
|
|
59
|
+
* A directory's `children` names what a listing shows; a link's `target` is
|
|
60
|
+
* followed as any link is.
|
|
61
|
+
*/
|
|
62
|
+
export interface MountedTree {
|
|
63
|
+
node(path: string): FSNode | undefined;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
// Identity belongs to the node, so writes and renames preserve it while an
|
|
@@ -116,7 +133,7 @@ export interface NodeError extends Error {
|
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
export function createNodeError(
|
|
119
|
-
code: 'ENOENT' | 'ENOTDIR' | 'EISDIR' | 'EEXIST' | 'ENOTEMPTY' | 'ELOOP' | 'EINVAL',
|
|
136
|
+
code: 'ENOENT' | 'ENOTDIR' | 'EISDIR' | 'EEXIST' | 'ENOTEMPTY' | 'ELOOP' | 'EINVAL' | 'EROFS',
|
|
120
137
|
syscall: string,
|
|
121
138
|
path: string,
|
|
122
139
|
message?: string
|
|
@@ -127,6 +144,7 @@ export function createNodeError(
|
|
|
127
144
|
EISDIR: -21,
|
|
128
145
|
EEXIST: -17,
|
|
129
146
|
ENOTEMPTY: -39,
|
|
147
|
+
EROFS: -30,
|
|
130
148
|
};
|
|
131
149
|
|
|
132
150
|
const messages: Record<string, string> = {
|
|
@@ -135,6 +153,7 @@ export function createNodeError(
|
|
|
135
153
|
EISDIR: 'is a directory',
|
|
136
154
|
EEXIST: 'file already exists',
|
|
137
155
|
ENOTEMPTY: 'directory not empty',
|
|
156
|
+
EROFS: 'read-only file system',
|
|
138
157
|
};
|
|
139
158
|
|
|
140
159
|
const err = new Error(
|
|
@@ -216,6 +235,7 @@ export class VirtualFS {
|
|
|
216
235
|
}
|
|
217
236
|
|
|
218
237
|
private serializeNode(path: string, node: FSNode, files: VFSFileEntry[]): void {
|
|
238
|
+
if (node.mount) return; // a mounted tree is its answerer's, never the filesystem's contents
|
|
219
239
|
if (node.type === 'file') {
|
|
220
240
|
// Encode binary content as base64
|
|
221
241
|
let content = '';
|
|
@@ -284,6 +304,7 @@ export class VirtualFS {
|
|
|
284
304
|
*/
|
|
285
305
|
private writeFileSyncInternal(path: string, data: string | Uint8Array, emitEvent: boolean): void {
|
|
286
306
|
const normalized = this.normalizePath(path);
|
|
307
|
+
this.assertWritable(normalized, 'open');
|
|
287
308
|
const parentPath = this.getParentPath(normalized);
|
|
288
309
|
const basename = this.getBasename(normalized);
|
|
289
310
|
|
|
@@ -377,11 +398,14 @@ export class VirtualFS {
|
|
|
377
398
|
if (depth > 40) throw createNodeError('ELOOP', 'stat', path);
|
|
378
399
|
const segments = this.getPathSegments(path);
|
|
379
400
|
let current = this.root;
|
|
401
|
+
let mounted: { tree: MountedTree; at: number } | undefined;
|
|
380
402
|
for (let index = 0; index < segments.length; index += 1) {
|
|
381
|
-
if (current.type !== 'directory' || !current.children) {
|
|
403
|
+
if (current.type !== 'directory' || (!mounted && !current.children)) {
|
|
382
404
|
return undefined;
|
|
383
405
|
}
|
|
384
|
-
const child =
|
|
406
|
+
const child = mounted
|
|
407
|
+
? mounted.tree.node('/' + segments.slice(mounted.at + 1, index + 1).join('/'))
|
|
408
|
+
: current.children!.get(segments[index]);
|
|
385
409
|
if (!child) {
|
|
386
410
|
return undefined;
|
|
387
411
|
}
|
|
@@ -391,16 +415,51 @@ export class VirtualFS {
|
|
|
391
415
|
const target = this.__substrateLinkTarget(linkPath, child.target!);
|
|
392
416
|
return this.__substrateNode(rest ? target + '/' + rest : target, follow, depth + 1);
|
|
393
417
|
}
|
|
394
|
-
|
|
418
|
+
if (!mounted && child.mount) {
|
|
419
|
+
mounted = { tree: child.mount, at: index };
|
|
420
|
+
current = child.mount.node('/') ?? child;
|
|
421
|
+
} else {
|
|
422
|
+
current = child;
|
|
423
|
+
}
|
|
395
424
|
}
|
|
396
425
|
return current;
|
|
397
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Mount a read-only tree at a directory, as Linux mounts `/proc`: reads
|
|
430
|
+
* below it are the tree's answers, writes fail with `EROFS`, and a snapshot
|
|
431
|
+
* leaves it out. Answers the unmount.
|
|
432
|
+
*/
|
|
433
|
+
mount(path: string, tree: MountedTree): () => void {
|
|
434
|
+
const normalized = this.normalizePath(path);
|
|
435
|
+
const basename = this.getBasename(normalized);
|
|
436
|
+
if (!basename) throw createNodeError('EINVAL', 'mount', path);
|
|
437
|
+
const parent = this.ensureDirectory(this.getParentPath(normalized));
|
|
438
|
+
const existing = parent.children!.get(basename);
|
|
439
|
+
if (existing && (existing.type !== 'directory' || existing.mount || existing.children?.size)) {
|
|
440
|
+
throw createNodeError('EEXIST', 'mount', path);
|
|
441
|
+
}
|
|
442
|
+
const node: FSNode = { type: 'directory', children: new Map(), mtime: Date.now(), mode: 0o555, mount: tree };
|
|
443
|
+
parent.children!.set(basename, node);
|
|
444
|
+
return () => { if (parent.children!.get(basename) === node) parent.children!.delete(basename); };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A write at or below a mount point is refused, as a read-only filesystem refuses it. */
|
|
448
|
+
private assertWritable(path: string, syscall: string): void {
|
|
449
|
+
let current: FSNode | undefined = this.root;
|
|
450
|
+
for (const segment of this.getPathSegments(path)) {
|
|
451
|
+
current = current?.children?.get(segment);
|
|
452
|
+
if (!current) return;
|
|
453
|
+
if (current.mount) throw createNodeError('EROFS', syscall, path);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
398
456
|
/** A link's target as an absolute path: absolute as written, or relative to the link's directory. */
|
|
399
457
|
__substrateLinkTarget(linkPath: string, target: string): string {
|
|
400
458
|
return this.normalizePath(target.startsWith('/') ? target : this.getParentPath(linkPath) + '/' + target);
|
|
401
459
|
}
|
|
402
460
|
symlinkSync(target: string, path: string): void {
|
|
403
461
|
const normalized = this.normalizePath(path);
|
|
462
|
+
this.assertWritable(normalized, 'symlink');
|
|
404
463
|
const parentPath = this.getParentPath(normalized);
|
|
405
464
|
const basename = this.getBasename(normalized);
|
|
406
465
|
const parent = this.getNode(parentPath);
|
|
@@ -472,12 +531,14 @@ export class VirtualFS {
|
|
|
472
531
|
* Get stats for path
|
|
473
532
|
*/
|
|
474
533
|
chmodSync(path: string, mode: number): void {
|
|
534
|
+
this.assertWritable(path, 'chmod');
|
|
475
535
|
const node = this.getNode(path);
|
|
476
536
|
if (!node) throw createNodeError('ENOENT', 'chmod', path);
|
|
477
537
|
node.mode = mode & 0o7777;
|
|
478
538
|
}
|
|
479
539
|
|
|
480
540
|
utimesSync(path: string, atime: number | Date, mtime: number | Date): void {
|
|
541
|
+
this.assertWritable(path, 'utime');
|
|
481
542
|
const node = this.getNode(path);
|
|
482
543
|
if (!node) throw createNodeError('ENOENT', 'utimes', path);
|
|
483
544
|
node.atime = atime instanceof Date ? atime.getTime() : atime * 1000;
|
|
@@ -600,6 +661,9 @@ export class VirtualFS {
|
|
|
600
661
|
*/
|
|
601
662
|
mkdirSync(path: string, options?: { recursive?: boolean }): void {
|
|
602
663
|
const normalized = this.normalizePath(path);
|
|
664
|
+
// `mkdir -p` of a directory that is there, a mount point among them, is done
|
|
665
|
+
if (options?.recursive && this.getNode(normalized)?.type === 'directory') return;
|
|
666
|
+
this.assertWritable(normalized, 'mkdir');
|
|
603
667
|
|
|
604
668
|
if (options?.recursive) {
|
|
605
669
|
this.ensureDirectory(normalized);
|
|
@@ -657,6 +721,7 @@ export class VirtualFS {
|
|
|
657
721
|
*/
|
|
658
722
|
unlinkSync(path: string): void {
|
|
659
723
|
const normalized = this.normalizePath(path);
|
|
724
|
+
this.assertWritable(normalized, 'unlink');
|
|
660
725
|
const parentPath = this.getParentPath(normalized);
|
|
661
726
|
const basename = this.getBasename(normalized);
|
|
662
727
|
|
|
@@ -689,6 +754,7 @@ export class VirtualFS {
|
|
|
689
754
|
*/
|
|
690
755
|
rmdirSync(path: string): void {
|
|
691
756
|
const normalized = this.normalizePath(path);
|
|
757
|
+
this.assertWritable(normalized, 'rmdir');
|
|
692
758
|
const parentPath = this.getParentPath(normalized);
|
|
693
759
|
const basename = this.getBasename(normalized);
|
|
694
760
|
|
|
@@ -727,6 +793,8 @@ export class VirtualFS {
|
|
|
727
793
|
renameSync(oldPath: string, newPath: string): void {
|
|
728
794
|
const normalizedOld = this.normalizePath(oldPath);
|
|
729
795
|
const normalizedNew = this.normalizePath(newPath);
|
|
796
|
+
this.assertWritable(normalizedOld, 'rename');
|
|
797
|
+
this.assertWritable(normalizedNew, 'rename');
|
|
730
798
|
|
|
731
799
|
const oldParentPath = this.getParentPath(normalizedOld);
|
|
732
800
|
const oldBasename = this.getBasename(normalizedOld);
|
|
@@ -846,10 +914,14 @@ export class VirtualFS {
|
|
|
846
914
|
for (let hops = 0; hops <= 40; hops += 1) {
|
|
847
915
|
const segments = resolved.split('/').filter(Boolean);
|
|
848
916
|
let current: FSNode | undefined = this.root;
|
|
917
|
+
let mounted: { tree: MountedTree; at: number } | undefined;
|
|
849
918
|
let linked = false;
|
|
850
919
|
for (let index = 0; index < segments.length; index += 1) {
|
|
851
|
-
const child: FSNode | undefined =
|
|
920
|
+
const child: FSNode | undefined = mounted
|
|
921
|
+
? mounted.tree.node('/' + segments.slice(mounted.at + 1, index + 1).join('/'))
|
|
922
|
+
: current && current.children ? current.children.get(segments[index]) : void 0;
|
|
852
923
|
if (!child) throw createNodeError('ENOENT', 'realpath', path);
|
|
924
|
+
if (!mounted && child.mount) mounted = { tree: child.mount, at: index };
|
|
853
925
|
if (child.type === 'symlink') {
|
|
854
926
|
const linkPath = '/' + segments.slice(0, index + 1).join('/');
|
|
855
927
|
const rest = segments.slice(index + 1).join('/');
|