typescript-virtual-container 1.5.5 → 1.5.7

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.
Files changed (64) hide show
  1. package/README.md +117 -35
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/SSHMimic/index.d.ts +5 -1
  4. package/dist/SSHMimic/index.js +27 -3
  5. package/dist/SSHMimic/scp.d.ts +34 -0
  6. package/dist/SSHMimic/scp.js +285 -0
  7. package/dist/SSHMimic/sftp.d.ts +53 -3
  8. package/dist/SSHMimic/sftp.js +9 -3
  9. package/dist/VirtualFileSystem/binaryPack.d.ts +7 -0
  10. package/dist/VirtualFileSystem/binaryPack.js +37 -1
  11. package/dist/VirtualFileSystem/index.d.ts +7 -0
  12. package/dist/VirtualFileSystem/index.js +67 -27
  13. package/dist/VirtualFileSystem/internalTypes.d.ts +2 -0
  14. package/dist/VirtualFileSystem/path.d.ts +5 -0
  15. package/dist/VirtualFileSystem/path.js +24 -11
  16. package/dist/VirtualPackageManager/index.d.ts +4 -2
  17. package/dist/VirtualPackageManager/index.js +24 -4
  18. package/dist/VirtualShell/index.d.ts +4 -0
  19. package/dist/VirtualShell/index.js +1 -7
  20. package/dist/VirtualShell/shell.js +40 -10
  21. package/dist/VirtualShell/shellParser.js +1 -22
  22. package/dist/commands/awk.d.ts +6 -11
  23. package/dist/commands/awk.js +462 -109
  24. package/dist/commands/bzip2.d.ts +11 -0
  25. package/dist/commands/bzip2.js +91 -0
  26. package/dist/commands/exit.js +1 -1
  27. package/dist/commands/find.d.ts +2 -2
  28. package/dist/commands/find.js +209 -37
  29. package/dist/commands/helpers.d.ts +0 -20
  30. package/dist/commands/helpers.js +0 -97
  31. package/dist/commands/lsof.d.ts +6 -0
  32. package/dist/commands/lsof.js +30 -0
  33. package/dist/commands/perl.d.ts +6 -0
  34. package/dist/commands/perl.js +76 -0
  35. package/dist/commands/python.js +5 -2
  36. package/dist/commands/registry.js +19 -1
  37. package/dist/commands/runtime.js +65 -87
  38. package/dist/commands/sed.d.ts +2 -2
  39. package/dist/commands/sed.js +216 -34
  40. package/dist/commands/sh.js +42 -0
  41. package/dist/commands/strace.d.ts +6 -0
  42. package/dist/commands/strace.js +26 -0
  43. package/dist/commands/tar.d.ts +2 -1
  44. package/dist/commands/tar.js +138 -52
  45. package/dist/commands/test.js +2 -2
  46. package/dist/commands/zip.d.ts +11 -0
  47. package/dist/commands/zip.js +232 -0
  48. package/dist/modules/linuxRootfs.js +1 -4
  49. package/dist/modules/neofetch.js +2 -2
  50. package/dist/types/commands.d.ts +4 -0
  51. package/dist/utils/argv.d.ts +6 -0
  52. package/dist/utils/argv.js +32 -0
  53. package/dist/utils/expand.d.ts +5 -2
  54. package/dist/utils/expand.js +112 -45
  55. package/dist/utils/glob.d.ts +6 -0
  56. package/dist/utils/glob.js +34 -0
  57. package/dist/utils/tokenize.js +13 -13
  58. package/package.json +9 -7
  59. package/dist/self-standalone.d.ts +0 -1
  60. package/dist/self-standalone.js +0 -444
  61. package/dist/standalone-wo-sftp.d.ts +0 -1
  62. package/dist/standalone-wo-sftp.js +0 -30
  63. package/dist/standalone.d.ts +0 -1
  64. package/dist/standalone.js +0 -61
