Browse Source

fix(webworker): store and honour VFS permission bits

imccyu 1 month ago
parent
commit
c2a30af92d

+ 20 - 10
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts

@@ -6,7 +6,7 @@
  */
 import { requireActiveVfs } from '../../../storage/active.ts'
 import type { MemoryVfs } from '../../../storage/memory.ts'
-import type { VfsBigIntStats, VfsStatOptions, VfsStats } from '../../../storage/types.ts'
+import type { VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts'
 import { Buffer } from 'buffer'
 import { dirname } from './path.ts'
 
@@ -116,9 +116,10 @@ export function readFileSync(path: PathArg, options?: EncodingOption): Buffer |
  * Write a file.
  * @param path - file path.
  * @param data - bytes or text.
+ * @param options - write flag and creation mode, forwarded to the VFS.
  */
-export function writeFileSync(path: PathArg, data: string | Uint8Array): void {
-  vfs().writeFileSync(asPath(path), data)
+export function writeFileSync(path: PathArg, data: string | Uint8Array, options?: VfsWriteOptions): void {
+  vfs().writeFileSync(asPath(path), data, options)
 }
 
 /**
@@ -149,6 +150,15 @@ export function statSync(path: PathArg, options?: VfsStatOptions): VfsStats | Vf
   return vfs().statSync(asPath(path), options)
 }
 
+/**
+ * Change an entry's permission bits; stat reads back exactly what was set.
+ * @param path - the path.
+ * @param mode - new permission bits (`0o777` mask), numeric or Node's octal string form.
+ */
+export function chmodSync(path: PathArg, mode: number | string): void {
+  vfs().chmodSync(asPath(path), typeof mode === 'string' ? Number.parseInt(mode, 8) : mode)
+}
+
 /**
  * Stat a path without following symlinks (the image has none).
  * @param path - the path.
@@ -190,7 +200,7 @@ export function readdirSync(
  * @param options - `recursive` creates parents.
  * @returns the first created path when recursive, else undefined.
  */
-export function mkdirSync(path: PathArg, options?: { recursive?: boolean }): string | undefined {
+export function mkdirSync(path: PathArg, options?: { recursive?: boolean; mode?: number }): string | undefined {
   return vfs().mkdirSync(asPath(path), options)
 }
 
@@ -489,19 +499,20 @@ export const promises = {
   writeFile: async (
     path: PathArg,
     data: string | Uint8Array,
-    options?: { flag?: string } | BufferEncoding | null,
+    options?: { flag?: string; mode?: number } | BufferEncoding | null,
   ): Promise<void> => {
     const flag = typeof options === 'object' && options !== null ? options.flag : undefined
+    const mode = typeof options === 'object' && options !== null ? options.mode : undefined
     if (flag !== undefined && flag.includes('x') && existsSync(path)) {
       const error = new Error(`EEXIST: file already exists, open '${asPath(path)}'`) as Error & { code: string }
       error.code = 'EEXIST'
       throw error
     }
     if (flag !== undefined && flag.startsWith('a')) appendFileSync(path, data)
-    else writeFileSync(path, data)
+    else writeFileSync(path, data, { ...flag === undefined ? {} : { flag }, ...mode === undefined ? {} : { mode } })
   },
   appendFile: async (path: PathArg, data: string | Uint8Array): Promise<void> => { appendFileSync(path, data) },
-  mkdir: async (path: PathArg, options?: { recursive?: boolean }): Promise<string | undefined> => mkdirSync(path, options),
+  mkdir: async (path: PathArg, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> => mkdirSync(path, options),
   mkdtemp: async (prefix: string): Promise<string> => mkdtempSync(prefix),
   readdir: async (
     path: PathArg,
@@ -514,8 +525,7 @@ export const promises = {
   unlink: async (path: PathArg): Promise<void> => { unlinkSync(path) },
   rename: async (from: PathArg, to: PathArg): Promise<void> => { renameSync(from, to) },
   access: async (path: PathArg): Promise<void> => { accessSync(path) },
-  // Permission bits have no meaning in the VFS; callers only ever relax them.
-  chmod: async (): Promise<void> => { /* no permission model */ },
+  chmod: async (path: PathArg, mode: number | string): Promise<void> => { chmodSync(path, mode) },
   cp: async (from: PathArg, to: PathArg): Promise<void> => {
     const source = asPath(from)
     const target = asPath(to)
@@ -567,7 +577,7 @@ type NodeFace = Partial<Omit<typeof import('node:fs'), OwnSignature>>
 /** CommonJS default export: the members `require()` hands a caller of this module. */
 export default {
   constants, promises, Dirent,
-  readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync,
+  readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync, chmodSync,
   readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync,
   openHandleSync, linkSync,
   openSync, readSync, writeSync, closeSync, watchFile, unwatchFile,

+ 81 - 32
packages/experimental/webworker-runtime/src/storage/memory.ts

@@ -17,8 +17,16 @@ const encoder = new TextEncoder()
 interface FileNode {
   bytes: Uint8Array
   mtimeMs: number
+  /** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */
+  mode: number
 }
 
+/** Creation default for files, Node's `0o666` under the classic `022` umask. */
+const DEFAULT_FILE_MODE = 0o644
+
+/** Creation default for directories, Node's `0o777` under the classic `022` umask. */
+const DEFAULT_DIRECTORY_MODE = 0o755
+
 function fail(code: string, syscall: string, path: string, detail?: string): never {
   const error = new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`) as VfsError
   error.code = code
@@ -33,15 +41,17 @@ function encodingOf(options: VfsReadOptions): VfsEncoding | undefined {
   return options.encoding ?? undefined
 }
 
-// Owner-only permission bits (600/700), here and in the BigInt shape below:
-// the VFS has one owner, and dsh-credentials-local refuses to start over a
-// secrets file readable beyond its owner.
-function statsOf(size: number, mtimeMs: number, directory: boolean): VfsStats {
+// Permission bits are entry state: creation takes the caller's mode (or the
+// umask-free default), `chmod` changes it, and both stat shapes report the
+// stored value — the round-trip consumers like dsh-credentials-local's
+// owner-only check rely on. The bits are never enforced: a single-owner
+// filesystem reads and writes as its owner regardless, like root.
+function statsOf(size: number, mtimeMs: number, directory: boolean, mode: number): VfsStats {
   return {
     size,
     mtimeMs,
     mtime: new Date(mtimeMs),
-    mode: directory ? 0o040700 : 0o100600,
+    mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777),
     isFile: () => !directory,
     isDirectory: () => directory,
     isSymbolicLink: () => false,
@@ -62,15 +72,16 @@ function statsOf(size: number, mtimeMs: number, directory: boolean): VfsStats {
  * @param mtimeMs - Modification time the entry carries.
  * @param directory - Whether the entry is a directory.
  * @param ino - Identity of the entry at this path.
+ * @param mode - Stored permission bits of the entry.
  * @returns Stats in the shape Node returns under `{ bigint: true }`.
  */
-function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint): VfsBigIntStats {
+function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsBigIntStats {
   const milliseconds = BigInt(Math.trunc(mtimeMs))
   const nanoseconds = milliseconds * 1_000_000n
   const time = new Date(mtimeMs)
   return {
     size: BigInt(size),
-    mode: directory ? 0o040700n : 0o100600n,
+    mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)),
     dev: 1n,
     ino,
     nlink: 1n,
@@ -104,6 +115,8 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b
 export class MemoryVfs {
   private readonly files = new Map<string, FileNode>()
   private readonly directories = new Set<string>([SEP])
+  /** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */
+  private readonly directoryModes = new Map<string, number>()
   private temporaries = 0
   // Identity per path, assigned on first stat and dropped when the path goes:
   // the filesystem service builds its version token from `ino` plus the
@@ -118,7 +131,7 @@ export class MemoryVfs {
       this.writeFileSync(path, data, options)
     },
     appendFile: async (path: string, data: string | Uint8Array): Promise<void> => { this.appendFileSync(path, data) },
-    mkdir: async (path: string, options?: { recursive?: boolean }): Promise<string | undefined> => this.mkdirSync(path, options),
+    mkdir: async (path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> => this.mkdirSync(path, options),
     readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise<string[] & VfsDirent[]> =>
       this.readdirSync(path, options),
     stat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
@@ -130,7 +143,7 @@ export class MemoryVfs {
     mkdtemp: async (prefix: string): Promise<string> => this.mkdtempSync(prefix),
     link: async (existing: string, next: string): Promise<void> => { this.linkSync(existing, next) },
     truncate: async (path: string, length?: number): Promise<void> => { this.truncateSync(path, length) },
-    chmod: async (): Promise<void> => {},
+    chmod: async (path: string, mode: number): Promise<void> => { this.chmodSync(path, mode) },
     opendir: async (path: string): Promise<VfsDir> => this.opendir(path),
     open: async (path: string, flags?: string, mode?: number): Promise<VfsFileHandle> => this.open(path, flags, mode),
     /** Resolves for any existing path: the VFS grants read and write to everything it holds. */
@@ -181,14 +194,14 @@ export class MemoryVfs {
   statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
     const target = this.key(path)
     const node = this.files.get(target)
-    const [size, mtimeMs, directory] = node !== undefined
-      ? [node.bytes.length, node.mtimeMs, false] as const
+    const [size, mtimeMs, directory, mode] = node !== undefined
+      ? [node.bytes.length, node.mtimeMs, false, node.mode] as const
       : this.directories.has(target)
-        ? [0, 0, true] as const
+        ? [0, 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const
         : fail('ENOENT', 'stat', target)
     return options?.bigint === true
-      ? bigIntStatsOf(size, mtimeMs, directory, this.identityOf(target))
-      : statsOf(size, mtimeMs, directory)
+      ? bigIntStatsOf(size, mtimeMs, directory, this.identityOf(target), mode)
+      : statsOf(size, mtimeMs, directory, mode)
   }
 
   /** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
@@ -285,7 +298,7 @@ export class MemoryVfs {
    * @param options - `recursive` creates missing parents.
    * @returns First created path when recursive, otherwise undefined.
    */
-  mkdirSync(path: string, options?: { recursive?: boolean }): string | undefined {
+  mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined {
     const target = this.key(path)
     if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
     if (this.directories.has(target)) {
@@ -298,6 +311,7 @@ export class MemoryVfs {
       this.mkdirSync(parent, options)
     }
     this.directories.add(target)
+    if (options?.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777)
     return target
   }
 
@@ -314,7 +328,10 @@ export class MemoryVfs {
     const flag = options?.flag ?? 'w'
     if (flag.startsWith('wx') && this.files.has(target)) fail('EEXIST', 'open', target)
     if (flag.startsWith('a')) {  this.appendFileSync(target, data); return }
-    this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target) })
+    // POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps
+    // the entry's bits.
+    const mode = this.files.get(target)?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE)
+    this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode })
   }
 
   /**
@@ -345,11 +362,10 @@ export class MemoryVfs {
    * Open a file handle.
    * @param path - File path.
    * @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
-   * @param mode - Accepted and ignored: the VFS has no permission bits.
+   * @param mode - Permission bits applied when the open creates the file.
    * @returns File handle.
    */
   open(path: string, flags = 'r', mode?: number): VfsFileHandle {
-    void mode
     const target = this.key(path)
     // Durable writers fsync the parent directory by opening it read-only.
     if (this.directories.has(target)) {
@@ -366,9 +382,10 @@ export class MemoryVfs {
     if (flags.startsWith('r') && !exists) fail('ENOENT', 'open', target)
     if (flags.startsWith('wx') && exists) fail('EEXIST', 'open', target)
     if (!flags.startsWith('r') && !this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
-    if (flags.startsWith('w') && !flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array())
-    if (flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), { flag: 'wx' })
-    if (flags.startsWith('a') && !exists) this.writeFileSync(target, new Uint8Array())
+    const creation = mode === undefined ? {} : { mode }
+    if (flags.startsWith('w') && !flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), creation)
+    if (flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), { flag: 'wx', ...creation })
+    if (flags.startsWith('a') && !exists) this.writeFileSync(target, new Uint8Array(), creation)
     const appending = flags.startsWith('a')
     return {
       write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => {
@@ -387,7 +404,7 @@ export class MemoryVfs {
       truncate: async (length = 0): Promise<void> => {
         const node = this.files.get(target)
         if (node === undefined) fail('ENOENT', 'ftruncate', target)
-        this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target) })
+        this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode })
       },
       ...this.handleTail(target),
     }
@@ -424,7 +441,7 @@ export class MemoryVfs {
     const merged = new Uint8Array(existing.bytes.length + addition.length)
     merged.set(existing.bytes)
     merged.set(addition, existing.bytes.length)
-    this.files.set(target, { bytes: merged, mtimeMs: this.touch(target) })
+    this.files.set(target, { bytes: merged, mtimeMs: this.touch(target), mode: existing?.mode ?? DEFAULT_FILE_MODE })
   }
 
   /**
@@ -453,8 +470,12 @@ export class MemoryVfs {
     }
     for (const candidate of [...this.directories]) {
       if (!candidate.startsWith(prefix) && candidate !== source) continue
+      const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length))
       this.directories.delete(candidate)
-      this.directories.add(candidate === source ? destination : join(destination, candidate.slice(prefix.length)))
+      this.directories.add(moved)
+      const bits = this.directoryModes.get(candidate)
+      this.directoryModes.delete(candidate)
+      if (bits !== undefined) this.directoryModes.set(moved, bits)
     }
     this.forgetIdentity(source)
     this.forgetIdentity(destination)
@@ -488,7 +509,26 @@ export class MemoryVfs {
     const target = this.key(path)
     const node = this.files.get(target)
     if (node === undefined) fail('ENOENT', 'truncate', target)
-    this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target) })
+    this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode })
+  }
+
+  /**
+   * Change an entry's permission bits; stat reads back exactly what was set.
+   * @param path - File or directory path.
+   * @param mode - New permission bits (`0o777` mask).
+   */
+  chmodSync(path: string, mode: number): void {
+    const target = this.key(path)
+    const node = this.files.get(target)
+    if (node !== undefined) {
+      node.mode = mode & 0o777
+      return
+    }
+    if (this.directories.has(target)) {
+      this.directoryModes.set(target, mode & 0o777)
+      return
+    }
+    fail('ENOENT', 'chmod', target)
   }
 
   /**
@@ -516,8 +556,13 @@ export class MemoryVfs {
       if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target)
       const prefix = `${target}${SEP}`
       for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.files.delete(candidate)
-      for (const candidate of [...this.directories]) if (candidate.startsWith(prefix)) this.directories.delete(candidate)
+      for (const candidate of [...this.directories]) {
+        if (!candidate.startsWith(prefix)) continue
+        this.directories.delete(candidate)
+        this.directoryModes.delete(candidate)
+      }
       this.directories.delete(target)
+      this.directoryModes.delete(target)
       this.forgetIdentity(target)
       return
     }
@@ -540,19 +585,23 @@ export class MemoryVfs {
    * Seed a file and its parent directories, for image loading and tests.
    * @param path - File path.
    * @param data - Text or bytes.
+   * @param mode - Permission bits recorded for the entry.
    */
-  seed(path: string, data: string | Uint8Array): void {
+  seed(path: string, data: string | Uint8Array, mode = DEFAULT_FILE_MODE): void {
     const target = this.key(path)
     this.mkdirSync(dirname(target), { recursive: true })
-    this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target) })
+    this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode: mode & 0o777 })
   }
 
   /**
    * Create a directory and its parents.
    * @param path - Directory path.
+   * @param mode - Permission bits recorded for the directory itself.
    */
-  seedDirectory(path: string): void {
-    this.mkdirSync(this.key(path), { recursive: true })
+  seedDirectory(path: string, mode = DEFAULT_DIRECTORY_MODE): void {
+    const target = this.key(path)
+    this.mkdirSync(target, { recursive: true })
+    if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode & 0o777)
   }
 
   /**
@@ -586,10 +635,10 @@ export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryV
     }
     const target = join(root, relativeName)
     if (entry.directory) {
-      vfs.seedDirectory(target)
+      vfs.seedDirectory(target, entry.mode)
       continue
     }
-    vfs.seed(target, entry.bytes)
+    vfs.seed(target, entry.bytes, entry.mode)
   }
   return vfs
 }

+ 4 - 1
packages/experimental/webworker-runtime/src/storage/tar.ts

@@ -20,6 +20,8 @@ export interface TarEntry {
   readonly name: string
   readonly bytes: Uint8Array
   readonly directory: boolean
+  /** Permission bits from the header's mode field (`0o777` mask). */
+  readonly mode: number
 }
 
 /** Write an octal field: zero-padded digits with a terminating NUL. */
@@ -123,13 +125,14 @@ export function parseTar(archive: Uint8Array): TarEntry[] {
     const prefix = readField(header, 345, 155)
     const name = prefix === '' ? short : `${prefix}/${short}`
     const size = Number.parseInt(readField(header, 124, 12).trim() || '0', 8)
+    const mode = Number.parseInt(readField(header, 100, 8).trim() || '0', 8) & 0o777
     const typeflag = header[156]
     const directory = typeflag === 0x35 || name.endsWith('/')
     if (typeflag !== 0x30 && typeflag !== 0 && typeflag !== 0x35) {
       throw new Error(`vfs tar: unsupported entry type ${String.fromCharCode(typeflag ?? 0)} for "${name}"`)
     }
     const dataStart = offset + BLOCK
-    entries.push({ name, bytes: archive.subarray(dataStart, dataStart + size), directory })
+    entries.push({ name, bytes: archive.subarray(dataStart, dataStart + size), directory, mode })
     offset = dataStart + Math.ceil(size / BLOCK) * BLOCK
   }
   return entries

+ 21 - 2
packages/experimental/webworker-runtime/tests/node/fs.spec.ts

@@ -168,7 +168,7 @@ fs.writeFileSync('/dsh/versioned.txt', 'one')
 {
   const stats = bigStats('/dsh/versioned.txt')
   check('bigint stat reports mode as a BigInt', typeof stats.mode, 'bigint')
-  check('bigint mode masks to an owner-only file permission', Number(stats.mode & 0o777n), 0o600)
+  check('bigint mode masks to the creation-default file permission', Number(stats.mode & 0o777n), 0o644)
   check('bigint stat reports the identity fields the version token needs', [
     typeof stats.dev, typeof stats.ino, typeof stats.size, typeof stats.mtimeNs, typeof stats.ctimeNs,
   ], ['bigint', 'bigint', 'bigint', 'bigint', 'bigint'])
@@ -199,5 +199,24 @@ fs.writeFileSync('/dsh/versioned.txt', 'one')
   check('a removed and recreated path reports a new identity', bigStats('/dsh/versioned.txt').ino !== first, true)
 }
 
-check('a directory reports owner-only directory mode in the bigint shape', Number(bigStats('/dsh/config').mode & 0o777n), 0o700)
+check('a directory reports the creation-default mode in the bigint shape', Number(bigStats('/dsh/config').mode & 0o777n), 0o755)
+
+// ---------------------------------------------------------------------------
+// Permission bits round-trip: creation takes the caller's mode, chmod changes
+// it, stat reads back the stored value. dsh-credentials-local's owner-only
+// check reads exactly this (writeFileAtomic writes `wx` + mode 600, renames
+// into place, then the provider stats the result).
+// ---------------------------------------------------------------------------
+
+fs.writeFileSync('/dsh/secrets.tmp', 'k: v\n', { mode: 0o600, flag: 'wx' })
+fs.renameSync('/dsh/secrets.tmp', '/dsh/secrets.yaml')
+check('a wx write with mode 600 stats as 600 after rename', fs.statSync('/dsh/secrets.yaml').mode & 0o777, 0o600)
+fs.writeFileSync('/dsh/secrets.yaml', 'k: w\n')
+check('a rewrite keeps the creation bits', fs.statSync('/dsh/secrets.yaml').mode & 0o777, 0o600)
+fs.chmodSync('/dsh/secrets.yaml', 0o640)
+check('chmod reads back exactly what was set', fs.statSync('/dsh/secrets.yaml').mode & 0o777, 0o640)
+await fsp.chmod('/dsh/secrets.yaml', 0o600)
+check('promises.chmod reads back through the bigint shape', Number(bigStats('/dsh/secrets.yaml').mode & 0o777n), 0o600)
+fs.mkdirSync('/dsh/vault', { mode: 0o700 })
+check('mkdir honours its mode option', fs.statSync('/dsh/vault').mode & 0o777, 0o700)
 check('promises.stat forwards the bigint option', typeof (await fsp.stat('/dsh/config', { bigint: true })).mode, 'bigint')

+ 4 - 0
packages/experimental/webworker-runtime/tests/storage/tar.spec.ts

@@ -21,6 +21,10 @@ describe('tar codec', () => {
     expect([...byName.keys()].sort()).toEqual(Object.keys(files).sort())
     expect([...byName.get('node_modules/pkg/lib/index.js')!.bytes]).toEqual([...payload])
     expect(byName.get('home/')!.bytes.byteLength).toBe(0)
+    // The header mode field carries the packed permission bits: normal
+    // 644/755, which the VFS mount reports back through stat.
+    expect(byName.get('node_modules/pkg/lib/index.js')!.mode).toBe(0o644)
+    expect(byName.get('home/')!.mode).toBe(0o755)
   })
 
   it('mounts as a VFS with directories synthesized along file paths', () => {