typescript-virtual-container 1.5.2 → 1.5.4
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 +43 -23
- package/dist/.tsbuildinfo +1 -0
- package/dist/SSHMimic/executor.js +23 -5
- package/dist/commands/basename.d.ts +13 -0
- package/dist/commands/basename.js +45 -0
- package/dist/commands/file.d.ts +8 -0
- package/dist/commands/file.js +57 -0
- package/dist/commands/fun.d.ts +32 -0
- package/dist/commands/fun.js +172 -0
- package/dist/commands/ifconfig.d.ts +7 -0
- package/dist/commands/ifconfig.js +52 -0
- package/dist/commands/last.d.ts +13 -0
- package/dist/commands/last.js +68 -0
- package/dist/commands/manuals-bundle.js +598 -6
- package/dist/commands/registry.js +24 -2
- package/dist/commands/runtime.js +159 -106
- package/dist/commands/sh.js +5 -0
- package/dist/commands/tput.d.ts +13 -0
- package/dist/commands/tput.js +76 -0
- package/dist/commands/w.d.ts +7 -0
- package/dist/commands/w.js +38 -0
- package/dist/utils/expand.d.ts +12 -0
- package/dist/utils/expand.js +84 -0
- package/package.json +9 -3
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -50
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -31
- package/.github/dependabot.yml +0 -27
- package/.github/pull_request_template.md +0 -21
- package/.github/workflows/create-pull-request.yml +0 -85
- package/.github/workflows/publish.yml +0 -25
- package/.github/workflows/test-battery.yml +0 -102
- package/.vscode/settings.json +0 -20
- package/CODE_OF_CONDUCT.md +0 -39
- package/CONTRIBUTING.md +0 -59
- package/HONEYPOT.md +0 -358
- package/SECURITY.md +0 -33
- package/benchmark-results.txt +0 -40
- package/benchmark-virtualshell.ts +0 -88
- package/biome.json +0 -37
- package/build.js +0 -22
- package/builds/fortune-nyx-v1.5.1-directbash-k6.1.0.mjs +0 -1768
- package/builds/fortune-nyx-v1.5.1-ssh-nosftp.js +0 -1768
- package/builds/fortune-nyx-v1.5.1-ssh.cjs +0 -1769
- package/builds/fortune-nyx-v1.5.1-web.min.js +0 -17022
- package/bun.lock +0 -244
- package/docs/.nojekyll +0 -1
- package/docs/app.js +0 -1755
- package/docs/assets/hierarchy.js +0 -1
- package/docs/assets/highlight.css +0 -162
- package/docs/assets/icons.js +0 -18
- package/docs/assets/icons.svg +0 -1
- package/docs/assets/main.js +0 -60
- package/docs/assets/navigation.js +0 -1
- package/docs/assets/search.js +0 -1
- package/docs/assets/style.css +0 -1633
- package/docs/classes/HoneyPot.html +0 -31
- package/docs/classes/IdleManager.html +0 -162
- package/docs/classes/SshClient.html +0 -66
- package/docs/classes/VirtualFileSystem.html +0 -279
- package/docs/classes/VirtualPackageManager.html +0 -63
- package/docs/classes/VirtualSftpServer.html +0 -169
- package/docs/classes/VirtualShell.html +0 -285
- package/docs/classes/VirtualSshServer.html +0 -182
- package/docs/classes/VirtualUserManager.html +0 -276
- package/docs/demo.html +0 -82
- package/docs/functions/assertDiff.html +0 -6
- package/docs/functions/diffSnapshots.html +0 -7
- package/docs/functions/formatDiff.html +0 -6
- package/docs/functions/getArg.html +0 -13
- package/docs/functions/getFlag.html +0 -15
- package/docs/functions/ifFlag.html +0 -11
- package/docs/hierarchy.html +0 -1
- package/docs/index.html +0 -1869
- package/docs/interfaces/AuditLogEntry.html +0 -6
- package/docs/interfaces/CommandContext.html +0 -22
- package/docs/interfaces/CommandResult.html +0 -26
- package/docs/interfaces/ExecStream.html +0 -11
- package/docs/interfaces/HoneyPotStats.html +0 -16
- package/docs/interfaces/IdleManagerOptions.html +0 -7
- package/docs/interfaces/InstalledPackage.html +0 -20
- package/docs/interfaces/NanoEditorSession.html +0 -8
- package/docs/interfaces/PackageDefinition.html +0 -30
- package/docs/interfaces/PackageFile.html +0 -8
- package/docs/interfaces/PasswordChallenge.html +0 -16
- package/docs/interfaces/RemoveOptions.html +0 -4
- package/docs/interfaces/ShellEnv.html +0 -6
- package/docs/interfaces/ShellModule.html +0 -14
- package/docs/interfaces/ShellProperties.html +0 -14
- package/docs/interfaces/ShellStream.html +0 -11
- package/docs/interfaces/SudoChallenge.html +0 -24
- package/docs/interfaces/VfsBaseNode.html +0 -12
- package/docs/interfaces/VfsDiff.html +0 -10
- package/docs/interfaces/VfsDiffEntry.html +0 -6
- package/docs/interfaces/VfsDiffModified.html +0 -10
- package/docs/interfaces/VfsDirectoryNode.html +0 -15
- package/docs/interfaces/VfsFileNode.html +0 -17
- package/docs/interfaces/VfsOptions.html +0 -26
- package/docs/interfaces/VfsSnapshot.html +0 -3
- package/docs/interfaces/VfsSnapshotBaseNode.html +0 -8
- package/docs/interfaces/VfsSnapshotDirectoryNode.html +0 -10
- package/docs/interfaces/VfsSnapshotFileNode.html +0 -12
- package/docs/interfaces/VirtualActiveSession.html +0 -12
- package/docs/interfaces/VirtualSftpServerOptions.html +0 -7
- package/docs/interfaces/VirtualShellVfsLike.html +0 -15
- package/docs/interfaces/VirtualShellVfsOptions.html +0 -3
- package/docs/interfaces/WriteFileOptions.html +0 -6
- package/docs/media/LICENSE +0 -21
- package/docs/modules.html +0 -1
- package/docs/types/ArgParseOptions.html +0 -4
- package/docs/types/CommandMode.html +0 -2
- package/docs/types/CommandOutcome.html +0 -2
- package/docs/types/IdleState.html +0 -1
- package/docs/types/VfsNodeStats.html +0 -2
- package/docs/types/VfsNodeType.html +0 -2
- package/docs/types/VfsPersistenceMode.html +0 -5
- package/docs/types/VfsSnapshotNode.html +0 -2
- package/examples/README.md +0 -288
- package/examples/app.js +0 -1755
- package/examples/app.ts +0 -299
- package/examples/build.js +0 -27
- package/examples/demo.html +0 -33
- package/examples/honeypot-audit.ts +0 -180
- package/examples/honeypot-export.ts +0 -253
- package/examples/honeypot-quickstart.ts +0 -110
- package/examples/index.html +0 -82
- package/examples/server.js +0 -55
- package/polyfills/buffer.js +0 -117
- package/polyfills/node_child_process/index.js +0 -2
- package/polyfills/node_crypto/index.js +0 -167
- package/polyfills/node_events/index.js +0 -9
- package/polyfills/node_fs/index.js +0 -202
- package/polyfills/node_fs/promises.js +0 -4
- package/polyfills/node_os/index.js +0 -9
- package/polyfills/node_path/index.js +0 -28
- package/polyfills/node_vm/index.js +0 -7
- package/polyfills/node_zlib/index.js +0 -3
- package/polyfills/process.js +0 -14
- package/polyfills/ssh2/index.js +0 -75
- package/scripts/build-all.mjs +0 -226
- package/scripts/build-names.mjs +0 -43
- package/scripts/generate-manuals-bundle.mjs +0 -49
- package/scripts/postinstall.js +0 -42
- package/scripts/publish-package.sh +0 -70
- package/src/Honeypot/index.ts +0 -457
- package/src/SSHClient/index.ts +0 -270
- package/src/SSHMimic/exec.ts +0 -49
- package/src/SSHMimic/executor.ts +0 -251
- package/src/SSHMimic/hostKey.ts +0 -21
- package/src/SSHMimic/index.ts +0 -337
- package/src/SSHMimic/loginBanner.ts +0 -36
- package/src/SSHMimic/loginFormat.ts +0 -10
- package/src/SSHMimic/prompt.ts +0 -14
- package/src/SSHMimic/sftp.ts +0 -883
- package/src/VirtualFileSystem/binaryPack.ts +0 -258
- package/src/VirtualFileSystem/index.ts +0 -1193
- package/src/VirtualFileSystem/internalTypes.ts +0 -43
- package/src/VirtualFileSystem/journal.ts +0 -171
- package/src/VirtualFileSystem/path.ts +0 -74
- package/src/VirtualPackageManager/index.ts +0 -1006
- package/src/VirtualShell/idleManager.ts +0 -137
- package/src/VirtualShell/index.ts +0 -475
- package/src/VirtualShell/shell.ts +0 -700
- package/src/VirtualShell/shellParser.ts +0 -285
- package/src/VirtualUserManager/index.ts +0 -758
- package/src/bun.d.ts +0 -1
- package/src/commands/adduser.ts +0 -103
- package/src/commands/alias.ts +0 -69
- package/src/commands/apt.ts +0 -233
- package/src/commands/awk.ts +0 -168
- package/src/commands/base64.ts +0 -29
- package/src/commands/cat.ts +0 -52
- package/src/commands/cd.ts +0 -25
- package/src/commands/chmod.ts +0 -85
- package/src/commands/clear.ts +0 -15
- package/src/commands/command-helpers.ts +0 -286
- package/src/commands/cp.ts +0 -83
- package/src/commands/curl.ts +0 -147
- package/src/commands/cut.ts +0 -36
- package/src/commands/date.ts +0 -30
- package/src/commands/declare.ts +0 -49
- package/src/commands/deluser.ts +0 -98
- package/src/commands/df.ts +0 -23
- package/src/commands/diff.ts +0 -43
- package/src/commands/dpkg.ts +0 -180
- package/src/commands/du.ts +0 -56
- package/src/commands/echo.ts +0 -58
- package/src/commands/env.ts +0 -23
- package/src/commands/exit.ts +0 -18
- package/src/commands/export.ts +0 -34
- package/src/commands/find.ts +0 -68
- package/src/commands/free.ts +0 -47
- package/src/commands/grep.ts +0 -116
- package/src/commands/groups.ts +0 -19
- package/src/commands/gzip.ts +0 -88
- package/src/commands/head.ts +0 -52
- package/src/commands/help.ts +0 -152
- package/src/commands/helpers.ts +0 -234
- package/src/commands/history.ts +0 -34
- package/src/commands/hostname.ts +0 -14
- package/src/commands/htop.ts +0 -20
- package/src/commands/id.ts +0 -19
- package/src/commands/index.ts +0 -9
- package/src/commands/kill.ts +0 -19
- package/src/commands/ln.ts +0 -71
- package/src/commands/ls.ts +0 -243
- package/src/commands/lsb-release.ts +0 -63
- package/src/commands/man.ts +0 -31
- package/src/commands/manuals/adduser.txt +0 -11
- package/src/commands/manuals/apt-cache.txt +0 -12
- package/src/commands/manuals/apt.txt +0 -20
- package/src/commands/manuals/awk.txt +0 -13
- package/src/commands/manuals/cat.txt +0 -14
- package/src/commands/manuals/cd.txt +0 -16
- package/src/commands/manuals/chmod.txt +0 -16
- package/src/commands/manuals/clear.txt +0 -10
- package/src/commands/manuals/cp.txt +0 -10
- package/src/commands/manuals/curl.txt +0 -20
- package/src/commands/manuals/date.txt +0 -14
- package/src/commands/manuals/declare.txt +0 -12
- package/src/commands/manuals/deluser.txt +0 -10
- package/src/commands/manuals/df.txt +0 -10
- package/src/commands/manuals/dpkg-query.txt +0 -11
- package/src/commands/manuals/dpkg.txt +0 -14
- package/src/commands/manuals/du.txt +0 -11
- package/src/commands/manuals/echo.txt +0 -11
- package/src/commands/manuals/false.txt +0 -10
- package/src/commands/manuals/find.txt +0 -11
- package/src/commands/manuals/free.txt +0 -12
- package/src/commands/manuals/grep.txt +0 -13
- package/src/commands/manuals/groups.txt +0 -10
- package/src/commands/manuals/gzip.txt +0 -11
- package/src/commands/manuals/head.txt +0 -10
- package/src/commands/manuals/help.txt +0 -11
- package/src/commands/manuals/history.txt +0 -11
- package/src/commands/manuals/hostname.txt +0 -10
- package/src/commands/manuals/id.txt +0 -10
- package/src/commands/manuals/kill.txt +0 -13
- package/src/commands/manuals/ls.txt +0 -20
- package/src/commands/manuals/lsb_release.txt +0 -14
- package/src/commands/manuals/mkdir.txt +0 -10
- package/src/commands/manuals/mv.txt +0 -10
- package/src/commands/manuals/nano.txt +0 -11
- package/src/commands/manuals/neofetch.txt +0 -10
- package/src/commands/manuals/node.txt +0 -13
- package/src/commands/manuals/npm.txt +0 -13
- package/src/commands/manuals/npx.txt +0 -13
- package/src/commands/manuals/passwd.txt +0 -11
- package/src/commands/manuals/ping.txt +0 -10
- package/src/commands/manuals/printf.txt +0 -11
- package/src/commands/manuals/ps.txt +0 -10
- package/src/commands/manuals/pwd.txt +0 -10
- package/src/commands/manuals/python3.txt +0 -13
- package/src/commands/manuals/readlink.txt +0 -10
- package/src/commands/manuals/return.txt +0 -10
- package/src/commands/manuals/rm.txt +0 -10
- package/src/commands/manuals/sed.txt +0 -11
- package/src/commands/manuals/set.txt +0 -11
- package/src/commands/manuals/shift.txt +0 -10
- package/src/commands/manuals/sleep.txt +0 -10
- package/src/commands/manuals/sort.txt +0 -12
- package/src/commands/manuals/source.txt +0 -11
- package/src/commands/manuals/ssh.txt +0 -11
- package/src/commands/manuals/stat.txt +0 -10
- package/src/commands/manuals/su.txt +0 -13
- package/src/commands/manuals/sudo.txt +0 -11
- package/src/commands/manuals/tail.txt +0 -10
- package/src/commands/manuals/tar.txt +0 -19
- package/src/commands/manuals/tee.txt +0 -10
- package/src/commands/manuals/test.txt +0 -11
- package/src/commands/manuals/touch.txt +0 -11
- package/src/commands/manuals/tr.txt +0 -10
- package/src/commands/manuals/trap.txt +0 -10
- package/src/commands/manuals/true.txt +0 -10
- package/src/commands/manuals/type.txt +0 -10
- package/src/commands/manuals/uname.txt +0 -12
- package/src/commands/manuals/uniq.txt +0 -12
- package/src/commands/manuals/unset.txt +0 -10
- package/src/commands/manuals/uptime.txt +0 -11
- package/src/commands/manuals/wc.txt +0 -12
- package/src/commands/manuals/wget.txt +0 -12
- package/src/commands/manuals/which.txt +0 -10
- package/src/commands/manuals/whoami.txt +0 -10
- package/src/commands/manuals/xargs.txt +0 -10
- package/src/commands/manuals-bundle.ts +0 -898
- package/src/commands/mkdir.ts +0 -31
- package/src/commands/mv.ts +0 -50
- package/src/commands/nano.ts +0 -38
- package/src/commands/neofetch.ts +0 -53
- package/src/commands/node.ts +0 -341
- package/src/commands/npm.ts +0 -132
- package/src/commands/passwd.ts +0 -50
- package/src/commands/ping.ts +0 -32
- package/src/commands/printf.ts +0 -129
- package/src/commands/ps.ts +0 -58
- package/src/commands/pwd.ts +0 -9
- package/src/commands/python.ts +0 -2229
- package/src/commands/read.ts +0 -46
- package/src/commands/registry.ts +0 -249
- package/src/commands/rm.ts +0 -42
- package/src/commands/runtime.ts +0 -378
- package/src/commands/sed.ts +0 -68
- package/src/commands/seq.ts +0 -43
- package/src/commands/set.ts +0 -29
- package/src/commands/sh.ts +0 -467
- package/src/commands/shift.ts +0 -63
- package/src/commands/sleep.ts +0 -20
- package/src/commands/sort.ts +0 -46
- package/src/commands/source.ts +0 -52
- package/src/commands/stat.ts +0 -61
- package/src/commands/su.ts +0 -72
- package/src/commands/sudo.ts +0 -76
- package/src/commands/tail.ts +0 -53
- package/src/commands/tar.ts +0 -102
- package/src/commands/tee.ts +0 -36
- package/src/commands/test.ts +0 -137
- package/src/commands/touch.ts +0 -28
- package/src/commands/tr.ts +0 -70
- package/src/commands/tree.ts +0 -20
- package/src/commands/true.ts +0 -27
- package/src/commands/type.ts +0 -48
- package/src/commands/uname.ts +0 -29
- package/src/commands/uniq.ts +0 -39
- package/src/commands/unset.ts +0 -17
- package/src/commands/uptime.ts +0 -54
- package/src/commands/wc.ts +0 -55
- package/src/commands/wget.ts +0 -148
- package/src/commands/which.ts +0 -37
- package/src/commands/who.ts +0 -25
- package/src/commands/whoami.ts +0 -14
- package/src/commands/xargs.ts +0 -31
- package/src/index.ts +0 -67
- package/src/modules/linuxRootfs.ts +0 -1961
- package/src/modules/neofetch.ts +0 -358
- package/src/modules/shellInteractive.ts +0 -57
- package/src/modules/shellRuntime.ts +0 -76
- package/src/self-standalone.ts +0 -542
- package/src/standalone-wo-sftp.ts +0 -38
- package/src/standalone.ts +0 -72
- package/src/types/commands.ts +0 -146
- package/src/types/pipeline.ts +0 -52
- package/src/types/streams.ts +0 -32
- package/src/types/tar-stream.d.ts +0 -38
- package/src/types/vfs.ts +0 -98
- package/src/utils/expand.ts +0 -491
- package/src/utils/perfLogger.ts +0 -72
- package/src/utils/tokenize.ts +0 -98
- package/src/utils/vfsDiff.ts +0 -275
- package/tests/command-helpers.test.ts +0 -116
- package/tests/commands-admin-net.test.ts +0 -441
- package/tests/commands-advanced.test.ts +0 -456
- package/tests/commands-core.test.ts +0 -562
- package/tests/commands-missing.test.ts +0 -570
- package/tests/commands-specific-units.test.ts +0 -327
- package/tests/commands-text-sys.test.ts +0 -445
- package/tests/expand.test.ts +0 -170
- package/tests/helpers.test.ts +0 -97
- package/tests/new-features.test.ts +0 -1036
- package/tests/parser-executor.test.ts +0 -37
- package/tests/sftp.test.ts +0 -323
- package/tests/ssh-exec.test.ts +0 -45
- package/tests/test-helper.ts +0 -79
- package/tests/users.test.ts +0 -86
- package/tsconfig.json +0 -49
- package/typedoc.json +0 -47
|
@@ -1,1193 +0,0 @@
|
|
|
1
|
-
/** biome-ignore-all lint/style/useNamingConvention: NW ? */
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
|
-
import * as fsSync from "node:fs";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import { gunzipSync, gzipSync } from "node:zlib";
|
|
6
|
-
import type {
|
|
7
|
-
RemoveOptions,
|
|
8
|
-
VfsDirectoryNode,
|
|
9
|
-
VfsFileNode,
|
|
10
|
-
VfsNodeStats,
|
|
11
|
-
VfsSnapshot,
|
|
12
|
-
VfsSnapshotDirectoryNode,
|
|
13
|
-
VfsSnapshotFileNode,
|
|
14
|
-
VfsSnapshotNode,
|
|
15
|
-
WriteFileOptions,
|
|
16
|
-
} from "../types/vfs";
|
|
17
|
-
import { decodeVfs, encodeVfs, isBinarySnapshot } from "./binaryPack";
|
|
18
|
-
import type {
|
|
19
|
-
InternalDirectoryNode,
|
|
20
|
-
InternalFileNode,
|
|
21
|
-
InternalNode,
|
|
22
|
-
InternalStubNode,
|
|
23
|
-
} from "./internalTypes";
|
|
24
|
-
import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
|
|
25
|
-
import { getNode, getParentDirectory, normalizePath } from "./path";
|
|
26
|
-
|
|
27
|
-
// ── Persistence options ───────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* "memory" — pure in-memory, no disk I/O (default).
|
|
31
|
-
*
|
|
32
|
-
* "fs" — mirrors the VFS tree to a directory on the host filesystem.
|
|
33
|
-
* `snapshotPath` must be set to the directory where the binary
|
|
34
|
-
* snapshot file will be read/written (`vfs-snapshot.vfsb`).
|
|
35
|
-
*/
|
|
36
|
-
export type VfsPersistenceMode = "memory" | "fs";
|
|
37
|
-
|
|
38
|
-
export interface VfsOptions {
|
|
39
|
-
/**
|
|
40
|
-
* Persistence mode.
|
|
41
|
-
* - `"memory"` (default): no disk access, snapshot via `toSnapshot()`.
|
|
42
|
-
* - `"fs"`: auto-save JSON snapshot to `snapshotPath` on every
|
|
43
|
-
* `flushMirror()` call, and restore from it on `restoreMirror()`.
|
|
44
|
-
*/
|
|
45
|
-
mode?: VfsPersistenceMode;
|
|
46
|
-
/**
|
|
47
|
-
* Directory used by `"fs"` mode.
|
|
48
|
-
* The snapshot file will be written to `<snapshotPath>/vfs-snapshot.json`.
|
|
49
|
-
* Required when `mode` is `"fs"`.
|
|
50
|
-
*/
|
|
51
|
-
snapshotPath?: string;
|
|
52
|
-
/**
|
|
53
|
-
* Interval in milliseconds between automatic checkpoints in `"fs"` mode.
|
|
54
|
-
* Set to `0` to disable automatic flushing (manual `flushMirror()` only).
|
|
55
|
-
* Default: 30_000 (30 seconds).
|
|
56
|
-
*/
|
|
57
|
-
flushIntervalMs?: number;
|
|
58
|
-
/**
|
|
59
|
-
* Trigger a checkpoint after this many write operations, regardless of the
|
|
60
|
-
* timer interval. Prevents unbounded journal growth during bulk operations
|
|
61
|
-
* (e.g. a 15 000-file SFTP transfer). Default: 500.
|
|
62
|
-
* Set to `0` to disable write-count flushing.
|
|
63
|
-
*/
|
|
64
|
-
flushAfterNWrites?: number;
|
|
65
|
-
/**
|
|
66
|
-
* Files larger than this threshold (bytes) are evicted from RAM after each
|
|
67
|
-
* `flushMirror()` and reloaded on demand from the snapshot.
|
|
68
|
-
* Default: 65536 (64 KB). Set to `0` to disable eviction.
|
|
69
|
-
* Only applies to `"fs"` mode.
|
|
70
|
-
*/
|
|
71
|
-
evictionThresholdBytes?: number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── VirtualFileSystem ─────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* In-memory virtual filesystem with optional JSON-snapshot persistence.
|
|
78
|
-
*
|
|
79
|
-
* **Memory mode** (default) — all state lives in a fast recursive tree.
|
|
80
|
-
* Use `toSnapshot()` / `fromSnapshot()` / `importSnapshot()` for serialisation.
|
|
81
|
-
*
|
|
82
|
-
* **FS mode** — same in-memory tree, but `restoreMirror()` loads a binary
|
|
83
|
-
* snapshot from disk and `flushMirror()` writes it back. This gives you
|
|
84
|
-
* persistent VFS state across process restarts without any real POSIX filesystem
|
|
85
|
-
* semantics leaking through.
|
|
86
|
-
*
|
|
87
|
-
* @example
|
|
88
|
-
* ```ts
|
|
89
|
-
* // Pure in-memory (default)
|
|
90
|
-
* const vfs = new VirtualFileSystem();
|
|
91
|
-
*
|
|
92
|
-
* // With disk persistence
|
|
93
|
-
* const vfs = new VirtualFileSystem({ mode: "fs", snapshotPath: "./data" });
|
|
94
|
-
* await vfs.restoreMirror(); // load from disk (no-op if no snapshot yet)
|
|
95
|
-
* // ... use vfs ...
|
|
96
|
-
* await vfs.flushMirror(); // persist to disk
|
|
97
|
-
* ```
|
|
98
|
-
*/
|
|
99
|
-
class VirtualFileSystem extends EventEmitter {
|
|
100
|
-
private root: InternalDirectoryNode;
|
|
101
|
-
private readonly mode: VfsPersistenceMode;
|
|
102
|
-
private readonly snapshotFile: string | null;
|
|
103
|
-
/** Path to the WAL journal file (null in memory mode). */
|
|
104
|
-
private readonly journalFile: string | null;
|
|
105
|
-
/** Eviction threshold in bytes (0 = disabled). Files above this are purged after flush. */
|
|
106
|
-
private readonly evictionThreshold: number;
|
|
107
|
-
/** Max writes between forced flushes (0 = disabled). */
|
|
108
|
-
private readonly flushAfterNWrites: number;
|
|
109
|
-
/** Pending write counter since last checkpoint. */
|
|
110
|
-
private _writesSinceFlush = 0;
|
|
111
|
-
/** NodeJS timer handle for periodic auto-flush (null = disabled or stopped). */
|
|
112
|
-
private _flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
113
|
-
/** True if the VFS has unflushed changes. */
|
|
114
|
-
private _dirty = false;
|
|
115
|
-
/** Active host-directory mounts: vPath → { hostPath, readOnly } */
|
|
116
|
-
private readonly mounts = new Map<string, { hostPath: string; readOnly: boolean }>();
|
|
117
|
-
/** True when running in a browser environment (no host FS access). */
|
|
118
|
-
private static readonly isBrowser =
|
|
119
|
-
typeof process === "undefined" || typeof (process as NodeJS.Process).versions?.node === "undefined";
|
|
120
|
-
|
|
121
|
-
constructor(options: VfsOptions = {}) {
|
|
122
|
-
super();
|
|
123
|
-
this.mode = options.mode ?? "memory";
|
|
124
|
-
if (this.mode === "fs") {
|
|
125
|
-
if (!options.snapshotPath) {
|
|
126
|
-
throw new Error(
|
|
127
|
-
'VirtualFileSystem: "snapshotPath" is required when mode is "fs".',
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
this.snapshotFile = path.resolve(
|
|
131
|
-
options.snapshotPath,
|
|
132
|
-
"vfs-snapshot.vfsb",
|
|
133
|
-
);
|
|
134
|
-
this.journalFile = path.resolve(options.snapshotPath, "vfs-journal.bin");
|
|
135
|
-
this.evictionThreshold = options.evictionThresholdBytes ?? 64 * 1024; // 64 KB default
|
|
136
|
-
this.flushAfterNWrites = options.flushAfterNWrites ?? 500;
|
|
137
|
-
const intervalMs = options.flushIntervalMs ?? 1_000;
|
|
138
|
-
if (intervalMs > 0) {
|
|
139
|
-
this._flushTimer = setInterval(() => {
|
|
140
|
-
const dirty = this._dirty;
|
|
141
|
-
if (dirty) void this._autoFlush();
|
|
142
|
-
}, intervalMs);
|
|
143
|
-
// Don't block process exit on this timer
|
|
144
|
-
if (typeof this._flushTimer === "object" && this._flushTimer !== null && "unref" in this._flushTimer) {
|
|
145
|
-
(this._flushTimer as NodeJS.Timeout).unref();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
this.snapshotFile = null;
|
|
150
|
-
this.journalFile = null;
|
|
151
|
-
this.evictionThreshold = 0; // disabled in memory mode
|
|
152
|
-
this.flushAfterNWrites = 0;
|
|
153
|
-
}
|
|
154
|
-
this.root = this.makeDir("", 0o755);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
private makeDir(name: string, mode: number): InternalDirectoryNode {
|
|
160
|
-
const now = Date.now();
|
|
161
|
-
return {
|
|
162
|
-
type: "directory",
|
|
163
|
-
name,
|
|
164
|
-
mode,
|
|
165
|
-
createdAt: now,
|
|
166
|
-
updatedAt: now,
|
|
167
|
-
children: Object.create(null) as Record<string, InternalNode>,
|
|
168
|
-
_childCount: 0,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private makeFile(
|
|
173
|
-
name: string,
|
|
174
|
-
content: Buffer,
|
|
175
|
-
mode: number,
|
|
176
|
-
compressed: boolean,
|
|
177
|
-
): InternalFileNode {
|
|
178
|
-
const now = Date.now();
|
|
179
|
-
return {
|
|
180
|
-
type: "file",
|
|
181
|
-
name,
|
|
182
|
-
content,
|
|
183
|
-
mode,
|
|
184
|
-
compressed,
|
|
185
|
-
createdAt: now,
|
|
186
|
-
updatedAt: now,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private makeStub(name: string, content: string, mode: number): InternalStubNode {
|
|
191
|
-
const now = Date.now();
|
|
192
|
-
return { type: "stub", name, stubContent: content, mode, createdAt: now, updatedAt: now };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Write a lazy stub — stores content as a plain string with no Buffer allocation.
|
|
197
|
-
* Use for static rootfs files that may never be read. On first `writeFile()`,
|
|
198
|
-
* the stub is promoted to a real `InternalFileNode`.
|
|
199
|
-
* Parent directories are created when missing.
|
|
200
|
-
*/
|
|
201
|
-
public writeStub(targetPath: string, content: string, mode = 0o644): void {
|
|
202
|
-
const normalized = normalizePath(targetPath);
|
|
203
|
-
const { parent, name } = getParentDirectory(
|
|
204
|
-
this.root,
|
|
205
|
-
normalized,
|
|
206
|
-
true,
|
|
207
|
-
(p) => this.mkdirRecursive(p, 0o755),
|
|
208
|
-
);
|
|
209
|
-
const existing = parent.children[name];
|
|
210
|
-
if (existing?.type === "directory") {
|
|
211
|
-
throw new Error(`Cannot write stub '${normalized}': path is a directory.`);
|
|
212
|
-
}
|
|
213
|
-
// Don't overwrite a real file or an already-promoted node
|
|
214
|
-
if (existing?.type === "file") return;
|
|
215
|
-
if (!existing) parent._childCount++;
|
|
216
|
-
parent.children[name] = this.makeStub(name, content, mode);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private mkdirRecursive(targetPath: string, mode: number): void {
|
|
220
|
-
const normalized = normalizePath(targetPath);
|
|
221
|
-
if (normalized === "/") return;
|
|
222
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
223
|
-
let current = this.root;
|
|
224
|
-
let builtPath = "";
|
|
225
|
-
for (const part of parts) {
|
|
226
|
-
builtPath += `/${part}`;
|
|
227
|
-
let child = current.children[part];
|
|
228
|
-
if (!child) {
|
|
229
|
-
child = this.makeDir(part, mode);
|
|
230
|
-
current.children[part] = child;
|
|
231
|
-
current._childCount++;
|
|
232
|
-
this.emit("dir:create", { path: builtPath, mode });
|
|
233
|
-
this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
|
|
234
|
-
} else if (child.type !== "directory") {
|
|
235
|
-
throw new Error(
|
|
236
|
-
`Cannot create directory '${builtPath}': path is a file.`,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
current = child as InternalDirectoryNode;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── Persistence ───────────────────────────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* In `"fs"` mode: reads the binary snapshot (`vfs-snapshot.vfsb`) from disk.
|
|
247
|
-
* Automatically falls back to legacy JSON format for backward compatibility.
|
|
248
|
-
* Silently succeeds when the snapshot file does not exist yet.
|
|
249
|
-
*
|
|
250
|
-
* In `"memory"` mode: no-op (kept for API compatibility).
|
|
251
|
-
*/
|
|
252
|
-
public async restoreMirror(): Promise<void> {
|
|
253
|
-
if (this.mode !== "fs" || !this.snapshotFile) return;
|
|
254
|
-
|
|
255
|
-
if (!fsSync.existsSync(this.snapshotFile)) {
|
|
256
|
-
// No snapshot yet — but replay journal if it exists (crash after writes, before first flush)
|
|
257
|
-
if (this.journalFile) {
|
|
258
|
-
const entries = readJournal(this.journalFile);
|
|
259
|
-
if (entries.length > 0) this._replayJournal(entries);
|
|
260
|
-
}
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
const raw = fsSync.readFileSync(this.snapshotFile);
|
|
266
|
-
if (isBinarySnapshot(raw)) {
|
|
267
|
-
// Fast binary format (current)
|
|
268
|
-
this.root = decodeVfs(raw);
|
|
269
|
-
} else {
|
|
270
|
-
// Legacy JSON fallback — auto-migrates on next flushMirror()
|
|
271
|
-
const snapshot: VfsSnapshot = JSON.parse(raw.toString("utf8"));
|
|
272
|
-
this.root = this.deserializeDir(snapshot.root, "");
|
|
273
|
-
console.info(
|
|
274
|
-
"[VirtualFileSystem] Migrating legacy JSON snapshot to binary format.",
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
this.emit("snapshot:restore", { path: this.snapshotFile });
|
|
278
|
-
// Replay WAL journal on top of the loaded snapshot
|
|
279
|
-
if (this.journalFile) {
|
|
280
|
-
const entries = readJournal(this.journalFile);
|
|
281
|
-
if (entries.length > 0) this._replayJournal(entries);
|
|
282
|
-
}
|
|
283
|
-
} catch (err) {
|
|
284
|
-
// Corrupt or unreadable snapshot — start fresh and warn
|
|
285
|
-
console.warn(
|
|
286
|
-
`[VirtualFileSystem] Could not restore snapshot from ${this.snapshotFile}:`,
|
|
287
|
-
err instanceof Error ? err.message : String(err),
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* In `"fs"` mode: serialises the in-memory tree to a binary snapshot on disk
|
|
294
|
-
* (`vfs-snapshot.vfsb`). ~27% smaller and significantly faster than JSON+base64.
|
|
295
|
-
* The directory is created if it does not exist.
|
|
296
|
-
*
|
|
297
|
-
* In `"memory"` mode: emits `"mirror:flush"` and returns (no disk write).
|
|
298
|
-
*/
|
|
299
|
-
public async flushMirror(): Promise<void> {
|
|
300
|
-
if (this.mode !== "fs" || !this.snapshotFile) {
|
|
301
|
-
this.emit("mirror:flush");
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const dir = path.dirname(this.snapshotFile);
|
|
306
|
-
fsSync.mkdirSync(dir, { recursive: true });
|
|
307
|
-
const root = this.root;
|
|
308
|
-
const binary = encodeVfs(root);
|
|
309
|
-
fsSync.writeFileSync(this.snapshotFile, binary);
|
|
310
|
-
// Checkpoint complete — truncate the journal (entries are now in the snapshot)
|
|
311
|
-
if (this.journalFile) truncateJournal(this.journalFile);
|
|
312
|
-
this._dirty = false;
|
|
313
|
-
this._writesSinceFlush = 0;
|
|
314
|
-
this.emit("mirror:flush", { path: this.snapshotFile });
|
|
315
|
-
// Evict large files from RAM now that the snapshot is on disk
|
|
316
|
-
this.evictLargeFiles();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Returns the current persistence mode. */
|
|
320
|
-
public getMode(): VfsPersistenceMode {
|
|
321
|
-
return this.mode;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Returns the snapshot file path used in `"fs"` mode, or `null`. */
|
|
325
|
-
public getSnapshotPath(): string | null {
|
|
326
|
-
return this.snapshotFile;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ── Public filesystem API ─────────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
/** Creates a directory (and any missing parents). */
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// ── Auto-flush scheduler ──────────────────────────────────────────────────
|
|
336
|
-
|
|
337
|
-
/** Internal: flush triggered by timer or write-count threshold. */
|
|
338
|
-
private async _autoFlush(): Promise<void> {
|
|
339
|
-
if (!this._dirty) return;
|
|
340
|
-
await this.flushMirror();
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** Mark VFS as having unflushed writes and trigger threshold flush if needed. */
|
|
344
|
-
private _markDirty(): void {
|
|
345
|
-
this._dirty = true;
|
|
346
|
-
if (this.flushAfterNWrites > 0) {
|
|
347
|
-
this._writesSinceFlush++;
|
|
348
|
-
if (this._writesSinceFlush >= this.flushAfterNWrites) {
|
|
349
|
-
this._writesSinceFlush = 0;
|
|
350
|
-
void this._autoFlush();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Stop the automatic flush timer and perform a final checkpoint.
|
|
357
|
-
* Call this when shutting down to ensure all data is persisted.
|
|
358
|
-
*
|
|
359
|
-
* @example
|
|
360
|
-
* ```ts
|
|
361
|
-
* process.on("SIGINT", async () => {
|
|
362
|
-
* await shell.vfs.stopAutoFlush();
|
|
363
|
-
* process.exit(0);
|
|
364
|
-
* });
|
|
365
|
-
* ```
|
|
366
|
-
*/
|
|
367
|
-
public async stopAutoFlush(): Promise<void> {
|
|
368
|
-
if (this._flushTimer !== null) {
|
|
369
|
-
clearInterval(this._flushTimer);
|
|
370
|
-
this._flushTimer = null;
|
|
371
|
-
}
|
|
372
|
-
if (this._dirty) await this.flushMirror();
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Replace the entire root tree — used internally by `bootstrapLinuxRootfs`
|
|
377
|
-
* to hot-swap the static rootfs snapshot without going through importSnapshot
|
|
378
|
-
* (which would re-journal every node in fs mode).
|
|
379
|
-
* @internal
|
|
380
|
-
*/
|
|
381
|
-
public importRootTree(root: InternalDirectoryNode): void {
|
|
382
|
-
const prev = this._replayMode;
|
|
383
|
-
this._replayMode = true;
|
|
384
|
-
try { this.root = root; } finally { this._replayMode = prev; }
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Merge a static rootfs tree into the existing live tree.
|
|
389
|
-
* Used by `bootstrapLinuxRootfs` when a persisted snapshot already exists,
|
|
390
|
-
* to layer in missing system files without overwriting user data.
|
|
391
|
-
*
|
|
392
|
-
* Rules:
|
|
393
|
-
* - Directories: recurse — never overwrite a live dir with an empty one.
|
|
394
|
-
* - Files/stubs: only written if the path does NOT yet exist in the live tree.
|
|
395
|
-
* This ensures user-created files always win over static defaults.
|
|
396
|
-
*
|
|
397
|
-
* @internal
|
|
398
|
-
*/
|
|
399
|
-
public mergeRootTree(incoming: InternalDirectoryNode): void {
|
|
400
|
-
const prev = this._replayMode;
|
|
401
|
-
this._replayMode = true;
|
|
402
|
-
try { this._mergeDir(this.root, incoming); } finally { this._replayMode = prev; }
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private _mergeDir(live: InternalDirectoryNode, incoming: InternalDirectoryNode): void {
|
|
406
|
-
for (const [name, node] of Object.entries(incoming.children)) {
|
|
407
|
-
const existing = live.children[name];
|
|
408
|
-
if (node.type === "directory") {
|
|
409
|
-
if (!existing) {
|
|
410
|
-
// Dir doesn't exist yet — add it
|
|
411
|
-
live.children[name] = node;
|
|
412
|
-
live._childCount++;
|
|
413
|
-
} else if (existing.type === "directory") {
|
|
414
|
-
// Both dirs — recurse
|
|
415
|
-
this._mergeDir(existing, node);
|
|
416
|
-
}
|
|
417
|
-
// existing is a file where dir expected — leave user file alone
|
|
418
|
-
} else {
|
|
419
|
-
// File or stub — only add if not already present
|
|
420
|
-
if (!existing) {
|
|
421
|
-
live.children[name] = node;
|
|
422
|
-
live._childCount++;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/** Serialise current tree to VFSB binary. Used for the static rootfs cache. */
|
|
429
|
-
public encodeBinary(): Buffer {
|
|
430
|
-
return encodeVfs(this.root);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Release the in-memory VFS tree, freeing all InternalNode objects for GC.
|
|
435
|
-
* The tree MUST be restored via `importRootTree()` before any VFS operation.
|
|
436
|
-
* Called by IdleManager when freezing an idle shell.
|
|
437
|
-
* @internal
|
|
438
|
-
*/
|
|
439
|
-
public releaseTree(): void {
|
|
440
|
-
// Replace root with a minimal stub — keeps the object alive but frees all children
|
|
441
|
-
this.root = this.makeDir("", 0o755);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Set to true during journal replay to suppress re-journaling. */
|
|
445
|
-
private _replayMode = false;
|
|
446
|
-
|
|
447
|
-
/** Append a journal entry if in fs mode and not replaying. */
|
|
448
|
-
private _journal(entry: Parameters<typeof appendJournalEntry>[1]): void {
|
|
449
|
-
if (this.journalFile && !this._replayMode) {
|
|
450
|
-
appendJournalEntry(this.journalFile, entry);
|
|
451
|
-
this._markDirty();
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** Replay a list of journal entries onto the in-memory tree. */
|
|
456
|
-
private _replayJournal(entries: ReturnType<typeof readJournal>): void {
|
|
457
|
-
this._replayMode = true;
|
|
458
|
-
try {
|
|
459
|
-
for (const e of entries) {
|
|
460
|
-
try {
|
|
461
|
-
if (e.op === JournalOp.WRITE) {
|
|
462
|
-
this.writeFile(e.path, e.content ?? Buffer.alloc(0), { mode: e.mode });
|
|
463
|
-
} else if (e.op === JournalOp.MKDIR) {
|
|
464
|
-
this.mkdir(e.path, e.mode);
|
|
465
|
-
} else if (e.op === JournalOp.REMOVE) {
|
|
466
|
-
if (this.exists(e.path)) this.remove(e.path, { recursive: true });
|
|
467
|
-
} else if (e.op === JournalOp.CHMOD) {
|
|
468
|
-
if (this.exists(e.path)) this.chmod(e.path, e.mode ?? 0o644);
|
|
469
|
-
} else if (e.op === JournalOp.MOVE) {
|
|
470
|
-
if (this.exists(e.path) && e.dest) this.move(e.path, e.dest);
|
|
471
|
-
} else if (e.op === JournalOp.SYMLINK) {
|
|
472
|
-
if (e.dest) this.symlink(e.dest, e.path);
|
|
473
|
-
}
|
|
474
|
-
} catch { /* ignore individual replay errors — best-effort */ }
|
|
475
|
-
}
|
|
476
|
-
} finally {
|
|
477
|
-
this._replayMode = false;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
// ── RAM eviction ──────────────────────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Walk the in-memory tree and evict file contents that exceed
|
|
486
|
-
* `evictionThreshold`. Called automatically after `flushMirror()`.
|
|
487
|
-
* Safe to call at any time — evicted files are reloaded on demand.
|
|
488
|
-
*/
|
|
489
|
-
public evictLargeFiles(): void {
|
|
490
|
-
if (!this.snapshotFile || this.evictionThreshold === 0) return;
|
|
491
|
-
if (!fsSync.existsSync(this.snapshotFile)) return;
|
|
492
|
-
this._evictDir(this.root);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
private _evictDir(dir: InternalDirectoryNode): void {
|
|
496
|
-
for (const node of Object.values(dir.children)) {
|
|
497
|
-
if (node.type === "directory") {
|
|
498
|
-
this._evictDir(node);
|
|
499
|
-
} else if (node.type === "file" && !node.evicted) {
|
|
500
|
-
const rawSize = node.compressed
|
|
501
|
-
? (node.size ?? node.content.length * 2) // estimate uncompressed
|
|
502
|
-
: node.content.length;
|
|
503
|
-
if (rawSize > this.evictionThreshold) {
|
|
504
|
-
node.size = rawSize;
|
|
505
|
-
node.content = Buffer.alloc(0); // free heap
|
|
506
|
-
node.evicted = true;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
// stubs: nothing to evict — content is already a plain string, not a Buffer
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Reload a single evicted file node's content from the current snapshot.
|
|
515
|
-
* No-op if the node is not evicted.
|
|
516
|
-
*/
|
|
517
|
-
private _reloadEvicted(node: InternalFileNode, normalizedPath: string): void {
|
|
518
|
-
if (!node.evicted || !this.snapshotFile) return;
|
|
519
|
-
if (!fsSync.existsSync(this.snapshotFile)) return;
|
|
520
|
-
try {
|
|
521
|
-
// Load and parse the snapshot to find this specific node
|
|
522
|
-
const raw = fsSync.readFileSync(this.snapshotFile);
|
|
523
|
-
const tmpRoot = decodeVfs(raw);
|
|
524
|
-
const parts = normalizedPath.split("/").filter(Boolean);
|
|
525
|
-
let cur: InternalNode = tmpRoot;
|
|
526
|
-
for (const part of parts) {
|
|
527
|
-
if (cur.type !== "directory") return;
|
|
528
|
-
const next: InternalNode | undefined = cur.children[part];
|
|
529
|
-
if (!next) return;
|
|
530
|
-
cur = next;
|
|
531
|
-
}
|
|
532
|
-
if (cur.type === "file") {
|
|
533
|
-
node.content = cur.content;
|
|
534
|
-
node.compressed = cur.compressed;
|
|
535
|
-
node.evicted = undefined;
|
|
536
|
-
}
|
|
537
|
-
} catch {
|
|
538
|
-
// Snapshot unreadable — leave evicted; caller will get empty content
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// ── Mount API ─────────────────────────────────────────────────────────────
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Mount a host directory into the VFS at `vPath`.
|
|
546
|
-
*
|
|
547
|
-
* Files inside `vPath` are read directly from the host filesystem via
|
|
548
|
-
* `node:fs`. All standard VFS operations (`readFile`, `writeFile`,
|
|
549
|
-
* `exists`, `stat`, `list`) are transparently delegated.
|
|
550
|
-
*
|
|
551
|
-
* In browser environments the mount is silently ignored — `vPath` remains
|
|
552
|
-
* an empty in-memory directory.
|
|
553
|
-
*
|
|
554
|
-
* @param vPath Absolute path inside the VM (e.g. `"/app"`).
|
|
555
|
-
* @param hostPath Path on the host filesystem — relative paths are
|
|
556
|
-
* resolved from `process.cwd()`.
|
|
557
|
-
* @param readOnly When `true` (default), write operations inside the
|
|
558
|
-
* mount throw `EROFS: read-only file system`.
|
|
559
|
-
*
|
|
560
|
-
* @example
|
|
561
|
-
* ```ts
|
|
562
|
-
* shell.vfs.mount("/app", "./src", { readOnly: true });
|
|
563
|
-
* // cat /app/index.ts — reads ./src/index.ts from host
|
|
564
|
-
* ```
|
|
565
|
-
*/
|
|
566
|
-
public mount(
|
|
567
|
-
vPath: string,
|
|
568
|
-
hostPath: string,
|
|
569
|
-
{ readOnly = true }: { readOnly?: boolean } = {},
|
|
570
|
-
): void {
|
|
571
|
-
if (VirtualFileSystem.isBrowser) return; // silently degrade in browser
|
|
572
|
-
const normalized = normalizePath(vPath);
|
|
573
|
-
const resolved = path.resolve(hostPath);
|
|
574
|
-
if (!fsSync.existsSync(resolved)) {
|
|
575
|
-
throw new Error(`VirtualFileSystem.mount: host path does not exist: "${resolved}"`);
|
|
576
|
-
}
|
|
577
|
-
if (!fsSync.statSync(resolved).isDirectory()) {
|
|
578
|
-
throw new Error(`VirtualFileSystem.mount: host path is not a directory: "${resolved}"`);
|
|
579
|
-
}
|
|
580
|
-
// Ensure the mount point exists in the VFS tree
|
|
581
|
-
this.mkdir(normalized);
|
|
582
|
-
this.mounts.set(normalized, { hostPath: resolved, readOnly });
|
|
583
|
-
this.emit("mount", { vPath: normalized, hostPath: resolved, readOnly });
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* Unmount a previously mounted host directory.
|
|
588
|
-
* The in-memory VFS directory at `vPath` is preserved but the host
|
|
589
|
-
* delegation is removed.
|
|
590
|
-
*/
|
|
591
|
-
public unmount(vPath: string): void {
|
|
592
|
-
const normalized = normalizePath(vPath);
|
|
593
|
-
if (this.mounts.delete(normalized)) {
|
|
594
|
-
this.emit("unmount", { vPath: normalized });
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
/** List all active mounts. */
|
|
599
|
-
public getMounts(): Array<{ vPath: string; hostPath: string; readOnly: boolean }> {
|
|
600
|
-
return [...this.mounts.entries()].map(([vPath, opts]) => ({
|
|
601
|
-
vPath, ...opts,
|
|
602
|
-
}));
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* If `targetPath` is inside a mount, return `{ hostPath, readOnly, relPath }`.
|
|
607
|
-
* `relPath` is the path relative to the mount's host directory.
|
|
608
|
-
* Returns `null` if the path is not under any mount.
|
|
609
|
-
*/
|
|
610
|
-
private resolveMount(targetPath: string): {
|
|
611
|
-
hostPath: string;
|
|
612
|
-
readOnly: boolean;
|
|
613
|
-
relPath: string;
|
|
614
|
-
fullHostPath: string;
|
|
615
|
-
} | null {
|
|
616
|
-
const normalized = normalizePath(targetPath);
|
|
617
|
-
// Iterate mounts from most specific to least specific
|
|
618
|
-
const sorted = [...this.mounts.entries()].sort(
|
|
619
|
-
([a], [b]) => b.length - a.length,
|
|
620
|
-
);
|
|
621
|
-
for (const [vBase, opts] of sorted) {
|
|
622
|
-
if (normalized === vBase || normalized.startsWith(`${vBase}/`)) {
|
|
623
|
-
const relPath = normalized.slice(vBase.length).replace(/^\//, "");
|
|
624
|
-
const fullHostPath = relPath ? path.join(opts.hostPath, relPath) : opts.hostPath;
|
|
625
|
-
return { hostPath: opts.hostPath, readOnly: opts.readOnly, relPath, fullHostPath };
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
public mkdir(targetPath: string, mode: number = 0o755): void {
|
|
632
|
-
const normalized = normalizePath(targetPath);
|
|
633
|
-
const existing = (() => {
|
|
634
|
-
try {
|
|
635
|
-
return getNode(this.root, normalized);
|
|
636
|
-
} catch {
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
})();
|
|
640
|
-
if (existing && existing.type !== "directory") {
|
|
641
|
-
throw new Error(
|
|
642
|
-
`Cannot create directory '${normalized}': path is a file.`,
|
|
643
|
-
);
|
|
644
|
-
}
|
|
645
|
-
this.mkdirRecursive(normalized, mode);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Writes UTF-8 text or binary content into a file.
|
|
650
|
-
* Parent directories are created when missing.
|
|
651
|
-
*/
|
|
652
|
-
public writeFile(
|
|
653
|
-
targetPath: string,
|
|
654
|
-
content: string | Buffer,
|
|
655
|
-
options: WriteFileOptions = {},
|
|
656
|
-
): void {
|
|
657
|
-
// Delegate to host FS if inside a mount
|
|
658
|
-
const m = this.resolveMount(targetPath);
|
|
659
|
-
if (m) {
|
|
660
|
-
if (m.readOnly) throw new Error(`EROFS: read-only file system, open '${m.fullHostPath}'`);
|
|
661
|
-
const dir = path.dirname(m.fullHostPath);
|
|
662
|
-
if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true });
|
|
663
|
-
fsSync.writeFileSync(m.fullHostPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
const normalized = normalizePath(targetPath);
|
|
667
|
-
const { parent, name } = getParentDirectory(
|
|
668
|
-
this.root,
|
|
669
|
-
normalized,
|
|
670
|
-
true,
|
|
671
|
-
(p) => this.mkdirRecursive(p, 0o755),
|
|
672
|
-
);
|
|
673
|
-
|
|
674
|
-
const existing = parent.children[name];
|
|
675
|
-
if (existing?.type === "directory") {
|
|
676
|
-
throw new Error(
|
|
677
|
-
`Cannot write file '${normalized}': path is a directory.`,
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const rawContent = Buffer.isBuffer(content)
|
|
682
|
-
? content
|
|
683
|
-
: Buffer.from(content, "utf8");
|
|
684
|
-
const shouldCompress = options.compress ?? false;
|
|
685
|
-
const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
|
|
686
|
-
const mode = options.mode ?? 0o644;
|
|
687
|
-
|
|
688
|
-
if (existing && existing.type === "file") {
|
|
689
|
-
// Update real file in place
|
|
690
|
-
const f = existing as InternalFileNode;
|
|
691
|
-
f.content = storedContent;
|
|
692
|
-
f.compressed = shouldCompress;
|
|
693
|
-
f.mode = mode;
|
|
694
|
-
f.updatedAt = Date.now();
|
|
695
|
-
} else {
|
|
696
|
-
// Create new real file — also promotes stubs (no _childCount change for stubs)
|
|
697
|
-
if (!existing) parent._childCount++;
|
|
698
|
-
parent.children[name] = this.makeFile(name, storedContent, mode, shouldCompress);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
this.emit("file:write", { path: normalized, size: storedContent.length });
|
|
702
|
-
this._journal({ op: JournalOp.WRITE, path: normalized, content: rawContent, mode });
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* Reads file content as a UTF-8 string.
|
|
707
|
-
* Gzip-compressed files are transparently decompressed.
|
|
708
|
-
*/
|
|
709
|
-
public readFile(targetPath: string): string {
|
|
710
|
-
const m = this.resolveMount(targetPath);
|
|
711
|
-
if (m) {
|
|
712
|
-
if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, open '${m.fullHostPath}'`);
|
|
713
|
-
return fsSync.readFileSync(m.fullHostPath, "utf8");
|
|
714
|
-
}
|
|
715
|
-
const normalized = normalizePath(targetPath);
|
|
716
|
-
const node = getNode(this.root, normalized);
|
|
717
|
-
if (node.type === "stub") {
|
|
718
|
-
this.emit("file:read", { path: normalized, size: node.stubContent.length });
|
|
719
|
-
return node.stubContent;
|
|
720
|
-
}
|
|
721
|
-
if (node.type !== "file") {
|
|
722
|
-
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
723
|
-
}
|
|
724
|
-
const f = node as InternalFileNode;
|
|
725
|
-
if (f.evicted) this._reloadEvicted(f, normalized);
|
|
726
|
-
const raw = f.compressed ? gunzipSync(f.content) : f.content;
|
|
727
|
-
this.emit("file:read", { path: normalized, size: raw.length });
|
|
728
|
-
return raw.toString("utf8");
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** Reads file content as a Buffer (decompresses if needed). */
|
|
732
|
-
public readFileRaw(targetPath: string): Buffer {
|
|
733
|
-
const m = this.resolveMount(targetPath);
|
|
734
|
-
if (m) {
|
|
735
|
-
if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, open '${m.fullHostPath}'`);
|
|
736
|
-
return fsSync.readFileSync(m.fullHostPath);
|
|
737
|
-
}
|
|
738
|
-
const normalized = normalizePath(targetPath);
|
|
739
|
-
const node = getNode(this.root, normalized);
|
|
740
|
-
if (node.type === "stub") {
|
|
741
|
-
const buf = Buffer.from(node.stubContent, "utf8");
|
|
742
|
-
this.emit("file:read", { path: normalized, size: buf.length });
|
|
743
|
-
return buf;
|
|
744
|
-
}
|
|
745
|
-
if (node.type !== "file") {
|
|
746
|
-
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
747
|
-
}
|
|
748
|
-
const f = node as InternalFileNode;
|
|
749
|
-
if (f.evicted) this._reloadEvicted(f, normalized);
|
|
750
|
-
const raw = f.compressed ? gunzipSync(f.content) : f.content;
|
|
751
|
-
this.emit("file:read", { path: normalized, size: raw.length });
|
|
752
|
-
return raw;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/** Returns true when a file or directory exists at path. */
|
|
756
|
-
public exists(targetPath: string): boolean {
|
|
757
|
-
const m = this.resolveMount(targetPath);
|
|
758
|
-
if (m) return fsSync.existsSync(m.fullHostPath);
|
|
759
|
-
try {
|
|
760
|
-
getNode(this.root, normalizePath(targetPath));
|
|
761
|
-
return true;
|
|
762
|
-
} catch {
|
|
763
|
-
return false;
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** Updates mode bits on a node. */
|
|
768
|
-
public chmod(targetPath: string, mode: number): void {
|
|
769
|
-
const normalized = normalizePath(targetPath);
|
|
770
|
-
getNode(this.root, normalized).mode = mode;
|
|
771
|
-
this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/** Returns metadata for a file or directory. */
|
|
775
|
-
public stat(targetPath: string): VfsNodeStats {
|
|
776
|
-
const m = this.resolveMount(targetPath);
|
|
777
|
-
if (m) {
|
|
778
|
-
if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: stat '${m.fullHostPath}'`);
|
|
779
|
-
const hst = fsSync.statSync(m.fullHostPath);
|
|
780
|
-
const name = m.relPath.split("/").pop() ?? m.fullHostPath.split("/").pop() ?? "";
|
|
781
|
-
const now = hst.mtime;
|
|
782
|
-
if (hst.isDirectory()) {
|
|
783
|
-
return {
|
|
784
|
-
type: "directory",
|
|
785
|
-
name,
|
|
786
|
-
path: normalizePath(targetPath),
|
|
787
|
-
mode: 0o755,
|
|
788
|
-
createdAt: hst.birthtime,
|
|
789
|
-
updatedAt: now,
|
|
790
|
-
childrenCount: fsSync.readdirSync(m.fullHostPath).length,
|
|
791
|
-
} satisfies VfsDirectoryNode;
|
|
792
|
-
}
|
|
793
|
-
return {
|
|
794
|
-
type: "file",
|
|
795
|
-
name,
|
|
796
|
-
path: normalizePath(targetPath),
|
|
797
|
-
mode: m.readOnly ? 0o444 : 0o644,
|
|
798
|
-
createdAt: hst.birthtime,
|
|
799
|
-
updatedAt: now,
|
|
800
|
-
compressed: false,
|
|
801
|
-
size: hst.size,
|
|
802
|
-
} satisfies VfsFileNode;
|
|
803
|
-
}
|
|
804
|
-
const normalized = normalizePath(targetPath);
|
|
805
|
-
const node = getNode(this.root, normalized);
|
|
806
|
-
const name = normalized === "/" ? "" : path.posix.basename(normalized);
|
|
807
|
-
if (node.type === "stub") {
|
|
808
|
-
const s = node as InternalStubNode;
|
|
809
|
-
return {
|
|
810
|
-
type: "file",
|
|
811
|
-
name,
|
|
812
|
-
path: normalized,
|
|
813
|
-
mode: s.mode,
|
|
814
|
-
createdAt: new Date(s.createdAt),
|
|
815
|
-
updatedAt: new Date(s.updatedAt),
|
|
816
|
-
compressed: false,
|
|
817
|
-
size: s.stubContent.length,
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
if (node.type === "file") {
|
|
821
|
-
const f = node as InternalFileNode;
|
|
822
|
-
return {
|
|
823
|
-
type: "file",
|
|
824
|
-
name,
|
|
825
|
-
path: normalized,
|
|
826
|
-
mode: f.mode,
|
|
827
|
-
createdAt: new Date(f.createdAt),
|
|
828
|
-
updatedAt: new Date(f.updatedAt),
|
|
829
|
-
compressed: f.compressed,
|
|
830
|
-
size: f.evicted ? (f.size ?? 0) : f.content.length,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
const d = node as InternalDirectoryNode;
|
|
834
|
-
return {
|
|
835
|
-
type: "directory",
|
|
836
|
-
name,
|
|
837
|
-
path: normalized,
|
|
838
|
-
mode: d.mode,
|
|
839
|
-
createdAt: new Date(d.createdAt),
|
|
840
|
-
updatedAt: new Date(d.updatedAt),
|
|
841
|
-
childrenCount: d._childCount,
|
|
842
|
-
};
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/** Lists direct children names of a directory (sorted). */
|
|
846
|
-
public list(dirPath: string = "/"): string[] {
|
|
847
|
-
const m = this.resolveMount(dirPath);
|
|
848
|
-
if (m) {
|
|
849
|
-
if (!fsSync.existsSync(m.fullHostPath)) return [];
|
|
850
|
-
try {
|
|
851
|
-
return fsSync.readdirSync(m.fullHostPath).sort();
|
|
852
|
-
} catch { return []; }
|
|
853
|
-
}
|
|
854
|
-
const normalized = normalizePath(dirPath);
|
|
855
|
-
const node = getNode(this.root, normalized);
|
|
856
|
-
if (node.type !== "directory") {
|
|
857
|
-
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
858
|
-
}
|
|
859
|
-
return Object.keys((node as InternalDirectoryNode).children).sort();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/** Renders ASCII tree view of a directory hierarchy. */
|
|
863
|
-
public tree(dirPath: string = "/"): string {
|
|
864
|
-
const normalized = normalizePath(dirPath);
|
|
865
|
-
const node = getNode(this.root, normalized);
|
|
866
|
-
if (node.type !== "directory") {
|
|
867
|
-
throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
|
|
868
|
-
}
|
|
869
|
-
const label = dirPath === "/" ? "/" : path.posix.basename(normalized);
|
|
870
|
-
return this.renderTreeLines(node as InternalDirectoryNode, label);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
private renderTreeLines(dir: InternalDirectoryNode, label: string): string {
|
|
874
|
-
const lines = [label];
|
|
875
|
-
const entries = Object.keys(dir.children).sort();
|
|
876
|
-
for (let i = 0; i < entries.length; i++) {
|
|
877
|
-
const name = entries[i]!;
|
|
878
|
-
const child = dir.children[name]!;
|
|
879
|
-
const isLast = i === entries.length - 1;
|
|
880
|
-
const connector = isLast ? "└── " : "├── ";
|
|
881
|
-
const nextPrefix = isLast ? " " : "│ ";
|
|
882
|
-
lines.push(`${connector}${name}`);
|
|
883
|
-
if (child.type === "directory") {
|
|
884
|
-
const sub = this.renderTreeLines(child as InternalDirectoryNode, "")
|
|
885
|
-
.split("\n")
|
|
886
|
-
.slice(1)
|
|
887
|
-
.map((l) => `${nextPrefix}${l}`);
|
|
888
|
-
lines.push(...sub);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return lines.join("\n");
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/** Computes total stored bytes under a path. */
|
|
895
|
-
public getUsageBytes(targetPath: string = "/"): number {
|
|
896
|
-
return this.computeUsage(getNode(this.root, normalizePath(targetPath)));
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
private computeUsage(node: InternalNode): number {
|
|
900
|
-
if (node.type === "file") return (node as InternalFileNode).content.length;
|
|
901
|
-
if (node.type === "stub") return node.stubContent.length;
|
|
902
|
-
let total = 0;
|
|
903
|
-
for (const child of Object.values((node as InternalDirectoryNode).children)) {
|
|
904
|
-
total += this.computeUsage(child);
|
|
905
|
-
}
|
|
906
|
-
return total;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/** Compresses a file's content with gzip in place. */
|
|
910
|
-
public compressFile(targetPath: string): void {
|
|
911
|
-
const node = getNode(this.root, normalizePath(targetPath));
|
|
912
|
-
if (node.type !== "file")
|
|
913
|
-
throw new Error(`Cannot compress '${targetPath}': not a file.`);
|
|
914
|
-
const f = node as InternalFileNode;
|
|
915
|
-
if (!f.compressed) {
|
|
916
|
-
f.content = gzipSync(f.content);
|
|
917
|
-
f.compressed = true;
|
|
918
|
-
f.updatedAt = Date.now();
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/** Decompresses a gzip-compressed file in place. */
|
|
923
|
-
public decompressFile(targetPath: string): void {
|
|
924
|
-
const node = getNode(this.root, normalizePath(targetPath));
|
|
925
|
-
if (node.type !== "file")
|
|
926
|
-
throw new Error(`Cannot decompress '${targetPath}': not a file.`);
|
|
927
|
-
const f = node as InternalFileNode;
|
|
928
|
-
if (f.compressed) {
|
|
929
|
-
f.content = gunzipSync(f.content);
|
|
930
|
-
f.compressed = false;
|
|
931
|
-
f.updatedAt = Date.now();
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Creates a symbolic link.
|
|
937
|
-
* The link node is stored with mode `0o120777` (POSIX symlink convention).
|
|
938
|
-
*/
|
|
939
|
-
public symlink(targetPath: string, linkPath: string): void {
|
|
940
|
-
const normalizedLink = normalizePath(linkPath);
|
|
941
|
-
const normalizedTarget = targetPath.startsWith("/")
|
|
942
|
-
? normalizePath(targetPath)
|
|
943
|
-
: targetPath;
|
|
944
|
-
const { parent, name } = getParentDirectory(
|
|
945
|
-
this.root,
|
|
946
|
-
normalizedLink,
|
|
947
|
-
true,
|
|
948
|
-
(p) => this.mkdirRecursive(p, 0o755),
|
|
949
|
-
);
|
|
950
|
-
const symNode: InternalFileNode = {
|
|
951
|
-
type: "file",
|
|
952
|
-
name,
|
|
953
|
-
content: Buffer.from(normalizedTarget, "utf8"),
|
|
954
|
-
mode: 0o120777,
|
|
955
|
-
compressed: false,
|
|
956
|
-
createdAt: Date.now(),
|
|
957
|
-
updatedAt: Date.now(),
|
|
958
|
-
};
|
|
959
|
-
parent.children[name] = symNode;
|
|
960
|
-
parent._childCount++;
|
|
961
|
-
// Journal before emit
|
|
962
|
-
this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
|
|
963
|
-
this.emit("symlink:create", {
|
|
964
|
-
link: normalizedLink,
|
|
965
|
-
target: normalizedTarget,
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
/** Returns true when the path is a symbolic link node. */
|
|
970
|
-
public isSymlink(targetPath: string): boolean {
|
|
971
|
-
try {
|
|
972
|
-
const node = getNode(this.root, normalizePath(targetPath));
|
|
973
|
-
return node.type === "file" && node.mode === 0o120777;
|
|
974
|
-
} catch {
|
|
975
|
-
return false;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/**
|
|
980
|
-
* Resolves a symlink chain up to `maxDepth` hops.
|
|
981
|
-
* Throws when the chain is too long (circular links).
|
|
982
|
-
*/
|
|
983
|
-
public resolveSymlink(linkPath: string, maxDepth = 8): string {
|
|
984
|
-
let current = normalizePath(linkPath);
|
|
985
|
-
for (let depth = 0; depth < maxDepth; depth++) {
|
|
986
|
-
try {
|
|
987
|
-
const node = getNode(this.root, current);
|
|
988
|
-
if (node.type === "file" && node.mode === 0o120777) {
|
|
989
|
-
const target = (node as InternalFileNode).content.toString("utf8");
|
|
990
|
-
current = target.startsWith("/")
|
|
991
|
-
? target
|
|
992
|
-
: normalizePath(
|
|
993
|
-
path.posix.join(path.posix.dirname(current), target),
|
|
994
|
-
);
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
} catch {
|
|
998
|
-
break;
|
|
999
|
-
}
|
|
1000
|
-
return current;
|
|
1001
|
-
}
|
|
1002
|
-
throw new Error(`Too many levels of symbolic links: ${linkPath}`);
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/** Removes a file or directory node. */
|
|
1006
|
-
public remove(targetPath: string, options: RemoveOptions = {}): void {
|
|
1007
|
-
const m = this.resolveMount(targetPath);
|
|
1008
|
-
if (m) {
|
|
1009
|
-
if (m.readOnly) throw new Error(`EROFS: read-only file system, unlink '${m.fullHostPath}'`);
|
|
1010
|
-
if (!fsSync.existsSync(m.fullHostPath)) throw new Error(`ENOENT: no such file or directory, unlink '${m.fullHostPath}'`);
|
|
1011
|
-
const hst = fsSync.statSync(m.fullHostPath);
|
|
1012
|
-
if (hst.isDirectory()) {
|
|
1013
|
-
fsSync.rmSync(m.fullHostPath, { recursive: options.recursive ?? false });
|
|
1014
|
-
} else {
|
|
1015
|
-
fsSync.unlinkSync(m.fullHostPath);
|
|
1016
|
-
}
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
const normalized = normalizePath(targetPath);
|
|
1020
|
-
if (normalized === "/") throw new Error("Cannot remove root directory.");
|
|
1021
|
-
const node = getNode(this.root, normalized);
|
|
1022
|
-
if (node.type === "directory") {
|
|
1023
|
-
const dir = node as InternalDirectoryNode;
|
|
1024
|
-
if (!options.recursive && dir._childCount > 0) {
|
|
1025
|
-
throw new Error(
|
|
1026
|
-
`Directory '${normalized}' is not empty. Use recursive option.`,
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
const { parent, name } = getParentDirectory(
|
|
1031
|
-
this.root,
|
|
1032
|
-
normalized,
|
|
1033
|
-
false,
|
|
1034
|
-
() => {},
|
|
1035
|
-
);
|
|
1036
|
-
delete parent.children[name];
|
|
1037
|
-
parent._childCount--; this.emit("node:remove", { path: normalized });
|
|
1038
|
-
this._journal({ op: JournalOp.REMOVE, path: normalized });
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/** Moves or renames a node. */
|
|
1042
|
-
public move(fromPath: string, toPath: string): void {
|
|
1043
|
-
const fromNormalized = normalizePath(fromPath);
|
|
1044
|
-
const toNormalized = normalizePath(toPath);
|
|
1045
|
-
if (fromNormalized === "/" || toNormalized === "/") {
|
|
1046
|
-
throw new Error("Cannot move root directory.");
|
|
1047
|
-
}
|
|
1048
|
-
const node = getNode(this.root, fromNormalized);
|
|
1049
|
-
if (this.exists(toNormalized)) {
|
|
1050
|
-
throw new Error(`Destination '${toNormalized}' already exists.`);
|
|
1051
|
-
}
|
|
1052
|
-
this.mkdirRecursive(path.posix.dirname(toNormalized), 0o755);
|
|
1053
|
-
const { parent: destParent, name: destName } = getParentDirectory(
|
|
1054
|
-
this.root,
|
|
1055
|
-
toNormalized,
|
|
1056
|
-
false,
|
|
1057
|
-
() => {},
|
|
1058
|
-
);
|
|
1059
|
-
const { parent: srcParent, name: srcName } = getParentDirectory(
|
|
1060
|
-
this.root,
|
|
1061
|
-
fromNormalized,
|
|
1062
|
-
false,
|
|
1063
|
-
() => {},
|
|
1064
|
-
);
|
|
1065
|
-
delete srcParent.children[srcName];
|
|
1066
|
-
srcParent._childCount--;
|
|
1067
|
-
node.name = destName;
|
|
1068
|
-
destParent.children[destName] = node;
|
|
1069
|
-
destParent._childCount++;
|
|
1070
|
-
this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// ── Snapshot serialisation ─────────────────────────────────────────────────
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* Exports the entire filesystem as a JSON-serialisable snapshot.
|
|
1077
|
-
*
|
|
1078
|
-
* Works regardless of the persistence mode. Useful for test fixtures,
|
|
1079
|
-
* manual backups, or passing VFS state between processes.
|
|
1080
|
-
*/
|
|
1081
|
-
public toSnapshot(): VfsSnapshot {
|
|
1082
|
-
return { root: this.serializeDir(this.root) };
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
private serializeDir(dir: InternalDirectoryNode): VfsSnapshotDirectoryNode {
|
|
1086
|
-
const children: VfsSnapshotNode[] = [];
|
|
1087
|
-
for (const child of Object.values(dir.children)) {
|
|
1088
|
-
if (child.type === "stub") {
|
|
1089
|
-
// Serialize stub as a regular file node
|
|
1090
|
-
children.push({
|
|
1091
|
-
type: "file",
|
|
1092
|
-
name: child.name,
|
|
1093
|
-
mode: child.mode,
|
|
1094
|
-
createdAt: new Date(child.createdAt).toISOString(),
|
|
1095
|
-
updatedAt: new Date(child.updatedAt).toISOString(),
|
|
1096
|
-
compressed: false,
|
|
1097
|
-
contentBase64: Buffer.from(child.stubContent, "utf8").toString("base64"),
|
|
1098
|
-
} satisfies VfsSnapshotFileNode);
|
|
1099
|
-
} else if (child.type === "file") {
|
|
1100
|
-
children.push(this.serializeFile(child as InternalFileNode));
|
|
1101
|
-
} else {
|
|
1102
|
-
children.push(this.serializeDir(child as InternalDirectoryNode));
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
return {
|
|
1106
|
-
type: "directory",
|
|
1107
|
-
name: dir.name,
|
|
1108
|
-
mode: dir.mode,
|
|
1109
|
-
createdAt: new Date(dir.createdAt).toISOString(),
|
|
1110
|
-
updatedAt: new Date(dir.updatedAt).toISOString(),
|
|
1111
|
-
children,
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
private serializeFile(file: InternalFileNode): VfsSnapshotFileNode {
|
|
1116
|
-
return {
|
|
1117
|
-
type: "file",
|
|
1118
|
-
name: file.name,
|
|
1119
|
-
mode: file.mode,
|
|
1120
|
-
createdAt: new Date(file.createdAt).toISOString(),
|
|
1121
|
-
updatedAt: new Date(file.updatedAt).toISOString(),
|
|
1122
|
-
compressed: file.compressed,
|
|
1123
|
-
contentBase64: file.content.toString("base64"),
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* Creates a new `VirtualFileSystem` instance (memory mode) from a snapshot.
|
|
1129
|
-
*
|
|
1130
|
-
* @example
|
|
1131
|
-
* ```ts
|
|
1132
|
-
* const vfs = VirtualFileSystem.fromSnapshot(savedSnapshot);
|
|
1133
|
-
* ```
|
|
1134
|
-
*/
|
|
1135
|
-
public static fromSnapshot(snapshot: VfsSnapshot): VirtualFileSystem {
|
|
1136
|
-
const vfs = new VirtualFileSystem();
|
|
1137
|
-
vfs.root = vfs.deserializeDir(snapshot.root, "");
|
|
1138
|
-
return vfs;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
/**
|
|
1142
|
-
* Replaces the current filesystem state with the content of a snapshot.
|
|
1143
|
-
* The persistence mode is preserved.
|
|
1144
|
-
*
|
|
1145
|
-
* @example
|
|
1146
|
-
* ```ts
|
|
1147
|
-
* vfs.importSnapshot(savedSnapshot);
|
|
1148
|
-
* ```
|
|
1149
|
-
*/
|
|
1150
|
-
public importSnapshot(snapshot: VfsSnapshot): void {
|
|
1151
|
-
this.root = this.deserializeDir(snapshot.root, "");
|
|
1152
|
-
this.emit("snapshot:import");
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
private deserializeDir(
|
|
1156
|
-
snap: VfsSnapshotDirectoryNode,
|
|
1157
|
-
name: string,
|
|
1158
|
-
): InternalDirectoryNode {
|
|
1159
|
-
const dir: InternalDirectoryNode = {
|
|
1160
|
-
type: "directory",
|
|
1161
|
-
name,
|
|
1162
|
-
mode: snap.mode,
|
|
1163
|
-
createdAt: Date.parse(snap.createdAt),
|
|
1164
|
-
updatedAt: Date.parse(snap.updatedAt),
|
|
1165
|
-
children: Object.create(null) as Record<string, InternalNode>,
|
|
1166
|
-
_childCount: 0,
|
|
1167
|
-
};
|
|
1168
|
-
for (const child of snap.children) {
|
|
1169
|
-
if (child.type === "file") {
|
|
1170
|
-
const f = child as VfsSnapshotFileNode;
|
|
1171
|
-
dir.children[f.name] = {
|
|
1172
|
-
type: "file",
|
|
1173
|
-
name: f.name,
|
|
1174
|
-
mode: f.mode,
|
|
1175
|
-
createdAt: Date.parse(f.createdAt),
|
|
1176
|
-
updatedAt: Date.parse(f.updatedAt),
|
|
1177
|
-
compressed: f.compressed,
|
|
1178
|
-
content: Buffer.from(f.contentBase64, "base64"),
|
|
1179
|
-
};
|
|
1180
|
-
} else {
|
|
1181
|
-
const sub = this.deserializeDir(
|
|
1182
|
-
child as VfsSnapshotDirectoryNode,
|
|
1183
|
-
child.name,
|
|
1184
|
-
);
|
|
1185
|
-
dir.children[child.name] = sub;
|
|
1186
|
-
}
|
|
1187
|
-
dir._childCount++;
|
|
1188
|
-
}
|
|
1189
|
-
return dir;
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
export default VirtualFileSystem;
|