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.
- package/README.md +868 -1245
- package/benchmark-results.txt +21 -21
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +116 -20
- 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 +4 -6
- package/dist/VirtualUserManager/index.d.ts +25 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +33 -0
- 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/package.json +5 -2
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/index.ts +143 -28
- package/src/VirtualFileSystem/index.ts +500 -280
- package/src/VirtualShell/index.ts +4 -6
- package/src/VirtualUserManager/index.ts +41 -0
- 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/standalone.js +62 -52
- package/standalone.js.map +4 -4
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +66 -83
|
@@ -1,371 +1,519 @@
|
|
|
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 {
|
|
6
|
-
|
|
5
|
+
import { getNode, getParentDirectory, normalizePath } from "./path";
|
|
6
|
+
// ── VirtualFileSystem ─────────────────────────────────────────────────────────
|
|
7
7
|
/**
|
|
8
|
-
* In-memory virtual filesystem with
|
|
8
|
+
* In-memory virtual filesystem with optional JSON-snapshot persistence.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
38
|
-
|
|
43
|
+
else {
|
|
44
|
+
this.snapshotFile = null;
|
|
39
45
|
}
|
|
46
|
+
this.root = this.makeDir("", 0o755);
|
|
40
47
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
98
|
+
* In `"memory"` mode: no-op (kept for API compatibility).
|
|
89
99
|
*/
|
|
90
100
|
async restoreMirror() {
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
120
|
+
* In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
|
|
98
121
|
*/
|
|
99
122
|
async flushMirror() {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
const
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
this.
|
|
159
|
-
|
|
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
|
|
164
|
-
const raw =
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
return
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
223
|
-
updatedAt:
|
|
224
|
-
compressed:
|
|
225
|
-
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:
|
|
234
|
-
updatedAt:
|
|
235
|
-
childrenCount:
|
|
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
|
-
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
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
|
|
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
|
-
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
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
|
|
265
|
-
return this.renderTreeLines(
|
|
273
|
+
const label = dirPath === "/" ? "/" : path.posix.basename(normalized);
|
|
274
|
+
return this.renderTreeLines(node, label);
|
|
266
275
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
fs.writeFileSync(fsPath, gunzipSync(content));
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
314
366
|
}
|
|
315
367
|
}
|
|
316
368
|
/**
|
|
317
|
-
*
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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;
|