typescript-virtual-container 1.5.3 → 1.5.5

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