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.
- package/README.md +871 -1231
- package/benchmark-results.txt +21 -21
- package/biome.json +9 -0
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +127 -15
- package/dist/VirtualFileSystem/index.d.ts +115 -88
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +406 -258
- package/dist/VirtualShell/index.d.ts +3 -4
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +5 -23
- package/dist/VirtualUserManager/index.d.ts +41 -3
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +83 -21
- package/dist/commands/chmod.d.ts +3 -0
- package/dist/commands/chmod.d.ts.map +1 -0
- package/dist/commands/chmod.js +31 -0
- package/dist/commands/cp.d.ts +3 -0
- package/dist/commands/cp.d.ts.map +1 -0
- package/dist/commands/cp.js +68 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.d.ts.map +1 -0
- package/dist/commands/find.js +48 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +61 -35
- package/dist/commands/head.d.ts +3 -0
- package/dist/commands/head.d.ts.map +1 -0
- package/dist/commands/head.js +30 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +25 -35
- package/dist/commands/ln.d.ts +3 -0
- package/dist/commands/ln.d.ts.map +1 -0
- package/dist/commands/ln.js +42 -0
- package/dist/commands/mv.d.ts +3 -0
- package/dist/commands/mv.d.ts.map +1 -0
- package/dist/commands/mv.js +35 -0
- package/dist/commands/tail.d.ts +3 -0
- package/dist/commands/tail.d.ts.map +1 -0
- package/dist/commands/tail.js +33 -0
- package/dist/commands/wc.d.ts +3 -0
- package/dist/commands/wc.d.ts.map +1 -0
- package/dist/commands/wc.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/standalone.js +7 -9
- package/package.json +7 -3
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/index.ts +159 -17
- package/src/VirtualFileSystem/index.ts +500 -280
- package/src/VirtualShell/index.ts +5 -33
- package/src/VirtualUserManager/index.ts +92 -26
- package/src/commands/chmod.ts +33 -0
- package/src/commands/cp.ts +76 -0
- package/src/commands/find.ts +61 -0
- package/src/commands/grep.ts +54 -38
- package/src/commands/head.ts +35 -0
- package/src/commands/index.ts +25 -43
- package/src/commands/ln.ts +47 -0
- package/src/commands/mv.ts +43 -0
- package/src/commands/tail.ts +37 -0
- package/src/commands/wc.ts +48 -0
- package/src/index.ts +1 -0
- package/src/standalone.ts +12 -9
- package/standalone.js +102 -0
- package/standalone.js.map +7 -0
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +66 -83
|
@@ -1,435 +1,655 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import * as
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
import { normalizePath } from "./path";
|
|
20
|
+
|
|
21
|
+
// ── Persistence options ───────────────────────────────────────────────────────
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
*
|
|
24
|
+
* "memory" — pure in-memory, no disk I/O (default).
|
|
16
25
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
class VirtualFileSystem extends EventEmitter {
|
|
24
|
-
private readonly mirrorRoot: string;
|
|
30
|
+
export type VfsPersistenceMode = "memory" | "fs";
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
throw new Error(`Invalid path '${targetPath}'.`);
|
|
38
|
-
}
|
|
48
|
+
// ── VirtualFileSystem ─────────────────────────────────────────────────────────
|
|
39
49
|
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
const stats = fs.statSync(targetPath);
|
|
56
|
-
if (stats.isFile()) {
|
|
57
|
-
return stats.size;
|
|
58
|
-
}
|
|
97
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
59
98
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
157
|
+
* In `"memory"` mode: no-op (kept for API compatibility).
|
|
110
158
|
*/
|
|
111
159
|
public async restoreMirror(): Promise<void> {
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
182
|
+
* In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
|
|
120
183
|
*/
|
|
121
184
|
public async flushMirror(): Promise<void> {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 '${
|
|
221
|
+
`Cannot create directory '${normalized}': path is a file.`,
|
|
140
222
|
);
|
|
141
223
|
}
|
|
142
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
this.
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
265
|
-
updatedAt:
|
|
266
|
-
compressed:
|
|
267
|
-
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:
|
|
277
|
-
updatedAt:
|
|
278
|
-
childrenCount:
|
|
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
|
-
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
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
|
-
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
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
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
421
|
-
const toFsPath = this.resolveFsPath(toNormalized);
|
|
549
|
+
// ── Snapshot serialisation ─────────────────────────────────────────────────
|
|
422
550
|
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|