diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index f5c4835cbd..13b7db7728 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/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 => { 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 => { appendFileSync(path, data) }, - mkdir: async (path: PathArg, options?: { recursive?: boolean }): Promise => mkdirSync(path, options), + mkdir: async (path: PathArg, options?: { recursive?: boolean; mode?: number }): Promise => mkdirSync(path, options), mkdtemp: async (prefix: string): Promise => mkdtempSync(prefix), readdir: async ( path: PathArg, @@ -514,8 +525,7 @@ export const promises = { unlink: async (path: PathArg): Promise => { unlinkSync(path) }, rename: async (from: PathArg, to: PathArg): Promise => { renameSync(from, to) }, access: async (path: PathArg): Promise => { accessSync(path) }, - // Permission bits have no meaning in the VFS; callers only ever relax them. - chmod: async (): Promise => { /* no permission model */ }, + chmod: async (path: PathArg, mode: number | string): Promise => { chmodSync(path, mode) }, cp: async (from: PathArg, to: PathArg): Promise => { const source = asPath(from) const target = asPath(to) @@ -567,7 +577,7 @@ type NodeFace = Partial> /** 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, diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 930fb87f11..c8ec3e2769 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/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() private readonly directories = new Set([SEP]) + /** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */ + private readonly directoryModes = new Map() 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 => { this.appendFileSync(path, data) }, - mkdir: async (path: string, options?: { recursive?: boolean }): Promise => this.mkdirSync(path, options), + mkdir: async (path: string, options?: { recursive?: boolean; mode?: number }): Promise => this.mkdirSync(path, options), readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise => this.readdirSync(path, options), stat: async (path: string, options?: VfsStatOptions): Promise => this.statSync(path, options), @@ -130,7 +143,7 @@ export class MemoryVfs { mkdtemp: async (prefix: string): Promise => this.mkdtempSync(prefix), link: async (existing: string, next: string): Promise => { this.linkSync(existing, next) }, truncate: async (path: string, length?: number): Promise => { this.truncateSync(path, length) }, - chmod: async (): Promise => {}, + chmod: async (path: string, mode: number): Promise => { this.chmodSync(path, mode) }, opendir: async (path: string): Promise => this.opendir(path), open: async (path: string, flags?: string, mode?: number): Promise => 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 => { 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 } diff --git a/packages/experimental/webworker-runtime/src/storage/tar.ts b/packages/experimental/webworker-runtime/src/storage/tar.ts index 3d69025a59..abd7f351c2 100644 --- a/packages/experimental/webworker-runtime/src/storage/tar.ts +++ b/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 diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index c9a6286bae..e32e8ba27a 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/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') diff --git a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts b/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts index fd46a2a09b..47aee5ec6b 100644 --- a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts +++ b/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', () => {