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