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.
Files changed (364) hide show
  1. package/README.md +43 -23
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/SSHMimic/executor.js +23 -5
  4. package/dist/commands/basename.d.ts +13 -0
  5. package/dist/commands/basename.js +45 -0
  6. package/dist/commands/file.d.ts +8 -0
  7. package/dist/commands/file.js +57 -0
  8. package/dist/commands/fun.d.ts +32 -0
  9. package/dist/commands/fun.js +172 -0
  10. package/dist/commands/ifconfig.d.ts +7 -0
  11. package/dist/commands/ifconfig.js +52 -0
  12. package/dist/commands/last.d.ts +13 -0
  13. package/dist/commands/last.js +68 -0
  14. package/dist/commands/manuals-bundle.js +598 -6
  15. package/dist/commands/registry.js +24 -2
  16. package/dist/commands/runtime.js +159 -106
  17. package/dist/commands/sh.js +5 -0
  18. package/dist/commands/tput.d.ts +13 -0
  19. package/dist/commands/tput.js +76 -0
  20. package/dist/commands/w.d.ts +7 -0
  21. package/dist/commands/w.js +38 -0
  22. package/dist/utils/expand.d.ts +12 -0
  23. package/dist/utils/expand.js +84 -0
  24. package/package.json +9 -3
  25. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -50
  26. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -31
  27. package/.github/dependabot.yml +0 -27
  28. package/.github/pull_request_template.md +0 -21
  29. package/.github/workflows/create-pull-request.yml +0 -85
  30. package/.github/workflows/publish.yml +0 -25
  31. package/.github/workflows/test-battery.yml +0 -102
  32. package/.vscode/settings.json +0 -20
  33. package/CODE_OF_CONDUCT.md +0 -39
  34. package/CONTRIBUTING.md +0 -59
  35. package/HONEYPOT.md +0 -358
  36. package/SECURITY.md +0 -33
  37. package/benchmark-results.txt +0 -40
  38. package/benchmark-virtualshell.ts +0 -88
  39. package/biome.json +0 -37
  40. package/build.js +0 -22
  41. package/builds/fortune-nyx-v1.5.1-directbash-k6.1.0.mjs +0 -1768
  42. package/builds/fortune-nyx-v1.5.1-ssh-nosftp.js +0 -1768
  43. package/builds/fortune-nyx-v1.5.1-ssh.cjs +0 -1769
  44. package/builds/fortune-nyx-v1.5.1-web.min.js +0 -17022
  45. package/bun.lock +0 -244
  46. package/docs/.nojekyll +0 -1
  47. package/docs/app.js +0 -1755
  48. package/docs/assets/hierarchy.js +0 -1
  49. package/docs/assets/highlight.css +0 -162
  50. package/docs/assets/icons.js +0 -18
  51. package/docs/assets/icons.svg +0 -1
  52. package/docs/assets/main.js +0 -60
  53. package/docs/assets/navigation.js +0 -1
  54. package/docs/assets/search.js +0 -1
  55. package/docs/assets/style.css +0 -1633
  56. package/docs/classes/HoneyPot.html +0 -31
  57. package/docs/classes/IdleManager.html +0 -162
  58. package/docs/classes/SshClient.html +0 -66
  59. package/docs/classes/VirtualFileSystem.html +0 -279
  60. package/docs/classes/VirtualPackageManager.html +0 -63
  61. package/docs/classes/VirtualSftpServer.html +0 -169
  62. package/docs/classes/VirtualShell.html +0 -285
  63. package/docs/classes/VirtualSshServer.html +0 -182
  64. package/docs/classes/VirtualUserManager.html +0 -276
  65. package/docs/demo.html +0 -82
  66. package/docs/functions/assertDiff.html +0 -6
  67. package/docs/functions/diffSnapshots.html +0 -7
  68. package/docs/functions/formatDiff.html +0 -6
  69. package/docs/functions/getArg.html +0 -13
  70. package/docs/functions/getFlag.html +0 -15
  71. package/docs/functions/ifFlag.html +0 -11
  72. package/docs/hierarchy.html +0 -1
  73. package/docs/index.html +0 -1869
  74. package/docs/interfaces/AuditLogEntry.html +0 -6
  75. package/docs/interfaces/CommandContext.html +0 -22
  76. package/docs/interfaces/CommandResult.html +0 -26
  77. package/docs/interfaces/ExecStream.html +0 -11
  78. package/docs/interfaces/HoneyPotStats.html +0 -16
  79. package/docs/interfaces/IdleManagerOptions.html +0 -7
  80. package/docs/interfaces/InstalledPackage.html +0 -20
  81. package/docs/interfaces/NanoEditorSession.html +0 -8
  82. package/docs/interfaces/PackageDefinition.html +0 -30
  83. package/docs/interfaces/PackageFile.html +0 -8
  84. package/docs/interfaces/PasswordChallenge.html +0 -16
  85. package/docs/interfaces/RemoveOptions.html +0 -4
  86. package/docs/interfaces/ShellEnv.html +0 -6
  87. package/docs/interfaces/ShellModule.html +0 -14
  88. package/docs/interfaces/ShellProperties.html +0 -14
  89. package/docs/interfaces/ShellStream.html +0 -11
  90. package/docs/interfaces/SudoChallenge.html +0 -24
  91. package/docs/interfaces/VfsBaseNode.html +0 -12
  92. package/docs/interfaces/VfsDiff.html +0 -10
  93. package/docs/interfaces/VfsDiffEntry.html +0 -6
  94. package/docs/interfaces/VfsDiffModified.html +0 -10
  95. package/docs/interfaces/VfsDirectoryNode.html +0 -15
  96. package/docs/interfaces/VfsFileNode.html +0 -17
  97. package/docs/interfaces/VfsOptions.html +0 -26
  98. package/docs/interfaces/VfsSnapshot.html +0 -3
  99. package/docs/interfaces/VfsSnapshotBaseNode.html +0 -8
  100. package/docs/interfaces/VfsSnapshotDirectoryNode.html +0 -10
  101. package/docs/interfaces/VfsSnapshotFileNode.html +0 -12
  102. package/docs/interfaces/VirtualActiveSession.html +0 -12
  103. package/docs/interfaces/VirtualSftpServerOptions.html +0 -7
  104. package/docs/interfaces/VirtualShellVfsLike.html +0 -15
  105. package/docs/interfaces/VirtualShellVfsOptions.html +0 -3
  106. package/docs/interfaces/WriteFileOptions.html +0 -6
  107. package/docs/media/LICENSE +0 -21
  108. package/docs/modules.html +0 -1
  109. package/docs/types/ArgParseOptions.html +0 -4
  110. package/docs/types/CommandMode.html +0 -2
  111. package/docs/types/CommandOutcome.html +0 -2
  112. package/docs/types/IdleState.html +0 -1
  113. package/docs/types/VfsNodeStats.html +0 -2
  114. package/docs/types/VfsNodeType.html +0 -2
  115. package/docs/types/VfsPersistenceMode.html +0 -5
  116. package/docs/types/VfsSnapshotNode.html +0 -2
  117. package/examples/README.md +0 -288
  118. package/examples/app.js +0 -1755
  119. package/examples/app.ts +0 -299
  120. package/examples/build.js +0 -27
  121. package/examples/demo.html +0 -33
  122. package/examples/honeypot-audit.ts +0 -180
  123. package/examples/honeypot-export.ts +0 -253
  124. package/examples/honeypot-quickstart.ts +0 -110
  125. package/examples/index.html +0 -82
  126. package/examples/server.js +0 -55
  127. package/polyfills/buffer.js +0 -117
  128. package/polyfills/node_child_process/index.js +0 -2
  129. package/polyfills/node_crypto/index.js +0 -167
  130. package/polyfills/node_events/index.js +0 -9
  131. package/polyfills/node_fs/index.js +0 -202
  132. package/polyfills/node_fs/promises.js +0 -4
  133. package/polyfills/node_os/index.js +0 -9
  134. package/polyfills/node_path/index.js +0 -28
  135. package/polyfills/node_vm/index.js +0 -7
  136. package/polyfills/node_zlib/index.js +0 -3
  137. package/polyfills/process.js +0 -14
  138. package/polyfills/ssh2/index.js +0 -75
  139. package/scripts/build-all.mjs +0 -226
  140. package/scripts/build-names.mjs +0 -43
  141. package/scripts/generate-manuals-bundle.mjs +0 -49
  142. package/scripts/postinstall.js +0 -42
  143. package/scripts/publish-package.sh +0 -70
  144. package/src/Honeypot/index.ts +0 -457
  145. package/src/SSHClient/index.ts +0 -270
  146. package/src/SSHMimic/exec.ts +0 -49
  147. package/src/SSHMimic/executor.ts +0 -251
  148. package/src/SSHMimic/hostKey.ts +0 -21
  149. package/src/SSHMimic/index.ts +0 -337
  150. package/src/SSHMimic/loginBanner.ts +0 -36
  151. package/src/SSHMimic/loginFormat.ts +0 -10
  152. package/src/SSHMimic/prompt.ts +0 -14
  153. package/src/SSHMimic/sftp.ts +0 -883
  154. package/src/VirtualFileSystem/binaryPack.ts +0 -258
  155. package/src/VirtualFileSystem/index.ts +0 -1193
  156. package/src/VirtualFileSystem/internalTypes.ts +0 -43
  157. package/src/VirtualFileSystem/journal.ts +0 -171
  158. package/src/VirtualFileSystem/path.ts +0 -74
  159. package/src/VirtualPackageManager/index.ts +0 -1006
  160. package/src/VirtualShell/idleManager.ts +0 -137
  161. package/src/VirtualShell/index.ts +0 -475
  162. package/src/VirtualShell/shell.ts +0 -700
  163. package/src/VirtualShell/shellParser.ts +0 -285
  164. package/src/VirtualUserManager/index.ts +0 -758
  165. package/src/bun.d.ts +0 -1
  166. package/src/commands/adduser.ts +0 -103
  167. package/src/commands/alias.ts +0 -69
  168. package/src/commands/apt.ts +0 -233
  169. package/src/commands/awk.ts +0 -168
  170. package/src/commands/base64.ts +0 -29
  171. package/src/commands/cat.ts +0 -52
  172. package/src/commands/cd.ts +0 -25
  173. package/src/commands/chmod.ts +0 -85
  174. package/src/commands/clear.ts +0 -15
  175. package/src/commands/command-helpers.ts +0 -286
  176. package/src/commands/cp.ts +0 -83
  177. package/src/commands/curl.ts +0 -147
  178. package/src/commands/cut.ts +0 -36
  179. package/src/commands/date.ts +0 -30
  180. package/src/commands/declare.ts +0 -49
  181. package/src/commands/deluser.ts +0 -98
  182. package/src/commands/df.ts +0 -23
  183. package/src/commands/diff.ts +0 -43
  184. package/src/commands/dpkg.ts +0 -180
  185. package/src/commands/du.ts +0 -56
  186. package/src/commands/echo.ts +0 -58
  187. package/src/commands/env.ts +0 -23
  188. package/src/commands/exit.ts +0 -18
  189. package/src/commands/export.ts +0 -34
  190. package/src/commands/find.ts +0 -68
  191. package/src/commands/free.ts +0 -47
  192. package/src/commands/grep.ts +0 -116
  193. package/src/commands/groups.ts +0 -19
  194. package/src/commands/gzip.ts +0 -88
  195. package/src/commands/head.ts +0 -52
  196. package/src/commands/help.ts +0 -152
  197. package/src/commands/helpers.ts +0 -234
  198. package/src/commands/history.ts +0 -34
  199. package/src/commands/hostname.ts +0 -14
  200. package/src/commands/htop.ts +0 -20
  201. package/src/commands/id.ts +0 -19
  202. package/src/commands/index.ts +0 -9
  203. package/src/commands/kill.ts +0 -19
  204. package/src/commands/ln.ts +0 -71
  205. package/src/commands/ls.ts +0 -243
  206. package/src/commands/lsb-release.ts +0 -63
  207. package/src/commands/man.ts +0 -31
  208. package/src/commands/manuals/adduser.txt +0 -11
  209. package/src/commands/manuals/apt-cache.txt +0 -12
  210. package/src/commands/manuals/apt.txt +0 -20
  211. package/src/commands/manuals/awk.txt +0 -13
  212. package/src/commands/manuals/cat.txt +0 -14
  213. package/src/commands/manuals/cd.txt +0 -16
  214. package/src/commands/manuals/chmod.txt +0 -16
  215. package/src/commands/manuals/clear.txt +0 -10
  216. package/src/commands/manuals/cp.txt +0 -10
  217. package/src/commands/manuals/curl.txt +0 -20
  218. package/src/commands/manuals/date.txt +0 -14
  219. package/src/commands/manuals/declare.txt +0 -12
  220. package/src/commands/manuals/deluser.txt +0 -10
  221. package/src/commands/manuals/df.txt +0 -10
  222. package/src/commands/manuals/dpkg-query.txt +0 -11
  223. package/src/commands/manuals/dpkg.txt +0 -14
  224. package/src/commands/manuals/du.txt +0 -11
  225. package/src/commands/manuals/echo.txt +0 -11
  226. package/src/commands/manuals/false.txt +0 -10
  227. package/src/commands/manuals/find.txt +0 -11
  228. package/src/commands/manuals/free.txt +0 -12
  229. package/src/commands/manuals/grep.txt +0 -13
  230. package/src/commands/manuals/groups.txt +0 -10
  231. package/src/commands/manuals/gzip.txt +0 -11
  232. package/src/commands/manuals/head.txt +0 -10
  233. package/src/commands/manuals/help.txt +0 -11
  234. package/src/commands/manuals/history.txt +0 -11
  235. package/src/commands/manuals/hostname.txt +0 -10
  236. package/src/commands/manuals/id.txt +0 -10
  237. package/src/commands/manuals/kill.txt +0 -13
  238. package/src/commands/manuals/ls.txt +0 -20
  239. package/src/commands/manuals/lsb_release.txt +0 -14
  240. package/src/commands/manuals/mkdir.txt +0 -10
  241. package/src/commands/manuals/mv.txt +0 -10
  242. package/src/commands/manuals/nano.txt +0 -11
  243. package/src/commands/manuals/neofetch.txt +0 -10
  244. package/src/commands/manuals/node.txt +0 -13
  245. package/src/commands/manuals/npm.txt +0 -13
  246. package/src/commands/manuals/npx.txt +0 -13
  247. package/src/commands/manuals/passwd.txt +0 -11
  248. package/src/commands/manuals/ping.txt +0 -10
  249. package/src/commands/manuals/printf.txt +0 -11
  250. package/src/commands/manuals/ps.txt +0 -10
  251. package/src/commands/manuals/pwd.txt +0 -10
  252. package/src/commands/manuals/python3.txt +0 -13
  253. package/src/commands/manuals/readlink.txt +0 -10
  254. package/src/commands/manuals/return.txt +0 -10
  255. package/src/commands/manuals/rm.txt +0 -10
  256. package/src/commands/manuals/sed.txt +0 -11
  257. package/src/commands/manuals/set.txt +0 -11
  258. package/src/commands/manuals/shift.txt +0 -10
  259. package/src/commands/manuals/sleep.txt +0 -10
  260. package/src/commands/manuals/sort.txt +0 -12
  261. package/src/commands/manuals/source.txt +0 -11
  262. package/src/commands/manuals/ssh.txt +0 -11
  263. package/src/commands/manuals/stat.txt +0 -10
  264. package/src/commands/manuals/su.txt +0 -13
  265. package/src/commands/manuals/sudo.txt +0 -11
  266. package/src/commands/manuals/tail.txt +0 -10
  267. package/src/commands/manuals/tar.txt +0 -19
  268. package/src/commands/manuals/tee.txt +0 -10
  269. package/src/commands/manuals/test.txt +0 -11
  270. package/src/commands/manuals/touch.txt +0 -11
  271. package/src/commands/manuals/tr.txt +0 -10
  272. package/src/commands/manuals/trap.txt +0 -10
  273. package/src/commands/manuals/true.txt +0 -10
  274. package/src/commands/manuals/type.txt +0 -10
  275. package/src/commands/manuals/uname.txt +0 -12
  276. package/src/commands/manuals/uniq.txt +0 -12
  277. package/src/commands/manuals/unset.txt +0 -10
  278. package/src/commands/manuals/uptime.txt +0 -11
  279. package/src/commands/manuals/wc.txt +0 -12
  280. package/src/commands/manuals/wget.txt +0 -12
  281. package/src/commands/manuals/which.txt +0 -10
  282. package/src/commands/manuals/whoami.txt +0 -10
  283. package/src/commands/manuals/xargs.txt +0 -10
  284. package/src/commands/manuals-bundle.ts +0 -898
  285. package/src/commands/mkdir.ts +0 -31
  286. package/src/commands/mv.ts +0 -50
  287. package/src/commands/nano.ts +0 -38
  288. package/src/commands/neofetch.ts +0 -53
  289. package/src/commands/node.ts +0 -341
  290. package/src/commands/npm.ts +0 -132
  291. package/src/commands/passwd.ts +0 -50
  292. package/src/commands/ping.ts +0 -32
  293. package/src/commands/printf.ts +0 -129
  294. package/src/commands/ps.ts +0 -58
  295. package/src/commands/pwd.ts +0 -9
  296. package/src/commands/python.ts +0 -2229
  297. package/src/commands/read.ts +0 -46
  298. package/src/commands/registry.ts +0 -249
  299. package/src/commands/rm.ts +0 -42
  300. package/src/commands/runtime.ts +0 -378
  301. package/src/commands/sed.ts +0 -68
  302. package/src/commands/seq.ts +0 -43
  303. package/src/commands/set.ts +0 -29
  304. package/src/commands/sh.ts +0 -467
  305. package/src/commands/shift.ts +0 -63
  306. package/src/commands/sleep.ts +0 -20
  307. package/src/commands/sort.ts +0 -46
  308. package/src/commands/source.ts +0 -52
  309. package/src/commands/stat.ts +0 -61
  310. package/src/commands/su.ts +0 -72
  311. package/src/commands/sudo.ts +0 -76
  312. package/src/commands/tail.ts +0 -53
  313. package/src/commands/tar.ts +0 -102
  314. package/src/commands/tee.ts +0 -36
  315. package/src/commands/test.ts +0 -137
  316. package/src/commands/touch.ts +0 -28
  317. package/src/commands/tr.ts +0 -70
  318. package/src/commands/tree.ts +0 -20
  319. package/src/commands/true.ts +0 -27
  320. package/src/commands/type.ts +0 -48
  321. package/src/commands/uname.ts +0 -29
  322. package/src/commands/uniq.ts +0 -39
  323. package/src/commands/unset.ts +0 -17
  324. package/src/commands/uptime.ts +0 -54
  325. package/src/commands/wc.ts +0 -55
  326. package/src/commands/wget.ts +0 -148
  327. package/src/commands/which.ts +0 -37
  328. package/src/commands/who.ts +0 -25
  329. package/src/commands/whoami.ts +0 -14
  330. package/src/commands/xargs.ts +0 -31
  331. package/src/index.ts +0 -67
  332. package/src/modules/linuxRootfs.ts +0 -1961
  333. package/src/modules/neofetch.ts +0 -358
  334. package/src/modules/shellInteractive.ts +0 -57
  335. package/src/modules/shellRuntime.ts +0 -76
  336. package/src/self-standalone.ts +0 -542
  337. package/src/standalone-wo-sftp.ts +0 -38
  338. package/src/standalone.ts +0 -72
  339. package/src/types/commands.ts +0 -146
  340. package/src/types/pipeline.ts +0 -52
  341. package/src/types/streams.ts +0 -32
  342. package/src/types/tar-stream.d.ts +0 -38
  343. package/src/types/vfs.ts +0 -98
  344. package/src/utils/expand.ts +0 -491
  345. package/src/utils/perfLogger.ts +0 -72
  346. package/src/utils/tokenize.ts +0 -98
  347. package/src/utils/vfsDiff.ts +0 -275
  348. package/tests/command-helpers.test.ts +0 -116
  349. package/tests/commands-admin-net.test.ts +0 -441
  350. package/tests/commands-advanced.test.ts +0 -456
  351. package/tests/commands-core.test.ts +0 -562
  352. package/tests/commands-missing.test.ts +0 -570
  353. package/tests/commands-specific-units.test.ts +0 -327
  354. package/tests/commands-text-sys.test.ts +0 -445
  355. package/tests/expand.test.ts +0 -170
  356. package/tests/helpers.test.ts +0 -97
  357. package/tests/new-features.test.ts +0 -1036
  358. package/tests/parser-executor.test.ts +0 -37
  359. package/tests/sftp.test.ts +0 -323
  360. package/tests/ssh-exec.test.ts +0 -45
  361. package/tests/test-helper.ts +0 -79
  362. package/tests/users.test.ts +0 -86
  363. package/tsconfig.json +0 -49
  364. 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;