@@ -0,0 +1,34 @@
1
+ /**
2
+ * SCP (Secure Copy Protocol) server-side implementation over SSH exec.
3
+ *
4
+ * Supports:
5
+ * scp -t [-r] <dest> — sink mode (client → server, uploading)
6
+ * scp -f [-r] <src> — source mode (server → client, downloading)
7
+ *
8
+ * The SCP wire protocol is a line-oriented binary protocol sent over the
9
+ * SSH exec channel. Control messages are ASCII lines terminated with \n.
10
+ * File data immediately follows the ready acknowledgement (\0).
11
+ *
12
+ * Sink control messages (client → server):
13
+ * C<mode> <size> <name>\n — regular file
14
+ * D<mode> 0 <name>\n — enter directory
15
+ * E\n — leave directory
16
+ * T<mtime_s> 0 <atime_s> 0\n — timestamps (ignored)
17
+ *
18
+ * Acknowledgement byte: \0 = ok, \1 = warning, \2 = error.
19
+ */
20
+ import type { VirtualShell } from "../VirtualShell";
21
+ interface ScpStream {
22
+ write(data: Buffer | string): boolean;
23
+ on(event: "data", listener: (chunk: Buffer) => void): this;
24
+ on(event: "end" | "close", listener: () => void): this;
25
+ stderr: {
26
+ write(data: string | Buffer): void;
27
+ };
28
+ exit(code: number): void;
29
+ end(): void;
30
+ }
31
+ export declare function runScpSink(stream: ScpStream, destArg: string, authUser: string, shell: VirtualShell, recursive: boolean): void;
32
+ export declare function runScpSource(stream: ScpStream, srcArg: string, _authUser: string, shell: VirtualShell, recursive: boolean): void;
33
+ export declare function handleScp(stream: ScpStream, rawArgs: string[], authUser: string, shell: VirtualShell): void;
34
+ export {};
@@ -0,0 +1,285 @@
1
+ /**
2
+ * SCP (Secure Copy Protocol) server-side implementation over SSH exec.
3
+ *
4
+ * Supports:
5
+ * scp -t [-r] <dest> — sink mode (client → server, uploading)
6
+ * scp -f [-r] <src> — source mode (server → client, downloading)
7
+ *
8
+ * The SCP wire protocol is a line-oriented binary protocol sent over the
9
+ * SSH exec channel. Control messages are ASCII lines terminated with \n.
10
+ * File data immediately follows the ready acknowledgement (\0).
11
+ *
12
+ * Sink control messages (client → server):
13
+ * C<mode> <size> <name>\n — regular file
14
+ * D<mode> 0 <name>\n — enter directory
15
+ * E\n — leave directory
16
+ * T<mtime_s> 0 <atime_s> 0\n — timestamps (ignored)
17
+ *
18
+ * Acknowledgement byte: \0 = ok, \1 = warning, \2 = error.
19
+ */
20
+ import { basename } from "node:path";
21
+ import { resolvePath } from "../modules/shellRuntime";
22
+ // ── Helpers ───────────────────────────────────────────────────────────────────
23
+ const ACK = Buffer.from([0x00]);
24
+ const ERR = (msg) => Buffer.from(`\x02${msg}\n`);
25
+ function scpError(stream, msg, code = 1) {
26
+ stream.stderr.write(`scp: ${msg}\n`);
27
+ stream.write(ERR(`scp: ${msg}`));
28
+ stream.exit(code);
29
+ stream.end();
30
+ }
31
+ function parseArgs(args) {
32
+ const flags = args.filter((a) => a.startsWith("-")).join("");
33
+ return {
34
+ sink: flags.includes("t"),
35
+ source: flags.includes("f"),
36
+ recursive: flags.includes("r"),
37
+ preserve: flags.includes("p"),
38
+ target: args.find((a) => !a.startsWith("-")),
39
+ };
40
+ }
41
+ // ── Sink mode (upload: client → server) ──────────────────────────────────────
42
+ export function runScpSink(stream, destArg, authUser, shell, recursive) {
43
+ // Buffered reader that handles arbitrary chunk boundaries
44
+ let buf = Buffer.alloc(0);
45
+ const dirStack = [resolvePath("/", destArg)];
46
+ let state = "cmd";
47
+ let pendingFile = null;
48
+ let bytesRead = 0;
49
+ const fileChunks = [];
50
+ let finished = false;
51
+ function currentDir() {
52
+ return dirStack.at(-1) ?? "/";
53
+ }
54
+ function send(data) {
55
+ stream.write(typeof data === "string" ? Buffer.from(data) : data);
56
+ }
57
+ function processCmd(line) {
58
+ const type = line[0];
59
+ // Timestamps — ignored, just ack
60
+ if (type === "T") {
61
+ send(ACK);
62
+ return;
63
+ }
64
+ // End directory
65
+ if (type === "E") {
66
+ dirStack.pop();
67
+ send(ACK);
68
+ return;
69
+ }
70
+ // File or directory header: C<mode> <size> <name> / D<mode> 0 <name>
71
+ if (type === "C" || type === "D") {
72
+ const parts = line.slice(1).split(" ");
73
+ const name = parts.slice(2).join(" "); // name may contain spaces
74
+ const size = Number(parts[1] ?? "0");
75
+ if (!name || name === "." || name === "..") {
76
+ send(ERR("invalid filename"));
77
+ return;
78
+ }
79
+ if (type === "D") {
80
+ if (!recursive) {
81
+ send(ERR("not a regular file"));
82
+ return;
83
+ }
84
+ const dir = `${currentDir()}/${name}`;
85
+ if (!shell.vfs.exists(dir)) {
86
+ try {
87
+ shell.vfs.mkdir(dir, 0o755);
88
+ }
89
+ catch { /* already exists */ }
90
+ }
91
+ dirStack.push(dir);
92
+ send(ACK);
93
+ return;
94
+ }
95
+ // type === "C"
96
+ const destPath = `${currentDir()}/${name}`;
97
+ pendingFile = { name, size, dest: destPath };
98
+ bytesRead = 0;
99
+ fileChunks.length = 0;
100
+ state = "data";
101
+ send(ACK);
102
+ return;
103
+ }
104
+ send(ERR(`unknown control message: ${line[0]}`));
105
+ }
106
+ function processBuffer() {
107
+ while (buf.length > 0) {
108
+ if (state === "cmd") {
109
+ const nl = buf.indexOf(0x0a); // \n
110
+ if (nl === -1)
111
+ break; // wait for more
112
+ const line = buf.subarray(0, nl).toString("utf8").replace(/\r$/, "");
113
+ buf = buf.subarray(nl + 1);
114
+ processCmd(line);
115
+ }
116
+ else if (state === "data" && pendingFile) {
117
+ const remaining = pendingFile.size - bytesRead;
118
+ if (remaining > 0) {
119
+ const take = Math.min(buf.length, remaining);
120
+ fileChunks.push(buf.subarray(0, take));
121
+ buf = buf.subarray(take);
122
+ bytesRead += take;
123
+ }
124
+ if (bytesRead >= pendingFile.size) {
125
+ // Next byte must be \0 (end-of-file marker from client)
126
+ if (buf.length === 0)
127
+ break;
128
+ const marker = buf[0];
129
+ buf = buf.subarray(1);
130
+ if (marker !== 0x00) {
131
+ scpError(stream, "protocol error: expected \\0 after file data");
132
+ finished = true;
133
+ return;
134
+ }
135
+ // Write file to VFS
136
+ const content = Buffer.concat(fileChunks);
137
+ try {
138
+ shell.writeFileAsUser(authUser, pendingFile.dest, content);
139
+ }
140
+ catch (e) {
141
+ send(ERR(`cannot write ${pendingFile.dest}: ${String(e)}`));
142
+ finished = true;
143
+ return;
144
+ }
145
+ pendingFile = null;
146
+ state = "cmd";
147
+ send(ACK);
148
+ }
149
+ }
150
+ else {
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ // Send initial ready signal
156
+ send(ACK);
157
+ stream.on("data", (chunk) => {
158
+ if (finished)
159
+ return;
160
+ buf = Buffer.concat([buf, chunk]);
161
+ processBuffer();
162
+ });
163
+ stream.on("end", () => {
164
+ stream.exit(0);
165
+ stream.end();
166
+ });
167
+ }
168
+ // ── Source mode (download: server → client) ───────────────────────────────────
169
+ export function runScpSource(stream, srcArg, _authUser, shell, recursive) {
170
+ const srcPath = resolvePath("/", srcArg);
171
+ if (!shell.vfs.exists(srcPath)) {
172
+ scpError(stream, `${srcArg}: No such file or directory`);
173
+ return;
174
+ }
175
+ const entries = [];
176
+ function collect(p, name) {
177
+ const st = shell.vfs.stat(p);
178
+ if (st.type === "directory") {
179
+ if (!recursive) {
180
+ // skip directories silently like real scp
181
+ return;
182
+ }
183
+ entries.push({ kind: "dir-open", name });
184
+ for (const child of shell.vfs.list(p)) {
185
+ collect(`${p}/${child}`, child);
186
+ }
187
+ entries.push({ kind: "dir-close" });
188
+ }
189
+ else {
190
+ entries.push({ kind: "file", path: p, name });
191
+ }
192
+ }
193
+ collect(srcPath, basename(srcPath));
194
+ if (entries.length === 0) {
195
+ stream.exit(0);
196
+ stream.end();
197
+ return;
198
+ }
199
+ // State machine driven by \0 acks from client
200
+ let idx = 0;
201
+ let sendingData = false;
202
+ let pendingData = null;
203
+ function sendNext() {
204
+ if (idx >= entries.length) {
205
+ stream.exit(0);
206
+ stream.end();
207
+ return;
208
+ }
209
+ const entry = entries[idx];
210
+ idx++;
211
+ if (entry.kind === "dir-open") {
212
+ stream.write(`D0755 0 ${entry.name}\n`);
213
+ // wait for ack, then recurse
214
+ }
215
+ else if (entry.kind === "dir-close") {
216
+ stream.write("E\n");
217
+ // wait for ack
218
+ }
219
+ else {
220
+ const content = shell.vfs.readFileRaw(entry.path);
221
+ stream.write(`C0644 ${content.length} ${entry.name}\n`);
222
+ pendingData = content;
223
+ sendingData = true;
224
+ }
225
+ }
226
+ let buf = Buffer.alloc(0);
227
+ let waitingForDataAck = false;
228
+ function processAcks() {
229
+ while (buf.length > 0) {
230
+ const byte = buf[0];
231
+ buf = buf.subarray(1);
232
+ if (byte === 0x01 || byte === 0x02) {
233
+ // Error from client
234
+ stream.exit(1);
235
+ stream.end();
236
+ return;
237
+ }
238
+ // byte === 0x00: ack
239
+ if (waitingForDataAck) {
240
+ waitingForDataAck = false;
241
+ sendNext();
242
+ return;
243
+ }
244
+ if (sendingData && pendingData !== null) {
245
+ // Client acked the header — send data then \0
246
+ stream.write(pendingData);
247
+ stream.write(ACK);
248
+ pendingData = null;
249
+ sendingData = false;
250
+ waitingForDataAck = true;
251
+ }
252
+ else {
253
+ sendNext();
254
+ }
255
+ }
256
+ }
257
+ stream.on("data", (chunk) => {
258
+ buf = Buffer.concat([buf, chunk]);
259
+ processAcks();
260
+ });
261
+ stream.on("end", () => {
262
+ stream.exit(0);
263
+ stream.end();
264
+ });
265
+ // Wait for initial \0 from client before sending anything
266
+ // (processAcks will call sendNext on first ack)
267
+ }
268
+ // ── Entry point ───────────────────────────────────────────────────────────────
269
+ export function handleScp(stream, rawArgs, authUser, shell) {
270
+ const { sink, source, recursive, target } = parseArgs(rawArgs);
271
+ if (!sink && !source) {
272
+ scpError(stream, "missing -t or -f flag");
273
+ return;
274
+ }
275
+ if (!target) {
276
+ scpError(stream, "missing target path");
277
+ return;
278
+ }
279
+ if (sink) {
280
+ runScpSink(stream, target, authUser, shell, recursive);
281
+ }
282
+ else {
283
+ runScpSource(stream, target, authUser, shell, recursive);
284
+ }
285
+ }
@@ -4,16 +4,60 @@ import { Server as SshServer } from "ssh2";
4
4
  import type VirtualFileSystem from "../VirtualFileSystem";
5
5
  import { VirtualShell } from "../VirtualShell";
6
6
  import type { VirtualUserManager } from "../VirtualUserManager";
7
+ /** @internal */
8
+ interface SftpAttributes {
9
+ mode: number;
10
+ uid: number;
11
+ gid: number;
12
+ size: number;
13
+ atime: number | Date;
14
+ mtime: number | Date;
15
+ }
16
+ /** @internal */
17
+ interface SftpServerStream {
18
+ on(event: "OPEN", listener: (reqid: number, filename: string, flags: number) => void): this;
19
+ on(event: "READ", listener: (reqid: number, handle: Buffer, offset: number, length: number) => void): this;
20
+ on(event: "WRITE", listener: (reqid: number, handle: Buffer, offset: number, data: Buffer) => void): this;
21
+ on(event: "FSTAT", listener: (reqid: number, handle: Buffer) => void): this;
22
+ on(event: "CLOSE", listener: (reqid: number, handle: Buffer) => void): this;
23
+ on(event: "OPENDIR", listener: (reqid: number, path: string) => void): this;
24
+ on(event: "READDIR", listener: (reqid: number, handle: Buffer) => void): this;
25
+ on(event: "STAT", listener: (reqid: number, path: string) => void): this;
26
+ on(event: "LSTAT", listener: (reqid: number, path: string) => void): this;
27
+ on(event: "FSETSTAT", listener: (reqid: number, handle: Buffer, attrs: Partial<SftpAttributes>) => void): this;
28
+ on(event: "SETSTAT", listener: (reqid: number, path: string, attrs: Partial<SftpAttributes>) => void): this;
29
+ on(event: "REALPATH", listener: (reqid: number, path: string) => void): this;
30
+ on(event: "MKDIR", listener: (reqid: number, path: string) => void): this;
31
+ on(event: "RMDIR", listener: (reqid: number, path: string) => void): this;
32
+ on(event: "REMOVE", listener: (reqid: number, path: string) => void): this;
33
+ on(event: "RENAME", listener: (reqid: number, oldPath: string, newPath: string) => void): this;
34
+ on(event: "READLINK", listener: (reqid: number) => void): this;
35
+ on(event: "SYMLINK", listener: (reqid: number) => void): this;
36
+ on(event: "END", listener: () => void): this;
37
+ on(event: "end", listener: () => void): this;
38
+ on(event: "error", listener: (error: Error) => void): this;
39
+ on(event: "close", listener: () => void): this;
40
+ status(reqid: number, code: number): void;
41
+ attrs(reqid: number, attrs: SftpAttributes): void;
42
+ handle(reqid: number, handle: Buffer): void;
43
+ data(reqid: number, data: Buffer): void;
44
+ name(reqid: number, entries: Array<{
45
+ filename: string;
46
+ longname: string;
47
+ attrs: SftpAttributes;
48
+ }>): void;
49
+ }
7
50
  /** Options for {@link SftpMimic} constructor. */
8
51
  export interface SftpMimicOptions {
9
- port: number;
52
+ /** TCP port to bind. Optional — omit if using as a subsystem handler only (no standalone server). */
53
+ port?: number;
10
54
  hostname?: string;
11
55
  shell?: VirtualShell;
12
56
  vfs?: VirtualFileSystem;
13
57
  users?: VirtualUserManager;
14
58
  }
15
59
  export declare class SftpMimic extends EventEmitter {
16
- port: number;
60
+ port: number | undefined;
17
61
  server: SshServer | null;
18
62
  private readonly hostname;
19
63
  private readonly shell;
@@ -44,5 +88,11 @@ export declare class SftpMimic extends EventEmitter {
44
88
  private openHandle;
45
89
  private getHandle;
46
90
  private closeHandle;
47
- private attachSftpHandlers;
91
+ /**
92
+ * Attach SFTP handlers to an already-accepted sftp stream.
93
+ * Used internally by {@link SshMimic} for the SFTP subsystem on the SSH port.
94
+ * @internal
95
+ */
96
+ attachSftpHandlers(sftp: SftpServerStream, authUser: string): void;
48
97
  }
98
+ export {};
@@ -71,6 +71,9 @@ export class SftpMimic extends EventEmitter {
71
71
  return this.shell?.users ?? this.users;
72
72
  }
73
73
  async start() {
74
+ if (this.port === undefined) {
75
+ throw new Error("SftpMimic: cannot start — no port configured (this instance is a subsystem handler only)");
76
+ }
74
77
  perf.mark("start");
75
78
  const privateKey = loadOrCreateHostKey();
76
79
  // Ensure VirtualShell is fully initialized before accepting connections
@@ -271,6 +274,11 @@ export class SftpMimic extends EventEmitter {
271
274
  closeHandle(handle) {
272
275
  this.handles.delete(handle.toString("hex"));
273
276
  }
277
+ /**
278
+ * Attach SFTP handlers to an already-accepted sftp stream.
279
+ * Used internally by {@link SshMimic} for the SFTP subsystem on the SSH port.
280
+ * @internal
281
+ */
274
282
  attachSftpHandlers(sftp, authUser) {
275
283
  const getVfs = () => this.getVfs();
276
284
  const getUsers = () => this.getUsers();
@@ -283,8 +291,6 @@ export class SftpMimic extends EventEmitter {
283
291
  return;
284
292
  }
285
293
  const openMode = flags;
286
- const _canRead = Boolean(openMode & OPEN_MODE.READ);
287
- const _canWrite = Boolean(openMode & OPEN_MODE.WRITE || openMode & OPEN_MODE.APPEND);
288
294
  const canCreate = Boolean(openMode & OPEN_MODE.CREAT);
289
295
  const shouldTruncate = Boolean(openMode & OPEN_MODE.TRUNC);
290
296
  try {
@@ -337,7 +343,7 @@ export class SftpMimic extends EventEmitter {
337
343
  sftp.status(reqid, SFTP_STATUS_CODE.EOF);
338
344
  return;
339
345
  }
340
- const chunk = entry.buffer.slice(offset, offset + length);
346
+ const chunk = entry.buffer.subarray(offset, offset + length);
341
347
  sftp.data(reqid, chunk);
342
348
  });
343
349
  sftp.on("WRITE", async (reqid, handle, offset, data) => {
@@ -36,6 +36,13 @@ import type { InternalDirectoryNode } from "./internalTypes";
36
36
  * No base64, no JSON. ~27% smaller than the JSON+base64 format for typical VFS trees.
37
37
  */
38
38
  export declare function encodeVfs(root: InternalDirectoryNode): Buffer;
39
+ /**
40
+ * Shallow-fork a decoded rootfs tree: creates new InternalDirectoryNode objects
41
+ * (necessary for per-shell write isolation) but shares all InternalFileNode and
42
+ * InternalStubNode references. Safe because file/stub nodes are never mutated in-place
43
+ * — writes replace the parent's children[name] reference with a new node.
44
+ */
45
+ export declare function forkDirTree(base: InternalDirectoryNode): InternalDirectoryNode;
39
46
  /**
40
47
  * Deserialise a binary Buffer produced by {@link encodeVfs} back into an
41
48
  * InternalDirectoryNode tree. Throws on magic/version mismatch or truncation.
@@ -162,7 +162,7 @@ class Decoder {
162
162
  }
163
163
  function decodeNode(dec) {
164
164
  const type = dec.readUint8();
165
- const name = dec.readString();
165
+ const name = internName(dec.readString());
166
166
  const mode = dec.readUint32();
167
167
  const createdAt = dec.readFloat64();
168
168
  const updatedAt = dec.readFloat64();
@@ -194,10 +194,46 @@ function decodeNode(dec) {
194
194
  updatedAt,
195
195
  children,
196
196
  _childCount: count,
197
+ _sortedKeys: null,
197
198
  };
198
199
  }
199
200
  throw new Error(`[VFS binary] Unknown node type: 0x${type.toString(16)}`);
200
201
  }
202
+ // String intern pool for node names — avoids duplicate string allocations per decode call.
203
+ // Names like "bin", "etc", "usr", "passwd" appear in every shell's tree.
204
+ const _namePool = new Map();
205
+ function internName(s) {
206
+ const cached = _namePool.get(s);
207
+ if (cached !== undefined)
208
+ return cached;
209
+ _namePool.set(s, s);
210
+ return s;
211
+ }
212
+ /**
213
+ * Shallow-fork a decoded rootfs tree: creates new InternalDirectoryNode objects
214
+ * (necessary for per-shell write isolation) but shares all InternalFileNode and
215
+ * InternalStubNode references. Safe because file/stub nodes are never mutated in-place
216
+ * — writes replace the parent's children[name] reference with a new node.
217
+ */
218
+ export function forkDirTree(base) {
219
+ const children = Object.create(null);
220
+ for (const name in base.children) {
221
+ const child = base.children[name];
222
+ children[name] = child.type === "directory"
223
+ ? forkDirTree(child)
224
+ : child; // shared — file/stub nodes are immutable until replaced
225
+ }
226
+ return {
227
+ type: "directory",
228
+ name: base.name,
229
+ mode: base.mode,
230
+ createdAt: base.createdAt,
231
+ updatedAt: base.updatedAt,
232
+ children,
233
+ _childCount: base._childCount,
234
+ _sortedKeys: base._sortedKeys, // safe to share — sorted list doesn't change for base
235
+ };
236
+ }
201
237
  /**
202
238
  * Deserialise a binary Buffer produced by {@link encodeVfs} back into an
203
239
  * InternalDirectoryNode tree. Throws on magic/version mismatch or truncation.
@@ -86,6 +86,8 @@ declare class VirtualFileSystem extends EventEmitter {
86
86
  private _dirty;
87
87
  /** Active host-directory mounts: vPath → { hostPath, readOnly } */
88
88
  private readonly mounts;
89
+ /** Sorted mounts cache (longest-path-first). Rebuilt lazily on mount/unmount. */
90
+ private _sortedMounts;
89
91
  /** True when running in a browser environment (no host FS access). */
90
92
  private static readonly isBrowser;
91
93
  constructor(options?: VfsOptions);
@@ -248,6 +250,11 @@ declare class VirtualFileSystem extends EventEmitter {
248
250
  chmod(targetPath: string, mode: number): void;
249
251
  /** Returns metadata for a file or directory. */
250
252
  stat(targetPath: string): VfsNodeStats;
253
+ /**
254
+ * Fast type-only check — no Date/string allocation.
255
+ * Use instead of `stat().type` when that's all you need.
256
+ */
257
+ statType(targetPath: string): "file" | "directory" | null;
251
258
  /** Lists direct children names of a directory (sorted). */
252
259
  list(dirPath?: string): string[];
253
260
  /** Renders ASCII tree view of a directory hierarchy. */