UNPKG

@pgxsinkit/pgwasm

Version:
1,017 lines (942 loc) • 46 kB
/** * A WASI preview1 FILESYSTEM adapter over `RepackedSyncClient`. * * ## Why this file exists * * The broker gives a futex-parked thread synchronous access to ONE repacked store. A wasm engine does * not speak that API — it speaks `wasi_snapshot_preview1`, thirty-odd i32-returning imports over its * own linear memory. This module is the seam: every WASI file call a guest makes is translated into a * broker request, and the errno the broker already speaks (the protocol's numbers ARE WASI preview1 * errnos) is handed straight back. Nothing here knows about pgwasm, OPFS, or any particular engine — * it needs a client, a way to reach the guest's memory, and nothing else. * * ## What it owns and what it refuses to touch * * The adapter owns fd `preopenFd` (3 by default — the "/" preopen) and every fd at or above `fdBase` * (4 by default). It NEVER touches fds 0–2: a host keeps its own stdin/stdout/stderr, and * {@link WasiPreview1Fs.compose} builds the merged import object that routes each call to whichever * side owns the fd. `owns()` is public so a host can make that decision itself. * * ## The fd table * * One entry per open descriptor, keyed by the WASI fd the guest sees: * * { fd, path, isDir, clientFd, offset, fdflags, rightsBase, rightsInheriting, readable, writable } * * - `path` is the normalized absolute path the fd was opened with. It is kept for the whole life of * the descriptor because the store resizes by PATH, not by descriptor: `fd_filestat_set_size` and * `fd_allocate` resolve through it. * - `clientFd` is the broker's descriptor for a FILE. A DIRECTORY has none: the store cannot open a * directory at all (its `open` answers `EISDIR`), so a directory fd is adapter-local — a remembered * path plus a listing snapshot — and is still usable for `fd_readdir`, `fd_filestat_get`, `fd_sync` * and as a dirfd for every path operation. * - `offset` is tracked HERE, not in the store. Every read and write the adapter issues carries an * explicit position (the store's `pread`/`pwrite` form), so the store's own cursor is never used and * `fd_seek`/`fd_tell` are exact even though several threads share one store. * * ## The mapping, decision by decision * * - **rights → access.** `RIGHTS_FD_READ` grants read, `RIGHTS_FD_WRITE` grants write; a request that * asks for neither is read-only. That access is enforced by the BROKER (per its own fd) and again * here, because a POSIX `open(O_RDONLY|O_CREAT)` has to open the store descriptor wider than the * guest asked for — see below. * - **oflags → POSIX bits.** `CREAT`→`O_CREAT`, `EXCL`→`O_EXCL`, `TRUNC`→`O_TRUNC`, and the access * mode from the rights. `planOpen` (server side) rejects `O_CREAT`/`O_TRUNC` without write access, * so a create/truncate request always adds write to the POSIX access mode while the adapter keeps * the NARROWER access the rights asked for and rejects a later `fd_write` with `ENOTCAPABLE`. * - **`O_DIRECTORY` and directories.** With `DIRECTORY` set the adapter never calls `open` (that would * create a file); it stats and builds a directory fd. Without it, `open` is attempted and an * `EISDIR` answer is recognised as "this is a directory" and turned into a directory fd too — which * costs nothing on the common file path. * - **`O_APPEND` is emulated here, never passed to the broker.** An append write resolves end-of-file * with `fstat` and then `pwrite`s there, so the adapter's own offset stays exact and * `fd_fdstat_set_flags` can turn `APPEND` on and off on a descriptor that was not opened with it. * - **`fd_seek`.** `SET`/`CUR` are pure arithmetic on the adapter's offset; `END` resolves the size * with `fstat` on the broker fd (never `size(path)`, which would race a rename). * - **`fd_readdir` cookies.** A cookie is an index into a listing SNAPSHOT taken on cookie 0, exactly * as a POSIX `readdir` may. The snapshot is built out of `readdirPage` calls, and it exists for a * protocol reason as much as a semantic one: the broker treats a cursor past the end of the listing * as a protocol violation and DETACHES the client, so a stale cookie from a directory that shrank * must never reach it. A cookie past the snapshot's end reports zero bytes used — end of directory. * - **truncate by path.** `fd_filestat_set_size` and `fd_allocate` call `truncate(path, size)` with * the descriptor's remembered path. The store has no hard links, so nothing is lost. * - **`fd_sync`/`fd_datasync`.** Both map to the broker's `fsync`, whose durability is STORE-WIDE: on * success every byte written through the broker by ANY client before the call returned is * recoverable. That is stronger than `fd_sync` promises, never weaker. * - **symlinks.** The store HAS them, and `SYMLINK_FOLLOW` therefore means something everywhere it * appears: `path_filestat_get` picks `stat` or `lstat`, and a `path_open` without it sends * `O_NOFOLLOW` so a final component that IS a link answers `ELOOP` instead of opening its target. * `path_unlink_file` removes the LINK, never the target — that is the core's `unlink`, not * something added here. `path_symlink` writes a link and `path_readlink` reads one back, both * verbatim: the store takes ABSOLUTE targets only (see `validateSymlinkTarget`), so a relative * target is `EINVAL` rather than being silently reinterpreted, and an absolute one is a STORE * path — which is the same thing as a guest path whenever the preopen is the store root, the * default and the only arrangement a datadir uses. `path_link` remains `ENOTSUP`: the store has * no hard links. * - **`fd_advise`** is a no-op success. **`fd_filestat_set_times`/`path_filestat_set_times`** answer * `ENOTSUP`: the broker exposes no `utimes` opcode, and inventing an adapter-local timestamp would * make two threads on ONE store disagree about a file's mtime, which is exactly what this whole * arrangement exists to prevent. * * ## Failure discipline * * Every exported function is wrapped so that a JS exception can never leave it. A throw out of a WASI * import unwinds through the guest's nounwind frames and surfaces as a bare `RuntimeError: * unreachable` with no attribution at all; instead the wrapper reports `EIO` and hands the cause to * `onError` with the call name. A transport failure (the coordinator died, the client was detached) is * therefore an `EIO` the guest can act on rather than an abort it cannot. */ import type { RepackedSyncClient } from "../broker/client"; import { O_CREAT, O_EXCL, O_NOFOLLOW, O_RDONLY, O_RDWR, O_TRUNC, O_WRONLY } from "../broker/protocol"; import type { BrokerStat } from "../broker/protocol"; /** * WASI preview1 errno numbers. A superset of the store's own `FS_ERRNO` (whose values already ARE * these numbers); the extra codes are ones a filesystem ADAPTER has to answer and the store core * never produces, so they live here rather than widening the core's error vocabulary. */ export const WASI_ERRNO = { SUCCESS: 0, ACCES: 2, BADF: 8, EXIST: 20, INVAL: 28, IO: 29, ISDIR: 31, LOOP: 32, NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTEMPTY: 55, NOTSUP: 58, OVERFLOW: 61, PERM: 63, SPIPE: 70, NOTCAPABLE: 76, } as const; /** WASI preview1 filetypes. */ export const WASI_FILETYPE = { UNKNOWN: 0, BLOCK_DEVICE: 1, CHARACTER_DEVICE: 2, DIRECTORY: 3, REGULAR_FILE: 4, SOCKET_DGRAM: 5, SOCKET_STREAM: 6, SYMBOLIC_LINK: 7, } as const; /** `path_open` oflags. */ export const OFLAGS_CREAT = 1; export const OFLAGS_DIRECTORY = 2; export const OFLAGS_EXCL = 4; export const OFLAGS_TRUNC = 8; /** `fdflags`, on `path_open` and `fd_fdstat_set_flags`. */ export const FDFLAGS_APPEND = 1; export const FDFLAGS_DSYNC = 2; export const FDFLAGS_NONBLOCK = 4; export const FDFLAGS_RSYNC = 8; export const FDFLAGS_SYNC = 16; /** `lookupflags`, on every path operation that could follow a symlink. */ export const LOOKUPFLAGS_SYMLINK_FOLLOW = 1; /** The two rights the adapter reads; the rest are carried through untouched. */ export const RIGHTS_FD_READ = 1n << 1n; export const RIGHTS_FD_WRITE = 1n << 6n; /** What a preopen advertises when the host asked for nothing narrower. */ export const RIGHTS_ALL = 0xffff_ffff_ffff_ffffn; /** `fd_seek` whence values. */ export const WHENCE_SET = 0; export const WHENCE_CUR = 1; export const WHENCE_END = 2; /** `filestat` is 64 bytes; `dirent` is 24 bytes plus the name. */ const FILESTAT_BYTES = 64; const DIRENT_HEADER_BYTES = 24; /** What `stat` reports for a directory — the store has no directory size, and `0` confuses callers. */ const DIRECTORY_SIZE = 4096n; const NS_PER_MS = 1_000_000n; /** One open descriptor the adapter owns. */ interface AdapterFd { readonly fd: number; /** The normalized absolute path this fd was opened with; the store resizes by path only. */ path: string; readonly isDir: boolean; /** The broker's descriptor — `undefined` for a directory, which the store cannot open. */ readonly clientFd: number | undefined; /** The adapter's own cursor. Every transfer carries an explicit position, so this is authoritative. */ offset: bigint; fdflags: number; rightsBase: bigint; rightsInheriting: bigint; readonly readable: boolean; readonly writable: boolean; /** The listing snapshot a `fd_readdir` cookie indexes into, taken on cookie 0. */ listing: DirentSnapshot | undefined; } interface DirentSnapshot { readonly names: readonly string[]; /** Lazily filled per emitted entry: a `d_type` costs one `lstat`, so only emitted names pay it. */ readonly filetypes: Map<string, number>; } export interface WasiPreview1FsOptions { /** The synchronous broker client this adapter turns WASI calls into. */ readonly client: RepackedSyncClient; /** * The guest's linear memory, resolved on EVERY call. A shared wasm memory grows underneath the host * and any cached `Uint8Array`/`DataView` goes stale (or detaches outright, for a non-shared one), so * nothing here holds a view across a call. */ readonly memory: () => ArrayBuffer | SharedArrayBuffer; /** The fd the guest sees the preopened directory as. Defaults to 3, the WASI convention. */ readonly preopenFd?: number; /** The directory that preopen names. Defaults to the store root. */ readonly preopenPath?: string; /** The first fd the adapter hands out, and the bottom of the range it claims. Defaults to 4. */ readonly fdBase?: number; /** The adapter's clock, in milliseconds. Defaults to the wall clock; used for diagnostics. */ readonly now?: () => bigint; /** Where a JS exception that escaped a WASI call is reported. Defaults to `console.error`. */ readonly onError?: (call: string, cause: unknown) => void; } /** The `wasi_snapshot_preview1` filesystem surface, with the ABI's exact signatures. */ export interface WasiPreview1FsFunctions { fd_prestat_get(fd: number, resultPtr: number): number; fd_prestat_dir_name(fd: number, pathPtr: number, pathLen: number): number; fd_close(fd: number): number; fd_read(fd: number, iovsPtr: number, iovsLen: number, nreadPtr: number): number; fd_pread(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nreadPtr: number): number; fd_write(fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number; fd_pwrite(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nwrittenPtr: number): number; fd_seek(fd: number, offset: bigint, whence: number, resultPtr: number): number; fd_tell(fd: number, resultPtr: number): number; fd_fdstat_get(fd: number, resultPtr: number): number; fd_fdstat_set_flags(fd: number, fdflags: number): number; fd_fdstat_set_rights(fd: number, rightsBase: bigint, rightsInheriting: bigint): number; fd_filestat_get(fd: number, resultPtr: number): number; fd_filestat_set_size(fd: number, size: bigint): number; fd_filestat_set_times(fd: number, atim: bigint, mtim: bigint, fstflags: number): number; fd_readdir(fd: number, bufPtr: number, bufLen: number, cookie: bigint, bufusedPtr: number): number; fd_sync(fd: number): number; fd_datasync(fd: number): number; fd_allocate(fd: number, offset: bigint, length: bigint): number; fd_advise(fd: number, offset: bigint, length: bigint, advice: number): number; path_open( dirfd: number, dirflags: number, pathPtr: number, pathLen: number, oflags: number, rightsBase: bigint, rightsInheriting: bigint, fdflags: number, resultPtr: number, ): number; path_filestat_get(dirfd: number, flags: number, pathPtr: number, pathLen: number, resultPtr: number): number; path_filestat_set_times( dirfd: number, flags: number, pathPtr: number, pathLen: number, atim: bigint, mtim: bigint, fstflags: number, ): number; path_create_directory(dirfd: number, pathPtr: number, pathLen: number): number; path_remove_directory(dirfd: number, pathPtr: number, pathLen: number): number; path_unlink_file(dirfd: number, pathPtr: number, pathLen: number): number; path_rename(dirfd: number, oldPtr: number, oldLen: number, newDirfd: number, newPtr: number, newLen: number): number; path_readlink( dirfd: number, pathPtr: number, pathLen: number, bufPtr: number, bufLen: number, bufusedPtr: number, ): number; path_symlink(oldPtr: number, oldLen: number, dirfd: number, newPtr: number, newLen: number): number; path_link( oldDirfd: number, oldFlags: number, oldPtr: number, oldLen: number, newDirfd: number, newPtr: number, newLen: number, ): number; } /** The adapter: the WASI surface plus the three composition/lifecycle helpers a host needs. */ export interface WasiPreview1Fs extends WasiPreview1FsFunctions { /** Whether this adapter answers for `fd`: the preopen, or anything at or above `fdBase`. */ owns(fd: number): boolean; /** How many descriptors the adapter currently holds, preopen excluded. */ openFdCount(): number; /** * A merged `wasi_snapshot_preview1` object: every filesystem call goes to the adapter when the fd * (or the dirfd, for a path operation) is adapter-owned and to `base` otherwise. Everything in * `base` that is not a filesystem call — `args_get`, `clock_time_get`, `poll_oneoff`, `proc_exit`, * `random_get`, `sched_yield`, the socket stubs — is carried through untouched, as are `base`'s own * fd 0/1/2 handlers. */ compose(base: Readonly<Record<string, unknown>>): Record<string, unknown>; /** * Close every descriptor, returning how many were released. The call a thread makes on its way out: * without it the coordinator holds the thread's store descriptors until the whole channel detaches. */ closeAll(): number; } /** Every filesystem import name, mapped to the argument index carrying the fd or dirfd. */ const FD_ARGUMENT_INDEX: Readonly<Record<keyof WasiPreview1FsFunctions, number>> = { fd_prestat_get: 0, fd_prestat_dir_name: 0, fd_close: 0, fd_read: 0, fd_pread: 0, fd_write: 0, fd_pwrite: 0, fd_seek: 0, fd_tell: 0, fd_fdstat_get: 0, fd_fdstat_set_flags: 0, fd_fdstat_set_rights: 0, fd_filestat_get: 0, fd_filestat_set_size: 0, fd_filestat_set_times: 0, fd_readdir: 0, fd_sync: 0, fd_datasync: 0, fd_allocate: 0, fd_advise: 0, path_open: 0, path_filestat_get: 0, path_filestat_set_times: 0, path_create_directory: 0, path_remove_directory: 0, path_unlink_file: 0, path_rename: 0, path_readlink: 0, // The only call whose directory argument is not first: `path_symlink(old_path, old_len, fd, …)`. path_symlink: 2, path_link: 0, }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder("utf-8", { fatal: false }); /** * Canonicalize a guest path the way the store demands: absolute, no `.`/`..`, no empty or repeated * separator, no trailing slash. Deliberately lenient in the same places a WASI host has to be — * wasi-libc hands preopen-RELATIVE paths, but an absolute one is accepted too, and a trailing NUL from * a fixed-size buffer is stripped rather than rejected. */ export function normalizeWasiPath(path: string): string { let value = path.replace(/\0+$/u, ""); if (value === "" || value === ".") return "/"; if (!value.startsWith("/")) value = `/${value}`; const parts: string[] = []; for (const segment of value.split("/")) { if (segment === "" || segment === ".") continue; if (segment === "..") { parts.pop(); continue; } parts.push(segment); } return `/${parts.join("/")}`; } /** * A stable synthetic inode number for a path. The store has no inode numbers on the wire, and a WASI * `filestat`/`dirent` must carry one; a 64-bit FNV-1a of the path is stable for as long as the path * is, never zero, and never collides in practice within one datadir. */ function inodeOf(path: string): bigint { let hash = 0xcbf2_9ce4_8422_2325n; const bytes = textEncoder.encode(path); for (const byte of bytes) { hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x1000_0000_01b3n); } return hash === 0n ? 1n : hash; } function toBigInt(value: bigint | number): bigint { return typeof value === "bigint" ? value : BigInt(Math.trunc(value)); } export function createWasiPreview1Fs(options: WasiPreview1FsOptions): WasiPreview1Fs { const client = options.client; const preopenFd = options.preopenFd ?? 3; const preopenPath = normalizeWasiPath(options.preopenPath ?? "/"); const fdBase = options.fdBase ?? preopenFd + 1; const now = options.now ?? (() => BigInt(Date.now())); const onError = options.onError ?? ((call: string, cause: unknown) => { const detail = cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause); console.error(`[wasi-preview1 ${call} @${now()}] ${detail}`); }); if (!Number.isSafeInteger(preopenFd) || preopenFd < 3) { throw new RangeError("a WASI preopen fd must be a safe integer of at least 3 (0-2 are stdio)"); } if (!Number.isSafeInteger(fdBase) || fdBase <= preopenFd) { throw new RangeError("the WASI adapter fd base must be a safe integer above the preopen fd"); } const table = new Map<number, AdapterFd>(); let nextFd = fdBase; const preopenEntry = (): AdapterFd => ({ fd: preopenFd, path: preopenPath, isDir: true, clientFd: undefined, offset: 0n, fdflags: 0, rightsBase: RIGHTS_ALL, rightsInheriting: RIGHTS_ALL, readable: true, writable: false, listing: undefined, }); table.set(preopenFd, preopenEntry()); // ---- memory access ------------------------------------------------------- // Re-derived per call, never cached: the guest memory is shared and grows underneath us. const bytes = (): Uint8Array => new Uint8Array(options.memory()); const view = (): DataView => new DataView(options.memory()); function readGuestString(ptr: number, length: number): string { // `.slice()`, not `.subarray()`: TextDecoder refuses a SharedArrayBuffer-backed view in Chrome // (the [AllowShared] rule). Paths are short, so the copy costs nothing. return textDecoder.decode(bytes().slice(ptr, ptr + length)); } function* iovecs(ptr: number, count: number): Generator<{ ptr: number; len: number }> { const data = view(); for (let index = 0; index < count; index += 1) { const base = ptr + index * 8; yield { ptr: data.getUint32(base, true), len: data.getUint32(base + 4, true) }; } } // ---- fd table ------------------------------------------------------------ function owns(fd: number): boolean { return fd === preopenFd || (Number.isSafeInteger(fd) && fd >= fdBase); } function entryOf(fd: number): AdapterFd | undefined { return table.get(fd); } function allocate(entry: Omit<AdapterFd, "fd">): number { const fd = nextFd; nextFd += 1; table.set(fd, { ...entry, fd }); return fd; } function resolve(dirfd: number, ptr: number, length: number): string | undefined { const dir = entryOf(dirfd); if (dir === undefined || !dir.isDir) return undefined; const relative = readGuestString(ptr, length); if (relative.startsWith("/")) return normalizeWasiPath(relative); return normalizeWasiPath(dir.path === "/" ? `/${relative}` : `${dir.path}/${relative}`); } // ---- stat ---------------------------------------------------------------- function filetypeOf(kind: BrokerStat["kind"]): number { if (kind === "directory") return WASI_FILETYPE.DIRECTORY; return kind === "symlink" ? WASI_FILETYPE.SYMBOLIC_LINK : WASI_FILETYPE.REGULAR_FILE; } function writeFilestat(ptr: number, path: string, stat: BrokerStat): void { // Zero first: the struct has padding the guest is entitled to see as zero, and a reused stack // slot would otherwise hand it whatever the last call left there. bytes().fill(0, ptr, ptr + FILESTAT_BYTES); const data = view(); const isDirectory = stat.kind === "directory"; const times = [stat.atimeMs, stat.mtimeMs, stat.ctimeMs].map((ms) => ms * NS_PER_MS); data.setBigUint64(ptr + 0, 1n, true); // dev data.setBigUint64(ptr + 8, inodeOf(path), true); // ino data.setUint8(ptr + 16, filetypeOf(stat.kind)); data.setBigUint64(ptr + 24, 1n, true); // nlink // A link's size is its target's byte length, exactly as POSIX `lstat` reports it. data.setBigUint64(ptr + 32, isDirectory ? DIRECTORY_SIZE : stat.size, true); data.setBigUint64(ptr + 40, times[0]!, true); // atim data.setBigUint64(ptr + 48, times[1]!, true); // mtim data.setBigUint64(ptr + 56, times[2]!, true); // ctim } /** The size of a descriptor's file, from the broker fd (never the path — a rename must not race it). */ function sizeOf(entry: AdapterFd): { errno: number; size: bigint } { if (entry.clientFd === undefined) return { errno: 0, size: DIRECTORY_SIZE }; const stat = client.fstat(entry.clientFd); if (stat.errno !== 0 || stat.stat === undefined) return { errno: stat.errno || WASI_ERRNO.IO, size: 0n }; return { errno: 0, size: stat.stat.size }; } // ---- the WASI surface ---------------------------------------------------- const fs: WasiPreview1FsFunctions = { fd_prestat_get(fd, resultPtr) { if (fd !== preopenFd) return WASI_ERRNO.BADF; const data = view(); data.setUint8(resultPtr, 0); // preopentype::dir data.setUint32(resultPtr + 4, textEncoder.encode(preopenPath).byteLength, true); return WASI_ERRNO.SUCCESS; }, fd_prestat_dir_name(fd, pathPtr, pathLen) { if (fd !== preopenFd) return WASI_ERRNO.BADF; const encoded = textEncoder.encode(preopenPath); if (pathLen < encoded.byteLength) return WASI_ERRNO.INVAL; bytes().set(encoded, pathPtr); return WASI_ERRNO.SUCCESS; }, fd_close(fd) { if (fd === preopenFd) return WASI_ERRNO.SUCCESS; // the preopen outlives every guest close const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; table.delete(fd); if (entry.clientFd === undefined) return WASI_ERRNO.SUCCESS; return client.close(entry.clientFd).errno; }, fd_read(fd, iovsPtr, iovsLen, nreadPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.readable) return WASI_ERRNO.NOTCAPABLE; let total = 0; let errno: number = WASI_ERRNO.SUCCESS; for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) { if (len === 0) continue; const chunk = client.read(entry.clientFd, len, entry.offset + BigInt(total)); if (chunk.count > 0) bytes().set(chunk.bytes, ptr); total += chunk.count; if (chunk.errno !== 0) { errno = chunk.errno; break; } if (chunk.count < len) break; // end of file } entry.offset += BigInt(total); view().setUint32(nreadPtr, total, true); // A rejection part-way through still reports what transferred, exactly as a short read does. return total > 0 ? WASI_ERRNO.SUCCESS : errno; }, fd_pread(fd, iovsPtr, iovsLen, offset, nreadPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.readable) return WASI_ERRNO.NOTCAPABLE; let at = toBigInt(offset); let total = 0; let errno: number = WASI_ERRNO.SUCCESS; for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) { if (len === 0) continue; const chunk = client.read(entry.clientFd, len, at); if (chunk.count > 0) bytes().set(chunk.bytes, ptr); at += BigInt(chunk.count); total += chunk.count; if (chunk.errno !== 0) { errno = chunk.errno; break; } if (chunk.count < len) break; } view().setUint32(nreadPtr, total, true); return total > 0 ? WASI_ERRNO.SUCCESS : errno; }, fd_write(fd, iovsPtr, iovsLen, nwrittenPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.writable) return WASI_ERRNO.NOTCAPABLE; // O_APPEND is emulated here: resolve end-of-file once, then write at explicit positions. if ((entry.fdflags & FDFLAGS_APPEND) !== 0) { const end = sizeOf(entry); if (end.errno !== 0) return end.errno; entry.offset = end.size; } let total = 0; let errno: number = WASI_ERRNO.SUCCESS; for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) { if (len === 0) continue; const source = bytes().subarray(ptr, ptr + len); const written = client.write(entry.clientFd, source, entry.offset + BigInt(total)); total += written.count; if (written.errno !== 0) { errno = written.errno; break; } if (written.count < len) break; // the store admitted less than asked } entry.offset += BigInt(total); view().setUint32(nwrittenPtr, total, true); return total > 0 ? WASI_ERRNO.SUCCESS : errno; }, fd_pwrite(fd, iovsPtr, iovsLen, offset, nwrittenPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.writable) return WASI_ERRNO.NOTCAPABLE; let at = toBigInt(offset); let total = 0; let errno: number = WASI_ERRNO.SUCCESS; for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) { if (len === 0) continue; const written = client.write(entry.clientFd, bytes().subarray(ptr, ptr + len), at); at += BigInt(written.count); total += written.count; if (written.errno !== 0) { errno = written.errno; break; } if (written.count < len) break; } view().setUint32(nwrittenPtr, total, true); return total > 0 ? WASI_ERRNO.SUCCESS : errno; }, fd_seek(fd, offset, whence, resultPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.BADF; // a directory has no seekable offset const delta = toBigInt(offset); let target: bigint; if (whence === WHENCE_SET) target = delta; else if (whence === WHENCE_CUR) target = entry.offset + delta; else if (whence === WHENCE_END) { const end = sizeOf(entry); if (end.errno !== 0) return end.errno; target = end.size + delta; } else return WASI_ERRNO.INVAL; if (target < 0n) return WASI_ERRNO.INVAL; entry.offset = target; view().setBigUint64(resultPtr, target, true); return WASI_ERRNO.SUCCESS; }, fd_tell(fd, resultPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.BADF; view().setBigUint64(resultPtr, entry.offset, true); return WASI_ERRNO.SUCCESS; }, fd_fdstat_get(fd, resultPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; const data = view(); data.setUint8(resultPtr, entry.isDir ? WASI_FILETYPE.DIRECTORY : WASI_FILETYPE.REGULAR_FILE); data.setUint8(resultPtr + 1, 0); data.setUint16(resultPtr + 2, entry.fdflags, true); data.setUint32(resultPtr + 4, 0, true); data.setBigUint64(resultPtr + 8, entry.rightsBase, true); data.setBigUint64(resultPtr + 16, entry.rightsInheriting, true); return WASI_ERRNO.SUCCESS; }, fd_fdstat_set_flags(fd, fdflags) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; // APPEND is the adapter's own emulation, so it can be turned on and off freely; the rest of the // sync bits are accepted and ignored (the store's only durability primitive is `fsync`, which is // already store-wide and stronger than DSYNC/RSYNC/SYNC promise). NONBLOCK is meaningless here: // every broker call blocks by construction. entry.fdflags = fdflags & (FDFLAGS_APPEND | FDFLAGS_DSYNC | FDFLAGS_NONBLOCK | FDFLAGS_RSYNC | FDFLAGS_SYNC); return WASI_ERRNO.SUCCESS; }, fd_fdstat_set_rights(fd, rightsBase, rightsInheriting) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; // Rights may only ever be narrowed. const base = toBigInt(rightsBase); const inheriting = toBigInt(rightsInheriting); if ((base & ~entry.rightsBase) !== 0n || (inheriting & ~entry.rightsInheriting) !== 0n) { return WASI_ERRNO.NOTCAPABLE; } entry.rightsBase = base; entry.rightsInheriting = inheriting; return WASI_ERRNO.SUCCESS; }, fd_filestat_get(fd, resultPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; const stat = entry.clientFd === undefined ? client.stat(entry.path) : client.fstat(entry.clientFd); if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO; writeFilestat(resultPtr, entry.path, stat.stat); return WASI_ERRNO.SUCCESS; }, fd_filestat_set_size(fd, size) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.writable) return WASI_ERRNO.NOTCAPABLE; // The store resizes by PATH only, which is why every fd remembers the path it was opened with. return client.truncate(entry.path, toBigInt(size)).errno; }, fd_filestat_set_times(fd, _atim, _mtim, fstflags) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if ((fstflags & ~0b1111) !== 0) return WASI_ERRNO.INVAL; if (fstflags === 0) return WASI_ERRNO.SUCCESS; // The broker exposes no `utimes`. Answering ENOTSUP is the honest answer: a timestamp kept in // this adapter alone would make two threads on ONE store disagree about a file's mtime. return WASI_ERRNO.NOTSUP; }, fd_readdir(fd, bufPtr, bufLen, cookie, bufusedPtr) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (!entry.isDir) return WASI_ERRNO.NOTDIR; const start = Number(toBigInt(cookie)); if (!Number.isSafeInteger(start) || start < 0) return WASI_ERRNO.INVAL; // Snapshot on cookie 0 so pagination is stable across guest mutations — and so a stale cookie // can never be forwarded to the broker, which treats a cursor past the listing as a protocol // violation and detaches the client. if (start === 0 || entry.listing === undefined) { const listed = client.readdir(entry.path); if (listed.errno !== 0) return listed.errno; entry.listing = { names: listed.entries, filetypes: new Map() }; } const listing = entry.listing; const data = view(); const memory = bytes(); let used = 0; for (let index = start; index < listing.names.length; index += 1) { const name = listing.names[index]!; const nameBytes = textEncoder.encode(name); if (used + DIRENT_HEADER_BYTES >= bufLen) { // No room for another header: report the buffer full so the caller grows it and retries. used = bufLen; break; } let filetype = listing.filetypes.get(name); if (filetype === undefined) { // `lstat`, so a link is reported as one: a guest that walks a directory to decide what to // recurse into must see the link rather than whatever it points at. const child = client.lstat(entry.path === "/" ? `/${name}` : `${entry.path}/${name}`); filetype = child.errno !== 0 || child.stat === undefined ? WASI_FILETYPE.UNKNOWN : filetypeOf(child.stat.kind); listing.filetypes.set(name, filetype); } const at = bufPtr + used; data.setBigUint64(at, BigInt(index + 1), true); // d_next data.setBigUint64(at + 8, inodeOf(entry.path === "/" ? `/${name}` : `${entry.path}/${name}`), true); // d_ino data.setUint32(at + 16, nameBytes.byteLength, true); // d_namlen data.setUint8(at + 20, filetype); data.setUint8(at + 21, 0); data.setUint16(at + 22, 0, true); const room = bufLen - used - DIRENT_HEADER_BYTES; const copied = Math.min(nameBytes.byteLength, room); memory.set(nameBytes.subarray(0, copied), at + DIRENT_HEADER_BYTES); used += DIRENT_HEADER_BYTES + copied; if (copied < nameBytes.byteLength) break; // truncated name: the caller grows the buffer } data.setUint32(bufusedPtr, used, true); return WASI_ERRNO.SUCCESS; }, fd_sync(fd) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; // Store-wide by construction: on success every byte any client wrote before the call is durable. return entry.clientFd === undefined ? client.fsync().errno : client.fsync(entry.clientFd).errno; }, fd_datasync(fd) { return fs.fd_sync(fd); }, fd_allocate(fd, offset, length) { const entry = entryOf(fd); if (entry === undefined) return WASI_ERRNO.BADF; if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR; if (!entry.writable) return WASI_ERRNO.NOTCAPABLE; const end = toBigInt(offset) + toBigInt(length); if (end < 0n) return WASI_ERRNO.INVAL; const current = sizeOf(entry); if (current.errno !== 0) return current.errno; // The store has no space RESERVATION primitive, so this extends by truncation instead. That // satisfies everything `posix_fallocate` observably promises — the file is at least that big, // existing bytes are untouched, the gap reads as zeros — but it does not pre-commit capacity, so // a later write into the range can still fail on a full store. if (end <= current.size) return WASI_ERRNO.SUCCESS; return client.truncate(entry.path, end).errno; }, fd_advise(fd, _offset, _length, _advice) { return entryOf(fd) === undefined ? WASI_ERRNO.BADF : WASI_ERRNO.SUCCESS; }, path_open(dirfd, dirflags, pathPtr, pathLen, oflags, rightsBase, rightsInheriting, fdflags, resultPtr) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; const followLinks = (dirflags & LOOKUPFLAGS_SYMLINK_FOLLOW) !== 0; const base = toBigInt(rightsBase); const inheriting = toBigInt(rightsInheriting); const wantsRead = (base & RIGHTS_FD_READ) !== 0n; const wantsWrite = (base & RIGHTS_FD_WRITE) !== 0n; const create = (oflags & OFLAGS_CREAT) !== 0; const exclusive = (oflags & OFLAGS_EXCL) !== 0; const truncate = (oflags & OFLAGS_TRUNC) !== 0; const directory = (oflags & OFLAGS_DIRECTORY) !== 0; // A request that asks for neither right is a plain read-only open. const readable = wantsRead || !wantsWrite; const writable = wantsWrite; const openDirectory = (): number => { const stat = followLinks ? client.stat(path) : client.lstat(path); if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO; // Without SYMLINK_FOLLOW a final component that IS a link must not be opened through. if (stat.stat.kind === "symlink") return WASI_ERRNO.LOOP; if (stat.stat.kind !== "directory") return WASI_ERRNO.NOTDIR; if (create && exclusive) return WASI_ERRNO.EXIST; const fd = allocate({ path, isDir: true, clientFd: undefined, offset: 0n, fdflags: 0, rightsBase: base === 0n ? RIGHTS_ALL : base, rightsInheriting: inheriting === 0n ? RIGHTS_ALL : inheriting, readable: true, writable: false, listing: undefined, }); view().setUint32(resultPtr, fd, true); return WASI_ERRNO.SUCCESS; }; // With O_DIRECTORY the path must never be created as a file, so `open` is not attempted at all. if (directory) return openDirectory(); // `planOpen` refuses O_CREAT/O_TRUNC without write access, but POSIX `open(O_RDONLY|O_CREAT)` is // perfectly legal — so the STORE descriptor is opened wide enough to satisfy the plan while the // adapter keeps the narrower access the rights asked for and rejects a later write itself. const posixWrite = writable || create || truncate; const posixRead = readable || !posixWrite; let flags = posixRead && posixWrite ? O_RDWR : posixWrite ? O_WRONLY : O_RDONLY; if (create) flags |= O_CREAT; if (exclusive) flags |= O_EXCL; if (truncate) flags |= O_TRUNC; // The broker follows a final link by default; the absent lookup flag is what turns that off. if (!followLinks) flags |= O_NOFOLLOW; // O_APPEND is never sent: it is emulated on this side so the adapter's offset stays exact and // `fd_fdstat_set_flags` can add or remove APPEND on an already-open descriptor. const opened = client.open(path, flags); if (opened.errno !== 0) { // The store cannot open a directory at all — it answers EISDIR — so that answer is how a // directory is recognised on the common path, at no cost to a file open. if (opened.errno === WASI_ERRNO.ISDIR) return create && exclusive ? WASI_ERRNO.EXIST : openDirectory(); return opened.errno; } const fd = allocate({ path, isDir: false, clientFd: opened.fd, offset: 0n, fdflags: fdflags & (FDFLAGS_APPEND | FDFLAGS_DSYNC | FDFLAGS_NONBLOCK | FDFLAGS_RSYNC | FDFLAGS_SYNC), rightsBase: base === 0n ? RIGHTS_ALL : base, rightsInheriting: inheriting === 0n ? RIGHTS_ALL : inheriting, readable, writable, listing: undefined, }); view().setUint32(resultPtr, fd, true); return WASI_ERRNO.SUCCESS; }, path_filestat_get(dirfd, flags, pathPtr, pathLen, resultPtr) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; // The split that makes `std::fs::symlink_metadata` differ from `std::fs::metadata`: without // the flag the LINK is reported, with it the thing it points at. const follow = (flags & LOOKUPFLAGS_SYMLINK_FOLLOW) !== 0; const stat = follow ? client.stat(path) : client.lstat(path); if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO; writeFilestat(resultPtr, path, stat.stat); return WASI_ERRNO.SUCCESS; }, path_filestat_set_times(dirfd, _flags, pathPtr, pathLen, _atim, _mtim, fstflags) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; if ((fstflags & ~0b1111) !== 0) return WASI_ERRNO.INVAL; const stat = client.lstat(path); if (stat.errno !== 0) return stat.errno; if (fstflags === 0) return WASI_ERRNO.SUCCESS; return WASI_ERRNO.NOTSUP; // see fd_filestat_set_times }, path_create_directory(dirfd, pathPtr, pathLen) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; return client.mkdir(path).errno; }, path_remove_directory(dirfd, pathPtr, pathLen) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; return client.rmdir(path).errno; }, path_unlink_file(dirfd, pathPtr, pathLen) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; return client.unlink(path).errno; }, path_rename(dirfd, oldPtr, oldLen, newDirfd, newPtr, newLen) { const oldPath = resolve(dirfd, oldPtr, oldLen); const newPath = resolve(newDirfd, newPtr, newLen); if (oldPath === undefined || newPath === undefined) return WASI_ERRNO.BADF; const result = client.rename(oldPath, newPath); if (result.errno !== 0) return result.errno; // Every descriptor still open on the old path must follow it, or a later `fd_filestat_set_size` // would resize whatever now occupies the source name. const prefix = oldPath === "/" ? "/" : `${oldPath}/`; for (const entry of table.values()) { if (entry.path === oldPath) entry.path = newPath; else if (entry.path.startsWith(prefix)) entry.path = newPath + entry.path.slice(oldPath.length); } return WASI_ERRNO.SUCCESS; }, path_readlink(dirfd, pathPtr, pathLen, bufPtr, bufLen, bufusedPtr) { const path = resolve(dirfd, pathPtr, pathLen); if (path === undefined) return WASI_ERRNO.BADF; // EINVAL for a path that is not a link, ENOENT for one that is not there — both come from the // store, whose errnos already ARE these numbers. const link = client.readlink(path); if (link.errno !== 0 || link.target === undefined) return link.errno || WASI_ERRNO.IO; const encoded = textEncoder.encode(link.target); // preview1 writes no terminator and truncates silently; `bufused === buflen` is how a caller // learns its buffer was too small, so a short buffer must NOT be an error here. const copied = Math.min(encoded.byteLength, bufLen); if (copied > 0) bytes().set(encoded.subarray(0, copied), bufPtr); view().setUint32(bufusedPtr, copied, true); return WASI_ERRNO.SUCCESS; }, path_symlink(oldPtr, oldLen, dirfd, newPtr, newLen) { const path = resolve(dirfd, newPtr, newLen); if (path === undefined) return WASI_ERRNO.BADF; // The TARGET is the link's contents, not a path to walk: it is read verbatim (bar a trailing // NUL from a fixed-size guest buffer) and stored as given. The store takes absolute targets // only and answers EINVAL for anything else. const target = readGuestString(oldPtr, oldLen).replace(/\0+$/u, ""); return client.symlink(target, path).errno; }, path_link(oldDirfd, _oldFlags, _oldPtr, _oldLen, newDirfd, _newPtr, _newLen) { if (entryOf(oldDirfd)?.isDir !== true || entryOf(newDirfd)?.isDir !== true) return WASI_ERRNO.BADF; return WASI_ERRNO.NOTSUP; // the store has no hard links }, }; // ---- failure containment ------------------------------------------------- // A JS exception thrown out of a WASI import unwinds through the guest's nounwind frames and // surfaces as a bare `RuntimeError: unreachable` with nothing attached. Every call is therefore // wrapped: the guest sees EIO and the host sees the stack. const guarded: Record<string, unknown> = {}; for (const name of Object.keys(fs) as (keyof WasiPreview1FsFunctions)[]) { const inner = fs[name] as (...args: unknown[]) => number; guarded[name] = (...args: unknown[]): number => { try { return inner(...args); } catch (cause) { onError(name, cause); return WASI_ERRNO.IO; } }; } const adapter = guarded as unknown as WasiPreview1Fs; adapter.owns = owns; adapter.openFdCount = (): number => table.size - (table.has(preopenFd) ? 1 : 0); adapter.closeAll = (): number => { let released = 0; for (const entry of table.values()) { if (entry.clientFd === undefined) continue; client.close(entry.clientFd); released += 1; } table.clear(); table.set(preopenFd, preopenEntry()); nextFd = fdBase; return released; }; adapter.compose = (base: Readonly<Record<string, unknown>>): Record<string, unknown> => { const merged: Record<string, unknown> = { ...base }; for (const [name, index] of Object.entries(FD_ARGUMENT_INDEX)) { const mine = guarded[name] as (...args: unknown[]) => number; const theirs = base[name]; if (typeof theirs !== "function") { // The base host does not implement this call at all (`fd_tell`, `fd_allocate` and friends are // commonly absent): the adapter answers for its own fds and reports EBADF for anything else, // which is exactly what an absent implementation would have to say. merged[name] = (...args: unknown[]): number => (owns(Number(args[index])) ? mine(...args) : WASI_ERRNO.BADF); continue; } const fallback = theirs as (...args: unknown[]) => number; merged[name] = (...args: unknown[]): number => (owns(Number(args[index])) ? mine(...args) : fallback(...args)); } return merged; }; return adapter; }