typescript-virtual-container 1.2.3 → 1.2.5

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 (69) hide show
  1. package/README.md +871 -1231
  2. package/benchmark-results.txt +21 -21
  3. package/biome.json +9 -0
  4. package/dist/SSHMimic/index.d.ts +19 -2
  5. package/dist/SSHMimic/index.d.ts.map +1 -1
  6. package/dist/SSHMimic/index.js +127 -15
  7. package/dist/VirtualFileSystem/index.d.ts +115 -88
  8. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  9. package/dist/VirtualFileSystem/index.js +406 -258
  10. package/dist/VirtualShell/index.d.ts +3 -4
  11. package/dist/VirtualShell/index.d.ts.map +1 -1
  12. package/dist/VirtualShell/index.js +5 -23
  13. package/dist/VirtualUserManager/index.d.ts +41 -3
  14. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  15. package/dist/VirtualUserManager/index.js +83 -21
  16. package/dist/commands/chmod.d.ts +3 -0
  17. package/dist/commands/chmod.d.ts.map +1 -0
  18. package/dist/commands/chmod.js +31 -0
  19. package/dist/commands/cp.d.ts +3 -0
  20. package/dist/commands/cp.d.ts.map +1 -0
  21. package/dist/commands/cp.js +68 -0
  22. package/dist/commands/find.d.ts +3 -0
  23. package/dist/commands/find.d.ts.map +1 -0
  24. package/dist/commands/find.js +48 -0
  25. package/dist/commands/grep.d.ts.map +1 -1
  26. package/dist/commands/grep.js +61 -35
  27. package/dist/commands/head.d.ts +3 -0
  28. package/dist/commands/head.d.ts.map +1 -0
  29. package/dist/commands/head.js +30 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +25 -35
  32. package/dist/commands/ln.d.ts +3 -0
  33. package/dist/commands/ln.d.ts.map +1 -0
  34. package/dist/commands/ln.js +42 -0
  35. package/dist/commands/mv.d.ts +3 -0
  36. package/dist/commands/mv.d.ts.map +1 -0
  37. package/dist/commands/mv.js +35 -0
  38. package/dist/commands/tail.d.ts +3 -0
  39. package/dist/commands/tail.d.ts.map +1 -0
  40. package/dist/commands/tail.js +33 -0
  41. package/dist/commands/wc.d.ts +3 -0
  42. package/dist/commands/wc.d.ts.map +1 -0
  43. package/dist/commands/wc.js +48 -0
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/standalone.js +7 -9
  47. package/package.json +7 -3
  48. package/scripts/publish-package.sh +70 -0
  49. package/src/SSHMimic/index.ts +159 -17
  50. package/src/VirtualFileSystem/index.ts +500 -280
  51. package/src/VirtualShell/index.ts +5 -33
  52. package/src/VirtualUserManager/index.ts +92 -26
  53. package/src/commands/chmod.ts +33 -0
  54. package/src/commands/cp.ts +76 -0
  55. package/src/commands/find.ts +61 -0
  56. package/src/commands/grep.ts +54 -38
  57. package/src/commands/head.ts +35 -0
  58. package/src/commands/index.ts +25 -43
  59. package/src/commands/ln.ts +47 -0
  60. package/src/commands/mv.ts +43 -0
  61. package/src/commands/tail.ts +37 -0
  62. package/src/commands/wc.ts +48 -0
  63. package/src/index.ts +1 -0
  64. package/src/standalone.ts +12 -9
  65. package/standalone.js +102 -0
  66. package/standalone.js.map +7 -0
  67. package/tests/bun-test-shim.ts +1 -0
  68. package/tests/sftp.test.ts +115 -191
  69. package/tests/users.test.ts +66 -83
@@ -1,435 +1,655 @@
1
1
  import { EventEmitter } from "node:events";
2
- import * as fs from "node:fs";
2
+ import * as fsSync from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
5
+ import type {
6
+ InternalDirectoryNode,
7
+ InternalFileNode,
8
+ InternalNode,
9
+ } from "./internalTypes";
10
+ import { getNode, getParentDirectory, normalizePath } from "./path";
5
11
  import type {
6
12
  RemoveOptions,
7
13
  VfsNodeStats,
14
+ VfsSnapshot,
15
+ VfsSnapshotDirectoryNode,
16
+ VfsSnapshotFileNode,
17
+ VfsSnapshotNode,
8
18
  WriteFileOptions,
9
19
  } from "../types/vfs";
10
- import type { PerfLogger } from "../utils/perfLogger";
11
- import { createPerfLogger } from "../utils/perfLogger";
12
- import { normalizePath } from "./path";
20
+
21
+ // ── Persistence options ───────────────────────────────────────────────────────
13
22
 
