memory.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023
  1. /**
  2. * In-memory filesystem behind the worker's `node:fs` proxy. Contents come from
  3. * the build-time image (see {@link loadVfsImage}); this remains the synchronous
  4. * authority when an asynchronous durable sink mirrors selected subtrees.
  5. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory
  6. */
  7. import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts'
  8. import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts'
  9. import { parseTar } from './tar.ts'
  10. import type {
  11. Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, VfsOpenFile,
  12. VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions,
  13. } from './types.ts'
  14. const decoder = new TextDecoder()
  15. const encoder = new TextEncoder()
  16. interface FileNode {
  17. bytes: Uint8Array
  18. mtimeMs: number
  19. /** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */
  20. mode: number
  21. /** Stable identity shared by hard links and retained by open descriptors. */
  22. identity?: bigint
  23. /** One path normally, a Set only for hard links, or undefined after the final unlink. */
  24. paths: string | Set<string> | undefined
  25. }
  26. /** Creation default for files, Node's `0o666` under the classic `022` umask. */
  27. const DEFAULT_FILE_MODE = 0o644
  28. /** Creation default for directories, Node's `0o777` under the classic `022` umask. */
  29. const DEFAULT_DIRECTORY_MODE = 0o755
  30. function fail(code: string, syscall: string, path: string, detail?: string): never {
  31. const error = new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`) as VfsError
  32. error.code = code
  33. error.path = path
  34. error.syscall = syscall
  35. throw error
  36. }
  37. function encodingOf(options: VfsReadOptions): VfsEncoding | undefined {
  38. if (options === null || options === undefined) return undefined
  39. if (typeof options === 'string') return options
  40. return options.encoding ?? undefined
  41. }
  42. // Permission bits are entry state: creation takes the caller's mode (or the
  43. // umask-free default), `chmod` changes it, and both stat shapes report the
  44. // stored value — the round-trip consumers like dsh-credentials-local's
  45. // owner-only check rely on. The bits are never enforced: a single-owner
  46. // filesystem reads and writes as its owner regardless, like root.
  47. function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsStats {
  48. return {
  49. size,
  50. ino: Number(ino),
  51. mtimeMs,
  52. ctimeMs: mtimeMs,
  53. atimeMs: mtimeMs,
  54. birthtimeMs: mtimeMs,
  55. mtime: new Date(mtimeMs),
  56. mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777),
  57. isFile: () => !directory,
  58. isDirectory: () => directory,
  59. isSymbolicLink: () => false,
  60. isFIFO: () => false,
  61. isSocket: () => false,
  62. isBlockDevice: () => false,
  63. isCharacterDevice: () => false,
  64. }
  65. }
  66. /**
  67. * The same entry as {@link statsOf}, in the BigInt shape.
  68. *
  69. * Timestamps carry millisecond resolution scaled to nanoseconds, which is what
  70. * the underlying `mtimeMs` holds; the VFS keeps that value strictly increasing
  71. * per entry so two writes inside one millisecond still differ.
  72. * @param size - Byte length; zero for a directory.
  73. * @param mtimeMs - Modification time the entry carries.
  74. * @param directory - Whether the entry is a directory.
  75. * @param ino - Identity of the entry at this path.
  76. * @param mode - Stored permission bits of the entry.
  77. * @returns Stats in the shape Node returns under `{ bigint: true }`.
  78. */
  79. function bigIntStatsOf(
  80. size: number,
  81. mtimeMs: number,
  82. directory: boolean,
  83. ino: bigint,
  84. mode: number,
  85. nlink = 1,
  86. ): VfsBigIntStats {
  87. const milliseconds = BigInt(Math.trunc(mtimeMs))
  88. const nanoseconds = milliseconds * 1_000_000n
  89. const time = new Date(mtimeMs)
  90. return {
  91. size: BigInt(size),
  92. mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)),
  93. dev: 1n,
  94. ino,
  95. nlink: BigInt(nlink),
  96. mtimeMs: milliseconds,
  97. mtimeNs: nanoseconds,
  98. ctimeMs: milliseconds,
  99. ctimeNs: nanoseconds,
  100. atimeMs: milliseconds,
  101. atimeNs: nanoseconds,
  102. birthtimeMs: milliseconds,
  103. birthtimeNs: nanoseconds,
  104. mtime: time,
  105. ctime: time,
  106. atime: time,
  107. birthtime: time,
  108. isFile: () => !directory,
  109. isDirectory: () => directory,
  110. isSymbolicLink: () => false,
  111. isFIFO: () => false,
  112. isSocket: () => false,
  113. isBlockDevice: () => false,
  114. isCharacterDevice: () => false,
  115. }
  116. }
  117. interface OpenMode {
  118. readonly readable: boolean
  119. readonly writable: boolean
  120. readonly append: boolean
  121. readonly create: boolean
  122. readonly truncate: boolean
  123. readonly exclusive: boolean
  124. }
  125. /** Parse the Node string flags supported by the compatibility filesystem. */
  126. function openMode(flags: string): OpenMode {
  127. const base = flags[0]
  128. const suffix = flags.slice(1).split('')
  129. const validSuffix = suffix.every(flag => flag === '+' || flag === 'x' || flag === 's')
  130. const uniqueSuffix = new Set(suffix).size === suffix.length
  131. if ((base !== 'r' && base !== 'w' && base !== 'a') || !validSuffix || !uniqueSuffix
  132. || base === 'r' && flags.includes('x')) {
  133. const error = new TypeError(`The argument 'flags' is invalid. Received '${flags}'`) as TypeError & { code: string }
  134. error.code = 'ERR_INVALID_ARG_VALUE'
  135. throw error
  136. }
  137. return {
  138. readable: base === 'r' || flags.includes('+'),
  139. writable: base !== 'r' || flags.includes('+'),
  140. append: base === 'a',
  141. create: base === 'w' || base === 'a',
  142. truncate: base === 'w',
  143. exclusive: flags.includes('x'),
  144. }
  145. }
  146. /** Resize bytes exactly, preserving the prefix and zero-filling growth. */
  147. function resize(bytes: Uint8Array, length: number): Uint8Array {
  148. if (!Number.isSafeInteger(length) || length < 0) {
  149. const error = new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`) as RangeError & { code: string }
  150. error.code = 'ERR_OUT_OF_RANGE'
  151. throw error
  152. }
  153. const resized = new Uint8Array(length)
  154. resized.set(bytes.subarray(0, length))
  155. return resized
  156. }
  157. /** Construction inputs for {@link MemoryVfs}. */
  158. export interface MemoryVfsOptions {
  159. /** Durable write-behind observer; absent leaves the filesystem ephemeral. */
  160. readonly sink?: VfsMutationSink
  161. }
  162. /**
  163. * Filesystem held in two maps: one for file bytes, one for directories.
  164. * Every path is normalized to an absolute POSIX path without a trailing
  165. * separator, so callers may pass either form.
  166. */
  167. export class MemoryVfs implements Vfs {
  168. private readonly files = new Map<string, FileNode>()
  169. private readonly directories = new Set<string>([SEP])
  170. /** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */
  171. private readonly directoryModes = new Map<string, number>()
  172. /** Directory mtimes advance when their immediate entry set changes. */
  173. private readonly directoryMtimes = new Map<string, number>()
  174. private readonly mutationListeners = new Set<VfsMutationListener>()
  175. private readonly sink: VfsMutationSink | undefined
  176. private temporaries = 0
  177. // Directories retain path identities. File identities live on FileNode so
  178. // descriptors, renames, and hard links continue to address the same file.
  179. private readonly identities = new Map<string, bigint>()
  180. private lastIdentity = 0n
  181. /**
  182. * Build the synchronous filesystem authority.
  183. * @param options - Optional durable write-behind sink.
  184. */
  185. constructor(options: MemoryVfsOptions = {}) {
  186. this.sink = options.sink
  187. }
  188. /**
  189. * Settle the durable sink without changing in-memory success.
  190. * @returns A promise that resolves when all recorded mutations are stored.
  191. */
  192. async flush(): Promise<void> {
  193. await this.sink?.flush()
  194. }
  195. /**
  196. * Observe committed runtime mutations. Image seeding is deliberately silent.
  197. * @param listener - Consumer called after each successful mutation.
  198. * @returns A disposer that prevents future calls.
  199. */
  200. subscribe(listener: VfsMutationListener): () => void {
  201. this.mutationListeners.add(listener)
  202. return () => { this.mutationListeners.delete(listener) }
  203. }
  204. /** Publish after state changes; one faulty observer cannot roll back a write. */
  205. private publish(mutation: VfsMutation): void {
  206. const observers: VfsMutationListener[] = [
  207. ...(this.sink === undefined ? [] : [(change: VfsMutation): void => { this.sink?.record(change) }]),
  208. ...this.mutationListeners,
  209. ]
  210. for (const listener of observers) {
  211. try {
  212. listener(mutation)
  213. } catch (error) {
  214. console.error('webworker vfs: mutation observer failed', error)
  215. }
  216. }
  217. }
  218. /** Promise face mirroring `node:fs/promises` for the methods the roster uses. */
  219. readonly promises = {
  220. readFile: async (path: string, options?: VfsReadOptions): Promise<string | Uint8Array> => this.readFileSync(path, options),
  221. writeFile: async (path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise<void> => {
  222. this.writeFileSync(path, data, options)
  223. },
  224. appendFile: async (path: string, data: string | Uint8Array): Promise<void> => { this.appendFileSync(path, data) },
  225. mkdir: async (path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> =>
  226. this.mkdirSync(path, options),
  227. readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise<string[] & VfsDirent[]> =>
  228. this.readdirSync(path, options),
  229. stat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
  230. lstat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
  231. realpath: async (path: string): Promise<string> => this.realpathSync(path),
  232. rename: async (from: string, to: string): Promise<void> => { this.renameSync(from, to) },
  233. unlink: async (path: string): Promise<void> => { this.unlinkSync(path) },
  234. rm: async (path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { this.rmSync(path, options) },
  235. mkdtemp: async (prefix: string): Promise<string> => this.mkdtempSync(prefix),
  236. link: async (existing: string, next: string): Promise<void> => { this.linkSync(existing, next) },
  237. truncate: async (path: string, length?: number): Promise<void> => { this.truncateSync(path, length) },
  238. chmod: async (path: string, mode: number): Promise<void> => { this.chmodSync(path, mode) },
  239. opendir: async (path: string): Promise<VfsDir> => this.opendir(path),
  240. open: async (path: string, flags?: string, mode?: number): Promise<VfsFileHandle> => this.open(path, flags, mode),
  241. /** Resolves for any existing path: the VFS grants read and write to everything it holds. */
  242. access: async (path: string): Promise<void> => {
  243. const target = normalize(resolve(path))
  244. if (!this.files.has(target) && !this.directories.has(target)) fail('ENOENT', 'access', target)
  245. },
  246. }
  247. /** @returns Absolute path with no trailing separator. */
  248. private key(path: string): string {
  249. const absolute = normalize(resolve(path))
  250. return absolute.length > 1 && absolute.endsWith(SEP) ? absolute.slice(0, -1) : absolute
  251. }
  252. /**
  253. * Read a file.
  254. * @param path - File path.
  255. * @param options - `'utf8'` or `{encoding}` for text; omitted for bytes.
  256. * @returns Text or a copy-free view of the stored bytes.
  257. */
  258. readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array {
  259. const target = this.key(path)
  260. const node = this.files.get(target)
  261. if (node === undefined) {
  262. if (this.directories.has(target)) fail('EISDIR', 'read', target)
  263. fail('ENOENT', 'open', target)
  264. }
  265. return encodingOf(options) === undefined ? node.bytes : decoder.decode(node.bytes)
  266. }
  267. /**
  268. * Report whether a path exists.
  269. * @param path - Path to test.
  270. * @returns True for files and directories.
  271. */
  272. existsSync(path: string): boolean {
  273. const target = this.key(path)
  274. return this.files.has(target) || this.directories.has(target)
  275. }
  276. /**
  277. * Stat a path.
  278. * @param path - Path to stat.
  279. * @param options - `bigint` selects the BigInt stats Node returns for it.
  280. * @returns Stats for the file or directory.
  281. */
  282. statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
  283. const target = this.key(path)
  284. const node = this.files.get(target)
  285. const [size, mtimeMs, directory, mode] = node !== undefined
  286. ? [node.bytes.length, node.mtimeMs, false, node.mode] as const
  287. : this.directories.has(target)
  288. ? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const
  289. : fail('ENOENT', 'stat', target)
  290. const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node)
  291. return options?.bigint === true
  292. ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.fileLinkCount(node))
  293. : statsOf(size, mtimeMs, directory, identity, mode)
  294. }
  295. /** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
  296. private plainStats(path: string): VfsStats {
  297. return this.statSync(path) as VfsStats
  298. }
  299. /** @returns The stable identity of an existing path, assigning one on first observation. */
  300. private identityOf(target: string): bigint {
  301. const existing = this.identities.get(target)
  302. if (existing !== undefined) return existing
  303. this.lastIdentity += 1n
  304. this.identities.set(target, this.lastIdentity)
  305. return this.lastIdentity
  306. }
  307. /** @returns The inode-like identity retained by a file node across names. */
  308. private identityOfFile(node: FileNode): bigint {
  309. if (node.identity !== undefined) return node.identity
  310. this.lastIdentity += 1n
  311. node.identity = this.lastIdentity
  312. return node.identity
  313. }
  314. /** @returns The number of names currently linked to one file node. */
  315. private fileLinkCount(node: FileNode): number {
  316. return typeof node.paths === 'string' ? 1 : node.paths?.size ?? 0
  317. }
  318. /** Add one map name, promoting the rare hard-link case to a Set. */
  319. private addFilePath(node: FileNode, path: string): void {
  320. if (node.paths === undefined) {
  321. node.paths = path
  322. } else if (typeof node.paths === 'string') {
  323. node.paths = new Set([node.paths, path])
  324. } else {
  325. node.paths.add(path)
  326. }
  327. }
  328. /** Remove one map name, collapsing a remaining single link back to a string. */
  329. private removeFilePath(node: FileNode, path: string): void {
  330. if (typeof node.paths === 'string') {
  331. node.paths = undefined
  332. return
  333. }
  334. if (node.paths === undefined) return
  335. node.paths.delete(path)
  336. if (node.paths.size === 1) {
  337. const [remaining] = node.paths
  338. node.paths = remaining
  339. }
  340. }
  341. /** Set one file-map entry while maintaining both nodes' reverse path indexes. */
  342. private setFile(path: string, node: FileNode): void {
  343. const previous = this.files.get(path)
  344. if (previous === node) return
  345. if (previous !== undefined) this.removeFilePath(previous, path)
  346. this.files.set(path, node)
  347. this.addFilePath(node, path)
  348. }
  349. /** Delete one file-map entry while retaining an unlinked node held by a descriptor. */
  350. private deleteFile(path: string): FileNode | undefined {
  351. const node = this.files.get(path)
  352. if (node === undefined) return undefined
  353. this.files.delete(path)
  354. this.removeFilePath(node, path)
  355. return node
  356. }
  357. /** Publish one linked name after a content or metadata write. */
  358. private publishFilePath(node: FileNode, path: string, appendedFrom?: number): void {
  359. this.publish({
  360. kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false,
  361. ...appendedFrom === undefined ? {} : { appendedFrom },
  362. })
  363. }
  364. /** Publish a content or metadata write for every hard link to one node. */
  365. private publishFile(node: FileNode, appendedFrom?: number): void {
  366. if (typeof node.paths === 'string') {
  367. this.publishFilePath(node, node.paths, appendedFrom)
  368. return
  369. }
  370. if (node.paths === undefined) return
  371. for (const path of node.paths) this.publishFilePath(node, path, appendedFrom)
  372. }
  373. /** Replace bytes on one file identity and notify all linked paths. */
  374. private replaceFile(node: FileNode, bytes: Uint8Array, appendedFrom?: number): void {
  375. node.bytes = bytes
  376. node.mtimeMs = this.touchNode(node)
  377. this.publishFile(node, appendedFrom)
  378. }
  379. /** Write at one offset, zero-filling any gap. */
  380. private writeFileNode(node: FileNode, position: number, data: Uint8Array): number {
  381. const offset = Math.max(0, position)
  382. const previousLength = node.bytes.length
  383. const bytes = new Uint8Array(Math.max(previousLength, offset + data.length))
  384. bytes.set(node.bytes)
  385. bytes.set(data, offset)
  386. this.replaceFile(node, bytes, offset === previousLength ? previousLength : undefined)
  387. return data.length
  388. }
  389. /** Resize one file identity and notify all linked paths. */
  390. private truncateFile(node: FileNode, length: number): void {
  391. this.replaceFile(node, resize(node.bytes, length))
  392. }
  393. /** @returns Plain stats for an open file, including after its last name is removed. */
  394. private fileStats(node: FileNode): VfsStats {
  395. return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode)
  396. }
  397. /** Forget removed directory identities, so recreated paths report new ones. */
  398. private forgetIdentity(target: string): void {
  399. this.identities.delete(target)
  400. const prefix = `${target}${SEP}`
  401. for (const known of [...this.identities.keys()]) {
  402. if (known.startsWith(prefix)) this.identities.delete(known)
  403. }
  404. }
  405. /**
  406. * Modification time for a write, strictly after the entry's previous one.
  407. *
  408. * The clock has millisecond resolution and these writes are in memory, so two
  409. * revisions of one file routinely land in the same millisecond. The filesystem
  410. * service's stale-write guard compares timestamps, so an equal one would let a
  411. * stale overwrite through.
  412. * @param target - Normalized path being written.
  413. * @returns Now, or one millisecond past the entry's current time.
  414. */
  415. private touch(target: string): number {
  416. return this.touchNode(this.files.get(target))
  417. }
  418. /** @returns A modification time strictly newer than one file node's current value. */
  419. private touchNode(node?: FileNode): number {
  420. const previous = node?.mtimeMs
  421. const now = Date.now()
  422. return previous === undefined ? now : Math.max(now, previous + 1)
  423. }
  424. /** Advance a directory's mtime after its immediate children change. */
  425. private touchDirectory(target: string): void {
  426. const previous = this.directoryMtimes.get(target)
  427. const now = Date.now()
  428. this.directoryMtimes.set(target, previous === undefined ? now : Math.max(now, previous + 1))
  429. }
  430. /**
  431. * List a directory.
  432. * @param path - Directory path.
  433. * @param options - `withFileTypes` returns {@link VfsDirent} objects instead of names.
  434. * @returns Immediate entry names, or directory entries.
  435. */
  436. readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] {
  437. const target = this.key(path)
  438. if (!this.directories.has(target)) {
  439. if (this.files.has(target)) fail('ENOTDIR', 'scandir', target)
  440. fail('ENOENT', 'scandir', target)
  441. }
  442. const prefix = target === SEP ? SEP : `${target}${SEP}`
  443. const names = new Set<string>()
  444. for (const candidate of [...this.files.keys(), ...this.directories]) {
  445. if (!candidate.startsWith(prefix) || candidate === target) continue
  446. const rest = candidate.slice(prefix.length)
  447. if (rest === '') continue
  448. const [head = rest] = rest.split(SEP)
  449. names.add(head)
  450. }
  451. const sorted = [...names].sort()
  452. if (options?.withFileTypes !== true) return sorted as string[] & VfsDirent[]
  453. return sorted.map(name => this.direntOf(target, name)) as string[] & VfsDirent[]
  454. }
  455. /** @returns Directory entry for one child of `directory`. */
  456. private direntOf(directory: string, name: string): VfsDirent {
  457. const stats = this.plainStats(join(directory, name))
  458. return {
  459. name,
  460. parentPath: directory,
  461. isFile: () => stats.isFile(),
  462. isDirectory: () => stats.isDirectory(),
  463. isSymbolicLink: () => false,
  464. }
  465. }
  466. /**
  467. * Resolve a path; the VFS has no symlinks, so this only normalizes.
  468. * @param path - Path to resolve.
  469. * @returns Absolute path.
  470. */
  471. realpathSync(path: string): string {
  472. const target = this.key(path)
  473. if (!this.existsSync(target)) fail('ENOENT', 'realpath', target)
  474. return target
  475. }
  476. /**
  477. * Create a directory.
  478. * @param path - Directory path.
  479. * @param options - `recursive` creates missing parents.
  480. * @returns First created path when recursive, otherwise undefined.
  481. */
  482. mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined {
  483. const target = this.key(path)
  484. if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
  485. if (this.directories.has(target)) {
  486. if (options?.recursive === true) return undefined
  487. fail('EEXIST', 'mkdir', target)
  488. }
  489. const parent = dirname(target)
  490. if (!this.directories.has(parent)) {
  491. if (options?.recursive !== true) fail('ENOENT', 'mkdir', target)
  492. this.mkdirSync(parent, options)
  493. }
  494. this.directories.add(target)
  495. this.touchDirectory(target)
  496. this.touchDirectory(parent)
  497. const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 0o777
  498. if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode)
  499. this.publish({ kind: 'mkdir', path: target, mode })
  500. return target
  501. }
  502. /**
  503. * Write a file, replacing existing contents.
  504. * @param path - File path; its parent directory must exist.
  505. * @param data - Text or bytes.
  506. * @param options - `flag` `wx` refuses an existing file, `a` appends.
  507. */
  508. writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void {
  509. const target = this.key(path)
  510. if (this.directories.has(target)) fail('EISDIR', 'open', target)
  511. if (!this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
  512. const flag = options?.flag ?? 'w'
  513. if (flag.startsWith('wx') && this.files.has(target)) fail('EEXIST', 'open', target)
  514. if (flag.startsWith('a')) { this.appendFileSync(target, data); return }
  515. // POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps
  516. // the entry's bits.
  517. const previous = this.files.get(target)
  518. const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE)
  519. const bytes = typeof data === 'string' ? encoder.encode(data) : data
  520. if (previous !== undefined) {
  521. this.replaceFile(previous, bytes)
  522. return
  523. }
  524. const node: FileNode = { bytes, mtimeMs: this.touch(target), mode, paths: undefined }
  525. this.setFile(target, node)
  526. this.touchDirectory(dirname(target))
  527. this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true })
  528. }
  529. /**
  530. * Open a directory; consumers enumerate entries or just prove it is one.
  531. * @param path - Directory path.
  532. * @returns Directory handle.
  533. */
  534. opendir(path: string): VfsDir {
  535. const target = this.key(path)
  536. const names = this.readdirSync(target)
  537. let cursor = 0
  538. const direntOf = (name: string): VfsDirent => this.direntOf(target, name)
  539. return {
  540. path: target,
  541. close: async (): Promise<void> => {},
  542. read: async (): Promise<{ name: string } | null> => {
  543. const name = names[cursor]
  544. cursor += 1
  545. return name === undefined ? null : direntOf(name)
  546. },
  547. async *[Symbol.asyncIterator]() {
  548. for (const name of names) yield direntOf(name)
  549. },
  550. }
  551. }
  552. /**
  553. * Open a file handle.
  554. * @param path - File path.
  555. * @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
  556. * @param mode - Permission bits applied when the open creates the file.
  557. * @returns File handle.
  558. */
  559. open(path: string, flags = 'r', mode?: number): VfsFileHandle {
  560. const target = this.key(path)
  561. // Durable writers fsync the parent directory by opening it read-only.
  562. if (this.directories.has(target)) {
  563. if (!flags.startsWith('r')) fail('EISDIR', 'open', target)
  564. return {
  565. write: async (): Promise<{ bytesWritten: number }> => fail('EISDIR', 'write', target),
  566. writeFile: async (): Promise<void> => fail('EISDIR', 'write', target),
  567. readFile: async (): Promise<string | Uint8Array> => fail('EISDIR', 'read', target),
  568. truncate: async (): Promise<void> => fail('EISDIR', 'ftruncate', target),
  569. ...this.handleTail(target),
  570. }
  571. }
  572. const file = this.openFileSync(target, flags, mode)
  573. let position = 0
  574. let closed = false
  575. const current = (syscall: string): VfsOpenFile => {
  576. if (closed) fail('EBADF', syscall, target)
  577. return file
  578. }
  579. return {
  580. write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => {
  581. const bytes = typeof data === 'string' ? encoder.encode(data) : data
  582. const descriptor = current('write')
  583. const offset = descriptor.append ? descriptor.stat().size : position
  584. const bytesWritten = descriptor.write(offset, bytes)
  585. position = offset + bytesWritten
  586. return { bytesWritten }
  587. },
  588. writeFile: async (data: string | Uint8Array): Promise<void> => {
  589. const bytes = typeof data === 'string' ? encoder.encode(data) : data
  590. const descriptor = current('write')
  591. const offset = descriptor.append ? descriptor.stat().size : position
  592. position = offset + descriptor.write(offset, bytes)
  593. },
  594. readFile: async (options?: VfsReadOptions): Promise<string | Uint8Array> => {
  595. const descriptor = current('read')
  596. const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position))
  597. position += bytes.length
  598. return encodingOf(options) === undefined ? bytes : decoder.decode(bytes)
  599. },
  600. truncate: async (length = 0): Promise<void> => {
  601. current('ftruncate').truncate(length)
  602. },
  603. stat: async (): Promise<VfsStats> => current('fstat').stat(),
  604. sync: async (): Promise<void> => { current('fsync'); await this.flush() },
  605. datasync: async (): Promise<void> => { current('fdatasync'); await this.flush() },
  606. close: async (): Promise<void> => { closed = true },
  607. }
  608. }
  609. /**
  610. * Open one synchronous descriptor over a stable file identity.
  611. * @param path - File path.
  612. * @param flags - Node open flags.
  613. * @param mode - Permission bits applied only when a file is created.
  614. * @returns An open file that survives path rename, replacement, and unlink.
  615. */
  616. openFileSync(path: string, flags = 'r', mode?: number): VfsOpenFile {
  617. const target = this.key(path)
  618. const access = openMode(flags)
  619. const existing = this.files.get(target)
  620. if (this.directories.has(target)) fail('EISDIR', 'open', target)
  621. if (access.exclusive && existing !== undefined) fail('EEXIST', 'open', target)
  622. if (!access.create && existing === undefined) fail('ENOENT', 'open', target)
  623. if (access.create && existing === undefined) {
  624. this.writeFileSync(target, new Uint8Array(), mode === undefined ? undefined : { mode })
  625. } else if (access.truncate && existing !== undefined) {
  626. this.truncateFile(existing, 0)
  627. }
  628. const node = this.files.get(target)
  629. if (node === undefined) fail('ENOENT', 'open', target)
  630. return {
  631. readable: access.readable,
  632. writable: access.writable,
  633. append: access.append,
  634. read: (position, length) => {
  635. if (!access.readable) fail('EBADF', 'read', target)
  636. return node.bytes.subarray(position, position + length)
  637. },
  638. write: (position, data) => {
  639. if (!access.writable) fail('EBADF', 'write', target)
  640. return this.writeFileNode(node, access.append ? node.bytes.length : position, data)
  641. },
  642. truncate: (length) => {
  643. if (!access.writable) fail('EINVAL', 'ftruncate', target)
  644. this.truncateFile(node, length)
  645. },
  646. stat: () => this.fileStats(node),
  647. }
  648. }
  649. /**
  650. * Directory-handle members for metadata, durability, and release.
  651. * `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem
  652. * resolves immediately and `close` releases nothing.
  653. * @param target - Normalized path the handle was opened on.
  654. * @returns Metadata plus the no-op durability and release calls.
  655. */
  656. private handleTail(target: string): Pick<VfsFileHandle, 'stat' | 'sync' | 'datasync' | 'close'> {
  657. return {
  658. stat: async (): Promise<VfsStats> => this.plainStats(target),
  659. sync: async (): Promise<void> => { await this.flush() },
  660. datasync: async (): Promise<void> => { await this.flush() },
  661. close: async (): Promise<void> => {},
  662. }
  663. }
  664. /**
  665. * Append to a file, creating it when absent.
  666. * @param path - File path.
  667. * @param data - Text or bytes.
  668. */
  669. appendFileSync(path: string, data: string | Uint8Array): void {
  670. const target = this.key(path)
  671. const existing = this.files.get(target)
  672. const addition = typeof data === 'string' ? encoder.encode(data) : data
  673. if (existing === undefined) { this.writeFileSync(target, addition); return }
  674. this.writeFileNode(existing, existing.bytes.length, addition)
  675. }
  676. /**
  677. * Move a file or directory subtree.
  678. * @param from - Source path.
  679. * @param to - Destination path.
  680. */
  681. renameSync(from: string, to: string): void {
  682. const source = this.key(from)
  683. const destination = this.key(to)
  684. if (source === destination) return
  685. const node = this.files.get(source)
  686. if (node !== undefined) {
  687. if (this.directories.has(destination)) fail('EISDIR', 'rename', destination)
  688. if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
  689. if (this.files.get(destination) === node) return
  690. this.deleteFile(source)
  691. this.setFile(destination, node)
  692. this.forgetIdentity(source)
  693. this.forgetIdentity(destination)
  694. this.touchDirectory(dirname(source))
  695. this.touchDirectory(dirname(destination))
  696. this.publish({ kind: 'remove', path: source })
  697. this.publish({ kind: 'write', path: destination, bytes: node.bytes, mode: node.mode, entryChanged: true })
  698. return
  699. }
  700. if (!this.directories.has(source)) fail('ENOENT', 'rename', source)
  701. if (this.files.has(destination)) fail('ENOTDIR', 'rename', destination)
  702. if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
  703. if (this.directories.has(destination)) {
  704. if (this.readdirSync(destination).length > 0) fail('ENOTEMPTY', 'rename', destination)
  705. this.directories.delete(destination)
  706. this.directoryModes.delete(destination)
  707. this.directoryMtimes.delete(destination)
  708. }
  709. const prefix = `${source}${SEP}`
  710. const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = []
  711. for (const [candidate, value] of [...this.files]) {
  712. if (!candidate.startsWith(prefix)) continue
  713. this.deleteFile(candidate)
  714. const target = join(destination, candidate.slice(prefix.length))
  715. this.setFile(target, value)
  716. movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode })
  717. }
  718. const movedDirectories: Array<{ path: string; mode: number }> = []
  719. for (const candidate of [...this.directories]) {
  720. if (!candidate.startsWith(prefix) && candidate !== source) continue
  721. const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length))
  722. this.directories.delete(candidate)
  723. this.directories.add(moved)
  724. const bits = this.directoryModes.get(candidate)
  725. this.directoryModes.delete(candidate)
  726. if (bits !== undefined) this.directoryModes.set(moved, bits)
  727. movedDirectories.push({ path: moved, mode: bits ?? DEFAULT_DIRECTORY_MODE })
  728. const mtime = this.directoryMtimes.get(candidate)
  729. this.directoryMtimes.delete(candidate)
  730. if (mtime !== undefined) this.directoryMtimes.set(moved, mtime)
  731. }
  732. this.forgetIdentity(source)
  733. this.forgetIdentity(destination)
  734. this.touchDirectory(dirname(source))
  735. this.touchDirectory(dirname(destination))
  736. this.publish({ kind: 'remove', path: source })
  737. for (const directory of movedDirectories) {
  738. this.publish({ kind: 'mkdir', path: directory.path, mode: directory.mode })
  739. }
  740. for (const entry of movedFiles) {
  741. this.publish({
  742. kind: 'write', path: entry.path, bytes: entry.bytes, mode: entry.mode, entryChanged: true,
  743. })
  744. }
  745. }
  746. /**
  747. * Give existing bytes a second name.
  748. *
  749. * Both names retain one file identity, so writes and metadata changes through
  750. * either name remain visible through the other until that name is removed.
  751. * @param existing - Source file path.
  752. * @param next - Additional path; its parent must exist and it must be free.
  753. */
  754. linkSync(existing: string, next: string): void {
  755. const source = this.key(existing)
  756. const target = this.key(next)
  757. const node = this.files.get(source)
  758. if (node === undefined) fail('ENOENT', 'link', source)
  759. if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target)
  760. if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target)
  761. this.setFile(target, node)
  762. this.touchDirectory(dirname(target))
  763. this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true })
  764. }
  765. /**
  766. * Shorten a file.
  767. * @param path - File path.
  768. * @param length - Byte length to keep; defaults to zero.
  769. */
  770. truncateSync(path: string, length = 0): void {
  771. const target = this.key(path)
  772. const node = this.files.get(target)
  773. if (node === undefined) fail('ENOENT', 'truncate', target)
  774. this.truncateFile(node, length)
  775. }
  776. /**
  777. * Change an entry's permission bits; stat reads back exactly what was set.
  778. * @param path - File or directory path.
  779. * @param mode - New permission bits (`0o777` mask).
  780. */
  781. chmodSync(path: string, mode: number): void {
  782. const target = this.key(path)
  783. const node = this.files.get(target)
  784. if (node !== undefined) {
  785. node.mode = mode & 0o777
  786. if (typeof node.paths === 'string') {
  787. this.publish({ kind: 'chmod', path: node.paths, mode: node.mode })
  788. } else if (node.paths !== undefined) {
  789. for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode })
  790. }
  791. return
  792. }
  793. if (this.directories.has(target)) {
  794. const bits = mode & 0o777
  795. this.directoryModes.set(target, bits)
  796. this.publish({ kind: 'chmod', path: target, mode: bits })
  797. return
  798. }
  799. fail('ENOENT', 'chmod', target)
  800. }
  801. /**
  802. * Remove a file.
  803. * @param path - File path.
  804. */
  805. unlinkSync(path: string): void {
  806. const target = this.key(path)
  807. if (this.deleteFile(target) === undefined) fail('ENOENT', 'unlink', target)
  808. this.forgetIdentity(target)
  809. this.touchDirectory(dirname(target))
  810. this.publish({ kind: 'remove', path: target })
  811. }
  812. /**
  813. * Remove a file or directory.
  814. * @param path - Path to remove.
  815. * @param options - `recursive` removes subtrees, `force` ignores absence.
  816. */
  817. rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void {
  818. const target = this.key(path)
  819. if (this.deleteFile(target) !== undefined) {
  820. this.forgetIdentity(target)
  821. this.touchDirectory(dirname(target))
  822. this.publish({ kind: 'remove', path: target })
  823. return
  824. }
  825. if (this.directories.has(target)) {
  826. if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target)
  827. const prefix = `${target}${SEP}`
  828. for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate)
  829. for (const candidate of [...this.directories]) {
  830. if (!candidate.startsWith(prefix)) continue
  831. this.directories.delete(candidate)
  832. this.directoryModes.delete(candidate)
  833. this.directoryMtimes.delete(candidate)
  834. }
  835. this.directories.delete(target)
  836. this.directoryModes.delete(target)
  837. this.directoryMtimes.delete(target)
  838. this.forgetIdentity(target)
  839. this.touchDirectory(dirname(target))
  840. this.publish({ kind: 'remove', path: target })
  841. return
  842. }
  843. if (options?.force !== true) fail('ENOENT', 'rm', target)
  844. }
  845. /**
  846. * Create a uniquely named directory beside `prefix`, as `fs.mkdtempSync` does.
  847. * @param prefix - Path prefix; the suffix is appended without a separator.
  848. * @returns The created directory path.
  849. */
  850. mkdtempSync(prefix: string): string {
  851. this.temporaries += 1
  852. const target = `${prefix}${Date.now().toString(36)}${this.temporaries.toString(36)}`
  853. this.mkdirSync(target, { recursive: true })
  854. return this.key(target)
  855. }
  856. /**
  857. * Seed a file and its parent directories, for image loading and tests.
  858. * @param path - File path.
  859. * @param data - Text or bytes.
  860. * @param options - Permission bits and modification time supplied by the image or durable store.
  861. */
  862. seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void {
  863. const target = this.key(path)
  864. this.seedDirectory(dirname(target))
  865. this.setFile(target, {
  866. bytes: typeof data === 'string' ? encoder.encode(data) : data,
  867. mtimeMs: options.mtimeMs ?? this.touch(target),
  868. mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777,
  869. paths: undefined,
  870. })
  871. this.touchDirectory(dirname(target))
  872. }
  873. /**
  874. * Create a directory and its parents.
  875. * @param path - Directory path.
  876. * @param options - Permission bits and modification time supplied by the image or durable store.
  877. */
  878. seedDirectory(path: string, options: VfsSeedOptions = {}): void {
  879. const target = this.key(path)
  880. if (!this.directories.has(target)) {
  881. const parent = dirname(target)
  882. if (parent !== target) this.seedDirectory(parent)
  883. if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
  884. this.directories.add(target)
  885. this.directoryMtimes.set(target, options.mtimeMs ?? Date.now())
  886. this.touchDirectory(parent)
  887. }
  888. if (options.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777)
  889. if (options.mtimeMs !== undefined) this.directoryMtimes.set(target, options.mtimeMs)
  890. }
  891. /**
  892. * Report what this filesystem holds, for the host's boot diagnostics.
  893. * @returns File count, directory count, and total byte size.
  894. */
  895. usage(): { files: number; directories: number; bytes: number } {
  896. let bytes = 0
  897. for (const node of this.files.values()) bytes += node.bytes.length
  898. return { files: this.files.size, directories: this.directories.size, bytes }
  899. }
  900. }
  901. /**
  902. * Mount a tar image produced by the build-time collector.
  903. *
  904. * Entry names are relative to `root` (`node_modules/...`, `config/cordis.yml`);
  905. * an absolute entry name is a collector defect and fails loud. File contents
  906. * stay views into `image` — nothing is copied at mount time.
  907. * @param image - The ustar archive, as `inflateImage` produces it from the fetched image.
  908. * @param root - Virtual root the entries mount under.
  909. * @param vfs - Filesystem to fill; a fresh one by default.
  910. * @returns The filled filesystem.
  911. */
  912. export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryVfs()): MemoryVfs {
  913. vfs.seedDirectory(root)
  914. for (const entry of parseTar(image)) {
  915. const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
  916. if (relativeName.startsWith(SEP)) {
  917. throw new Error(`webworker vfs: image entry must be relative to ${root}, received "${entry.name}"`)
  918. }
  919. const target = join(root, relativeName)
  920. if (entry.directory) {
  921. vfs.seedDirectory(target, { mode: entry.mode })
  922. continue
  923. }
  924. vfs.seed(target, entry.bytes, { mode: entry.mode })
  925. }
  926. return vfs
  927. }
  928. /**
  929. * Apply one ordered data overlay to an already mounted base image.
  930. *
  931. * Overlay entries may replace files only under the layout's data directories;
  932. * module code, configuration, and the lowering manifest cannot be shadowed.
  933. * Paths containing traversal segments are refused before normalization. Later
  934. * overlays win for files, while file/directory type conflicts fail loud.
  935. * @param image - Uncompressed ustar overlay archive.
  936. * @param root - Virtual root shared with the base image.
  937. * @param vfs - Mounted filesystem to update.
  938. * @returns The same filesystem after applying the overlay.
  939. */
  940. export function loadVfsOverlay(image: Uint8Array, root: string, vfs: MemoryVfs): MemoryVfs {
  941. for (const entry of parseTar(image)) {
  942. const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
  943. const path = relativeName.endsWith('/') ? relativeName.slice(0, -1) : relativeName
  944. const segments = path.split('/')
  945. if (path === '' || relativeName.startsWith(SEP)
  946. || segments.some(segment => segment === '' || segment === '.' || segment === '..')
  947. || !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? '')) {
  948. throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join('/ or ')}, received "${entry.name}"`)
  949. }
  950. const target = join(root, path)
  951. if (entry.directory) {
  952. vfs.seedDirectory(target, { mode: entry.mode })
  953. continue
  954. }
  955. if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) {
  956. throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`)
  957. }
  958. vfs.seed(target, entry.bytes, { mode: entry.mode })
  959. }
  960. return vfs
  961. }