typescript-virtual-container 1.4.6 → 1.4.8
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/.vscode/settings.json +1 -0
- package/README.md +0 -1
- package/benchmark-results.txt +21 -21
- package/builds/self-standalone.js +1111 -299
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +993 -183
- package/builds/standalone-wo-sftp.js.map +4 -4
- package/builds/standalone.cjs +984 -173
- package/builds/standalone.cjs.map +4 -4
- package/dist/SSHMimic/exec.d.ts.map +1 -1
- package/dist/SSHMimic/exec.js +0 -1
- package/dist/SSHMimic/exec.js.map +1 -1
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +3 -1
- package/dist/SSHMimic/index.js.map +1 -1
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +0 -6
- package/dist/SSHMimic/sftp.js.map +1 -1
- package/dist/VirtualFileSystem/binaryPack.d.ts.map +1 -1
- package/dist/VirtualFileSystem/binaryPack.js +21 -9
- package/dist/VirtualFileSystem/binaryPack.js.map +1 -1
- package/dist/VirtualFileSystem/index.d.ts +93 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +361 -46
- package/dist/VirtualFileSystem/index.js.map +1 -1
- package/dist/VirtualFileSystem/internalTypes.d.ts +23 -4
- package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
- package/dist/VirtualFileSystem/journal.d.ts +47 -0
- package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
- package/dist/VirtualFileSystem/journal.js +178 -0
- package/dist/VirtualFileSystem/journal.js.map +1 -0
- package/dist/VirtualFileSystem/path.js +1 -1
- package/dist/VirtualFileSystem/path.js.map +1 -1
- package/dist/VirtualShell/idleManager.d.ts +65 -0
- package/dist/VirtualShell/idleManager.d.ts.map +1 -0
- package/dist/VirtualShell/idleManager.js +106 -0
- package/dist/VirtualShell/idleManager.js.map +1 -0
- package/dist/VirtualShell/index.d.ts +28 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +48 -0
- package/dist/VirtualShell/index.js.map +1 -1
- package/dist/VirtualShell/shell.js +4 -4
- package/dist/VirtualShell/shell.js.map +1 -1
- package/dist/commands/man.d.ts.map +1 -1
- package/dist/commands/man.js +5 -27
- package/dist/commands/man.js.map +1 -1
- package/dist/commands/manuals-bundle.d.ts +11 -0
- package/dist/commands/manuals-bundle.d.ts.map +1 -0
- package/dist/commands/manuals-bundle.js +898 -0
- package/dist/commands/manuals-bundle.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/linuxRootfs.d.ts +8 -1
- package/dist/modules/linuxRootfs.d.ts.map +1 -1
- package/dist/modules/linuxRootfs.js +47 -14
- package/dist/modules/linuxRootfs.js.map +1 -1
- package/dist/self-standalone.js +16 -1
- package/dist/self-standalone.js.map +1 -1
- package/dist/standalone.js +22 -0
- package/dist/standalone.js.map +1 -1
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/HoneyPot.html +8 -8
- package/docs/classes/IdleManager.html +159 -0
- package/docs/classes/SshClient.html +18 -18
- package/docs/classes/VirtualFileSystem.html +57 -32
- package/docs/classes/VirtualPackageManager.html +12 -12
- package/docs/classes/VirtualSftpServer.html +3 -3
- package/docs/classes/VirtualShell.html +40 -25
- package/docs/classes/VirtualSshServer.html +5 -5
- package/docs/classes/VirtualUserManager.html +26 -26
- package/docs/functions/assertDiff.html +1 -1
- package/docs/functions/diffSnapshots.html +1 -1
- package/docs/functions/formatDiff.html +1 -1
- package/docs/functions/getArg.html +1 -1
- package/docs/functions/getFlag.html +1 -1
- package/docs/functions/ifFlag.html +1 -1
- package/docs/hierarchy.html +1 -1
- package/docs/index.html +1 -2
- package/docs/interfaces/AuditLogEntry.html +2 -2
- package/docs/interfaces/CommandContext.html +11 -11
- package/docs/interfaces/CommandResult.html +12 -12
- package/docs/interfaces/ExecStream.html +5 -5
- package/docs/interfaces/HoneyPotStats.html +2 -2
- package/docs/interfaces/IdleManagerOptions.html +7 -0
- package/docs/interfaces/InstalledPackage.html +10 -10
- package/docs/interfaces/NanoEditorSession.html +4 -4
- package/docs/interfaces/PackageDefinition.html +13 -13
- package/docs/interfaces/PackageFile.html +4 -4
- package/docs/interfaces/RemoveOptions.html +2 -2
- package/docs/interfaces/ShellEnv.html +3 -3
- package/docs/interfaces/ShellModule.html +7 -7
- package/docs/interfaces/ShellProperties.html +4 -4
- package/docs/interfaces/ShellStream.html +6 -6
- package/docs/interfaces/SudoChallenge.html +8 -8
- package/docs/interfaces/VfsBaseNode.html +6 -6
- package/docs/interfaces/VfsDiff.html +5 -5
- package/docs/interfaces/VfsDiffEntry.html +3 -3
- package/docs/interfaces/VfsDiffModified.html +5 -5
- package/docs/interfaces/VfsDirectoryNode.html +7 -7
- package/docs/interfaces/VfsFileNode.html +8 -8
- package/docs/interfaces/VfsOptions.html +18 -4
- package/docs/interfaces/VfsSnapshot.html +2 -2
- package/docs/interfaces/VfsSnapshotBaseNode.html +3 -3
- package/docs/interfaces/VfsSnapshotDirectoryNode.html +4 -4
- package/docs/interfaces/VfsSnapshotFileNode.html +5 -5
- package/docs/interfaces/WriteFileOptions.html +3 -3
- package/docs/modules.html +1 -1
- package/docs/types/CommandMode.html +1 -1
- package/docs/types/CommandOutcome.html +1 -1
- package/docs/types/IdleState.html +1 -0
- package/docs/types/VfsNodeStats.html +1 -1
- package/docs/types/VfsNodeType.html +1 -1
- package/docs/types/VfsPersistenceMode.html +1 -1
- package/docs/types/VfsSnapshotNode.html +1 -1
- package/package.json +5 -4
- package/scripts/generate-manuals-bundle.mjs +49 -0
- package/src/SSHMimic/exec.ts +0 -1
- package/src/SSHMimic/index.ts +3 -1
- package/src/SSHMimic/sftp.ts +0 -6
- package/src/VirtualFileSystem/binaryPack.ts +21 -9
- package/src/VirtualFileSystem/index.ts +369 -52
- package/src/VirtualFileSystem/internalTypes.ts +24 -4
- package/src/VirtualFileSystem/journal.ts +163 -0
- package/src/VirtualFileSystem/path.ts +1 -1
- package/src/VirtualShell/idleManager.ts +133 -0
- package/src/VirtualShell/index.ts +48 -0
- package/src/VirtualShell/shell.ts +4 -4
- package/src/commands/man.ts +5 -35
- package/src/commands/manuals-bundle.ts +898 -0
- package/src/index.ts +2 -0
- package/src/modules/linuxRootfs.ts +58 -14
- package/src/self-standalone.ts +14 -1
- package/src/standalone.ts +23 -0
- package/builds/standalone.js +0 -491
- package/builds/standalone.js.map +0 -7
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
/** biome-ignore-all lint/style/useNamingConvention: NW ? */
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
import * as fsSync from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
6
|
import { decodeVfs, encodeVfs, isBinarySnapshot } from "./binaryPack";
|
|
7
|
+
import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
|
|
6
8
|
import { getNode, getParentDirectory, normalizePath } from "./path";
|
|
7
9
|
// ── VirtualFileSystem ─────────────────────────────────────────────────────────
|
|
8
10
|
/**
|
|
@@ -32,6 +34,18 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
32
34
|
root;
|
|
33
35
|
mode;
|
|
34
36
|
snapshotFile;
|
|
37
|
+
/** Path to the WAL journal file (null in memory mode). */
|
|
38
|
+
journalFile;
|
|
39
|
+
/** Eviction threshold in bytes (0 = disabled). Files above this are purged after flush. */
|
|
40
|
+
evictionThreshold;
|
|
41
|
+
/** Max writes between forced flushes (0 = disabled). */
|
|
42
|
+
flushAfterNWrites;
|
|
43
|
+
/** Pending write counter since last checkpoint. */
|
|
44
|
+
_writesSinceFlush = 0;
|
|
45
|
+
/** NodeJS timer handle for periodic auto-flush (null = disabled or stopped). */
|
|
46
|
+
_flushTimer = null;
|
|
47
|
+
/** True if the VFS has unflushed changes. */
|
|
48
|
+
_dirty = false;
|
|
35
49
|
/** Active host-directory mounts: vPath → { hostPath, readOnly } */
|
|
36
50
|
mounts = new Map();
|
|
37
51
|
/** True when running in a browser environment (no host FS access). */
|
|
@@ -44,26 +58,44 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
44
58
|
throw new Error('VirtualFileSystem: "snapshotPath" is required when mode is "fs".');
|
|
45
59
|
}
|
|
46
60
|
this.snapshotFile = path.resolve(options.snapshotPath, "vfs-snapshot.vfsb");
|
|
61
|
+
this.journalFile = path.resolve(options.snapshotPath, "vfs-journal.bin");
|
|
62
|
+
this.evictionThreshold = options.evictionThresholdBytes ?? 64 * 1024; // 64 KB default
|
|
63
|
+
this.flushAfterNWrites = options.flushAfterNWrites ?? 500;
|
|
64
|
+
const intervalMs = options.flushIntervalMs ?? 30_000;
|
|
65
|
+
if (intervalMs > 0) {
|
|
66
|
+
this._flushTimer = setInterval(() => {
|
|
67
|
+
if (this._dirty)
|
|
68
|
+
void this._autoFlush();
|
|
69
|
+
}, intervalMs);
|
|
70
|
+
// Don't block process exit on this timer
|
|
71
|
+
if (typeof this._flushTimer === "object" && this._flushTimer !== null && "unref" in this._flushTimer) {
|
|
72
|
+
this._flushTimer.unref();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
else {
|
|
49
77
|
this.snapshotFile = null;
|
|
78
|
+
this.journalFile = null;
|
|
79
|
+
this.evictionThreshold = 0; // disabled in memory mode
|
|
80
|
+
this.flushAfterNWrites = 0;
|
|
50
81
|
}
|
|
51
82
|
this.root = this.makeDir("", 0o755);
|
|
52
83
|
}
|
|
53
84
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
54
85
|
makeDir(name, mode) {
|
|
55
|
-
const now =
|
|
86
|
+
const now = Date.now();
|
|
56
87
|
return {
|
|
57
88
|
type: "directory",
|
|
58
89
|
name,
|
|
59
90
|
mode,
|
|
60
91
|
createdAt: now,
|
|
61
92
|
updatedAt: now,
|
|
62
|
-
children:
|
|
93
|
+
children: Object.create(null),
|
|
94
|
+
_childCount: 0,
|
|
63
95
|
};
|
|
64
96
|
}
|
|
65
97
|
makeFile(name, content, mode, compressed) {
|
|
66
|
-
const now =
|
|
98
|
+
const now = Date.now();
|
|
67
99
|
return {
|
|
68
100
|
type: "file",
|
|
69
101
|
name,
|
|
@@ -74,6 +106,30 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
74
106
|
updatedAt: now,
|
|
75
107
|
};
|
|
76
108
|
}
|
|
109
|
+
makeStub(name, content, mode) {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
return { type: "stub", name, stubContent: content, mode, createdAt: now, updatedAt: now };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Write a lazy stub — stores content as a plain string with no Buffer allocation.
|
|
115
|
+
* Use for static rootfs files that may never be read. On first `writeFile()`,
|
|
116
|
+
* the stub is promoted to a real `InternalFileNode`.
|
|
117
|
+
* Parent directories are created when missing.
|
|
118
|
+
*/
|
|
119
|
+
writeStub(targetPath, content, mode = 0o644) {
|
|
120
|
+
const normalized = normalizePath(targetPath);
|
|
121
|
+
const { parent, name } = getParentDirectory(this.root, normalized, true, (p) => this.mkdirRecursive(p, 0o755));
|
|
122
|
+
const existing = parent.children[name];
|
|
123
|
+
if (existing?.type === "directory") {
|
|
124
|
+
throw new Error(`Cannot write stub '${normalized}': path is a directory.`);
|
|
125
|
+
}
|
|
126
|
+
// Don't overwrite a real file or an already-promoted node
|
|
127
|
+
if (existing?.type === "file")
|
|
128
|
+
return;
|
|
129
|
+
if (!existing)
|
|
130
|
+
parent._childCount++;
|
|
131
|
+
parent.children[name] = this.makeStub(name, content, mode);
|
|
132
|
+
}
|
|
77
133
|
mkdirRecursive(targetPath, mode) {
|
|
78
134
|
const normalized = normalizePath(targetPath);
|
|
79
135
|
if (normalized === "/")
|
|
@@ -83,11 +139,13 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
83
139
|
let builtPath = "";
|
|
84
140
|
for (const part of parts) {
|
|
85
141
|
builtPath += `/${part}`;
|
|
86
|
-
let child = current.children
|
|
142
|
+
let child = current.children[part];
|
|
87
143
|
if (!child) {
|
|
88
144
|
child = this.makeDir(part, mode);
|
|
89
|
-
current.children
|
|
145
|
+
current.children[part] = child;
|
|
146
|
+
current._childCount++;
|
|
90
147
|
this.emit("dir:create", { path: builtPath, mode });
|
|
148
|
+
this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
|
|
91
149
|
}
|
|
92
150
|
else if (child.type !== "directory") {
|
|
93
151
|
throw new Error(`Cannot create directory '${builtPath}': path is a file.`);
|
|
@@ -106,8 +164,15 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
106
164
|
async restoreMirror() {
|
|
107
165
|
if (this.mode !== "fs" || !this.snapshotFile)
|
|
108
166
|
return;
|
|
109
|
-
if (!fsSync.existsSync(this.snapshotFile))
|
|
167
|
+
if (!fsSync.existsSync(this.snapshotFile)) {
|
|
168
|
+
// No snapshot yet — but replay journal if it exists (crash after writes, before first flush)
|
|
169
|
+
if (this.journalFile) {
|
|
170
|
+
const entries = readJournal(this.journalFile);
|
|
171
|
+
if (entries.length > 0)
|
|
172
|
+
this._replayJournal(entries);
|
|
173
|
+
}
|
|
110
174
|
return;
|
|
175
|
+
}
|
|
111
176
|
try {
|
|
112
177
|
const raw = fsSync.readFileSync(this.snapshotFile);
|
|
113
178
|
if (isBinarySnapshot(raw)) {
|
|
@@ -121,6 +186,12 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
121
186
|
console.info("[VirtualFileSystem] Migrating legacy JSON snapshot to binary format.");
|
|
122
187
|
}
|
|
123
188
|
this.emit("snapshot:restore", { path: this.snapshotFile });
|
|
189
|
+
// Replay WAL journal on top of the loaded snapshot
|
|
190
|
+
if (this.journalFile) {
|
|
191
|
+
const entries = readJournal(this.journalFile);
|
|
192
|
+
if (entries.length > 0)
|
|
193
|
+
this._replayJournal(entries);
|
|
194
|
+
}
|
|
124
195
|
}
|
|
125
196
|
catch (err) {
|
|
126
197
|
// Corrupt or unreadable snapshot — start fresh and warn
|
|
@@ -143,7 +214,14 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
143
214
|
fsSync.mkdirSync(dir, { recursive: true });
|
|
144
215
|
const binary = encodeVfs(this.root);
|
|
145
216
|
fsSync.writeFileSync(this.snapshotFile, binary);
|
|
217
|
+
// Checkpoint complete — truncate the journal (entries are now in the snapshot)
|
|
218
|
+
if (this.journalFile)
|
|
219
|
+
truncateJournal(this.journalFile);
|
|
220
|
+
this._dirty = false;
|
|
221
|
+
this._writesSinceFlush = 0;
|
|
146
222
|
this.emit("mirror:flush", { path: this.snapshotFile });
|
|
223
|
+
// Evict large files from RAM now that the snapshot is on disk
|
|
224
|
+
this.evictLargeFiles();
|
|
147
225
|
}
|
|
148
226
|
/** Returns the current persistence mode. */
|
|
149
227
|
getMode() {
|
|
@@ -155,6 +233,183 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
155
233
|
}
|
|
156
234
|
// ── Public filesystem API ─────────────────────────────────────────────────
|
|
157
235
|
/** Creates a directory (and any missing parents). */
|
|
236
|
+
// ── Auto-flush scheduler ──────────────────────────────────────────────────
|
|
237
|
+
/** Internal: flush triggered by timer or write-count threshold. */
|
|
238
|
+
async _autoFlush() {
|
|
239
|
+
if (!this._dirty)
|
|
240
|
+
return;
|
|
241
|
+
await this.flushMirror();
|
|
242
|
+
}
|
|
243
|
+
/** Mark VFS as having unflushed writes and trigger threshold flush if needed. */
|
|
244
|
+
_markDirty() {
|
|
245
|
+
this._dirty = true;
|
|
246
|
+
if (this.flushAfterNWrites > 0) {
|
|
247
|
+
this._writesSinceFlush++;
|
|
248
|
+
if (this._writesSinceFlush >= this.flushAfterNWrites) {
|
|
249
|
+
this._writesSinceFlush = 0;
|
|
250
|
+
void this._autoFlush();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Stop the automatic flush timer and perform a final checkpoint.
|
|
256
|
+
* Call this when shutting down to ensure all data is persisted.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* process.on("SIGINT", async () => {
|
|
261
|
+
* await shell.vfs.stopAutoFlush();
|
|
262
|
+
* process.exit(0);
|
|
263
|
+
* });
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
async stopAutoFlush() {
|
|
267
|
+
if (this._flushTimer !== null) {
|
|
268
|
+
clearInterval(this._flushTimer);
|
|
269
|
+
this._flushTimer = null;
|
|
270
|
+
}
|
|
271
|
+
if (this._dirty)
|
|
272
|
+
await this.flushMirror();
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Replace the entire root tree — used internally by `bootstrapLinuxRootfs`
|
|
276
|
+
* to hot-swap the static rootfs snapshot without going through importSnapshot
|
|
277
|
+
* (which would re-journal every node in fs mode).
|
|
278
|
+
* @internal
|
|
279
|
+
*/
|
|
280
|
+
importRootTree(root) {
|
|
281
|
+
const prev = this._replayMode;
|
|
282
|
+
this._replayMode = true;
|
|
283
|
+
try {
|
|
284
|
+
this.root = root;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
this._replayMode = prev;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Serialise current tree to VFSB binary. Used for the static rootfs cache. */
|
|
291
|
+
encodeBinary() {
|
|
292
|
+
return encodeVfs(this.root);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Release the in-memory VFS tree, freeing all InternalNode objects for GC.
|
|
296
|
+
* The tree MUST be restored via `importRootTree()` before any VFS operation.
|
|
297
|
+
* Called by IdleManager when freezing an idle shell.
|
|
298
|
+
* @internal
|
|
299
|
+
*/
|
|
300
|
+
releaseTree() {
|
|
301
|
+
// Replace root with a minimal stub — keeps the object alive but frees all children
|
|
302
|
+
this.root = this.makeDir("", 0o755);
|
|
303
|
+
}
|
|
304
|
+
/** Set to true during journal replay to suppress re-journaling. */
|
|
305
|
+
_replayMode = false;
|
|
306
|
+
/** Append a journal entry if in fs mode and not replaying. */
|
|
307
|
+
_journal(entry) {
|
|
308
|
+
if (this.journalFile && !this._replayMode) {
|
|
309
|
+
appendJournalEntry(this.journalFile, entry);
|
|
310
|
+
this._markDirty();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Replay a list of journal entries onto the in-memory tree. */
|
|
314
|
+
_replayJournal(entries) {
|
|
315
|
+
this._replayMode = true;
|
|
316
|
+
try {
|
|
317
|
+
for (const e of entries) {
|
|
318
|
+
try {
|
|
319
|
+
if (e.op === JournalOp.WRITE) {
|
|
320
|
+
this.writeFile(e.path, e.content ?? Buffer.alloc(0), { mode: e.mode });
|
|
321
|
+
}
|
|
322
|
+
else if (e.op === JournalOp.MKDIR) {
|
|
323
|
+
this.mkdir(e.path, e.mode);
|
|
324
|
+
}
|
|
325
|
+
else if (e.op === JournalOp.REMOVE) {
|
|
326
|
+
if (this.exists(e.path))
|
|
327
|
+
this.remove(e.path, { recursive: true });
|
|
328
|
+
}
|
|
329
|
+
else if (e.op === JournalOp.CHMOD) {
|
|
330
|
+
if (this.exists(e.path))
|
|
331
|
+
this.chmod(e.path, e.mode ?? 0o644);
|
|
332
|
+
}
|
|
333
|
+
else if (e.op === JournalOp.MOVE) {
|
|
334
|
+
if (this.exists(e.path) && e.dest)
|
|
335
|
+
this.move(e.path, e.dest);
|
|
336
|
+
}
|
|
337
|
+
else if (e.op === JournalOp.SYMLINK) {
|
|
338
|
+
if (e.dest)
|
|
339
|
+
this.symlink(e.dest, e.path);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { /* ignore individual replay errors — best-effort */ }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
this._replayMode = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ── RAM eviction ──────────────────────────────────────────────────────────
|
|
350
|
+
/**
|
|
351
|
+
* Walk the in-memory tree and evict file contents that exceed
|
|
352
|
+
* `evictionThreshold`. Called automatically after `flushMirror()`.
|
|
353
|
+
* Safe to call at any time — evicted files are reloaded on demand.
|
|
354
|
+
*/
|
|
355
|
+
evictLargeFiles() {
|
|
356
|
+
if (!this.snapshotFile || this.evictionThreshold === 0)
|
|
357
|
+
return;
|
|
358
|
+
if (!fsSync.existsSync(this.snapshotFile))
|
|
359
|
+
return;
|
|
360
|
+
this._evictDir(this.root);
|
|
361
|
+
}
|
|
362
|
+
_evictDir(dir) {
|
|
363
|
+
for (const node of Object.values(dir.children)) {
|
|
364
|
+
if (node.type === "directory") {
|
|
365
|
+
this._evictDir(node);
|
|
366
|
+
}
|
|
367
|
+
else if (node.type === "file" && !node.evicted) {
|
|
368
|
+
const rawSize = node.compressed
|
|
369
|
+
? (node.size ?? node.content.length * 2) // estimate uncompressed
|
|
370
|
+
: node.content.length;
|
|
371
|
+
if (rawSize > this.evictionThreshold) {
|
|
372
|
+
node.size = rawSize;
|
|
373
|
+
node.content = Buffer.alloc(0); // free heap
|
|
374
|
+
node.evicted = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// stubs: nothing to evict — content is already a plain string, not a Buffer
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Reload a single evicted file node's content from the current snapshot.
|
|
382
|
+
* No-op if the node is not evicted.
|
|
383
|
+
*/
|
|
384
|
+
_reloadEvicted(node, normalizedPath) {
|
|
385
|
+
if (!node.evicted || !this.snapshotFile)
|
|
386
|
+
return;
|
|
387
|
+
if (!fsSync.existsSync(this.snapshotFile))
|
|
388
|
+
return;
|
|
389
|
+
try {
|
|
390
|
+
// Load and parse the snapshot to find this specific node
|
|
391
|
+
const raw = fsSync.readFileSync(this.snapshotFile);
|
|
392
|
+
const tmpRoot = decodeVfs(raw);
|
|
393
|
+
const parts = normalizedPath.split("/").filter(Boolean);
|
|
394
|
+
let cur = tmpRoot;
|
|
395
|
+
for (const part of parts) {
|
|
396
|
+
if (cur.type !== "directory")
|
|
397
|
+
return;
|
|
398
|
+
const next = cur.children[part];
|
|
399
|
+
if (!next)
|
|
400
|
+
return;
|
|
401
|
+
cur = next;
|
|
402
|
+
}
|
|
403
|
+
if (cur.type === "file") {
|
|
404
|
+
node.content = cur.content;
|
|
405
|
+
node.compressed = cur.compressed;
|
|
406
|
+
node.evicted = undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Snapshot unreadable — leave evicted; caller will get empty content
|
|
411
|
+
}
|
|
412
|
+
}
|
|
158
413
|
// ── Mount API ─────────────────────────────────────────────────────────────
|
|
159
414
|
/**
|
|
160
415
|
* Mount a host directory into the VFS at `vPath`.
|
|
@@ -262,7 +517,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
262
517
|
}
|
|
263
518
|
const normalized = normalizePath(targetPath);
|
|
264
519
|
const { parent, name } = getParentDirectory(this.root, normalized, true, (p) => this.mkdirRecursive(p, 0o755));
|
|
265
|
-
const existing = parent.children
|
|
520
|
+
const existing = parent.children[name];
|
|
266
521
|
if (existing?.type === "directory") {
|
|
267
522
|
throw new Error(`Cannot write file '${normalized}': path is a directory.`);
|
|
268
523
|
}
|
|
@@ -272,17 +527,22 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
272
527
|
const shouldCompress = options.compress ?? false;
|
|
273
528
|
const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
|
|
274
529
|
const mode = options.mode ?? 0o644;
|
|
275
|
-
if (existing) {
|
|
530
|
+
if (existing && existing.type === "file") {
|
|
531
|
+
// Update real file in place
|
|
276
532
|
const f = existing;
|
|
277
533
|
f.content = storedContent;
|
|
278
534
|
f.compressed = shouldCompress;
|
|
279
535
|
f.mode = mode;
|
|
280
|
-
f.updatedAt =
|
|
536
|
+
f.updatedAt = Date.now();
|
|
281
537
|
}
|
|
282
538
|
else {
|
|
283
|
-
|
|
539
|
+
// Create new real file — also promotes stubs (no _childCount change for stubs)
|
|
540
|
+
if (!existing)
|
|
541
|
+
parent._childCount++;
|
|
542
|
+
parent.children[name] = this.makeFile(name, storedContent, mode, shouldCompress);
|
|
284
543
|
}
|
|
285
544
|
this.emit("file:write", { path: normalized, size: storedContent.length });
|
|
545
|
+
this._journal({ op: JournalOp.WRITE, path: normalized, content: rawContent, mode });
|
|
286
546
|
}
|
|
287
547
|
/**
|
|
288
548
|
* Reads file content as a UTF-8 string.
|
|
@@ -297,10 +557,16 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
297
557
|
}
|
|
298
558
|
const normalized = normalizePath(targetPath);
|
|
299
559
|
const node = getNode(this.root, normalized);
|
|
560
|
+
if (node.type === "stub") {
|
|
561
|
+
this.emit("file:read", { path: normalized, size: node.stubContent.length });
|
|
562
|
+
return node.stubContent;
|
|
563
|
+
}
|
|
300
564
|
if (node.type !== "file") {
|
|
301
565
|
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
302
566
|
}
|
|
303
567
|
const f = node;
|
|
568
|
+
if (f.evicted)
|
|
569
|
+
this._reloadEvicted(f, normalized);
|
|
304
570
|
const raw = f.compressed ? gunzipSync(f.content) : f.content;
|
|
305
571
|
this.emit("file:read", { path: normalized, size: raw.length });
|
|
306
572
|
return raw.toString("utf8");
|
|
@@ -315,10 +581,17 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
315
581
|
}
|
|
316
582
|
const normalized = normalizePath(targetPath);
|
|
317
583
|
const node = getNode(this.root, normalized);
|
|
584
|
+
if (node.type === "stub") {
|
|
585
|
+
const buf = Buffer.from(node.stubContent, "utf8");
|
|
586
|
+
this.emit("file:read", { path: normalized, size: buf.length });
|
|
587
|
+
return buf;
|
|
588
|
+
}
|
|
318
589
|
if (node.type !== "file") {
|
|
319
590
|
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
320
591
|
}
|
|
321
592
|
const f = node;
|
|
593
|
+
if (f.evicted)
|
|
594
|
+
this._reloadEvicted(f, normalized);
|
|
322
595
|
const raw = f.compressed ? gunzipSync(f.content) : f.content;
|
|
323
596
|
this.emit("file:read", { path: normalized, size: raw.length });
|
|
324
597
|
return raw;
|
|
@@ -338,7 +611,9 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
338
611
|
}
|
|
339
612
|
/** Updates mode bits on a node. */
|
|
340
613
|
chmod(targetPath, mode) {
|
|
341
|
-
|
|
614
|
+
const normalized = normalizePath(targetPath);
|
|
615
|
+
getNode(this.root, normalized).mode = mode;
|
|
616
|
+
this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
|
|
342
617
|
}
|
|
343
618
|
/** Returns metadata for a file or directory. */
|
|
344
619
|
stat(targetPath) {
|
|
@@ -374,6 +649,19 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
374
649
|
const normalized = normalizePath(targetPath);
|
|
375
650
|
const node = getNode(this.root, normalized);
|
|
376
651
|
const name = normalized === "/" ? "" : path.posix.basename(normalized);
|
|
652
|
+
if (node.type === "stub") {
|
|
653
|
+
const s = node;
|
|
654
|
+
return {
|
|
655
|
+
type: "file",
|
|
656
|
+
name,
|
|
657
|
+
path: normalized,
|
|
658
|
+
mode: s.mode,
|
|
659
|
+
createdAt: new Date(s.createdAt),
|
|
660
|
+
updatedAt: new Date(s.updatedAt),
|
|
661
|
+
compressed: false,
|
|
662
|
+
size: s.stubContent.length,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
377
665
|
if (node.type === "file") {
|
|
378
666
|
const f = node;
|
|
379
667
|
return {
|
|
@@ -381,10 +669,10 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
381
669
|
name,
|
|
382
670
|
path: normalized,
|
|
383
671
|
mode: f.mode,
|
|
384
|
-
createdAt: f.createdAt,
|
|
385
|
-
updatedAt: f.updatedAt,
|
|
672
|
+
createdAt: new Date(f.createdAt),
|
|
673
|
+
updatedAt: new Date(f.updatedAt),
|
|
386
674
|
compressed: f.compressed,
|
|
387
|
-
size: f.content.length,
|
|
675
|
+
size: f.evicted ? (f.size ?? 0) : f.content.length,
|
|
388
676
|
};
|
|
389
677
|
}
|
|
390
678
|
const d = node;
|
|
@@ -393,9 +681,9 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
393
681
|
name,
|
|
394
682
|
path: normalized,
|
|
395
683
|
mode: d.mode,
|
|
396
|
-
createdAt: d.createdAt,
|
|
397
|
-
updatedAt: d.updatedAt,
|
|
398
|
-
childrenCount: d.
|
|
684
|
+
createdAt: new Date(d.createdAt),
|
|
685
|
+
updatedAt: new Date(d.updatedAt),
|
|
686
|
+
childrenCount: d._childCount,
|
|
399
687
|
};
|
|
400
688
|
}
|
|
401
689
|
/** Lists direct children names of a directory (sorted). */
|
|
@@ -416,7 +704,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
416
704
|
if (node.type !== "directory") {
|
|
417
705
|
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
418
706
|
}
|
|
419
|
-
return
|
|
707
|
+
return Object.keys(node.children).sort();
|
|
420
708
|
}
|
|
421
709
|
/** Renders ASCII tree view of a directory hierarchy. */
|
|
422
710
|
tree(dirPath = "/") {
|
|
@@ -430,10 +718,10 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
430
718
|
}
|
|
431
719
|
renderTreeLines(dir, label) {
|
|
432
720
|
const lines = [label];
|
|
433
|
-
const entries =
|
|
721
|
+
const entries = Object.keys(dir.children).sort();
|
|
434
722
|
for (let i = 0; i < entries.length; i++) {
|
|
435
723
|
const name = entries[i];
|
|
436
|
-
const child = dir.children
|
|
724
|
+
const child = dir.children[name];
|
|
437
725
|
const isLast = i === entries.length - 1;
|
|
438
726
|
const connector = isLast ? "└── " : "├── ";
|
|
439
727
|
const nextPrefix = isLast ? " " : "│ ";
|
|
@@ -455,8 +743,10 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
455
743
|
computeUsage(node) {
|
|
456
744
|
if (node.type === "file")
|
|
457
745
|
return node.content.length;
|
|
746
|
+
if (node.type === "stub")
|
|
747
|
+
return node.stubContent.length;
|
|
458
748
|
let total = 0;
|
|
459
|
-
for (const child of node.children
|
|
749
|
+
for (const child of Object.values(node.children)) {
|
|
460
750
|
total += this.computeUsage(child);
|
|
461
751
|
}
|
|
462
752
|
return total;
|
|
@@ -470,7 +760,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
470
760
|
if (!f.compressed) {
|
|
471
761
|
f.content = gzipSync(f.content);
|
|
472
762
|
f.compressed = true;
|
|
473
|
-
f.updatedAt =
|
|
763
|
+
f.updatedAt = Date.now();
|
|
474
764
|
}
|
|
475
765
|
}
|
|
476
766
|
/** Decompresses a gzip-compressed file in place. */
|
|
@@ -482,7 +772,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
482
772
|
if (f.compressed) {
|
|
483
773
|
f.content = gunzipSync(f.content);
|
|
484
774
|
f.compressed = false;
|
|
485
|
-
f.updatedAt =
|
|
775
|
+
f.updatedAt = Date.now();
|
|
486
776
|
}
|
|
487
777
|
}
|
|
488
778
|
/**
|
|
@@ -501,10 +791,13 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
501
791
|
content: Buffer.from(normalizedTarget, "utf8"),
|
|
502
792
|
mode: 0o120777,
|
|
503
793
|
compressed: false,
|
|
504
|
-
createdAt:
|
|
505
|
-
updatedAt:
|
|
794
|
+
createdAt: Date.now(),
|
|
795
|
+
updatedAt: Date.now(),
|
|
506
796
|
};
|
|
507
|
-
parent.children
|
|
797
|
+
parent.children[name] = symNode;
|
|
798
|
+
parent._childCount++;
|
|
799
|
+
// Journal before emit
|
|
800
|
+
this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
|
|
508
801
|
this.emit("symlink:create", {
|
|
509
802
|
link: normalizedLink,
|
|
510
803
|
target: normalizedTarget,
|
|
@@ -567,13 +860,15 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
567
860
|
const node = getNode(this.root, normalized);
|
|
568
861
|
if (node.type === "directory") {
|
|
569
862
|
const dir = node;
|
|
570
|
-
if (!options.recursive && dir.
|
|
863
|
+
if (!options.recursive && dir._childCount > 0) {
|
|
571
864
|
throw new Error(`Directory '${normalized}' is not empty. Use recursive option.`);
|
|
572
865
|
}
|
|
573
866
|
}
|
|
574
867
|
const { parent, name } = getParentDirectory(this.root, normalized, false, () => { });
|
|
575
|
-
parent.children
|
|
868
|
+
delete parent.children[name];
|
|
869
|
+
parent._childCount--;
|
|
576
870
|
this.emit("node:remove", { path: normalized });
|
|
871
|
+
this._journal({ op: JournalOp.REMOVE, path: normalized });
|
|
577
872
|
}
|
|
578
873
|
/** Moves or renames a node. */
|
|
579
874
|
move(fromPath, toPath) {
|
|
@@ -589,9 +884,12 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
589
884
|
this.mkdirRecursive(path.posix.dirname(toNormalized), 0o755);
|
|
590
885
|
const { parent: destParent, name: destName } = getParentDirectory(this.root, toNormalized, false, () => { });
|
|
591
886
|
const { parent: srcParent, name: srcName } = getParentDirectory(this.root, fromNormalized, false, () => { });
|
|
592
|
-
srcParent.children
|
|
887
|
+
delete srcParent.children[srcName];
|
|
888
|
+
srcParent._childCount--;
|
|
593
889
|
node.name = destName;
|
|
594
|
-
destParent.children
|
|
890
|
+
destParent.children[destName] = node;
|
|
891
|
+
destParent._childCount++;
|
|
892
|
+
this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
|
|
595
893
|
}
|
|
596
894
|
// ── Snapshot serialisation ─────────────────────────────────────────────────
|
|
597
895
|
/**
|
|
@@ -605,17 +903,32 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
605
903
|
}
|
|
606
904
|
serializeDir(dir) {
|
|
607
905
|
const children = [];
|
|
608
|
-
for (const child of dir.children
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
906
|
+
for (const child of Object.values(dir.children)) {
|
|
907
|
+
if (child.type === "stub") {
|
|
908
|
+
// Serialize stub as a regular file node
|
|
909
|
+
children.push({
|
|
910
|
+
type: "file",
|
|
911
|
+
name: child.name,
|
|
912
|
+
mode: child.mode,
|
|
913
|
+
createdAt: new Date(child.createdAt).toISOString(),
|
|
914
|
+
updatedAt: new Date(child.updatedAt).toISOString(),
|
|
915
|
+
compressed: false,
|
|
916
|
+
contentBase64: Buffer.from(child.stubContent, "utf8").toString("base64"),
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
else if (child.type === "file") {
|
|
920
|
+
children.push(this.serializeFile(child));
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
children.push(this.serializeDir(child));
|
|
924
|
+
}
|
|
612
925
|
}
|
|
613
926
|
return {
|
|
614
927
|
type: "directory",
|
|
615
928
|
name: dir.name,
|
|
616
929
|
mode: dir.mode,
|
|
617
|
-
createdAt: dir.createdAt.toISOString(),
|
|
618
|
-
updatedAt: dir.updatedAt.toISOString(),
|
|
930
|
+
createdAt: new Date(dir.createdAt).toISOString(),
|
|
931
|
+
updatedAt: new Date(dir.updatedAt).toISOString(),
|
|
619
932
|
children,
|
|
620
933
|
};
|
|
621
934
|
}
|
|
@@ -624,8 +937,8 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
624
937
|
type: "file",
|
|
625
938
|
name: file.name,
|
|
626
939
|
mode: file.mode,
|
|
627
|
-
createdAt: file.createdAt.toISOString(),
|
|
628
|
-
updatedAt: file.updatedAt.toISOString(),
|
|
940
|
+
createdAt: new Date(file.createdAt).toISOString(),
|
|
941
|
+
updatedAt: new Date(file.updatedAt).toISOString(),
|
|
629
942
|
compressed: file.compressed,
|
|
630
943
|
contentBase64: file.content.toString("base64"),
|
|
631
944
|
};
|
|
@@ -661,27 +974,29 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
661
974
|
type: "directory",
|
|
662
975
|
name,
|
|
663
976
|
mode: snap.mode,
|
|
664
|
-
createdAt:
|
|
665
|
-
updatedAt:
|
|
666
|
-
children:
|
|
977
|
+
createdAt: Date.parse(snap.createdAt),
|
|
978
|
+
updatedAt: Date.parse(snap.updatedAt),
|
|
979
|
+
children: Object.create(null),
|
|
980
|
+
_childCount: 0,
|
|
667
981
|
};
|
|
668
982
|
for (const child of snap.children) {
|
|
669
983
|
if (child.type === "file") {
|
|
670
984
|
const f = child;
|
|
671
|
-
dir.children
|
|
985
|
+
dir.children[f.name] = {
|
|
672
986
|
type: "file",
|
|
673
987
|
name: f.name,
|
|
674
988
|
mode: f.mode,
|
|
675
|
-
createdAt:
|
|
676
|
-
updatedAt:
|
|
989
|
+
createdAt: Date.parse(f.createdAt),
|
|
990
|
+
updatedAt: Date.parse(f.updatedAt),
|
|
677
991
|
compressed: f.compressed,
|
|
678
992
|
content: Buffer.from(f.contentBase64, "base64"),
|
|
679
|
-
}
|
|
993
|
+
};
|
|
680
994
|
}
|
|
681
995
|
else {
|
|
682
996
|
const sub = this.deserializeDir(child, child.name);
|
|
683
|
-
dir.children
|
|
997
|
+
dir.children[child.name] = sub;
|
|
684
998
|
}
|
|
999
|
+
dir._childCount++;
|
|
685
1000
|
}
|
|
686
1001
|
return dir;
|
|
687
1002
|
}
|