diff --git a/apps/nest-node-probe/correctness/fs-stat.ts b/apps/nest-node-probe/correctness/fs-stat.ts new file mode 100644 index 0000000..2a6e456 --- /dev/null +++ b/apps/nest-node-probe/correctness/fs-stat.ts @@ -0,0 +1,137 @@ +import { existsSync, promises as fsPromises, stat, statSync, Stats } from 'node:fs' + +interface StatsReport { + dev: number + ino: number + mode: number + nlink: number + uid: number + gid: number + rdev: number + size: number + blksize: number + blocks: number + atimePlausible: boolean + mtimeMs: number + ctimeMs: number + birthtimeMs: number + mtime: string + ctime: string + birthtime: string + isFile: boolean + isDirectory: boolean + isBlockDevice: boolean + isCharacterDevice: boolean + isSymbolicLink: boolean + isFIFO: boolean + isSocket: boolean +} + +interface ErrorReport { + message: string + code: string + errno: number + syscall: string + path: string +} + +function report(stats: Stats, volatileTimes = false): StatsReport { + const timeMs = (ms: number): number => (volatileTimes ? -1 : ms) + const time = (date: Date): string => (volatileTimes ? 'volatile' : date.toISOString()) + return { + dev: stats.dev, + ino: stats.ino, + mode: stats.mode, + nlink: stats.nlink, + uid: stats.uid, + gid: stats.gid, + rdev: stats.rdev, + size: stats.size, + blksize: stats.blksize, + blocks: stats.blocks, + atimePlausible: stats.atimeMs > 0 && stats.atime.getTime() === Math.round(stats.atimeMs), + mtimeMs: timeMs(stats.mtimeMs), + ctimeMs: timeMs(stats.ctimeMs), + birthtimeMs: timeMs(stats.birthtimeMs), + mtime: time(stats.mtime), + ctime: time(stats.ctime), + birthtime: time(stats.birthtime), + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + isBlockDevice: stats.isBlockDevice(), + isCharacterDevice: stats.isCharacterDevice(), + isSymbolicLink: stats.isSymbolicLink(), + isFIFO: stats.isFIFO(), + isSocket: stats.isSocket() + } +} + +function errorReport(error: unknown): ErrorReport { + if (error instanceof Error && 'code' in error && 'errno' in error && 'syscall' in error && 'path' in error) { + return { + message: error.message, + code: String(error.code), + errno: Number(error.errno), + syscall: String(error.syscall), + path: String(error.path) + } + } + return { message: String(error), code: '', errno: 0, syscall: '', path: '' } +} + +const file = 'apps/nest-node-probe/correctness/fs-stat.ts' +const directory = 'runtime/node' +const device = '/dev/null' +const missing = 'apps/nest-node-probe/correctness/does-not-exist' +const notDirectory = 'apps/nest-node-probe/correctness/fs-stat.ts/child' + +async function main(): Promise { + console.log(JSON.stringify(report(statSync(file)))) + console.log(JSON.stringify(report(statSync(directory)))) + console.log(JSON.stringify(report(statSync(device), true))) + console.log(JSON.stringify(report(statSync(new URL(`file://${process.cwd()}/${file}`))))) + console.log(existsSync(file), existsSync(missing)) + + for (const path of [missing, notDirectory]) { + try { + statSync(path) + console.log('no error') + } catch (error) { + console.log(JSON.stringify(errorReport(error))) + } + } + + console.log(statSync(missing, { throwIfNoEntry: false }) === undefined, statSync(notDirectory, { throwIfNoEntry: false }) === undefined) + const present = statSync(file, { throwIfNoEntry: false }) + console.log(present === undefined ? 'undefined' : JSON.stringify(report(present))) + try { + statSync(missing, { throwIfNoEntry: true }) + console.log('no error') + } catch (error) { + console.log(JSON.stringify(errorReport(error))) + } + + const fromPromise = await fsPromises.stat(directory) + console.log(JSON.stringify(report(fromPromise))) + try { + await fsPromises.stat(missing) + console.log('no error') + } catch (error) { + console.log(JSON.stringify(errorReport(error))) + } + + await new Promise((resolve) => { + let ordered = 'callback ran before stat returned' + stat(file, (err, stats) => { + console.log(ordered) + console.log(err === null, stats === undefined ? 'undefined' : JSON.stringify(report(stats))) + stat(missing, (missingErr, missingStats) => { + console.log(missingErr === null ? 'null' : JSON.stringify(errorReport(missingErr)), missingStats === undefined) + resolve() + }) + }) + ordered = 'callback ran after stat returned' + }) +} + +main() diff --git a/plugin/intrinsics.mjs b/plugin/intrinsics.mjs index c9efca1..43cbfd1 100644 --- a/plugin/intrinsics.mjs +++ b/plugin/intrinsics.mjs @@ -611,6 +611,16 @@ export const intrinsics = { returnType: 'bool', decl: 'bool __gea_node_fs_access(std::string path, double mode);' }, + __gea_node_fs_stat: { + emit: '__gea_node_fs_stat', + returnType: 'double', + decl: 'double __gea_node_fs_stat(std::string path, bool followSymlinks);' + }, + __gea_node_fs_stat_field: { + emit: '__gea_node_fs_stat_field', + returnType: 'double', + decl: 'double __gea_node_fs_stat_field(double field);' + }, // These return the native Buffer carrier directly. Their declarations live // in gea_node_buffer.hpp, which generated_support.hpp includes, so no // gea_cpp_value bridge is introduced for random/file bytes. diff --git a/runtime/gea_node.cpp b/runtime/gea_node.cpp index 3ca7987..cac56fe 100644 --- a/runtime/gea_node.cpp +++ b/runtime/gea_node.cpp @@ -1955,15 +1955,20 @@ inline std::string net_errno_name(double requested) { GEA_NODE_ERRNO_NAME(EINVAL) GEA_NODE_ERRNO_NAME(EIO) GEA_NODE_ERRNO_NAME(EISCONN) + GEA_NODE_ERRNO_NAME(ELOOP) GEA_NODE_ERRNO_NAME(EMFILE) GEA_NODE_ERRNO_NAME(EMSGSIZE) + GEA_NODE_ERRNO_NAME(ENAMETOOLONG) GEA_NODE_ERRNO_NAME(ENETDOWN) GEA_NODE_ERRNO_NAME(ENETUNREACH) GEA_NODE_ERRNO_NAME(ENFILE) GEA_NODE_ERRNO_NAME(ENOBUFS) + GEA_NODE_ERRNO_NAME(ENOENT) GEA_NODE_ERRNO_NAME(ENOMEM) GEA_NODE_ERRNO_NAME(ENOTCONN) + GEA_NODE_ERRNO_NAME(ENOTDIR) GEA_NODE_ERRNO_NAME(ENOTSOCK) + GEA_NODE_ERRNO_NAME(EOVERFLOW) GEA_NODE_ERRNO_NAME(EPERM) GEA_NODE_ERRNO_NAME(EPIPE) GEA_NODE_ERRNO_NAME(EPROTO) @@ -4186,6 +4191,33 @@ inline bool __gea_node_fs_access(std::string path, double mode) { return gea::node::platform::canAccess(path, static_cast(mode)); } +static gea::node::platform::FileStatus gea_node_fs_last_stat; + +inline double __gea_node_fs_stat(std::string path, bool followSymlinks) { + return static_cast(gea::node::platform::statFile(path, followSymlinks, gea_node_fs_last_stat)); +} + +inline double __gea_node_fs_stat_field(double field) { + const gea::node::platform::FileStatus &st = gea_node_fs_last_stat; + switch (static_cast(field)) { + case 0: return st.dev; + case 1: return st.ino; + case 2: return st.mode; + case 3: return st.nlink; + case 4: return st.uid; + case 5: return st.gid; + case 6: return st.rdev; + case 7: return st.size; + case 8: return st.blksize; + case 9: return st.blocks; + case 10: return st.atimeMs; + case 11: return st.mtimeMs; + case 12: return st.ctimeMs; + case 13: return st.birthtimeMs; + default: return 0.0; + } +} + // The same refusal under both runtimes, raised the way each one raises: v1 // writes its global thrown slot and sets the flag, v2 throws a real C++ // exception carrying the identical `gea::Value` payload -- which is also what diff --git a/runtime/gea_node_platform.hpp b/runtime/gea_node_platform.hpp index f247b01..de200fb 100644 --- a/runtime/gea_node_platform.hpp +++ b/runtime/gea_node_platform.hpp @@ -35,7 +35,7 @@ // signal handler) writes to wake the loop // startThread(entry, argument) a detached thread; false when none starts // utcTime(seconds, tm) gmtime -// canAccess / readWholeFile / fillRandom +// canAccess / readWholeFile / statFile / fillRandom // arguments / environmentEntries / executablePath / canonicalPath / // currentDirectory / processId / parentProcessId / exitImmediately / // onlineProcessors / machineArch / kernelRelease @@ -52,6 +52,25 @@ // Everything here is inline: the reactor is compiled into the program's own // translation unit (see `gea_node.hpp`), and so is this. +namespace gea::node::platform { +struct FileStatus { + double dev = 0; + double ino = 0; + double mode = 0; + double nlink = 0; + double uid = 0; + double gid = 0; + double rdev = 0; + double size = 0; + double blksize = 0; + double blocks = 0; + double atimeMs = 0; + double mtimeMs = 0; + double ctimeMs = 0; + double birthtimeMs = 0; +}; +} // namespace gea::node::platform + #if defined(_WIN32) #include "gea_node_platform_win32.hpp" #else diff --git a/runtime/gea_node_platform_posix.hpp b/runtime/gea_node_platform_posix.hpp index 9b2c13b..a813e5e 100644 --- a/runtime/gea_node_platform_posix.hpp +++ b/runtime/gea_node_platform_posix.hpp @@ -206,6 +206,46 @@ inline void utcTime(std::time_t seconds, std::tm &out) { ::gmtime_r(&seconds, &o inline bool canAccess(const std::string &path, int mode) { return ::access(path.c_str(), mode) == 0; } +inline double fileTimeMs(const struct timespec &time) { + return static_cast(time.tv_sec) * 1000.0 + static_cast(time.tv_nsec) / 1000000.0; +} + +inline int statFile(const std::string &path, bool followSymlinks, FileStatus &out) { + struct stat st; + const int rc = followSymlinks ? ::stat(path.c_str(), &st) : ::lstat(path.c_str(), &st); + if (rc != 0) return uvErrorNumber(errno); + const auto widen = [](auto value) { return static_cast(static_cast(value)); }; + out.dev = widen(st.st_dev); + out.ino = widen(st.st_ino); + out.mode = widen(st.st_mode); + out.nlink = widen(st.st_nlink); + out.uid = widen(st.st_uid); + out.gid = widen(st.st_gid); + out.rdev = widen(st.st_rdev); + out.size = widen(st.st_size); + out.blksize = widen(st.st_blksize); + out.blocks = widen(st.st_blocks); +#if defined(__APPLE__) + out.atimeMs = fileTimeMs(st.st_atimespec); + out.mtimeMs = fileTimeMs(st.st_mtimespec); + out.ctimeMs = fileTimeMs(st.st_ctimespec); + out.birthtimeMs = fileTimeMs(st.st_birthtimespec); +#else + out.atimeMs = fileTimeMs(st.st_atim); + out.mtimeMs = fileTimeMs(st.st_mtim); + out.ctimeMs = fileTimeMs(st.st_ctim); + out.birthtimeMs = out.ctimeMs; +#if defined(__linux__) && defined(STATX_BTIME) + struct statx stx; + const int flags = followSymlinks ? 0 : AT_SYMLINK_NOFOLLOW; + if (::statx(AT_FDCWD, path.c_str(), flags, STATX_BTIME, &stx) == 0 && (stx.stx_mask & STATX_BTIME) != 0) { + out.birthtimeMs = static_cast(stx.stx_btime.tv_sec) * 1000.0 + static_cast(stx.stx_btime.tv_nsec) / 1000000.0; + } +#endif +#endif + return 0; +} + inline bool readWholeFile(const std::string &path, std::vector &out, std::string &failure) { const int fd = ::open(path.c_str(), O_RDONLY); if (fd < 0) { diff --git a/runtime/gea_node_platform_win32.hpp b/runtime/gea_node_platform_win32.hpp index a906c7c..24cd462 100644 --- a/runtime/gea_node_platform_win32.hpp +++ b/runtime/gea_node_platform_win32.hpp @@ -609,6 +609,51 @@ inline bool canAccess(const std::string &path, int mode) { return true; } +inline double fileTimeMs(const LARGE_INTEGER &time) { + return (static_cast(time.QuadPart) - 116444736000000000.0) / 10000.0; +} + +// The mode bits libuv reports on Windows: a type from the attributes and the +// handle, read for everyone, written unless the read-only attribute is set. +inline int statFile(const std::string &path, bool followSymlinks, FileStatus &out) { + const DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | (followSymlinks ? 0 : FILE_FLAG_OPEN_REPARSE_POINT); + const HANDLE file = ::CreateFileW(wide(path).c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, flags, nullptr); + if (file == INVALID_HANDLE_VALUE) return uvErrorNumber(errnoFromWin32(::GetLastError())); + BY_HANDLE_FILE_INFORMATION info; + FILE_BASIC_INFO basic; + FILE_STANDARD_INFO standard; + if (!::GetFileInformationByHandle(file, &info) || !::GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof(basic)) || + !::GetFileInformationByHandleEx(file, FileStandardInfo, &standard, sizeof(standard))) { + const DWORD error = ::GetLastError(); + ::CloseHandle(file); + return uvErrorNumber(errnoFromWin32(error)); + } + FILE_STORAGE_INFO storage; + const bool hasStorage = ::GetFileInformationByHandleEx(file, FileStorageInfo, &storage, sizeof(storage)) != 0; + const DWORD type = ::GetFileType(file); + ::CloseHandle(file); + const bool directory = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + const bool link = !followSymlinks && (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + const unsigned typeBits = link ? 0120000u : directory ? 0040000u : type == FILE_TYPE_CHAR ? 0020000u : 0100000u; + const unsigned permissionBits = (info.dwFileAttributes & FILE_ATTRIBUTE_READONLY) != 0 ? 0444u : 0666u; + out.dev = static_cast(info.dwVolumeSerialNumber); + out.ino = static_cast((static_cast(info.nFileIndexHigh) << 32) | info.nFileIndexLow); + out.mode = static_cast(typeBits | permissionBits); + out.nlink = static_cast(info.nNumberOfLinks); + out.uid = 0; + out.gid = 0; + out.rdev = 0; + out.size = directory ? 0.0 : static_cast(standard.EndOfFile.QuadPart); + out.blksize = hasStorage ? static_cast(storage.PhysicalBytesPerSectorForPerformance) : 4096.0; + out.blocks = static_cast((standard.AllocationSize.QuadPart + 511) / 512); + out.atimeMs = fileTimeMs(basic.LastAccessTime); + out.mtimeMs = fileTimeMs(basic.LastWriteTime); + out.ctimeMs = fileTimeMs(basic.ChangeTime); + out.birthtimeMs = fileTimeMs(basic.CreationTime); + return 0; +} + inline bool readWholeFile(const std::string &path, std::vector &out, std::string &failure) { const std::wstring name = wide(path); const DWORD attributes = ::GetFileAttributesW(name.c_str()); diff --git a/runtime/node/fs.ts b/runtime/node/fs.ts index cd30034..03e0e77 100644 --- a/runtime/node/fs.ts +++ b/runtime/node/fs.ts @@ -12,12 +12,26 @@ import { nodeNotImplemented } from './not-implemented' declare function __gea_node_fs_access(path: string, mode: number): boolean /** @gea-host-inert */ declare function __gea_node_fs_read_file(path: string): Buffer +/** @gea-host-inert */ +declare function __gea_node_fs_stat(path: string, followSymlinks: boolean): number +/** @gea-host-inert */ +declare function __gea_node_fs_stat_field(field: number): number +/** @gea-host-inert */ +declare function __gea_node_net_errno_name(error: number): string const nodeFsConstants = { F_OK: 0, R_OK: 4, W_OK: 2, - X_OK: 1 + X_OK: 1, + S_IFMT: 0o170000, + S_IFREG: 0o100000, + S_IFDIR: 0o040000, + S_IFCHR: 0o020000, + S_IFBLK: 0o060000, + S_IFIFO: 0o010000, + S_IFLNK: 0o120000, + S_IFSOCK: 0o140000 } /** @gea-host-inert */ @@ -44,47 +58,50 @@ function windowsFilePath(path: URL): string { return pathname.slice(1) } -export class FsPromises { - async access(path: string | URL, mode: number = 0): Promise { - const filename = filePath(path) - if (!__gea_node_fs_access(filename, mode)) throw new Error(`ENOENT: cannot access '${filename}'`) - } +export interface FsSystemError extends Error { + errno: number + code: string + syscall: string + path: string +} - readFile(path: string | URL, encoding: BufferEncoding): Promise - readFile(path: string | URL, encoding?: null): Promise - async readFile(path: string | URL, encoding: BufferEncoding | null = null): Promise { - const filename = filePath(path) - if (!__gea_node_fs_access(filename, nodeFsConstants.R_OK)) { - throw new Error(`ENOENT: cannot read '${filename}'`) - } - const bytes = __gea_node_fs_read_file(filename) - return encoding === null ? bytes : bytes.toString(encoding) +function fsErrnoDescription(code: string, errnoValue: number): string { + switch (code) { + case 'EACCES': + return 'permission denied' + case 'EIO': + return 'i/o error' + case 'ELOOP': + return 'too many symbolic links encountered' + case 'ENAMETOOLONG': + return 'name too long' + case 'ENOENT': + return 'no such file or directory' + case 'ENOMEM': + return 'not enough memory' + case 'ENOTDIR': + return 'not a directory' + case 'EOVERFLOW': + return 'value too large for defined data type' + case 'EPERM': + return 'operation not permitted' + default: + return `Unknown system error ${errnoValue}` } } -const nodeFsPromises = new FsPromises() - -export { nodeFsConstants as constants, nodeFsPromises as promises } - -// `existsSync` is exactly what it is on real Node: an access(2)-style -// presence check with the error swallowed. `__gea_node_fs_access` already IS -// that check, so this is a real implementation, not a stand-in for one. -export function existsSync(path: string | URL): boolean { - return __gea_node_fs_access(filePath(path), nodeFsConstants.F_OK) +function fsSystemError(errnoValue: number, syscall: string, path: string): FsSystemError { + const named = __gea_node_net_errno_name(errnoValue) + const code = named === 'UNKNOWN' ? `Unknown system error ${errnoValue}` : named + const error = new Error(`${code}: ${fsErrnoDescription(code, errnoValue)}, ${syscall} '${path}'`) as FsSystemError + error.errno = -errnoValue + error.code = code + error.syscall = syscall + error.path = path + return error } -// The real Node.js `fs.Stats` shape. Declared in full because callers -// (an HTTP framework's static-file middleware) narrow on it structurally, but see -// `statSync` below: nothing on this target can actually produce one of these, -// so the type exists without a value ever legitimately carrying it. -export interface Stats { - isFile(): boolean - isDirectory(): boolean - isBlockDevice(): boolean - isCharacterDevice(): boolean - isSymbolicLink(): boolean - isFIFO(): boolean - isSocket(): boolean +export class Stats { dev: number ino: number mode: number @@ -103,21 +120,161 @@ export interface Stats { mtime: Date ctime: Date birthtime: Date + + constructor( + dev: number, + ino: number, + mode: number, + nlink: number, + uid: number, + gid: number, + rdev: number, + size: number, + blksize: number, + blocks: number, + atimeMs: number, + mtimeMs: number, + ctimeMs: number, + birthtimeMs: number + ) { + this.dev = dev + this.ino = ino + this.mode = mode + this.nlink = nlink + this.uid = uid + this.gid = gid + this.rdev = rdev + this.size = size + this.blksize = blksize + this.blocks = blocks + this.atimeMs = atimeMs + this.mtimeMs = mtimeMs + this.ctimeMs = ctimeMs + this.birthtimeMs = birthtimeMs + this.atime = new Date(Math.round(atimeMs)) + this.mtime = new Date(Math.round(mtimeMs)) + this.ctime = new Date(Math.round(ctimeMs)) + this.birthtime = new Date(Math.round(birthtimeMs)) + } + + private isType(type: number): boolean { + return (this.mode & nodeFsConstants.S_IFMT) === type + } + isFile(): boolean { + return this.isType(nodeFsConstants.S_IFREG) + } + isDirectory(): boolean { + return this.isType(nodeFsConstants.S_IFDIR) + } + isBlockDevice(): boolean { + return this.isType(nodeFsConstants.S_IFBLK) + } + isCharacterDevice(): boolean { + return this.isType(nodeFsConstants.S_IFCHR) + } + isSymbolicLink(): boolean { + return this.isType(nodeFsConstants.S_IFLNK) + } + isFIFO(): boolean { + return this.isType(nodeFsConstants.S_IFIFO) + } + isSocket(): boolean { + return this.isType(nodeFsConstants.S_IFSOCK) + } +} + +export interface StatOptions { + bigint?: boolean } -// There is no `stat`/`fstat` primitive bound on this target (`gea_node.cpp` -// exposes exactly two fs syscalls: `access` and a whole-file `read`) -- no -// mtime, no mode, no size independent of reading the whole file, and no way -// to tell a directory from a regular file. Inventing any of those (today's -// Date as a fake mtime, `false` as a guessed `isDirectory()`) would be a -// fabricated value wearing a real type, which is worse than refusing. This -// refuses by name instead: a program that reaches it fails loudly rather than -// silently mis-serving, e.g. treating every path as "not found" or every file -// as unmodified. -export function statSync(path: string | URL, options?: unknown): Stats { - void path - void options - return nodeNotImplemented('fs', 'statSync') +function statPath(path: string | URL, options: StatOptions | undefined, syscall: string, member: string): Stats { + if (options !== undefined && options.bigint === true) return nodeNotImplemented('fs', `${member} with bigint`) + const filename = filePath(path) + const errnoValue = __gea_node_fs_stat(filename, syscall === 'stat') + if (errnoValue !== 0) throw fsSystemError(errnoValue, syscall, filename) + return new Stats( + __gea_node_fs_stat_field(0), + __gea_node_fs_stat_field(1), + __gea_node_fs_stat_field(2), + __gea_node_fs_stat_field(3), + __gea_node_fs_stat_field(4), + __gea_node_fs_stat_field(5), + __gea_node_fs_stat_field(6), + __gea_node_fs_stat_field(7), + __gea_node_fs_stat_field(8), + __gea_node_fs_stat_field(9), + __gea_node_fs_stat_field(10), + __gea_node_fs_stat_field(11), + __gea_node_fs_stat_field(12), + __gea_node_fs_stat_field(13) + ) +} + +export interface StatSyncOptions extends StatOptions { + throwIfNoEntry?: boolean +} + +export function statSync(path: string | URL, options?: StatSyncOptions & { throwIfNoEntry?: true }): Stats +export function statSync(path: string | URL, options: StatSyncOptions & { throwIfNoEntry: false }): Stats | undefined +export function statSync(path: string | URL, options?: StatSyncOptions): Stats | undefined { + try { + return statPath(path, options, 'stat', 'statSync') + } catch (error) { + if (options !== undefined && options.throwIfNoEntry === false && error instanceof Error && 'syscall' in error) { + const failure = error as FsSystemError + if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return undefined + } + throw error + } +} + +export type StatCallback = (err: FsSystemError | null, stats: Stats | undefined) => void + +export function stat(path: string | URL, optionsOrCallback: StatOptions | StatCallback, callback?: StatCallback): void { + const options: StatOptions | undefined = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback + const done: StatCallback | undefined = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback + if (done === undefined) throw new TypeError('The "cb" argument must be of type function. Received undefined') + let stats: Stats | undefined + let failure: FsSystemError | null = null + try { + stats = statPath(path, options, 'stat', 'stat') + } catch (error) { + if (error instanceof Error && 'syscall' in error) failure = error as FsSystemError + else throw error + } + Promise.resolve().then(() => { + done(failure, stats) + }) +} + +export class FsPromises { + async access(path: string | URL, mode: number = 0): Promise { + const filename = filePath(path) + if (!__gea_node_fs_access(filename, mode)) throw new Error(`ENOENT: cannot access '${filename}'`) + } + + readFile(path: string | URL, encoding: BufferEncoding): Promise + readFile(path: string | URL, encoding?: null): Promise + async readFile(path: string | URL, encoding: BufferEncoding | null = null): Promise { + const filename = filePath(path) + if (!__gea_node_fs_access(filename, nodeFsConstants.R_OK)) { + throw new Error(`ENOENT: cannot read '${filename}'`) + } + const bytes = __gea_node_fs_read_file(filename) + return encoding === null ? bytes : bytes.toString(encoding) + } + + async stat(path: string | URL, options?: StatOptions): Promise { + return statPath(path, options, 'stat', 'promises.stat') + } +} + +const nodeFsPromises = new FsPromises() + +export { nodeFsConstants as constants, nodeFsPromises as promises } + +export function existsSync(path: string | URL): boolean { + return __gea_node_fs_access(filePath(path), nodeFsConstants.F_OK) } export interface ReadStreamOptions { diff --git a/runtime/node/generated/facades/fs.ts b/runtime/node/generated/facades/fs.ts index 948f4a1..cd17fbf 100644 --- a/runtime/node/generated/facades/fs.ts +++ b/runtime/node/generated/facades/fs.ts @@ -1,7 +1,7 @@ // Generated from the pinned Node 24 declaration inventory. Do not edit. // Canonical builtin: node:fs import { nodeNotImplemented } from "../../not-implemented" -export { constants, createReadStream, existsSync, promises, ReadStream, statSync } from "../../fs" +export { constants, createReadStream, existsSync, promises, ReadStream, stat, Stats, statSync } from "../../fs" export function access(...args: unknown[]): never { void args @@ -367,11 +367,6 @@ export function rmSync(...args: unknown[]): never { return nodeNotImplemented("node:fs", "rmSync") } -export function stat(...args: unknown[]): never { - void args - return nodeNotImplemented("node:fs", "stat") -} - export function statfs(...args: unknown[]): never { void args return nodeNotImplemented("node:fs", "statfs") @@ -382,13 +377,6 @@ export function statfsSync(...args: unknown[]): never { return nodeNotImplemented("node:fs", "statfsSync") } -export class Stats { - constructor(...args: unknown[]) { - void args - nodeNotImplemented("node:fs", "Stats") - } -} - export class StatsFs { constructor(...args: unknown[]) { void args diff --git a/runtime/node/generated/node24-compatibility.json b/runtime/node/generated/node24-compatibility.json index 0cf05ca..58ea856 100644 --- a/runtime/node/generated/node24-compatibility.json +++ b/runtime/node/generated/node24-compatibility.json @@ -8265,6 +8265,9 @@ "status": "partial", "composition": "generated-export-equals", "overlay": "events.ts", + "supportedConstructSignatures": [ + "(options?: EventEmitterOptions): EventEmitter" + ], "members": [ { "name": "addAbortListener", @@ -9301,7 +9304,11 @@ { "name": "stat", "kind": "function", - "status": "stubbed" + "status": "partial", + "overlay": "fs.ts", + "supportedCallSignatures": [ + "(path: string | URL, optionsOrCallback: StatOptions | StatCallback, callback?: StatCallback): void" + ] }, { "name": "statfs", @@ -9316,13 +9323,18 @@ { "name": "Stats", "kind": "class", - "status": "stubbed", + "status": "partial", + "overlay": "fs.ts", + "supportedConstructSignatures": [ + "(dev: number, ino: number, mode: number, nlink: number, uid: number, gid: number, rdev: number, size: number, blksize: number, blocks: number, atimeMs: number, mtimeMs: number, ctimeMs: number, birthtimeMs: number): Stats" + ], "members": [ { "name": "", "kind": "Constructor", "scope": "instance", - "status": "stubbed" + "status": "partial", + "overlayKind": "Constructor" } ] }, @@ -9337,7 +9349,8 @@ "status": "partial", "overlay": "fs.ts", "supportedCallSignatures": [ - "(path: string | URL, options?: unknown): Stats" + "(path: string | URL, options?: StatSyncOptions & { throwIfNoEntry?: true; }): Stats", + "(path: string | URL, options: StatSyncOptions & { throwIfNoEntry: false; }): Stats | undefined" ] }, { @@ -17321,9 +17334,7 @@ "status": "partial", "overlay": "stream/web.ts", "supportedConstructSignatures": [ - "(underlyingSource: UnderlyingByteSource, strategy?: { highWaterMark?: number; }): ReadableStream>", - "(underlyingSource: UnderlyingDefaultSource, strategy?: QueuingStrategy): ReadableStream", - "(underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream" + "(underlyingSource?: UnderlyingReadableStreamSource, strategy?: QueuingStrategyInit): ReadableStream" ], "members": [ { diff --git a/scripts/test-fs-stat.mjs b/scripts/test-fs-stat.mjs new file mode 100644 index 0000000..0986a73 --- /dev/null +++ b/scripts/test-fs-stat.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/fs-stat.ts') +const executable = resolve(output, 'fs-stat') +const options = { cwd: root, encoding: 'utf8', env: { ...process.env, TMPDIR: output } } +mkdirSync(output, { recursive: true }) + +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + ...options, + stdio: 'inherit' +}) +const expected = execFileSync(process.execPath, ['--experimental-strip-types', source], options) +const actual = execFileSync(executable, [], { ...options, timeout: 15000 }) + +const expectedLines = expected.trim().split('\n') +const actualLines = actual.trim().split('\n') +assert.equal(actualLines.length, expectedLines.length, `line count\n--- node\n${expected}\n--- compiled\n${actual}`) +for (let i = 0; i < expectedLines.length; i++) assert.equal(actualLines[i], expectedLines[i], `line ${i + 1}`) +assert.ok(expectedLines.length >= 12, 'the probe printed every section') +console.log(`FS_STAT_PARITY_OK:${expectedLines.length}`)