diff --git a/src/node/BUILD.bazel b/src/node/BUILD.bazel index 80eba20636d..5166155e22a 100644 --- a/src/node/BUILD.bazel +++ b/src/node/BUILD.bazel @@ -14,6 +14,7 @@ wd_ts_bundle( "*.js", "assert/*.ts", "dns/*.ts", + "fs/*.ts", "stream/*.js", "path/*.ts", "util/*.ts", diff --git a/src/node/diagnostics_channel.ts b/src/node/diagnostics_channel.ts index 616a38eb7d6..5b5ba34017b 100644 --- a/src/node/diagnostics_channel.ts +++ b/src/node/diagnostics_channel.ts @@ -284,7 +284,7 @@ export function tracingChannel( this[kAsyncEnd] = channel(`tracing:${nameOrChannels}:asyncEnd`); this[kError] = channel(`tracing:${nameOrChannels}:error`); } else { - validateObject(nameOrChannels, 'channels', {}); + validateObject(nameOrChannels, 'channels'); const channels = nameOrChannels as TracingChannels; this[kStart] = validateChannel(channels.start, 'channels.start'); this[kEnd] = validateChannel(channels.end, 'channels.end'); diff --git a/src/node/fs.ts b/src/node/fs.ts new file mode 100644 index 00000000000..d88a1a04b2c --- /dev/null +++ b/src/node/fs.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import * as promises from 'node-internal:internal_fs_promises'; +import * as constants from 'node-internal:internal_fs_constants'; +import * as syncMethods from 'node-internal:internal_fs_sync'; +import * as callbackMethods from 'node-internal:internal_fs_callback'; +import { WriteStream, ReadStream } from 'node-internal:internal_fs_streams'; +import { Dirent, Dir } from 'node-internal:internal_fs'; + +export * from 'node-internal:internal_fs_callback'; +export * from 'node-internal:internal_fs_sync'; +export { constants, promises, Dirent, Dir, WriteStream, ReadStream }; + +export default { + constants, + promises, + Dirent, + Dir, + WriteStream, + ReadStream, + ...syncMethods, + ...callbackMethods, +}; diff --git a/src/node/fs/promises.ts b/src/node/fs/promises.ts new file mode 100644 index 00000000000..a09bfd2a775 --- /dev/null +++ b/src/node/fs/promises.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import * as fs from 'node-internal:internal_fs_promises'; +import * as constants from 'node-internal:internal_fs_constants'; + +export * from 'node-internal:internal_fs_promises'; +export { constants }; + +export default { + constants, + ...fs, +}; diff --git a/src/node/internal/crypto_keys.ts b/src/node/internal/crypto_keys.ts index 2aa5610fe61..924a4c8feaa 100644 --- a/src/node/internal/crypto_keys.ts +++ b/src/node/internal/crypto_keys.ts @@ -112,7 +112,7 @@ function validateExportOptions( type: KeyObjectType, name = 'options' ): asserts options is ExportOptions { - validateObject(options, name, {}); + validateObject(options, name); // Yes, converting to any is a bit of a cheat, but it allows us to check // each option individually without having to do a bunch of type guards. const opts = options; @@ -182,7 +182,7 @@ export abstract class KeyObject { } public export(options: ExportOptions = {}): KeyExportResult { - validateObject(options, 'options', {}); + validateObject(options, 'options'); validateExportOptions(options, this.type); diff --git a/src/node/internal/crypto_random.ts b/src/node/internal/crypto_random.ts index ed4a5265fc0..56a08065bc9 100644 --- a/src/node/internal/crypto_random.ts +++ b/src/node/internal/crypto_random.ts @@ -303,7 +303,7 @@ function processGeneratePrimeOptions(options: GeneratePrimeOptions): { safe: boolean; bigint: boolean; } { - validateObject(options, 'options', {}); + validateObject(options, 'options'); const { safe = false, bigint = false } = options; let { add, rem } = options; validateBoolean(safe, 'options.safe'); @@ -427,7 +427,7 @@ export function checkPrimeSync( options: CheckPrimeOptions = {} ): boolean { candidate = validateCandidate(candidate); - validateObject(options, 'options', {}); + validateObject(options, 'options'); const checks = validateChecks(options); return cryptoImpl.checkPrimeSync(candidate as ArrayBufferView, checks); } @@ -451,7 +451,7 @@ export function checkPrime( callback = options; options = {}; } - validateObject(options, 'options', {}); + validateObject(options, 'options'); validateFunction(callback, 'callback'); const checks = validateChecks(options); new Promise((res, rej) => { diff --git a/src/node/internal/filesystem.d.ts b/src/node/internal/filesystem.d.ts new file mode 100644 index 00000000000..c4ef4d858f3 --- /dev/null +++ b/src/node/internal/filesystem.d.ts @@ -0,0 +1,94 @@ +export interface StatOptions { + followSymlinks?: boolean; +} + +export interface Stat { + type: 'file' | 'directory' | 'symlink'; + size: number; + lastModified: bigint; + created: bigint; + writable: boolean; + device: boolean; +} + +export function stat(pathOrFd: number | URL, options: StatOptions): Stat | null; +export function setLastModified( + pathOrFd: number | URL, + mtime: Date, + options: StatOptions +): void; + +export function truncate(pathOrFd: number | URL, length: number): void; + +export function readLink( + path: URL, + options: { failIfNotSymlink: boolean } +): string; + +export function link(from: URL, to: URL, options: { symbolic: boolean }): void; + +export function unlink(path: URL): void; + +export function open( + path: URL, + options: { + read: boolean; + write: boolean; + append: boolean; + exclusive: boolean; + followSymlinks: boolean; + } +): number; + +export function close(fd: number): void; + +export function write( + fd: number, + buffers: ArrayBufferView[], + options: { + position: number | bigint | null; + } +): number; + +export function read( + fd: number, + buffers: ArrayBufferView[], + options: { + position: number | bigint | null; + } +): number; + +export function readAll(pathOrFd: number | URL): Uint8Array; + +export function writeAll( + pathOrFd: number | URL, + data: ArrayBufferView, + options: { append: boolean; exclusive: boolean } +): number; + +export function renameOrCopy( + from: URL, + to: URL, + options: { copy: boolean } +): void; + +export function mkdir( + path: URL, + options: { recursive: boolean; tmp: boolean } +): string | undefined; + +export function rm( + path: URL, + options: { recursive: boolean; force: boolean; dironly: boolean } +): void; + +export interface DirEntryHandle { + name: string; + parentPath: string; + type: number; +} + +export function readdir( + path: URL, + options: { recursive: boolean } +): DirEntryHandle[]; diff --git a/src/node/internal/internal_errors.ts b/src/node/internal/internal_errors.ts index 1f37f9c0190..7481fd06815 100644 --- a/src/node/internal/internal_errors.ts +++ b/src/node/internal/internal_errors.ts @@ -817,3 +817,45 @@ export class ERR_INVALID_CHAR extends NodeTypeError { super('ERR_INVALID_CHAR', msg); } } + +export class NodeFsError extends NodeError { + syscall: string; + path?: string; + constructor(code: string, message: string, syscall: string) { + super(code, message); + this.syscall = syscall; + } +} + +export class NodeSyscallError extends NodeError { + syscall: string; + errno?: number; + constructor(code: string, message: string, syscall: string) { + super(code, message); + this.syscall = syscall; + } +} + +export class ERR_ENOENT extends NodeFsError { + constructor(path: string, options: { syscall: string }) { + super('ENOENT', `No such file or directory: ${path}`, options.syscall); + this.code = 'ENOENT'; + this.path = path; + } +} + +export class ERR_EBADF extends NodeSyscallError { + constructor(options: { syscall: string }) { + super('EBADF', `Bad file descriptor`, options.syscall); + this.code = 'EBADF'; + this.errno = -9; // EBADF + } +} + +export class ERR_EINVAL extends NodeSyscallError { + constructor(options: { syscall: string }) { + super('EINVAL', `Invalid argument`, options.syscall); + this.code = 'EINVAL'; + this.errno = -22; // EINVAL + } +} diff --git a/src/node/internal/internal_fs.ts b/src/node/internal/internal_fs.ts new file mode 100644 index 00000000000..444836ee92a --- /dev/null +++ b/src/node/internal/internal_fs.ts @@ -0,0 +1,115 @@ +import { + UV_DIRENT_DIR, + UV_DIRENT_FILE, + UV_DIRENT_BLOCK, + UV_DIRENT_CHAR, + UV_DIRENT_LINK, + UV_DIRENT_FIFO, + UV_DIRENT_SOCKET, +} from 'node-internal:internal_fs_constants'; +import { getOptions } from 'node-internal:internal_fs_utils'; +import { validateFunction, validateUint32 } from 'node-internal:validators'; +import { ERR_MISSING_ARGS } from 'node-internal:internal_errors'; + +const kType = Symbol('type'); + +export class Dirent { + public name: string; + public parentPath: string; + private [kType]: number; + + public constructor(name: string, type: number, path: string) { + this.name = name; + this.parentPath = path; + this[kType] = type; + } + + public isDirectory(): boolean { + return this[kType] === UV_DIRENT_DIR; + } + + public isFile(): boolean { + return this[kType] === UV_DIRENT_FILE; + } + + public isBlockDevice(): boolean { + return this[kType] === UV_DIRENT_BLOCK; + } + + public isCharacterDevice(): boolean { + return this[kType] === UV_DIRENT_CHAR; + } + + public isSymbolicLink(): boolean { + return this[kType] === UV_DIRENT_LINK; + } + + public isFIFO(): boolean { + return this[kType] === UV_DIRENT_FIFO; + } + + public isSocket(): boolean { + return this[kType] === UV_DIRENT_SOCKET; + } +} + +export class Dir { + // @ts-expect-error TS6133 Value is not read. + #handle: unknown; // eslint-disable-line no-unused-private-class-members + #path: string; + #options: Record; + + public constructor( + handle: unknown, + path: string, + options: Record + ) { + if (handle == null) { + throw new ERR_MISSING_ARGS('handle'); + } + this.#handle = handle; + this.#path = path; + this.#options = { + bufferSize: 32, + ...getOptions(options, { + encoding: 'utf8', + }), + }; + + validateUint32(this.#options.bufferSize, 'options.bufferSize', true); + } + + public get path(): string { + return this.#path; + } + + public read(callback: unknown): void { + validateFunction(callback, 'callback'); + throw new Error('Not implemented'); + } + + public processReadResult(): void { + throw new Error('Not implemented'); + } + + public readSyncRecursive(): void { + throw new Error('Not implemented'); + } + + public readSync(): void { + throw new Error('Not implemented'); + } + + public close(): void { + throw new Error('Not implemented'); + } + + public closeSync(): void { + throw new Error('Not implemented'); + } + + // eslint-disable-next-line @typescript-eslint/require-await,require-yield + public async *entries(): AsyncGenerator { + throw new Error('Not implemented'); + } +} diff --git a/src/node/internal/internal_fs_callback.ts b/src/node/internal/internal_fs_callback.ts new file mode 100644 index 00000000000..7898bc0c95c --- /dev/null +++ b/src/node/internal/internal_fs_callback.ts @@ -0,0 +1,1273 @@ +import * as fssync from 'node-internal:internal_fs_sync'; +import type { + FStatOptions, + MkdirTempSyncOptions, + ReadDirOptions, + ReadDirResult, + ReadFileSyncOptions, + ReadLinkSyncOptions, + StatOptions, + WriteSyncOptions, +} from 'node-internal:internal_fs_sync'; +import { + validatePosition, + type FilePath, +} from 'node-internal:internal_fs_utils'; +import { F_OK } from 'node-internal:internal_fs_constants'; +import { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, +} from 'node-internal:internal_errors'; +import { Stats } from 'node-internal:internal_fs_utils'; +import { type Dir } from 'node-internal:internal_fs'; +import { Buffer } from 'node-internal:internal_buffer'; +import { isArrayBufferView } from 'node-internal:internal_types'; +import { validateUint32 } from 'node-internal:validators'; +import type { + BigIntStatsFs, + CopySyncOptions, + MakeDirectoryOptions, + OpenDirOptions, + ReadAsyncOptions, + RmOptions, + RmDirOptions, + StatsFs, + WriteFileOptions, +} from 'node:fs'; + +type ErrorOnlyCallback = (err: unknown) => void; +type SingleArgCallback = (err: unknown, result?: T) => void; +type DoubleArgCallback = (err: unknown, result1?: T, result2?: U) => void; + +function callWithErrorOnlyCallback( + fn: () => void, + callback: undefined | ErrorOnlyCallback +): void { + if (typeof callback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], callback); + } + try { + fn(); + // Note that any errors thrown by the callback will be "handled" by passing + // them along to the reportError function, which logs them and triggers the + // global "error" event. + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(null)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(err)); + } +} + +function callWithSingleArgCallback( + fn: () => T, + callback: undefined | SingleArgCallback +): void { + if (typeof callback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], callback); + } + try { + const result = fn(); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(null, result)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(err)); + } +} + +export function access(path: FilePath, callback: ErrorOnlyCallback): void; +export function access( + path: FilePath, + mode: number, + callback: ErrorOnlyCallback +): void; +export function access( + path: FilePath, + modeOrCallback: number | ErrorOnlyCallback = F_OK, + callback?: ErrorOnlyCallback +): void { + let mode: number; + if (typeof modeOrCallback === 'function') { + callback = modeOrCallback; + mode = F_OK; + } else { + mode = modeOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.accessSync(path, mode), callback); +} + +export type ExistsCallback = (result: boolean) => void; + +export function exists(path: FilePath, callback: ExistsCallback): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(fssync.existsSync(path))); +} + +export function appendFile( + path: number | FilePath, + data: string | ArrayBufferView, + callback: ErrorOnlyCallback +): void; +export function appendFile( + path: number | FilePath, + data: string | ArrayBufferView, + options: WriteFileOptions, + callback: ErrorOnlyCallback +): void; +export function appendFile( + path: number | FilePath, + data: string | ArrayBufferView, + optionsOrCallback: WriteFileOptions | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let options: WriteFileOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { + encoding: 'utf8', + mode: 0o666, + flag: 'a', + flush: false, + }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback( + () => fssync.appendFileSync(path, data, options), + callback + ); +} + +export function chmod( + path: FilePath, + mode: number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.chmodSync(path, mode), callback); +} + +export function chown( + path: FilePath, + uid: number, + gid: number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.chownSync(path, uid, gid), callback); +} + +export function close( + fd: number, + callback: ErrorOnlyCallback = () => {} +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.closeSync(fd), callback); +} + +export function copyFile( + src: FilePath, + dest: FilePath, + callback: ErrorOnlyCallback +): void; +export function copyFile( + src: FilePath, + dest: FilePath, + mode: number, + callback: ErrorOnlyCallback +): void; +export function copyFile( + src: FilePath, + dest: FilePath, + modeOrCallback: number | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let mode: number; + if (typeof modeOrCallback === 'function') { + callback = modeOrCallback; + mode = 0; + } else { + mode = modeOrCallback; + } + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.copyFileSync(src, dest, mode), + callback + ); +} + +export function cp( + src: FilePath, + dest: FilePath, + callback: ErrorOnlyCallback +): void; +export function cp( + src: FilePath, + dest: FilePath, + options: CopySyncOptions, + callback: ErrorOnlyCallback +): void; +export function cp( + src: FilePath, + dest: FilePath, + optionsOrCallback: CopySyncOptions | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let options: CopySyncOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.cpSync(src, dest, options), callback); +} + +export function fchmod( + fd: number, + mode: string | number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.fchmodSync(fd, mode), callback); +} + +export function fchown( + fd: number, + uid: number, + gid: number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.fchownSync(fd, uid, gid), callback); +} + +export function fdatasync(fd: number, callback: ErrorOnlyCallback): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.fdatasyncSync(fd), callback); +} + +export function fstat(fd: number, callback: SingleArgCallback): void; +export function fstat( + fd: number, + options: FStatOptions, + callback: SingleArgCallback +): void; +export function fstat( + fd: number, + optionsOrCallback: SingleArgCallback | FStatOptions, + callback?: SingleArgCallback +): void { + let options: FStatOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { bigint: false }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.fstatSync(fd, options), callback); +} + +export function fsync( + fd: number, + callback: ErrorOnlyCallback = () => {} +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.fsyncSync(fd), callback); +} + +export function ftruncate(fd: number, callback: ErrorOnlyCallback): void; +export function ftruncate( + fd: number, + len: number, + callback: ErrorOnlyCallback +): void; +export function ftruncate( + fd: number, + lenOrCallback: number | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let len: number; + if (typeof lenOrCallback === 'function') { + callback = lenOrCallback; + len = 0; + } else { + len = lenOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.ftruncateSync(fd, len), callback); +} + +export function futimes( + fd: number, + atime: string | number | Date, + mtime: string | number | Date, + callback: ErrorOnlyCallback +): void { + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.futimesSync(fd, atime, mtime), + callback + ); +} + +export function lchmod( + path: FilePath, + mode: string | number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.lchmodSync(path, mode), callback); +} + +export function lchown( + path: FilePath, + uid: number, + gid: number, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.lchownSync(path, uid, gid), callback); +} + +export function lutimes( + path: FilePath, + atime: string | number | Date, + mtime: string | number | Date, + callback: ErrorOnlyCallback +): void { + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.lutimesSync(path, atime, mtime), + callback + ); +} + +export function link( + src: FilePath, + dest: FilePath, + callback: ErrorOnlyCallback +): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.linkSync(src, dest), callback); +} + +export function lstat( + path: FilePath, + callback: SingleArgCallback +): void; +export function lstat( + path: FilePath, + options: StatOptions, + callback: SingleArgCallback +): void; +export function lstat( + path: FilePath, + optionsOrCallback: SingleArgCallback | StatOptions, + callback?: SingleArgCallback +): void { + let options: StatOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { bigint: false }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.lstatSync(path, options), callback); +} + +export function mkdir( + path: FilePath, + callback: SingleArgCallback +): void; +export function mkdir( + path: FilePath, + options: number | MakeDirectoryOptions, + callback: SingleArgCallback +): void; +export function mkdir( + path: FilePath, + optionsOrCallback: + | number + | SingleArgCallback + | MakeDirectoryOptions, + callback?: SingleArgCallback +): void { + let options: number | MakeDirectoryOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.mkdirSync(path, options), callback); +} + +export function mkdtemp( + prefix: FilePath, + callback: SingleArgCallback +): void; +export function mkdtemp( + prefix: FilePath, + options: MkdirTempSyncOptions, + callback: SingleArgCallback +): void; +export function mkdtemp( + prefix: FilePath, + optionsOrCallback: SingleArgCallback | MkdirTempSyncOptions, + callback?: SingleArgCallback +): void { + let options: MkdirTempSyncOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback( + () => fssync.mkdtempSync(prefix, options), + callback + ); +} + +export function open(path: FilePath, callback: SingleArgCallback): void; +export function open( + path: FilePath, + flags: string | number, + callback: SingleArgCallback +): void; +export function open( + path: FilePath, + flags: string | number, + mode: string | number, + callback: SingleArgCallback +): void; +export function open( + path: FilePath, + flagsOrCallback: string | number | SingleArgCallback = 'r', + modeOrCallback: string | number | SingleArgCallback = 0o666, + callback?: SingleArgCallback +): void { + let flags: string | number; + let mode: string | number; + if (typeof flagsOrCallback === 'function') { + callback = flagsOrCallback; + flags = 'r'; + mode = 0o666; + } else if (typeof modeOrCallback === 'function') { + callback = modeOrCallback; + mode = 0o666; + } else { + flags = flagsOrCallback; + mode = modeOrCallback; + } + callWithSingleArgCallback(() => fssync.openSync(path, flags, mode), callback); +} + +export function opendir(path: FilePath, callback: SingleArgCallback): void; +export function opendir( + path: FilePath, + options: OpenDirOptions, + callback: SingleArgCallback +): void; +export function opendir( + path: FilePath, + optionsOrCallback: SingleArgCallback | OpenDirOptions, + callback?: SingleArgCallback +): void { + let options: OpenDirOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { + encoding: 'utf8', + bufferSize: 32, + recursive: false, + }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.opendirSync(path, options), callback); +} + +// read has a complex polymorphic signature so this is a bit gnarly. +// The various signatures include: +// fs.read(fd, buffer, offset, length, position, callback) +// fs.read(fd, callback) +// fs.read(fd, buffer, callback) +// fs.read(fd, buffer, { offset, length, position }, callback) +// fs.read(fd, { buffer, offset, length, position }, callback) +// +// Where fd is always a number, buffer is an ArrayBufferView, offset and +// length are numbers, but position can be a number or bigint, and offset +// length, and position are optional. The callback is always a function +// that receives three arguments: err, bytesRead, and buffer. +export function read( + fd: number, + bufferOptionsOrCallback: + | T + | ReadAsyncOptions + | DoubleArgCallback, + offsetOptionsOrCallback?: + | ReadAsyncOptions + | number + | DoubleArgCallback, + lengthOrCallback?: null | number | DoubleArgCallback, + position?: number | bigint | null, + callback?: DoubleArgCallback +): void { + // Node.js... you're killing me here with these polymorphic signatures. + // + // We're going to normalize the arguments so that we can defer to the + // readSync variant using the signature readSync(fd, buffer, options) + // + // The callback is always the last argument but may appear in the second, + // third, fourth, or sixth position depending on the signature used. When we + // find it, we can ignore the remaining arguments that come after it, + // defaulting any missing arguments to whatever default is defined for + // them. + // + // The second argument is always either a buffer, an options object that + // contains a buffer property, or the callback. If it's the callback, + // then we will allocate a new buffer for the read with size 16384 bytes, + // and default the offset to 0, length to the buffer size, and position to + // null (indicating that the internal read position for the fd is to be + // used). If it's an options object, we will use the buffer property from it + // if it exists. If the buffer property is not present, we will allocate a + // new buffer for the read with size 16384 bytes. The offset, length, and + // position properties will be used if they are present in the options + // object or defaulted to 0, the buffer size, and null respectively. + // If the second argument is a buffer, we will use it and look for the + // offset, length, position, and callback arguments in the remaining arguments. + // + // The third argument is either ignored (if the second argument is the + // callback), or it is one of either the offset, the options object, or + // the callback. If it is the callback, we will default the offset to 0, + // length to the buffer size (that had to have been provided by the second + // argument), and position to null. If it is the options object, we will + // get the offset, length, and position properties from it if they exist, + // or default them to 0, the buffer size, and null respectively. If it is + // the offset, we will look for the length, position, and callback in the + // remaining arguments. + // + // The fourth argument is either ignored (if the second or third argument is + // the callback), or it is the length as either a number, null, or explicitly + // passed as undefined, or it is the callback. If it is the callback, we will + // default the length to the buffer size (that had to have been provided by + // the second argument), and default position to null, then look for the + // callback in the sixth argument. If it is the length, we will look for the + // position and callback in the remaining arguments. + // + // The fifth argument is either ignored (if the callback has already been + // seen) or it is the position as either a number, bigint, null, or explicitly + // undefined. Any other type in this position is an error. + // + // The sixth argument is either ignored (if the callback has already been + // seen) or it is the callback. If it is not a function then an error is + // thrown. + // + // Once we have collected all of the arguments, we will call the readSync + // method with signature readSync(fd, buffer, { offset, length, position }) + // and pass the return value, and the buffer, to the Node.js-style callback + // with the signature callback(null, returnValue, buffer). If the call throws, + // then we will pass the error to the callback as the first argument. + + let actualCallback: undefined | DoubleArgCallback; + let actualBuffer: T; // Buffer, TypedArray, or DataView + let actualOffset = 0; // Offset from the beginning of the buffer + let actualLength: number; // Length of the data to read into the buffer + // Should never be negative and never extends + // beyond the end of the buffer (that is, + // actualOffset + actualLength <= actualBuffer.byteLength) + let actualPosition: number | bigint | null = null; // The position within the + // file to read from. If null, + // the current position for the fd + // is used. + + // Handle the case where the second argument is the callback + if (typeof bufferOptionsOrCallback === 'function') { + actualCallback = bufferOptionsOrCallback; + // Default buffer size when not provided + // The use of as unknown as T here is a bit of a hack to satisfy the types... + actualBuffer = Buffer.alloc(16384) as unknown as T; + actualLength = actualBuffer.byteLength; + } + // Handle the case where the second argument is an options object + else if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + bufferOptionsOrCallback != null && + typeof bufferOptionsOrCallback === 'object' && + !isArrayBufferView(bufferOptionsOrCallback) + ) { + // It's an options object + const { + buffer = Buffer.alloc(16384), + offset = buffer.byteOffset, + length = buffer.byteLength, + position = null, + } = bufferOptionsOrCallback; + if (!isArrayBufferView(buffer)) { + throw new ERR_INVALID_ARG_TYPE( + 'options.buffer', + ['Buffer', 'TypedArray', 'DataView'], + buffer + ); + } + validateUint32(offset, 'options.offset'); + validateUint32(length, 'options.length'); + validatePosition(position, 'options.position'); + + actualBuffer = buffer as unknown as T; + actualOffset = offset; + actualLength = length; + actualPosition = position; + + // The callback must be in the third argument + if (typeof offsetOptionsOrCallback !== 'function') { + throw new ERR_INVALID_ARG_TYPE( + 'callback', + ['function'], + offsetOptionsOrCallback + ); + } + actualCallback = offsetOptionsOrCallback; + } + // Handle the case where the second argument is a buffer + else { + actualBuffer = bufferOptionsOrCallback; + + if (!isArrayBufferView(actualBuffer)) { + throw new ERR_INVALID_ARG_TYPE( + 'buffer', + ['Buffer', 'TypedArray', 'DataView'], + actualBuffer + ); + } + + actualLength = actualBuffer.byteLength; + actualOffset = actualBuffer.byteOffset; + + // Now we need to find the callback and other parameters + if (typeof offsetOptionsOrCallback === 'function') { + // fs.read(fd, buffer, callback) + actualCallback = offsetOptionsOrCallback; + } else if ( + typeof offsetOptionsOrCallback === 'object' && + !(offsetOptionsOrCallback instanceof Number) + ) { + // fs.read(fd, buffer, options, callback) + const { + offset = actualOffset, + length = actualLength, + position = null, + } = offsetOptionsOrCallback; + validateUint32(offset, 'options.offset'); + validateUint32(length, 'options.length'); + validatePosition(position, 'options.position'); + actualOffset = offset; + actualLength = length; + actualPosition = position; + + // The callback must be in the fourth argument. + if (typeof lengthOrCallback !== 'function') { + throw new ERR_INVALID_ARG_TYPE( + 'callback', + ['function'], + lengthOrCallback + ); + } + actualCallback = lengthOrCallback; + } else { + // fs.read(fd, buffer, offset, length, position, callback) + actualOffset = + typeof offsetOptionsOrCallback === 'number' + ? offsetOptionsOrCallback + : 0; + + if (typeof lengthOrCallback === 'function') { + actualCallback = lengthOrCallback; + actualPosition = null; + actualLength = actualBuffer.byteLength; + } else { + actualLength = lengthOrCallback ?? actualBuffer.byteLength; + + validateUint32(position, 'position'); + actualPosition = position; + + actualCallback = callback; + } + } + } + + // We know that the function must be called with at least 3 arguments and + // that the first argument is always a number (the fd) and the last must + // always be the callback. + // If the actualCallback is not set at this point, then we have a problem. + if (typeof actualCallback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], actualCallback); + } + + // We also have a problem if the actualBuffer is not set here correctly. + if (!isArrayBufferView(actualBuffer)) { + throw new ERR_INVALID_ARG_TYPE( + 'buffer', + ['Buffer', 'TypedArray', 'DataView'], + actualBuffer + ); + } + + // At this point we have the following: + // - actualBuffer: The buffer to read into + // - actualOffset: The offset into the buffer to start writing at + // - actualLength: The length of the data to read into the buffer + // - actualPosition: The position within the file to read from (or null) + // - actualCallback: The callback to call when done + // - fd: The file descriptor to read from + // Let actualOffset + actualLength should never be greater than the + // buffer size. Let's check that. + if ( + actualOffset < 0 || + actualLength < 0 || + actualOffset + actualLength > actualBuffer.byteLength + ) { + throw new ERR_INVALID_ARG_VALUE( + 'offset', + 'must be >= 0 and <= buffer.length' + ); + } + // The actualOffset, actualLength, and actualPosition values should always + // be greater or equal to 0 (unless actualPosition is null)... keeping in + // mind that actualPosition can be a number or a bigint. + + // As a special case, if the actualBuffer length is 0, or if actualLength + // is 0, then can just call the callback with 0 bytes read and return. + if (actualBuffer.byteLength === 0 || actualLength === 0) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => actualCallback(null, 0, actualBuffer)); + return; + } + + // Now that we've normalized all the parameters, call readSync + try { + const bytesRead = fssync.readSync(fd, actualBuffer, { + offset: actualOffset, + length: actualLength, + position: actualPosition, + }); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => actualCallback(null, bytesRead, actualBuffer)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => actualCallback(err)); + } +} + +export function readdir( + path: FilePath, + callback: SingleArgCallback +): void; +export function readdir( + path: FilePath, + options: ReadDirOptions, + callback: SingleArgCallback +): void; +export function readdir( + path: FilePath, + optionsOrCallback: SingleArgCallback | ReadDirOptions, + callback?: SingleArgCallback +): void { + let options: ReadDirOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { + encoding: 'utf8', + withFileTypes: false, + recursive: false, + }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.readdirSync(path, options), callback); +} + +export function readFile( + path: FilePath, + optionsOrCallback: SingleArgCallback | ReadFileSyncOptions, + callback?: SingleArgCallback +): void { + let options: ReadFileSyncOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.readFileSync(path, options), callback); +} + +export function readlink( + path: FilePath, + callback: SingleArgCallback +): void; +export function readlink( + path: FilePath, + options: ReadLinkSyncOptions, + callback: SingleArgCallback +): void; +export function readlink( + path: FilePath, + optionsOrCallback: SingleArgCallback | ReadLinkSyncOptions, + callback?: SingleArgCallback +): void { + let options: ReadLinkSyncOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.readlinkSync(path, options), callback); +} + +export function readv( + fd: number, + buffers: T[], + positionOrCallback: + | undefined + | null + | bigint + | number + | DoubleArgCallback, + callback?: DoubleArgCallback +): void { + if (typeof positionOrCallback === 'function') { + callback = positionOrCallback; + positionOrCallback = null; + } + if (typeof callback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], callback); + } + + validatePosition(positionOrCallback, 'position'); + + try { + const read = fssync.readvSync(fd, buffers, positionOrCallback); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(null, read, buffers)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(err)); + } +} + +export function realpath( + path: FilePath, + callback: SingleArgCallback +): void; +export function realpath( + path: FilePath, + options: ReadLinkSyncOptions, + callback: SingleArgCallback +): void; +export function realpath( + path: FilePath, + optionsOrCallback: SingleArgCallback | ReadLinkSyncOptions, + callback?: SingleArgCallback +): void { + let options: ReadLinkSyncOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.realpathSync(path, options), callback); +} + +realpath.native = realpath; + +export function rename( + oldPath: FilePath, + newPath: FilePath, + callback: ErrorOnlyCallback +): void { + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.renameSync(oldPath, newPath), + callback + ); +} + +export function rmdir(path: FilePath, callback: ErrorOnlyCallback): void; +export function rmdir( + path: FilePath, + options: RmDirOptions, + callback: ErrorOnlyCallback +): void; +export function rmdir( + path: FilePath, + optionsOrCallback: ErrorOnlyCallback | RmDirOptions, + callback?: ErrorOnlyCallback +): void { + let options: RmDirOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.rmdirSync(path, options), callback); +} + +export function rm(path: FilePath, callback: ErrorOnlyCallback): void; +export function rm( + path: FilePath, + options: RmOptions, + callback: ErrorOnlyCallback +): void; +export function rm( + path: FilePath, + optionsOrCallback: ErrorOnlyCallback | RmOptions, + callback?: ErrorOnlyCallback +): void { + let options: RmOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.rmSync(path, options), callback); +} + +export function stat( + path: FilePath, + callback: SingleArgCallback +): void; +export function stat( + path: FilePath, + options: StatOptions, + callback: SingleArgCallback +): void; +export function stat( + path: FilePath, + optionsOrCallback: SingleArgCallback | StatOptions, + callback?: SingleArgCallback +): void { + let options: StatOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { bigint: false }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.statSync(path, options), callback); +} + +export function statfs( + path: FilePath, + callback: SingleArgCallback +): void; +export function statfs( + path: FilePath, + options: { bigint: boolean }, + callback: SingleArgCallback +): void; +export function statfs( + path: FilePath, + optionsOrCallback: + | SingleArgCallback + | { bigint?: boolean }, + callback?: SingleArgCallback +): void { + let options: { bigint?: boolean }; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { bigint: false }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback(() => fssync.statfsSync(path, options), callback); +} + +export function symlink( + target: FilePath, + path: FilePath, + callback: ErrorOnlyCallback +): void; +export function symlink( + target: FilePath, + path: FilePath, + type: string, + callback: ErrorOnlyCallback +): void; +export function symlink( + target: FilePath, + path: FilePath, + typeOrCallback: string | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let type: string | null; + if (typeof typeOrCallback === 'function') { + callback = typeOrCallback; + type = null; + } else { + type = typeOrCallback; + } + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.symlinkSync(target, path, type), + callback + ); +} + +export function truncate(path: FilePath, callback: ErrorOnlyCallback): void; +export function truncate( + path: FilePath, + len: number, + callback: ErrorOnlyCallback +): void; +export function truncate( + path: FilePath, + lenOrCallback: number | ErrorOnlyCallback, + callback?: ErrorOnlyCallback +): void { + let len: number; + if (typeof lenOrCallback === 'function') { + callback = lenOrCallback; + len = 0; + } else { + len = lenOrCallback; + } + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.truncateSync(path, len), callback); +} + +export function unlink(path: FilePath, callback: ErrorOnlyCallback): void { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + callWithErrorOnlyCallback(() => fssync.unlinkSync(path), callback); +} + +export function utimes( + path: FilePath, + atime: string | number | Date, + mtime: string | number | Date, + callback: ErrorOnlyCallback +): void { + callWithErrorOnlyCallback( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + () => fssync.utimesSync(path, atime, mtime), + callback + ); +} + +export function write( + fd: number, + buffer: T | string, + offsetOptionsPositionOrCallback?: + | null + | WriteSyncOptions + | number + | bigint + | DoubleArgCallback, + encodingLengthOrCallback?: + | number + | BufferEncoding + | DoubleArgCallback, + positionOrCallback?: null | bigint | number | DoubleArgCallback, + callback?: DoubleArgCallback +): void { + if (typeof offsetOptionsPositionOrCallback === 'function') { + callback = offsetOptionsPositionOrCallback; + offsetOptionsPositionOrCallback = undefined; + } + if (typeof encodingLengthOrCallback === 'function') { + callback = encodingLengthOrCallback; + encodingLengthOrCallback = undefined; + } + if (typeof positionOrCallback === 'function') { + callback = positionOrCallback; + positionOrCallback = undefined; + } + if (typeof callback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], callback); + } + // Because the callback expects the buffer to be returned in the callback, + // we need to make sure that the buffer is not a string here rather than + // relying on the transformation in the writeSync call. + if (typeof buffer === 'string') { + let encoding = 'utf8'; + if (typeof encodingLengthOrCallback === 'string') { + encoding = encodingLengthOrCallback; + } + buffer = Buffer.from(buffer, encoding) as unknown as T; + } + try { + const written = fssync.writeSync( + fd, + buffer, + offsetOptionsPositionOrCallback, + encodingLengthOrCallback, + positionOrCallback + ); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(null, written, buffer as unknown as T)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(err)); + } +} + +export function writeFile( + path: number | FilePath, + data: string | ArrayBufferView, + callback: ErrorOnlyCallback +): void; +export function writeFile( + path: number | FilePath, + data: string | ArrayBufferView, + options: WriteFileOptions, + callback: ErrorOnlyCallback +): void; +export function writeFile( + path: number | FilePath, + data: string | ArrayBufferView, + optionsOrCallback: ErrorOnlyCallback | WriteFileOptions, + callback?: ErrorOnlyCallback +): void { + let options: WriteFileOptions; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = { + encoding: 'utf8', + mode: 0o666, + flag: 'w', + flush: false, + }; + } else { + options = optionsOrCallback; + } + callWithSingleArgCallback( + () => fssync.writeFileSync(path, data, options), + callback + ); +} + +export function writev( + fd: number, + buffers: T[], + positionOrCallback?: null | number | bigint | DoubleArgCallback, + callback?: DoubleArgCallback +): void { + if (typeof positionOrCallback === 'function') { + callback = positionOrCallback; + positionOrCallback = null; + } + if (typeof callback !== 'function') { + throw new ERR_INVALID_ARG_TYPE('callback', ['function'], callback); + } + try { + const written = fssync.writevSync(fd, buffers, positionOrCallback); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(null, written, buffers)); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + queueMicrotask(() => callback(err)); + } +} + +export function unwatchFile(): void { + // We currently do not implement file watching. + throw new Error('Not implemented'); +} + +export function watch(): void { + // We currently do not implement file watching. + throw new Error('Not implemented'); +} + +export function watchFile(): void { + // We currently do not implement file watching. + throw new Error('Not implemented'); +} + +export function createReadStream(): void { + throw new Error('Not implemented'); +} +export function createWriteStream(): void { + throw new Error('Not implemented'); +} + +// eslint-enable @typescript-eslint/no-confusing-void-expression + +// An API is considered stubbed if it is not implemented by the function +// exists with the correct signature and throws an error if called. If +// a function exists that does not have the correct signature, it is +// not considered fully stubbed. +// An API is considered optimized if the API has been implemented and +// tested and then optimized for performance. +// Implemented APIs here are a bit different than in the sync version +// since most of these are implemented in terms of calling the sync +// version. We consider it implemented here if the code is present and +// calls the sync api even if the sync api itself it not fully implemented. +// +// (S == Stubbed, I == Implemented, T == Tested, O == Optimized) +// S I T O +// [x][x][x][ ] fs.access(path[, mode], callback) +// [x][x][ ][ ] fs.appendFile(path, data[, options], callback) +// [x][x][x][ ] fs.chmod(path, mode, callback) +// [x][x][x][ ] fs.chown(path, uid, gid, callback) +// [x][x][x][ ] fs.close(fd[, callback]) +// [x][x][ ][ ] fs.copyFile(src, dest[, mode], callback) +// [x][x][ ][ ] fs.cp(src, dest[, options], callback) +// [ ][ ][ ][ ] fs.createReadStream(path[, options]) +// [ ][ ][ ][ ] fs.createWriteStream(path[, options]) +// [x][x][x][ ] fs.exists(path, callback) +// [x][x][x][ ] fs.fchmod(fd, mode, callback) +// [x][x][x][ ] fs.fchown(fd, uid, gid, callback) +// [x][x][x][ ] fs.fdatasync(fd, callback) +// [x][x][x][ ] fs.fstat(fd[, options], callback) +// [x][x][x][ ] fs.fsync(fd, callback) +// [x][x][x][ ] fs.ftruncate(fd[, len], callback) +// [x][x][x][ ] fs.futimes(fd, atime, mtime, callback) +// [ ][ ][ ][ ] fs.glob(pattern[, options], callback) +// [x][x][x][ ] fs.lchmod(path, mode, callback) +// [x][x][x][ ] fs.lchown(path, uid, gid, callback) +// [x][x][x][ ] fs.lutimes(path, atime, mtime, callback) +// [x][x][x][ ] fs.link(existingPath, newPath, callback) +// [x][x][x][ ] fs.lstat(path[, options], callback) +// [x][x][ ][ ] fs.mkdir(path[, options], callback) +// [x][x][ ][ ] fs.mkdtemp(prefix[, options], callback) +// [x][x][x][ ] fs.open(path[, flags[, mode]], callback) +// [ ][ ][ ][ ] fs.openAsBlob(path[, options]) +// [x][x][ ][ ] fs.opendir(path[, options], callback) +// [x][x][ ][ ] fs.read(fd, buffer, offset, length, position, callback) +// [x][x][ ][ ] fs.read(fd[, options], callback) +// [x][x][ ][ ] fs.read(fd, buffer[, options], callback) +// [x][x][ ][ ] fs.readdir(path[, options], callback) +// [x][x][ ][ ] fs.readFile(path[, options], callback) +// [x][x][x][ ] fs.readlink(path[, options], callback) +// [x][x][ ][ ] fs.readv(fd, buffers[, position], callback) +// [x][x][x][ ] fs.realpath(path[, options], callback) +// [x][x][x][ ] fs.realpath.native(path[, options], callback) +// [x][x][ ][ ] fs.rename(oldPath, newPath, callback) +// [x][x][ ][ ] fs.rmdir(path[, options], callback) +// [x][x][ ][ ] fs.rm(path[, options], callback) +// [x][x][x][ ] fs.stat(path[, options], callback) +// [x][x][x][ ] fs.statfs(path[, options], callback) +// [x][x][x][ ] fs.symlink(target, path[, type], callback) +// [x][x][x][ ] fs.truncate(path[, len], callback) +// [x][x][x][ ] fs.unlink(path, callback) +// [ ][ ][ ][ ] fs.unwatchFile(filename[, listener]) +// [x][x][x][ ] fs.utimes(path, atime, mtime, callback) +// [ ][ ][ ][ ] fs.watch(filename[, options][, listener]) +// [ ][ ][ ][ ] fs.watchFile(filename[, options], listener) +// [x][x][ ][ ] fs.write(fd, buffer, offset[, length[, position]], callback) +// [x][x][ ][ ] fs.write(fd, buffer[, options], callback) +// [x][x][ ][ ] fs.write(fd, string[, position[, encoding]], callback) +// [x][x][ ][ ] fs.writeFile(file, data[, options], callback) +// [x][x][ ][ ] fs.writev(fd, buffers[, position], callback) diff --git a/src/node/internal/internal_fs_constants.ts b/src/node/internal/internal_fs_constants.ts new file mode 100644 index 00000000000..ccc29a54f63 --- /dev/null +++ b/src/node/internal/internal_fs_constants.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +export const UV_FS_SYMLINK_DIR = 1; +export const UV_FS_SYMLINK_JUNCTION = 2; +export const O_RDONLY = 0; +export const O_WRONLY = 1; +export const O_RDWR = 2; +export const UV_DIRENT_UNKNOWN = 0; +export const UV_DIRENT_FILE = 1; +export const UV_DIRENT_DIR = 2; +export const UV_DIRENT_LINK = 3; +export const UV_DIRENT_FIFO = 4; +export const UV_DIRENT_SOCKET = 5; +export const UV_DIRENT_CHAR = 6; +export const UV_DIRENT_BLOCK = 7; +export const EXTENSIONLESS_FORMAT_JAVASCRIPT = 0; +export const EXTENSIONLESS_FORMAT_WASM = 1; +export const S_IFMT = 61440; +export const S_IFREG = 32768; +export const S_IFDIR = 16384; +export const S_IFCHR = 8192; +export const S_IFBLK = 24576; +export const S_IFIFO = 4096; +export const S_IFLNK = 40960; +export const S_IFSOCK = 49152; +export const O_CREAT = 64; +export const O_EXCL = 128; +export const UV_FS_O_FILEMAP = 0; +export const O_NOCTTY = 256; +export const O_TRUNC = 512; +export const O_APPEND = 1024; +export const O_DIRECTORY = 65536; +export const O_NOATIME = 262144; +export const O_NOFOLLOW = 131072; +export const O_SYNC = 1052672; +export const O_DSYNC = 4096; +export const O_DIRECT = 16384; +export const O_NONBLOCK = 2048; +export const S_IRWXU = 448; +export const S_IRUSR = 256; +export const S_IWUSR = 128; +export const S_IXUSR = 64; +export const S_IRWXG = 56; +export const S_IRGRP = 32; +export const S_IWGRP = 16; +export const S_IXGRP = 8; +export const S_IRWXO = 7; +export const S_IROTH = 4; +export const S_IWOTH = 2; +export const S_IXOTH = 1; +export const F_OK = 0; +export const R_OK = 4; +export const W_OK = 2; +export const X_OK = 1; +export const UV_FS_COPYFILE_EXCL = 1; +export const COPYFILE_EXCL = UV_FS_COPYFILE_EXCL; +export const UV_FS_COPYFILE_FICLONE = 2; +export const COPYFILE_FICLONE = UV_FS_COPYFILE_FICLONE; +export const UV_FS_COPYFILE_FICLONE_FORCE = 4; +export const COPYFILE_FICLONE_FORCE = UV_FS_COPYFILE_FICLONE_FORCE; diff --git a/src/node/internal/internal_fs_promises.ts b/src/node/internal/internal_fs_promises.ts new file mode 100644 index 00000000000..8115b895486 --- /dev/null +++ b/src/node/internal/internal_fs_promises.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/* eslint-disable @typescript-eslint/require-await,@typescript-eslint/no-unused-vars */ + +import { + getOptions, + copyObject, + normalizePath, + kMaxUserId, + validateCpOptions, + toUnixTimestamp, + validateRmdirOptions, + type FilePath, +} from 'node-internal:internal_fs_utils'; +import * as constants from 'node-internal:internal_fs_constants'; +import { + parseFileMode, + validateInteger, + validateBoolean, + validateAbortSignal, + validateOneOf, +} from 'node-internal:validators'; +import type { RmDirOptions, CopyOptions } from 'node:fs'; + +export async function access( + _path: FilePath, + _mode: number = constants.F_OK +): Promise { + throw new Error('Not implemented'); +} + +export async function appendFile( + _path: FilePath, + _data: unknown, + options: Record +): Promise { + options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + options = copyObject(options); + options.flag ||= 'a'; + throw new Error('Not implemented'); +} + +export async function chmod(path: FilePath, mode: number): Promise { + path = normalizePath(path); + mode = parseFileMode(mode, 'mode'); + throw new Error('Not implemented'); +} + +export async function chown( + path: FilePath, + uid: number, + gid: number +): Promise { + path = normalizePath(path); + validateInteger(uid, 'uid', -1, kMaxUserId); + validateInteger(gid, 'gid', -1, kMaxUserId); + throw new Error('Not implemented'); +} + +export async function copyFile( + src: FilePath, + dest: FilePath, + _mode: number +): Promise { + src = normalizePath(src); + dest = normalizePath(dest); + throw new Error('Not implemented'); +} + +export async function cp( + src: FilePath, + dest: FilePath, + options: CopyOptions +): Promise { + options = validateCpOptions(options); + src = normalizePath(src); + dest = normalizePath(dest); + throw new Error('Not implemented'); +} + +export async function lchmod(_path: FilePath, _mode: number): Promise { + throw new Error('Not implemented'); +} + +export async function lchown( + path: FilePath, + uid: number, + gid: number +): Promise { + path = normalizePath(path); + validateInteger(uid, 'uid', -1, kMaxUserId); + validateInteger(gid, 'gid', -1, kMaxUserId); + throw new Error('Not implemented'); +} + +export async function lutimes( + path: FilePath, + atime: string | number | Date, + mtime: string | number | Date +): Promise { + path = normalizePath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); + throw new Error('Not implemented'); +} + +export async function link( + existingPath: FilePath, + newPath: FilePath +): Promise { + existingPath = normalizePath(existingPath); + newPath = normalizePath(newPath); + throw new Error('Not implemented'); +} + +export async function lstat( + path: FilePath, + _options: Record = { bigint: false } +): Promise { + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function mkdir( + path: FilePath, + options?: Record +): Promise { + if (typeof options === 'number' || typeof options === 'string') { + options = { mode: options }; + } + const recursive = options?.recursive ?? false; + let mode = options?.mode ?? 0o777; + path = normalizePath(path); + validateBoolean(recursive, 'options.recursive'); + mode = parseFileMode(mode, 'mode', 0o777); + throw new Error('Not implemented'); +} + +export async function mkdtemp( + prefix: FilePath, + options: Record +): Promise { + options = getOptions(options); + prefix = normalizePath(prefix); + throw new Error('Not implemented'); +} + +export async function open( + path: FilePath, + _flags: number | string | null, + mode: number +): Promise { + path = normalizePath(path); + mode = parseFileMode(mode, 'mode', 0o666); + throw new Error('Not implemented'); +} + +export async function opendir( + path: FilePath, + options: Record +): Promise { + path = normalizePath(path); + options = getOptions(options, { + encoding: 'utf8', + }); + throw new Error('Not implemented'); +} + +export async function readdir( + path: FilePath, + options: Record +): Promise { + options = copyObject(getOptions(options)); + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function readFile( + _path: FilePath, + options: Record +): Promise { + options = getOptions(options, { flag: 'r' }); + throw new Error('Not implemented'); +} + +export async function readlink( + path: FilePath, + options: Record +): Promise { + options = getOptions(options); + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function realpath( + path: FilePath, + options: Record +): Promise { + path = normalizePath(path); + options = getOptions(options); + throw new Error('Not implemented'); +} + +export async function rename( + oldPath: FilePath, + newPath: FilePath +): Promise { + oldPath = normalizePath(oldPath); + newPath = normalizePath(newPath); + throw new Error('Not implemented'); +} + +export async function rmdir( + path: FilePath, + options: RmDirOptions +): Promise { + path = normalizePath(path); + options = validateRmdirOptions(options); + throw new Error('Not implemented'); +} + +export async function rm( + path: FilePath, + _options: Record +): Promise { + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function stat( + path: FilePath, + _options: Record = { bigint: false } +): Promise { + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function statfs( + _path: FilePath, + _options: Record = { bigint: false } +): Promise { + throw new Error('Not implemented'); +} + +export async function symlink( + target: FilePath, + path: FilePath, + type: string | null | undefined +): Promise { + validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); + target = normalizePath(target); + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function truncate( + _path: FilePath, + _len: number = 0 +): Promise { + throw new Error('Not implemented'); +} + +export async function unlink(path: FilePath): Promise { + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export async function utimes( + path: FilePath, + atime: string | number | Date, + mtime: string | number | Date +): Promise { + path = normalizePath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); + throw new Error('Not implemented'); +} + +export async function watch(): Promise { + throw new Error('Not implemented'); +} + +export async function writeFile( + _path: FilePath, + _data: unknown, + options: Record +): Promise { + options = getOptions(options, { + encoding: 'utf8', + mode: 0o666, + flag: 'w', + flush: false, + }); + const flush = options.flush ?? false; + + validateBoolean(flush, 'options.flush'); + validateAbortSignal(options.signal, 'options.signal'); + + throw new Error('Not implemented'); +} diff --git a/src/node/internal/internal_fs_streams.ts b/src/node/internal/internal_fs_streams.ts new file mode 100644 index 00000000000..90c53cb0f3d --- /dev/null +++ b/src/node/internal/internal_fs_streams.ts @@ -0,0 +1,121 @@ +import { Readable } from 'node-internal:streams_readable'; +import { Writable } from 'node-internal:streams_writable'; + +export type ReadStreamOptions = { + autoDestroy?: boolean; + fd?: number; + flags?: string; + highWaterMark?: number; + mode?: number; +}; + +// @ts-expect-error TS2323 Cannot redeclare. +export declare class ReadStream extends Readable { + public fd: number | null; + public flags: string; + public path: string; + public mode: number; + + public constructor(path: string, options?: Record); + public close(callback: VoidFunction): void; +} + +// @ts-expect-error TS2323 Cannot redeclare. +export function ReadStream( + this: unknown, + path: string, + options?: ReadStreamOptions +): ReadStream { + if (!(this instanceof ReadStream)) { + return new ReadStream(path, options); + } + + // TODO(soon): Implement this. + + Reflect.apply(Readable, this, [options]); + return this; +} +Object.setPrototypeOf(ReadStream.prototype, Readable.prototype); +Object.setPrototypeOf(ReadStream, Readable); + +Object.defineProperty(ReadStream.prototype, 'autoClose', { + get(this: ReadStream): boolean { + // TODO(soon): Implement this. + return false; + }, + set(this: ReadStream, _val: boolean): void { + // TODO(soon): Implement this. + }, +}); + +ReadStream.prototype.close = function (_cb: VoidFunction): void { + throw new Error('Not implemented'); +}; + +Object.defineProperty(ReadStream.prototype, 'pending', { + get(this: ReadStream): boolean { + return this.fd === null; + }, + configurable: true, +}); + +export type WriteStreamOptions = { + flags?: string; + encoding?: string; + fd?: number; + mode?: number; + autoClose?: boolean; +}; + +// @ts-expect-error TS2323 Cannot redeclare. +export declare class WriteStream extends Writable { + public constructor(path: string, options?: WriteStreamOptions); + public close(cb: VoidFunction): void; + public destroySoon(): void; +} + +// @ts-expect-error TS2323 Cannot redeclare. +export function WriteStream( + this: unknown, + path: string, + options?: WriteStreamOptions +): WriteStream { + if (!(this instanceof WriteStream)) { + return new WriteStream(path, options); + } + + // TODO(soon): Implement this. + + Reflect.apply(Writable, this, [options]); + return this; +} +Object.setPrototypeOf(WriteStream.prototype, Writable.prototype); +Object.setPrototypeOf(WriteStream, Writable); + +Object.defineProperty(WriteStream.prototype, 'autoClose', { + get(this: WriteStream): boolean { + return false; + }, + set(this: WriteStream, _val: boolean): void { + // TODO(soon): implement this + }, +}); + +WriteStream.prototype.close = function ( + this: typeof WriteStream, + _cb: VoidFunction +): void { + throw new Error('Not implemented'); +}; + +// There is no shutdown() for files. +// eslint-disable-next-line @typescript-eslint/unbound-method +WriteStream.prototype.destroySoon = WriteStream.prototype.end; + +Object.defineProperty(WriteStream.prototype, 'pending', { + get(this: WriteStream): boolean { + // TODO(soon): Implement this + return false; + }, + configurable: true, +}); diff --git a/src/node/internal/internal_fs_sync.ts b/src/node/internal/internal_fs_sync.ts new file mode 100644 index 00000000000..b9daa0451af --- /dev/null +++ b/src/node/internal/internal_fs_sync.ts @@ -0,0 +1,1006 @@ +/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unnecessary-condition */ + +import { + kMaxUserId, + stringToFlags, + getValidatedFd, + validateBufferArray, + validateStringAfterArrayBufferView, + normalizePath, + Stats, + kBadge, + type FilePath, + validatePosition, +} from 'node-internal:internal_fs_utils'; +import { + validateInteger, + parseFileMode, + validateBoolean, + validateObject, + validateOneOf, + validateEncoding, + validateUint32, +} from 'node-internal:validators'; +import { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_ENOENT, + ERR_EBADF, + ERR_EINVAL, +} from 'node-internal:internal_errors'; +import { isArrayBufferView } from 'node-internal:internal_types'; +import { + F_OK, + X_OK, + W_OK, + O_WRONLY, + O_RDWR, + O_APPEND, + O_EXCL, + COPYFILE_EXCL, + COPYFILE_FICLONE_FORCE, +} from 'node-internal:internal_fs_constants'; +import { type Dir, Dirent } from 'node-internal:internal_fs'; +import { default as cffs } from 'cloudflare-internal:filesystem'; + +import { Buffer } from 'node-internal:internal_buffer'; +import type { + BigIntStatsFs, + CopySyncOptions, + MakeDirectoryOptions, + OpenDirOptions, + ReadSyncOptions, + RmOptions, + RmDirOptions, + StatsFs, + WriteFileOptions, +} from 'node:fs'; + +function accessSyncImpl( + path: FilePath, + mode: number = F_OK, + followSymlinks: boolean +): void { + validateUint32(mode, 'mode'); + + const normalizedPath = normalizePath(path); + + // If the X_OK flag is set we will always throw because we don't + // support executable files. + if (mode & X_OK) { + throw new ERR_ENOENT(normalizedPath.pathname, { syscall: 'access' }); + } + + const stat = cffs.stat(normalizedPath, { followSymlinks }); + + // Similar to node.js, we make no differentiation between the file + // not existing and the file existing but not being accessible. + if (stat == null) { + // Not found... + throw new ERR_ENOENT(normalizedPath.pathname, { syscall: 'access' }); + } + + if (mode & W_OK && !stat.writable) { + // Nor writable... + throw new ERR_ENOENT(normalizedPath.pathname, { syscall: 'access' }); + } + + // We always assume that files are readable, so if we get here the + // path is accessible. +} + +export function accessSync(path: FilePath, mode: number = F_OK): void { + accessSyncImpl(path, mode, true); +} + +export function appendFileSync( + path: number | FilePath, + data: string | ArrayBufferView, + options: BufferEncoding | null | WriteFileOptions = {} +): number { + if (typeof options === 'string' || options == null) { + options = { encoding: options as BufferEncoding | null }; + } + const { + encoding = 'utf8', + mode = 0o666, + flag = 'a', + flush = false, + } = options ?? {}; + // We defer to writeFileSync for the actual implementation and validation + return writeFileSync(path, data, { encoding, mode, flag, flush }); +} + +export function chmodSync(path: FilePath, mode: string | number): void { + parseFileMode(mode, 'mode'); + accessSync(path, F_OK); + // We do not implement chmod in any meaningful way as our filesystem + // has no concept of user-defined permissions. Once we validate the inputs + // we just return as a non-op. + // The reason we call accessSync is to ensure, at the very least, that + // the path exists and would otherwise be accessible. +} + +export function chownSync(path: FilePath, uid: number, gid: number): void { + validateInteger(uid, 'uid', -1, kMaxUserId); + validateInteger(gid, 'gid', -1, kMaxUserId); + accessSync(path, F_OK); + // We do not implement chown in any meaningful way as our filesystem + // has no concept of ownership. Once we validate the inputs we just + // return as a non-op. + // The reason we call accessSync is to ensure, at the very least, that + // the path exists and would otherwise be accessible. +} + +export function closeSync(fd: number): void { + cffs.close(getValidatedFd(fd)); +} + +export function copyFileSync( + src: FilePath, + dest: FilePath, + _mode: number +): void { + cffs.renameOrCopy(normalizePath(src), normalizePath(dest), { copy: true }); +} + +export function cpSync( + src: FilePath, + dest: FilePath, + options: CopySyncOptions = {} +): void { + validateObject(options, 'options'); + const { + dereference = false, + errorOnExist = false, + force = true, + filter, + mode = 0, + preserveTimestamps = false, + recursive = false, + verbatimSymlinks = false, + } = options; + validateBoolean(dereference, 'options.dereference'); + validateBoolean(errorOnExist, 'options.errorOnExist'); + validateBoolean(force, 'options.force'); + validateBoolean(preserveTimestamps, 'options.preserveTimestamps'); + validateBoolean(recursive, 'options.recursive'); + validateBoolean(verbatimSymlinks, 'options.verbatimSymlinks'); + validateUint32(mode, 'options.mode'); + + if (mode & COPYFILE_FICLONE_FORCE) { + throw new ERR_INVALID_ARG_VALUE( + 'options.mode', + 'COPYFILE_FICLONE_FORCE is not supported' + ); + } + + if (filter !== undefined && typeof filter !== 'function') { + throw new ERR_INVALID_ARG_TYPE('options.filter', 'function', filter); + } + + const exclusive = Boolean(mode & COPYFILE_EXCL); + // We're not current implementing the exclusive flag. We're validating + // it here just to use it so the compiler doesn't complain. + validateBoolean(exclusive, ''); + + src = normalizePath(src); + dest = normalizePath(dest); + throw new Error('Not implemented'); +} + +export function existsSync(path: FilePath): boolean { + try { + // The existsSync function follows symlinks. If the symlink is broken, + // it will return false. + accessSync(path, F_OK); + return true; + } catch (err) { + return false; + } +} + +export function fchmodSync(fd: number, mode: string | number): void { + fd = getValidatedFd(fd); + parseFileMode(mode, 'mode'); + if (cffs.stat(fd, { followSymlinks: true }) == null) { + throw new ERR_EBADF({ syscall: 'fchmod' }); + } + // We do not implement chmod in any meaningful way as our filesystem + // has no concept of user-defined permissions. Once we validate the inputs + // we just return as a non-op. + // The reason we call cffs.stat is to ensure, at the very least, that + // the fd is valid and would otherwise be accessible. +} + +export function fchownSync(fd: number, uid: number, gid: number): void { + fd = getValidatedFd(fd); + validateInteger(uid, 'uid', -1, kMaxUserId); + validateInteger(gid, 'gid', -1, kMaxUserId); + if (cffs.stat(fd, { followSymlinks: true }) == null) { + throw new ERR_EBADF({ syscall: 'fchown' }); + } + // We do not implement chown in any meaningful way as our filesystem + // has no concept of ownership. Once we validate the inputs we just + // return as a non-op. + // The reason we call accessSync is to ensure, at the very least, that + // the path exists and would otherwise be accessible. +} + +export function fdatasyncSync(fd: number): void { + fd = getValidatedFd(fd); + // We do not implement fdatasync in any meaningful way. At most we + // will validate that the fd is valid and would otherwise be accessible. + if (cffs.stat(fd, { followSymlinks: true }) == null) { + throw new ERR_EBADF({ syscall: 'datasync' }); + } +} + +export type FStatOptions = { + bigint?: boolean | undefined; +}; + +export function fstatSync(fd: number, options: FStatOptions = {}): Stats { + fd = getValidatedFd(fd); + validateObject(options, 'options'); + const { bigint = false } = options; + validateBoolean(bigint, 'options.bigint'); + const stat = cffs.stat(fd, { followSymlinks: true }); + if (stat == null) { + throw new ERR_EBADF({ syscall: 'stat' }); + } + return new Stats(kBadge, stat, { bigint }); +} + +export function fsyncSync(fd: number): void { + fd = getValidatedFd(fd); + // We do not implement fdatasync in any meaningful way. At most we + // will validate that the fd is valid and would otherwise be accessible. + if (cffs.stat(fd, { followSymlinks: true }) == null) { + throw new ERR_EBADF({ syscall: 'sync' }); + } +} + +export function ftruncateSync(fd: number, len: number = 0): void { + validateUint32(len, 'len'); + cffs.truncate(getValidatedFd(fd), len); +} + +function getDate(time: string | number | bigint | Date): Date { + if (typeof time === 'number') { + return new Date(time); + } else if (typeof time === 'bigint') { + return new Date(Number(time)); + } else if (typeof time === 'string') { + return new Date(time); + } else if (time instanceof Date) { + return time; + } + throw new ERR_INVALID_ARG_TYPE( + 'time', + ['string', 'number', 'bigint', 'Date'], + time + ); +} + +export function futimesSync( + fd: number, + atime: string | number | bigint | Date, + mtime: string | number | bigint | Date +): void { + // We do not actually make use of access time in our filesystem. We just + // validate the inputs here. + atime = getDate(atime); + mtime = getDate(mtime); + cffs.setLastModified(getValidatedFd(fd), mtime, {}); +} + +export function lchmodSync(path: FilePath, mode: string | number): void { + path = normalizePath(path); + parseFileMode(mode, 'mode'); + if (cffs.stat(normalizePath(path), { followSymlinks: false }) == null) { + throw new ERR_ENOENT(path.pathname, { syscall: 'lchmod' }); + } + // We do not implement chmod in any meaningful way as our filesystem + // has no concept of user-defined permissions. Once we validate the inputs + // we just return as a non-op. + // The reason we call cffs.stat is to ensure, at the very least, that + // the fd is valid and would otherwise be accessible. +} + +export function lchownSync(path: FilePath, uid: number, gid: number): void { + path = normalizePath(path); + validateInteger(uid, 'uid', -1, kMaxUserId); + validateInteger(gid, 'gid', -1, kMaxUserId); + if (cffs.stat(normalizePath(path), { followSymlinks: false }) == null) { + throw new ERR_ENOENT(path.pathname, { syscall: 'lchown' }); + } + // We do not implement chown in any meaningful way as our filesystem + // has no concept of user-defined permissions. Once we validate the inputs + // we just return as a non-op. + // The reason we call cffs.stat is to ensure, at the very least, that + // the fd is valid and would otherwise be accessible. +} + +export function lutimesSync( + path: FilePath, + atime: string | number | bigint | Date, + mtime: string | number | bigint | Date +): void { + // We do not actually make use of access time in our filesystem. We just + // validate the inputs here. + atime = getDate(atime); + mtime = getDate(mtime); + cffs.setLastModified(normalizePath(path), mtime, { followSymlinks: false }); +} + +export function linkSync(existingPath: FilePath, newPath: FilePath): void { + cffs.link(normalizePath(newPath), normalizePath(existingPath), { + symbolic: false, + }); +} + +// We could use the StatSyncOptions from @types:node here but the definition +// of that in @types:node is bit overly complex for our use here. +export type StatOptions = { + bigint?: boolean | undefined; + throwIfNoEntry?: boolean | undefined; +}; + +export function lstatSync( + path: FilePath, + options: StatOptions = { bigint: false, throwIfNoEntry: true } +): Stats | undefined { + validateObject(options, 'options'); + const { bigint = false, throwIfNoEntry = true } = options; + validateBoolean(bigint, 'options.bigint'); + validateBoolean(throwIfNoEntry, 'options.throwIfNoEntry'); + const normalizedPath = normalizePath(path); + const stat = cffs.stat(normalizedPath, { followSymlinks: false }); + if (stat == null) { + if (throwIfNoEntry) { + throw new ERR_ENOENT(normalizedPath.pathname, { syscall: 'lstat' }); + } + return undefined; + } + return new Stats(kBadge, stat, { bigint }); +} + +export function mkdirSync( + path: FilePath, + options: number | MakeDirectoryOptions = {} +): string | undefined { + const { recursive = false, mode = 0o777 } = ((): MakeDirectoryOptions => { + if (typeof options === 'number') { + return { mode: options }; + } else { + validateObject(options, 'options'); + return options; + } + })(); + + validateBoolean(recursive, 'options.recursive'); + + // We don't implement the mode option in any meaningful way. We just validate it. + parseFileMode(mode, 'mode'); + + return cffs.mkdir(normalizePath(path), { recursive, tmp: false }); +} + +export type MkdirTempSyncOptions = { + encoding?: BufferEncoding | null | undefined; +}; + +export function mkdtempSync( + prefix: FilePath, + options: BufferEncoding | null | MkdirTempSyncOptions = {} +): string { + if (typeof options === 'string' || options == null) { + options = { encoding: options }; + } + validateObject(options, 'options'); + const { encoding = 'utf8' } = options; + validateEncoding(encoding, 'options.encoding'); + prefix = normalizePath(prefix, encoding); + const ret = cffs.mkdir(normalizePath(prefix), { + recursive: false, + tmp: true, + }); + if (ret === undefined) { + // If mkdir failed it should throw a meaningful error. If we get + // here, it means something else went wrong and we're just going + // to throw a generic EINVAL error. + throw new ERR_EINVAL({ syscall: 'mkdtemp' }); + } + return ret; +} + +export function opendirSync(path: FilePath, options: OpenDirOptions = {}): Dir { + validateObject(options, 'options'); + const { encoding = 'utf8', bufferSize = 32, recursive = false } = options; + validateEncoding(encoding, 'options.encoding'); + validateUint32(bufferSize, 'options.bufferSize'); + validateBoolean(recursive, 'options.recursive'); + path = normalizePath(path); + throw new Error('Not implemented'); +} + +export function openSync( + path: FilePath, + flags: string | number = 'r', + mode: string | number = 0o666 +): number { + // We don't actually the the mode in any meaningful way. We just validate it. + parseFileMode(mode, 'mode', 0o666); + const newFlags = stringToFlags(flags); + + const read = !(newFlags & O_WRONLY) || Boolean(newFlags & O_RDWR); + const write = Boolean(newFlags & O_WRONLY) || Boolean(newFlags & O_RDWR); + const append = Boolean(newFlags & O_APPEND); + const exclusive = Boolean(newFlags & O_EXCL); + const followSymlinks = true; + + return cffs.open(normalizePath(path), { + read, + write, + append, + exclusive, + followSymlinks, + }); +} + +// We could use the @types/node definition here but it's a bit overly +// complex for our needs here. +export type ReadDirOptions = { + encoding?: BufferEncoding | null | undefined; + withFileTypes?: boolean | undefined; + recursive?: boolean | undefined; +}; + +export type ReadDirResult = string[] | Buffer[] | Dirent[]; + +export function readdirSync( + path: FilePath, + options: BufferEncoding | null | ReadDirOptions = {} +): ReadDirResult { + if (typeof options === 'string' || options == null) { + options = { encoding: options }; + } + validateObject(options, 'options'); + const { + encoding = 'utf8', + withFileTypes = false, + recursive = false, + } = options; + validateEncoding(encoding, 'options.encoding'); + validateBoolean(withFileTypes, 'options.withFileTypes'); + validateBoolean(recursive, 'options.recursive'); + + const handles = cffs.readdir(normalizePath(path), { recursive }); + + if (withFileTypes) { + return handles.map((handle) => { + return new Dirent(handle.name, handle.type, handle.parentPath); + }); + } + + return handles.map((handle) => { + return handle.name; + }); +} + +export type ReadFileSyncOptions = { + encoding?: BufferEncoding | null | undefined; + flag?: string | number | undefined; +}; + +export function readFileSync( + pathOrFd: number | FilePath, + options: BufferEncoding | null | ReadFileSyncOptions = {} +): string | Buffer { + if (typeof options === 'string' || options == null) { + options = { encoding: options }; + } + validateObject(options, 'options'); + const { encoding = 'utf8', flag = 'r' } = options; + validateEncoding(encoding, 'options.encoding'); + stringToFlags(flag); + + // TODO(node:fs): We are currently ignoring flags on readFileSync. + + const u8 = ((): Uint8Array => { + if (typeof pathOrFd === 'number') { + return cffs.readAll(getValidatedFd(pathOrFd)); + } + return cffs.readAll(normalizePath(pathOrFd)); + })(); + + const buf = Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength); + if (typeof encoding === 'string') { + return buf.toString(encoding); + } + return buf; +} + +export type ReadLinkSyncOptions = { + encoding?: BufferEncoding | null | undefined; +}; + +export function readlinkSync( + path: FilePath, + options: BufferEncoding | null | ReadLinkSyncOptions = {} +): string | Buffer { + if (typeof options === 'string' || options == null) { + options = { encoding: options }; + } + validateObject(options, 'options'); + const { encoding = 'utf8' } = options; + validateEncoding(encoding, 'options.encoding'); + const dest = Buffer.from( + cffs.readLink(normalizePath(path), { failIfNotSymlink: true }) + ); + if (typeof encoding === 'string') { + return dest.toString(encoding); + } + return dest; +} + +// readSync is overloaded to support two different signatures: +// fs.readSync(fd, buffer, offset, length, position) +// fs.readSync(fd, buffer, options) +// +// fd is always a number, buffer is an ArrayBufferView, offset and length +// are numbers, and position is either a number, bigint, or null. +export function readSync( + fd: number, + buffer: NodeJS.ArrayBufferView, + offsetOrOptions: ReadSyncOptions | number = {}, + length?: number, + position: number | bigint | null = null +): number { + fd = getValidatedFd(fd); + + // Great fun with polymorphism here. We're going to normalize the arguments + // to match the first signature (fd, buffer, offset, length, position). + // + // If the third argument is an object, then we will pull the offset, length, + // and position from it, ignoring the remaining arguments. If the third + // argument is a number, then we will use it as the offset and pull the + // length and position from the fourth and fifth arguments. If the third + // position is any other type, then we will throw an error. + + if (!isArrayBufferView(buffer)) { + throw new ERR_INVALID_ARG_TYPE( + 'buffer', + ['Buffer', 'TypedArray', 'DataView'], + buffer + ); + } + + let actualOffset = buffer.byteOffset; + let actualLength = buffer.byteLength; + let actualPosition = position; + + // Handle the case where the third argument is an options object + if (offsetOrOptions != null && typeof offsetOrOptions === 'object') { + const { + offset = 0, + length = buffer.byteLength - offset, + position = null, + } = offsetOrOptions; + actualOffset = offset; + actualLength = length; + actualPosition = position; + } + // Handle the case where the third argument is a number (offset) + else if (typeof offsetOrOptions === 'number') { + actualOffset = offsetOrOptions; + actualLength = length ?? buffer.byteLength - actualOffset; + actualPosition = position; + } else { + throw new ERR_INVALID_ARG_TYPE( + 'offset', + ['number', 'object'], + offsetOrOptions + ); + } + + validateUint32(actualOffset, 'offset'); + validateUint32(actualLength, 'length'); + validatePosition(actualPosition, 'position'); + + // The actualOffset plus actualLength must not exceed the buffer's byte length. + if (actualOffset + actualLength > buffer.byteLength) { + throw new ERR_INVALID_ARG_VALUE('offset', actualOffset, 'out of bounds'); + } + + if (actualLength === 0 || buffer.byteLength === 0) { + return 0; + } + + return readvSync( + fd, + [Buffer.from(buffer.buffer, actualOffset, actualLength)], + actualPosition + ); +} + +export function readvSync( + fd: number, + buffers: NodeJS.ArrayBufferView[], + position: number | bigint | null = null +): number { + fd = getValidatedFd(fd); + validateBufferArray(buffers); + + if ( + position != null && + typeof position !== 'number' && + typeof position !== 'bigint' + ) { + throw new ERR_INVALID_ARG_TYPE( + 'position', + ['null', 'number', 'bigint'], + position + ); + } + + if (buffers.length === 0) { + return 0; + } + return cffs.read(fd, buffers, { position }); +} + +// TODO: Implement fs.realpathSync.native +export function realpathSync( + p: FilePath, + options: BufferEncoding | null | ReadLinkSyncOptions = {} +): string | Buffer { + if (typeof options === 'string' || options == null) { + options = { encoding: options }; + } + validateObject(options, 'options'); + const { encoding = 'utf8' } = options; + validateEncoding(encoding, 'options.encoding'); + const dest = Buffer.from( + cffs.readLink(normalizePath(p), { failIfNotSymlink: false }) + ); + if (typeof encoding === 'string') { + return dest.toString(encoding); + } + return dest; +} + +realpathSync.native = realpathSync; + +export function renameSync(src: FilePath, dest: FilePath): void { + cffs.renameOrCopy(normalizePath(src), normalizePath(dest), { copy: false }); +} + +export function rmdirSync(path: FilePath, options: RmDirOptions = {}): void { + validateObject(options, 'options'); + const { maxRetries = 0, recursive = false, retryDelay = 0 } = options; + // We do not implement the maxRetries or retryDelay options in any meaningful + // way. We just validate them. + validateUint32(maxRetries, 'options.maxRetries'); + validateBoolean(recursive, 'options.recursive'); + validateUint32(retryDelay, 'options.retryDelay'); + + cffs.rm(normalizePath(path), { recursive, force: false, dironly: true }); +} + +export function rmSync(path: FilePath, options: RmOptions = {}): void { + validateObject(options, 'options'); + const { + force = false, + maxRetries = 0, + recursive = false, + retryDelay = 0, + } = options; + // We do not implement the maxRetries or retryDelay options in any meaningful + // way. We just validate them. + validateBoolean(force, 'options.force'); + validateUint32(maxRetries, 'options.maxRetries'); + validateBoolean(recursive, 'options.recursive'); + validateUint32(retryDelay, 'options.retryDelay'); + + cffs.rm(normalizePath(path), { recursive, force, dironly: false }); +} + +export function statSync( + path: FilePath, + options: StatOptions = {} +): Stats | undefined { + validateObject(options, 'options'); + const { bigint = false, throwIfNoEntry = true } = options; + validateBoolean(bigint, 'options.bigint'); + validateBoolean(throwIfNoEntry, 'options.throwIfNoEntry'); + const normalizedPath = normalizePath(path); + const stat = cffs.stat(normalizedPath, { followSymlinks: true }); + if (stat == null) { + if (throwIfNoEntry) { + throw new ERR_ENOENT(normalizedPath.pathname, { syscall: 'stat' }); + } + return undefined; + } + return new Stats(kBadge, stat, { bigint }); +} + +export function statfsSync( + path: FilePath, + options: { bigint?: boolean | undefined } = {} +): StatsFs | BigIntStatsFs { + normalizePath(path); + validateObject(options, 'options'); + const { bigint = false } = options; + validateBoolean(bigint, 'options.bigint'); + // We don't implement statfs in any meaningful way. Nor will we actually + // validate that the path exists. We just return a non-op dummy object. + if (bigint) { + return { + type: 0n, + bsize: 0n, + blocks: 0n, + bfree: 0n, + bavail: 0n, + files: 0n, + ffree: 0n, + }; + } else { + return { + type: 0, + bsize: 0, + blocks: 0, + bfree: 0, + bavail: 0, + files: 0, + ffree: 0, + }; + } +} + +export function symlinkSync( + target: FilePath, + path: FilePath, + type: string | null = null +): void { + // We don't implement type in any meaningful way but we do validate it. + validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); + cffs.link(normalizePath(path), normalizePath(target), { symbolic: true }); +} + +export function truncateSync(path: FilePath, len: number = 0): void { + validateUint32(len, 'len'); + cffs.truncate(normalizePath(path), len); +} + +export function unlinkSync(path: FilePath): void { + cffs.unlink(normalizePath(path)); +} + +export function utimesSync( + path: FilePath, + atime: number | string | bigint | Date, + mtime: number | string | bigint | Date +): void { + // We do not actually make use of access time in our filesystem. We just + // validate the inputs here. + atime = getDate(atime); + mtime = getDate(mtime); + cffs.setLastModified(normalizePath(path), mtime, { followSymlinks: true }); +} + +export function writeFileSync( + path: number | FilePath, + data: string | ArrayBufferView, + options: BufferEncoding | null | WriteFileOptions = {} +): number { + if (typeof path === 'number') { + path = getValidatedFd(path); + } else { + path = normalizePath(path); + } + + if (typeof options === 'string' || options == null) { + options = { encoding: options as BufferEncoding | null }; + } + + validateObject(options, 'options'); + const { + encoding = 'utf8', + mode = 0o666, + flag = 'w', + flush = false, + } = options; + validateEncoding(encoding, 'options.encoding'); + validateBoolean(flush, 'options.flush'); + parseFileMode(mode, 'options.mode', 0o666); + const newFlag = stringToFlags(flag as string); + + const append = Boolean(newFlag & O_APPEND); + const write = Boolean(newFlag & O_WRONLY || newFlag & O_RDWR) || append; + const exclusive = Boolean(newFlag & O_EXCL); + + if (!write) { + throw new ERR_INVALID_ARG_VALUE( + 'flag', + flag, + 'must be indicate write or append' + ); + } + + // We're not currently implementing the exclusive flag. We're validating + // it here just to use it so the compiler doesn't complain. + validateBoolean(exclusive, ''); + + if (typeof data === 'string') { + data = Buffer.from(data, encoding); + } + + if (!isArrayBufferView(data)) { + throw new ERR_INVALID_ARG_TYPE( + 'data', + ['string', 'Buffer', 'TypedArray', 'DataView'], + data + ); + } + + return cffs.writeAll(path, data, { append, exclusive }); +} + +export type WriteSyncOptions = { + offset?: number | undefined; + length?: number | undefined; + position?: number | null | undefined; +}; + +export function writeSync( + fd: number, + buffer: NodeJS.ArrayBufferView | string, + offsetOrOptions: number | WriteSyncOptions | null | bigint = null, + length?: number | BufferEncoding | null, + position?: number | bigint | null +): number { + fd = getValidatedFd(fd); + + let offset: number | undefined | null = offsetOrOptions as number; + if (isArrayBufferView(buffer)) { + if (typeof offsetOrOptions === 'object' && offsetOrOptions != null) { + ({ + offset = 0, + length = buffer.byteLength - offset, + position = null, + } = (offsetOrOptions as WriteSyncOptions | null) || {}); + } + position ??= null; + offset ??= buffer.byteOffset; + + validateInteger(offset, 'offset', 0); + + length ??= buffer.byteLength - offset; + + validateInteger(length, 'length', 0); + + // Validate that the offset + length do not exceed the buffer's byte length. + if (offset + length > buffer.byteLength) { + throw new ERR_INVALID_ARG_VALUE('offset', offset, 'out of bounds'); + } + + return writevSync( + fd, + [Buffer.from(buffer.buffer, offset, length)], + position + ); + } + + validateStringAfterArrayBufferView(buffer, 'buffer'); + + // In this case, offsetOrOptions must either be a number, bigint, or null. + if ( + offsetOrOptions != null && + typeof offsetOrOptions !== 'number' && + typeof offsetOrOptions !== 'bigint' + ) { + throw new ERR_INVALID_ARG_TYPE( + 'offset', + ['null', 'number', 'bigint'], + offsetOrOptions + ); + } + position = offsetOrOptions; + + validateEncoding(buffer, length as string); + buffer = Buffer.from(buffer, length as string /* encoding */); + + return writevSync(fd, [buffer], position); +} + +export function writevSync( + fd: number, + buffers: NodeJS.ArrayBufferView[], + position: number | null | bigint = null +): number { + fd = getValidatedFd(fd); + validateBufferArray(buffers); + + if ( + position != null && + typeof position !== 'number' && + typeof position !== 'bigint' + ) { + throw new ERR_INVALID_ARG_TYPE( + 'position', + ['null', 'number', 'bigint'], + position + ); + } + + if (buffers.length === 0) { + return 0; + } + + return cffs.write(fd, buffers, { position }); +} + +// An API is considered stubbed if it is not implemented by the function +// exists with the correct signature and throws an error if called. If +// a function exists that does not have the correct signature, it is +// not considered fully stubbed. +// An API is considered optimized if the API has been implemented and +// tested and then optimized for performance. +// +// (S == Stubbed, I == Implemented, T == Tested, O == Optimized, V = Verified) +// For T, 1 == basic tests, 2 == node.js tests ported +// Verified means that the behavior or the API has been verified to be +// consistent with the node.js API. This does not mean that the behaviors. +// We can only determine verification status once the node.js tests are +// ported and verified to work correctly. +// match exactly, just that they are consistent. +// S I T V O +// [x][x][1][ ][ ] fs.accessSync(path[, mode]) +// [x][x][1][ ][ ] fs.appendFileSync(path, data[, options]) +// [x][x][1][ ][ ] fs.chmodSync(path, mode) +// [x][x][1][ ][ ] fs.chownSync(path, uid, gid) +// [x][x][1][ ][ ] fs.closeSync(fd) +// [x][x][1][ ][ ] fs.copyFileSync(src, dest[, mode]) +// [x][ ][ ][ ][ ] fs.cpSync(src, dest[, options]) +// [x][x][1][ ][ ] fs.existsSync(path) +// [x][x][1][ ][ ] fs.fchmodSync(fd, mode) +// [x][x][1][ ][ ] fs.fchownSync(fd, uid, gid) +// [x][x][1][ ][ ] fs.fdatasyncSync(fd) +// [x][x][1][ ][ ] fs.fstatSync(fd[, options]) +// [x][x][1][ ][ ] fs.fsyncSync(fd) +// [x][x][1][ ][ ] fs.ftruncateSync(fd[, len]) +// [x][x][1][ ][ ] fs.futimesSync(fd, atime, mtime) +// [ ][ ][ ][ ][ ] fs.globSync(pattern[, options]) +// [x][x][1][ ][ ] fs.lchmodSync(path, mode) +// [x][x][1][ ][ ] fs.lchownSync(path, uid, gid) +// [x][x][1][ ][ ] fs.lutimesSync(path, atime, mtime) +// [x][x][1][ ][ ] fs.linkSync(existingPath, newPath) +// [x][x][1][ ][ ] fs.lstatSync(path[, options]) +// [x][x][1][ ][ ] fs.mkdirSync(path[, options]) +// [x][x][1][ ][ ] fs.mkdtempSync(prefix[, options]) +// [x][ ][ ][ ][ ] fs.opendirSync(path[, options]) +// [x][x][1][ ][ ] fs.openSync(path[, flags[, mode]]) +// [x][x][1][ ][ ] fs.readdirSync(path[, options]) +// [x][x][1][ ][ ] fs.readFileSync(path[, options]) +// [x][x][1][ ][ ] fs.readlinkSync(path[, options]) +// [x][x][1][ ][ ] fs.readSync(fd, buffer, offset, length[, position]) +// [x][x][1][ ][ ] fs.readSync(fd, buffer[, options]) +// [x][x][1][ ][ ] fs.readvSync(fd, buffers[, position]) +// [x][x][1][ ][ ] fs.realpathSync(path[, options]) +// [x][x][1][ ][ ] fs.realpathSync.native(path[, options]) +// [x][x][1][ ][ ] fs.renameSync(oldPath, newPath) +// [x][x][1][ ][ ] fs.rmdirSync(path[, options]) +// [x][x][1][ ][ ] fs.rmSync(path[, options]) +// [x][x][1][ ][ ] fs.statSync(path[, options]) +// [x][x][1][ ][ ] fs.statfsSync(path[, options]) +// [x][x][1][ ][ ] fs.symlinkSync(target, path[, type]) +// [x][x][1][ ][ ] fs.truncateSync(path[, len]) +// [x][x][1][ ][ ] fs.unlinkSync(path) +// [x][x][1][ ][ ] fs.utimesSync(path, atime, mtime) +// [x][x][1][ ][ ] fs.writeFileSync(file, data[, options]) +// [x][x][1][ ][ ] fs.writeSync(fd, buffer, offset[, length[, position]]) +// [x][x][1][ ][ ] fs.writeSync(fd, buffer[, options]) +// [x][x][1][ ][ ] fs.writeSync(fd, string[, position[, encoding]]) +// [x][x][1][ ][ ] fs.writevSync(fd, buffers[, position]) diff --git a/src/node/internal/internal_fs_utils.ts b/src/node/internal/internal_fs_utils.ts new file mode 100644 index 00000000000..900044744a3 --- /dev/null +++ b/src/node/internal/internal_fs_utils.ts @@ -0,0 +1,572 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { + AbortError, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INCOMPATIBLE_OPTION_PAIR, + ERR_OUT_OF_RANGE, +} from 'node-internal:internal_errors'; +import { + validateAbortSignal, + validateObject, + validateBoolean, + validateInteger, + validateFunction, + validateInt32, + validateUint32, +} from 'node-internal:validators'; +import { isDate, isArrayBufferView } from 'node-internal:internal_types'; +import { + F_OK, + W_OK, + R_OK, + X_OK, + COPYFILE_EXCL, + COPYFILE_FICLONE, + O_RDONLY, + O_APPEND, + O_CREAT, + O_RDWR, + O_EXCL, + O_SYNC, + O_TRUNC, + O_WRONLY, + S_IFCHR, + S_IFDIR, + S_IFREG, + S_IFLNK, + S_IFMT, + S_IFSOCK, + S_IFIFO, + S_IFBLK, + UV_FS_COPYFILE_FICLONE_FORCE, +} from 'node-internal:internal_fs_constants'; + +import { strictEqual } from 'node-internal:internal_assert'; + +import { Buffer } from 'node-internal:internal_buffer'; +export type FilePath = string | URL | Buffer; + +import type { CopyOptions, CopySyncOptions, RmDirOptions } from 'node:fs'; + +import type { Stat as InternalStat } from 'cloudflare-internal:filesystem'; + +// A non-public symbol used to ensure that certain constructors cannot +// be called from user-code +export const kBadge = Symbol('kBadge'); + +export function normalizePath(path: FilePath, encoding: string = 'utf8'): URL { + // We treat all of our virtual file system paths as file URLs + // as a way of normalizing them. Because our file system is + // fully virtual, we don't need to worry about a number of the + // issues that real file system paths have and don't need to + // worry quite as much about strictly checking for null bytes + // in the path. The URL parsing will take care of those details. + // We do, however, need to be sensitive to the fact that there + // are two different URL impls in the runtime that are selected + // based on compat flags. A worker that is using the legacy URL + // implementation will end up seeing slightly different behavior + // here but that's not something we need to worry about for now. + if (typeof path === 'string') { + return new URL(path, 'file://'); + } else if (path instanceof URL) { + return path; + } else if (Buffer.isBuffer(path)) { + return new URL(path.toString(encoding), 'file://'); + } + throw new ERR_INVALID_ARG_TYPE('path', ['string', 'Buffer', 'URL'], path); +} + +// The access modes can be any of F_OK, R_OK, W_OK or X_OK. Some might not be +// available on specific systems. They can be used in combination as well +// (F_OK | R_OK | W_OK | X_OK). +export const kMinimumAccessMode = Math.min(F_OK, W_OK, R_OK, X_OK); +export const kMaximumAccessMode = F_OK | W_OK | R_OK | X_OK; + +export const kDefaultCopyMode = 0; +// The copy modes can be any of COPYFILE_EXCL, COPYFILE_FICLONE or +// COPYFILE_FICLONE_FORCE. They can be used in combination as well +// (COPYFILE_EXCL | COPYFILE_FICLONE | COPYFILE_FICLONE_FORCE). +export const kMinimumCopyMode = Math.min( + kDefaultCopyMode, + COPYFILE_EXCL, + COPYFILE_FICLONE, + UV_FS_COPYFILE_FICLONE_FORCE +); +export const kMaximumCopyMode = + COPYFILE_EXCL | COPYFILE_FICLONE | UV_FS_COPYFILE_FICLONE_FORCE; + +// Most platforms don't allow reads or writes >= 2 GiB. +// See https://github.com/libuv/libuv/pull/1501. +export const kIoMaxLength = 2 ** 31 - 1; + +// Use 64kb in case the file type is not a regular file and thus do not know the +// actual file size. Increasing the value further results in more frequent over +// allocation for small files and consumes CPU time and memory that should be +// used else wise. +// Use up to 512kb per read otherwise to partition reading big files to prevent +// blocking other threads in case the available threads are all in use. +export const kReadFileUnknownBufferLength = 64 * 1024; +export const kReadFileBufferLength = 512 * 1024; + +export const kWriteFileMaxChunkSize = 512 * 1024; + +export const kMaxUserId = 2 ** 32 - 1; + +export function assertEncoding(encoding: unknown): asserts encoding is string { + if (encoding && !Buffer.isEncoding(encoding as string)) { + const reason = 'is invalid encoding'; + throw new ERR_INVALID_ARG_VALUE('encoding', encoding, reason); + } +} + +export function getOptions( + options: string | Record | null, + defaultOptions: Record = {} +): Record { + if (options == null || typeof options === 'function') { + return defaultOptions; + } + + if (typeof options === 'string') { + defaultOptions = { ...defaultOptions }; + defaultOptions.encoding = options; + options = defaultOptions; + } else if (typeof options !== 'object') { + throw new ERR_INVALID_ARG_TYPE('options', ['string', 'Object'], options); + } + + if (options.encoding !== 'buffer') assertEncoding(options.encoding); + + if (options.signal !== undefined) { + validateAbortSignal(options.signal, 'options.signal'); + } + + return options; +} + +export function copyObject(source: Record): Record { + const target: Record = {}; + for (const key in source) target[key] = source[key] as T; + return target; +} + +export function getValidMode( + mode: number | undefined, + type: 'copyFile' | 'access' +): number { + let min = kMinimumAccessMode; + let max = kMaximumAccessMode; + let def = F_OK; + if (type === 'copyFile') { + min = kMinimumCopyMode; + max = kMaximumCopyMode; + def = mode || kDefaultCopyMode; + } else { + strictEqual(type, 'access'); + } + if (mode == null) { + return def; + } + validateInteger(mode, 'mode', min, max); + return mode; +} + +const defaultCpOptions: CopyOptions = { + dereference: false, + errorOnExist: false, + force: true, + preserveTimestamps: false, + recursive: false, + verbatimSymlinks: false, +}; + +export function validateCpOptions( + _options: unknown +): CopyOptions | CopySyncOptions { + if (_options === undefined) return { ...defaultCpOptions }; + validateObject(_options, 'options'); + const options: CopyOptions = { ...defaultCpOptions, ..._options }; + validateBoolean(options.dereference, 'options.dereference'); + validateBoolean(options.errorOnExist, 'options.errorOnExist'); + validateBoolean(options.force, 'options.force'); + validateBoolean(options.preserveTimestamps, 'options.preserveTimestamps'); + validateBoolean(options.recursive, 'options.recursive'); + validateBoolean(options.verbatimSymlinks, 'options.verbatimSymlinks'); + options.mode = getValidMode(options.mode, 'copyFile'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + if (options.dereference === true && options.verbatimSymlinks === true) { + throw new ERR_INCOMPATIBLE_OPTION_PAIR('dereference', 'verbatimSymlinks'); + } + if (options.filter !== undefined) { + // eslint-disable-next-line @typescript-eslint/unbound-method + validateFunction(options.filter, 'options.filter'); + } + return options; +} + +// converts Date or number to a fractional UNIX timestamp +export function toUnixTimestamp( + time: string | number | Date, + name: string = 'time' +): string | number | Date { + // @ts-expect-error TS2367 number to string comparison error. + if (typeof time === 'string' && +time == time) { + return +time; + } + if (Number.isFinite(time)) { + // @ts-expect-error TS2365 Number.isFinite does not assert correctly. + if (time < 0) { + return Date.now() / 1000; + } + return time; + } + if (isDate(time)) { + // Convert to 123.456 UNIX timestamp + return time.getTime() / 1000; + } + throw new ERR_INVALID_ARG_TYPE(name, ['Date', 'Time in seconds'], time); +} + +export function stringToFlags( + flags: number | null | undefined | string, + name: string = 'flags' +): number { + if (typeof flags === 'number') { + validateInt32(flags, name); + return flags; + } + + if (flags == null) { + return O_RDONLY; + } + + switch (flags) { + case 'r': + return O_RDONLY; + case 'rs': // Fall through. + case 'sr': + return O_RDONLY | O_SYNC; + case 'r+': + return O_RDWR; + case 'rs+': // Fall through. + case 'sr+': + return O_RDWR | O_SYNC; + + case 'w': + return O_TRUNC | O_CREAT | O_WRONLY; + case 'wx': // Fall through. + case 'xw': + return O_TRUNC | O_CREAT | O_WRONLY | O_EXCL; + + case 'w+': + return O_TRUNC | O_CREAT | O_RDWR; + case 'wx+': // Fall through. + case 'xw+': + return O_TRUNC | O_CREAT | O_RDWR | O_EXCL; + + case 'a': + return O_APPEND | O_CREAT | O_WRONLY; + case 'ax': // Fall through. + case 'xa': + return O_APPEND | O_CREAT | O_WRONLY | O_EXCL; + case 'as': // Fall through. + case 'sa': + return O_APPEND | O_CREAT | O_WRONLY | O_SYNC; + + case 'a+': + return O_APPEND | O_CREAT | O_RDWR; + case 'ax+': // Fall through. + case 'xa+': + return O_APPEND | O_CREAT | O_RDWR | O_EXCL; + case 'as+': // Fall through. + case 'sa+': + return O_APPEND | O_CREAT | O_RDWR | O_SYNC; + } + + throw new ERR_INVALID_ARG_VALUE('flags', flags); +} + +export function checkAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } +} + +const defaultRmdirOptions: RmDirOptions = { + retryDelay: 100, + maxRetries: 0, + recursive: false, +}; + +export function validateRmdirOptions( + _options: RmDirOptions | undefined, + defaults: RmDirOptions = defaultRmdirOptions +): RmDirOptions { + if (_options === undefined) return defaults; + validateObject(_options, 'options'); + + const options = { ...defaults, ..._options }; + + // eslint-disable-next-line @typescript-eslint/no-deprecated + validateBoolean(options.recursive, 'options.recursive'); + validateInt32(options.retryDelay, 'options.retryDelay', 0); + validateUint32(options.maxRetries, 'options.maxRetries'); + + return options; +} + +export function validatePosition( + position: unknown, + name: string +): asserts position is number | bigint | null { + if (typeof position === 'number') { + validateUint32(position, name); + } else if (typeof position !== 'bigint' && position !== null) { + throw new ERR_INVALID_ARG_TYPE( + name, + ['integer', 'bigint', 'null'], + position + ); + } +} + +export function validateOffsetLengthRead( + offset: number, + length: number, + bufferLength: number +): void { + if (offset < 0) { + throw new ERR_OUT_OF_RANGE('offset', '>= 0', offset); + } + if (length < 0) { + throw new ERR_OUT_OF_RANGE('length', '>= 0', length); + } + if (offset + length > bufferLength) { + throw new ERR_OUT_OF_RANGE('length', `<= ${bufferLength - offset}`, length); + } +} + +export function getValidatedFd(fd: number, propName: string = 'fd'): number { + if (Object.is(fd, -0)) { + return 0; + } + + validateInt32(fd, propName, 0); + + return fd; +} + +export function validateBufferArray( + buffers: unknown, + propName: string = 'buffer' +): ArrayBufferView[] { + if (!Array.isArray(buffers)) + throw new ERR_INVALID_ARG_TYPE(propName, 'ArrayBufferView[]', buffers); + + for (let i = 0; i < buffers.length; i++) { + if (!isArrayBufferView(buffers[i])) + throw new ERR_INVALID_ARG_TYPE(propName, 'ArrayBufferView[]', buffers); + } + + return buffers as ArrayBufferView[]; +} + +export function validateStringAfterArrayBufferView( + buffer: unknown, + name: string +): void { + if (typeof buffer !== 'string') { + throw new ERR_INVALID_ARG_TYPE( + name, + ['string', 'Buffer', 'TypedArray', 'DataView'], + buffer + ); + } +} + +export function validateOffsetLengthWrite( + offset: number, + length: number, + byteLength: number +): void { + if (offset > byteLength) { + throw new ERR_OUT_OF_RANGE('offset', `<= ${byteLength}`, offset); + } + + if (length > byteLength - offset) { + throw new ERR_OUT_OF_RANGE('length', `<= ${byteLength - offset}`, length); + } + + if (length < 0) { + throw new ERR_OUT_OF_RANGE('length', '>= 0', length); + } + + validateInt32(length, 'length', 0); +} + +// Our implementation of the Stats class differs a bit from Node.js' in that +// the one in Node.js uses the older function-style class. However, use of +// new fs.Stats(...) has been deprecated in Node.js for quite some time and +// users really aren't supposed to be trying to create their own Stats objects. +// Therefore, we intentionally use a class-style object here and make it an +// error to try to create your own Stats object using the constructor. +export class Stats { + public dev: number | bigint; + public ino: number | bigint; + public mode: number | bigint; + public nlink: number | bigint; + public uid: number | bigint; + public gid: number | bigint; + public rdev: number | bigint; + public size: number | bigint; + public blksize: number | bigint; + public blocks: number | bigint; + public atimeMs: number | bigint; + public mtimeMs: number | bigint; + public ctimeMs: number | bigint; + public birthtimeMs: number | bigint; + public atimeNs?: bigint; + public mtimeNs?: bigint; + public ctimeNs?: bigint; + public birthtimeNs?: bigint; + public atime: Date; + public mtime: Date; + public ctime: Date; + public birthtime: Date; + + public constructor( + badge: symbol, + stat: InternalStat, + options: { bigint: boolean } + ) { + // The kBadge symbol is never exported for users. We use it as an internal + // marker to ensure that only internal code can create a Stats object using + // the constructor. + if (badge !== kBadge) { + throw new TypeError('Illegal constructor'); + } + + // All nodes are always readable + this.mode = 0o444; + if (stat.writable) { + this.mode |= 0o222; // writable + } + + if (stat.device) { + this.mode |= S_IFCHR; + } else { + switch (stat.type) { + case 'file': + this.mode |= S_IFREG; + break; + case 'directory': + this.mode |= S_IFDIR; + break; + case 'symlink': + this.mode |= S_IFLNK; + break; + } + } + + if (options.bigint) { + this.dev = BigInt(stat.device); + this.size = BigInt(stat.size); + + this.atimeNs = 0n; + this.mtimeNs = stat.lastModified; + this.ctimeNs = stat.lastModified; + this.birthtimeNs = stat.created; + this.atimeMs = this.atimeNs / 1_000_000n; + this.mtimeMs = this.mtimeNs / 1_000_000n; + this.ctimeMs = this.ctimeNs / 1_000_000n; + this.birthtimeMs = this.birthtimeNs / 1_000_000n; + this.atime = new Date(Number(this.atimeMs)); + this.mtime = new Date(Number(this.mtimeMs)); + this.ctime = new Date(Number(this.ctimeMs)); + this.birthtime = new Date(Number(this.birthtimeMs)); + + // We have no meaningful definition of these values. + this.ino = 0n; + this.nlink = 1n; + this.uid = 0n; + this.gid = 0n; + this.rdev = 0n; + this.blksize = 0n; + this.blocks = 0n; + } else { + this.dev = Number(stat.device); + this.size = stat.size; + + this.atimeMs = 0; + this.mtimeMs = Number(stat.lastModified) / 1_000_000; + this.ctimeMs = Number(stat.lastModified) / 1_000_000; + this.birthtimeMs = Number(stat.created) / 1_000_000; + this.atime = new Date(this.atimeMs); + this.mtime = new Date(this.mtimeMs); + this.ctime = new Date(this.ctimeMs); + this.birthtime = new Date(this.birthtimeMs); + + // We have no meaningful definition of these values. + this.ino = 0; + this.nlink = 1; + this.uid = 0; + this.gid = 0; + this.rdev = 0; + this.blksize = 0; + this.blocks = 0; + } + } + + public isBlockDevice(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFBLK; + } + + public isCharacterDevice(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFCHR; + } + + public isDirectory(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFDIR; + } + + public isFIFO(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFIFO; + } + + public isFile(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFREG; + } + + public isSocket(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFSOCK; + } + + public isSymbolicLink(): boolean { + return (Number(this.mode) & S_IFMT) === S_IFLNK; + } +} diff --git a/src/node/internal/internal_inspect.ts b/src/node/internal/internal_inspect.ts index 7be5382b14c..8be156c169c 100644 --- a/src/node/internal/internal_inspect.ts +++ b/src/node/internal/internal_inspect.ts @@ -59,7 +59,11 @@ import { isBigIntObject, } from 'node-internal:internal_types'; // import { ALL_PROPERTIES, ONLY_ENUMERABLE, getOwnNonIndexProperties } from "node-internal:internal_utils"; -import { validateObject, validateString } from 'node-internal:validators'; +import { + validateObject, + validateString, + kValidateObjectAllowArray, +} from 'node-internal:validators'; // Simplified assertions to avoid `Assertions require every name in the call target to be // declared with an explicit type` TypeScript error @@ -2710,7 +2714,7 @@ export function formatWithOptions( inspectOptions: InspectOptions, ...args: unknown[] ): string { - validateObject(inspectOptions, 'inspectOptions', { allowArray: true }); + validateObject(inspectOptions, 'inspectOptions', kValidateObjectAllowArray); return formatWithOptionsInternal(inspectOptions, args); } diff --git a/src/node/internal/internal_path.ts b/src/node/internal/internal_path.ts index c5efa96d0fa..436aa564414 100644 --- a/src/node/internal/internal_path.ts +++ b/src/node/internal/internal_path.ts @@ -141,7 +141,7 @@ type PathObject = { }; function _format(sep: string, pathObject: PathObject): string { - validateObject(pathObject, 'pathObject', {}); + validateObject(pathObject, 'pathObject'); const dir = pathObject.dir || pathObject.root; const base = pathObject.base || diff --git a/src/node/internal/process.ts b/src/node/internal/process.ts index 10fd8210968..641c01e46aa 100644 --- a/src/node/internal/process.ts +++ b/src/node/internal/process.ts @@ -137,7 +137,7 @@ export const env = new Proxy(getInitialEnv(), { prop: PropertyKey, descriptor: PropertyDescriptor ) { - validateObject(descriptor, 'descriptor', {}); + validateObject(descriptor, 'descriptor'); if (Reflect.has(descriptor, 'get') || Reflect.has(descriptor, 'set')) { throw new ERR_INVALID_ARG_VALUE( 'descriptor', diff --git a/src/node/internal/streams_readable.d.ts b/src/node/internal/streams_readable.d.ts new file mode 100644 index 00000000000..120a9900ed6 --- /dev/null +++ b/src/node/internal/streams_readable.d.ts @@ -0,0 +1,3 @@ +import { Readable } from 'node:stream'; + +export { Readable }; diff --git a/src/node/internal/validators.ts b/src/node/internal/validators.ts index a331532a4f2..cf294c090e1 100644 --- a/src/node/internal/validators.ts +++ b/src/node/internal/validators.ts @@ -76,28 +76,41 @@ export function validateInteger( } } -export interface ValidateObjectOptions { - allowArray?: boolean; - allowFunction?: boolean; - nullable?: boolean; -} - export function validateObject( value: unknown, name: string, - options?: ValidateObjectOptions + options: number = kValidateObjectNone ): asserts value is Record { - const useDefaultOptions = options == null; - const allowArray = useDefaultOptions ? false : options.allowArray; - const allowFunction = useDefaultOptions ? false : options.allowFunction; - const nullable = useDefaultOptions ? false : options.nullable; - if ( - (!nullable && value === null) || - (!allowArray && Array.isArray(value)) || - (typeof value !== 'object' && - (!allowFunction || typeof value !== 'function')) - ) { - throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + if (options === kValidateObjectNone) { + if (value === null || Array.isArray(value)) { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + } + + if (typeof value !== 'object') { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + } + } else { + const throwOnNullable = (kValidateObjectAllowNullable & options) === 0; + + if (throwOnNullable && value === null) { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + } + + const throwOnArray = (kValidateObjectAllowArray & options) === 0; + + if (throwOnArray && Array.isArray(value)) { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + } + + const throwOnFunction = (kValidateObjectAllowFunction & options) === 0; + const typeofValue = typeof value; + + if ( + typeofValue !== 'object' && + (throwOnFunction || typeofValue !== 'function') + ) { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value); + } } } @@ -333,6 +346,39 @@ export function validatePort( return +port | 0; } +const octalReg = /^[0-7]+$/; +const modeDesc = 'must be a 32-bit unsigned integer or an octal string'; + +export function parseFileMode( + value: unknown, + name: string, + def?: number +): number { + if (def != null) { + value ??= def; + } + if (typeof value === 'string') { + if (!octalReg.test(value)) { + throw new ERR_INVALID_ARG_VALUE(name, value, modeDesc); + } + value = Number.parseInt(value, 8); + } + + validateUint32(value, name); + return value; +} + +export const kValidateObjectNone = 0; +export const kValidateObjectAllowNullable = 1 << 0; +export const kValidateObjectAllowArray = 1 << 1; +export const kValidateObjectAllowFunction = 1 << 2; +export const kValidateObjectAllowObjects = + kValidateObjectAllowArray | kValidateObjectAllowFunction; +export const kValidateObjectAllowObjectsAndNull = + kValidateObjectAllowNullable | + kValidateObjectAllowArray | + kValidateObjectAllowFunction; + export default { isInt32, isUint32, @@ -353,4 +399,13 @@ export default { // Zlib specific checkFiniteNumber, checkRangesOrGetDefault, + + // Filesystem specific + parseFileMode, + kValidateObjectNone, + kValidateObjectAllowNullable, + kValidateObjectAllowArray, + kValidateObjectAllowFunction, + kValidateObjectAllowObjects, + kValidateObjectAllowObjectsAndNull, }; diff --git a/src/node/tsconfig.json b/src/node/tsconfig.json index 414e82cb2ab..f8df68e7467 100644 --- a/src/node/tsconfig.json +++ b/src/node/tsconfig.json @@ -33,6 +33,7 @@ "node-internal:*": ["./internal/*"], "cloudflare-internal:sockets": ["./internal/sockets.d.ts"], "cloudflare-internal:workers": ["./internal/workers.d.ts"], + "cloudflare-internal:filesystem": ["./internal/filesystem.d.ts"], "workerd:compatibility-flags": ["./internal/compatibility-flags.d.ts"] } }, diff --git a/src/node/util.ts b/src/node/util.ts index 40f4ca86594..c64cb0d4337 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -13,6 +13,7 @@ import { validateFunction, validateAbortSignal, validateObject, + kValidateObjectAllowObjects, } from 'node-internal:validators'; import { debuglog } from 'node-internal:debuglog'; @@ -185,10 +186,7 @@ export async function aborted(signal: AbortSignal, resource: object) { // this additional option. Unfortunately Node.js does not make this argument optional. // We'll just ignore it. validateAbortSignal(signal, 'signal'); - validateObject(resource, 'resource', { - allowArray: true, - allowFunction: true, - }); + validateObject(resource, 'resource', kValidateObjectAllowObjects); if (signal.aborted) return Promise.resolve(); const { promise, resolve } = Promise.withResolvers(); const opts = { __proto__: null, once: true }; diff --git a/src/workerd/api/filesystem.c++ b/src/workerd/api/filesystem.c++ index fb2b6e60a79..2a9480608d3 100644 --- a/src/workerd/api/filesystem.c++ +++ b/src/workerd/api/filesystem.c++ @@ -1,11 +1,816 @@ #include "filesystem.h" #include "blob.h" +#include "url-standard.h" +#include "url.h" #include namespace workerd::api { +// ======================================================================================= +// Implementation of cloudflare-internal:filesystem in support of node:fs + +namespace { +static constexpr uint32_t kMax = kj::maxValue; +constexpr kj::StringPtr nameForFsType(FsType type) { + switch (type) { + case FsType::FILE: + return "file"_kj; + case FsType::DIRECTORY: + return "directory"_kj; + case FsType::SYMLINK: + return "symlink"_kj; + } + KJ_UNREACHABLE; +} + +// A file path is passed to the C++ layer as a URL object. However, we have two different +// implementations of URL in the system. This class wraps and abstracts over both of them. +struct NormalizedFilePath { + kj::OneOf, jsg::Url> url; + + static kj::OneOf, jsg::Url> normalize(FileSystemModule::FilePath path) { + KJ_SWITCH_ONEOF(path) { + KJ_CASE_ONEOF(legacy, jsg::Ref) { + return JSG_REQUIRE_NONNULL( + jsg::Url::tryParse(legacy->getHref(), "file:///"_kj), Error, "Invalid URL"_kj); + } + KJ_CASE_ONEOF(standard, jsg::Ref) { + return kj::mv(standard); + } + } + KJ_UNREACHABLE; + } + + NormalizedFilePath(FileSystemModule::FilePath path): url(normalize(kj::mv(path))) { + validate(); + } + + void validate() { + KJ_SWITCH_ONEOF(url) { + KJ_CASE_ONEOF(standard, jsg::Ref) { + JSG_REQUIRE(standard->getProtocol() == "file:"_kj, Error, "File path must be a file: URL"); + JSG_REQUIRE(standard->getHost().size() == 0, Error, "File path must not have a host"); + } + KJ_CASE_ONEOF(legacy, jsg::Url) { + JSG_REQUIRE(legacy.getProtocol() == "file:"_kj, TypeError, "File path must be a file: URL"); + JSG_REQUIRE(legacy.getHost().size() == 0, Error, "File path must not have a host"); + } + } + } + + operator const jsg::Url&() const { + KJ_SWITCH_ONEOF(url) { + KJ_CASE_ONEOF(legacy, jsg::Url) { + return legacy; + } + KJ_CASE_ONEOF(standard, jsg::Ref) { + return *standard; + } + } + KJ_UNREACHABLE; + } + + operator const kj::Path() const { + const jsg::Url& url = *this; + auto path = kj::str(url.getPathname().slice(1)); + kj::Path root{}; + return root.eval(path); + } +}; +} // namespace + +Stat::Stat(const workerd::Stat& stat) + : type(nameForFsType(stat.type)), + size(stat.size), + lastModified((stat.lastModified - kj::UNIX_EPOCH) / kj::NANOSECONDS), + created((stat.created - kj::UNIX_EPOCH) / kj::NANOSECONDS), + writable(stat.writable), + device(stat.device) {} + +kj::Maybe FileSystemModule::stat( + jsg::Lock& js, kj::OneOf pathOrFd, StatOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + KJ_SWITCH_ONEOF(pathOrFd) { + KJ_CASE_ONEOF(path, FilePath) { + NormalizedFilePath normalizedPath(kj::mv(path)); + KJ_IF_SOME(node, + vfs.resolve( + js, normalizedPath, {.followLinks = options.followSymlinks.orDefault(true)})) { + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + return Stat(file->stat(js)); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + return Stat(dir->stat(js)); + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If a symbolic link is returned here then the options.followSymLinks + // must have been set to false. + return Stat(link->stat(js)); + } + } + KJ_UNREACHABLE; + } + } + KJ_CASE_ONEOF(fd, int) { + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + KJ_SWITCH_ONEOF(opened.node) { + KJ_CASE_ONEOF(file, kj::Rc) { + return Stat(file->stat(js)); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + return Stat(dir->stat(js)); + } + KJ_CASE_ONEOF(link, kj::Rc) { + return Stat(link->stat(js)); + } + } + KJ_UNREACHABLE; + } + } + return kj::none; +} + +void FileSystemModule::setLastModified( + jsg::Lock& js, kj::OneOf pathOrFd, kj::Date lastModified, StatOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + KJ_SWITCH_ONEOF(pathOrFd) { + KJ_CASE_ONEOF(path, FilePath) { + NormalizedFilePath normalizedPath(kj::mv(path)); + KJ_IF_SOME(node, + vfs.resolve( + js, normalizedPath, {.followLinks = options.followSymlinks.orDefault(true)})) { + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + file->setLastModified(js, lastModified); + return; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + // Do nothing + return; + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If we got here, then followSymLinks was set to false. We cannot + // change the last modified time of a symbolic link in our vfs so + // we do nothing. + return; + } + } + } + } + KJ_CASE_ONEOF(fd, int) { + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + KJ_SWITCH_ONEOF(opened.node) { + KJ_CASE_ONEOF(file, kj::Rc) { + file->setLastModified(js, lastModified); + return; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + // Do nothing + return; + } + KJ_CASE_ONEOF(link, kj::Rc) { + // Do nothing + return; + } + } + } + } + KJ_UNREACHABLE; +} + +void FileSystemModule::truncate(jsg::Lock& js, kj::OneOf pathOrFd, uint32_t size) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + KJ_SWITCH_ONEOF(pathOrFd) { + KJ_CASE_ONEOF(path, FilePath) { + NormalizedFilePath normalizedPath(kj::mv(path)); + KJ_IF_SOME(node, vfs.resolve(js, normalizedPath)) { + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + file->resize(js, size); + return; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + return; + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If we got here, then followSymLinks was set to false. We cannot + // truncate a symbolic link. + JSG_FAIL_REQUIRE(Error, "Invalid operation on a symlink"); + } + } + } + } + KJ_CASE_ONEOF(fd, int) { + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + KJ_SWITCH_ONEOF(opened.node) { + KJ_CASE_ONEOF(file, kj::Rc) { + file->resize(js, size); + return; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + return; + } + KJ_CASE_ONEOF(link, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a symlink"); + } + } + } + } + KJ_UNREACHABLE; +} + +kj::String FileSystemModule::readLink(jsg::Lock& js, FilePath path, ReadLinkOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + auto node = JSG_REQUIRE_NONNULL( + vfs.resolve(js, normalizedPath, {.followLinks = false}), Error, "file not found"); + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + JSG_REQUIRE(!options.failIfNotSymlink, Error, "invalid argument"); + kj::Path path = normalizedPath; + return path.toString(true); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_REQUIRE(!options.failIfNotSymlink, Error, "invalid argument"); + kj::Path path = normalizedPath; + return path.toString(true); + } + KJ_CASE_ONEOF(link, kj::Rc) { + return link->getTargetPath().toString(true); + } + } + KJ_UNREACHABLE; +} + +void FileSystemModule::link(jsg::Lock& js, FilePath from, FilePath to, LinkOptions options) { + // The from argument is where we are creating the link, while the to is the target. + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedFrom(kj::mv(from)); + NormalizedFilePath normalizedTo(kj::mv(to)); + + // First, let's make sure the destination (from) does not already exist. + const jsg::Url& fromUrl = normalizedFrom; + const jsg::Url& toUrl = normalizedTo; + + JSG_REQUIRE(vfs.resolve(js, fromUrl) == kj::none, Error, "File already exists"_kj); + // Now, let's split the fromUrl into a base directory URL and a file name so + // that we can make sure the destination directory exists. + jsg::Url::Relative fromRelative = fromUrl.getRelative(); + JSG_REQUIRE(fromRelative.name.size() > 0, Error, "Invalid filename"_kj); + + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, fromRelative.base), Error, "Directory does not exist"_kj); + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + + // Dir is where the new link will go. fromRelative.name is the name of the new link + // in this directory. + + // If we are creating a symbolic link, we do not need to check if the target exists. + if (options.symbolic) { + dir->add(js, fromRelative.name, vfs.newSymbolicLink(js, toUrl)); + return; + } + + // If we are creating a hard link, however, the target must exist. + auto target = JSG_REQUIRE_NONNULL( + vfs.resolve(js, toUrl, {.followLinks = false}), Error, "file not found"_kj); + KJ_SWITCH_ONEOF(target) { + KJ_CASE_ONEOF(file, kj::Rc) { + dir->add(js, fromRelative.name, file.addRef()); + } + KJ_CASE_ONEOF(tdir, kj::Rc) { + dir->add(js, fromRelative.name, tdir.addRef()); + } + KJ_CASE_ONEOF(link, kj::Rc) { + dir->add(js, fromRelative.name, link.addRef()); + } + } +} + +void FileSystemModule::unlink(jsg::Lock& js, FilePath path) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + const jsg::Url& url = normalizedPath; + auto relative = url.getRelative(); + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + // The unlink method cannot be used to remove directories. + + kj::Path fpath(relative.name); + auto stat = JSG_REQUIRE_NONNULL(dir->stat(js, fpath), Error, "file not found"); + JSG_REQUIRE(stat.type != FsType::DIRECTORY, Error, "Cannot unlink a directory"); + + dir->remove(js, fpath); +} + +int FileSystemModule::open(jsg::Lock& js, FilePath path, OpenOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + auto& opened = vfs.openFd(js, normalizedPath, + workerd::VirtualFileSystem::OpenOptions{ + .read = options.read, + .write = options.write, + .append = options.append, + .exclusive = options.exclusive, + .followLinks = options.followSymlinks, + }); + return opened.fd; +} + +void FileSystemModule::close(jsg::Lock& js, int fd) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + vfs.closeFd(js, fd); +} + +uint32_t FileSystemModule::write( + jsg::Lock& js, int fd, kj::Array data, WriteOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + + static const auto getPosition = [](jsg::Lock& js, auto& opened, auto& file, + const WriteOptions& options) -> uint32_t { + if (opened.append) { + // If the file descriptor is opened in append mode, we ignore the position + // option and always append to the end of the file. + auto stat = file->stat(js); + return stat.size; + } + auto pos = options.position.orDefault(opened.position); + JSG_REQUIRE(pos <= kMax, Error, "Position out of range"); + return static_cast(pos); + }; + + KJ_SWITCH_ONEOF(opened.node) { + KJ_CASE_ONEOF(file, kj::Rc) { + auto pos = getPosition(js, opened, file, options); + uint32_t total = 0; + for (auto& buffer: data) { + auto written = file->write(js, pos, buffer); + pos += written; + total += written; + } + // We only update the position if the options.position is not set and + // the file descriptor is not opened in append mode. + if (options.position == kj::none && !opened.append) { + opened.position += total; + } + return total; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If we get here, then followSymLinks was set to false when open was called. + // We can't write to a symbolic link. + JSG_FAIL_REQUIRE(Error, "Invalid operation on a symlink"); + } + } + KJ_UNREACHABLE; +} + +uint32_t FileSystemModule::read( + jsg::Lock& js, int fd, kj::Array data, WriteOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + JSG_REQUIRE(opened.read, Error, "File descriptor not opened for reading"_kj); + + KJ_SWITCH_ONEOF(opened.node) { + KJ_CASE_ONEOF(file, kj::Rc) { + auto pos = options.position.orDefault(opened.position); + JSG_REQUIRE(pos <= kMax, Error, "Position out of range"); + uint32_t total = 0; + for (auto& buffer: data) { + auto read = file->read(js, pos, buffer); + // if read is less than the size of the buffer, we are at EOF. + pos += read; + total += read; + if (read < buffer.size()) break; + } + // We only update the position if the options.position is not set. + if (options.position == kj::none) { + opened.position += total; + } + return total; + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If we get here, then followSymLinks was set to false when open was called. + // We can't read from a symbolic link. + JSG_FAIL_REQUIRE(Error, "Invalid operation on a symlink"); + } + } + KJ_UNREACHABLE; +} + +jsg::BufferSource FileSystemModule::readAll(jsg::Lock& js, kj::OneOf pathOrFd) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + KJ_SWITCH_ONEOF(pathOrFd) { + KJ_CASE_ONEOF(path, FilePath) { + NormalizedFilePath normalized(kj::mv(path)); + KJ_IF_SOME(node, vfs.resolve(js, normalized)) { + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + return file->readAllBytes(js); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + } + KJ_CASE_ONEOF(link, kj::Rc) { + // We shouldn't be able to get here since we are following symlinks. + KJ_UNREACHABLE; + } + } + } + } + KJ_CASE_ONEOF(fd, int) { + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + JSG_REQUIRE(opened.read, Error, "File descriptor not opened for reading"_kj); + + // Make sure we're reading from a file. + auto& file = JSG_REQUIRE_NONNULL( + opened.node.tryGet>(), Error, "Invalid operation"_kj); + + // Move the opened.position to the end of the file. + KJ_DEFER({ + auto stat = file->stat(js); + opened.position = stat.size; + }); + + return file->readAllBytes(js); + } + } + KJ_UNREACHABLE; +} + +uint32_t FileSystemModule::writeAll(jsg::Lock& js, + kj::OneOf pathOrFd, + jsg::BufferSource data, + WriteAllOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + + JSG_REQUIRE(data.size() <= kMax, Error, "Data size out of range"_kj); + + KJ_SWITCH_ONEOF(pathOrFd) { + KJ_CASE_ONEOF(path, FilePath) { + NormalizedFilePath normalized(kj::mv(path)); + KJ_IF_SOME(node, vfs.resolve(js, normalized)) { + // If the exclusive option is set, the file must not already exist. + JSG_REQUIRE(!options.exclusive, Error, "file already exists"); + // The file already exists, we can write to it. + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + // First let's check that the file is writable. + auto stat = file->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + + // If the append option is set, we will write to the end of the file + // instead of overwriting it. + if (options.append) { + return file->write(js, stat.size, data); + } + + // Otherwise, we overwrite the entire file. + return file->writeAll(js, data); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "Invalid operation on a directory"); + } + KJ_CASE_ONEOF(link, kj::Rc) { + // If we get here, then followSymLinks was set to false when open was called. + // We can't write to a symbolic link. + JSG_FAIL_REQUIRE(Error, "Invalid operation on a symlink"); + } + } + KJ_UNREACHABLE; + } + // The file does not exist. We first need to create it, then write to it. + // Let's make sure the parent directory exists. + const jsg::Url& url = normalized; + jsg::Url::Relative relative = url.getRelative(); + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + // Let's make sure the parent is a directory. + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + auto stat = dir->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + auto file = workerd::File::newWritable(js, static_cast(data.size())); + auto written = file->writeAll(js, data); + dir->add(js, relative.name, kj::mv(file)); + return written; + } + KJ_CASE_ONEOF(fd, int) { + auto& opened = JSG_REQUIRE_NONNULL(vfs.tryGetFd(js, fd), Error, "Bad file descriptor"_kj); + // Otherwise, we'll overwrite the file... + JSG_REQUIRE(opened.write, Error, "File descriptor not opened for writing"_kj); + + // Make sure we're writing to a file. + auto& file = JSG_REQUIRE_NONNULL( + opened.node.tryGet>(), Error, "Invalid operation"_kj); + + auto stat = file->stat(js); + // Make sure the file is writable. + JSG_REQUIRE(stat.writable, Error, "access is denied"); + + KJ_DEFER({ + // In either case, we need to update the position of the file descriptor. + stat = file->stat(js); + opened.position = stat.size; + }); + + // If the file descriptor was opened in append mode, or if the append option + // is set, then we'll use write instead to append to the end of the file. + if (opened.append || options.append) { + return write(js, fd, kj::arr(kj::mv(data)), + { + .position = stat.size, + }); + } + + // Otherwise, we overwrite the entire file. + return file->writeAll(js, data); + } + } + + KJ_UNREACHABLE; +} + +void FileSystemModule::renameOrCopy( + jsg::Lock& js, FilePath src, FilePath dest, RenameOrCopyOptions options) { + // The source must exist, the destination must not. + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedSrc(kj::mv(src)); + NormalizedFilePath normalizedDest(kj::mv(dest)); + + auto srcNode = JSG_REQUIRE_NONNULL(vfs.resolve(js, normalizedSrc), Error, "File not found"_kj); + + const jsg::Url& destUrl = normalizedDest; + const jsg::Url& srcUrl = normalizedSrc; + JSG_REQUIRE(vfs.resolve(js, destUrl) == kj::none, Error, "File already exists"_kj); + auto relative = destUrl.getRelative(); + // The destination parent must exist. + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + // Let's make sure the parent is a directory. + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + + kj::Maybe> srcParent; + if (!options.copy) { + // If we are not copying, let's make sure that the source directory is writable + // before we actually try moving it. + auto relative = srcUrl.getRelative(); + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + auto& srcDir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + auto stat = srcDir->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + srcParent = srcDir.addRef(); + } + + // The next part is easy. We either clone or add ref the original node and add it to the + // destination directory. + KJ_SWITCH_ONEOF(srcNode) { + KJ_CASE_ONEOF(file, kj::Rc) { + auto newFile = options.copy ? file->clone(js) : file.addRef(); + dir->add(js, relative.name, kj::mv(newFile)); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + JSG_REQUIRE(!options.copy, Error, "Cannot copy a directory"); + dir->add(js, relative.name, dir.addRef()); + } + KJ_CASE_ONEOF(link, kj::Rc) { + dir->add(js, relative.name, link.addRef()); + } + } + + KJ_IF_SOME(dir, srcParent) { + auto relative = srcUrl.getRelative(); + dir->remove(js, kj::Path({relative.name}), {.recursive = true}); + } +} + +jsg::Optional FileSystemModule::mkdir( + jsg::Lock& js, FilePath path, MkdirOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + const jsg::Url& url = normalizedPath; + + // The path must not already exist. However, if the path is a directory, we + // will just return rather than throwing an error. + KJ_IF_SOME(node, vfs.resolve(js, url, {.followLinks = false})) { + KJ_SWITCH_ONEOF(node) { + KJ_CASE_ONEOF(file, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "File already exists"_kj); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + // The directory already exists. We will just return. + return kj::none; + } + KJ_CASE_ONEOF(link, kj::Rc) { + JSG_FAIL_REQUIRE(Error, "File already exists"_kj); + } + } + }; + + if (options.recursive) { + JSG_REQUIRE(!options.tmp, Error, "Cannot recursively create a temporary directory"); + // If the recursive option is set, we will create all the directories in the + // path that do not exist, returning the path to the first one that was created. + const kj::Path kjPath = normalizedPath; + const auto parentPath = kjPath.parent(); + const auto name = kjPath.basename(); + kj::Maybe createdPath; + + // We'll start from the root and work our way down. + auto current = vfs.getRoot(js); + kj::Path currentPath{}; + for (const auto& part: parentPath) { + currentPath = currentPath.append(part); + // Try opening the next part of the path. Note that we are not using the + // createAs option here because we don't necessarily want to implicitly + // create the directory if it doesn't exist. We want to create it explicitly + // so that we can return the path to the first directory that was created + // and tryOpen does not us if the directory already existed or was created. + KJ_IF_SOME(node, current->tryOpen(js, kj::Path({part}))) { + // Let's make sure the node is a directory. + auto& dir = JSG_REQUIRE_NONNULL( + node.tryGet>(), Error, "Invalid argument"_kj); + // Now we can check the next part of the path. + current = kj::mv(dir); + continue; + } + + // The node does not exist, let's create it so long as the current + // directory is writable. + auto stat = current->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + auto dir = workerd::Directory::newWritable(); + current->add(js, part, dir.addRef()); + current = kj::mv(dir); + if (createdPath == kj::none) { + createdPath = currentPath.toString(true); + } + } + + // Now that we have th parent directory, let's try creating the new directory. + auto newDir = workerd::Directory::newWritable(); + current->add(js, name.toString(false), kj::mv(newDir)); + return kj::mv(createdPath); + } + + // If the recursive option is not set, we will create the directory only if + // the parent directory exists. If the parent directory does not exist, we + // will return an error. + auto relative = url.getRelative(); + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + // Let's make sure the parent is a directory. + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + auto stat = dir->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + auto newDir = workerd::Directory::newWritable(); + + if (options.tmp) { + JSG_REQUIRE(tmpFileCounter < kMax, Error, "Too many temporary directories"); + auto name = kj::str(relative.name, tmpFileCounter++); + dir->add(js, name, kj::mv(newDir)); + auto newUrl = KJ_REQUIRE_NONNULL(relative.base.resolve(name), "invalid URL"); + return kj::str(newUrl.getPathname()); + } + + dir->add(js, relative.name, kj::mv(newDir)); + return kj::none; +} + +void FileSystemModule::rm(jsg::Lock& js, FilePath path, RmOptions options) { + // TODO(node:fs): Implement the force option. + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + const jsg::Url& url = normalizedPath; + auto relative = url.getRelative(); + auto parent = + JSG_REQUIRE_NONNULL(vfs.resolve(js, relative.base), Error, "Directory does not exist"_kj); + // Let's make sure the parent is a directory. + auto& dir = JSG_REQUIRE_NONNULL( + parent.tryGet>(), Error, "Invalid argument"_kj); + auto stat = dir->stat(js); + JSG_REQUIRE(stat.writable, Error, "access is denied"); + + kj::Path name(relative.name); + + if (options.dironly) { + // If the dironly option is set, we will only remove the entry if it is a directory. + auto stat = JSG_REQUIRE_NONNULL(dir->stat(js, name), Error, "file not found"); + JSG_REQUIRE(stat.type == workerd::FsType::DIRECTORY, Error, "Not a directory"); + } + + dir->remove(js, name, {.recursive = options.recursive}); +} + +namespace { +static constexpr int UV_DIRENT_FILE = 1; +static constexpr int UV_DIRENT_DIR = 2; +static constexpr int UV_DIRENT_LINK = 3; +static constexpr int UV_DIRENT_CHAR = 6; +void readdirImpl(jsg::Lock& js, + const workerd::VirtualFileSystem& vfs, + kj::Rc& dir, + const kj::Path& path, + const FileSystemModule::ReadDirOptions& options, + kj::Vector& entries) { + for (auto& entry: *dir.get()) { + auto name = options.recursive ? path.append(entry.key).toString(false) : kj::str(entry.key); + KJ_SWITCH_ONEOF(entry.value) { + KJ_CASE_ONEOF(file, kj::Rc) { + auto stat = file->stat(js); + entries.add(FileSystemModule::DirEntHandle{ + .name = kj::mv(name), + .parentPath = path.toString(true), + .type = stat.device ? UV_DIRENT_CHAR : UV_DIRENT_FILE, + }); + } + KJ_CASE_ONEOF(dir, kj::Rc) { + entries.add(FileSystemModule::DirEntHandle{ + .name = kj::mv(name), + .parentPath = path.toString(true), + .type = UV_DIRENT_DIR, + }); + + if (options.recursive) { + readdirImpl(js, vfs, dir, path.append(entry.key), options, entries); + } + } + KJ_CASE_ONEOF(link, kj::Rc) { + entries.add(FileSystemModule::DirEntHandle{ + .name = kj::mv(name), + .parentPath = path.toString(true), + .type = UV_DIRENT_LINK, + }); + + if (options.recursive) { + workerd::SymbolicLinkRecursionGuardScope guard; + guard.checkSeen(link.get()); + KJ_IF_SOME(target, link->resolve(js)) { + KJ_SWITCH_ONEOF(target) { + KJ_CASE_ONEOF(file, kj::Rc) { + // Do nothing + } + KJ_CASE_ONEOF(dir, kj::Rc) { + readdirImpl(js, vfs, dir, path.append(entry.key), options, entries); + } + } + } + } + } + } + } +} +} // namespace + +kj::Array FileSystemModule::readdir( + jsg::Lock& js, FilePath path, ReadDirOptions options) { + auto& vfs = JSG_REQUIRE_NONNULL( + workerd::VirtualFileSystem::tryGetCurrent(js), Error, "No current virtual file system"_kj); + NormalizedFilePath normalizedPath(kj::mv(path)); + + auto node = JSG_REQUIRE_NONNULL( + vfs.resolve(js, normalizedPath, {.followLinks = false}), Error, "file not found"_kj); + auto& dir = + JSG_REQUIRE_NONNULL(node.tryGet>(), Error, "not a directory"_kj); + + kj::Vector entries; + readdirImpl(js, vfs, dir, normalizedPath, options, entries); + return entries.releaseAsArray(); +} + // ======================================================================================= // Implementation of the Web File System API diff --git a/src/workerd/api/filesystem.h b/src/workerd/api/filesystem.h index a0480cb8f6d..fb96cc84048 100644 --- a/src/workerd/api/filesystem.h +++ b/src/workerd/api/filesystem.h @@ -10,6 +10,161 @@ namespace workerd::api { class Blob; class File; +class URL; // Legacy URL class impl +namespace url { +class URL; +} // namespace url + +// Metadata about a file, directory, or link. +struct Stat { + kj::StringPtr type; // One of either "file", "directory", or "symlink" + uint32_t size; + uint64_t lastModified; + uint64_t created; + bool writable; + bool device; + JSG_STRUCT(type, size, lastModified, created, writable, device); + + Stat(const workerd::Stat& stat); +}; + +class FileSystemModule final: public jsg::Object { + public: + using FilePath = kj::OneOf, jsg::Ref>; + + struct StatOptions { + jsg::Optional followSymlinks; + JSG_STRUCT(followSymlinks); + }; + + kj::Maybe stat(jsg::Lock& js, kj::OneOf pathOrFd, StatOptions options); + void setLastModified( + jsg::Lock& js, kj::OneOf pathOrFd, kj::Date lastModified, StatOptions options); + void truncate(jsg::Lock& js, kj::OneOf pathOrFd, uint32_t size); + + struct ReadLinkOptions { + // The readLink method is used for both realpath and readlink. The readlink + // API will throw an error if the path is not a symlink. The realpath API + // will return the resolved path either way. + bool failIfNotSymlink = false; + JSG_STRUCT(failIfNotSymlink); + }; + kj::String readLink(jsg::Lock& js, FilePath path, ReadLinkOptions options); + + struct LinkOptions { + bool symbolic; + JSG_STRUCT(symbolic); + }; + + void link(jsg::Lock& js, FilePath from, FilePath to, LinkOptions options); + + void unlink(jsg::Lock& js, FilePath path); + + struct OpenOptions { + // File is opened to supprt reads. + bool read; + // File is opened to support writes. + bool write; + // File is opened in append mode. Ignored if write is false. + bool append; + // If the exclusive option is set, throw if the file already exists. + bool exclusive; + // If the followSymlinks option is set, follow symbolic links. + bool followSymlinks = true; + JSG_STRUCT(read, write, append, exclusive, followSymlinks); + }; + + int open(jsg::Lock& js, FilePath path, OpenOptions options); + void close(jsg::Lock& js, int fd); + + struct WriteOptions { + kj::Maybe position; + JSG_STRUCT(position); + }; + + uint32_t write(jsg::Lock& js, int fd, kj::Array data, WriteOptions options); + uint32_t read(jsg::Lock& js, int fd, kj::Array data, WriteOptions options); + + jsg::BufferSource readAll(jsg::Lock& js, kj::OneOf pathOrFd); + + struct WriteAllOptions { + bool exclusive; + bool append; + JSG_STRUCT(exclusive, append); + }; + + uint32_t writeAll(jsg::Lock& js, + kj::OneOf pathOrFd, + jsg::BufferSource data, + WriteAllOptions options); + + struct RenameOrCopyOptions { + bool copy; + JSG_STRUCT(copy); + }; + + void renameOrCopy(jsg::Lock& js, FilePath src, FilePath dest, RenameOrCopyOptions options); + + struct MkdirOptions { + bool recursive = false; + // When temp is true, the directory name will be augmented with an + // additional random string to make it unique and the directory name + // will be returned. + bool tmp = false; + JSG_STRUCT(recursive, tmp); + }; + jsg::Optional mkdir(jsg::Lock& js, FilePath path, MkdirOptions options); + + struct RmOptions { + bool recursive = false; + bool force = false; + bool dironly = false; + JSG_STRUCT(recursive, force, dironly); + }; + void rm(jsg::Lock& js, FilePath path, RmOptions options); + + struct DirEntHandle { + kj::String name; + kj::String parentPath; + int type; + JSG_STRUCT(name, parentPath, type); + }; + struct ReadDirOptions { + bool recursive = false; + JSG_STRUCT(recursive); + }; + kj::Array readdir(jsg::Lock& js, FilePath path, ReadDirOptions options); + + FileSystemModule() = default; + FileSystemModule(jsg::Lock&, const jsg::Url&) {} + + JSG_RESOURCE_TYPE(FileSystemModule) { + JSG_METHOD(stat); + JSG_METHOD(setLastModified); + JSG_METHOD(truncate); + JSG_METHOD(readLink); + JSG_METHOD(link); + JSG_METHOD(unlink); + JSG_METHOD(open); + JSG_METHOD(close); + JSG_METHOD(write); + JSG_METHOD(read); + JSG_METHOD(readAll); + JSG_METHOD(writeAll); + JSG_METHOD(renameOrCopy); + JSG_METHOD(mkdir); + JSG_METHOD(rm); + JSG_METHOD(readdir); + } + + private: + // Rather than relying on random generation of temp directory names we + // use a simple counter. Once the counter reaches the max value we will + // refuse to create any more temp directories. This is a bit of a hack + // to allow temp dirs to be created outside of an IoContext. + uint32_t tmpFileCounter = 0; +}; + // ====================================================================================== // An implementation of the WHATWG Web File System API (https://fs.spec.whatwg.org/) // All of the classes in this part of the impl are defined by the spec. @@ -370,4 +525,14 @@ class StorageManager final: public jsg::Object { workerd::api::FileSystemDirectoryHandle::KeyIterator::Next, \ workerd::api::FileSystemDirectoryHandle::ValueIterator::Next +#define EW_FILESYSTEM_ISOLATE_TYPES \ + workerd::api::FileSystemModule, workerd::api::Stat, workerd::api::FileSystemModule::StatOptions, \ + workerd::api::FileSystemModule::ReadLinkOptions, \ + workerd::api::FileSystemModule::LinkOptions, workerd::api::FileSystemModule::OpenOptions, \ + workerd::api::FileSystemModule::WriteOptions, \ + workerd::api::FileSystemModule::WriteAllOptions, \ + workerd::api::FileSystemModule::RenameOrCopyOptions, \ + workerd::api::FileSystemModule::MkdirOptions, workerd::api::FileSystemModule::RmOptions, \ + workerd::api::FileSystemModule::DirEntHandle, workerd::api::FileSystemModule::ReadDirOptions + } // namespace workerd::api diff --git a/src/workerd/api/modules.h b/src/workerd/api/modules.h index 2c918048fe9..fdd72a9d12d 100644 --- a/src/workerd/api/modules.h +++ b/src/workerd/api/modules.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -51,6 +52,8 @@ void registerModules(Registry& registry, auto featureFlags) { registerRpcModules(registry, featureFlags); registry.template addBuiltinModule( "cloudflare-internal:env", workerd::jsg::ModuleRegistry::Type::INTERNAL); + registry.template addBuiltinModule( + "cloudflare-internal:filesystem", workerd::jsg::ModuleRegistry::Type::INTERNAL); } template @@ -80,6 +83,7 @@ void registerBuiltinModules(jsg::modules::ModuleRegistry::Builder& builder, auto jsg::modules::ModuleBundle::BuiltinBuilder builtinsBuilder( jsg::modules::ModuleBundle::BuiltinBuilder::Type::BUILTIN); builtinsBuilder.addObject("cloudflare-internal:env"_url); + builtinsBuilder.addObject("cloudflare-internal:filesystem"_url); jsg::modules::ModuleBundle::getBuiltInBundleFromCapnp(builtinsBuilder, CLOUDFLARE_BUNDLE); builder.add(builtinsBuilder.finish()); } diff --git a/src/workerd/api/node/BUILD.bazel b/src/workerd/api/node/BUILD.bazel index 3a640844c4e..1c2dacfe247 100644 --- a/src/workerd/api/node/BUILD.bazel +++ b/src/workerd/api/node/BUILD.bazel @@ -412,3 +412,9 @@ wd_test( args = ["--experimental"], data = ["tests/bound-als-test.js"], ) + +wd_test( + src = "tests/fs-access-test.wd-test", + args = ["--experimental"], + data = ["tests/fs-access-test.js"], +) diff --git a/src/workerd/api/node/node.h b/src/workerd/api/node/node.h index ef8d724f8e6..56835167ea8 100644 --- a/src/workerd/api/node/node.h +++ b/src/workerd/api/node/node.h @@ -20,6 +20,8 @@ #include +#include + namespace workerd::api::node { // To be exposed only as an internal module for use by other built-ins. @@ -65,6 +67,8 @@ bool isNodeJsCompatEnabled(auto featureFlags) { return featureFlags.getNodeJsCompat() || featureFlags.getNodeJsCompatV2(); } +bool isExperimentalNodeJsCompatModule(kj::StringPtr name); + template void registerNodeJsCompatModules(Registry& registry, auto featureFlags) { #define V(T, N) \ @@ -83,9 +87,7 @@ void registerNodeJsCompatModules(Registry& registry, auto featureFlags) { registry.addBuiltinBundleFiltered(NODE_BUNDLE, [&](jsg::Module::Reader module) { // node:fs and node:http will be considered experimental until they are completed, // so unless the experimental flag is enabled, don't register them. - auto name = module.getName(); - if (name == "node:fs"_kj || name == "node:http"_kj || name == "node:_http_common"_kj || - name == "node:_http_outgoing") { + if (isExperimentalNodeJsCompatModule(module.getName())) { return featureFlags.getWorkerdExperimental(); } @@ -157,4 +159,4 @@ kj::Own getExternalNodeJsCompatModuleBundle(auto fea api::node::CompatibilityFlags, EW_NODE_BUFFER_ISOLATE_TYPES, EW_NODE_CRYPTO_ISOLATE_TYPES, \ EW_NODE_DIAGNOSTICCHANNEL_ISOLATE_TYPES, EW_NODE_ASYNCHOOKS_ISOLATE_TYPES, \ EW_NODE_UTIL_ISOLATE_TYPES, EW_NODE_ZLIB_ISOLATE_TYPES, EW_NODE_URL_ISOLATE_TYPES, \ - EW_NODE_MODULE_ISOLATE_TYPES, EW_NODE_DNS_ISOLATE_TYPES, EW_NODE_TIMERS_ISOLATE_TYPES\ + EW_NODE_MODULE_ISOLATE_TYPES, EW_NODE_DNS_ISOLATE_TYPES, EW_NODE_TIMERS_ISOLATE_TYPES diff --git a/src/workerd/api/node/tests/fs-access-test.js b/src/workerd/api/node/tests/fs-access-test.js new file mode 100644 index 00000000000..24589cb9e58 --- /dev/null +++ b/src/workerd/api/node/tests/fs-access-test.js @@ -0,0 +1,1605 @@ +import { + deepStrictEqual, + notDeepStrictEqual, + ifError, + ok, + strictEqual, + throws, +} from 'node:assert'; + +import { + accessSync, + existsSync, + statSync, + chmodSync, + chownSync, + statfsSync, + lchmodSync, + lchownSync, + linkSync, + lstatSync, + symlinkSync, + readlinkSync, + realpathSync, + unlinkSync, + openSync, + closeSync, + fstatSync, + ftruncateSync, + fsyncSync, + fdatasyncSync, + fchmodSync, + fchownSync, + futimesSync, + utimesSync, + lutimesSync, + writeSync, + writevSync, + readSync, + readvSync, + readFileSync, + writeFileSync, + appendFileSync, + copyFileSync, + renameSync, + mkdirSync, + mkdtempSync, + rmSync, + rmdirSync, + readdirSync, + access, + stat, + chmod, + chown, + constants, + statfs, + link, + symlink, + readlink, + realpath, + unlink, + close, + fstat, + ftruncate, + fsync, + fdatasync, + fchmod, + fchown, + futimes, + utimes, + lutimes, +} from 'node:fs'; + +const { F_OK, R_OK, W_OK, X_OK } = constants; + +export const accessSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => accessSync(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => accessSync('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + // Known accessible paths, default permissions (F_OK) + accessSync('/'); + accessSync('/bundle'); + accessSync('/bundle/worker'); + accessSync('/dev'); + accessSync('/dev/null'); + accessSync('/dev/zero'); + accessSync('/dev/full'); + accessSync('/dev/random'); + accessSync('/tmp'); + + accessSync(Buffer.from('/')); + accessSync(Buffer.from('/bundle')); + accessSync(Buffer.from('/bundle/worker')); + accessSync(Buffer.from('/dev')); + accessSync(Buffer.from('/dev/null')); + accessSync(Buffer.from('/dev/zero')); + accessSync(Buffer.from('/dev/full')); + accessSync(Buffer.from('/dev/random')); + accessSync(Buffer.from('/tmp')); + + accessSync(new URL('file:///')); + accessSync(new URL('file:///bundle')); + accessSync(new URL('file:///bundle/worker')); + accessSync(new URL('file:///dev')); + accessSync(new URL('file:///dev/null')); + accessSync(new URL('file:///dev/zero')); + accessSync(new URL('file:///dev/full')); + accessSync(new URL('file:///dev/random')); + accessSync(new URL('file:///tmp')); + + accessSync('/', F_OK); + accessSync('/bundle', F_OK); + accessSync('/bundle/worker', F_OK); + accessSync('/dev', F_OK); + accessSync('/dev/null', F_OK); + accessSync('/dev/zero', F_OK); + accessSync('/dev/full', F_OK); + accessSync('/dev/random', F_OK); + accessSync('/tmp', F_OK); + + accessSync(Buffer.from('/'), F_OK); + accessSync(Buffer.from('/bundle'), F_OK); + accessSync(Buffer.from('/bundle/worker'), F_OK); + accessSync(Buffer.from('/dev'), F_OK); + accessSync(Buffer.from('/dev/null'), F_OK); + accessSync(Buffer.from('/dev/zero'), F_OK); + accessSync(Buffer.from('/dev/full'), F_OK); + accessSync(Buffer.from('/dev/random'), F_OK); + accessSync(Buffer.from('/tmp'), F_OK); + + accessSync(new URL('file:///'), F_OK); + accessSync(new URL('file:///bundle'), F_OK); + accessSync(new URL('file:///bundle/worker'), F_OK); + accessSync(new URL('file:///dev'), F_OK); + accessSync(new URL('file:///dev/null'), F_OK); + accessSync(new URL('file:///dev/zero'), F_OK); + accessSync(new URL('file:///dev/full'), F_OK); + accessSync(new URL('file:///dev/random'), F_OK); + accessSync(new URL('file:///tmp'), F_OK); + + // All of the known paths are readable (R_OK) + accessSync('/', R_OK); + accessSync('/bundle', R_OK); + accessSync('/bundle/worker', R_OK); + accessSync('/dev', R_OK); + accessSync('/dev/null', R_OK); + accessSync('/dev/zero', R_OK); + accessSync('/dev/full', R_OK); + accessSync('/dev/random', R_OK); + accessSync('/tmp', R_OK); + + accessSync(Buffer.from('/'), R_OK); + accessSync(Buffer.from('/bundle'), R_OK); + accessSync(Buffer.from('/bundle/worker'), R_OK); + accessSync(Buffer.from('/dev'), R_OK); + accessSync(Buffer.from('/dev/null'), R_OK); + accessSync(Buffer.from('/dev/zero'), R_OK); + accessSync(Buffer.from('/dev/full'), R_OK); + accessSync(Buffer.from('/dev/random'), R_OK); + accessSync(Buffer.from('/tmp'), R_OK); + + accessSync(new URL('file:///'), R_OK); + accessSync(new URL('file:///bundle'), R_OK); + accessSync(new URL('file:///bundle/worker'), R_OK); + accessSync(new URL('file:///dev'), R_OK); + accessSync(new URL('file:///dev/null'), R_OK); + accessSync(new URL('file:///dev/zero'), R_OK); + accessSync(new URL('file:///dev/full'), R_OK); + accessSync(new URL('file:///dev/random'), R_OK); + accessSync(new URL('file:///tmp'), R_OK); + + // Only some of the known paths are writable (W_OK) + throws(() => accessSync('/', W_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/bundle', W_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/bundle/worker', W_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev', W_OK), { + code: 'ENOENT', + }); + accessSync('/dev/null', W_OK); + accessSync('/dev/zero', W_OK); + accessSync('/dev/full', W_OK); + accessSync('/dev/random', W_OK); + accessSync('/tmp', W_OK); + + // No known paths are executable (X_OK) + throws(() => accessSync('/', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/bundle', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/bundle/worker', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev/null', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev/zero', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev/full', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/dev/random', X_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/tmp', X_OK), { + code: 'ENOENT', + }); + + // Paths that don't exist have no permissions. + throws(() => accessSync('/does/not/exist'), { + code: 'ENOENT', + }); + throws(() => accessSync('/does/not/exist', F_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/does/not/exist', R_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/does/not/exist', W_OK), { + code: 'ENOENT', + }); + throws(() => accessSync('/does/not/exist', X_OK), { + code: 'ENOENT', + }); + }, +}; + +export const existsSyncTest = { + test() { + // Incorrect inputs types results in false returned + ok(!existsSync(123)); + + // Known accessible paths + ok(existsSync('/')); + ok(existsSync('/bundle')); + ok(existsSync('/bundle/worker')); + ok(existsSync('/dev')); + ok(existsSync('/dev/null')); + ok(existsSync('/dev/zero')); + ok(existsSync('/dev/full')); + ok(existsSync('/dev/random')); + ok(existsSync('/tmp')); + + ok(existsSync(Buffer.from('/'))); + ok(existsSync(Buffer.from('/bundle'))); + ok(existsSync(Buffer.from('/bundle/worker'))); + ok(existsSync(Buffer.from('/dev'))); + ok(existsSync(Buffer.from('/dev/null'))); + ok(existsSync(Buffer.from('/dev/zero'))); + ok(existsSync(Buffer.from('/dev/full'))); + ok(existsSync(Buffer.from('/dev/random'))); + ok(existsSync(Buffer.from('/tmp'))); + + ok(existsSync(new URL('file:///'))); + ok(existsSync(new URL('file:///bundle'))); + ok(existsSync(new URL('file:///bundle/worker'))); + ok(existsSync(new URL('file:///dev'))); + ok(existsSync(new URL('file:///dev/null'))); + ok(existsSync(new URL('file:///dev/zero'))); + ok(existsSync(new URL('file:///dev/full'))); + ok(existsSync(new URL('file:///dev/random'))); + ok(existsSync(new URL('file:///tmp'))); + + // Paths that don't exist + ok(!existsSync('/does/not/exist')); + }, +}; + +export const statSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => statSync(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => statSync('/', { bigint: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => statSync('/', { throwIfNoEntry: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + // Known accessible paths + const stat = statSync('/'); + ok(stat); + ok(stat.isDirectory()); + ok(!stat.isFile()); + ok(!stat.isBlockDevice()); + ok(!stat.isCharacterDevice()); + ok(!stat.isFIFO()); + ok(!stat.isSocket()); + ok(!stat.isSymbolicLink()); + + ok(statSync(Buffer.from('/'))); + ok(statSync(Buffer.from('/')).isDirectory()); + + const bundleStat = statSync('/bundle'); + ok(bundleStat); + ok(bundleStat.isDirectory()); + ok(!bundleStat.isFile()); + ok(!bundleStat.isBlockDevice()); + ok(!bundleStat.isCharacterDevice()); + ok(!bundleStat.isFIFO()); + ok(!bundleStat.isSocket()); + ok(!bundleStat.isSymbolicLink()); + + const workerStat = statSync('/bundle/worker'); + ok(workerStat); + ok(!workerStat.isDirectory()); + ok(workerStat.isFile()); + ok(!workerStat.isBlockDevice()); + ok(!workerStat.isCharacterDevice()); + ok(!workerStat.isFIFO()); + ok(!workerStat.isSocket()); + ok(!workerStat.isSymbolicLink()); + + const devStat = statSync('/dev/null'); + ok(devStat); + ok(!devStat.isDirectory()); + ok(!devStat.isFile()); + ok(!devStat.isBlockDevice()); + ok(devStat.isCharacterDevice()); + ok(!devStat.isFIFO()); + ok(!devStat.isSocket()); + ok(!devStat.isSymbolicLink()); + + strictEqual(bundleStat.dev, 0); + strictEqual(devStat.dev, 1); + + const devStatBigInt = statSync('/dev/null', { bigint: true }); + ok(devStatBigInt); + strictEqual(typeof devStatBigInt.dev, 'bigint'); + strictEqual(devStatBigInt.dev, 1n); + + // Paths that don't exist throw by default + throws(() => statSync('/does/not/exist'), { + code: 'ENOENT', + }); + + // Unless the `throwIfNoEntry` option is set to false, in which case it + // returns undefined. + const statNoThrow = statSync('/does/not/exist', { throwIfNoEntry: false }); + strictEqual(statNoThrow, undefined); + }, +}; + +export const chmodSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => chmodSync(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => chmodSync('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + // Should be non-op + accessSync('/tmp', W_OK | R_OK); + chmodSync('/tmp', 0o000); + chmodSync('/tmp', '000'); + accessSync('/tmp', W_OK | R_OK); + + chmodSync(Buffer.from('/tmp'), 0o000); + chmodSync(Buffer.from('/tmp'), '000'); + accessSync(Buffer.from('/tmp'), W_OK | R_OK); + + chmodSync(new URL('file:///tmp'), 0o000); + chmodSync(new URL('file:///tmp'), '000'); + accessSync(new URL('file:///tmp'), W_OK | R_OK); + + // Should throw if the mode is invalid + throws(() => chmodSync('/tmp', -1), { + code: /ERR_OUT_OF_RANGE/, + }); + }, +}; + +export const chownSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => chownSync(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => chownSync('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + // Should be non-op + const stat1 = statSync('/tmp'); + strictEqual(stat1.uid, 0); + strictEqual(stat1.gid, 0); + chownSync('/tmp', 1000, 1000); + const stat2 = statSync('/tmp'); + strictEqual(stat1.uid, stat2.uid); + strictEqual(stat1.gid, stat2.gid); + + chownSync(Buffer.from('/tmp'), 1000, 1000); + const stat3 = statSync(Buffer.from('/tmp')); + strictEqual(stat1.uid, stat3.uid); + strictEqual(stat1.gid, stat3.gid); + + chownSync(new URL('file:///tmp'), 1000, 1000); + const stat4 = statSync(new URL('file:///tmp')); + strictEqual(stat1.uid, stat4.uid); + strictEqual(stat1.gid, stat4.gid); + + throws(() => chownSync('/tmp', -1000, 0), { + code: /ERR_OUT_OF_RANGE/, + }); + throws(() => chownSync('/tmp', 0, -1000), { + code: /ERR_OUT_OF_RANGE/, + }); + }, +}; + +export const accessTest = { + async test() { + // Incorrect input types should throw. + throws(() => accessSync(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => accessSync('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + const { promise: successDone, resolve: successResolve } = + Promise.withResolvers(); + const { promise: failDone, resolve: failResolve } = Promise.withResolvers(); + + let successCount = 0; + let failCount = 0; + + const success = (err) => { + ifError(err); + + // Resolve only after all the expected calls have been made. + if (++successCount == 86) successResolve(); + }; + + const fails = (code) => { + return (err) => { + ok(err); + strictEqual(err.code, code); + + if (++failCount == 18) failResolve(); + }; + }; + + // Known accessible paths, default permissions (F_OK) + access('/', success); + access('/bundle', success); + access('/bundle/worker', success); + access('/dev', success); + access('/dev/null', success); + access('/dev/zero', success); + access('/dev/full', success); + access('/dev/random', success); + access('/tmp', success); + + access(Buffer.from('/'), success); + access(Buffer.from('/bundle'), success); + access(Buffer.from('/bundle/worker'), success); + access(Buffer.from('/dev'), success); + access(Buffer.from('/dev/null'), success); + access(Buffer.from('/dev/zero'), success); + access(Buffer.from('/dev/full'), success); + access(Buffer.from('/dev/random'), success); + access(Buffer.from('/tmp'), success); + + access(new URL('file:///'), success); + access(new URL('file:///bundle'), success); + access(new URL('file:///bundle/worker'), success); + access(new URL('file:///dev'), success); + access(new URL('file:///dev/null'), success); + access(new URL('file:///dev/zero'), success); + access(new URL('file:///dev/full'), success); + access(new URL('file:///dev/random'), success); + access(new URL('file:///tmp'), success); + + access('/bundle', F_OK, success); + access('/', F_OK, success); + access('/bundle/worker', F_OK, success); + access('/dev', F_OK, success); + access('/dev/null', F_OK, success); + access('/dev/zero', F_OK, success); + access('/dev/full', F_OK, success); + access('/dev/random', F_OK, success); + access('/tmp', F_OK, success); + + access(Buffer.from('/'), F_OK, success); + access(Buffer.from('/bundle'), F_OK, success); + access(Buffer.from('/bundle/worker'), F_OK, success); + access(Buffer.from('/dev'), F_OK, success); + access(Buffer.from('/dev/null'), F_OK, success); + access(Buffer.from('/dev/zero'), F_OK, success); + access(Buffer.from('/dev/full'), F_OK, success); + access(Buffer.from('/dev/random'), F_OK, success); + access(Buffer.from('/tmp'), F_OK, success); + + access(new URL('file:///'), F_OK, success); + access(new URL('file:///bundle'), F_OK, success); + access(new URL('file:///bundle/worker'), F_OK, success); + access(new URL('file:///dev'), F_OK, success); + access(new URL('file:///dev/null'), F_OK, success); + access(new URL('file:///dev/zero'), F_OK, success); + access(new URL('file:///dev/full'), F_OK, success); + access(new URL('file:///dev/random'), F_OK, success); + access(new URL('file:///tmp'), F_OK, success); + + // All of the known paths are readable (R_OK) + access('/', R_OK, success); + access('/bundle', R_OK, success); + access('/bundle/worker', R_OK, success); + access('/dev', R_OK, success); + access('/dev/null', R_OK, success); + access('/dev/zero', R_OK, success); + access('/dev/full', R_OK, success); + access('/dev/random', R_OK, success); + access('/tmp', R_OK, success); + + access(Buffer.from('/'), R_OK, success); + access(Buffer.from('/bundle'), R_OK, success); + access(Buffer.from('/bundle/worker'), R_OK, success); + access(Buffer.from('/dev'), R_OK, success); + access(Buffer.from('/dev/null'), R_OK, success); + access(Buffer.from('/dev/zero'), R_OK, success); + access(Buffer.from('/dev/full'), R_OK, success); + access(Buffer.from('/dev/random'), R_OK, success); + access(Buffer.from('/tmp'), R_OK, success); + + access(new URL('file:///'), R_OK, success); + access(new URL('file:///bundle'), R_OK, success); + access(new URL('file:///bundle/worker'), R_OK, success); + access(new URL('file:///dev'), R_OK, success); + access(new URL('file:///dev/null'), R_OK, success); + access(new URL('file:///dev/zero'), R_OK, success); + access(new URL('file:///dev/full'), R_OK, success); + access(new URL('file:///dev/random'), R_OK, success); + access(new URL('file:///tmp'), R_OK, success); + + // Only some of the known paths are writable (W_OK) + access('/', W_OK, fails('ENOENT')); + access('/bundle', W_OK, fails('ENOENT')); + access('/bundle/worker', W_OK, fails('ENOENT')); + access('/dev', W_OK, fails('ENOENT')); + + access('/dev/null', W_OK, success); + access('/dev/zero', W_OK, success); + access('/dev/full', W_OK, success); + access('/dev/random', W_OK, success); + access('/tmp', W_OK, success); + + // No known paths are executable (X_OK) + access('/', X_OK, fails('ENOENT')); + access('/bundle', X_OK, fails('ENOENT')); + access('/bundle/worker', X_OK, fails('ENOENT')); + access('/dev', X_OK, fails('ENOENT')); + access('/dev/null', X_OK, fails('ENOENT')); + access('/dev/zero', X_OK, fails('ENOENT')); + access('/dev/full', X_OK, fails('ENOENT')); + access('/dev/random', X_OK, fails('ENOENT')); + access('/tmp', X_OK, fails('ENOENT')); + + // Paths that don't exist have no permissions. + access('/does/not/exist', fails('ENOENT')); + access('/does/not/exist', F_OK, fails('ENOENT')); + access('/does/not/exist', R_OK, fails('ENOENT')); + access('/does/not/exist', W_OK, fails('ENOENT')); + access('/does/not/exist', X_OK, fails('ENOENT')); + + await Promise.all([successDone, failDone]); + }, +}; + +export const statTest = { + async test() { + // Incorrect input types should throw. + throws(() => stat(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => stat('/', { bigint: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => stat('/', { throwIfNoEntry: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + // Known accessible paths + async function callStatSuccess(path, fn, bigint = false) { + const { promise, resolve, reject } = Promise.withResolvers(); + stat(path, { bigint }, (err, stat) => { + if (err) return reject(err); + try { + fn(stat); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + async function callStatFail(path, code) { + const { promise, resolve, reject } = Promise.withResolvers(); + stat(path, (err) => { + if (err) { + strictEqual(err.code, code); + return resolve(); + } + reject(new Error('Expected error was not thrown')); + }); + await promise; + } + + await callStatSuccess('/', (stat) => { + ok(stat); + ok(stat.isDirectory()); + ok(!stat.isFile()); + ok(!stat.isBlockDevice()); + ok(!stat.isCharacterDevice()); + ok(!stat.isFIFO()); + ok(!stat.isSocket()); + ok(!stat.isSymbolicLink()); + + strictEqual(stat.dev, 0); + strictEqual(typeof stat.dev, 'number'); + }); + + await callStatSuccess( + '/', + (stat) => { + ok(stat); + ok(stat.isDirectory()); + ok(!stat.isFile()); + ok(!stat.isBlockDevice()); + ok(!stat.isCharacterDevice()); + ok(!stat.isFIFO()); + ok(!stat.isSocket()); + ok(!stat.isSymbolicLink()); + + strictEqual(stat.dev, 0n); + strictEqual(typeof stat.dev, 'bigint'); + }, + true /* bigint */ + ); + + await callStatSuccess(Buffer.from('/'), (stat) => { + ok(stat); + ok(stat.isDirectory()); + ok(!stat.isFile()); + ok(!stat.isBlockDevice()); + ok(!stat.isCharacterDevice()); + ok(!stat.isFIFO()); + ok(!stat.isSocket()); + ok(!stat.isSymbolicLink()); + }); + + await callStatSuccess(new URL('file:///'), (stat) => { + ok(stat); + ok(stat.isDirectory()); + ok(!stat.isFile()); + ok(!stat.isBlockDevice()); + ok(!stat.isCharacterDevice()); + ok(!stat.isFIFO()); + ok(!stat.isSocket()); + ok(!stat.isSymbolicLink()); + }); + + await callStatFail('/does/not/exist', 'ENOENT'); + }, +}; + +export const chmodTest = { + async test() { + // Incorrect input types should throw. + throws(() => chmod(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => chmod('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + async function callChmod(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + chmod(path, 0o000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + accessSync('/tmp', W_OK | R_OK); + await callChmod('/tmp'); + await callChmod('/tmp'); + accessSync('/tmp', W_OK | R_OK); + + await callChmod(Buffer.from('/tmp')); + await callChmod(Buffer.from('/tmp')); + accessSync(Buffer.from('/tmp'), W_OK | R_OK); + }, +}; + +export const chownTest = { + async test() { + // Incorrect input types should throw. + throws(() => chown(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => chown('/', {}), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + async function callChown(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + chown(path, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + const stat1 = statSync('/tmp'); + strictEqual(stat1.uid, 0); + strictEqual(stat1.gid, 0); + await callChown('/tmp'); + const stat2 = statSync('/tmp'); + strictEqual(stat1.uid, stat2.uid); + strictEqual(stat1.gid, stat2.gid); + + await callChown(Buffer.from('/tmp')); + const stat3 = statSync(Buffer.from('/tmp')); + strictEqual(stat1.uid, stat3.uid); + strictEqual(stat1.gid, stat3.gid); + + await callChown(new URL('file:///tmp')); + const stat4 = statSync(new URL('file:///tmp')); + strictEqual(stat1.uid, stat4.uid); + strictEqual(stat1.gid, stat4.gid); + }, +}; + +export const statfsTest = { + async test() { + const check = { + type: 0, + bsize: 0, + blocks: 0, + bfree: 0, + bavail: 0, + files: 0, + ffree: 0, + }; + const checkBn = { + type: 0n, + bsize: 0n, + blocks: 0n, + bfree: 0n, + bavail: 0n, + files: 0n, + ffree: 0n, + }; + + deepStrictEqual(statfsSync('/'), check); + deepStrictEqual(statfsSync('/bundle', { bigint: true }), checkBn); + + async function callStatfs(path, fn, bigint = false) { + const { promise, resolve, reject } = Promise.withResolvers(); + statfs(path, { bigint }, (err, stat) => { + if (err) return reject(err); + try { + fn(stat); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + await callStatfs('/', (stat) => { + deepStrictEqual(stat, check); + }); + await callStatfs( + '/', + (stat) => { + deepStrictEqual(stat, checkBn); + }, + true /* bigint */ + ); + + // Throws if the path is invalid / wrong type + throws(() => statfs(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + throws(() => statfs('/', { bigint: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + }, +}; + +export const linkTest = { + async test() { + ok(!existsSync('/tmp/a')); + linkSync('/dev/null', '/tmp/a'); + ok(existsSync('/tmp/a')); + + // These are the same file. + deepStrictEqual(statSync('/tmp/a'), statSync('/dev/null')); + deepStrictEqual(lstatSync('/tmp/a'), statSync('/dev/null')); + + // Because this is a hard link, the realpath is /tmp/a + strictEqual(realpathSync('/tmp/a'), '/tmp/a'); + + // And readlinkSync throws + throws(() => readlinkSync('/tmp/a'), { + message: 'invalid argument', + }); + + ok(!existsSync('/tmp/b')); + symlinkSync('/dev/null', '/tmp/b'); + ok(existsSync('/tmp/b')); + + deepStrictEqual(statSync('/tmp/b'), statSync('/dev/null')); + const lstatB = lstatSync('/tmp/b'); + notDeepStrictEqual(lstatB, statSync('/dev/null')); + ok(lstatB.isSymbolicLink()); + + strictEqual(realpathSync('/tmp/b'), '/dev/null'); + strictEqual(readlinkSync('/tmp/b'), '/dev/null'); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + link('/dev/null', '/tmp/c', (err) => { + try { + ifError(err); + ok(existsSync('/tmp/c')); + deepStrictEqual(statSync('/tmp/c'), statSync('/dev/null')); + deepStrictEqual(lstatSync('/tmp/c'), statSync('/dev/null')); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + symlink('/dev/null', '/tmp/d', (err) => { + try { + ifError(err); + ok(existsSync('/tmp/d')); + deepStrictEqual(statSync('/tmp/d'), statSync('/dev/null')); + notDeepStrictEqual(lstatSync('/tmp/d'), statSync('/dev/null')); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + readlink('/tmp/d', (err, link) => { + try { + ifError(err); + strictEqual(link, '/dev/null'); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + realpath('/tmp/d', (err, link) => { + try { + ifError(err); + strictEqual(link, '/dev/null'); + } catch (err) { + reject(err); + } + resolve(); + }); + } + + // Creating a symlink to a file that doesn't exist works. + symlinkSync('/does/not/exist', '/tmp/e'); + + // But creating a hard link to a file that doesn't exist throws. + throws(() => linkSync('/does/not/exist', '/tmp/f'), { + message: /file not found/, + }); + + // If the link name is empty, throw + throws(() => symlinkSync('/does/not/exist', new URL('file:///tmp/g/')), { + message: /Invalid filename/, + }); + + // If the directory does not exist, throw + throws(() => symlinkSync('/does/not/exist', new URL('file:///tmp/a/b/c')), { + message: /Directory does not exist/, + }); + + // If the file already exists, throw + throws(() => symlinkSync('/does/not/exist', '/tmp/a'), { + message: /File already exists/, + }); + throws(() => linkSync('/does/not/exist', '/tmp/a'), { + message: /File already exists/, + }); + + // If the destination is read-only, throw + throws(() => symlinkSync('/dev/null', '/bundle/a'), { + message: /Cannot add a file/, + }); + throws(() => linkSync('/dev/null', '/bundle/a'), { + message: /Cannot add a file/, + }); + + // lchmod and lchown are non-ops. They don't throw but they also + // don't change anything. + lchmodSync('/tmp/a', 0o000); + lchmodSync('/tmp/b', 0o000); + lchownSync('/tmp/a', 1000, 1000); + lchownSync('/tmp/b', 1000, 1000); + + // unlinkSync removes things + unlinkSync('/tmp/a'); + ok(!existsSync('/tmp/a')); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + unlink('/tmp/b', (err) => { + if (err) return reject(err); + ok(!existsSync('/tmp/b')); + resolve(); + }); + await promise; + } + + // Cannot unlink read-only files + throws(() => unlinkSync('/bundle/worker'), { + message: /Cannot remove a file/, + }); + + // Cannot unlink directories + throws(() => unlinkSync('/bundle'), { + message: /Cannot unlink a directory/, + }); + }, +}; + +export const openCloseTest = { + async test() { + ok(!existsSync('/tmp/test.txt')); + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + ok(stat); + ok(stat.isFile()); + ok(!stat.isDirectory()); + strictEqual(stat.size, 0n); + + throws(() => fstatSync(123), { + message: /Bad file descriptor/, + }); + throws(() => fstatSync(fd, { bigint: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => fstatSync('abc'), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + strictEqual(fd, 0); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fstat(fd, (err, stat) => { + try { + ifError(err); + ok(stat); + ok(stat.isFile()); + ok(!stat.isDirectory()); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + ftruncateSync(fd, 10); + const stat2 = fstatSync(fd); + ok(stat2); + ok(stat2.isFile()); + strictEqual(stat2.size, 10); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + ftruncate(fd, 20, (err) => { + if (err) return reject(err); + const stat3 = fstatSync(fd); + ok(stat3); + ok(stat3.isFile()); + strictEqual(stat3.size, 20); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fsync(fd, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fdatasync(fd, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // fchmod and fchown are non-ops. They don't throw but they also + // don't change anything. + fchmodSync(fd, 0o000); + fchmodSync(fd, '000'); + fchownSync(fd, 1000, 1000); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fchmod(fd, 0o000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fchown(fd, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + fsyncSync(fd); + fdatasyncSync(fd); + + // Close the file + closeSync(fd); + // Can close multiple times + closeSync(fd); + // Can close non-existent file descriptors + closeSync(123); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + close(fd, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + }, +}; + +export const utimesTest = { + async test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.atimeMs, 0n); + strictEqual(stat.mtimeMs, 0n); + strictEqual(stat.ctimeMs, 0n); + strictEqual(stat.birthtimeMs, 0n); + strictEqual(stat.atimeNs, 0n); + strictEqual(stat.mtimeNs, 0n); + strictEqual(stat.atimeNs, 0n); + strictEqual(stat.mtimeNs, 0n); + + utimesSync('/tmp/test.txt', 1000, 2000); + const stat2 = fstatSync(fd, { bigint: true }); + + strictEqual(stat2.atimeMs, 0n); + strictEqual(stat2.mtimeMs, 2000n); + + lutimesSync('/tmp/test.txt', 3000, 4000); + const stat3 = fstatSync(fd, { bigint: true }); + strictEqual(stat3.atimeMs, 0n); + strictEqual(stat3.mtimeMs, 4000n); + + futimesSync(fd, 5000, 6000); + const stat4 = fstatSync(fd, { bigint: true }); + strictEqual(stat4.atimeMs, 0n); + strictEqual(stat4.mtimeMs, 6000n); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + futimes(fd, 7000, 8000, (err) => { + if (err) return reject(err); + const stat5 = fstatSync(fd, { bigint: true }); + strictEqual(stat5.atimeMs, 0n); + strictEqual(stat5.mtimeMs, 8000n); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + utimes('/tmp/test.txt', 8000, 9000, (err) => { + if (err) return reject(err); + const stat5 = fstatSync(fd, { bigint: true }); + strictEqual(stat5.atimeMs, 0n); + strictEqual(stat5.mtimeMs, 9000n); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + lutimes('/tmp/test.txt', 9000, 10000, (err) => { + if (err) return reject(err); + const stat5 = fstatSync(fd, { bigint: true }); + strictEqual(stat5.atimeMs, 0n); + strictEqual(stat5.mtimeMs, 10000n); + resolve(); + }); + await promise; + } + + closeSync(fd); + }, +}; + +export const writeSyncTest = { + test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, 'Hello World'); + writeSync(fd, '!!!!'); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest = Buffer.alloc(15); + + // When we don't specify a position, it reads from the current position, + // which currently is the end of the file... so we get nothint here. + strictEqual(readSync(fd, dest), 0); + + // But when we do specify a position, we can read from the beginning... + strictEqual(readSync(fd, dest, 0, dest.byteLength, 0), 15); + strictEqual(dest.toString(), 'Hello World!!!!'); + + // Likewise, we can use an options object for the position + dest.fill(0); + strictEqual(dest.toString(), '\0'.repeat(dest.byteLength)); + strictEqual(readSync(fd, dest, { position: 0 }), 15); + strictEqual(dest.toString(), 'Hello World!!!!'); + + const dest2 = readFileSync('/tmp/test.txt'); + const dest3 = readFileSync(fd); + const dest4 = readFileSync(fd, { encoding: 'utf8' }); + strictEqual(dest2.toString(), 'Hello World!!!!'); + strictEqual(dest3.toString(), 'Hello World!!!!'); + strictEqual(dest4, 'Hello World!!!!'); + + closeSync(fd); + }, +}; + +export const writeSyncTest2 = { + test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + strictEqual(writeSync(fd, 'Hello World', 2n), 11); + + // Writing to a position beyond max uint32_t is not allowed. + throws(() => writeSync(fd, 'Hello World', 2n ** 32n), { + message: 'Position out of range', + }); + throws(() => writeSync(fd, 'Hello World', 2 ** 32), { + message: 'Position out of range', + }); + + strictEqual(writeSync(fd, 'aa', 0, 'ascii'), 2); + + const stat2 = fstatSync(fd); + strictEqual(stat2.size, 13); + + const dest = Buffer.alloc(stat2.size); + strictEqual(readSync(fd, dest, 0, dest.byteLength, 0), 13); + strictEqual(dest.toString(), 'aaHello World'); + + closeSync(fd); + }, +}; + +export const writeSyncTest3 = { + test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, Buffer.from('Hello World')); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 11n); + + closeSync(fd); + }, +}; + +export const writeSyncTest4 = { + test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + // Writing a partial buffer works + writeSync(fd, Buffer.from('Hello World'), 1, 3, 1); + + // Specifying an offset or length beyond the buffer size is not allowed. + throws(() => writeSync(fd, Buffer.from('Hello World'), 100, 3), { + message: /out of bounds/, + }); + // Specifying an offset or length beyond the buffer size is not allowed. + throws(() => writeSync(fd, Buffer.from('Hello World'), 0, 100), { + message: /out of bounds/, + }); + + throws(() => writeSync(fd, Buffer.from('hello world'), 'a'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + throws(() => writeSync(fd, Buffer.from('hello world'), 1n), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + throws(() => writeSync(fd, Buffer.from('hello world'), 0, 'a'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + throws(() => writeSync(fd, Buffer.from('hello world'), 1, 1n), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + throws(() => writeSync(fd, 123), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 4n); + + closeSync(fd); + }, +}; + +export const writeSyncAppend = { + test() { + const fd = openSync('/tmp/test.txt', 'a'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + // In append mode, the position is ignored. + + writeSync(fd, 'Hello World', 1000); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 11n); + + writeSync(fd, '!!!!', 2000); + const stat3 = fstatSync(fd, { bigint: true }); + strictEqual(stat3.size, 15n); + + closeSync(fd); + }, +}; + +export const writevSyncTest = { + test() { + const fd = openSync('/tmp/test.txt', 'w+'); + ok(existsSync('/tmp/test.txt')); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writevSync(fd, [Buffer.from('Hello World'), Buffer.from('!!!!')]); + + throws(() => writevSync(fd, [1, 2]), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + throws(() => writevSync(100, [Buffer.from('')]), { + message: 'Bad file descriptor', + }); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest1 = Buffer.alloc(5); + const dest2 = Buffer.alloc(10); + const dest3 = Buffer.alloc(5); + let read = readvSync(fd, [dest1, dest2, dest3], 0); + strictEqual(read, 15); + let dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString('utf8', 0, read), 'Hello World!!!!'); + + dest1.fill(0); + dest2.fill(0); + dest3.fill(0); + read = readvSync(fd, [dest1, dest2, dest3], 1); + strictEqual(read, 14); + dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString('utf8', 0, read), 'ello World!!!!'); + + // Reading from a position beyond the end of the file returns nothing. + strictEqual(readvSync(fd, [dest1], 100), 0); + + closeSync(fd); + }, +}; + +export const writeFileSyncTest = { + test() { + ok(!existsSync('/tmp/test.txt')); + strictEqual(writeFileSync('/tmp/test.txt', 'Hello World'), 11); + ok(existsSync('/tmp/test.txt')); + let stat = statSync('/tmp/test.txt'); + strictEqual(stat.size, 11); + strictEqual(readFileSync('/tmp/test.txt').toString(), 'Hello World'); + + strictEqual(appendFileSync('/tmp/test.txt', '!!!!'), 4); + stat = statSync('/tmp/test.txt'); + strictEqual(stat.size, 15); + strictEqual(readFileSync('/tmp/test.txt').toString(), 'Hello World!!!!'); + + // We can also use a file descriptor + const fd = openSync('/tmp/test.txt', 'a+'); + writeFileSync(fd, '##'); + strictEqual(readFileSync(fd).toString(), 'Hello World!!!!##'); + closeSync(fd); + }, +}; + +export const copyAndRenameTest = { + test() { + ok(!existsSync('/tmp/test.txt')); + ok(!existsSync('/tmp/test2.txt')); + writeFileSync('/tmp/test.txt', 'Hello World'); + ok(existsSync('/tmp/test.txt')); + ok(!existsSync('/tmp/test2.txt')); + + copyFileSync('/tmp/test.txt', '/tmp/test2.txt'); + // Both files exist + ok(existsSync('/tmp/test.txt')); + ok(existsSync('/tmp/test2.txt')); + + strictEqual( + readFileSync('/tmp/test.txt').toString(), + readFileSync('/tmp/test2.txt').toString() + ); + + // We can modify one of the files and the other remains unchanged + writeFileSync('/tmp/test.txt', 'Hello World 2'); + strictEqual(readFileSync('/tmp/test.txt').toString(), 'Hello World 2'); + strictEqual(readFileSync('/tmp/test2.txt').toString(), 'Hello World'); + + // Renaming the files work + renameSync('/tmp/test.txt', '/tmp/test3.txt'); + ok(!existsSync('/tmp/test.txt')); + ok(existsSync('/tmp/test3.txt')); + strictEqual(readFileSync('/tmp/test3.txt').toString(), 'Hello World 2'); + }, +}; + +export const mkdirTest = { + test() { + ok(!existsSync('/tmp/testdir')); + strictEqual(mkdirSync('/tmp/testdir'), undefined); + ok(existsSync('/tmp/testdir')); + + ok(!existsSync('/tmp/testdir/a/b/c')); + strictEqual( + mkdirSync('/tmp/testdir/a/b/c', { recursive: true }), + '/tmp/testdir/a' + ); + ok(existsSync('/tmp/testdir/a/b/c')); + + // Cannot create a directory in a read-only location + throws(() => mkdirSync('/bundle/a'), { + message: /access is denied/, + }); + + // Creating a directory that already exists is a non-op + mkdirSync('/tmp/testdir'); + + // Attempting to create a directory that already exists as a file throws + writeFileSync('/tmp/abc', 'Hello World'); + throws(() => mkdirSync('/tmp/abc'), { + message: /File already exists/, + }); + + // Attempting to create a directory recursively when a parent is a file + // throws + throws(() => mkdirSync('/tmp/abc/foo', { recursive: true }), { + message: /Invalid argument/, + }); + + // Passing incorrect types for options throws + throws(() => mkdirSync('/tmp/testdir', { recursive: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + throws(() => mkdirSync('/tmp/testdir', 'abc'), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + strictEqual(mkdtempSync('/tmp/testdir-'), '/tmp/testdir-0'); + strictEqual(mkdtempSync('/tmp/testdir-'), '/tmp/testdir-1'); + throws(() => mkdtempSync('/bundle/testdir-'), { + message: /access is denied/, + }); + }, +}; + +export const rmTest = { + test() { + ok(!existsSync('/tmp/testdir')); + mkdirSync('/tmp/testdir'); + writeFileSync('/tmp/testdir/a.txt', 'Hello World'); + throws(() => rmdirSync('/tmp/testdir'), { + message: /Directory is not empty/, + }); + rmdirSync('/tmp/testdir', { recursive: true }); + + ok(!existsSync('/tmp/testdir')); + mkdirSync('/tmp/testdir'); + writeFileSync('/tmp/testdir/a.txt', 'Hello World'); + writeFileSync('/tmp/testdir/b.txt', 'Hello World'); + ok(existsSync('/tmp/testdir/a.txt')); + + // removing a file works + rmSync('/tmp/testdir/a.txt'); + + ok(!existsSync('/tmp/testdir/a.txt')); + throws(() => rmSync('/tmp/testdir')); + rmSync('/tmp/testdir', { recursive: true }); + + // Passing incorrect types for options throws + throws(() => rmSync('/tmp/testdir', { recursive: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', 'abc'), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', { force: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', { maxRetries: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', { retryDelay: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', { maxRetries: 1, retryDelay: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmSync('/tmp/testdir', { maxRetries: 'yes', retryDelay: 1 }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws( + () => + rmSync('/tmp/testdir', { maxRetries: 1, retryDelay: 1, force: 'yes' }), + { + code: /ERR_INVALID_ARG_TYPE/, + } + ); + + throws(() => rmdirSync('/tmp/testdir', { recursive: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmdirSync('/tmp/testdir', 'abc'), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmdirSync('/tmp/testdir', { maxRetries: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws(() => rmdirSync('/tmp/testdir', { retryDelay: 'yes' }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + throws( + () => rmdirSync('/tmp/testdir', { maxRetries: 1, retryDelay: 'yes' }), + { + code: /ERR_INVALID_ARG_TYPE/, + } + ); + throws( + () => rmdirSync('/tmp/testdir', { maxRetries: 'yes', retryDelay: 1 }), + { + code: /ERR_INVALID_ARG_TYPE/, + } + ); + }, +}; + +export const readdirTest = { + test() { + deepStrictEqual(readdirSync('/'), ['bundle', 'tmp', 'dev']); + + { + const ents = readdirSync('/', { withFileTypes: true }); + strictEqual(ents.length, 3); + + strictEqual(ents[0].name, 'bundle'); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, '/'); + } + + { + const ents = readdirSync('/', { withFileTypes: true, recursive: true }); + strictEqual(ents.length, 8); + + strictEqual(ents[0].name, 'bundle'); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, '/'); + + strictEqual(ents[1].name, 'bundle/worker'); + strictEqual(ents[1].isDirectory(), false); + strictEqual(ents[1].isFile(), true); + strictEqual(ents[1].isBlockDevice(), false); + strictEqual(ents[1].isCharacterDevice(), false); + strictEqual(ents[1].isFIFO(), false); + strictEqual(ents[1].isSocket(), false); + strictEqual(ents[1].isSymbolicLink(), false); + strictEqual(ents[1].parentPath, '/bundle'); + + strictEqual(ents[4].name, 'dev/null'); + strictEqual(ents[4].isDirectory(), false); + strictEqual(ents[4].isFile(), false); + strictEqual(ents[4].isBlockDevice(), false); + strictEqual(ents[4].isCharacterDevice(), true); + strictEqual(ents[4].isFIFO(), false); + strictEqual(ents[4].isSocket(), false); + strictEqual(ents[4].isSymbolicLink(), false); + strictEqual(ents[4].parentPath, '/dev'); + } + }, +}; diff --git a/src/workerd/api/node/tests/fs-access-test.wd-test b/src/workerd/api/node/tests/fs-access-test.wd-test new file mode 100644 index 00000000000..216c6b9be4b --- /dev/null +++ b/src/workerd/api/node/tests/fs-access-test.wd-test @@ -0,0 +1,15 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "fs-access-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "fs-access-test.js") + ], + compatibilityDate = "2025-05-01", + compatibilityFlags = ["nodejs_compat", "experimental"] + ) + ), + ], +); diff --git a/src/workerd/api/node/tests/node-compat-v2-test.js b/src/workerd/api/node/tests/node-compat-v2-test.js index f00f4db31b6..c00992136b5 100644 --- a/src/workerd/api/node/tests/node-compat-v2-test.js +++ b/src/workerd/api/node/tests/node-compat-v2-test.js @@ -39,16 +39,16 @@ export const nodeJsGetBuiltins = { // But process.getBuiltinModule should always return the built-in module. const builtInPath = process.getBuiltinModule('node:path'); - const builtInFs = process.getBuiltinModule('node:fs'); + const builtInVm = process.getBuiltinModule('node:vm'); + + // The built-in node:vm module is not available in workers, so this should + // return undefined. + assert.strictEqual(builtInVm, undefined); // These are from the worker bundle.... assert.strictEqual(fs, 1); assert.strictEqual(path, 2); - // But these are from the built-ins... - // node:fs is not implemented currently so it should be undefined here. - assert.strictEqual(builtInFs, undefined); - // node:path is implemented tho... assert.notStrictEqual(path, builtInPath); assert.strictEqual(typeof builtInPath, 'object'); diff --git a/src/workerd/api/node/util.c++ b/src/workerd/api/node/util.c++ index c9a3da36162..5df6ebf183d 100644 --- a/src/workerd/api/node/util.c++ +++ b/src/workerd/api/node/util.c++ @@ -12,6 +12,11 @@ namespace workerd::api::node { +bool isExperimentalNodeJsCompatModule(kj::StringPtr name) { + return name == "node:fs"_kj || name == "node:http"_kj || name == "node:_http_common"_kj || + name == "node:_http_outgoing"_kj; +} + MIMEParams::MIMEParams(kj::Maybe mimeType): mimeType(mimeType) {} // Oddly, Node.js allows creating MIMEParams directly but it's not actually diff --git a/src/workerd/api/url-standard.h b/src/workerd/api/url-standard.h index 7333f0fbae2..6207dd89b67 100644 --- a/src/workerd/api/url-standard.h +++ b/src/workerd/api/url-standard.h @@ -251,6 +251,9 @@ class URL: public jsg::Object { tracker.trackField("searchParams", maybeSearchParams); } + operator const jsg::Url&() const { return inner; } + operator jsg::Url() { return inner.clone(); } + private: jsg::Url inner; kj::Maybe> maybeSearchParams; diff --git a/src/workerd/io/worker-fs.c++ b/src/workerd/io/worker-fs.c++ index b800ba99270..5333111a898 100644 --- a/src/workerd/io/worker-fs.c++ +++ b/src/workerd/io/worker-fs.c++ @@ -451,20 +451,11 @@ class DirectoryBase final: public Directory { return entries.erase(path[0]); } KJ_CASE_ONEOF(dir, kj::Rc) { - path = path.slice(1, path.size()); - if (path.size() == 0) { - if (opts.recursive) { - // We can remove it since we are in recursive mode. - return entries.erase(path[0]); - } else if (dir->count(js) == 0) { - // The directory is empty. We can remove it. - return entries.erase(path[0]); - } - // The directory is not empty. We cannot remove it. - JSG_FAIL_REQUIRE(Error, "Directory is not empty"); - } else { - return dir->remove(js, path, kj::mv(opts)); + if (path.size() == 1) { + JSG_REQUIRE(opts.recursive || dir->count(js) == 0, Error, "Directory is not empty"); + return entries.erase(path[0]); } + return dir->remove(js, path.slice(1, path.size()), kj::mv(opts)); } KJ_CASE_ONEOF(link, kj::Rc) { // If we found a symbolic link, we can remove it if our path @@ -1047,7 +1038,8 @@ kj::Maybe VirtualFileSystem::tryGetCurrent(jsg::Lock&) return Worker::Api::current().getVirtualFileSystem(); } -kj::Maybe VirtualFileSystem::resolve(jsg::Lock& js, const jsg::Url& url) const { +kj::Maybe VirtualFileSystem::resolve( + jsg::Lock& js, const jsg::Url& url, ResolveOptions options) const { if (url.getProtocol() != "file:"_kj) { // We only accept file URLs. return kj::none; @@ -1055,7 +1047,10 @@ kj::Maybe VirtualFileSystem::resolve(jsg::Lock& js, const jsg::Url& url) // We want to strip the leading slash from the path. auto path = kj::str(url.getPathname().slice(1)); kj::Path root{}; - return getRoot(js)->tryOpen(js, root.eval(path)); + return getRoot(js)->tryOpen(js, root.eval(path), + Directory::OpenOptions{ + .followLinks = options.followLinks, + }); } kj::Maybe VirtualFileSystem::resolveStat(jsg::Lock& js, const jsg::Url& url) const { @@ -1229,7 +1224,7 @@ class DevFullFile final: public File, public kj::EnableAddRefToThis .type = FsType::FILE, .size = 0, .lastModified = kj::UNIX_EPOCH, - .writable = false, + .writable = true, .device = true, }; } @@ -1285,7 +1280,7 @@ class DevRandomFile final: public File, public kj::EnableAddRefToThis // be left intact. // The path must be relative to the current directory. // Note that this method will only remove the node from this directory. If the - // node as references in other directories, those will not be removed. + // node has references in other directories, those will not be removed. // If the target is a symbolic link, the symbolic link will be removed but // the target will not be removed. virtual bool remove(jsg::Lock& js, kj::PathPtr path, RemoveOptions options = {false}) = 0; @@ -449,8 +449,13 @@ class VirtualFileSystem { // The root of the virtual file system. virtual kj::Rc getRoot(jsg::Lock& js) const = 0; + struct ResolveOptions { + bool followLinks = true; + }; + // Resolves the given file URL into a file or directory. - kj::Maybe resolve(jsg::Lock& js, const jsg::Url& url) const; + kj::Maybe resolve( + jsg::Lock& js, const jsg::Url& url, ResolveOptions options = {true}) const; // Resolves the given file URL into metadata for a file or directory. kj::Maybe resolveStat(jsg::Lock& js, const jsg::Url& url) const; diff --git a/src/workerd/jsg/url.c++ b/src/workerd/jsg/url.c++ index c5791a69651..a1d1481623c 100644 --- a/src/workerd/jsg/url.c++ +++ b/src/workerd/jsg/url.c++ @@ -322,6 +322,15 @@ kj::Maybe Url::tryResolve(kj::ArrayPtr input) const { return tryParse(input, getHref()); } +Url::Relative Url::getRelative() const { + auto base = KJ_ASSERT_NONNULL(tryResolve("."_kj)); + auto pos = KJ_ASSERT_NONNULL(getPathname().findLast('/')); + return { + .base = kj::mv(base), + .name = kj::str(getPathname().slice(pos + 1)), + }; +} + kj::uint Url::hashCode() const { return kj::hashCode(getHref()); } diff --git a/src/workerd/jsg/url.h b/src/workerd/jsg/url.h index c989ed5e38d..ff30398124a 100644 --- a/src/workerd/jsg/url.h +++ b/src/workerd/jsg/url.h @@ -99,6 +99,12 @@ class Url final { // Resolve the input relative to this URL kj::Maybe tryResolve(kj::ArrayPtr input) const KJ_WARN_UNUSED_RESULT; + struct Relative; + // Given this URL, returns a struct that is a basename and a base Url pair + // such that base.tryResolve(basename) is equivalent to this URL. Query + // parameters and fragments are not preserved. + Relative getRelative() const KJ_LIFETIMEBOUND; + HostType getHostType() const; SchemeType getSchemeType() const; @@ -124,6 +130,11 @@ class Url final { kj::Own inner; }; +struct Url::Relative { + Url base; + kj::String name; +}; + constexpr Url::EquivalenceOption operator|(Url::EquivalenceOption a, Url::EquivalenceOption b) { return static_cast(static_cast(a) | static_cast(b)); } diff --git a/src/workerd/server/workerd-api.c++ b/src/workerd/server/workerd-api.c++ index 889659bd7e3..a2d801154a6 100644 --- a/src/workerd/server/workerd-api.c++ +++ b/src/workerd/server/workerd-api.c++ @@ -109,6 +109,7 @@ JSG_DECLARE_ISOLATE_TYPE(JsgWorkerdIsolate, EW_URLPATTERN_ISOLATE_TYPES, EW_URLPATTERN_STANDARD_ISOLATE_TYPES, EW_WEB_FILESYSTEM_ISOLATE_TYPE, + EW_FILESYSTEM_ISOLATE_TYPES, EW_WEBSOCKET_ISOLATE_TYPES, EW_SQL_ISOLATE_TYPES, EW_NODE_ISOLATE_TYPES,