14
23
  /**
15
- * In-memory virtual filesystem with tar.gz mirror persistence.
24
+ * "memory" pure in-memory, no disk I/O (default).
16
25
  *
17
- * Paths are normalized to POSIX-like absolute paths. Use
18
- * {@link VirtualFileSystem.restoreMirror} on startup and
19
- * {@link VirtualFileSystem.flushMirror} to persist pending changes.
26
+ * "fs" — mirrors the VFS tree to a directory on the host filesystem.
27
+ * `snapshotPath` must be set to the directory where the JSON
28
+ * snapshot file will be read/written.
20
29
  */
21
- const perf: PerfLogger = createPerfLogger("VirtualFileSystem");
22
-
23
- class VirtualFileSystem extends EventEmitter {
24
- private readonly mirrorRoot: string;
30
+ export type VfsPersistenceMode = "memory" | "fs";
25
31
 
26
- private ensureMirrorRoot(): void {
27
- fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
28
- }
29
-
30
- private resolveFsPath(targetPath: string): string {
31
- const normalized = normalizePath(targetPath);
32
- const relativePath = normalized.slice(1);
33
- const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
34
- const relative = path.relative(this.mirrorRoot, resolved);
32
+ export interface VfsOptions {
33
+ /**
34
+ * Persistence mode.
35
+ * - `"memory"` (default): no disk access, snapshot via `toSnapshot()`.
36
+ * - `"fs"`: auto-save JSON snapshot to `snapshotPath` on every
37
+ * `flushMirror()` call, and restore from it on `restoreMirror()`.
38
+ */
39
+ mode?: VfsPersistenceMode;
40
+ /**
41
+ * Directory used by `"fs"` mode.
42
+ * The snapshot file will be written to `<snapshotPath>/vfs-snapshot.json`.
43
+ * Required when `mode` is `"fs"`.
44
+ */
45
+ snapshotPath?: string;
46
+ }
35
47
 
36
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
37
- throw new Error(`Invalid path '${targetPath}'.`);
38
- }
48
+ // ── VirtualFileSystem ─────────────────────────────────────────────────────────
39
49
 
40
- return resolved;
41
- }
50
+ /**
51
+ * In-memory virtual filesystem with optional JSON-snapshot persistence.
52
+ *
53
+ * **Memory mode** (default) — all state lives in a fast recursive tree.
54
+ * Use `toSnapshot()` / `fromSnapshot()` / `importSnapshot()` for serialisation.
55
+ *
56
+ * **FS mode** — same in-memory tree, but `restoreMirror()` loads a JSON
57
+ * snapshot from disk and `flushMirror()` writes it back. This gives you
58
+ * persistent VFS state across process restarts without any real POSIX filesystem
59
+ * semantics leaking through.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * // Pure in-memory (default)
64
+ * const vfs = new VirtualFileSystem();
65
+ *
66
+ * // With disk persistence
67
+ * const vfs = new VirtualFileSystem({ mode: "fs", snapshotPath: "./data" });
68
+ * await vfs.restoreMirror(); // load from disk (no-op if no snapshot yet)
69
+ * // ... use vfs ...
70
+ * await vfs.flushMirror(); // persist to disk
71
+ * ```
72
+ */
73
+ class VirtualFileSystem extends EventEmitter {
74
+ private root: InternalDirectoryNode;
75
+ private readonly mode: VfsPersistenceMode;
76
+ private readonly snapshotFile: string | null;
42
77
 
43
- private detectGzipFile(targetPath: string): boolean {
44
- const fd = fs.openSync(targetPath, "r");
45
- try {
46
- const header = Buffer.alloc(2);
47
- const bytesRead = fs.readSync(fd, header, 0, 2, 0);
48
- return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
49
- } finally {
50
- fs.closeSync(fd);
78
+ constructor(options: VfsOptions = {}) {
79
+ super();
80
+ this.mode = options.mode ?? "memory";
81
+ if (this.mode === "fs") {
82
+ if (!options.snapshotPath) {
83
+ throw new Error(
84
+ 'VirtualFileSystem: "snapshotPath" is required when mode is "fs".',
85
+ );
86
+ }
87
+ this.snapshotFile = path.resolve(
88
+ options.snapshotPath,
89
+ "vfs-snapshot.json",
90
+ );
91
+ } else {
92
+ this.snapshotFile = null;
51
93
  }
94
+ this.root = this.makeDir("", 0o755);
52
95
  }
53
96
 
54
- private computeDiskUsageBytes(targetPath: string): number {
55
- const stats = fs.statSync(targetPath);
56
- if (stats.isFile()) {
57
- return stats.size;
58
- }
97
+ // ── Internal helpers ──────────────────────────────────────────────────────
59
98
 
60
- let total = 0;
61
- for (const entry of fs.readdirSync(targetPath)) {
62
- total += this.computeDiskUsageBytes(path.join(targetPath, entry));
63
- }
64
- return total;
99
+ private makeDir(name: string, mode: number): InternalDirectoryNode {
100
+ const now = new Date();
101
+ return {
102
+ type: "directory",
103
+ name,
104
+ mode,
105
+ createdAt: now,
106
+ updatedAt: now,
107
+ children: new Map(),
108
+ };
65
109
  }
66
110
 
67
- private renderTreeLines(targetPath: string, label: string): string {
68
- const lines = [label];
69
-
70
- const walk = (currentPath: string, prefix: string): void => {
71
- const entries = fs
72
- .readdirSync(currentPath, { withFileTypes: true })
73
- .map((entry) => entry.name)
74
- .sort((left, right) => left.localeCompare(right));
75
-
76
- for (let i = 0; i < entries.length; i += 1) {
77
- const name = entries[i]!;
78
- const isLast = i === entries.length - 1;
79
- const connector = isLast ? "└── " : "├── ";
80
- const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
81
- const entryPath = path.join(currentPath, name);
82
- const isDirectory = fs.statSync(entryPath).isDirectory();
83
-
84
- lines.push(`${prefix}${connector}${name}`);
85
- if (isDirectory) {
86
- walk(entryPath, nextPrefix);
87
- }
88
- }
111
+ private makeFile(
112
+ name: string,
113
+ content: Buffer,
114
+ mode: number,
115
+ compressed: boolean,
116
+ ): InternalFileNode {
117
+ const now = new Date();
118
+ return {
119
+ type: "file",
120
+ name,
121
+ content,
122
+ mode,
123
+ compressed,
124
+ createdAt: now,
125
+ updatedAt: now,
89
126
  };
90
-
91
- walk(targetPath, "");
92
- return lines.join("\n");
93
127
  }
94
128
 
95
- /**
96
- * Creates a virtual filesystem instance.
97
- *
98
- * @param baseDir Base directory used to resolve mirror archive location.
99
- */
100
- constructor(baseDir: string = process.cwd()) {
101
- super();
102
- perf.mark("constructor");
103
- this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
129
+ private mkdirRecursive(targetPath: string, mode: number): void {
130
+ const normalized = normalizePath(targetPath);
131
+ if (normalized === "/") return;
132
+ const parts = normalized.split("/").filter(Boolean);
133
+ let current = this.root;
134
+ let builtPath = "";
135
+ for (const part of parts) {
136
+ builtPath += `/${part}`;
137
+ let child = current.children.get(part);
138
+ if (!child) {
139
+ child = this.makeDir(part, mode);
140
+ current.children.set(part, child);
141
+ this.emit("dir:create", { path: builtPath, mode });
142
+ } else if (child.type !== "directory") {
143
+ throw new Error(
144
+ `Cannot create directory '${builtPath}': path is a file.`,
145
+ );
146
+ }
147
+ current = child as InternalDirectoryNode;
148
+ }
104
149
  }
105
150
 
151
+ // ── Persistence ───────────────────────────────────────────────────────────
152
+
106
153
  /**
107
- * Restores filesystem state from mirror archive.
154
+ * In `"fs"` mode: reads the JSON snapshot from disk and hydrates the tree.
155
+ * Silently succeeds when the snapshot file does not exist yet.
108
156
  *
109
- * If archive does not exist or cannot be read, creates fresh mirror file.
157
+ * In `"memory"` mode: no-op (kept for API compatibility).
110
158
  */
111
159
  public async restoreMirror(): Promise<void> {
112
- perf.mark("restoreMirror");
113
- this.ensureMirrorRoot();
160
+ if (this.mode !== "fs" || !this.snapshotFile) return;
161
+
162
+ if (!fsSync.existsSync(this.snapshotFile)) return;
163
+
164
+ try {
165
+ const raw = fsSync.readFileSync(this.snapshotFile, "utf8");
166
+ const snapshot: VfsSnapshot = JSON.parse(raw);
167
+ this.root = this.deserializeDir(snapshot.root, "");
168
+ this.emit("snapshot:restore", { path: this.snapshotFile });
169
+ } catch (err) {
170
+ // Corrupt or unreadable snapshot — start fresh and warn
171
+ console.warn(
172
+ `[VirtualFileSystem] Could not restore snapshot from ${this.snapshotFile}:`,
173
+ err instanceof Error ? err.message : String(err),
174
+ );
175
+ }
114
176
  }
115
177
 
116
178
  /**
117
- * Persists current filesystem state to mirror archive.
179
+ * In `"fs"` mode: serialises the in-memory tree to a JSON snapshot on disk.
180
+ * The directory is created if it does not exist.
118
181
  *
119
- * No-op when nothing changed and archive already exists.
182
+ * In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
120
183
  */
121
184
  public async flushMirror(): Promise<void> {
122
- perf.mark("flushMirror");
123
- this.ensureMirrorRoot();
124
- this.emit("mirror:flush");
185
+ if (this.mode !== "fs" || !this.snapshotFile) {
186
+ this.emit("mirror:flush");
187
+ return;
188
+ }
189
+
190
+ const dir = path.dirname(this.snapshotFile);
191
+ fsSync.mkdirSync(dir, { recursive: true });
192
+ const snapshot = this.toSnapshot();
193
+ fsSync.writeFileSync(this.snapshotFile, JSON.stringify(snapshot), "utf8");
194
+ this.emit("mirror:flush", { path: this.snapshotFile });
125
195
  }
126
196
 
127
- /**
128
- * Creates directory and any missing parent directories.
129
- *
130
- * @param targetPath Absolute or relative path to directory.
131
- * @param mode POSIX-like mode bits for new directories.
132
- */
197
+ /** Returns the current persistence mode. */
198
+ public getMode(): VfsPersistenceMode {
199
+ return this.mode;
200
+ }
201
+
202
+ /** Returns the snapshot file path used in `"fs"` mode, or `null`. */
203
+ public getSnapshotPath(): string | null {
204
+ return this.snapshotFile;
205
+ }
206
+
207
+ // ── Public filesystem API ─────────────────────────────────────────────────
208
+
209
+ /** Creates a directory (and any missing parents). */
133
210
  public mkdir(targetPath: string, mode: number = 0o755): void {
134
- perf.mark("mkdir");
135
- this.ensureMirrorRoot();
136
- const fsPath = this.resolveFsPath(targetPath);
137
- if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
211
+ const normalized = normalizePath(targetPath);
212
+ const existing = (() => {
213
+ try {
214
+ return getNode(this.root, normalized);
215
+ } catch {
216
+ return null;
217
+ }
218
+ })();
219
+ if (existing && existing.type !== "directory") {
138
220
  throw new Error(
139
- `Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
221
+ `Cannot create directory '${normalized}': path is a file.`,
140
222
  );
141
223
  }
142
- fs.mkdirSync(fsPath, { recursive: true, mode });
143
- this.emit("dir:create", { path: normalizePath(targetPath), mode });
224
+ this.mkdirRecursive(normalized, mode);
144
225
  }
145
226
 
146
227
  /**
147
- * Writes UTF-8 text or binary content into file.
148
- *
228
+ * Writes UTF-8 text or binary content into a file.
149
229
  * Parent directories are created when missing.
150
- *
151
- * @param targetPath Destination file path.
152
- * @param content File content as string or Buffer.
153
- * @param options Optional write behavior (mode, compression).
154
230
  */
155
231
  public writeFile(
156
232
  targetPath: string,
157
233
  content: string | Buffer,
158
234
  options: WriteFileOptions = {},
159
235
  ): void {
160
- perf.mark("writeFile");
161
- this.ensureMirrorRoot();
162
236
  const normalized = normalizePath(targetPath);
163
- const fsPath = this.resolveFsPath(normalized);
164
- const parentPath = path.dirname(fsPath);
165
- fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
237
+ const { parent, name } = getParentDirectory(
238
+ this.root,
239
+ normalized,
240
+ true,
241
+ (p) => this.mkdirRecursive(p, 0o755),
242
+ );
243
+
244
+ const existing = parent.children.get(name);
245
+ if (existing?.type === "directory") {
246
+ throw new Error(
247
+ `Cannot write file '${normalized}': path is a directory.`,
248
+ );
249
+ }
166
250
 
167
251
  const rawContent = Buffer.isBuffer(content)
168
252
  ? content
169
253
  : Buffer.from(content, "utf8");
170
254
  const shouldCompress = options.compress ?? false;
171
255
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
172
-
173
- if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
174
- throw new Error(
175
- `Cannot write file '${normalized}': path is a directory.`,
256
+ const mode = options.mode ?? 0o644;
257
+
258
+ if (existing) {
259
+ const f = existing as InternalFileNode;
260
+ f.content = storedContent;
261
+ f.compressed = shouldCompress;
262
+ f.mode = mode;
263
+ f.updatedAt = new Date();
264
+ } else {
265
+ parent.children.set(
266
+ name,
267
+ this.makeFile(name, storedContent, mode, shouldCompress),
176
268
  );
177
269
  }
178
270
 
179
- fs.writeFileSync(fsPath, storedContent);
180
- fs.chmodSync(fsPath, options.mode ?? 0o644);
181
271
  this.emit("file:write", { path: normalized, size: storedContent.length });
182
272
  }
183
273
 
184
274
  /**
185
- * Reads file content as UTF-8 text.
186
- *
187
- * Compressed files are transparently decompressed.
188
- *
189
- * @param targetPath Path to file.
190
- * @returns UTF-8 string content.
275
+ * Reads file content as a UTF-8 string.
276
+ * Gzip-compressed files are transparently decompressed.
191
277
  */
192
278
  public readFile(targetPath: string): string {
193
- perf.mark("readFile");
194
- this.ensureMirrorRoot();
195
- const fsPath = this.resolveFsPath(targetPath);
196
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
279
+ const normalized = normalizePath(targetPath);
280
+ const node = getNode(this.root, normalized);
281
+ if (node.type !== "file") {
197
282
  throw new Error(`Cannot read '${targetPath}': not a file.`);
198
283
  }
284
+ const f = node as InternalFileNode;
285
+ const raw = f.compressed ? gunzipSync(f.content) : f.content;
286
+ this.emit("file:read", { path: normalized, size: raw.length });
287
+ return raw.toString("utf8");
288
+ }
199
289
 
200
- const stored = fs.readFileSync(fsPath);
201
- const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
290
+ /** Reads file content as a Buffer (decompresses if needed). */
291
+ public readFileRaw(targetPath: string): Buffer {
202
292
  const normalized = normalizePath(targetPath);
293
+ const node = getNode(this.root, normalized);
294
+ if (node.type !== "file") {
295
+ throw new Error(`Cannot read '${targetPath}': not a file.`);
296
+ }
297
+ const f = node as InternalFileNode;
298
+ const raw = f.compressed ? gunzipSync(f.content) : f.content;
203
299
  this.emit("file:read", { path: normalized, size: raw.length });
204
- return raw.toString("utf8");
300
+ return raw;
205
301
  }
206
302
 
207
- /**
208
- * Checks whether node exists at path.
209
- *
210
- * @param targetPath Node path.
211
- * @returns True when file or directory exists.
212
- */
303
+ /** Returns true when a file or directory exists at path. */
213
304
  public exists(targetPath: string): boolean {
214
- perf.mark("exists");
215
305
  try {
216
- const fsPath = this.resolveFsPath(targetPath);
217
- return fs.existsSync(fsPath);
306
+ getNode(this.root, normalizePath(targetPath));
307
+ return true;
218
308
  } catch {
219
309
  return false;
220
310
  }
221
311
  }
222
312
 
223
- /**
224
- * Updates mode bits for file or directory.
225
- *
226
- * @param targetPath Node path.
227
- * @param mode New POSIX-like mode.
228
- */
313
+ /** Updates mode bits on a node. */
229
314
  public chmod(targetPath: string, mode: number): void {
230
- perf.mark("chmod");
231
- const fsPath = this.resolveFsPath(targetPath);
232
- if (!fs.existsSync(fsPath)) {
233
- throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
234
- }
235
- fs.chmodSync(fsPath, mode);
315
+ getNode(this.root, normalizePath(targetPath)).mode = mode;
236
316
  }
237
317
 
238
- /**
239
- * Returns metadata for file or directory.
240
- *
241
- * @param targetPath Node path.
242
- * @returns Typed stat object based on node type.
243
- */
318
+ /** Returns metadata for a file or directory. */
244
319
  public stat(targetPath: string): VfsNodeStats {
245
- perf.mark("stat");
246
- this.ensureMirrorRoot();
247
320
  const normalized = normalizePath(targetPath);
248
- const fsPath = this.resolveFsPath(normalized);
249
-
250
- if (!fs.existsSync(fsPath)) {
251
- throw new Error(`Path '${normalized}' does not exist.`);
252
- }
253
-
254
- const stats = fs.statSync(fsPath);
255
- const mode = stats.mode & 0o777;
321
+ const node = getNode(this.root, normalized);
256
322
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
257
-
258
- if (stats.isFile()) {
323
+ if (node.type === "file") {
324
+ const f = node as InternalFileNode;
259
325
  return {
260
326
  type: "file",
261
327
  name,
262
328
  path: normalized,
263
- mode,
264
- createdAt: stats.birthtime,
265
- updatedAt: stats.mtime,
266
- compressed: this.detectGzipFile(fsPath),
267
- size: stats.size,
329
+ mode: f.mode,
330
+ createdAt: f.createdAt,
331
+ updatedAt: f.updatedAt,
332
+ compressed: f.compressed,
333
+ size: f.content.length,
268
334
  };
269
335
  }
270
-
336
+ const d = node as InternalDirectoryNode;
271
337
  return {
272
338
  type: "directory",
273
339
  name,
274
340
  path: normalized,
275
- mode,
276
- createdAt: stats.birthtime,
277
- updatedAt: stats.mtime,
278
- childrenCount: fs.readdirSync(fsPath).length,
341
+ mode: d.mode,
342
+ createdAt: d.createdAt,
343
+ updatedAt: d.updatedAt,
344
+ childrenCount: d.children.size,
279
345
  };
280
346
  }
281
347
 
282
- /**
283
- * Lists direct children names of directory.
284
- *
285
- * @param dirPath Directory path, defaults to root.
286
- * @returns Sorted child names.
287
- */
348
+ /** Lists direct children names of a directory (sorted). */
288
349
  public list(dirPath: string = "/"): string[] {
289
- perf.mark("list");
290
- const fsPath = this.resolveFsPath(dirPath);
291
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
350
+ const normalized = normalizePath(dirPath);
351
+ const node = getNode(this.root, normalized);
352
+ if (node.type !== "directory") {
292
353
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
293
354
  }
294
-
295
- return fs.readdirSync(fsPath).sort();
355
+ return Array.from((node as InternalDirectoryNode).children.keys()).sort();
296
356
  }
297
357
 
298
- /**
299
- * Renders ASCII tree view of directory hierarchy.
300
- *
301
- * @param dirPath Directory path, defaults to root.
302
- * @returns Multi-line tree string.
303
- */
358
+ /** Renders ASCII tree view of a directory hierarchy. */
304
359
  public tree(dirPath: string = "/"): string {
305
- perf.mark("tree");
306
- const fsPath = this.resolveFsPath(dirPath);
307
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
360
+ const normalized = normalizePath(dirPath);
361
+ const node = getNode(this.root, normalized);
362
+ if (node.type !== "directory") {
308
363
  throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
309
364
  }
365
+ const label = dirPath === "/" ? "/" : path.posix.basename(normalized);
366
+ return this.renderTreeLines(node as InternalDirectoryNode, label);
367
+ }
310
368
 
311
- const rootLabel =
312
- dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
313
- return this.renderTreeLines(fsPath, rootLabel);
369
+ private renderTreeLines(dir: InternalDirectoryNode, label: string): string {
370
+ const lines = [label];
371
+ const entries = Array.from(dir.children.keys()).sort();
372
+ for (let i = 0; i < entries.length; i++) {
373
+ const name = entries[i]!;
374
+ const child = dir.children.get(name)!;
375
+ const isLast = i === entries.length - 1;
376
+ const connector = isLast ? "└── " : "├── ";
377
+ const nextPrefix = isLast ? " " : "│ ";
378
+ lines.push(`${connector}${name}`);
379
+ if (child.type === "directory") {
380
+ const sub = this.renderTreeLines(child as InternalDirectoryNode, "")
381
+ .split("\n")
382
+ .slice(1)
383
+ .map((l) => `${nextPrefix}${l}`);
384
+ lines.push(...sub);
385
+ }
386
+ }
387
+ return lines.join("\n");
314
388
  }
315
389
 
316
- /**
317
- * Computes total stored file bytes under a path.
318
- *
319
- * File usage is based on in-memory stored bytes, including compressed
320
- * payload size when files are marked as compressed.
321
- *
322
- * @param targetPath File or directory path to measure, defaults to root.
323
- * @returns Total byte usage for file content under target path.
324
- */
390
+ /** Computes total stored bytes under a path. */
325
391
  public getUsageBytes(targetPath: string = "/"): number {
326
- perf.mark("getUsageBytes");
327
- const fsPath = this.resolveFsPath(targetPath);
328
- if (!fs.existsSync(fsPath)) {
329
- throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
392
+ return this.computeUsage(getNode(this.root, normalizePath(targetPath)));
393
+ }
394
+
395
+ private computeUsage(node: InternalNode): number {
396
+ if (node.type === "file") return (node as InternalFileNode).content.length;
397
+ let total = 0;
398
+ for (const child of (node as InternalDirectoryNode).children.values()) {
399
+ total += this.computeUsage(child);
330
400
  }
331
- return this.computeDiskUsageBytes(fsPath);
401
+ return total;
332
402
  }
333
403
 
334
- /**
335
- * Compresses file content with gzip and flags node as compressed.
336
- *
337
- * @param targetPath Path to file.
338
- */
404
+ /** Compresses a file's content with gzip in place. */
339
405
  public compressFile(targetPath: string): void {
340
- perf.mark("compressFile");
341
- const fsPath = this.resolveFsPath(targetPath);
342
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
406
+ const node = getNode(this.root, normalizePath(targetPath));
407
+ if (node.type !== "file")
343
408
  throw new Error(`Cannot compress '${targetPath}': not a file.`);
409
+ const f = node as InternalFileNode;
410
+ if (!f.compressed) {
411
+ f.content = gzipSync(f.content);
412
+ f.compressed = true;
413
+ f.updatedAt = new Date();
344
414
  }
415
+ }
345
416
 
346
- if (!this.detectGzipFile(fsPath)) {
347
- const content = fs.readFileSync(fsPath);
348
- fs.writeFileSync(fsPath, gzipSync(content));
417
+ /** Decompresses a gzip-compressed file in place. */
418
+ public decompressFile(targetPath: string): void {
419
+ const node = getNode(this.root, normalizePath(targetPath));
420
+ if (node.type !== "file")
421
+ throw new Error(`Cannot decompress '${targetPath}': not a file.`);
422
+ const f = node as InternalFileNode;
423
+ if (f.compressed) {
424
+ f.content = gunzipSync(f.content);
425
+ f.compressed = false;
426
+ f.updatedAt = new Date();
349
427
  }
350
428
  }
351
429
 
352
430
  /**
353
- * Decompresses gzip-compressed file content.
354
- *
355
- * @param targetPath Path to file.
431
+ * Creates a symbolic link.
432
+ * The link node is stored with mode `0o120777` (POSIX symlink convention).
356
433
  */
357
- public decompressFile(targetPath: string): void {
358
- perf.mark("decompressFile");
359
- const fsPath = this.resolveFsPath(targetPath);
360
- if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
361
- throw new Error(`Cannot decompress '${targetPath}': not a file.`);
362
- }
434
+ public symlink(targetPath: string, linkPath: string): void {
435
+ const normalizedLink = normalizePath(linkPath);
436
+ const normalizedTarget = targetPath.startsWith("/")
437
+ ? normalizePath(targetPath)
438
+ : targetPath;
439
+ const { parent, name } = getParentDirectory(
440
+ this.root,
441
+ normalizedLink,
442
+ true,
443
+ (p) => this.mkdirRecursive(p, 0o755),
444
+ );
445
+ const symNode: InternalFileNode = {
446
+ type: "file",
447
+ name,
448
+ content: Buffer.from(normalizedTarget, "utf8"),
449
+ mode: 0o120777,
450
+ compressed: false,
451
+ createdAt: new Date(),
452
+ updatedAt: new Date(),
453
+ };
454
+ parent.children.set(name, symNode);
455
+ this.emit("symlink:create", {
456
+ link: normalizedLink,
457
+ target: normalizedTarget,
458
+ });
459
+ }
363
460
 
364
- if (this.detectGzipFile(fsPath)) {
365
- const content = fs.readFileSync(fsPath);
366
- fs.writeFileSync(fsPath, gunzipSync(content));
461
+ /** Returns true when the path is a symbolic link node. */
462
+ public isSymlink(targetPath: string): boolean {
463
+ try {
464
+ const node = getNode(this.root, normalizePath(targetPath));
465
+ return node.type === "file" && node.mode === 0o120777;
466
+ } catch {
467
+ return false;
367
468
  }
368
469
  }
369
470
 
370
471
  /**
371
- * Removes file or directory node.
372
- *
373
- * @param targetPath Path to remove.
374
- * @param options Removal options, including recursive delete.
472
+ * Resolves a symlink chain up to `maxDepth` hops.
473
+ * Throws when the chain is too long (circular links).
375
474
  */
376
- public remove(targetPath: string, options: RemoveOptions = {}): void {
377
- perf.mark("remove");
378
- const normalized = normalizePath(targetPath);
379
- if (normalized === "/") {
380
- throw new Error("Cannot remove root directory.");
381
- }
382
- const fsPath = this.resolveFsPath(normalized);
383
-
384
- if (!fs.existsSync(fsPath)) {
385
- throw new Error(`Path '${normalized}' does not exist.`);
475
+ public resolveSymlink(linkPath: string, maxDepth = 8): string {
476
+ let current = normalizePath(linkPath);
477
+ for (let depth = 0; depth < maxDepth; depth++) {
478
+ try {
479
+ const node = getNode(this.root, current);
480
+ if (node.type === "file" && node.mode === 0o120777) {
481
+ const target = (node as InternalFileNode).content.toString("utf8");
482
+ current = target.startsWith("/")
483
+ ? target
484
+ : normalizePath(
485
+ path.posix.join(path.posix.dirname(current), target),
486
+ );
487
+ continue;
488
+ }
489
+ } catch {
490
+ break;
491
+ }
492
+ return current;
386
493
  }
494
+ throw new Error(`Too many levels of symbolic links: ${linkPath}`);
495
+ }
387
496
 
388
- const stats = fs.statSync(fsPath);
389
- if (stats.isDirectory() && !options.recursive) {
390
- const entries = fs.readdirSync(fsPath);
391
- if (entries.length > 0) {
497
+ /** Removes a file or directory node. */
498
+ public remove(targetPath: string, options: RemoveOptions = {}): void {
499
+ const normalized = normalizePath(targetPath);
500
+ if (normalized === "/") throw new Error("Cannot remove root directory.");
501
+ const node = getNode(this.root, normalized);
502
+ if (node.type === "directory") {
503
+ const dir = node as InternalDirectoryNode;
504
+ if (!options.recursive && dir.children.size > 0) {
392
505
  throw new Error(
393
506
  `Directory '${normalized}' is not empty. Use recursive option.`,
394
507
  );
395
508
  }
396
509
  }
397
-
398
- if (stats.isDirectory()) {
399
- fs.rmSync(fsPath, { recursive: options.recursive ?? false });
400
- } else {
401
- fs.rmSync(fsPath);
402
- }
510
+ const { parent, name } = getParentDirectory(
511
+ this.root,
512
+ normalized,
513
+ false,
514
+ () => {},
515
+ );
516
+ parent.children.delete(name);
517
+ this.emit("node:remove", { path: normalized });
403
518
  }
404
519
 
405
- /**
406
- * Moves or renames node to destination path.
407
- *
408
- * @param fromPath Existing source path.
409
- * @param toPath Destination path.
410
- */
520
+ /** Moves or renames a node. */
411
521
  public move(fromPath: string, toPath: string): void {
412
- perf.mark("move");
413
522
  const fromNormalized = normalizePath(fromPath);
414
523
  const toNormalized = normalizePath(toPath);
415
-
416
524
  if (fromNormalized === "/" || toNormalized === "/") {
417
525
  throw new Error("Cannot move root directory.");
418
526
  }
527
+ const node = getNode(this.root, fromNormalized);
528
+ if (this.exists(toNormalized)) {
529
+ throw new Error(`Destination '${toNormalized}' already exists.`);
530
+ }
531
+ this.mkdirRecursive(path.posix.dirname(toNormalized), 0o755);
532
+ const { parent: destParent, name: destName } = getParentDirectory(
533
+ this.root,
534
+ toNormalized,
535
+ false,
536
+ () => {},
537
+ );
538
+ const { parent: srcParent, name: srcName } = getParentDirectory(
539
+ this.root,
540
+ fromNormalized,
541
+ false,
542
+ () => {},
543
+ );
544
+ srcParent.children.delete(srcName);
545
+ node.name = destName;
546
+ destParent.children.set(destName, node);
547
+ }
419
548
 
420
- const fromFsPath = this.resolveFsPath(fromNormalized);
421
- const toFsPath = this.resolveFsPath(toNormalized);
549
+ // ── Snapshot serialisation ─────────────────────────────────────────────────
422
550
 
423
- if (!fs.existsSync(fromFsPath)) {
424
- throw new Error(`Path '${fromNormalized}' does not exist.`);
425
- }
551
+ /**
552
+ * Exports the entire filesystem as a JSON-serialisable snapshot.
553
+ *
554
+ * Works regardless of the persistence mode. Useful for test fixtures,
555
+ * manual backups, or passing VFS state between processes.
556
+ */
557
+ public toSnapshot(): VfsSnapshot {
558
+ return { root: this.serializeDir(this.root) };
559
+ }
426
560
 
427
- if (fs.existsSync(toFsPath)) {
428
- throw new Error(`Destination '${toNormalized}' already exists.`);
561
+ private serializeDir(dir: InternalDirectoryNode): VfsSnapshotDirectoryNode {
562
+ const children: VfsSnapshotNode[] = [];
563
+ for (const child of dir.children.values()) {
564
+ children.push(
565
+ child.type === "file"
566
+ ? this.serializeFile(child as InternalFileNode)
567
+ : this.serializeDir(child as InternalDirectoryNode),
568
+ );
429
569
  }
570
+ return {
571
+ type: "directory",
572
+ name: dir.name,
573
+ mode: dir.mode,
574
+ createdAt: dir.createdAt.toISOString(),
575
+ updatedAt: dir.updatedAt.toISOString(),
576
+ children,
577
+ };
578
+ }
579
+
580
+ private serializeFile(file: InternalFileNode): VfsSnapshotFileNode {
581
+ return {
582
+ type: "file",
583
+ name: file.name,
584
+ mode: file.mode,
585
+ createdAt: file.createdAt.toISOString(),
586
+ updatedAt: file.updatedAt.toISOString(),
587
+ compressed: file.compressed,
588
+ contentBase64: file.content.toString("base64"),
589
+ };
590
+ }
591
+
592
+ /**
593
+ * Creates a new `VirtualFileSystem` instance (memory mode) from a snapshot.
594
+ *
595
+ * @example
596
+ * ```ts
597
+ * const vfs = VirtualFileSystem.fromSnapshot(savedSnapshot);
598
+ * ```
599
+ */
600
+ public static fromSnapshot(snapshot: VfsSnapshot): VirtualFileSystem {
601
+ const vfs = new VirtualFileSystem();
602
+ vfs.root = vfs.deserializeDir(snapshot.root, "");
603
+ return vfs;
604
+ }
605
+
606
+ /**
607
+ * Replaces the current filesystem state with the content of a snapshot.
608
+ * The persistence mode is preserved.
609
+ *
610
+ * @example
611
+ * ```ts
612
+ * vfs.importSnapshot(savedSnapshot);
613
+ * ```
614
+ */
615
+ public importSnapshot(snapshot: VfsSnapshot): void {
616
+ this.root = this.deserializeDir(snapshot.root, "");
617
+ this.emit("snapshot:import");
618
+ }
430
619
 
431
- fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
432
- fs.renameSync(fromFsPath, toFsPath);
620
+ private deserializeDir(
621
+ snap: VfsSnapshotDirectoryNode,
622
+ name: string,
623
+ ): InternalDirectoryNode {
624
+ const dir: InternalDirectoryNode = {
625
+ type: "directory",
626
+ name,
627
+ mode: snap.mode,
628
+ createdAt: new Date(snap.createdAt),
629
+ updatedAt: new Date(snap.updatedAt),
630
+ children: new Map(),
631
+ };
632
+ for (const child of snap.children) {
633
+ if (child.type === "file") {
634
+ const f = child as VfsSnapshotFileNode;
635
+ dir.children.set(f.name, {
636
+ type: "file",
637
+ name: f.name,
638
+ mode: f.mode,
639
+ createdAt: new Date(f.createdAt),
640
+ updatedAt: new Date(f.updatedAt),
641
+ compressed: f.compressed,
642
+ content: Buffer.from(f.contentBase64, "base64"),
643
+ });
644
+ } else {
645
+ const sub = this.deserializeDir(
646
+ child as VfsSnapshotDirectoryNode,
647
+ child.name,
648
+ );
649
+ dir.children.set(child.name, sub);
650
+ }
651
+ }
652
+ return dir;
433
653
  }
434
654
  }
435
655