diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2cfa16993..de4d8eea9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -56,6 +56,7 @@ - [`node:tty`](./interop/nodejs-builtins/supported-modules/tty.md) - [`node:url`](./interop/nodejs-builtins/supported-modules/url.md) - [`node:util`](./interop/nodejs-builtins/supported-modules/util.md) + - [`node:vfs`](./interop/nodejs-builtins/supported-modules/vfs.md) - [`node:vm`](./interop/nodejs-builtins/supported-modules/vm.md) - [`node:wasi`](./interop/nodejs-builtins/supported-modules/wasi.md) - [`node:worker_threads`](./interop/nodejs-builtins/supported-modules/worker-threads.md) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/fs.md b/docs/src/interop/nodejs-builtins/supported-modules/fs.md index 9234d9464..fd0d74920 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/fs.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/fs.md @@ -9,7 +9,8 @@ When either specifier occurs in bundled source, Jco adds a missing import to the selected world, installs `fs.wit` under `deps/jco-node-0.1.0`, and prints a CLI warning to alert to the fact that a WIT dependency has been added. -The default filesystem host provider always throws `ERR_JCO_FS_ADAPTER_REQUIRED`. +The default filesystem host provider returns a typed denial result, which the +guest reconstructs as `ERR_JCO_FS_ADAPTER_REQUIRED`. To use the passthrough NodeJS host provider you can map it in: ```console diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index 577cdde96..ebe2974fc 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -72,6 +72,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:tty`](./tty.md) | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default. | | [`node:url`](./url.md) | Node 24 URL, URLSearchParams, URLPattern, domain and file conversions; relative file paths use optional WASI environment imports. | | [`node:util`](./util.md), [`node:util/types`](./util.md) | Portable Node 24 utilities, sharing assertion equality and console formatting. No WIT capability; see the API page for engine and process restrictions. | +| [`node:vfs`](./vfs.md) | Node 26 memory VFS, default-denied Node passthrough, and WASI filesystem storage with configurable preopen placement. | | [`node:vm`](./vm.md) | Same-context script evaluation and function compilation in the guest. No WIT capability; separate realms, native caches and VM modules are unsupported. | | [`node:wasi`](./wasi.md) | Node 24.19 `WASI` construction over an explicit host capability, denied by default; `start()` and `initialize()` refuse because a component cannot instantiate a nested module. | | [`node:worker_threads`](./worker-threads.md) | Real Node host workers over an explicit capability, with guest-local environment data. Native ports, shared memory and profiling are unsupported. | diff --git a/docs/src/interop/nodejs-builtins/supported-modules/vfs.md b/docs/src/interop/nodejs-builtins/supported-modules/vfs.md new file mode 100644 index 000000000..a54d560d4 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/vfs.md @@ -0,0 +1,157 @@ +# `node:vfs` + +Jco implements the experimental Node 26.8.2 VFS API. Application code keeps its +ordinary `node:vfs` imports. `create()` uses an isolated `MemoryProvider` by +default; `RealFSProvider` accesses an explicitly supplied filesystem capability. +Only the `node:` specifier is intercepted. + +This provider selection mirrors [Node.js's `node:vfs` API](https://nodejs.org/api/vfs.html#vfscreateprovider-options). +`MemoryProvider` and `RealFSProvider` are upstream Node.js classes: application +code calls `create()` for memory storage, or explicitly passes +`new RealFSProvider(root)` for filesystem storage. Jco's `--with-nodejs-vfs-via` +option selects the backend used by `RealFSProvider`; it does not change the +in-memory default of `create()`. + +```js +import { create, RealFSProvider } from 'node:vfs'; + +export function run(root) { + const fs = create(new RealFSProvider(root)); + fs.mkdirSync('/reports', { recursive: true }); + fs.writeFileSync('/reports/result.txt', 'done'); + return fs.readFileSync('/reports/result.txt', 'utf8'); +} +``` + +Omit the provider to use memory. Memory operations never consult host providers, +preopens, or a storage resolver. `MemoryProvider.setReadOnly()` prevents subsequent +write operations through the provider while preserving existing contents. + +## Choosing a filesystem implementation + +| Selection | Host capability | Default behavior | +| --- | --- | --- | +| `--with-nodejs-vfs-via direct` | `jco:node/fs@0.1.0` | Filesystem access is denied with `ERR_JCO_FS_ADAPTER_REQUIRED`. | +| `direct` with an explicit Node host mapping | `jco:node/fs@0.1.0` | Node filesystem passthrough under each `RealFSProvider` root. | +| `--with-nodejs-vfs-via wasi-filesystem` | `wasi:filesystem/preopens` and `types` at `0.2.12` | Access is limited to the preopens supplied at instantiation. | + +`direct` is the default. It reuses the same filesystem boundary and host provider +as `node:fs`; mapping that capability grants it to both APIs in the component. +Imports and provider construction do not themselves perform filesystem operations. +The selected host is accessed lazily when an operation needs it. + +For Node passthrough: + +```sh +jco componentize app.js --wit wit --bundle -o app.wasm +jco transpile app.wasm -o out \ + --map 'jco:node/fs@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/vfs/host/node' +``` + +The `vfs/host/node` export reuses the existing Node filesystem provider. It does +not depend on the host having native `node:vfs` or enabling `--experimental-vfs`. +The VFS façade, virtual descriptors, and memory tree run inside the component. + +For WASI filesystem access: + +```sh +jco componentize app.js --wit wit --bundle \ + --with-nodejs-vfs-via wasi-filesystem -o app.wasm +jco transpile app.wasm -o out --instantiation async +``` + +Jco adds the selected interfaces and their WIT dependencies. Configure preopens +when instantiating the result, for example with preview2-shim: + +```js +import { instantiate } from './out/app.js'; +import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; + +const wasi = new WASIShim({ + sandbox: { preopens: { '/data': '/srv/application-data' } }, +}); +const app = await instantiate(undefined, wasi.getImportObject()); +app.run('/data'); +``` + +VFS roots and application paths use POSIX syntax. Real provider roots must be +absolute. Filesystem root directories must already exist. Relative paths are +resolved from the VFS root, not the host process's working directory. Root and +symlink checks are compatibility checks; VFS is not a replacement for host +isolation or correctly scoped WASI capabilities. + +## Configuring storage placement + +By default, a WASI real provider selects the longest preopen mount containing its +root. For a preopen `/data`, `new RealFSProvider('/data/projects/demo')` stores +contents in `projects/demo` within that preopen. A root with no matching preopen +fails with `EACCES`. An exact mount match uses the preopen's root directory. + +Supply a guest JavaScript module exporting `resolveRoot` to select another +preopen or directory: + +```js +// vfs-storage.js +export function resolveRoot(rootPath, preopens) { + const storage = preopens.find(([, name]) => name === '/data'); + if (!storage || rootPath !== '/workspace') { + throw new Error('No storage configured for this VFS root'); + } + return { descriptor: storage[0], directory: 'projects/demo' }; +} +``` + +```sh +jco componentize app.js --wit wit --bundle \ + --with-nodejs-vfs-via wasi-filesystem \ + --with-nodejs-vfs-wasi-config ./vfs-storage.js -o app.wasm +``` + +The module path is resolved from the command's working directory and bundled into +the guest. The callback receives the normalized VFS root and `[descriptor, +guestPath]` preopen pairs. It returns a borrowed descriptor and a directory +relative to it. Absolute directories and `..` paths escaping the preopen are +rejected. The selected directory must already exist. The resolver runs once per +real provider, on first use; separate VFS instances can choose different folders. +Exceptions propagate to the caller. The implementation disposes descriptors it +opens, but never disposes the resolver's borrowed preopen. + +Direct adapter users can configure the same callback through +`createWasiVfs({ preopens, resolveRoot })` from +`@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/vfs/impl/wasi-filesystem`. +Direct adapters and native Node builtins can coexist in one host application. +Application components should continue importing `node:vfs`. + +## Supported operations and limits + +The module exports `create`, `VirtualFileSystem`, `VirtualProvider`, +`MemoryProvider`, and `RealFSProvider`. File contents, directories, copy/rename, +hard links, symbolic links, metadata, directory handles, and scalar virtual +file-descriptor I/O are supported. Callback and promise façades share the same +provider. Custom providers inherit the base class's derived file operations. +VFS `openAsBlob()` returns a snapshot when the component engine supplies `Blob`. +As in the pinned runtime, `vfs.promises.open()` returns a numeric virtual +file descriptor, not a Node `fs.promises.FileHandle`. + +Mounting into native `node:fs` or the module loader, filesystem streams, and +watchers throw `ERR_JCO_UNSUPPORTED_NODE_API`. `mounted` stays false and +`mountPoint` stays null. Providers report `supportsWatch: false`. Missing custom +provider primitives throw `ERR_METHOD_NOT_IMPLEMENTED`. The component does not +emit Node's process-wide experimental warning. Memory metadata uses UID/GID zero +rather than consulting the host process. + +WASI 0.2.12 has no chmod/chown or access-permission test operation. Those calls +fail explicitly; existence-only `access` works. WASI stat fields absent from the +interface use zero for device, inode, ownership and birth time, and conventional +file/directory mode bits. Actual size, link count and available timestamps come +from WASI. Directory listing order is host-dependent. Resource operations use +WASI's synchronous descriptor methods and preserve 64-bit offsets. + +## Provenance + +The portable VFS algorithms are adapted from Node +[v26.8.2](https://github.com/nodejs/node/tree/f2f2c2f246c36bd74f082cb43ecfe830657d81c9/lib/internal/vfs), +commit `f2f2c2f246c36bd74f082cb43ecfe830657d81c9`, with MIT attribution retained. +The implementation reuses Jco's portable filesystem types, value objects, +validation, error transport, and Node host provider. Audited unenv 2.0.0-rc.24 has +no VFS implementation. Its alias map is not enabled for this module. diff --git a/packages/jco-std/LICENSE b/packages/jco-std/LICENSE index 78a58a4db..0074575c3 100644 --- a/packages/jco-std/LICENSE +++ b/packages/jco-std/LICENSE @@ -223,7 +223,7 @@ Software. The Node.js adaptations identified by upstream provenance comments in src/wasi/0.2.x/node/24.x.x/stream/, src/wasi/0.2.x/node/24.x.x/test/, src/wasi/0.2.x/node/24.x.x/util/, src/wasi/0.2.x/node/24.x.x/vm/, -and src/wasi/0.2.x/node/24.x.x/worker-threads/, +src/wasi/0.2.x/node/24.x.x/worker-threads/, and src/wasi/0.2.x/node/26.x.x/vfs/, and their compiled forms, are covered by the following notice: diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 3819582b4..653408476 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -621,6 +621,30 @@ "./wasi/0.2.x/node/24.x.x/wasi/host/node": { "types": "./dist/wasi/0.2.x/node/24.x.x/wasi-host-node.d.ts", "node": "./dist/wasi/0.2.x/node/24.x.x/wasi-host-node.js" + }, + "./wasi/0.2.x/node/26.x.x/vfs": { + "types": "./dist/wasi/0.2.x/node/26.x.x/vfs.d.ts", + "browser": "./dist/wasi/0.2.x/node/26.x.x/vfs.js", + "default": "./dist/wasi/0.2.x/node/26.x.x/vfs.js" + }, + "./wasi/0.2.x/node/26.x.x/vfs/core": { + "types": "./dist/wasi/0.2.x/node/26.x.x/vfs/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/26.x.x/vfs/core.js", + "default": "./dist/wasi/0.2.x/node/26.x.x/vfs/core.js" + }, + "./wasi/0.2.x/node/26.x.x/vfs/impl/wasi-filesystem": { + "types": "./dist/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.d.ts", + "browser": "./dist/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.js", + "default": "./dist/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.js" + }, + "./wasi/0.2.x/node/26.x.x/vfs/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/fs-host.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/fs-host.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/fs-host.js" + }, + "./wasi/0.2.x/node/26.x.x/vfs/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/fs-host-node.d.ts", + "node": "./dist/wasi/0.2.x/node/24.x.x/fs-host-node.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host-node.ts index dc2b5c64a..299e6db3b 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host-node.ts @@ -328,7 +328,6 @@ export const rmdir: FsHost["rmdir"] = (value, options: FsRemoveOptions) => capture(() => nodeFs.rmdirSync(path(value), { maxRetries: options.maxRetries, - recursive: false, retryDelay: options.retryDelay, }), ); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host.ts index 085bcd51e..24b272039 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs-host.ts @@ -1,14 +1,19 @@ -import type { FsHost } from "./fs/types.js"; -import { denyThrow } from "./internal/deny-host.js"; +import type { FsHost, FsResult } from "./fs/types.js"; /** * The default adapter intentionally grants no filesystem capability. Applications must map * `jco:node/fs@0.1.0` to a host implementation, such as the separately exported Node adapter. */ -const deny = denyThrow( - "ERR_JCO_FS_ADAPTER_REQUIRED", - "node:fs requires an explicitly configured filesystem host provider", -); +// This interface returns WIT results. Returning its error record keeps denial +// catchable inside a component instead of throwing out of the host trampoline. +const deny = (): FsResult => ({ + tag: "err", + val: { + name: "Error", + code: "ERR_JCO_FS_ADAPTER_REQUIRED", + message: "node:fs requires an explicitly configured filesystem host provider", + }, +}); export const access: FsHost["access"] = deny; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts index 885956e27..ce539d710 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts @@ -8,7 +8,8 @@ * * - `denyThrow` -- for interfaces whose functions do not return `result`. The provider throws a * coded `Error`, which jco surfaces to the guest as an exception carrying - * `ERR_JCO__ADAPTER_REQUIRED` (child-process, cluster, console, dns, fs, http). + * `ERR_JCO__ADAPTER_REQUIRED` (child-process, cluster, console, dns, http). + * - fs returns a tagged error result so its denial stays catchable across WIT. * - Interfaces returning `result` throw serialized error records, which the * bindings lower into the `err` case; the guest reconstructs the Node error (os). * - `denyVariant` -- for interfaces whose functions return `result` with an diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs.ts new file mode 100644 index 000000000..308e56bf8 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs.ts @@ -0,0 +1,16 @@ +import * as host from "jco:node/fs@0.1.0"; +import { createVfs } from "./vfs/core.js"; + +const vfs = createVfs(host); + +export const create = vfs.create; + +export const VirtualFileSystem = vfs.VirtualFileSystem; + +export const VirtualProvider = vfs.VirtualProvider; + +export const MemoryProvider = vfs.MemoryProvider; + +export const RealFSProvider = vfs.RealFSProvider; + +export default vfs; diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/core.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/core.ts new file mode 100644 index 000000000..640123d23 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/core.ts @@ -0,0 +1,48 @@ +/** + * Factory overload adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/vfs.js (MIT). + * The real provider is injected; memory never consults host capabilities. + */ +import { FsCore } from "../../24.x.x/fs/core.js"; +import type { FsHost } from "../../24.x.x/fs/types.js"; +import type { HostImports } from "../../24.x.x/internal/wit-types.js"; +import { VirtualFileSystem } from "./file-system.js"; +import { VirtualProvider } from "./provider.js"; +import { MemoryProvider } from "./memory.js"; +import { createRealFSProvider } from "./real.js"; +import type { RealProviderConstructor } from "./real.js"; +import type { VfsOptions } from "./types.js"; + +export interface VfsModule { + create: typeof create; + + VirtualFileSystem: typeof VirtualFileSystem; + + VirtualProvider: typeof VirtualProvider; + + MemoryProvider: typeof MemoryProvider; + + RealFSProvider: RealProviderConstructor; +} + +export function create(provider?: VirtualProvider | null, options?: VfsOptions): VirtualFileSystem; +export function create(options: VfsOptions): VirtualFileSystem; +export function create( + provider?: VirtualProvider | VfsOptions | null, + options?: VfsOptions, +): VirtualFileSystem { + if (provider != null && !(provider instanceof VirtualProvider) && typeof provider === "object") { + options = provider; + provider = undefined; + } + return new VirtualFileSystem(provider, options); +} + +export function createVfs( + host: HostImports | ((rootPath: string) => HostImports), +): VfsModule { + const RealFSProvider = createRealFSProvider( + (rootPath) => new FsCore(typeof host === "function" ? host(rootPath) : host), + ); + return { create, VirtualFileSystem, VirtualProvider, MemoryProvider, RealFSProvider }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/errors.ts new file mode 100644 index 000000000..668ad3e37 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/errors.ts @@ -0,0 +1,93 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/errors.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: reuse portable system errors instead of the native uv binding. + */ +import { systemError, unsupportedNodeApi } from "../../24.x.x/errors/core.js"; + +const ERRORS = { + ENOENT: [-2, "no such file or directory"], + ENOTDIR: [-20, "not a directory"], + ENOTEMPTY: [-39, "directory not empty"], + EISDIR: [-21, "illegal operation on a directory"], + EBADF: [-9, "bad file descriptor"], + EEXIST: [-17, "file already exists"], + EROFS: [-30, "read-only file system"], + EINVAL: [-22, "invalid argument"], + ELOOP: [-40, "too many symbolic links encountered"], + EACCES: [-13, "permission denied"], + EXDEV: [-18, "cross-device link not permitted"], +} as const; + +export function vfsError( + code: keyof typeof ERRORS, + syscall: string, + path?: string, +): Error & { code: string } { + const [errno, description] = ERRORS[code]; + + const suffix = path === undefined ? "" : ` '${path}'`; + return systemError({ + code, + errno, + syscall, + path, + message: `${code}: ${description}, ${syscall}${suffix}`, + }); +} + +export class ERR_METHOD_NOT_IMPLEMENTED extends Error { + readonly code = "ERR_METHOD_NOT_IMPLEMENTED"; + + constructor(method: string) { + super(`The ${method} method is not implemented`); + } +} + +export class ERR_INVALID_STATE extends Error { + readonly code = "ERR_INVALID_STATE"; + + constructor(message: string) { + super(message); + } +} + +export function unsupported(api: string): never { + throw unsupportedNodeApi( + `node:vfs ${api}`, + "component VFS does not provide native filesystem hooks, streams or watchers", + ); +} +export const createENOENT = (syscall: string, path?: string): Error & { code: string } => + vfsError("ENOENT", syscall, path); + +export const createENOTDIR = (syscall: string, path?: string): Error & { code: string } => + vfsError("ENOTDIR", syscall, path); + +export const createENOTEMPTY = (syscall: string, path?: string): Error & { code: string } => + vfsError("ENOTEMPTY", syscall, path); + +export const createEISDIR = (syscall: string, path?: string): Error & { code: string } => + vfsError("EISDIR", syscall, path); + +export const createEBADF = (syscall: string, path?: string): Error & { code: string } => + vfsError("EBADF", syscall, path); + +export const createEEXIST = (syscall: string, path?: string): Error & { code: string } => + vfsError("EEXIST", syscall, path); + +export const createEROFS = (syscall: string, path?: string): Error & { code: string } => + vfsError("EROFS", syscall, path); + +export const createEINVAL = (syscall: string, path?: string): Error & { code: string } => + vfsError("EINVAL", syscall, path); + +export const createELOOP = (syscall: string, path?: string): Error & { code: string } => + vfsError("ELOOP", syscall, path); + +export const createEACCES = (syscall: string, path?: string): Error & { code: string } => + vfsError("EACCES", syscall, path); + +export const createEXDEV = (syscall: string, path?: string): Error & { code: string } => + vfsError("EXDEV", syscall, path); diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/fd.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/fd.ts new file mode 100644 index 000000000..b80afd5df --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/fd.ts @@ -0,0 +1,25 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/fd.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed guest-local virtual descriptor registry. + */ +import type { VirtualFileHandle } from "./file-handle.js"; + +let nextFd = 0; + +const openFiles = new Map(); + +export function openVirtualFd(entry: VirtualFileHandle): number { + const fd = 0x40000000 | nextFd++; + openFiles.set(fd, { entry }); + return fd; +} + +export function getVirtualFd(fd: number): { entry: VirtualFileHandle } | undefined { + return openFiles.get(fd); +} + +export function closeVirtualFd(fd: number): boolean { + return openFiles.delete(fd); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-handle.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-handle.ts new file mode 100644 index 000000000..7227b8c53 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-handle.ts @@ -0,0 +1,232 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/file_handle.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed handles, shared Buffer; preserve upstream descriptor and append behavior. + */ +import { Buffer as NodeBuffer } from "node:buffer"; +import type { Buffer } from "./types.js"; +import { createEBADF, ERR_METHOD_NOT_IMPLEMENTED } from "./errors.js"; +import type { + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + Position, +} from "./types.js"; + +/** + * Base class for virtual file handles. + * Provides the interface that file handles must implement. + */ +export class VirtualFileHandle { + #pathValue: string; + + #flagsValue: string; + + #modeValue: number; + + #positionValue: number; + + #closedValue: boolean; + + constructor(path: string, flags: string, mode?: number) { + this.#pathValue = path; + this.#flagsValue = flags; + this.#modeValue = mode ?? 0o644; + this.#positionValue = 0; + this.#closedValue = false; + } + + get path(): string { + return this.#pathValue; + } + + get flags(): string { + return this.#flagsValue; + } + + get mode(): number { + return this.#modeValue; + } + + get position(): number { + return this.#positionValue; + } + + set position(pos: number) { + this.#positionValue = pos; + } + + get closed(): boolean { + return this.#closedValue; + } + + #checkClosed(syscall: string): void { + if (this.#closedValue) { + throw createEBADF(syscall); + } + } + + async read( + _buffer: Buffer, + _offset: number, + _length: number, + _position?: Position, + ): Promise<{ bytesRead: number; buffer: Buffer }> { + this.#checkClosed("read"); + throw new ERR_METHOD_NOT_IMPLEMENTED("read"); + } + + readSync(_buffer: Buffer, _offset: number, _length: number, _position?: Position): number { + this.#checkClosed("read"); + throw new ERR_METHOD_NOT_IMPLEMENTED("readSync"); + } + + async write( + _buffer: Buffer, + _offset: number, + _length: number, + _position?: Position, + ): Promise<{ bytesWritten: number; buffer: Buffer }> { + this.#checkClosed("write"); + throw new ERR_METHOD_NOT_IMPLEMENTED("write"); + } + + writeSync(_buffer: Buffer, _offset: number, _length: number, _position?: Position): number { + this.#checkClosed("write"); + throw new ERR_METHOD_NOT_IMPLEMENTED("writeSync"); + } + + async readFile(_options?: ReadFileOptions): Promise { + this.#checkClosed("read"); + throw new ERR_METHOD_NOT_IMPLEMENTED("readFile"); + } + + readFileSync(_options?: ReadFileOptions): Buffer | string { + this.#checkClosed("read"); + throw new ERR_METHOD_NOT_IMPLEMENTED("readFileSync"); + } + + async writeFile(_data: FileData, _options?: FileOptions): Promise { + this.#checkClosed("write"); + throw new ERR_METHOD_NOT_IMPLEMENTED("writeFile"); + } + + writeFileSync(_data: FileData, _options?: FileOptions): void { + this.#checkClosed("write"); + throw new ERR_METHOD_NOT_IMPLEMENTED("writeFileSync"); + } + + async stat(_options?: StatOptions): Promise { + this.#checkClosed("fstat"); + throw new ERR_METHOD_NOT_IMPLEMENTED("stat"); + } + + statSync(_options?: StatOptions): FileStats { + this.#checkClosed("fstat"); + throw new ERR_METHOD_NOT_IMPLEMENTED("statSync"); + } + + async truncate(_len: number = 0): Promise { + this.#checkClosed("ftruncate"); + throw new ERR_METHOD_NOT_IMPLEMENTED("truncate"); + } + + truncateSync(_len: number = 0): void { + this.#checkClosed("ftruncate"); + throw new ERR_METHOD_NOT_IMPLEMENTED("truncateSync"); + } + + async chmod(): Promise {} + + async chown(): Promise {} + + async utimes(): Promise {} + + async datasync(): Promise {} + + async sync(): Promise {} + + async readv( + buffers: Buffer[], + position?: number | null, + ): Promise<{ bytesRead: number; buffers: Buffer[] }> { + this.#checkClosed("readv"); + let totalRead = 0; + for (let i = 0; i < buffers.length; i++) { + const buf = buffers[i]; + + const pos = position != null ? position + totalRead : null; + + const { bytesRead } = await this.read(buf, 0, buf.byteLength, pos); + totalRead += bytesRead; + if (bytesRead < buf.byteLength) { + break; + } + } + return { bytesRead: totalRead, buffers }; + } + + async writev( + buffers: Buffer[], + position?: number | null, + ): Promise<{ bytesWritten: number; buffers: Buffer[] }> { + this.#checkClosed("writev"); + let totalWritten = 0; + for (let i = 0; i < buffers.length; i++) { + const buf = buffers[i]; + + const pos = position != null ? position + totalWritten : null; + + const { bytesWritten } = await this.write(buf, 0, buf.byteLength, pos); + totalWritten += bytesWritten; + if (bytesWritten < buf.byteLength) { + break; + } + } + return { bytesWritten: totalWritten, buffers }; + } + + async appendFile(data: FileData, options?: FileOptions): Promise { + this.#checkClosed("appendFile"); + const buffer = + typeof data === "string" + ? NodeBuffer.from(data, options?.encoding ?? undefined) + : NodeBuffer.from(data); + await this.write(buffer, 0, buffer.length, null); + } + + readableWebStream(): never { + throw new ERR_METHOD_NOT_IMPLEMENTED("readableWebStream"); + } + + readLines(): never { + throw new ERR_METHOD_NOT_IMPLEMENTED("readLines"); + } + + createReadStream(): never { + throw new ERR_METHOD_NOT_IMPLEMENTED("createReadStream"); + } + + createWriteStream(): never { + throw new ERR_METHOD_NOT_IMPLEMENTED("createWriteStream"); + } + + [Symbol.dispose](): void { + this.closeSync(); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + async close(): Promise { + this.#closedValue = true; + } + + closeSync(): void { + this.#closedValue = true; + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-system.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-system.ts new file mode 100644 index 000000000..df1b7d1f3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/file-system.ts @@ -0,0 +1,772 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/file_system.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed facade, portable paths/Dir; native mount, stream and watcher operations fail explicitly. + */ +import { createVfsPromises, type VfsPromises } from "./promises.js"; +import { invokeCallback, deferCallback, hasCode } from "./validation.js"; +import type { Buffer } from "./types.js"; +import { path as pathPosix } from "./path.js"; +import { Dir, Dirent } from "../../24.x.x/fs/classes.js"; +import { invalidArgType } from "../../24.x.x/errors/core.js"; +import { VirtualProvider } from "./provider.js"; +import { MemoryProvider } from "./memory.js"; +import { openVirtualFd, getVirtualFd, closeVirtualFd } from "./fd.js"; +import { createENOENT, createEBADF, createEISDIR, unsupported } from "./errors.js"; +import type { + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + DirectoryOptions, + DirectoryEntries, + MkdirOptions, + RemoveOptions, + VfsOptions, + Position, + Time, + Callback, +} from "./types.js"; + +const path = pathPosix; + +const { isAbsolute, resolve: resolvePath, join: joinPath } = pathPosix; + +const MathRandom = Math.random; + +const isUnderMountPoint = (value: string, root: string): boolean => + value === root || value.startsWith(root + "/"); +const getRelativePath = (value: string, root: string): string => value.slice(root.length) || "/"; + +export class VirtualFileSystem { + #providerValue: VirtualProvider; + + #mountPointValue: string | null; + + #mountedValue: boolean; + + #promisesValue: VfsPromises | null; + + constructor(providerOrOptions?: VirtualProvider | VfsOptions | null, options: VfsOptions = {}) { + const provider = + providerOrOptions && + "openSync" in providerOrOptions && + typeof providerOrOptions.openSync === "function" + ? (providerOrOptions as VirtualProvider) + : undefined; + if (providerOrOptions && !provider) { + options = providerOrOptions as VfsOptions; + } + if ( + options.emitExperimentalWarning !== undefined && + typeof options.emitExperimentalWarning !== "boolean" + ) { + throw invalidArgType( + "options.emitExperimentalWarning", + "boolean", + options.emitExperimentalWarning, + ); + } + this.#providerValue = provider ?? new MemoryProvider(); + this.#mountPointValue = null; + this.#mountedValue = false; + this.#promisesValue = null; + } + + get provider(): VirtualProvider { + return this.#providerValue; + } + + get mountPoint(): string | null { + return this.#mountPointValue; + } + + get mounted(): boolean { + return this.#mountedValue; + } + + get readonly(): boolean { + return this.#providerValue.readonly; + } + + mount(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.mount"); + } + + unmount(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.unmount"); + } + + [Symbol.dispose](): void { + if (this.#mountedValue) { + this.unmount(); + } + } + + shouldHandle(inputPath: string): boolean { + if (!this.#mountedValue || !this.#mountPointValue) { + return false; + } + const normalized = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath); + return isUnderMountPoint(normalized, this.#mountPointValue); + } + + #toProviderPath(inputPath: string): string { + if (this.#mountedValue && this.#mountPointValue) { + const resolved = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath); + if (!isUnderMountPoint(resolved, this.#mountPointValue)) { + throw createENOENT("open", inputPath); + } + return getRelativePath(resolved, this.#mountPointValue); + } + return pathPosix.normalize(inputPath); + } + + #toMountedPath(providerPath: string): string { + if (this.#mountedValue && this.#mountPointValue) { + return path.join(this.#mountPointValue, providerPath); + } + return providerPath; + } + + existsSync(filePath: string): boolean { + try { + const providerPath = this.#toProviderPath(filePath); + return this.#providerValue.existsSync(providerPath); + } catch { + return false; + } + } + + statSync(filePath: string, options?: StatOptions): FileStats { + const providerPath = this.#toProviderPath(filePath); + return this.#providerValue.statSync(providerPath, options); + } + + lstatSync(filePath: string, options?: StatOptions): FileStats { + const providerPath = this.#toProviderPath(filePath); + return this.#providerValue.lstatSync(providerPath, options); + } + + readFileSync(filePath: string, options?: ReadFileOptions): Buffer | string { + const providerPath = this.#toProviderPath(filePath); + return this.#providerValue.readFileSync(providerPath, options); + } + + writeFileSync(filePath: string, data: FileData, options?: FileOptions): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.writeFileSync(providerPath, data, options); + } + + appendFileSync(filePath: string, data: FileData, options?: FileOptions): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.appendFileSync(providerPath, data, options); + } + + readdirSync(dirPath: string, options?: DirectoryOptions): DirectoryEntries { + const providerPath = this.#toProviderPath(dirPath); + + const result = this.#providerValue.readdirSync(providerPath, options); + + // Fix Dirent parentPath from provider-relative to actual VFS path + if (options?.withFileTypes === true) { + const recursive = options?.recursive === true; + for (let i = 0; i < result.length; i++) { + const dirent = result[i] as Dirent; + if (recursive) { + // In recursive mode, name may contain slashes (e.g. 'a/b.txt'). + // Fix to basename only and set correct parentPath. + const slashIdx = dirent.name.lastIndexOf("/"); + if (slashIdx !== -1) { + const subdir = dirent.name.slice(0, slashIdx); + result[i] = new Dirent( + dirent.name.slice(slashIdx + 1), + joinPath(dirPath, subdir), + dirent.isDirectory() ? "directory" : dirent.isSymbolicLink() ? "symlink" : "file", + ); + } else { + result[i] = new Dirent( + dirent.name, + dirPath, + dirent.isDirectory() ? "directory" : dirent.isSymbolicLink() ? "symlink" : "file", + ); + } + } else { + result[i] = new Dirent( + dirent.name, + dirPath, + dirent.isDirectory() ? "directory" : dirent.isSymbolicLink() ? "symlink" : "file", + ); + } + } + } + + return result; + } + + mkdirSync(dirPath: string, options?: MkdirOptions): string | undefined { + const providerPath = this.#toProviderPath(dirPath); + return this.#providerValue.mkdirSync(providerPath, options); + } + + rmdirSync(dirPath: string): void { + const providerPath = this.#toProviderPath(dirPath); + this.#providerValue.rmdirSync(providerPath); + } + + unlinkSync(filePath: string): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.unlinkSync(providerPath); + } + + renameSync(oldPath: string, newPath: string): void { + const oldProviderPath = this.#toProviderPath(oldPath); + + const newProviderPath = this.#toProviderPath(newPath); + this.#providerValue.renameSync(oldProviderPath, newProviderPath); + } + + copyFileSync(src: string, dest: string, mode?: number): void { + const srcProviderPath = this.#toProviderPath(src); + + const destProviderPath = this.#toProviderPath(dest); + this.#providerValue.copyFileSync(srcProviderPath, destProviderPath, mode); + } + + realpathSync(filePath: string, options?: DirectoryOptions): string { + const providerPath = this.#toProviderPath(filePath); + + const realProviderPath = this.#providerValue.realpathSync(providerPath, options); + return this.#toMountedPath(realProviderPath); + } + + readlinkSync(linkPath: string, options?: DirectoryOptions): string | Buffer { + const providerPath = this.#toProviderPath(linkPath); + return this.#providerValue.readlinkSync(providerPath, options); + } + + symlinkSync(target: string, path: string, type?: string): void { + const providerPath = this.#toProviderPath(path); + this.#providerValue.symlinkSync(target, providerPath, type); + } + + accessSync(filePath: string, mode?: number): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.accessSync(providerPath, mode); + } + + rmSync(filePath: string, options?: RemoveOptions): void { + const recursive = options?.recursive === true; + + const force = options?.force === true; + + let stats; + try { + stats = this.lstatSync(filePath); + } catch (err) { + if (force && hasCode(err, "ENOENT")) { + return; + } + throw err; + } + + // Symlinks should be unlinked directly, never recursed into + if (stats.isSymbolicLink()) { + this.unlinkSync(filePath); + return; + } + + if (stats.isDirectory()) { + if (!recursive) { + throw createEISDIR("rm", filePath); + } + const entries = this.readdirSync(filePath); + for (let i = 0; i < entries.length; i++) { + this.rmSync(joinPath(filePath, String(entries[i])), options); + } + this.rmdirSync(filePath); + } else { + this.unlinkSync(filePath); + } + } + + truncateSync(filePath: string, len: number = 0): void { + if (len < 0) { + len = 0; + } + const providerPath = this.#toProviderPath(filePath); + + const handle = this.#providerValue.openSync(providerPath, "r+"); + try { + handle.truncateSync(len); + } finally { + handle.closeSync(); + } + } + + ftruncateSync(fd: number, len: number = 0): void { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF("ftruncate"); + } + vfd.entry.truncateSync(len); + } + + linkSync(existingPath: string, newPath: string): void { + const existingProviderPath = this.#toProviderPath(existingPath); + + const newProviderPath = this.#toProviderPath(newPath); + this.#providerValue.linkSync(existingProviderPath, newProviderPath); + } + + chmodSync(filePath: string, mode?: number): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.chmodSync(providerPath, mode!); + } + + chownSync(filePath: string, uid: number, gid: number): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.chownSync(providerPath, uid, gid); + } + + lchownSync(filePath: string, uid: number, gid: number): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.lchownSync(providerPath, uid, gid); + } + + utimesSync(filePath: string, atime: Time, mtime: Time): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.utimesSync(providerPath, atime, mtime); + } + + lutimesSync(filePath: string, atime: Time, mtime: Time): void { + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.lutimesSync(providerPath, atime, mtime); + } + + mkdtempSync(prefix: string): string { + const providerPrefix = this.#toProviderPath(prefix); + // Generate random 6-character suffix like Node does + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += chars[(MathRandom() * chars.length) | 0]; + } + const dirPath = providerPrefix + suffix; + this.#providerValue.mkdirSync(dirPath); + return this.#toMountedPath(dirPath); + } + + opendirSync(dirPath: string, options?: DirectoryOptions): Dir { + const entries = this.readdirSync(dirPath, { + withFileTypes: true, + recursive: options?.recursive, + }); + return new Dir(dirPath, entries as Dirent[]); + } + + openAsBlob(filePath: string, options?: { type?: string }): Blob { + const providerPath = this.#toProviderPath(filePath); + + const content = this.#providerValue.readFileSync(providerPath); + + const type = options?.type || ""; + return new Blob([typeof content === "string" ? content : new Uint8Array(content)], { type }); + } + + openSync(filePath: string, flags: string | number = "r", mode?: number): number { + const providerPath = this.#toProviderPath(filePath); + + const handle = this.#providerValue.openSync(providerPath, flags, mode); + return openVirtualFd(handle); + } + + closeSync(fd: number): void { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF("close"); + } + vfd.entry.closeSync(); + closeVirtualFd(fd); + } + + readSync( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): number { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF("read"); + } + return vfd.entry.readSync(buffer, offset, length, position); + } + + writeSync( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): number { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF("write"); + } + return vfd.entry.writeSync(buffer, offset, length, position); + } + + fstatSync(fd: number, options?: StatOptions): FileStats { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF("fstat"); + } + return vfd.entry.statSync(options); + } + + readFile( + filePath: string, + options?: ReadFileOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.readFile(this.#toProviderPath(filePath), options).then( + (data) => invokeCallback(callback, null, data), + (err) => invokeCallback(callback, err), + ); + } + + writeFile( + filePath: string, + data: FileData, + options?: FileOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.writeFile(this.#toProviderPath(filePath), data, options).then( + () => invokeCallback(callback, null), + (err) => invokeCallback(callback, err), + ); + } + + stat( + filePath: string, + options?: StatOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.stat(this.#toProviderPath(filePath), options).then( + (stats) => invokeCallback(callback, null, stats), + (err) => invokeCallback(callback, err), + ); + } + + lstat( + filePath: string, + options?: StatOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.lstat(this.#toProviderPath(filePath), options).then( + (stats) => invokeCallback(callback, null, stats), + (err) => invokeCallback(callback, err), + ); + } + + readdir( + dirPath: string, + options?: DirectoryOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.readdir(this.#toProviderPath(dirPath), options).then( + (entries) => invokeCallback(callback, null, entries), + (err) => invokeCallback(callback, err), + ); + } + + realpath( + filePath: string, + options?: DirectoryOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.realpath(this.#toProviderPath(filePath), options).then( + (realPath) => invokeCallback(callback, null, this.#toMountedPath(realPath)), + (err) => invokeCallback(callback, err), + ); + } + + readlink( + linkPath: string, + options?: DirectoryOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + this.#providerValue.readlink(this.#toProviderPath(linkPath), options).then( + (target) => invokeCallback(callback, null, target), + (err) => invokeCallback(callback, err), + ); + } + + access(filePath: string, mode?: number | Callback, callback?: Callback): void { + if (typeof mode === "function") { + callback = mode; + mode = undefined; + } + + this.#providerValue.access(this.#toProviderPath(filePath), mode).then( + () => invokeCallback(callback, null), + (err) => invokeCallback(callback, err), + ); + } + + open( + filePath: string, + flags?: string | number | Callback, + mode?: number | Callback, + callback?: Callback, + ): void { + if (typeof flags === "function") { + callback = flags; + flags = "r"; + mode = undefined; + } else if (typeof mode === "function") { + callback = mode; + mode = undefined; + } + + const providerPath = this.#toProviderPath(filePath); + this.#providerValue.open(providerPath, flags, mode).then( + (handle) => { + const fd = openVirtualFd(handle); + invokeCallback(callback, null, fd); + }, + (err) => invokeCallback(callback, err), + ); + } + + close(fd: number, callback?: Callback): void { + const vfd = getVirtualFd(fd); + if (!vfd) { + deferCallback(callback, createEBADF("close")); + return; + } + + vfd.entry.close().then( + () => { + closeVirtualFd(fd); + invokeCallback(callback, null); + }, + (err) => invokeCallback(callback, err), + ); + } + + read( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position?: Position, + callback?: (error: Error | null, count?: number, buffer?: Buffer) => void, + ): void { + const vfd = getVirtualFd(fd); + if (!vfd) { + deferCallback(callback, createEBADF("read")); + return; + } + + vfd.entry.read(buffer, offset, length, position).then( + ({ bytesRead }) => invokeCallback(callback, null, bytesRead, buffer), + (err) => invokeCallback(callback, err), + ); + } + + write( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position?: Position, + callback?: (error: Error | null, count?: number, buffer?: Buffer) => void, + ): void { + const vfd = getVirtualFd(fd); + if (!vfd) { + deferCallback(callback, createEBADF("write")); + return; + } + + vfd.entry.write(buffer, offset, length, position).then( + ({ bytesWritten }) => invokeCallback(callback, null, bytesWritten, buffer), + (err) => invokeCallback(callback, err), + ); + } + + rm(filePath: string, options?: RemoveOptions | Callback, callback?: Callback): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + try { + this.rmSync(filePath, options); + deferCallback(callback, null); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + fstat( + fd: number, + options?: StatOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + const vfd = getVirtualFd(fd); + if (!vfd) { + deferCallback(callback, createEBADF("fstat")); + return; + } + + vfd.entry.stat(options).then( + (stats) => invokeCallback(callback, null, stats), + (err) => invokeCallback(callback, err), + ); + } + + truncate(filePath: string, len?: number | Callback, callback?: Callback): void { + if (typeof len === "function") { + callback = len; + len = 0; + } + try { + this.truncateSync(filePath, len); + deferCallback(callback, null); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + ftruncate(fd: number, len?: number | Callback, callback?: Callback): void { + if (typeof len === "function") { + callback = len; + len = 0; + } + try { + this.ftruncateSync(fd, len); + deferCallback(callback, null); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + link(existingPath: string, newPath: string, callback?: Callback): void { + try { + this.linkSync(existingPath, newPath); + deferCallback(callback, null); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + mkdtemp( + prefix: string, + options?: DirectoryOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + try { + const dirPath = this.mkdtempSync(prefix); + deferCallback(callback, null, dirPath); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + opendir( + dirPath: string, + options?: DirectoryOptions | Callback, + callback?: Callback, + ): void { + if (typeof options === "function") { + callback = options; + options = undefined; + } + try { + const dir = this.opendirSync(dirPath, options); + deferCallback(callback, null, dir); + } catch (err) { + deferCallback(callback, err instanceof Error ? err : new Error(String(err))); + } + } + + createReadStream(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.createReadStream"); + } + + createWriteStream(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.createWriteStream"); + } + + watch(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.watch"); + } + + watchFile(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.watchFile"); + } + + unwatchFile(..._args: unknown[]): never { + return unsupported("VirtualFileSystem.unwatchFile"); + } + + get promises(): VfsPromises { + if (this.#promisesValue === null) { + this.#promisesValue = createVfsPromises( + this.#providerValue, + (path) => this.#toProviderPath(path), + (path) => this.#toMountedPath(path), + ); + } + return this.#promisesValue; + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory-file-handle.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory-file-handle.ts new file mode 100644 index 000000000..629c6107a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory-file-handle.ts @@ -0,0 +1,322 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/file_handle.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed handles, shared Buffer; preserve upstream descriptor and append behavior. + */ +import { Buffer as NodeBuffer } from "node:buffer"; +import type { Buffer } from "./types.js"; +import { createEBADF, ERR_INVALID_STATE } from "./errors.js"; +import type { MemoryEntry } from "./memory.js"; +import type { + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + Position, +} from "./types.js"; + +import { VirtualFileHandle } from "./file-handle.js"; + +const DateNow = Date.now; + +const MathMax = Math.max; + +const MathMin = Math.min; + +function isCurrentPosition(position: Position | undefined): boolean { + return position === null || position === undefined || position === -1; +} + +/** + * A file handle for in-memory file content. + * Used by MemoryProvider and similar providers. + */ +export class MemoryFileHandle extends VirtualFileHandle { + #content: Buffer; + + #size: number; + + #entry: MemoryEntry; + + #getStats: (size: number) => FileStats; + + #checkClosed(syscall: string): void { + if (this.closed) { + throw createEBADF(syscall); + } + } + + constructor( + path: string, + flags: string, + mode: number | undefined, + content: Buffer, + entry: MemoryEntry, + getStats: (size: number) => FileStats, + ) { + super(path, flags, mode); + this.#content = content; + this.#size = content.length; + this.#entry = entry; + this.#getStats = getStats; + + // Handle different open modes + if (flags === "w" || flags === "w+" || flags === "wx" || flags === "wx+") { + // Write mode: truncate + this.#content = NodeBuffer.alloc(0); + this.#size = 0; + if (entry) { + entry.content = this.#content; + } + } else if (flags === "a" || flags === "a+" || flags === "ax" || flags === "ax+") { + // Append mode: position at end + this.position = this.#size; + } + } + + #checkWritable(): void { + if (this.flags === "r") { + throw createEBADF("write"); + } + } + + #checkReadable(): void { + const f = this.flags; + if (f === "w" || f === "a" || f === "wx" || f === "ax") { + throw createEBADF("read"); + } + } + + #isAppend(): boolean { + const f = this.flags; + return f === "a" || f === "a+" || f === "ax" || f === "ax+"; + } + + get content(): Buffer { + // If entry has a dynamic content provider, get fresh content sync + if (this.#entry?.isDynamic && this.#entry.isDynamic()) { + return this.#entry.getContentSync(); + } + return this.#content.subarray(0, this.#size); + } + + async getContentAsync(): Promise { + // If entry has a dynamic content provider, get fresh content async + if (this.#entry?.getContentAsync) { + return this.#entry.getContentAsync(); + } + return this.#content; + } + + readSync(buffer: Buffer, offset: number, length: number, position?: Position): number { + this.#checkClosed("read"); + this.#checkReadable(); + + // Get content (resolves dynamic content providers) + const content = this.content; + + const useCurrentPosition = isCurrentPosition(position); + + const readPos = useCurrentPosition ? this.position : Number(position); + + const available = content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + content.copy(buffer, offset, readPos, readPos + bytesToRead); + + // Update position if not using explicit position + if (useCurrentPosition) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + async read( + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): Promise<{ bytesRead: number; buffer: Buffer }> { + const bytesRead = this.readSync(buffer, offset, length, position); + return { bytesRead, buffer }; + } + + writeSync(buffer: Buffer, offset: number, length: number, position?: Position): number { + this.#checkClosed("write"); + this.#checkWritable(); + + // In append mode, always write at the end + const useCurrentPosition = isCurrentPosition(position); + + const writePos = this.#isAppend() + ? this.#size + : useCurrentPosition + ? this.position + : Number(position); + const data = buffer.subarray(offset, offset + length); + + // Expand buffer if needed (geometric doubling for amortized O(1) appends) + const neededSize = writePos + length; + if (neededSize > this.#content.length) { + const newCapacity = MathMax(neededSize, this.#content.length * 2); + + const newContent = NodeBuffer.alloc(newCapacity); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } + + // Write the data + this.#content.set(data, writePos); + + // Update actual content size + if (neededSize > this.#size) { + this.#size = neededSize; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + + // Update position if not using explicit position + if (useCurrentPosition) { + this.position = writePos + length; + } + + return length; + } + + async write( + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): Promise<{ bytesWritten: number; buffer: Buffer }> { + const bytesWritten = this.writeSync(buffer, offset, length, position); + return { bytesWritten, buffer }; + } + + readFileSync(options?: ReadFileOptions): Buffer | string { + this.#checkClosed("read"); + this.#checkReadable(); + + // Get content (resolves dynamic content providers) + const content = this.content; + + const encoding = typeof options === "string" ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return NodeBuffer.from(content); + } + + async readFile(options?: ReadFileOptions): Promise { + this.#checkClosed("read"); + this.#checkReadable(); + + // Get content asynchronously (supports async content providers) + const content = await this.getContentAsync(); + + const encoding = typeof options === "string" ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return NodeBuffer.from(content); + } + + writeFileSync(data: FileData, options?: FileOptions): void { + this.#checkClosed("write"); + this.#checkWritable(); + + const buffer = + typeof data === "string" + ? NodeBuffer.from(data, options?.encoding ?? undefined) + : NodeBuffer.from(data); + + // In append mode, append to existing content + if (this.#isAppend()) { + const neededSize = this.#size + buffer.length; + if (neededSize > this.#content.length) { + const newCapacity = MathMax(neededSize, this.#content.length * 2); + + const newContent = NodeBuffer.alloc(newCapacity); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } + this.#content.set(buffer, this.#size); + this.#size = neededSize; + } else { + this.#content = NodeBuffer.from(buffer); + this.#size = buffer.length; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + + this.position = this.#size; + } + + async writeFile(data: FileData, options?: FileOptions): Promise { + this.writeFileSync(data, options); + } + + statSync(_options?: StatOptions): FileStats { + this.#checkClosed("fstat"); + if (this.#entry) { + return this.#getStats(this.#size); + } + throw new ERR_INVALID_STATE("stats not available"); + } + + async stat(options?: StatOptions): Promise { + return this.statSync(options); + } + + truncateSync(len: number = 0): void { + this.#checkClosed("ftruncate"); + this.#checkWritable(); + + if (len < this.#size) { + // Zero out truncated region to avoid stale data + this.#content.fill(0, len, this.#size); + this.#size = len; + } else if (len > this.#size) { + if (len > this.#content.length) { + const newContent = NodeBuffer.alloc(len); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } else { + // Buffer has enough capacity, just zero-fill the extension + this.#content.fill(0, this.#size, len); + } + this.#size = len; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + } + + async truncate(len: number = 0): Promise { + this.truncateSync(len); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory.ts new file mode 100644 index 000000000..858521716 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/memory.ts @@ -0,0 +1,991 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/providers/memory.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed tree and existing value objects; no native watcher or inaccessible lazy-population hooks. + */ +import { hasCode } from "./validation.js"; +import { Buffer as NodeBuffer } from "node:buffer"; +import type { Buffer } from "./types.js"; +import { path as pathPosix } from "./path.js"; +import { Dirent } from "../../24.x.x/fs/classes.js"; +import { O_APPEND, O_CREAT, O_EXCL, O_RDWR, O_TRUNC, O_WRONLY } from "../../24.x.x/fs/constants.js"; +import { VirtualProvider } from "./provider.js"; +import type { VirtualFileHandle } from "./file-handle.js"; +import { MemoryFileHandle } from "./memory-file-handle.js"; +import { createFileStats, createDirectoryStats, createSymlinkStats } from "./stats.js"; +import { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEEXIST, + createEINVAL, + createELOOP, + createEROFS, + ERR_INVALID_STATE, + unsupported, +} from "./errors.js"; +import type { + FileStats, + DirectoryOptions, + DirectoryEntries, + MkdirOptions, + StatOptions, + Time, +} from "./types.js"; + +const DateNow = Date.now; + +const isPromise = (value: unknown): value is Promise => value instanceof Promise; + +const startsWith = (value: string, prefix: string): boolean => value.startsWith(prefix); + +const UV_DIRENT_FILE = "file"; + +const UV_DIRENT_DIR = "directory"; + +const UV_DIRENT_LINK = "symlink"; + +function normalizeFlags(flags: string | number): string { + if (typeof flags === "string") { + return flags; + } + if (typeof flags !== "number") { + return "r"; + } + + const rdwr = (flags & O_RDWR) !== 0; + + const append = (flags & O_APPEND) !== 0; + + const excl = (flags & O_EXCL) !== 0; + + const write = (flags & O_WRONLY) !== 0 || (flags & O_CREAT) !== 0 || (flags & O_TRUNC) !== 0; + + if (append) { + return "a" + (excl ? "x" : "") + (rdwr ? "+" : ""); + } + if (write) { + return "w" + (excl ? "x" : "") + (rdwr ? "+" : ""); + } + if (rdwr) { + return "r+"; + } + return "r"; +} + +/** + * Converts a time argument (Date, number, or string) to milliseconds. + * Numbers are treated as seconds (matching Node.js utimes convention). + * @param {Date|number|string} time The time value + * @returns {number} Milliseconds since epoch + */ +function toMs(time: Time): number { + if (typeof time === "number") { + return time * 1000; + } + if (typeof time === "string") { + return DateNow(); + } // Fallback for string timestamps + if (typeof time === "object" && time !== null) { + return +time; + } + return time; +} + +// Entry types +const TYPE_FILE = 0; + +const TYPE_DIR = 1; + +const TYPE_SYMLINK = 2; + +// Maximum symlink resolution depth +const kMaxSymlinkDepth = 40; + +/** + * Internal entry representation for MemoryProvider. + */ +export class MemoryEntry { + type: number; + + mode: number; + + content: Buffer; + + contentProvider: (() => string | Buffer | Promise) | null; + + target: string; + + children: Map; + + nlink: number; + + uid: number; + + gid: number; + + atime: number; + + mtime: number; + + ctime: number; + + birthtime: number; + + constructor(type: number, options: MkdirOptions = {}) { + this.type = type; + this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644); + this.content = NodeBuffer.alloc(0); // For files - static Buffer content + this.contentProvider = null; // For files - dynamic content function + this.target = ""; // For symlinks + this.children = new Map(); // For directories + this.nlink = 1; + this.uid = 0; + this.gid = 0; + const now = DateNow(); + this.atime = now; + this.mtime = now; + this.ctime = now; + this.birthtime = now; + } + + getContentSync(): Buffer { + if (this.contentProvider !== null) { + const result = this.contentProvider(); + if (isPromise(result)) { + // It's a Promise - can't use sync API + throw new ERR_INVALID_STATE("cannot use sync API with async content provider"); + } + return typeof result === "string" ? NodeBuffer.from(result) : result; + } + return this.content; + } + + async getContentAsync(): Promise { + if (this.contentProvider !== null) { + const result = await this.contentProvider(); + return typeof result === "string" ? NodeBuffer.from(result) : result; + } + return this.content; + } + + isDynamic(): boolean { + return this.contentProvider !== null; + } + + isFile(): boolean { + return this.type === TYPE_FILE; + } + + isDirectory(): boolean { + return this.type === TYPE_DIR; + } + + isSymbolicLink(): boolean { + return this.type === TYPE_SYMLINK; + } +} + +/** + * In-memory filesystem provider. + * Supports full read/write operations. + */ +export class MemoryProvider extends VirtualProvider { + #root: MemoryEntry; + + #readonly: boolean; + + constructor() { + super(); + // Root directory + this.#root = new MemoryEntry(TYPE_DIR); + this.#root.children = new Map(); + this.#readonly = false; + } + + get readonly(): boolean { + return this.#readonly; + } + + get supportsWatch(): boolean { + return false; + } + + setReadOnly(): void { + this.#readonly = true; + } + + get supportsSymlinks(): boolean { + return true; + } + + #normalizePath(path: string): string { + // Convert backslashes to forward slashes + let normalized = path.replaceAll("\\", "/"); + // Ensure absolute path + if (normalized[0] !== "/") { + normalized = "/" + normalized; + } + // Use path.posix.normalize to resolve . and .. + return pathPosix.normalize(normalized); + } + + #splitPath(path: string): string[] { + if (path === "/") { + return []; + } + return path.slice(1).split("/"); + } + + #resolveSymlinkTarget(symlinkPath: string, target: string): string { + if (target.startsWith("/")) { + return this.#normalizePath(target); + } + // Relative target: resolve against symlink's parent directory + const parentPath = pathPosix.dirname(symlinkPath); + return this.#normalizePath(pathPosix.join(parentPath, target)); + } + + #lookupEntry( + path: string, + followSymlinks = true, + depth = 0, + ): { entry: MemoryEntry | null; resolvedPath: string | null; eloop?: boolean } { + const normalized = this.#normalizePath(path); + + if (normalized === "/") { + return { entry: this.#root, resolvedPath: "/" }; + } + + const segments = this.#splitPath(normalized); + + let current = this.#root; + + let currentPath = "/"; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Always follow symlinks for intermediate path components + if (current.isSymbolicLink()) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + + const result = this.#lookupEntry(targetPath, true, depth + 1); + if (result.eloop) { + return result; + } + if (!result.entry) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath!; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + const entry = current.children.get(segment); + if (!entry) { + return { entry: null, resolvedPath: null }; + } + + currentPath = pathPosix.join(currentPath, segment); + current = entry; + } + + // Follow symlink at the end if requested + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + return this.#lookupEntry(targetPath, true, depth + 1); + } + + return { entry: current, resolvedPath: currentPath }; + } + + #getEntry(path: string, syscall: string, followSymlinks = true): MemoryEntry { + const result = this.#lookupEntry(path, followSymlinks); + if (result.eloop) { + throw createELOOP(syscall, path); + } + if (!result.entry) { + throw createENOENT(syscall, path); + } + return result.entry; + } + + #ensureParent(path: string, create: boolean, syscall: string): MemoryEntry { + if (path === "/") { + return this.#root; + } + const parentPath = pathPosix.dirname(path); + + const segments = this.#splitPath(parentPath); + + let current = this.#root; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + const currentPath = pathPosix.join("/", ...segments.slice(0, i)); + + // Follow symlinks in parent path + if (current.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + let entry = current.children.get(segment); + if (!entry) { + if (create) { + entry = new MemoryEntry(TYPE_DIR); + entry.children = new Map(); + current.children.set(segment, entry); + } else { + throw createENOENT(syscall, path); + } + } + current = entry; + } + + // Follow symlinks on the final parent entry + if (current.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget(parentPath, current.target); + + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + return current; + } + + #createStats(entry: MemoryEntry, size?: number, bigint?: boolean): FileStats { + const options = { + mode: entry.mode, + nlink: entry.nlink, + uid: entry.uid, + gid: entry.gid, + atimeMs: entry.atime, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + bigint, + }; + + if (entry.isFile()) { + let fileSize = size; + if (fileSize === undefined) { + fileSize = entry.isDynamic() ? entry.getContentSync().length : entry.content.length; + } + return createFileStats(fileSize, options); + } else if (entry.isDirectory()) { + return createDirectoryStats(options); + } else if (entry.isSymbolicLink()) { + return createSymlinkStats(entry.target.length, options); + } + + throw new ERR_INVALID_STATE("Unknown entry type"); + } + + openSync(path: string, flags: string | number = "r", mode?: number): VirtualFileHandle { + const normalized = this.#normalizePath(path); + + // Normalize numeric flags to string + flags = normalizeFlags(flags); + + // Handle create and exclusive modes + const isCreate = + flags === "w" || + flags === "w+" || + flags === "a" || + flags === "a+" || + flags === "wx" || + flags === "wx+" || + flags === "ax" || + flags === "ax+"; + const isExclusive = flags === "wx" || flags === "wx+" || flags === "ax" || flags === "ax+"; + + const isWritable = flags !== "r"; + + // Check readonly for any writable mode + if (this.readonly && isWritable) { + throw createEROFS("open", path); + } + + let entry; + try { + entry = this.#getEntry(normalized, "open"); + // Exclusive flag: file must not exist + if (isExclusive) { + throw createEEXIST("open", path); + } + } catch (err) { + if (!hasCode(err, "ENOENT") || !isCreate) { + throw err; + } + // Create the file + const parent = this.#ensureParent(normalized, false, "open"); + + const name = pathPosix.basename(normalized); + entry = new MemoryEntry(TYPE_FILE, { mode }); + entry.content = NodeBuffer.alloc(0); + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + if (entry.isDirectory()) { + throw createEISDIR("open", path); + } + + if (entry.isSymbolicLink()) { + // Should have been resolved already, but just in case + throw createEINVAL("open", path); + } + + const getStats = (size: number) => this.#createStats(entry, size); + return new MemoryFileHandle( + normalized, + flags, + mode ?? entry.mode, + entry.content, + entry, + getStats, + ); + } + + async open( + path: string, + flags: string | number = "r", + mode?: number, + ): Promise { + return this.openSync(path, flags, mode); + } + + statSync(path: string, options?: StatOptions): FileStats { + const entry = this.#getEntry(path, "stat", true); + return this.#createStats(entry, undefined, options?.bigint); + } + + async stat(path: string, options?: StatOptions): Promise { + return this.statSync(path, options); + } + + lstatSync(path: string, options?: StatOptions): FileStats { + const entry = this.#getEntry(path, "lstat", false); + return this.#createStats(entry, undefined, options?.bigint); + } + + async lstat(path: string, options?: StatOptions): Promise { + return this.lstatSync(path, options); + } + + readdirSync(path: string, options?: DirectoryOptions): DirectoryEntries { + const entry = this.#getEntry(path, "scandir", true); + if (!entry.isDirectory()) { + throw createENOTDIR("scandir", path); + } + + const normalized = this.#normalizePath(path); + + const withFileTypes = options?.withFileTypes === true; + + const recursive = options?.recursive === true; + + if (recursive) { + return this.#readdirRecursive(entry, normalized, withFileTypes); + } + + if (withFileTypes) { + const dirents: Dirent[] = []; + for (const { 0: name, 1: childEntry } of entry.children) { + let type: "file" | "directory" | "symlink"; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + dirents.push(new Dirent(name, normalized, type)); + } + return dirents; + } + + return Array.from(entry.children.keys()); + } + + #readdirRecursive( + dirEntry: MemoryEntry, + dirPath: string, + withFileTypes: boolean, + ): DirectoryEntries { + const results: (string | Dirent)[] = []; + // Directories on the current traversal path. A directory reached again + // through a symlink cycle is not descended into (but is still listed). + const active = new Set(); + + // Traverse depth-first with an explicit stack instead of recursion, so a + // deeply nested tree cannot exhaust the call stack. Each frame is a + // directory being walked together with a snapshot of its children and the + // index of the next child to visit. + const enter = (entry: MemoryEntry, currentPath: string, relativePath: string): void => { + active.add(entry); + stack.push({ + entry, + currentPath, + relativePath, + children: Array.from(entry.children), + index: 0, + }); + }; + + const stack: { + entry: MemoryEntry; + + currentPath: string; + + relativePath: string; + + children: [string, MemoryEntry][]; + + index: number; + }[] = []; + enter(dirEntry, dirPath, ""); + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame.index >= frame.children.length) { + active.delete(frame.entry); + stack.pop(); + continue; + } + + const { 0: name, 1: childEntry } = frame.children[frame.index++]; + + const childRelative = frame.relativePath ? frame.relativePath + "/" + name : name; + + if (withFileTypes) { + let type: "file" | "directory" | "symlink"; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + results.push(new Dirent(childRelative, dirPath, type)); + } else { + results.push(childRelative); + } + + // Follow symlinks to directories for recursive traversal, skipping any + // directory already on the active path to avoid symlink cycles. + let resolvedChild = childEntry; + if (childEntry.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget( + pathPosix.join(frame.currentPath, name), + childEntry.target, + ); + const result = this.#lookupEntry(targetPath, true, 0); + if (result.entry) { + resolvedChild = result.entry; + } + } + if (resolvedChild.isDirectory() && !active.has(resolvedChild)) { + enter(resolvedChild, pathPosix.join(frame.currentPath, name), childRelative); + } + } + + return results as string[] | Dirent[]; + } + + async readdir(path: string, options?: DirectoryOptions): Promise { + return this.readdirSync(path, options); + } + + mkdirSync(path: string, options?: MkdirOptions): string | undefined { + if (this.readonly) { + throw createEROFS("mkdir", path); + } + + const normalized = this.#normalizePath(path); + + const recursive = options?.recursive === true; + + // Check if already exists + const existing = this.#lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory() && recursive) { + // Already exists, that's ok for recursive + return undefined; + } + throw createEEXIST("mkdir", path); + } + + if (recursive) { + // Create all parent directories + const segments = this.#splitPath(normalized); + + let current = this.#root; + + let currentPath = "/"; + + let resolvedCurrentPath = "/"; + + let firstCreated; + + for (const segment of segments) { + currentPath = pathPosix.join(currentPath, segment); + const resolvedPath = pathPosix.join(resolvedCurrentPath, segment); + + let entry = current.children.get(segment); + if (!entry) { + entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new Map(); + current.children.set(segment, entry); + if (firstCreated === undefined) { + firstCreated = currentPath; + } + resolvedCurrentPath = resolvedPath; + } else if (entry.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget(resolvedPath, entry.target); + + const result = this.#lookupEntry(targetPath, true, 0); + if (result.eloop) { + throw createELOOP("mkdir", path); + } + if (!result.entry) { + throw createENOENT("mkdir", path); + } + entry = result.entry; + resolvedCurrentPath = result.resolvedPath!; + } else { + resolvedCurrentPath = resolvedPath; + } + + if (!entry.isDirectory()) { + throw createENOTDIR("mkdir", path); + } + current = entry; + } + return firstCreated; + } + + const parent = this.#ensureParent(normalized, false, "mkdir"); + + const name = pathPosix.basename(normalized); + + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new Map(); + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + return undefined; + } + + async mkdir(path: string, options?: MkdirOptions): Promise { + return this.mkdirSync(path, options); + } + + rmdirSync(path: string): void { + if (this.readonly) { + throw createEROFS("rmdir", path); + } + + const normalized = this.#normalizePath(path); + + const entry = this.#getEntry(normalized, "rmdir", false); + + if (!entry.isDirectory()) { + throw createENOTDIR("rmdir", path); + } + + if (entry.children.size > 0) { + throw createENOTEMPTY("rmdir", path); + } + + const parent = this.#ensureParent(normalized, false, "rmdir"); + + const name = pathPosix.basename(normalized); + parent.children.delete(name); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async rmdir(path: string): Promise { + this.rmdirSync(path); + } + + unlinkSync(path: string): void { + if (this.readonly) { + throw createEROFS("unlink", path); + } + + const normalized = this.#normalizePath(path); + + const entry = this.#getEntry(normalized, "unlink", false); + + if (entry.isDirectory()) { + throw createEISDIR("unlink", path); + } + + const parent = this.#ensureParent(normalized, false, "unlink"); + + const name = pathPosix.basename(normalized); + parent.children.delete(name); + entry.nlink--; + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async unlink(path: string): Promise { + this.unlinkSync(path); + } + + renameSync(oldPath: string, newPath: string): void { + if (this.readonly) { + throw createEROFS("rename", oldPath); + } + + const normalizedOld = this.#normalizePath(oldPath); + + const normalizedNew = this.#normalizePath(newPath); + + // Get the entry (without following symlinks for the entry itself) + const entry = this.#getEntry(normalizedOld, "rename", false); + + if (entry.isDirectory() && startsWith(normalizedNew, `${normalizedOld}/`)) { + throw createEINVAL("rename", oldPath); + } + + // Validate destination parent exists (do not auto-create) + const newParent = this.#ensureParent(normalizedNew, false, "rename"); + + const newName = pathPosix.basename(normalizedNew); + + // Check if destination exists + const existingDest = newParent.children.get(newName); + if (existingDest) { + // Cannot overwrite a directory with a non-directory + if (existingDest.isDirectory() && !entry.isDirectory()) { + throw createEISDIR("rename", newPath); + } + // Cannot overwrite a non-directory with a directory + if (!existingDest.isDirectory() && entry.isDirectory()) { + throw createENOTDIR("rename", newPath); + } + } + + // Remove from old location (after destination validation) + const oldParent = this.#ensureParent(normalizedOld, false, "rename"); + + const oldName = pathPosix.basename(normalizedOld); + oldParent.children.delete(oldName); + + // Add to new location + newParent.children.set(newName, entry); + + const now = DateNow(); + oldParent.mtime = now; + oldParent.ctime = now; + if (newParent !== oldParent) { + newParent.mtime = now; + newParent.ctime = now; + } + } + + async rename(oldPath: string, newPath: string): Promise { + this.renameSync(oldPath, newPath); + } + + linkSync(existingPath: string, newPath: string): void { + if (this.readonly) { + throw createEROFS("link", newPath); + } + + const normalizedExisting = this.#normalizePath(existingPath); + + const normalizedNew = this.#normalizePath(newPath); + + const entry = this.#getEntry(normalizedExisting, "link", true); + if (!entry.isFile()) { + // Hard links to directories are not supported + throw createEINVAL("link", existingPath); + } + + // Check if new path already exists + const existing = this.#lookupEntry(normalizedNew, false); + if (existing.entry) { + throw createEEXIST("link", newPath); + } + + const parent = this.#ensureParent(normalizedNew, false, "link"); + + const name = pathPosix.basename(normalizedNew); + // Hard link: same entry object referenced by both names + parent.children.set(name, entry); + entry.nlink++; + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async link(existingPath: string, newPath: string): Promise { + this.linkSync(existingPath, newPath); + } + + readlinkSync(path: string, _options?: DirectoryOptions): string | Buffer { + const normalized = this.#normalizePath(path); + + const entry = this.#getEntry(normalized, "readlink", false); + + if (!entry.isSymbolicLink()) { + throw createEINVAL("readlink", path); + } + + return entry.target; + } + + async readlink(path: string, options?: DirectoryOptions): Promise { + return this.readlinkSync(path, options); + } + + symlinkSync(target: string, path: string, _type?: string): void { + if (this.readonly) { + throw createEROFS("symlink", path); + } + + const normalized = this.#normalizePath(path); + + // Check if already exists + const existing = this.#lookupEntry(normalized, false); + if (existing.entry) { + throw createEEXIST("symlink", path); + } + + const parent = this.#ensureParent(normalized, false, "symlink"); + + const name = pathPosix.basename(normalized); + + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async symlink(target: string, path: string, type?: string): Promise { + this.symlinkSync(target, path, type); + } + + realpathSync(path: string, _options?: DirectoryOptions): string { + const result = this.#lookupEntry(path, true, 0); + if (result.eloop) { + throw createELOOP("realpath", path); + } + if (!result.entry) { + throw createENOENT("realpath", path); + } + return result.resolvedPath!; + } + + async realpath(path: string, options?: DirectoryOptions): Promise { + return this.realpathSync(path, options); + } + + chmodSync(path: string, mode: number): void { + const entry = this.#getEntry(path, "chmod", true); + // Preserve file type bits, update permission bits + entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777); + entry.ctime = DateNow(); + } + + lchmodSync(path: string, mode: number): void { + const entry = this.#getEntry(path, "chmod", false); + // Preserve file type bits, update permission bits + entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777); + entry.ctime = DateNow(); + } + + chownSync(path: string, uid: number, gid: number): void { + const entry = this.#getEntry(path, "chown", true); + if (uid >= 0) { + entry.uid = uid; + } + if (gid >= 0) { + entry.gid = gid; + } + entry.ctime = DateNow(); + } + + lchownSync(path: string, uid: number, gid: number): void { + const entry = this.#getEntry(path, "chown", false); + if (uid >= 0) { + entry.uid = uid; + } + if (gid >= 0) { + entry.gid = gid; + } + entry.ctime = DateNow(); + } + + utimesSync(path: string, atime: Time, mtime: Time): void { + const entry = this.#getEntry(path, "utime", true); + entry.atime = toMs(atime); + entry.mtime = toMs(mtime); + entry.ctime = DateNow(); + } + + lutimesSync(path: string, atime: Time, mtime: Time): void { + const entry = this.#getEntry(path, "utime", false); + entry.atime = toMs(atime); + entry.mtime = toMs(mtime); + entry.ctime = DateNow(); + } + + watch(..._args: unknown[]): never { + return unsupported("MemoryProvider.watch"); + } + + watchAsync(..._args: unknown[]): never { + return unsupported("MemoryProvider.watchAsync"); + } + + watchFile(..._args: unknown[]): never { + return unsupported("MemoryProvider.watchFile"); + } + + unwatchFile(..._args: unknown[]): never { + return unsupported("MemoryProvider.unwatchFile"); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/path.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/path.ts new file mode 100644 index 000000000..40e797f6d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/path.ts @@ -0,0 +1,4 @@ +import { createPath } from "../../24.x.x/path.js"; + +/** VFS paths are POSIX paths and never consult the process working directory. */ +export const path = createPath({ initialCwd: () => "/", getEnvironment: () => [] }).posix; diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/promises.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/promises.ts new file mode 100644 index 000000000..7cd5deabf --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/promises.ts @@ -0,0 +1,255 @@ +/** + * Promise namespace adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, + * lib/internal/vfs/file_system.js (MIT; see jco-std/LICENSE). + * Kept separate from the sync/callback facade; both share one provider. + */ +import type { VirtualProvider } from "./provider.js"; +import type { + Buffer, + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + DirectoryOptions, + DirectoryEntries, + MkdirOptions, + RemoveOptions, + Time, +} from "./types.js"; +import { openVirtualFd } from "./fd.js"; +import { createEISDIR, unsupported } from "./errors.js"; +import { hasCode } from "./validation.js"; +import { path } from "./path.js"; + +const ObjectFreeze = Object.freeze; + +const MathRandom = Math.random; + +const joinPath = path.join; + +export interface VfsPromises { + readFile(path: string, options?: ReadFileOptions): Promise; + writeFile(path: string, data: FileData, options?: FileOptions): Promise; + appendFile(path: string, data: FileData, options?: FileOptions): Promise; + stat(path: string, options?: StatOptions): Promise; + lstat(path: string, options?: StatOptions): Promise; + readdir(path: string, options?: DirectoryOptions): Promise; + mkdir(path: string, options?: MkdirOptions): Promise; + rmdir(path: string): Promise; + unlink(path: string): Promise; + rename(oldPath: string, newPath: string): Promise; + copyFile(src: string, dest: string, mode?: number): Promise; + realpath(path: string, options?: DirectoryOptions): Promise; + readlink(path: string, options?: DirectoryOptions): Promise; + symlink(target: string, path: string, type?: string): Promise; + access(path: string, mode?: number): Promise; + rm(filePath: string, options?: RemoveOptions): Promise; + truncate(filePath: string, len?: number): Promise; + link(existingPath: string, newPath: string): Promise; + mkdtemp(prefix: string): Promise; + chmod(path: string, mode: number): Promise; + chown(path: string, uid: number, gid: number): Promise; + lchown(path: string, uid: number, gid: number): Promise; + utimes(path: string, atime: Time, mtime: Time): Promise; + lutimes(path: string, atime: Time, mtime: Time): Promise; + open(filePath: string, flags?: string | number, mode?: number): Promise; + lchmod(path: string, mode: number): Promise; + watch(...args: unknown[]): never; +} + +export function createVfsPromises( + provider: VirtualProvider, + toProviderPath: (path: string) => string, + toMountedPath: (path: string) => string, +): VfsPromises { + return ObjectFreeze({ + async readFile(filePath: string, options?: ReadFileOptions): Promise { + const providerPath = toProviderPath(filePath); + return provider.readFile(providerPath, options); + }, + + async writeFile(filePath: string, data: FileData, options?: FileOptions): Promise { + const providerPath = toProviderPath(filePath); + return provider.writeFile(providerPath, data, options); + }, + + async appendFile(filePath: string, data: FileData, options?: FileOptions): Promise { + const providerPath = toProviderPath(filePath); + return provider.appendFile(providerPath, data, options); + }, + + async stat(filePath: string, options?: StatOptions): Promise { + const providerPath = toProviderPath(filePath); + return provider.stat(providerPath, options); + }, + + async lstat(filePath: string, options?: StatOptions): Promise { + const providerPath = toProviderPath(filePath); + return provider.lstat(providerPath, options); + }, + + async readdir(dirPath: string, options?: DirectoryOptions): Promise { + const providerPath = toProviderPath(dirPath); + return provider.readdir(providerPath, options); + }, + + async mkdir(dirPath: string, options?: MkdirOptions): Promise { + const providerPath = toProviderPath(dirPath); + return provider.mkdir(providerPath, options); + }, + + async rmdir(dirPath: string): Promise { + const providerPath = toProviderPath(dirPath); + return provider.rmdir(providerPath); + }, + + async unlink(filePath: string): Promise { + const providerPath = toProviderPath(filePath); + return provider.unlink(providerPath); + }, + + async rename(oldPath: string, newPath: string): Promise { + const oldProviderPath = toProviderPath(oldPath); + + const newProviderPath = toProviderPath(newPath); + return provider.rename(oldProviderPath, newProviderPath); + }, + + async copyFile(src: string, dest: string, mode?: number): Promise { + const srcProviderPath = toProviderPath(src); + + const destProviderPath = toProviderPath(dest); + return provider.copyFile(srcProviderPath, destProviderPath, mode); + }, + + async realpath(filePath: string, options?: DirectoryOptions): Promise { + const providerPath = toProviderPath(filePath); + return toMountedPath(await provider.realpath(providerPath, options)); + }, + + async readlink(linkPath: string, options?: DirectoryOptions): Promise { + const providerPath = toProviderPath(linkPath); + return provider.readlink(providerPath, options); + }, + + async symlink(target: string, path: string, type?: string): Promise { + const providerPath = toProviderPath(path); + return provider.symlink(target, providerPath, type); + }, + + async access(filePath: string, mode?: number): Promise { + const providerPath = toProviderPath(filePath); + return provider.access(providerPath, mode); + }, + + async rm(filePath: string, options?: RemoveOptions): Promise { + const recursive = options?.recursive === true; + + const force = options?.force === true; + + let stats; + try { + stats = await provider.lstat(toProviderPath(filePath)); + } catch (err) { + if (force && hasCode(err, "ENOENT")) { + return; + } + throw err; + } + + // Symlinks should be unlinked directly, never recursed into + if (stats.isSymbolicLink()) { + await provider.unlink(toProviderPath(filePath)); + return; + } + + if (stats.isDirectory()) { + if (!recursive) { + throw createEISDIR("rm", filePath); + } + const entries = await provider.readdir(toProviderPath(filePath)); + for (let i = 0; i < entries.length; i++) { + await this.rm(joinPath(filePath, String(entries[i])), options); + } + await provider.rmdir(toProviderPath(filePath)); + } else { + await provider.unlink(toProviderPath(filePath)); + } + }, + + async truncate(filePath: string, len: number = 0): Promise { + const providerPath = toProviderPath(filePath); + + const handle = await provider.open(providerPath, "r+"); + try { + await handle.truncate(len); + } finally { + await handle.close(); + } + }, + + async link(existingPath: string, newPath: string): Promise { + const existingProviderPath = toProviderPath(existingPath); + + const newProviderPath = toProviderPath(newPath); + return provider.link(existingProviderPath, newProviderPath); + }, + + async mkdtemp(prefix: string): Promise { + const providerPrefix = toProviderPath(prefix); + + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += chars[(MathRandom() * chars.length) | 0]; + } + const dirPath = providerPrefix + suffix; + await provider.mkdir(dirPath); + return toMountedPath(dirPath); + }, + + async chmod(filePath: string, mode?: number): Promise { + const providerPath = toProviderPath(filePath); + provider.chmodSync(providerPath, mode!); + }, + + async chown(filePath: string, uid: number, gid: number): Promise { + const providerPath = toProviderPath(filePath); + provider.chownSync(providerPath, uid, gid); + }, + + async lchown(filePath: string, uid: number, gid: number): Promise { + const providerPath = toProviderPath(filePath); + provider.lchownSync(providerPath, uid, gid); + }, + + async utimes(filePath: string, atime: Time, mtime: Time): Promise { + const providerPath = toProviderPath(filePath); + provider.utimesSync(providerPath, atime, mtime); + }, + + async lutimes(filePath: string, atime: Time, mtime: Time): Promise { + const providerPath = toProviderPath(filePath); + provider.lutimesSync(providerPath, atime, mtime); + }, + + async open(filePath: string, flags?: string | number, mode?: number): Promise { + const providerPath = toProviderPath(filePath); + + const handle = provider.openSync(providerPath, flags, mode); + return openVirtualFd(handle); + }, + + async lchmod(filePath: string, mode?: number): Promise { + const providerPath = toProviderPath(filePath); + provider.lchmodSync(providerPath, mode!); + }, + + watch(..._args: unknown[]): never { + return unsupported("promises.watch"); + }, + }); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/provider.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/provider.ts new file mode 100644 index 000000000..5ac64b371 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/provider.ts @@ -0,0 +1,375 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/provider.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Local changes: typed contracts, shared constants/errors, explicit watcher refusals. + */ +import type { Buffer } from "./types.js"; +import { R_OK, W_OK, X_OK, COPYFILE_EXCL } from "../../24.x.x/fs/constants.js"; +import { + createEROFS, + createEEXIST, + createEACCES, + ERR_METHOD_NOT_IMPLEMENTED, + unsupported, +} from "./errors.js"; +import type { VirtualFileHandle } from "./file-handle.js"; +import type { + Time, + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + DirectoryOptions, + DirectoryEntries, + MkdirOptions, +} from "./types.js"; + +export class VirtualProvider { + get readonly(): boolean { + return false; + } + + get supportsSymlinks(): boolean { + return false; + } + + get supportsWatch(): boolean { + return false; + } + + async open( + _path: string, + _flags: string | number = "r", + _mode?: number, + ): Promise { + throw new ERR_METHOD_NOT_IMPLEMENTED("open"); + } + + openSync(_path: string, _flags: string | number = "r", _mode?: number): VirtualFileHandle { + throw new ERR_METHOD_NOT_IMPLEMENTED("openSync"); + } + + async stat(_path: string, _options?: StatOptions): Promise { + throw new ERR_METHOD_NOT_IMPLEMENTED("stat"); + } + + statSync(_path: string, _options?: StatOptions): FileStats { + throw new ERR_METHOD_NOT_IMPLEMENTED("statSync"); + } + + async lstat(path: string, options?: StatOptions): Promise { + // Default: same as stat (for providers that don't support symlinks) + return this.stat(path, options); + } + + lstatSync(path: string, options?: StatOptions): FileStats { + // Default: same as statSync (for providers that don't support symlinks) + return this.statSync(path, options); + } + + async readdir(_path: string, _options?: DirectoryOptions): Promise { + throw new ERR_METHOD_NOT_IMPLEMENTED("readdir"); + } + + readdirSync(_path: string, _options?: DirectoryOptions): DirectoryEntries { + throw new ERR_METHOD_NOT_IMPLEMENTED("readdirSync"); + } + + async mkdir(path: string, _options?: MkdirOptions): Promise { + if (this.readonly) { + throw createEROFS("mkdir", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("mkdir"); + } + + mkdirSync(path: string, _options?: MkdirOptions): string | undefined { + if (this.readonly) { + throw createEROFS("mkdir", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("mkdirSync"); + } + + async rmdir(path: string): Promise { + if (this.readonly) { + throw createEROFS("rmdir", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("rmdir"); + } + + rmdirSync(path: string): void { + if (this.readonly) { + throw createEROFS("rmdir", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("rmdirSync"); + } + + async unlink(path: string): Promise { + if (this.readonly) { + throw createEROFS("unlink", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("unlink"); + } + + unlinkSync(path: string): void { + if (this.readonly) { + throw createEROFS("unlink", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("unlinkSync"); + } + + async rename(oldPath: string, _newPath: string): Promise { + if (this.readonly) { + throw createEROFS("rename", oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("rename"); + } + + renameSync(oldPath: string, _newPath: string): void { + if (this.readonly) { + throw createEROFS("rename", oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("renameSync"); + } + + lchownSync(path: string, uid: number, gid: number): void { + return this.chownSync(path, uid, gid); + } + + async readFile(path: string, options?: ReadFileOptions): Promise { + const flag = typeof options === "object" && options !== null ? (options.flag ?? "r") : "r"; + + const handle = await this.open(path, flag); + try { + return await handle.readFile(options); + } finally { + await handle.close(); + } + } + + readFileSync(path: string, options?: ReadFileOptions): Buffer | string { + const flag = typeof options === "object" && options !== null ? (options.flag ?? "r") : "r"; + + const handle = this.openSync(path, flag); + try { + return handle.readFileSync(options); + } finally { + handle.closeSync(); + } + } + + async writeFile(path: string, data: FileData, options?: FileOptions): Promise { + if (this.readonly) { + throw createEROFS("open", path); + } + const flag = options?.flag ?? "w"; + + const handle = await this.open(path, flag, options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + writeFileSync(path: string, data: FileData, options?: FileOptions): void { + if (this.readonly) { + throw createEROFS("open", path); + } + const flag = options?.flag ?? "w"; + + const handle = this.openSync(path, flag, options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + async appendFile(path: string, data: FileData, options?: FileOptions): Promise { + if (this.readonly) { + throw createEROFS("open", path); + } + const flag = options?.flag ?? "a"; + + const handle = await this.open(path, flag, options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + appendFileSync(path: string, data: FileData, options?: FileOptions): void { + if (this.readonly) { + throw createEROFS("open", path); + } + const flag = options?.flag ?? "a"; + + const handle = this.openSync(path, flag, options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + async exists(path: string): Promise { + try { + await this.stat(path); + return true; + } catch { + return false; + } + } + + existsSync(path: string): boolean { + try { + this.statSync(path); + return true; + } catch { + return false; + } + } + + async copyFile(src: string, dest: string, mode: number = 0): Promise { + if (this.readonly) { + throw createEROFS("copyfile", dest); + } + if ((mode & COPYFILE_EXCL) !== 0) { + if (await this.exists(dest)) { + throw createEEXIST("copyfile", dest); + } + } + const content = await this.readFile(src); + await this.writeFile(dest, content); + } + + copyFileSync(src: string, dest: string, mode: number = 0): void { + if (this.readonly) { + throw createEROFS("copyfile", dest); + } + if ((mode & COPYFILE_EXCL) !== 0) { + if (this.existsSync(dest)) { + throw createEEXIST("copyfile", dest); + } + } + const content = this.readFileSync(src); + this.writeFileSync(dest, content); + } + + async realpath(path: string, _options?: DirectoryOptions): Promise { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + await this.stat(path); + return path; + } + + realpathSync(path: string, _options?: DirectoryOptions): string { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + this.statSync(path); + return path; + } + + async access(path: string, mode: number = 0): Promise { + const stats = await this.stat(path); + this.#checkAccessMode(path, stats, mode); + } + + accessSync(path: string, mode: number = 0): void { + const stats = this.statSync(path); + this.#checkAccessMode(path, stats, mode); + } + + #checkAccessMode(path: string, stats: FileStats, mode?: number): void { + if (mode == null || mode === 0) { + return; + } // F_OK = 0, existence-only check + + const fileMode = Number(stats.mode) & 0o777; // Permission bits + // Check owner permissions (simplified: treat VFS user as owner) + if ((mode & R_OK) !== 0 && (fileMode & 0o400) === 0) { + throw createEACCES("access", path); + } + if ((mode & W_OK) !== 0 && (fileMode & 0o200) === 0) { + throw createEACCES("access", path); + } + if ((mode & X_OK) !== 0 && (fileMode & 0o100) === 0) { + throw createEACCES("access", path); + } + } + + async link(existingPath: string, newPath: string): Promise { + if (this.readonly) { + throw createEROFS("link", newPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("link"); + } + + linkSync(existingPath: string, newPath: string): void { + if (this.readonly) { + throw createEROFS("link", newPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("linkSync"); + } + + async readlink(_path: string, _options?: DirectoryOptions): Promise { + throw new ERR_METHOD_NOT_IMPLEMENTED("readlink"); + } + + readlinkSync(_path: string, _options?: DirectoryOptions): string | Buffer { + throw new ERR_METHOD_NOT_IMPLEMENTED("readlinkSync"); + } + + async symlink(target: string, path: string, _type?: string): Promise { + if (this.readonly) { + throw createEROFS("symlink", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("symlink"); + } + + symlinkSync(target: string, path: string, _type?: string): void { + if (this.readonly) { + throw createEROFS("symlink", path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED("symlinkSync"); + } + + watch(..._args: unknown[]): never { + return unsupported("VirtualProvider.watch"); + } + + watchAsync(..._args: unknown[]): never { + return unsupported("VirtualProvider.watchAsync"); + } + + watchFile(..._args: unknown[]): never { + return unsupported("VirtualProvider.watchFile"); + } + + unwatchFile(..._args: unknown[]): never { + return unsupported("VirtualProvider.unwatchFile"); + } + + lutimesSync(_path: string, _atime: Time, _mtime: Time): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("lutimesSync"); + } + + utimesSync(_path: string, _atime: Time, _mtime: Time): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("utimesSync"); + } + + chownSync(_path: string, _uid: number, _gid: number): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("chownSync"); + } + + lchmodSync(_path: string, _mode: number): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("lchmodSync"); + } + + chmodSync(_path: string, _mode: number): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("chmodSync"); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/real.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/real.ts new file mode 100644 index 000000000..a6842a118 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/real.ts @@ -0,0 +1,366 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/providers/real.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Reuse the portable FsCore over injected hosts; paths are POSIX and roots absolute. + */ +import { Buffer as NodeBuffer } from "node:buffer"; +import type { Buffer } from "./types.js"; +import { FsCore } from "../../24.x.x/fs/core.js"; +import { VirtualProvider } from "./provider.js"; +import { VirtualFileHandle } from "./file-handle.js"; +import { path } from "./path.js"; +import { createENOENT, createEBADF } from "./errors.js"; +import { hasCode } from "./validation.js"; +import { invalidArgValue } from "../../24.x.x/errors/core.js"; +import type { + FileStats, + FileData, + FileOptions, + ReadFileOptions, + StatOptions, + DirectoryOptions, + DirectoryEntries, + MkdirOptions, + Position, + Time, +} from "./types.js"; + +class RealFileHandle extends VirtualFileHandle { + readonly #core: FsCore; + + readonly #fd: number; + + constructor(core: FsCore, filePath: string, flags: string | number, mode?: number) { + super(filePath, String(flags), mode); + this.#core = core; + this.#fd = core.openSync(filePath, flags, mode); + } + + #checkOpen(syscall: string): void { + if (this.closed) { + throw createEBADF(syscall); + } + } + + readSync(buffer: Buffer, offset: number, length: number, position?: Position): number { + this.#checkOpen("read"); + return this.#core.readSync(this.#fd, buffer, offset, length, position); + } + + async read( + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): Promise<{ bytesRead: number; buffer: Buffer }> { + return { bytesRead: this.readSync(buffer, offset, length, position), buffer }; + } + + writeSync(buffer: Buffer, offset: number, length: number, position?: Position): number { + this.#checkOpen("write"); + return this.#core.writeSync( + this.#fd, + buffer, + offset, + length, + position == null ? position : Number(position), + ); + } + + async write( + buffer: Buffer, + offset: number, + length: number, + position?: Position, + ): Promise<{ bytesWritten: number; buffer: Buffer }> { + return { bytesWritten: this.writeSync(buffer, offset, length, position), buffer }; + } + + readFileSync(options?: ReadFileOptions): Buffer | string { + this.#checkOpen("read"); + const value = this.#core.readFileSync(this.#fd, options); + return typeof value === "string" ? value : NodeBuffer.from(value); + } + + async readFile(options?: ReadFileOptions): Promise { + return this.readFileSync(options); + } + + writeFileSync(data: FileData, options?: FileOptions): void { + this.#checkOpen("write"); + this.#core.writeFileSync(this.#fd, data, options); + } + + async writeFile(data: FileData, options?: FileOptions): Promise { + this.writeFileSync(data, options); + } + + statSync(options?: StatOptions): FileStats { + this.#checkOpen("fstat"); + return this.#core.fstatSync(this.#fd, options); + } + + async stat(options?: StatOptions): Promise { + return this.statSync(options); + } + + truncateSync(len = 0): void { + this.#checkOpen("ftruncate"); + this.#core.ftruncateSync(this.#fd, len); + } + + async truncate(len = 0): Promise { + this.truncateSync(len); + } + + closeSync(): void { + if (this.closed) { + return; + } + this.#core.closeSync(this.#fd); + super.closeSync(); + } + + async close(): Promise { + this.closeSync(); + } +} + +/** Bind each provider root to a host implementation without granting authority at import time. */ +export interface RealProvider extends VirtualProvider { + readonly rootPath: string; +} + +export type RealProviderConstructor = new (rootPath: string) => RealProvider; + +export function createRealFSProvider( + createCore: (rootPath: string) => FsCore, +): RealProviderConstructor { + return class RealFSProvider extends VirtualProvider { + readonly #rootPath: string; + + #coreValue: FsCore | undefined; + + #canonicalRootValue: string | undefined; + + constructor(rootPath: string) { + super(); + if (typeof rootPath !== "string" || !rootPath.startsWith("/")) { + throw invalidArgValue("rootPath", rootPath, "must be an absolute POSIX path"); + } + this.#rootPath = path.normalize(rootPath); + Object.defineProperties(this, { + readonly: { value: false, enumerable: true, writable: true, configurable: true }, + supportsSymlinks: { value: true, enumerable: true, writable: true, configurable: true }, + }); + } + + get rootPath(): string { + return this.#rootPath; + } + + get #core(): FsCore { + return (this.#coreValue ??= createCore(this.#rootPath)); + } + + get #canonicalRoot(): string { + // Resolve lazily so construction does not require a filesystem capability. + // Host aliases such as macOS /var must be compared in the same form as realpath results. + return (this.#canonicalRootValue ??= String(this.#core.realpathSync(this.#rootPath))); + } + + #inside(candidate: string, root = this.#rootPath): boolean { + const prefix = root.endsWith("/") ? root : root + "/"; + return candidate === root || candidate.startsWith(prefix); + } + + #resolve(vfsPath: string, followFinal = true): string { + const relative = vfsPath.startsWith("/") ? vfsPath.slice(1) : vfsPath; + + const candidate = path.resolve(this.#rootPath, relative); + if (!this.#inside(candidate)) { + throw createENOENT("open", vfsPath); + } + + // Check the deepest existing ancestor as well as the lexical path. Keep + // policy failures outside the ENOENT catch so an escaping link is rejected. + let current = followFinal ? candidate : path.dirname(candidate); + while (this.#inside(current)) { + let resolved: string; + try { + resolved = String(this.#core.realpathSync(current)); + } catch (error) { + if (!hasCode(error, "ENOENT")) { + throw error; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + continue; + } + if (!this.#inside(resolved, this.#canonicalRoot)) { + throw createENOENT("open", vfsPath); + } + return candidate; + } + return candidate; + } + + #virtual(realPath: string): string { + // readlink may retain the supplied root spelling; realpath returns its canonical spelling. + const root = this.#inside(realPath) ? this.#rootPath : this.#canonicalRoot; + if (!this.#inside(realPath, root)) { + throw createENOENT("realpath", realPath); + } + return "/" + path.relative(root, realPath); + } + + openSync(vfsPath: string, flags: string | number = "r", mode?: number): VirtualFileHandle { + return new RealFileHandle(this.#core, this.#resolve(vfsPath), flags, mode); + } + + async open( + vfsPath: string, + flags: string | number = "r", + mode?: number, + ): Promise { + return this.openSync(vfsPath, flags, mode); + } + + statSync(vfsPath: string, options?: StatOptions): FileStats { + return this.#core.statSync(this.#resolve(vfsPath), options)!; + } + + async stat(vfsPath: string, options?: StatOptions): Promise { + return this.statSync(vfsPath, options); + } + + lstatSync(vfsPath: string, options?: StatOptions): FileStats { + return this.#core.lstatSync(this.#resolve(vfsPath, false), options)!; + } + + async lstat(vfsPath: string, options?: StatOptions): Promise { + return this.lstatSync(vfsPath, options); + } + + readdirSync(vfsPath: string, options?: DirectoryOptions): DirectoryEntries { + return this.#core.readdirSync(this.#resolve(vfsPath), options) as DirectoryEntries; + } + + async readdir(vfsPath: string, options?: DirectoryOptions): Promise { + return this.readdirSync(vfsPath, options); + } + + mkdirSync(vfsPath: string, options?: MkdirOptions): string | undefined { + return this.#core.mkdirSync(this.#resolve(vfsPath), options); + } + + async mkdir(vfsPath: string, options?: MkdirOptions): Promise { + return this.mkdirSync(vfsPath, options); + } + + rmdirSync(vfsPath: string): void { + return this.#core.rmdirSync(this.#resolve(vfsPath, false)); + } + + async rmdir(vfsPath: string): Promise { + return this.rmdirSync(vfsPath); + } + + unlinkSync(vfsPath: string): void { + return this.#core.unlinkSync(this.#resolve(vfsPath, false)); + } + + async unlink(vfsPath: string): Promise { + return this.unlinkSync(vfsPath); + } + + renameSync(oldPath: string, newPath: string): void { + return this.#core.renameSync(this.#resolve(oldPath, false), this.#resolve(newPath, false)); + } + + async rename(oldPath: string, newPath: string): Promise { + return this.renameSync(oldPath, newPath); + } + + linkSync(oldPath: string, newPath: string): void { + return this.#core.linkSync(this.#resolve(oldPath, false), this.#resolve(newPath, false)); + } + + async link(oldPath: string, newPath: string): Promise { + return this.linkSync(oldPath, newPath); + } + + realpathSync(vfsPath: string, _options?: DirectoryOptions): string { + return this.#virtual(String(this.#core.realpathSync(this.#resolve(vfsPath)))); + } + + async realpath(vfsPath: string, options?: DirectoryOptions): Promise { + return this.realpathSync(vfsPath, options); + } + + accessSync(vfsPath: string, mode = 0): void { + return this.#core.accessSync(this.#resolve(vfsPath), mode); + } + + async access(vfsPath: string, mode = 0): Promise { + return this.accessSync(vfsPath, mode); + } + + copyFileSync(source: string, destination: string, mode = 0): void { + return this.#core.copyFileSync(this.#resolve(source), this.#resolve(destination), mode); + } + + async copyFile(source: string, destination: string, mode = 0): Promise { + return this.copyFileSync(source, destination, mode); + } + + readlinkSync(vfsPath: string, options?: DirectoryOptions): string | Buffer { + const target = String(this.#core.readlinkSync(this.#resolve(vfsPath, false))); + + const result = path.isAbsolute(target) ? this.#virtual(target) : target; + return options?.encoding === "buffer" ? NodeBuffer.from(result) : result; + } + + async readlink(vfsPath: string, options?: DirectoryOptions): Promise { + return this.readlinkSync(vfsPath, options); + } + + symlinkSync(target: string, vfsPath: string, type?: string): void { + const destination = this.#resolve(vfsPath, false); + + const realTarget = path.isAbsolute(target) ? this.#resolve(target) : target; + if (type !== undefined && type !== "file" && type !== "dir" && type !== "junction") { + throw invalidArgValue("type", type); + } + this.#core.symlinkSync(realTarget, destination, type); + } + + async symlink(target: string, vfsPath: string, type?: string): Promise { + this.symlinkSync(target, vfsPath, type); + } + + chmodSync(vfsPath: string, mode: number): void { + this.#core.chmodSync(this.#resolve(vfsPath, true), mode); + } + + chownSync(vfsPath: string, uid: number, gid: number): void { + this.#core.chownSync(this.#resolve(vfsPath, true), uid, gid); + } + + lchownSync(vfsPath: string, uid: number, gid: number): void { + this.#core.lchownSync(this.#resolve(vfsPath, false), uid, gid); + } + + utimesSync(vfsPath: string, atime: Time, mtime: Time): void { + this.#core.utimesSync(this.#resolve(vfsPath, true), atime, mtime); + } + + lutimesSync(vfsPath: string, atime: Time, mtime: Time): void { + this.#core.lutimesSync(this.#resolve(vfsPath, false), atime, mtime); + } + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/stats.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/stats.ts new file mode 100644 index 000000000..dcfd58337 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/stats.ts @@ -0,0 +1,83 @@ +/** + * Adapted from nodejs/node v26.8.2, commit + * f2f2c2f246c36bd74f082cb43ecfe830657d81c9, lib/internal/vfs/stats.js. + * Copyright Node.js contributors. MIT license (see jco-std/LICENSE). + * Reconstruct existing portable Stats instead of calling getStatsFromBinding. + */ +import { Stats } from "../../24.x.x/fs/classes.js"; +import type { FsNumeric, FsFileType } from "../../24.x.x/fs/types.js"; +import type { FileStats } from "./types.js"; + +interface StatValues { + mode?: number; + + nlink?: number; + + uid?: number; + + gid?: number; + + atimeMs?: number; + + mtimeMs?: number; + + ctimeMs?: number; + + birthtimeMs?: number; + + bigint?: boolean; +} + +let nextInode = 1; + +function createStats( + size: number, + fileType: FsFileType, + mode: number, + options: StatValues, +): FileStats { + const now = Date.now(); + + const numeric = (value: number): FsNumeric => + options.bigint + ? { tag: "bigint", val: BigInt(Math.trunc(value)) } + : { tag: "number", val: value }; + const time = (value: number | undefined): number => value ?? now; + + const nanos = (value: number | undefined): bigint | undefined => + options.bigint ? BigInt(Math.trunc(time(value) * 1e6)) : undefined; + + return new Stats({ + dev: numeric(4085), + ino: numeric(nextInode++), + mode: numeric(mode | (options.mode ?? (fileType === "directory" ? 0o755 : 0o644))), + nlink: numeric(options.nlink ?? 1), + uid: numeric(options.uid ?? 0), + gid: numeric(options.gid ?? 0), + rdev: numeric(0), + size: numeric(size), + blksize: numeric(4096), + blocks: numeric(Math.ceil(size / 512)), + atimeMs: numeric(time(options.atimeMs)), + mtimeMs: numeric(time(options.mtimeMs)), + ctimeMs: numeric(time(options.ctimeMs)), + birthtimeMs: numeric(time(options.birthtimeMs)), + atimeNs: nanos(options.atimeMs), + mtimeNs: nanos(options.mtimeMs), + ctimeNs: nanos(options.ctimeMs), + birthtimeNs: nanos(options.birthtimeMs), + fileType, + }); +} + +export function createFileStats(size: number, options: StatValues = {}): FileStats { + return createStats(size, "file", 0o100000, options); +} + +export function createDirectoryStats(options: StatValues = {}): FileStats { + return createStats(4096, "directory", 0o40000, options); +} + +export function createSymlinkStats(size: number, options: StatValues = {}): FileStats { + return createStats(size, "symlink", 0o120000, options); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/types.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/types.ts new file mode 100644 index 000000000..96b53ba48 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/types.ts @@ -0,0 +1,68 @@ +import type { Stats, Dirent } from "../../24.x.x/fs/classes.js"; + +export type FileStats = Stats; + +export type FileData = string | Uint8Array; + +export type Position = number | bigint | null; + +export interface VfsOptions { + emitExperimentalWarning?: boolean; +} + +export interface FileOptions { + encoding?: BufferEncoding | null; + + flag?: string; + + mode?: number; +} + +export type ReadFileOptions = BufferEncoding | FileOptions | null; + +export interface StatOptions { + bigint?: boolean; + + throwIfNoEntry?: boolean; +} + +export interface DirectoryOptions { + encoding?: BufferEncoding | "buffer"; + + recursive?: boolean; + + withFileTypes?: boolean; + + bufferSize?: number; +} + +export interface MkdirOptions { + recursive?: boolean; + + mode?: number; +} + +export interface RemoveOptions { + recursive?: boolean; + + force?: boolean; +} + +export type DirectoryEntries = string[] | Buffer[] | Dirent[]; + +export type Time = number | string | Date; + +export type Callback = (error: Error | null, value?: T) => void; + +export type { BufferEncoding } from "../../24.x.x/fs/public-types.js"; +import type { BufferEncoding } from "../../24.x.x/fs/public-types.js"; + +/** Buffer methods used by the VFS contract, without an @types/node dependency. + * Runtime values are the same Buffer objects as the component's node:buffer. + */ +export interface Buffer extends Uint8Array { + toString(encoding?: BufferEncoding, start?: number, end?: number): string; + subarray(start?: number, end?: number): Buffer; + copy(target: Uint8Array, targetStart?: number, sourceStart?: number, sourceEnd?: number): number; + equals(other: Uint8Array): boolean; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/validation.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/validation.ts new file mode 100644 index 000000000..ba49863ef --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/validation.ts @@ -0,0 +1,22 @@ +import { invalidArgType } from "../../24.x.x/errors/core.js"; + +export function hasCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +export function invokeCallback( + callback: ((...args: Args) => void) | undefined, + ...args: Args +): void { + if (typeof callback !== "function") { + throw invalidArgType("callback", "Function", callback); + } + callback(...args); +} + +export function deferCallback( + callback: ((...args: Args) => void) | undefined, + ...args: Args +): void { + queueMicrotask(() => invokeCallback(callback, ...args)); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-directory.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-directory.ts new file mode 100644 index 000000000..882a219e9 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-directory.ts @@ -0,0 +1,58 @@ +import type { FsPath, FsDirectoryEntry } from "../../24.x.x/fs/types.js"; +import type { StorageRoot } from "./wasi-types.js"; +import { path } from "./path.js"; +import { fileType } from "./wasi-stats.js"; +import { text } from "./wasi-errors.js"; + +/** Read directory snapshots while disposing every owned stream and descriptor. */ +export function createWasiReaddir(root: StorageRoot, local: (value: FsPath) => string) { + return function readdir( + value: FsPath, + recursive: boolean, + withFileTypes: boolean, + ): FsDirectoryEntry[] { + const base = text(value); + + const result: FsDirectoryEntry[] = []; + + const pending = [base]; + while (pending.length) { + const parentPath = pending.pop()!; + + const directory = root.descriptor.openAt( + { symlinkFollow: true }, + local({ tag: "text", val: parentPath }), + { directory: true }, + { read: true }, + ); + try { + const stream = directory.readDirectory(); + try { + for (;;) { + const entry = stream.readDirectoryEntry(); + if (!entry) { + break; + } + const childPath = path.join(parentPath, entry.name); + result.push( + withFileTypes + ? { + tag: "dirent", + val: { name: entry.name, parentPath, fileType: fileType(entry.type) }, + } + : { tag: "name", val: path.relative(base, childPath) }, + ); + if (recursive && entry.type === "directory") { + pending.push(childPath); + } + } + } finally { + stream[Symbol.dispose](); + } + } finally { + directory[Symbol.dispose](); + } + } + return result; + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-errors.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-errors.ts new file mode 100644 index 000000000..f52bdf918 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-errors.ts @@ -0,0 +1,87 @@ +import { Buffer as NodeBuffer } from "node:buffer"; +import { unsupportedNodeApi, systemError } from "../../24.x.x/errors/core.js"; +import type { FsHost, FsPath } from "../../24.x.x/fs/types.js"; +import type { HostImports } from "../../24.x.x/internal/wit-types.js"; +import type { Datetime } from "./wasi-types.js"; +import { createEINVAL } from "./errors.js"; + +const ERROR_CODES: Readonly> = { + access: "EACCES", + "not-permitted": "EPERM", + "no-entry": "ENOENT", + "not-directory": "ENOTDIR", + "is-directory": "EISDIR", + exist: "EEXIST", + "not-empty": "ENOTEMPTY", + loop: "ELOOP", + invalid: "EINVAL", + "bad-descriptor": "EBADF", + "read-only": "EROFS", + "cross-device": "EXDEV", + "insufficient-space": "ENOSPC", + io: "EIO", + unsupported: "ENOTSUP", +}; + +/** Component bindings wrap a WIT error-code in ComponentError.payload. */ +export function wasiErrorCode(error: unknown): string | undefined { + if (typeof error === "string") { + return error; + } + if ( + typeof error === "object" && + error !== null && + "payload" in error && + typeof error.payload === "string" + ) { + return error.payload; + } + return undefined; +} + +export function unsupported(): never { + throw unsupportedNodeApi( + "node:vfs", + "this operation has no equivalent in wasi:filesystem@0.2.12", + ); +} + +export function text(value: FsPath): string { + if (value.tag === "text") { + return value.val; + } + if (value.tag === "bytes") { + return NodeBuffer.from(value.val).toString(); + } + throw createEINVAL("open", value.val); +} + +export function timestamp(seconds: number): { tag: "timestamp"; val: Datetime } { + return { + tag: "timestamp", + val: { seconds: BigInt(Math.floor(seconds)), nanoseconds: Math.round((seconds % 1) * 1e9) }, + }; +} + +export function wrapWasiHost(host: HostImports): HostImports { + // Preserve each typed function's arguments and result. Only the exception + // representation changes: WASI error-code strings become Node system errors. + const wrapped = Object.fromEntries( + Object.entries(host).map(([name, operation]) => [ + name, + (...args: unknown[]) => { + try { + return Reflect.apply(operation, host, args); + } catch (error) { + const detail = wasiErrorCode(error); + if (detail === undefined) { + throw error; + } + const code = ERROR_CODES[detail] ?? "EIO"; + throw systemError({ code, syscall: name, message: `${code}: ${error}, ${name}` }); + } + }, + ]), + ); + return wrapped as HostImports; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-files.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-files.ts new file mode 100644 index 000000000..a8063a5f9 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-files.ts @@ -0,0 +1,166 @@ +import { Buffer as NodeBuffer } from "node:buffer"; +import { systemError } from "../../24.x.x/errors/core.js"; +import { O_APPEND, O_CREAT, O_EXCL, O_RDWR, O_TRUNC, O_WRONLY } from "../../24.x.x/fs/constants.js"; +import type { + FsPath, + FsPathOrDescriptor, + FsOpenMode, + FsReadResult, +} from "../../24.x.x/fs/types.js"; +import type { Descriptor, StorageRoot } from "./wasi-types.js"; +import { createEBADF, createEINVAL } from "./errors.js"; + +interface OpenFile { + descriptor: Descriptor; + + position: bigint; + + append: boolean; +} + +function openFlags(mode: FsOpenMode): { + create: boolean; + + exclusive: boolean; + + truncate: boolean; + + read: boolean; + + write: boolean; + + append: boolean; +} { + if (mode.tag === "number") { + return { + create: !!(mode.val & O_CREAT), + exclusive: !!(mode.val & O_EXCL), + truncate: !!(mode.val & O_TRUNC), + read: !(mode.val & O_WRONLY), + write: !!(mode.val & (O_WRONLY | O_RDWR)), + append: !!(mode.val & O_APPEND), + }; + } + const flag = mode.val; + if ( + !["r", "r+", "rs", "rs+", "w", "wx", "w+", "wx+", "a", "ax", "a+", "ax+", "as", "as+"].includes( + flag, + ) + ) { + throw createEINVAL("open", flag); + } + return { + create: flag.startsWith("w") || flag.startsWith("a"), + exclusive: flag.includes("x"), + truncate: flag.startsWith("w"), + read: flag.startsWith("r") || flag.includes("+"), + write: !flag.startsWith("r") || flag.includes("+"), + append: flag.startsWith("a"), + }; +} + +/** Own the descriptors and offsets for one VFS root. */ +export function createWasiFiles(root: StorageRoot, local: (value: FsPath) => string) { + const opened = new Map(); + + let nextFd = 1; + function file(fd: number): OpenFile { + const entry = opened.get(fd); + if (!entry) { + throw createEBADF("open"); + } + return entry; + } + + function open(value: FsPath, flags: FsOpenMode): number { + const selected = openFlags(flags); + + const descriptor = root.descriptor.openAt( + { symlinkFollow: true }, + local(value), + selected, + selected, + ); + const fd = nextFd++; + opened.set(fd, { descriptor, position: 0n, append: selected.append }); + return fd; + } + + function close(fd: number): void { + file(fd).descriptor[Symbol.dispose](); + opened.delete(fd); + } + + function withFile(value: FsPathOrDescriptor, flag: string, operation: (fd: number) => T): T { + if (value.tag === "descriptor") { + return operation(value.val); + } + const fd = open(value.val, { tag: "symbolic", val: flag }); + try { + return operation(fd); + } finally { + close(fd); + } + } + + function read(fd: number, length: number, position?: bigint): FsReadResult { + const entry = file(fd); + + const offset = position ?? entry.position; + + const [data] = entry.descriptor.read(BigInt(length), offset); + if (position === undefined) { + entry.position += BigInt(data.length); + } + return { bytesRead: data.length, data }; + } + + function write(fd: number, data: Uint8Array, position?: bigint): number { + const entry = file(fd); + + const offset = entry.append ? entry.descriptor.stat().size : (position ?? entry.position); + + const count = entry.descriptor.write(data, offset); + if (position === undefined) { + entry.position = offset + count; + } + return Number(count); + } + + function readFile(value: FsPathOrDescriptor, flag = "r"): Uint8Array { + return withFile(value, flag, (fd) => { + const chunks: Uint8Array[] = []; + for (;;) { + const result = read(fd, 65536); + if (!result.bytesRead) { + break; + } + chunks.push(result.data); + } + return NodeBuffer.concat(chunks); + }); + } + + function writeFile( + value: FsPathOrDescriptor, + data: Uint8Array, + flag: string, + flush: boolean, + ): void { + withFile(value, flag, (fd) => { + let written = 0; + while (written < data.length) { + const count = write(fd, data.subarray(written)); + if (!count) { + throw systemError({ code: "EIO", message: "VFS write made no progress" }); + } + written += count; + } + if (flush) { + file(fd).descriptor.sync(); + } + }); + } + + return { file, open, close, withFile, read, write, readFile, writeFile }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts new file mode 100644 index 000000000..a87efcfcd --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts @@ -0,0 +1,213 @@ +import type { FsHost, FsPath } from "../../24.x.x/fs/types.js"; +import type { HostImports } from "../../24.x.x/internal/wit-types.js"; +import { createVfs } from "./core.js"; +import type { VfsModule } from "./core.js"; +import { path } from "./path.js"; +import { createEACCES } from "./errors.js"; +import { resolveStorageRoot, validateStorageRoot, realpathWithin } from "./wasi-paths.js"; +import { wasiStats } from "./wasi-stats.js"; +import { createWasiFiles } from "./wasi-files.js"; +import { createWasiReaddir } from "./wasi-directory.js"; +import { unsupported, text, timestamp, wasiErrorCode, wrapWasiHost } from "./wasi-errors.js"; +import type { WasiVfsOptions } from "./wasi-types.js"; + +export type { Descriptor, Preopen, StorageRoot, WasiVfsOptions } from "./wasi-types.js"; +export { resolveStorageRoot } from "./wasi-paths.js"; + +/** + * One FsHost per VFS root. The resolver chooses storage once, on the first host + * operation. Preopens are borrowed; descriptors and directory streams are owned + * and disposed by the operation or the corresponding virtual file handle. + */ +function createWasiHost(rootPath: string, options: WasiVfsOptions): HostImports { + const root = validateStorageRoot( + (options.resolveRoot ?? resolveStorageRoot)(rootPath, options.preopens.getDirectories()), + ); + + function relative(value: FsPath): string { + const fullPath = path.normalize(text(value)); + + const result = path.relative(rootPath, fullPath); + if (result === ".." || result.startsWith("../")) { + throw createEACCES("open", fullPath); + } + return result; + } + + function local(value: FsPath): string { + return path.join(root.directory, relative(value)); + } + + const { file, open, close, withFile, read, write, readFile, writeFile } = createWasiFiles( + root, + local, + ); + + const readdir = createWasiReaddir(root, local); + + const host: HostImports = { + access(value, mode) { + root.descriptor.statAt({ symlinkFollow: true }, local(value)); + if (mode !== 0) { + unsupported(); + } + }, + appendFile: (value, data, opts) => writeFile(value, data, opts.flag ?? "a", opts.flush), + writeFile: (value, data, opts) => writeFile(value, data, opts.flag ?? "w", opts.flush), + readFile: (value, opts) => readFile(value, opts.flag), + close, + open: (value, flags) => open(value, flags), + read, + write, + readdir: (value, opts) => readdir(value, opts.recursive, opts.withFileTypes), + exists(value) { + try { + root.descriptor.statAt({ symlinkFollow: true }, local(value)); + return true; + } catch (error) { + if (wasiErrorCode(error) === "no-entry" || wasiErrorCode(error) === "not-directory") { + return false; + } + throw error; + } + }, + stat(value, opts) { + try { + return wasiStats( + root.descriptor.statAt({ symlinkFollow: true }, local(value)), + opts.bigint, + ); + } catch (error) { + if (wasiErrorCode(error) === "no-entry" && !opts.throwIfNoEntry) { + return undefined; + } + throw error; + } + }, + lstat(value, opts) { + try { + return wasiStats(root.descriptor.statAt({}, local(value)), opts.bigint); + } catch (error) { + if (wasiErrorCode(error) === "no-entry" && !opts.throwIfNoEntry) { + return undefined; + } + throw error; + } + }, + fstat: (fd, opts) => wasiStats(file(fd).descriptor.stat(), opts.bigint), + ftruncate: (fd, length) => file(fd).descriptor.setSize(BigInt(length)), + truncate: (value, length) => + withFile({ tag: "path", val: value }, "r+", (fd) => + file(fd).descriptor.setSize(BigInt(length)), + ), + fsync: (fd) => file(fd).descriptor.sync(), + fdatasync: (fd) => file(fd).descriptor.syncData(), + futimes: (fd, atime, mtime) => file(fd).descriptor.setTimes(timestamp(atime), timestamp(mtime)), + utimes: (value, atime, mtime) => + root.descriptor.setTimesAt( + { symlinkFollow: true }, + local(value), + timestamp(atime), + timestamp(mtime), + ), + lutimes: (value, atime, mtime) => + root.descriptor.setTimesAt({}, local(value), timestamp(atime), timestamp(mtime)), + readlink: (value) => root.descriptor.readlinkAt(local(value)), + realpath: (value) => path.join(rootPath, realpathWithin(root, relative(value))), + unlink: (value) => root.descriptor.unlinkFileAt(local(value)), + rmdir: (value) => root.descriptor.removeDirectoryAt(local(value)), + rename: (source, destination) => + root.descriptor.renameAt(local(source), root.descriptor, local(destination)), + link: (source, destination) => + root.descriptor.linkAt({}, local(source), root.descriptor, local(destination)), + symlink(target, value) { + const targetPath = text(target); + + const stored = path.isAbsolute(targetPath) + ? path.relative(path.dirname(local(value)), local(target)) + : targetPath; + root.descriptor.symlinkAt(stored, local(value)); + }, + mkdir(value, opts) { + const target = local(value); + if (!opts.recursive) { + root.descriptor.createDirectoryAt(target); + return undefined; + } + let current = "."; + + let first: string | undefined; + for (const part of target.split("/")) { + current = path.join(current, part); + try { + root.descriptor.createDirectoryAt(current); + first ??= path.join(rootPath, path.relative(root.directory, current)); + } catch (error) { + if ( + wasiErrorCode(error) !== "exist" || + root.descriptor.statAt({ symlinkFollow: true }, current).type !== "directory" + ) { + throw error; + } + } + } + return first; + }, + copyFile(source, destination, mode) { + if (mode & ~1) { + unsupported(); + } + writeFile( + { tag: "path", val: destination }, + readFile({ tag: "path", val: source }), + mode & 1 ? "wx" : "w", + false, + ); + }, + readv(fd, lengths, position) { + const buffers: Uint8Array[] = []; + + let bytesRead = 0; + for (const length of lengths) { + const result = read( + fd, + length, + position === undefined ? undefined : position + BigInt(bytesRead), + ); + buffers.push(result.data); + bytesRead += result.bytesRead; + if (result.bytesRead < length) { + break; + } + } + return { bytesRead, buffers }; + }, + writev(fd, buffers, position) { + let bytesWritten = 0; + for (const buffer of buffers) { + bytesWritten += write( + fd, + buffer, + position === undefined ? undefined : position + BigInt(bytesWritten), + ); + } + return bytesWritten; + }, + chmod: unsupported, + chown: unsupported, + fchmod: unsupported, + fchown: unsupported, + lchown: unsupported, + statfs: unsupported, + cp: unsupported, + glob: unsupported, + mkdtemp: unsupported, + rm: unsupported, + }; + + return wrapWasiHost(host); +} + +export function createWasiVfs(options: WasiVfsOptions): VfsModule { + return createVfs((rootPath) => createWasiHost(rootPath, options)); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-paths.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-paths.ts new file mode 100644 index 000000000..a89ff41c2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-paths.ts @@ -0,0 +1,72 @@ +import { path } from "./path.js"; +import { createEACCES, createEINVAL, createELOOP } from "./errors.js"; +import type { Preopen, StorageRoot } from "./wasi-types.js"; + +/** Choose the longest preopen mount containing the requested VFS root. */ +export function resolveStorageRoot(rootPath: string, preopens: readonly Preopen[]): StorageRoot { + const candidates = preopens + .filter(([, mount]) => { + const normalized = path.resolve("/", mount); + return ( + rootPath === normalized || rootPath.startsWith(normalized === "/" ? "/" : normalized + "/") + ); + }) + .sort((a, b) => path.resolve("/", b[1]).length - path.resolve("/", a[1]).length); + const selected = candidates[0]; + if (!selected) { + throw createEACCES("open", rootPath); + } + return { + descriptor: selected[0], + directory: path.relative(path.resolve("/", selected[1]), rootPath) || ".", + }; +} + +export function validateStorageRoot(root: StorageRoot): StorageRoot { + if (!root || typeof root.directory !== "string" || !root.descriptor) { + throw createEINVAL("open"); + } + const normalized = path.normalize(root.directory); + if (path.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../")) { + throw createEACCES("open", root.directory); + } + return { descriptor: root.descriptor, directory: normalized }; +} + +/** Resolve links within the selected preopen, retaining a finite symlink budget. */ +export function realpathWithin(root: StorageRoot, relative: string): string { + const segments = relative.split("/").filter(Boolean); + + const resolved: string[] = []; + + let links = 0; + while (segments.length) { + const segment = segments.shift()!; + if (segment === ".") { + continue; + } + if (segment === "..") { + if (!resolved.length) { + throw createEACCES("realpath", relative); + } + resolved.pop(); + continue; + } + const local = path.join(root.directory, ...resolved, segment); + + const stats = root.descriptor.statAt({}, local); + if (stats.type !== "symbolic-link") { + resolved.push(segment); + continue; + } + if (++links > 40) { + throw createELOOP("realpath", relative); + } + const target = root.descriptor.readlinkAt(local); + if (path.isAbsolute(target)) { + throw createEACCES("realpath", relative); + } + segments.unshift(...target.split("/")); + } + return resolved.join("/"); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-stats.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-stats.ts new file mode 100644 index 000000000..1e30477de --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-stats.ts @@ -0,0 +1,49 @@ +import type { FsStats, FsNumeric, FsFileType } from "../../24.x.x/fs/types.js"; +import type { Datetime, DescriptorStat, DescriptorType } from "./wasi-types.js"; + +export function fileType(type: DescriptorType): FsFileType { + switch (type) { + case "regular-file": + return "file"; + case "symbolic-link": + return "symlink"; + case "block-device": + return "block"; + case "character-device": + return "character"; + default: + return type; + } +} + +export function wasiStats(stat: DescriptorStat, bigint: boolean): FsStats { + const numeric = (value: number | bigint): FsNumeric => + bigint ? { tag: "bigint", val: BigInt(value) } : { tag: "number", val: Number(value) }; + const nanos = (value?: Datetime): bigint => + value ? value.seconds * 1_000_000_000n + BigInt(value.nanoseconds) : 0n; + const millis = (value?: Datetime): FsNumeric => + bigint ? numeric(nanos(value) / 1_000_000n) : numeric(Number(nanos(value)) / 1e6); + const mode = + stat.type === "directory" ? 0o40755 : stat.type === "symbolic-link" ? 0o120777 : 0o100644; + return { + dev: numeric(0), + ino: numeric(0), + mode: numeric(mode), + nlink: numeric(stat.linkCount), + uid: numeric(0), + gid: numeric(0), + rdev: numeric(0), + size: numeric(stat.size), + blksize: numeric(4096), + blocks: numeric((stat.size + 511n) / 512n), + atimeMs: millis(stat.dataAccessTimestamp), + mtimeMs: millis(stat.dataModificationTimestamp), + ctimeMs: millis(stat.statusChangeTimestamp), + birthtimeMs: numeric(0), + atimeNs: bigint ? nanos(stat.dataAccessTimestamp) : undefined, + mtimeNs: bigint ? nanos(stat.dataModificationTimestamp) : undefined, + ctimeNs: bigint ? nanos(stat.statusChangeTimestamp) : undefined, + birthtimeNs: bigint ? 0n : undefined, + fileType: fileType(stat.type), + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-types.ts b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-types.ts new file mode 100644 index 000000000..294c15a1f --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-types.ts @@ -0,0 +1,90 @@ +/** Structural subset of wasi:filesystem/types@0.2.12 used by the VFS implementation. */ +export interface Datetime { + seconds: bigint; + + nanoseconds: number; +} + +export type DescriptorType = + | "unknown" + | "block-device" + | "character-device" + | "directory" + | "fifo" + | "symbolic-link" + | "regular-file" + | "socket"; + +export interface DescriptorStat { + type: DescriptorType; + + linkCount: bigint; + + size: bigint; + + dataAccessTimestamp?: Datetime; + + dataModificationTimestamp?: Datetime; + + statusChangeTimestamp?: Datetime; +} + +export interface DirectoryEntryStream { + readDirectoryEntry(): { type: DescriptorType; name: string } | undefined; + [Symbol.dispose](): void; +} + +export interface Descriptor { + openAt( + pathFlags: { symlinkFollow?: boolean }, + path: string, + openFlags: { create?: boolean; directory?: boolean; exclusive?: boolean; truncate?: boolean }, + flags: { read?: boolean; write?: boolean }, + ): Descriptor; + read(length: bigint, offset: bigint): [Uint8Array, boolean]; + write(data: Uint8Array, offset: bigint): bigint; + stat(): DescriptorStat; + statAt(flags: { symlinkFollow?: boolean }, path: string): DescriptorStat; + readDirectory(): DirectoryEntryStream; + createDirectoryAt(path: string): void; + removeDirectoryAt(path: string): void; + unlinkFileAt(path: string): void; + renameAt(path: string, destination: Descriptor, newPath: string): void; + linkAt( + flags: { symlinkFollow?: boolean }, + path: string, + destination: Descriptor, + newPath: string, + ): void; + readlinkAt(path: string): string; + symlinkAt(target: string, path: string): void; + setSize(size: bigint): void; + setTimes( + atime: { tag: "timestamp"; val: Datetime }, + mtime: { tag: "timestamp"; val: Datetime }, + ): void; + setTimesAt( + flags: { symlinkFollow?: boolean }, + path: string, + atime: { tag: "timestamp"; val: Datetime }, + mtime: { tag: "timestamp"; val: Datetime }, + ): void; + sync(): void; + syncData(): void; + [Symbol.dispose](): void; +} + +export type Preopen = readonly [descriptor: Descriptor, guestPath: string]; + +export interface StorageRoot { + /** Borrowed capability: the implementation never disposes the selected preopen. */ + descriptor: Descriptor; + /** Directory relative to the preopen. Use "." for its root. Must already exist. */ + directory: string; +} + +export interface WasiVfsOptions { + preopens: { getDirectories(): Preopen[] }; + /** Called lazily, once per RealFSProvider. Memory providers never call it. */ + resolveRoot?: (rootPath: string, preopens: readonly Preopen[]) => StorageRoot; +} diff --git a/packages/jco-std/test/vitest.ts b/packages/jco-std/test/vitest.ts index 5a83d0408..a6f6fae61 100644 --- a/packages/jco-std/test/vitest.ts +++ b/packages/jco-std/test/vitest.ts @@ -22,6 +22,7 @@ export default defineConfig({ // unconditionally would abort on Node 24, which does not know the flag. execArgv: [ "--expose-gc", + ...(process.execArgv.includes("--experimental-vfs") ? ["--experimental-vfs"] : []), ...(process.execArgv.includes("--experimental-ffi") ? ["--experimental-ffi"] : []), ], }, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/unsupported.ts index ebf9e5f59..6224112cf 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/unsupported.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/unsupported.ts @@ -8,9 +8,10 @@ import { fs, promises } from "../helpers/fs.js"; describe("node:fs denied and unsupported behavior", () => { test.concurrent("denies host access by default", () => { - expect(() => denyHost.access({ tag: "text", val: "ignored" }, 0)).toThrow( - expect.objectContaining({ code: "ERR_JCO_FS_ADAPTER_REQUIRED" }), - ); + expect(denyHost.access({ tag: "text", val: "ignored" }, 0)).toMatchObject({ + tag: "err", + val: { code: "ERR_JCO_FS_ADAPTER_REQUIRED" }, + }); const core = createFsCore(denyHost); const deniedFs = createFs(core, createFsPromises(core)); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/helpers/vfs.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/helpers/vfs.ts new file mode 100644 index 000000000..4eefde7d5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/helpers/vfs.ts @@ -0,0 +1,31 @@ +import { createRequire } from "node:module"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createVfs } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/core.js"; +import type { VfsModule } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/core.js"; +import * as denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/fs-host.js"; +import * as host from "../../../../../../src/wasi/0.2.x/node/24.x.x/fs-host-node.js"; + +export const vfs = createVfs(denied); + +export const realVfs = createVfs(host); + +export const memory = () => vfs.create({ emitExperimentalWarning: false }); + +/** Only the pinned release is an oracle; fixture expectations still run on Node 24. */ +export function oracle(): VfsModule | undefined { + if (process.version !== "v26.8.2") { + return undefined; + } + return createRequire(import.meta.url)("node:vfs") as VfsModule; +} + +export async function withDirectory(run: (root: string) => T | Promise): Promise { + const root = await mkdtemp(join(tmpdir(), "jco-vfs-")); + try { + return await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/access.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/access.ts new file mode 100644 index 000000000..78ddb8abe --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/access.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: checks owner permission bits`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x", { mode: 0o400 }); + fs.accessSync("/file", 4); + expect(() => fs.accessSync("/file", 2)).toThrow(expect.objectContaining({ code: "EACCES" })); + expect(() => fs.accessSync("/missing")).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/append-file.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/append-file.ts new file mode 100644 index 000000000..7a96af731 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/append-file.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: appends without truncating and honors an explicit replacement flag`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "a"); + fs.appendFileSync("/file", "b"); + await fs.promises.appendFile("/file", "c"); + expect(fs.readFileSync("/file", "utf8")).toBe("abc"); + fs.appendFileSync("/file", "reset", { flag: "w" }); + expect(fs.readFileSync("/file", "utf8")).toBe("reset"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chmod.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chmod.ts new file mode 100644 index 000000000..07b9492eb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chmod.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: changes permission bits`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.chmodSync("/file", 0o600); + expect(Number(fs.statSync("/file").mode) & 0o777).toBe(0o600); + await fs.promises.chmod("/file", 0o400); + expect(Number(fs.statSync("/file").mode) & 0o777).toBe(0o400); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chown.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chown.ts new file mode 100644 index 000000000..048812597 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/chown.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: updates file ownership`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.chownSync("/file", 123, 456); + expect(fs.statSync("/file")).toMatchObject({ uid: 123, gid: 456 }); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/close.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/close.ts new file mode 100644 index 000000000..e08fd4adf --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/close.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: invalidates descriptors and reports double close`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + const fd = fs.openSync("/file"); + fs.closeSync(fd); + expect(() => fs.closeSync(fd)).toThrow(expect.objectContaining({ code: "EBADF" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/copy-file.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/copy-file.ts new file mode 100644 index 000000000..78f1b8836 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/copy-file.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: copies bytes and rejects an existing exclusive destination`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/source", "copy"); + fs.copyFileSync("/source", "/dest"); + expect(fs.readFileSync("/dest", "utf8")).toBe("copy"); + expect(() => fs.copyFileSync("/source", "/dest", 1)).toThrow( + expect.objectContaining({ code: "EEXIST" }), + ); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/create.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/create.ts new file mode 100644 index 000000000..3041a9520 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/create.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { vfs } from "../helpers/vfs.js"; + +test("creates isolated trees and accepts provider/options overloads", () => { + const first = vfs.create({ emitExperimentalWarning: false }); + const second = new vfs.VirtualFileSystem(); + first.writeFileSync("/file", "first"); + expect(second.existsSync("/file")).toBe(false); + expect(first.provider).toBeInstanceOf(vfs.MemoryProvider); + expect(vfs.create(first.provider).readFileSync("/file", "utf8")).toBe("first"); + expect(Object.isFrozen(first.promises)).toBe(true); + expect(first.promises).toBe(first.promises); + expect(() => vfs.create({ emitExperimentalWarning: "yes" as unknown as boolean })).toThrow( + /boolean/, + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/fstat.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/fstat.ts new file mode 100644 index 000000000..2545e1fbd --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/fstat.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: reports metadata for open descriptors`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "abc"); + const fd = fs.openSync("/file"); + expect(fs.fstatSync(fd).size).toBe(3); + fs.closeSync(fd); + expect(() => fs.fstatSync(fd)).toThrow(expect.objectContaining({ code: "EBADF" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/ftruncate.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/ftruncate.ts new file mode 100644 index 000000000..1fb9dddff --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/ftruncate.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: truncates an open virtual descriptor`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "abcdef"); + const fd = fs.openSync("/file", "r+"); + fs.ftruncateSync(fd, 2); + expect(fs.readFileSync("/file", "utf8")).toBe("ab"); + fs.closeSync(fd); + expect(() => fs.ftruncateSync(fd, 0)).toThrow(expect.objectContaining({ code: "EBADF" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lchown.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lchown.ts new file mode 100644 index 000000000..6b5aef6ba --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lchown.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: changes link ownership without changing its target`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.symlinkSync("/file", "/link"); + fs.lchownSync("/link", 12, 34); + expect(fs.lstatSync("/link")).toMatchObject({ uid: 12, gid: 34 }); + expect(fs.statSync("/file").uid).toBe(0); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/link.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/link.ts new file mode 100644 index 000000000..b758c5b2b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/link.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: shares a file entry and updates link counts`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.linkSync("/file", "/alias"); + expect(fs.statSync("/file").nlink).toBe(2); + fs.writeFileSync("/alias", "shared"); + expect(fs.readFileSync("/file", "utf8")).toBe("shared"); + fs.unlinkSync("/alias"); + expect(fs.statSync("/file").nlink).toBe(1); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lstat.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lstat.ts new file mode 100644 index 000000000..2c4f0afc2 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lstat.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: inspects symlinks without following them`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "data"); + fs.symlinkSync("/file", "/link"); + expect(fs.lstatSync("/link").isSymbolicLink()).toBe(true); + expect(fs.statSync("/link").isFile()).toBe(true); + expect((await fs.promises.lstat("/link")).isSymbolicLink()).toBe(true); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lutimes.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lutimes.ts new file mode 100644 index 000000000..f46e6c50f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/lutimes.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: sets link timestamps without touching the target`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.symlinkSync("/file", "/link"); + fs.lutimesSync("/link", 10, 20); + expect(fs.lstatSync("/link")).toMatchObject({ atimeMs: 10000, mtimeMs: 20000 }); + expect(fs.statSync("/file").mtimeMs).not.toBe(20000); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/memory-provider.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/memory-provider.ts new file mode 100644 index 000000000..a2e103917 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/memory-provider.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { vfs, memory } from "../helpers/vfs.js"; + +test("setReadOnly prevents new writes while preserving stored content", async () => { + const fs = memory(); + fs.writeFileSync("/file", "stored"); + (fs.provider as InstanceType).setReadOnly(); + expect(fs.readonly).toBe(true); + expect(fs.readFileSync("/file", "utf8")).toBe("stored"); + expect(() => fs.writeFileSync("/file", "changed")).toThrow( + expect.objectContaining({ code: "EROFS" }), + ); + await expect(fs.promises.mkdir("/new")).rejects.toMatchObject({ code: "EROFS" }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdir.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdir.ts new file mode 100644 index 000000000..14e5db88b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdir.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: creates parents and reports the first directory created`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + expect(fs.mkdirSync("/a/b", { recursive: true })).toBe("/a"); + expect(fs.mkdirSync("/a/b", { recursive: true })).toBeUndefined(); + expect(() => fs.mkdirSync("/a/b")).toThrow(expect.objectContaining({ code: "EEXIST" })); + expect(() => fs.mkdirSync("/missing/child")).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdtemp.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdtemp.ts new file mode 100644 index 000000000..0f00c8f95 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/mkdtemp.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: creates a unique directory with a six-character suffix`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + const first = fs.mkdtempSync("/tmp-"); + const second = await fs.promises.mkdtemp("/tmp-"); + expect(first).toMatch(/^\/tmp-[a-zA-Z0-9]{6}$/); + expect(first).not.toBe(second); + expect(fs.statSync(first).isDirectory()).toBe(true); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/module.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/module.ts new file mode 100644 index 000000000..e705029b1 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/module.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +test("exports the Node VFS namespace and constructor relationships", () => { + expect(Object.keys(vfs).sort()).toEqual([ + "MemoryProvider", + "RealFSProvider", + "VirtualFileSystem", + "VirtualProvider", + "create", + ]); + expect(new vfs.MemoryProvider()).toBeInstanceOf(vfs.VirtualProvider); + expect(new vfs.RealFSProvider("/data")).toBeInstanceOf(vfs.VirtualProvider); + const native = oracle(); + if (native) { + expect(Object.keys(vfs)).toEqual(Object.keys(native)); + } +}); + +test("preserves the VirtualFileSystem and promise namespace member sets", () => { + const native = oracle(); + if (!native) { + return; + } + expect(Object.getOwnPropertyNames(vfs.VirtualFileSystem.prototype).sort()).toEqual( + Object.getOwnPropertyNames(native.VirtualFileSystem.prototype).sort(), + ); + expect(Object.keys(vfs.create().promises).sort()).toEqual( + Object.keys(native.create({ emitExperimentalWarning: false }).promises).sort(), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open-as-blob.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open-as-blob.ts new file mode 100644 index 000000000..ab2843302 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open-as-blob.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: returns immutable file content and MIME type`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "blob"); + const blob = fs.openAsBlob("/file", { type: "text/plain" }); + fs.writeFileSync("/file", "changed"); + expect(await blob.text()).toBe("blob"); + expect(blob.type).toBe("text/plain"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open.ts new file mode 100644 index 000000000..4a684a019 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/open.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: uses virtual descriptor numbers including promise open`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + const fd = await fs.promises.open("/file", "r"); + expect(typeof fd).toBe("number"); + expect(fd).toBeGreaterThanOrEqual(0x40000000); + fs.closeSync(fd); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/opendir.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/opendir.ts new file mode 100644 index 000000000..88e113440 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/opendir.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: iterates entries and closes directory handles`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.mkdirSync("/dir"); + fs.writeFileSync("/dir/file", "x"); + const dir = fs.opendirSync("/dir"); + expect(dir.readSync()?.name).toBe("file"); + expect(dir.readSync()).toBeNull(); + dir.closeSync(); + expect(() => dir.readSync()).toThrow(expect.objectContaining({ code: "ERR_DIR_CLOSED" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read-file.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read-file.ts new file mode 100644 index 000000000..824ecddbb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read-file.ts @@ -0,0 +1,27 @@ +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: reads buffers, encodings, callbacks and promises`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "héllo"); + expect(fs.readFileSync("/file").toString()).toBe("héllo"); + expect(fs.readFileSync("/file", "hex")).toBe(Buffer.from("héllo").toString("hex")); + expect(await fs.promises.readFile("/file", "utf8")).toBe("héllo"); + expect( + await new Promise((resolve, reject) => + fs.readFile("/file", "utf8", (err, data) => (err ? reject(err) : resolve(data))), + ), + ).toBe("héllo"); + expect(() => fs.readFileSync("/missing")).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read.ts new file mode 100644 index 000000000..d60f7feb8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/read.ts @@ -0,0 +1,27 @@ +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: supports sequential and explicit-position descriptor reads`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "abcdef"); + const fd = fs.openSync("/file"); + const data = Buffer.alloc(2); + expect(fs.readSync(fd, data, 0, 2, null)).toBe(2); + expect(data.toString()).toBe("ab"); + expect(fs.readSync(fd, data, 0, 2, 4n)).toBe(2); + expect(data.toString()).toBe("ef"); + fs.readSync(fd, data, 0, 2, null); + expect(data.toString()).toBe("cd"); + fs.closeSync(fd); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readdir.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readdir.ts new file mode 100644 index 000000000..89892c4db --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readdir.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: lists recursive entries and their parent directories`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.mkdirSync("/dir/sub", { recursive: true }); + fs.writeFileSync("/dir/sub/file", "x"); + expect(fs.readdirSync("/dir", { recursive: true })).toEqual(["sub", "sub/file"]); + const entries = fs.readdirSync("/dir", { withFileTypes: true }); + expect(entries[0]).toMatchObject({ name: "sub", parentPath: "/dir" }); + expect(fs.readdirSync("/dir/sub")).toEqual(["file"]); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readlink.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readlink.ts new file mode 100644 index 000000000..a2cd00850 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/readlink.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: returns the stored link target`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.symlinkSync("../target", "/link"); + expect(fs.readlinkSync("/link")).toBe("../target"); + expect(await fs.promises.readlink("/link")).toBe("../target"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/real-fs-provider.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/real-fs-provider.ts new file mode 100644 index 000000000..0165e32d6 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/real-fs-provider.ts @@ -0,0 +1,53 @@ +import { readFile, writeFile, symlink, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { realVfs, vfs, withDirectory } from "../helpers/vfs.js"; + +test("host access is denied by default and lazy", () => { + const fs = vfs.create(new vfs.RealFSProvider("/data")); + expect(fs.provider).toBeInstanceOf(vfs.RealFSProvider); + expect(() => fs.readFileSync("/file")).toThrow( + expect.objectContaining({ code: "ERR_JCO_FS_ADAPTER_REQUIRED" }), + ); +}); + +test("Node passthrough reads, writes and rejects escaping symbolic links", async () => { + await withDirectory(async (root) => { + await mkdir(join(root, "inside")); + await writeFile(join(root, "outside"), "secret"); + await symlink("../outside", join(root, "inside", "link")); + const fs = realVfs.create(new realVfs.RealFSProvider(join(root, "inside"))); + await fs.promises.writeFile("/file", "host"); + expect(await readFile(join(root, "inside", "file"), "utf8")).toBe("host"); + expect(fs.readFileSync("/file", "utf8")).toBe("host"); + expect(() => fs.readFileSync("/link")).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +}); + +for (const suffix of ["", "nested"]) { + test(`Node passthrough accepts a symlink in the root ${suffix ? "ancestor" : "directory"}`, async () => { + await withDirectory(async (root) => { + await mkdir(join(root, "storage", "nested"), { recursive: true }); + await symlink("storage", join(root, "alias"), "dir"); + await writeFile(join(root, "outside"), "secret"); + + const provider = new realVfs.RealFSProvider(join(root, "alias", suffix)); + const fs = realVfs.create(provider); + expect(provider.rootPath).toBe(join(root, "alias", suffix)); + + fs.mkdirSync("/dir/sub", { recursive: true }); + await fs.promises.writeFile("/dir/sub/file", "stored"); + expect(await readFile(join(root, "storage", suffix, "dir/sub/file"), "utf8")).toBe("stored"); + expect(fs.realpathSync("/dir/sub/file")).toBe("/dir/sub/file"); + + fs.symlinkSync("/dir/sub/file", "/link"); + expect(fs.readlinkSync("/link")).toBe("/dir/sub/file"); + expect(fs.readFileSync("/link", "utf8")).toBe("stored"); + + await symlink(join(root, "outside"), join(root, "storage", suffix, "escape")); + expect(() => fs.readFileSync("/escape")).toThrow(expect.objectContaining({ code: "ENOENT" })); + await fs.promises.rm("/dir", { recursive: true }); + expect(fs.existsSync("/dir")).toBe(false); + }); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/realpath.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/realpath.ts new file mode 100644 index 000000000..17a00e100 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/realpath.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: resolves relative symlinks`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.mkdirSync("/dir"); + fs.writeFileSync("/file", "x"); + fs.symlinkSync("../file", "/dir/link"); + expect(fs.realpathSync("/dir/link")).toBe("/file"); + expect(await fs.promises.realpath("/dir/link")).toBe("/file"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rename.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rename.ts new file mode 100644 index 000000000..31bc81979 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rename.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: moves entries and replaces a destination file`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/old", "old"); + fs.writeFileSync("/new", "new"); + fs.renameSync("/old", "/new"); + expect(fs.existsSync("/old")).toBe(false); + expect(fs.readFileSync("/new", "utf8")).toBe("old"); + fs.mkdirSync("/dir"); + await fs.promises.rename("/new", "/dir/file"); + expect(fs.readdirSync("/dir")).toEqual(["file"]); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rm.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rm.ts new file mode 100644 index 000000000..c45628ab3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rm.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: recursively removes trees without following external links`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.mkdirSync("/dir"); + fs.writeFileSync("/outside", "keep"); + fs.symlinkSync("/outside", "/dir/link"); + expect(() => fs.rmSync("/dir")).toThrow(expect.objectContaining({ code: "EISDIR" })); + await fs.promises.rm("/dir", { recursive: true }); + expect(fs.readFileSync("/outside", "utf8")).toBe("keep"); + expect(() => fs.rmSync("/missing", { force: true })).not.toThrow(); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rmdir.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rmdir.ts new file mode 100644 index 000000000..95bbee638 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/rmdir.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: refuses nonempty directories`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.mkdirSync("/dir"); + fs.writeFileSync("/dir/file", "x"); + expect(() => fs.rmdirSync("/dir")).toThrow(expect.objectContaining({ code: "ENOTEMPTY" })); + fs.unlinkSync("/dir/file"); + fs.rmdirSync("/dir"); + expect(fs.existsSync("/dir")).toBe(false); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/stat.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/stat.ts new file mode 100644 index 000000000..9c52242b6 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/stat.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: returns file metadata and bigint fields`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "four"); + expect(fs.statSync("/file").isFile()).toBe(true); + expect(fs.statSync("/file").size).toBe(4); + expect(fs.statSync("/file", { bigint: true }).size).toBe(4n); + const directory = await fs.promises.stat("/"); + expect(directory.isDirectory()).toBe(true); + expect(directory.size).toBe(4096); + expect(directory.blocks).toBe(8); + expect(() => fs.statSync("/missing")).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/storage-root.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/storage-root.ts new file mode 100644 index 000000000..b19aa2049 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/storage-root.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { + resolveStorageRoot, + validateStorageRoot, +} from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/wasi-paths.js"; +import type { Descriptor } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/wasi-types.js"; + +// Selection must not consult the borrowed descriptor; every property read fails. +const descriptor = new Proxy( + {}, + { + get() { + throw new Error("descriptor used during selection"); + }, + }, +) as Descriptor; + +test("default storage picks the most specific mount and returns a relative directory", () => { + const selected = resolveStorageRoot("/data/projects/project", [ + [descriptor, "/"], + [descriptor, "/data"], + [descriptor, "/data/projects"], + ]); + expect(Object.is(selected.descriptor, descriptor)).toBe(true); + expect(selected.directory).toBe("project"); + expect(resolveStorageRoot("/data", [[descriptor, "/data"]]).directory).toBe("."); + expect( + resolveStorageRoot("/data/projects/project", [ + [descriptor, "/data/./././././"], + [descriptor, "/data/projects"], + ]).directory, + ).toBe("project"); + expect(() => resolveStorageRoot("/database", [[descriptor, "/data"]])).toThrow( + expect.objectContaining({ code: "EACCES" }), + ); + expect(() => resolveStorageRoot("/data", [])).toThrow( + expect.objectContaining({ code: "EACCES" }), + ); +}); + +test("custom storage directories cannot lexically escape the selected preopen", () => { + for (const directory of ["../escape", "/absolute", "nested/../../escape"]) { + expect(() => validateStorageRoot({ descriptor, directory })).toThrow( + expect.objectContaining({ code: "EACCES" }), + ); + } + expect(validateStorageRoot({ descriptor, directory: "nested/../storage" }).directory).toBe( + "storage", + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/symlink.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/symlink.ts new file mode 100644 index 000000000..a345f29e7 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/symlink.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: rejects loops and preserves dangling links`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.symlinkSync("/b", "/a"); + fs.symlinkSync("/a", "/b"); + expect(() => fs.statSync("/a")).toThrow(expect.objectContaining({ code: "ELOOP" })); + fs.symlinkSync("/absent", "/dangling"); + expect(fs.lstatSync("/dangling").isSymbolicLink()).toBe(true); + expect(fs.existsSync("/dangling")).toBe(false); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/truncate.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/truncate.ts new file mode 100644 index 000000000..1957da12b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/truncate.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: shrinks files and zero-fills growth`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "abcdef"); + fs.truncateSync("/file", 3); + expect(fs.readFileSync("/file", "utf8")).toBe("abc"); + await fs.promises.truncate("/file", 5); + expect([...(fs.readFileSync("/file") as Uint8Array)]).toEqual([97, 98, 99, 0, 0]); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unlink.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unlink.ts new file mode 100644 index 000000000..662b636ff --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unlink.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: removes a symlink while keeping its target`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.symlinkSync("/file", "/link"); + fs.unlinkSync("/link"); + expect(fs.existsSync("/link")).toBe(false); + expect(fs.readFileSync("/file", "utf8")).toBe("x"); + expect(() => fs.unlinkSync("/missing")).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unsupported.ts new file mode 100644 index 000000000..bee1cc84a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/unsupported.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { memory, vfs } from "../helpers/vfs.js"; + +test("native hooks, streams and watchers fail before inspecting arguments", () => { + const fs = memory(); + const poison = new Proxy( + {}, + { + get() { + throw new Error("argument inspected"); + }, + }, + ); + for (const operation of [ + () => fs.mount(poison), + () => fs.unmount(poison), + () => fs.createReadStream(poison), + () => fs.createWriteStream(poison), + () => fs.watch(poison), + () => fs.watchFile(poison), + () => fs.unwatchFile(poison), + () => fs.promises.watch(poison), + () => new vfs.MemoryProvider().watch(poison), + ]) { + expect(operation).toThrow(expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" })); + } + expect(fs.mounted).toBe(false); + expect(fs.mountPoint).toBeNull(); + expect(fs.shouldHandle("/file")).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/utimes.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/utimes.ts new file mode 100644 index 000000000..33348fef0 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/utimes.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: converts seconds and dates into timestamps`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "x"); + fs.utimesSync("/file", 10, new Date(20000)); + expect(fs.statSync("/file")).toMatchObject({ atimeMs: 10000, mtimeMs: 20000 }); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/virtual-provider.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/virtual-provider.ts new file mode 100644 index 000000000..93d9fd1fe --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/virtual-provider.ts @@ -0,0 +1,34 @@ +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { vfs } from "../helpers/vfs.js"; +import { VirtualFileHandle } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/file-handle.js"; + +test("derived provider reads close the supplied handle even on failure", () => { + let closed = 0; + let fail = false; + class Handle extends VirtualFileHandle { + readFileSync(): Buffer { + if (fail) { + throw new Error("read failed"); + } + return Buffer.from("custom"); + } + closeSync(): void { + closed++; + super.closeSync(); + } + } + class Provider extends vfs.VirtualProvider { + openSync(): VirtualFileHandle { + return new Handle("/file", "r"); + } + } + expect(vfs.create(new Provider()).readFileSync("/file").toString()).toBe("custom"); + expect(closed).toBe(1); + fail = true; + expect(() => vfs.create(new Provider()).readFileSync("/file")).toThrow("read failed"); + expect(closed).toBe(2); + expect(() => new vfs.VirtualProvider().statSync("/x")).toThrow( + expect.objectContaining({ code: "ERR_METHOD_NOT_IMPLEMENTED" }), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts new file mode 100644 index 000000000..5115abe11 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts @@ -0,0 +1,49 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { createFilesystem } from "@bytecodealliance/preview2-shim/filesystem"; +import { createWasiVfs } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.js"; +import type { Descriptor } from "../../../../../../src/wasi/0.2.x/node/26.x.x/vfs/wasi-types.js"; +import { withDirectory } from "../helpers/vfs.js"; + +for (const custom of [false, true]) { + test(`WASI preopen storage with ${custom ? "custom resolver" : "longest-prefix default"}`, async () => { + await withDirectory(async (root) => { + await mkdir(join(root, "storage")); + const filesystem = createFilesystem({ preopens: { "/data": root } }); + const preopens = { + getDirectories: () => + filesystem.preopens.getDirectories() as unknown as [Descriptor, string][], + }; + let resolved = 0; + const vfs = createWasiVfs({ + preopens, + ...(custom + ? { + resolveRoot: ( + _rootPath: string, + entries: readonly (readonly [Descriptor, string])[], + ) => { + resolved++; + return { descriptor: entries[0][0], directory: "storage" }; + }, + } + : {}), + }); + vfs.create().writeFileSync("/memory", "local"); + expect(resolved).toBe(0); + const fs = vfs.create(new vfs.RealFSProvider(custom ? "/virtual" : "/data/storage")); + fs.mkdirSync("/nested"); + fs.writeFileSync("/nested/file", "wasi"); + fs.appendFileSync("/nested/file", "+append"); + expect(fs.readFileSync("/nested/file", "utf8")).toBe("wasi+append"); + expect(await readFile(join(root, "storage", "nested", "file"), "utf8")).toBe("wasi+append"); + fs.symlinkSync("file", "/nested/link"); + expect(fs.realpathSync("/nested/link")).toBe("/nested/file"); + expect(fs.readdirSync("/nested").sort()).toEqual(["file", "link"]); + await fs.promises.rm("/nested", { recursive: true }); + expect(fs.existsSync("/nested")).toBe(false); + expect(resolved).toBe(custom ? 1 : 0); + }); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write-file.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write-file.ts new file mode 100644 index 000000000..a4e87f7bf --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write-file.ts @@ -0,0 +1,25 @@ +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: replaces file data, accepts bytes and exclusive flags`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "long original"); + fs.writeFileSync("/file", Buffer.from("new")); + expect(fs.readFileSync("/file", "utf8")).toBe("new"); + expect(() => fs.writeFileSync("/file", "x", { flag: "wx" })).toThrow( + expect.objectContaining({ code: "EEXIST" }), + ); + await fs.promises.writeFile("/async", "async"); + expect(fs.readFileSync("/async", "utf8")).toBe("async"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write.ts b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write.ts new file mode 100644 index 000000000..0dd9facf5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/26.x.x/vfs/write.ts @@ -0,0 +1,26 @@ +import { Buffer } from "node:buffer"; +import { expect, test } from "vitest"; +import { vfs, oracle } from "../helpers/vfs.js"; + +const native = oracle(); +const implementations = native + ? ([ + ["shim", vfs], + ["Node 26.8.2", native], + ] as const) + : ([["shim", vfs]] as const); + +for (const [name, implementation] of implementations) { + test(`${name}: writes at a chosen offset and retains append semantics`, async () => { + const fs = implementation.create({ emitExperimentalWarning: false }); + fs.writeFileSync("/file", "abc"); + const fd = fs.openSync("/file", "r+"); + expect(fs.writeSync(fd, Buffer.from("X"), 0, 1, 1)).toBe(1); + fs.closeSync(fd); + expect(fs.readFileSync("/file", "utf8")).toBe("aXc"); + const append = fs.openSync("/file", "a"); + fs.writeSync(append, Buffer.from("!"), 0, 1, 0); + fs.closeSync(append); + expect(fs.readFileSync("/file", "utf8")).toBe("aXc!"); + }); +} diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index 4e8d86cd1..d60dc9f63 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -13,6 +13,7 @@ import { nodeGlobals, type NodejsHttp2Via, type NodejsHttpVia, + type NodejsVfsVia, type WorldMetadata, } from "../node-builtins/index.js"; import { @@ -44,6 +45,9 @@ export interface ComponentizeOptions { bundle?: boolean; bundleConfig?: string; nodejsHttpVia?: NodejsHttpVia; + nodejsVfsVia?: NodejsVfsVia; + withNodejsVfsVia?: NodejsVfsVia; + withNodejsVfsWasiConfig?: string; nodejsHttp2Via?: NodejsHttp2Via; /** * The CLI spelling of {@link nodejsHttpVia}. @@ -210,6 +214,10 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): plugins: [ nodeBuiltinPlugin(await worldMetadataFor(witPath, opts.worldName), { nodejsHttpVia: opts.nodejsHttpVia ?? opts.withNodejsHttpVia, + nodejsVfsVia: opts.nodejsVfsVia ?? opts.withNodejsVfsVia, + vfsWasiConfigModule: opts.withNodejsVfsWasiConfig + ? resolve(opts.withNodejsVfsWasiConfig) + : undefined, nodejsHttp2Via: opts.nodejsHttp2Via ?? opts.withNodejsHttp2Via, // Match the socket bindings supplied by the selected component engine. wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index 7a54ce4a9..16c4eadde 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -90,6 +90,15 @@ program .choices(["direct", "wasi-sockets", "wasi-http"]) .default("direct"), ) + .addOption( + new Option("--with-nodejs-vfs-via ", "implementation used by bundled node:vfs code") + .choices(["direct", "wasi-filesystem"]) + .default("direct"), + ) + .option( + "--with-nodejs-vfs-wasi-config ", + "guest module exporting the WASI VFS storage resolveRoot function", + ) .requiredOption("-o, --out ", "output component file") .option("--debug-bindings", "Output debug bindings and metadata during componentization (by default to stderr)") .option("--debug-bindings-dir ", "Directory to which to output generated bindings and metadata") diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 071115dbe..87d2cf00e 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -33,6 +33,7 @@ import { createChildProcessBuiltin } from "./child-process.js"; import { createConsoleBuiltin } from "./console.js"; import { createDgramBuiltin } from "./dgram.js"; import { createDnsBuiltin } from "./dns.js"; +import { createVfsBuiltin } from "./vfs.js"; import { createFsBuiltin } from "./fs.js"; import { createNetBuiltin } from "./net.js"; import { createHttpBuiltin } from "./http.js"; @@ -48,6 +49,7 @@ import { composeBuiltins, VIRTUAL_PREFIX } from "./shared.js"; export type { NodeBuiltinOptions, NodejsHttpVia, + NodejsVfsVia, NodejsHttp2Via, WorldMetadata, NodeErrorGlobalsOptions, @@ -98,6 +100,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createDgramBuiltin, createDnsBuiltin, createFsBuiltin, + createVfsBuiltin, createNetBuiltin, createHttpBuiltin, createHttpsBuiltin, diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index 8da026182..557390f21 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -18,6 +18,14 @@ export interface NodeBuiltinOptions { /** Override the portable vm module when bundling or testing. */ vmModule?: string; + /** Select the VFS host boundary. Direct retains the default-deny filesystem mapping. */ + nodejsVfsVia?: NodejsVfsVia; + /** Guest module exporting a WASI VFS resolveRoot callback. */ + vfsWasiConfigModule?: string; + vfsModule?: string; + /** Override the WASI factory, e.g. to configure its storage-root resolver. */ + vfsWasiFilesystemImplementationModule?: string; + /** Override the worker_threads guest module for bundling/tests. */ workerThreadsModule?: string; @@ -152,3 +160,5 @@ export interface NodeGlobalsOptions extends NodeErrorGlobalsOptions { /** Path to Jco's audited `node:buffer` adapter (overridable for tests). */ bufferModule?: string; } + +export type NodejsVfsVia = "direct" | "wasi-filesystem"; diff --git a/packages/jco/src/node-builtins/vfs.ts b/packages/jco/src/node-builtins/vfs.ts new file mode 100644 index 000000000..a70a4c847 --- /dev/null +++ b/packages/jco/src/node-builtins/vfs.ts @@ -0,0 +1,52 @@ +import { type BuiltinContext, type BuiltinAdapter, builtin, stdModule, starReexportAdapter } from "./shared.js"; +import { VFS_WIT_REQUIREMENT, VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS } from "../node-wit.js"; + +export function createVfsBuiltin({ options, worldMetadata }: BuiltinContext): BuiltinAdapter { + const via = options.nodejsVfsVia ?? "direct"; + return builtin( + "node:vfs", + () => { + if (via === "direct") { + return starReexportAdapter(stdModule(options.vfsModule, "vfs", "26.x.x"), "vfs"); + } + const implementation = stdModule( + options.vfsWasiFilesystemImplementationModule, + "vfs/impl/wasi-filesystem", + "26.x.x", + ); + const configImport = options.vfsWasiConfigModule + ? `import { resolveRoot } from ${JSON.stringify(options.vfsWasiConfigModule)};` + : ""; + const configValue = options.vfsWasiConfigModule ? ", resolveRoot" : ""; + return ` +${configImport} +import * as preopens from "wasi:filesystem/preopens@0.2.12"; +import { createWasiVfs } from ${JSON.stringify(implementation)}; +const vfs = createWasiVfs({ preopens${configValue} }); +export default vfs; +export const { create, VirtualFileSystem, VirtualProvider, MemoryProvider, RealFSProvider } = vfs; +`; + }, + () => { + if (options.vfsWasiConfigModule && via !== "wasi-filesystem") { + throw new Error("VFS storage configuration requires --with-nodejs-vfs-via wasi-filesystem"); + } + if (via === "wasi-filesystem") { + for (const entry of worldMetadata.imports) { + if (entry.namespace !== "wasi" || entry.package !== "filesystem" || !entry.version) { + continue; + } + const { major, minor, patch } = entry.version; + if (major !== 0n || minor !== 2n || patch !== 12n) { + throw new Error( + "node:vfs via wasi-filesystem requires wasi:filesystem@0.2.12; remove conflicting imported versions", + ); + } + } + } + for (const requirement of via === "direct" ? [VFS_WIT_REQUIREMENT] : VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS) { + options.onWitRequirement?.(requirement); + } + }, + ); +} diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 7e024133e..40a344386 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -131,6 +131,9 @@ export const DNS_PROMISES_WIT_REQUIREMENT: NodeWitRequirement = { export const FS_WIT_REQUIREMENT = nodeRequirement("node:fs", "fs"); +/** VFS reuses the filesystem host protocol and its deny-by-default mapping. */ +export const VFS_WIT_REQUIREMENT: NodeWitRequirement = { ...FS_WIT_REQUIREMENT, nodeSpecifier: "node:vfs" }; + export const PROCESS_WIT_REQUIREMENT = nodeRequirement("node:process", "process"); export const SQLITE_WIT_REQUIREMENT = nodeRequirement("node:sqlite", "sqlite"); @@ -260,6 +263,14 @@ function wasiRequirement(witImport: string, dependencies: WitDependencyPackage[] }; } +export const VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS: readonly NodeWitRequirement[] = [ + "wasi:filesystem/preopens@0.2.12", + "wasi:filesystem/types@0.2.12", +].map((witImport) => ({ + ...wasiRequirement(witImport, [wasiDependency("wasi-filesystem"), WASI_IO_DEPENDENCY, WASI_CLOCKS_DEPENDENCY]), + nodeSpecifier: "node:vfs", +})); + export const HTTP_WASI_SOCKETS_WIT_REQUIREMENTS = [ wasiRequirement("wasi:sockets/instance-network@0.2.12", WASI_SOCKETS_DEPENDENCIES), wasiRequirement("wasi:sockets/network@0.2.12", WASI_SOCKETS_DEPENDENCIES), diff --git a/packages/jco/test/fixtures/componentize/node-vfs/component.js b/packages/jco/test/fixtures/componentize/node-vfs/component.js new file mode 100644 index 000000000..9a4ebf690 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-vfs/component.js @@ -0,0 +1,139 @@ +import vfs, { create, MemoryProvider, RealFSProvider, VirtualProvider } from "node:vfs"; +import * as namespace from "node:vfs"; +import { Buffer } from "node:buffer"; + +function exerciseSync(fs) { + fs.mkdirSync("/dir/sub", { recursive: true }); + fs.writeFileSync("/dir/sub/file", "hello"); + fs.appendFileSync("/dir/sub/file", " world"); + const sync = fs.readFileSync("/dir/sub/file", "utf8"); + const fd = fs.openSync("/dir/sub/file", "r+"); + const bytes = Buffer.alloc(5); + fs.readSync(fd, bytes, 0, 5, 6); + fs.writeSync(fd, Buffer.from("H"), 0, 1, 0); + const descriptorSize = fs.fstatSync(fd).size; + fs.closeSync(fd); + fs.copyFileSync("/dir/sub/file", "/dir/copied"); + fs.renameSync("/dir/copied", "/dir/moved"); + fs.linkSync("/dir/moved", "/dir/hardlink"); + fs.symlinkSync("sub/file", "/dir/link"); + const realpath = fs.realpathSync("/dir/link"); + const symlink = fs.lstatSync("/dir/link").isSymbolicLink(); + const readlink = fs.readlinkSync("/dir/link"); + const directory = fs.opendirSync("/dir/sub"); + const directoryEntry = directory.readSync().name; + directory.closeSync(); + const listing = fs.readdirSync("/dir").sort(); + fs.truncateSync("/dir/sub/file", 5); + const truncated = fs.readFileSync("/dir/sub/file", "utf8"); + const temp = fs.mkdtempSync("/temp-"); + const temporary = fs.statSync(temp).isDirectory(); + fs.rmdirSync(temp); + let missing; + try { + fs.readFileSync("/missing"); + } catch (error) { + missing = error.code; + } + fs.rmSync("/dir", { recursive: true }); + return { + sync, + + bytes: bytes.toString(), + descriptorSize, + realpath, + symlink, + readlink, + directoryEntry, + listing, + truncated, + temporary, + missing, + removed: !fs.existsSync("/dir"), + }; +} + +async function exercise(fs) { + const report = exerciseSync(fs); + await fs.promises.writeFile("/async", "hello world"); + const callback = await new Promise((resolve, reject) => { + fs.readFile("/async", "utf8", (error, data) => (error ? reject(error) : resolve(data))); + }); + const promise = await fs.promises.readFile("/async", "utf8"); + await fs.promises.unlink("/async"); + return { ...report, callback, promise }; +} + +async function memoryReport() { + const provider = new MemoryProvider(); + const fs = create(provider); + const report = await exercise(fs); + fs.writeFileSync("/retained", "memory"); + provider.setReadOnly(); + let readOnlyError; + try { + fs.writeFileSync("/retained", "changed"); + } catch (error) { + readOnlyError = error.code; + } + return JSON.stringify({ + ...report, + namespace: vfs.create === create && namespace.MemoryProvider === MemoryProvider, + provider: provider instanceof VirtualProvider, + isolated: !vfs.create().existsSync("/retained"), + readOnlyError, + }); +} + +async function filesystemReport(root) { + const fs = create(new RealFSProvider(root)); + const report = await exercise(fs); + fs.writeFileSync("/placement.txt", "vfs contents"); + return JSON.stringify(report); +} + +export function denied() { + try { + create(new RealFSProvider("/denied")).readFileSync("/file"); + } catch (error) { + return error.code; + } + return "unexpected success"; +} + +let report = ""; + +function start(operation) { + report = ""; + operation().then( + (value) => { + report = value; + }, + (error) => { + report = JSON.stringify({ error: String(error), stack: error.stack }); + }, + ); +} + +export function startMemory() { + start(memoryReport); +} + +export function startFilesystem(root) { + start(() => filesystemReport(root)); +} + +export function takeReport() { + return report; +} + +export function syncMemory() { + return JSON.stringify(exerciseSync(create())); +} + +export function syncFilesystem(root) { + const fs = create(new RealFSProvider(root)); + const report = exerciseSync(fs); + fs.writeFileSync("/placement.txt", "vfs contents"); + return JSON.stringify(report); +} diff --git a/packages/jco/test/fixtures/componentize/node-vfs/wit/component.wit b/packages/jco/test/fixtures/componentize/node-vfs/wit/component.wit new file mode 100644 index 000000000..59fa1222d --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-vfs/wit/component.wit @@ -0,0 +1,10 @@ +package test:vfs; + +world component { + export start-memory: func(); + export take-report: func() -> string; + export start-filesystem: func(root: string); + export sync-memory: func() -> string; + export sync-filesystem: func(root: string) -> string; + export denied: func() -> string; +} diff --git a/packages/jco/test/node/vfs.js b/packages/jco/test/node/vfs.js new file mode 100644 index 000000000..c8d2c5094 --- /dev/null +++ b/packages/jco/test/node/vfs.js @@ -0,0 +1,249 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { cp, mkdir, readFile, writeFile, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { VFS_WIT_REQUIREMENT, VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS } from "../../src/node-wit.js"; +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; +import { exec, getTmpDir, jcoPath, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; +import * as denied from "../../../jco-std/src/wasi/0.2.x/node/24.x.x/fs-host.js"; +import * as deniedTty from "../../../jco-std/src/wasi/0.2.x/node/24.x.x/tty-host.js"; +import * as nodeHost from "../../../jco-std/src/wasi/0.2.x/node/24.x.x/fs-host-node.js"; + +const fixture = fileURLToPath(new URL("../fixtures/componentize/node-vfs/", import.meta.url)); +const vfsModule = fileURLToPath(new URL("../../../jco-std/src/wasi/0.2.x/node/26.x.x/vfs.ts", import.meta.url)); +const wasiImplementation = fileURLToPath( + new URL("../../../jco-std/src/wasi/0.2.x/node/26.x.x/vfs/wasi-filesystem.ts", import.meta.url), +); + +const syncExpected = { + sync: "hello world", + bytes: "world", + descriptorSize: 11, + realpath: "/dir/sub/file", + symlink: true, + readlink: "sub/file", + directoryEntry: "file", + listing: ["hardlink", "link", "moved", "sub"], + truncated: "Hello", + temporary: true, + missing: "ENOENT", + removed: true, +}; + +const expected = { ...syncExpected, callback: "hello world", promise: "hello world" }; + +test("VFS selects the direct or WASI capability without intercepting bare specifiers", () => { + for (const nodejsVfsVia of ["direct", "wasi-filesystem"]) { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { nodejsVfsVia, vfsModule, vfsWasiFilesystemImplementationModule: wasiImplementation, onWitRequirement }, + ); + expect(plugin.resolveId("vfs")).toBeNull(); + expect(plugin.resolveId("node:vfs/missing")).toBeNull(); + expect(plugin.resolveId("node:vfs")).toBe("\0jco-node-builtin:node:vfs"); + const requirements = nodejsVfsVia === "direct" ? [VFS_WIT_REQUIREMENT] : VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS; + expect(onWitRequirement.mock.calls.map(([value]) => value)).toEqual(requirements); + } + expect(withDefaultNodeCapabilities({}).map["jco:node/fs@0.1.0"]).toMatch(/fs\/host$/); +}); + +test("WASI VFS diagnoses an incompatible existing filesystem import only when used", () => { + const plugin = nodeBuiltinPlugin( + { + imports: [ + { + namespace: "wasi", + package: "filesystem", + interface: "preopens", + version: { major: 0n, minor: 2n, patch: 3n }, + }, + ], + exports: [], + }, + { nodejsVfsVia: "wasi-filesystem" }, + ); + expect(plugin.resolveId("unrelated")).toBeNull(); + expect(() => plugin.resolveId("node:vfs")).toThrow(/requires wasi:filesystem@0.2.12/); +}); + +describe.skipIf(!hasJspi)("node:vfs components", () => { + for (const backend of ["starlingmonkey", "quickjs"]) { + for (const mode of ["direct", "wasi-filesystem", "wasi-custom"]) { + const via = mode === "wasi-custom" ? "wasi-filesystem" : mode; + const guestRoot = mode === "wasi-custom" ? "/tenant" : "/data/storage"; + describe(`${backend} via ${mode}`, () => { + let componentPath; + beforeAll(async () => { + const output = await getTmpDir(); + const wit = join(output, "wit"); + await cp(join(fixture, "wit"), wit, { recursive: true }); + const requirements = + via === "direct" ? [VFS_WIT_REQUIREMENT] : VFS_WASI_FILESYSTEM_WIT_REQUIREMENTS; + const entry = join(output, "component.js"); + await cp(join(fixture, "component.js"), entry); + const args = ["--bundle", "--with-nodejs-vfs-via", via]; + if (mode === "wasi-custom") { + const config = join(output, "storage.js"); + await writeFile( + config, + `export function resolveRoot(rootPath, preopens) { + if (rootPath !== "/tenant") throw new Error("Unexpected VFS root"); + const entry = preopens.find(([, name]) => name === "/data"); + return { descriptor: entry[0], directory: "storage" }; + }`, + ); + args.push("--with-nodejs-vfs-wasi-config", config); + } + componentPath = join(output, "component.wasm"); + await exec( + jcoPath, + "componentize", + entry, + "--backend", + backend, + "-w", + wit, + "-o", + componentPath, + ...args, + { closeStdin: true }, + ); + const world = await readFile(join(wit, "component.wit"), "utf8"); + for (const requirement of requirements) { + expect(world).toContain(`import ${requirement.witImport};`); + } + }, 600_000); + + async function instantiate(host, root) { + const wasi = new WASIShim({ sandbox: { preopens: root ? { "/data": root } : {} } }); + return setupAsyncTest({ + component: { + name: "node-vfs", + path: componentPath, + imports: { + ...wasi.getImportObject(), + // The injected process global may import TTY without using a terminal. + "jco:node/tty@0.1.0": deniedTty, + ...(via === "direct" ? { "jco:node/fs@0.1.0": host } : {}), + }, + }, + jco: { + transpile: { + extraArgs: { + asyncMode: "jspi", + asyncExports: ["*"], + map: { + "jco:node/tty@0.1.0": "jco:node/tty@0.1.0", + ...(via === "direct" ? { "jco:node/fs@0.1.0": "jco:node/fs@0.1.0" } : {}), + }, + }, + }, + }, + }); + } + + test("synchronous memory API and default denial work inside the guest", async () => { + const result = await instantiate(denied); + try { + expect(JSON.parse(await result.instance.syncMemory())).toEqual(syncExpected); + if (via === "direct") { + expect(await result.instance.denied()).toBe("ERR_JCO_FS_ADAPTER_REQUIRED"); + } + } finally { + await result.cleanup(); + } + }); + + // TODO(unskip): QuickJS lowers WIT u64 arguments as f64 and traps on + // BigInt file offsets (direct read and WASI descriptor.write). + test.skipIf(backend === "quickjs")( + "synchronous real storage uses the selected implementation", + async () => { + const root = await getTmpDir(); + await mkdir(join(root, "storage")); + // Exercise host aliases such as macOS /var -> /private/var on every platform. + await symlink("storage", join(root, "storage-alias"), "dir"); + const result = await instantiate(nodeHost, root); + try { + expect( + JSON.parse( + await result.instance.syncFilesystem( + via === "direct" ? join(root, "storage-alias") : guestRoot, + ), + ), + ).toEqual(syncExpected); + expect(await readFile(join(root, "storage", "placement.txt"), "utf8")).toBe("vfs contents"); + } finally { + await result.cleanup(); + } + }, + ); + + // TODO(unskip): QuickJS does not drain guest Promise jobs; synchronous coverage runs on both engines. + test.skipIf(backend === "quickjs")("memory exercises the API without a filesystem grant", async () => { + const result = await instantiate(denied); + try { + expect(await runReport(result.instance, "startMemory")).toEqual({ + ...expected, + namespace: true, + provider: true, + isolated: true, + readOnlyError: "EROFS", + }); + if (via === "direct") { + expect(await result.instance.denied()).toBe("ERR_JCO_FS_ADAPTER_REQUIRED"); + } + } finally { + await result.cleanup(); + } + }); + + // TODO(unskip): QuickJS does not drain guest Promise jobs after synchronous exports. + test.skipIf(backend === "quickjs")( + "real storage exercises sync, callbacks, promises, descriptors and links", + async () => { + const root = await getTmpDir(); + await mkdir(join(root, "storage")); + // Exercise host aliases such as macOS /var -> /private/var on every platform. + await symlink("storage", join(root, "storage-alias"), "dir"); + const result = await instantiate(nodeHost, root); + try { + expect( + await runReport( + result.instance, + "startFilesystem", + via === "direct" ? join(root, "storage-alias") : guestRoot, + ), + ).toEqual(expected); + expect(await readFile(join(root, "storage", "placement.txt"), "utf8")).toBe("vfs contents"); + expect( + await readFile(join(root, "storage", "dir", "sub", "file"), "utf8").catch( + (error) => error.code, + ), + ).toBe("ENOENT"); + } finally { + await result.cleanup(); + } + }, + ); + }); + } + } +}); + +async function runReport(instance, start, root) { + await instance[start](...(root === undefined ? [] : [root])); + for (let attempt = 0; attempt < 100; attempt++) { + const report = await instance.takeReport(); + if (report) { + return JSON.parse(report); + } + await delay(10); + } + throw new Error("VFS report did not complete"); +}