From db94b2d2578d7301f3eda32ae9aca1125f21f78f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 21:06:22 -0500 Subject: [PATCH 1/2] Add fs.cpSync, fs.cp and fs/promises cp fs.cpSync, fs.cp and fs/promises cp compile with every option: dereference, errorOnExist, filter, force, mode, preserveTimestamps, recursive and verbatimSymlinks. They follow Node's two implementations. cpSync follows cp-sync.js over the fs binding: copy errors are plain Errors with a code, std::error_code failures throw an ErrnoException, and links inside a copied directory point at their canonical targets. fs.cp and fs/promises cp follow cp.js, whose copy errors are SystemErrors with code, info, errno, syscall and path. Options and paths validate in Node's order with Node's errors. filter runs for the top-level paths and each entry in Node's order; an async filter must return a promise that is already settled, and a pending one throws SC2020. The callback and promise forms complete the copy before the callback runs or the promise settles. Windows builds throw SC2020. The copy lives in the new scr_fs_cp.c. Directory walks keep their frames on the heap, so deep trees also copy on a fiber's stack. File contents copy with sendfile on Linux, and COPYFILE_FICLONE uses FICLONE where it exists. ScrError gains the SystemError context and the ErrnoException key order, with matching fields in the compiler's Error layout. The stored fs.cp callback uses the same implementation. Corpus 5567-5571 cover the sync, callback and promise forms, errors, symlinks and 1500-level trees. --- docs/content/docs/limitations.mdx | 2 + .../content/limitations/builtin-apis/copy.mdx | 1 + .../limitations/builtin-function-values.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 22 +- .../generated/node-v24-backlog.json | 55 +- .../generated/node-v24-internal.json | 38 +- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 47 + .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/backend/native/runtime-inputs.ts | 1 + .../lowering/builtins/filesystem-calls.ts | 20 + .../lowering/builtins/module-calls.ts | 6 + .../src/frontend/lowering/lower-classes.ts | 4 + .../src/frontend/lowering/surfaces.ts | 19 + packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/surface-manifest.json | 12 + .../tests/corpus/5567-fs-cpsync.ts.json | 6 + .../tests/corpus/5568-fs-cp-errors.js.json | 6 + .../tests/corpus/5569-fs-cp-symlinks.ts.json | 6 + .../tests/corpus/5570-fs-cp-async.ts.json | 6 + .../tests/corpus/5571-fs-cp-deep.ts.json | 6 + .../runtime-pack-matrix.mjs | 1 + .../runtime-pack-matrix.mjs | 1 + packages/runtime/src/scr_async.c | 19 + packages/runtime/src/scr_async.h | 6 + packages/runtime/src/scr_bytes_io.c | 102 +- packages/runtime/src/scr_error.c | 6 +- packages/runtime/src/scr_error.h | 10 + packages/runtime/src/scr_fs_cp.c | 1464 +++++++++++++++++ packages/runtime/src/scr_fs_cp.h | 22 + packages/runtime/src/scr_inspect.h | 6 + packages/runtime/src/scr_json.c | 33 +- packages/runtime/src/scr_lib.c | 65 +- packages/runtime/src/scr_lib.h | 14 + packages/runtime/src/scr_runtime.h | 1 + packages/runtime/test/bytes.test.ts | 1 + packages/runtime/test/cipher.test.ts | 1 + packages/runtime/test/runtime.test.ts | 1 + packages/runtime/test/sha-kernels.test.ts | 1 + tests/corpus/5567-fs-cpsync.ts | 116 ++ tests/corpus/5568-fs-cp-errors.js | 115 ++ tests/corpus/5569-fs-cp-symlinks.ts | 93 ++ tests/corpus/5570-fs-cp-async.ts | 101 ++ tests/corpus/5571-fs-cp-deep.ts | 41 + 46 files changed, 2391 insertions(+), 98 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/copy.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5567-fs-cpsync.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5568-fs-cp-errors.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5569-fs-cp-symlinks.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5570-fs-cp-async.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5571-fs-cp-deep.ts.json create mode 100644 packages/runtime/src/scr_fs_cp.c create mode 100644 packages/runtime/src/scr_fs_cp.h create mode 100644 tests/corpus/5567-fs-cpsync.ts create mode 100644 tests/corpus/5568-fs-cp-errors.js create mode 100644 tests/corpus/5569-fs-cp-symlinks.ts create mode 100644 tests/corpus/5570-fs-cp-async.ts create mode 100644 tests/corpus/5571-fs-cp-deep.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 2748caa0a..789a22632 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -76,6 +76,8 @@ title: "Limitations" ../limitations/builtin-apis/filesystem-flags.mdx +../limitations/builtin-apis/copy.mdx + ../limitations/builtin-apis/glob.mdx ../limitations/builtin-apis/filehandle-and-descriptor-i-o.mdx diff --git a/docs/content/limitations/builtin-apis/copy.mdx b/docs/content/limitations/builtin-apis/copy.mdx new file mode 100644 index 000000000..94bc6c696 --- /dev/null +++ b/docs/content/limitations/builtin-apis/copy.mdx @@ -0,0 +1 @@ +- **Copy:** `fs.cpSync`, `fs.cp`, and `fs/promises` `cp` support every option and follow Node's two implementations: `cpSync` reports the native binding's errors, and the callback and promise forms report `SystemError`s. Files, directories, and symbolic links are copied in directory order, as in Node. The callback and promise forms complete the copy before the callback runs or the promise settles. An async `filter` must return a promise that is already settled; a pending one throws `SC2020`. On Windows, the three functions throw `SC2020`. diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 90ce9fddb..26450d598 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -15,4 +15,4 @@ Other immutable aliases of table-backed Node builtins support direct calls, but Synchronous and promise filesystem APIs with trailing arguments that affect behavior remain call-only. This includes writers, open, directory creation/removal, metadata readers, and directory readers. Passing them as values could discard modes, flags, encodings, or options during function-signature adaptation. -Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy supports regular files and recursive directories. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Symbolic-link copy, copy filters and clone modes, recursive directory reads, and Dirent results report a native limitation. `fs.glob` compiles only as a direct call; a stored `fs.glob` value throws `SC2020`. +Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy follows `fs.cp`. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Recursive directory reads and Dirent results report a native limitation. `fs.glob` compiles only as a direct call; a stored `fs.glob` value throws `SC2020`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 14b42ba7f..cbea47e40 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "c51180a4672492a73f1d", + "artifactVersion": "27e9eb6c8c27f2fb7bb6", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index ec30a1c1b..31f74dbe0 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -314,9 +314,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 195, + "partial": 198, "refused": 0, - "not-implemented": 78, + "not-implemented": 75, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -29667,9 +29667,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -30449,9 +30449,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -31714,9 +31714,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 1db4326a5..76ab23dce 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3472, "tiers": { "static": { - "audit-partial": 1059, - "verify-gap": 2286, + "audit-partial": 1062, + "verify-gap": 2283, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -26218,12 +26218,17 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fsPromises.cp", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] } } }, @@ -27172,12 +27177,17 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.cp", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] } } }, @@ -28704,12 +28714,17 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.cpSync", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 180e0be8d..91d2ede17 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -326,9 +326,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 195, + "partial": 198, "refused": 0, - "not-implemented": 78, + "not-implemented": 75, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -44029,6 +44029,10 @@ "tests/corpus/5548-fs-callback-arguments.cjs", "tests/corpus/5549-fs-callback-option-checks.cjs", "tests/corpus/5554-fs-streams-typed.ts", + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts", "tests/corpus/fs-native-realpath.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", @@ -45075,8 +45079,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fsPromises.cp", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] }, "anchorSource": "exact" }, @@ -46035,8 +46045,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.cp", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] }, "anchorSource": "exact" }, @@ -47663,8 +47679,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.cpSync", + "tests": [ + "tests/corpus/5567-fs-cpsync.ts", + "tests/corpus/5568-fs-cp-errors.js", + "tests/corpus/5569-fs-cp-symlinks.ts", + "tests/corpus/5570-fs-cp-async.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 4a46aec53..ef35792d7 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -313,6 +313,7 @@ { "chapter": "child_process", "signatures": ["`stdin` Type: {stream.Writable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2894-child-stdin-backpressure.ts", "tests/corpus/2895-child-stdin-write-after-end.ts", "tests/corpus/2896-child-stdin-write-after-finish.ts", "tests/corpus/2897-child-stdin-destroy.ts", "tests/corpus/2898-child-stdin-spawn-failure.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.lstat(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, { "chapter": "fs", "signatures": ["`fs.globSync(pattern[, options])`", "`fs.glob(pattern[, options], callback)`", "`fsPromises.glob(pattern[, options])`"], "status": "partial", "evidence": ["tests/corpus/5448-fs-glob-patterns.ts", "tests/corpus/5449-fs-glob-options.ts", "tests/corpus/5450-fs-glob-validation.js", "tests/corpus/5451-fs-promises-glob.ts", "tests/corpus/5452-fs-glob-callback.ts", "tests/fixtures/node-types/fs-glob.ts"] }, + { "chapter": "fs", "signatures": ["`fs.cpSync(src, dest[, options])`", "`fs.cp(src, dest[, options], callback)`", "`fsPromises.cp(src, dest[, options])`"], "status": "partial", "evidence": ["tests/corpus/5567-fs-cpsync.ts", "tests/corpus/5568-fs-cp-errors.js", "tests/corpus/5569-fs-cp-symlinks.ts", "tests/corpus/5570-fs-cp-async.ts"] }, { "chapter": "process", "signatures": ["`process.send(message[, sendHandle[, options]][, callback])`", "`process.disconnect()`", "`connected` Type: {boolean}", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts"] }, { "chapter": "fs", "symbols": ["fs.Stats", "stats.isBlockDevice", "stats.isCharacterDevice", "stats.isDirectory", "stats.isFIFO", "stats.isFile", "stats.isSocket", "stats.isSymbolicLink"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, { "chapter": "fs", "parent": "Class: `fs.Stats`", "signatures": ["`mode` Type: {number|bigint}", "`nlink` Type: {number|bigint}", "`uid` Type: {number|bigint}", "`gid` Type: {number|bigint}", "`rdev` Type: {number|bigint}", "`size` Type: {number|bigint}", "`blksize` Type: {number|bigint}", "`blocks` Type: {number|bigint}", "`atimeMs` Type: {number|bigint}", "`mtimeMs` Type: {number|bigint}", "`birthtimeMs` Type: {number|bigint}", "`atimeNs` Type: {bigint}", "`mtimeNs` Type: {bigint}", "`ctimeNs` Type: {bigint}", "`birthtimeNs` Type: {bigint}", "`atime` Type: {Date}", "`mtime` Type: {Date}", "`ctime` Type: {Date}", "`birthtime` Type: {Date}"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 49dbf948f..07234a146 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1251,6 +1251,45 @@ declare module "node:fs" { path: string | Uint8Array | URL, type?: string | null, ): void; + /* fs.cpSync / fs.cp: copies a file, symlink or (with recursive) a + * directory tree. filter decides per path; the callback and promise + * forms complete the copy before reporting. */ + export interface CopySyncOptions { + dereference?: boolean; + errorOnExist?: boolean; + filter?: (source: string, destination: string) => boolean; + force?: boolean; + mode?: number; + preserveTimestamps?: boolean; + recursive?: boolean; + verbatimSymlinks?: boolean; + } + export interface CopyOptions { + dereference?: boolean; + errorOnExist?: boolean; + filter?: (source: string, destination: string) => boolean | Promise; + force?: boolean; + mode?: number; + preserveTimestamps?: boolean; + recursive?: boolean; + verbatimSymlinks?: boolean; + } + export function cpSync( + source: string | URL, + destination: string | URL, + options?: CopySyncOptions, + ): void; + export function cp( + source: string | URL, + destination: string | URL, + callback: (err: NodeJS.ErrnoException | null) => void, + ): void; + export function cp( + source: string | URL, + destination: string | URL, + options: CopyOptions, + callback: (err: NodeJS.ErrnoException | null) => void, + ): void; export function symlink( target: string | Uint8Array | URL, path: string | Uint8Array | URL, @@ -1441,6 +1480,9 @@ declare module "node:fs" { readonly O_NONBLOCK: number; readonly O_TRUNC: number; readonly O_APPEND: number; + readonly COPYFILE_EXCL: number; + readonly COPYFILE_FICLONE: number; + readonly COPYFILE_FICLONE_FORCE: number; }; /* A stat(2) snapshot (statSync follows symlinks, lstatSync does not — * Node's split) — immutable; the supported surface is exactly these @@ -1789,6 +1831,11 @@ declare module "fs/promises" { path: string | Uint8Array | URL, type?: string | null, ): Promise; + export function cp( + source: string | URL, + destination: string | URL, + options?: import("node:fs").CopyOptions, + ): Promise; export function readlink( path: string | Uint8Array | URL, options: "buffer" | { encoding: "buffer" }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c4434833c..f529095f8 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -460,6 +460,7 @@ export const LIB_FN_SYMS: Record = { "fs.writevSync": "scr_fs_writev_sync", "fs.linkSync": "scr_fs_link_checked", "fs.symlinkSync": "scr_fs_symlink_checked", + "fs.cpSync": "scr_fs_cp_sync", "fs.readlinkSync": "scr_fs_readlink_dyn", "fs.statfsSync": "scr_fs_statfs_checked", "fs.readlinkSyncStr": "scr_fs_readlink_str", @@ -711,6 +712,7 @@ export const LIB_FN_SYMS: Record = { "fsp.rmdir": "scr_fsp_rmdir", "fsp.link": "scr_fsp_link", "fsp.symlink": "scr_fsp_symlink", + "fsp.cp": "scr_fsp_cp", "fsp.readlink": "scr_fsp_readlink_dyn", "fsp.statfs": "scr_fsp_statfs", "fsp.readlinkStr": "scr_fsp_readlink_str", diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index 81f5839f5..88f049b7c 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -46,6 +46,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [ "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 35c3a23bf..754eb6aa9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -106,6 +106,26 @@ export function lowerFsLinkOrStatfsCall( return { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; } +/** fs.cpSync / fs/promises cp: src, dest and the optional options cross + * as dyn values (an omitted argument is undefined); scr_fs_cp_begin + * validates them in Node's order. */ +export function lowerFsCpCall( + lowerer: Lowerer, + expr: ts.CallExpression, + fn: BuiltinModuleFn, + loc: SrcLoc, + name: string, +): IrExpr { + if (expr.arguments.length > 3 || expr.arguments.some(ts.isSpreadElement)) + lowerer.noLowering(`${name} with this argument shape`, expr); + const args = [0, 1, 2].map((index) => + expr.arguments[index] + ? lowerer.lowerExprExpecting(expr.arguments[index]!, DYN) + : dynUndefinedExpr(loc), + ); + return { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; +} + export function lowerFsTimestampCall( lowerer: Lowerer, expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index c711fdbca..fac19ca97 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -4,6 +4,7 @@ import { lowerDiagnosticsChannelCall } from "./async-context.js"; import { lowerTimersPromisesCall } from "./timers.js"; import { lowerFsCallbackCall, + lowerFsCpCall, lowerFsLinkOrStatfsCall, lowerFsTimestampCall, lowerFsVectorOrTruncateCall, @@ -72,6 +73,11 @@ export function lowerBuiltinModuleCall( if (fn.fn === "fs.callbackCall" && bi.member !== "rename") { return lowerFsCallbackCall(lowerer, expr, bi, loc); } + if ( + (bi.module === "fs" && bi.member === "cpSync") || + (bi.module === "fs/promises" && bi.member === "cp") + ) + return lowerFsCpCall(lowerer, expr, fn, loc, name); if ( (bi.module === "fs" && ["linkSync", "symlinkSync", "readlinkSync", "statfsSync"].includes(bi.member)) || diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index c83a071c3..f0ac1c8d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -793,6 +793,10 @@ export function registerBuiltinErrorClasses(lowerer: Lowerer): void { { name: "%systemCall", type: STRING }, { name: "%systemPath", type: STRING }, { name: "%systemDest", type: STRING }, + { name: "%systemMessage", type: STRING }, + { name: "%systemCode", type: STRING }, + { name: "%nativePlain", type: BOOL }, + { name: "%errnoOrder", type: BOOL }, ], loc, }, diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6f54713f1..e68db2fb1 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1222,6 +1222,16 @@ export const BUILTIN_MODULE_FNS: Record< result: VOID, valueParams: exactValueParams(DYN, DYN), }, + // Options and paths are checked by the runtime (scr_fs_cp_begin). + cpSync: { + fn: "fs.cpSync", + params: [DYN, DYN, DYN], + result: VOID, + valueParams: [ + ...exactValueParams(DYN, DYN), + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, symlinkSync: { fn: "fs.symlinkSync", params: [DYN, DYN, DYN], @@ -1362,6 +1372,15 @@ export const BUILTIN_MODULE_FNS: Record< result: { kind: "promise", inner: VOID }, valueParams: exactValueParams(DYN, DYN), }, + cp: { + fn: "fsp.cp", + params: [DYN, DYN, DYN], + result: { kind: "promise", inner: VOID }, + valueParams: [ + ...exactValueParams(DYN, DYN), + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, symlink: { fn: "fsp.symlink", params: [DYN, DYN, DYN], diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index f9f80bec2..f14b7e446 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -810,6 +810,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.writevSync", "fs.linkSync", "fs.symlinkSync", + "fs.cpSync", "fs.readlinkSync", "fs.statfsSync", "fs.readlinkSyncStr", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 43940e2d8..aa4fac3d3 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -979,6 +979,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "fs.writevSync": { argTypes: [F64, arrayOf(BYTES_U8), F64], result: F64 }, "fs.linkSync": { argTypes: [DYN, DYN], result: VOID }, "fs.symlinkSync": { argTypes: [DYN, DYN, DYN], result: VOID }, + /** fs.cpSync / fs/promises cp (scr_fs_cp.c): src, dest and options are + * dyn values the runtime validates in Node's order. */ + "fs.cpSync": { argTypes: [DYN, DYN, DYN], result: VOID }, "fs.readlinkSync": { argTypes: [DYN, DYN], result: DYN }, "fs.statfsSync": { argTypes: [DYN, DYN], result: DYN }, "fs.readlinkSyncStr": { argTypes: [DYN, DYN], result: STRING }, @@ -2683,6 +2686,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "fsp.rmdir": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, "fsp.link": { argTypes: [DYN, DYN], result: { kind: "promise", inner: VOID } }, "fsp.symlink": { argTypes: [DYN, DYN, DYN], result: { kind: "promise", inner: VOID } }, + "fsp.cp": { argTypes: [DYN, DYN, DYN], result: { kind: "promise", inner: VOID } }, "fsp.readlink": { argTypes: [DYN, DYN], result: { kind: "promise", inner: DYN } }, "fsp.statfs": { argTypes: [DYN, DYN], result: { kind: "promise", inner: DYN } }, "fsp.readlinkStr": { argTypes: [DYN, DYN], result: { kind: "promise", inner: STRING } }, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 33cc6acb6..6e84f82e9 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -867,6 +867,12 @@ "name": "fs.copyFileSync", "status": "static" }, + { + "id": "node-builtin.fs.cpSync", + "kind": "node-builtin", + "name": "fs.cpSync", + "status": "static" + }, { "id": "node-builtin.fs.createReadStream", "kind": "node-builtin", @@ -1018,6 +1024,12 @@ "name": "fs/promises.copyFile", "status": "static" }, + { + "id": "node-builtin.fs.promises.cp", + "kind": "node-builtin", + "name": "fs/promises.cp", + "status": "static" + }, { "id": "node-builtin.fs.promises.glob", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5567-fs-cpsync.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5567-fs-cpsync.ts.json new file mode 100644 index 000000000..130adad26 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5567-fs-cpsync.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5567-fs-cpsync.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5568-fs-cp-errors.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5568-fs-cp-errors.js.json new file mode 100644 index 000000000..9121a3a62 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5568-fs-cp-errors.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5568-fs-cp-errors.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5569-fs-cp-symlinks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5569-fs-cp-symlinks.ts.json new file mode 100644 index 000000000..c59a078f7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5569-fs-cp-symlinks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5569-fs-cp-symlinks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5570-fs-cp-async.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5570-fs-cp-async.ts.json new file mode 100644 index 000000000..2e9a401c6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5570-fs-cp-async.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5570-fs-cp-async.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5571-fs-cp-deep.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5571-fs-cp-deep.ts.json new file mode 100644 index 000000000..435930b9f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5571-fs-cp-deep.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5571-fs-cp-deep.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 1ffaaba1b..7b04ccbf8 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -21,6 +21,7 @@ const BASE_RUNTIME_SOURCES = [ "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index f864bf422..8bf1532aa 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -19,6 +19,7 @@ const BASE_RUNTIME_SOURCES = [ "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 51d222f88..c671046e4 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -2311,6 +2311,25 @@ static bool scr_await_settled(ScrPromise *p) { return true; } +bool scr_promise_settled_now(ScrPromise *p) { + if (p->state == SCR_PROM_PENDING) return false; + scr_prom_observe(p); + if (p->state == SCR_PROM_REJECTED) scr_promise_rethrow(p); + return true; +} + +bool scr_promise_payload_truthy(const ScrPromise *p) { + switch (p->payload_kind) { + case SCR_EXC_NONE: return false; + case SCR_EXC_F64: return p->f64 != 0 && p->f64 == p->f64; + case SCR_EXC_BOOL: return p->b; + case SCR_EXC_STR: return p->payload && ((const ScrStr *)p->payload)->len > 0; + default: + if (p->retain_fn == scr_dyn_retain_v) return p->payload && scr_dyn_truthy(p->payload); + return p->payload != NULL; + } +} + double scr_await_f64(ScrPromise *p) { return scr_await_settled(p) ? p->f64 : 0; } diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index 2b916de1f..25de65cfe 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -318,6 +318,12 @@ bool scr_promise_payload_is_dyn(const ScrPromise *p); double scr_promise_payload_num(const ScrPromise *p); bool scr_promise_payload_flag(const ScrPromise *p); bool scr_promise_await_settled(ScrPromise *p); /* park/hop; false = rethrown */ +/* An already-settled promise's outcome without awaiting (no job hop, no + * parking): false while p is pending; otherwise true, with the rejection + * rethrown when p rejected. payload_truthy is the fulfillment value's + * JavaScript truthiness. */ +bool scr_promise_settled_now(ScrPromise *p); +bool scr_promise_payload_truthy(const ScrPromise *p); void scr_promise_payload_release(const ScrPromise *p, void *v); void scr_set_timeout(ScrClosure *cb, double ms); /* cb ownership moves in */ diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 674b6cce9..6fe829eeb 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -19,7 +19,6 @@ static const ScrArrOps scr_bytes_io_bytes_ops = { scr_bytes_retain_v, scr_bytes_ #include #include #include -#include #ifndef O_SYNC #define O_SYNC 0 /* Windows CRT opens have no separate synchronous flag. */ #endif @@ -331,6 +330,10 @@ static ScrStr *scr_fs_checked_path(const ScrDyn *value, const char *name) { return checked; } +ScrStr *scr_fs_validated_path(const ScrDyn *value, const char *name) { return scr_fs_checked_path(value, name); } + +ScrDyn *scr_fs_option_get(const ScrDyn *options, const char *name) { return scr_fs_cb_option(options, name); } + static ScrStr *scr_fs_timestamp_path(const ScrDyn *value) { return scr_fs_checked_path(value, "path"); } @@ -554,44 +557,34 @@ static ScrStr *scr_fs_cb_join(const ScrStr *directory, const char *name) { return path; } -static void scr_fs_cb_copy(const ScrStr *source, const ScrStr *destination, bool recursive, bool force, bool preserve) { - struct stat status; - if ( -#ifdef _WIN32 - stat(source->data, &status) -#else - lstat(source->data, &status) -#endif - < 0) { scr_fs_throw(errno, "stat", source); return; } -#ifndef _WIN32 - if (S_ISLNK(status.st_mode)) { - const char *message = "Symbolic-link cp entries have no native lowering"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); - return; - } -#endif - if (S_ISDIR(status.st_mode)) { - if (!recursive) { scr_fs_throw(EISDIR, "cp", source); return; } - scr_fs_mkdir_recursive_mode((ScrStr *)destination, status.st_mode & 0777); - if (scr_exc_pending()) return; - DIR *directory = opendir(source->data); - if (!directory) { scr_fs_throw(errno, "opendir", source); return; } - struct dirent *entry; - while (!scr_exc_pending() && (entry = readdir(directory))) { - if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; - ScrStr *from = scr_fs_cb_join(source, entry->d_name), *to = scr_fs_cb_join(destination, entry->d_name); - scr_fs_cb_copy(from, to, true, force, preserve); - scr_str_release(from); scr_str_release(to); - } - closedir(directory); - } else { - if (!force && scr_fs_exists((ScrStr *)destination)) return; - scr_fs_copyfile((ScrStr *)source, (ScrStr *)destination); - } - if (preserve && !scr_exc_pending()) { - struct utimbuf times = { status.st_atime, status.st_mtime }; - if (utime(destination->data, ×) < 0) scr_fs_throw(errno, "utime", destination); - } +static ScrDyn *scr_fs_cb_stat_test(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; (void)argc; + return scr_dyn_new_bool(scr_box_get_bool(closure->caps[0])); +} + +static void scr_fs_cb_stat_method(ScrDyn *object, const char *name, bool answer) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_BOOL); + scr_box_set_bool(closure->caps[0], answer); + scr_dyn_obj_set(object, name, strlen(name), scr_dyn_new_func(closure, scr_fs_cb_stat_test, 0, "native:fs.Stats", name)); +} + +static void scr_fs_cb_stat_number(ScrDyn *object, const char *name, int64_t number, bool bigint) { + ScrDyn *value; + if (bigint) { + ScrBigInt *integer = scr_bigint_from_i64(number); + value = scr_dyn_new_bigint(integer); + scr_bigint_release(integer); + } else value = scr_dyn_new_num((double)number); + scr_dyn_obj_set(object, name, strlen(name), value); +} + +static void scr_fs_cb_stat_date(ScrDyn *object, const char *name, double milliseconds) { + ScrDyn *arguments = scr_dyn_new_arr(); + scr_dyn_arr_push(arguments, scr_dyn_new_num(milliseconds)); + ScrDyn *date = scr_dyn_native_date_new(arguments); + scr_dyn_release(arguments); + if (date) scr_dyn_obj_set(object, name, strlen(name), date); } static ScrDyn *scr_fs_cb_stats(const ScrStr *path, double fd, const ScrDyn *options, bool nofollow) { @@ -765,6 +758,24 @@ static ScrArr *scr_fs_cb_vectors(const ScrDyn *buffers) { static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc) { const char *op = member->data; + if (!strcmp(op, "cp")) { + /* fs.cp(src, dest[, options], callback): makeCallback, then the option + * and path validation (thrown), then the copy, whose error reaches the + * callback as callbackify delivers it. */ + const ScrDyn *options = scr_fs_cb_arg(args, argc, 2); + const ScrDyn *callback = scr_fs_cb_arg(args, argc, 3); + if (options->kind == SCR_DYN_FUNC) { + callback = options; + options = scr_dyn_undefined(); + } + if (!scr_fs_cb_chk(callback, "cb")) return NULL; + ScrFsCp *job = scr_fs_cp_begin(scr_fs_cb_arg(args, argc, 0), scr_fs_cb_arg(args, argc, 1), options); + if (!job) return NULL; + scr_fs_cp_run(job, true); + scr_fs_cp_free(job); + scr_fs_cb_schedule(callback, scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()), NULL); + return scr_dyn_retain(scr_dyn_undefined()); + } if (!strcmp(op, "statfs")) { const ScrDyn *options = scr_fs_cb_arg(args, argc, 1); const ScrDyn *callback = options->kind == SCR_DYN_FUNC ? options : scr_fs_cb_arg(args, argc, 2); @@ -833,8 +844,8 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc if (vector && !scr_fs_int_range_chk(scr_fs_cb_arg(args, argc, 0), "fd", 0, 2147483647.0, ">= 0 && <= 2147483647")) return NULL; ScrArr *vectors = vector ? scr_fs_cb_vectors(scr_fs_cb_arg(args, argc, 1)) : NULL; if (vector && !vectors) return NULL; - /* cp, glob and rm keep the last argument as their callback. */ - bool legacy = !strcmp(op, "cp") || !strcmp(op, "glob") || !strcmp(op, "rm"); + /* glob and rm keep the last argument as their callback. */ + bool legacy = !strcmp(op, "glob") || !strcmp(op, "rm"); size_t slot = timestamp ? 3 : legacy ? (argc ? argc - 1 : 0) : scr_fs_cb_slot(op, args, argc); const ScrDyn *callback = scr_fs_cb_arg(args, argc, slot); /* These check their callback after the arguments Node validates first @@ -1020,15 +1031,6 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc else if (!strcmp(op, "open")) { double opened = scr_fs_cb_open(path, ARG(1), open_mode, "r"); if (!scr_exc_pending()) result = scr_dyn_new_num(opened); - } else if (!strcmp(op, "cp")) { - if (scr_fs_cb_refuse_option(ARG(2), "filter") || scr_fs_cb_refuse_option(ARG(2), "dereference") || scr_fs_cb_refuse_option(ARG(2), "verbatimSymlinks") || scr_fs_cb_refuse_option(ARG(2), "errorOnExist") || scr_fs_cb_refuse_option(ARG(2), "mode")) goto done; - ScrStr *destination = scr_fs_cb_path(ARG(1), "dest"); - if (destination) { - ScrDyn *force = scr_fs_cb_option(ARG(2), "force"); - scr_fs_cb_copy(path, destination, scr_fs_cb_option_bool(ARG(2), "recursive"), - force && (force->kind == SCR_DYN_UNDEF || scr_dyn_truthy(force)), scr_fs_cb_option_bool(ARG(2), "preserveTimestamps")); - scr_dyn_release(force); scr_str_release(destination); - } } else if (!strcmp(op, "glob")) { /* Direct fs.glob calls lower to scr_fs_glob_callback (scr_glob.c); a * stored fs.glob value is not supported. */ diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 8af5edcd2..169accdd2 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -52,6 +52,8 @@ static void scr_error_gcfree(void *obj) { scr_str_release(e->system_call); scr_str_release(e->system_path); scr_str_release(e->system_dest); + scr_str_release(e->system_message); + scr_str_release(e->system_code); if (e->error_cause && scr_error_cause_drop) scr_error_cause_drop(obj); scr_obj_free_note(); scr_cyc_free(e); @@ -101,6 +103,8 @@ static void scr_error_destroy(void *obj) { scr_str_release(e->system_call); scr_str_release(e->system_path); scr_str_release(e->system_dest); + scr_str_release(e->system_message); + scr_str_release(e->system_code); if (e->error_cause && scr_error_cause_drop) scr_error_cause_drop(obj); scr_obj_free_note(); if (scr_error_traced) scr_cyc_free(e); @@ -258,7 +262,7 @@ static ScrStr *scr_error_format(ScrError *e, ScrStr *name, ScrStr *message) { * the code-prefix test is exact for runtime-thrown errors; a user * assigning an ERR_* code onto a plain Error would bracket here where * Node would not (documented — user code owns that spelling). */ - bool assertion = name && e->code && e->code->len >= 4 && + bool assertion = !e->native_plain && name && e->code && e->code->len >= 4 && memcmp(e->code->data, "ERR_", 4) == 0; if (!name || name->len == 0) { return message ? scr_str_retain(message) : scr_str_new("", 0); diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index fd75333f3..b33b926e9 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -106,6 +106,12 @@ typedef struct ScrError { ScrStr *system_call; ScrStr *system_path; ScrStr *system_dest; + /* Node's SystemError (internal/errors.js): the context message and errno + * name behind `info`; NULL for every other error. */ + ScrStr *system_message; + ScrStr *system_code; + bool native_plain; /* a native binding's coded Error: "Name: message", no "[code]" */ + bool errno_order; /* ErrnoException key order: errno, code, path, syscall */ } ScrError; enum { @@ -149,6 +155,10 @@ typedef struct ScrDomException { ScrStr *system_call; ScrStr *system_path; ScrStr *system_dest; + ScrStr *system_message; + ScrStr *system_code; + bool native_plain; + bool errno_order; double dom_code; /* the WebIDL legacy code (0 when the name is off-table) */ bool has_cause; /* the options form carried a `cause` member */ struct ScrDyn *cause; /* owned; NULL when has_cause is false */ diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c new file mode 100644 index 000000000..189c42294 --- /dev/null +++ b/packages/runtime/src/scr_fs_cp.c @@ -0,0 +1,1464 @@ +/* fs.cpSync, fs.cp and fs/promises cp. + * + * Node runs two implementations, and this unit ports both: + * - cpSync: lib/internal/fs/cp/cp-sync.js over the fs binding's + * cpSyncCheckPaths, cpSyncOverrideFile and cpSyncCopyDir. Those + * helpers use std::filesystem, so their checks, error shapes ("Error: + * message" with a code, and ErrnoException's "EEXIST, File exists + * 'path'") and libstdc++'s copy_file, create_directory and + * weakly_canonical rules are reproduced here. + * - fs.cp and fs/promises cp: lib/internal/fs/cp/cp.js, whose copy + * errors are SystemErrors. + * Both walk directories in readdir order, as Node does. The promise and + * callback forms complete the copy before their promise settles or their + * callback runs. An async filter must return a promise that is already + * settled when it returns; a pending one throws SC2020. + * + * scr_fs_cp_begin runs validateCpOptions and getValidatedPath(src, dest) + * and answers the job (NULL with the exception pending); scr_fs_cp_run + * copies, leaving any copy error pending. */ +#include "scr_runtime.h" + +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#if defined(__linux__) +#include +#include +#include +#include +#endif +#endif + +struct ScrFsCp { + ScrStr *src, *dest; + bool dereference, error_on_exist, force, preserve, recursive, verbatim; + int mode; + ScrDyn *filter; /* owned; NULL when absent */ +}; + +/* ── validateCpOptions and getValidatedPath ───────────────────────────── */ + +static bool cp_bool_option(const ScrDyn *options, const char *key, bool fallback, bool *out) { + ScrDyn *value = scr_fs_option_get(options, key); + if (!value) return false; + bool ok = true; + if (value->kind == SCR_DYN_UNDEF) { + *out = fallback; + } else if (value->kind == SCR_DYN_BOOL) { + *out = value->v.b; + } else { + char name[48]; + snprintf(name, sizeof name, "options.%s", key); + scr_dyn_prop_type_fail(name, "of type boolean", value); + ok = false; + } + scr_dyn_release(value); + return ok; +} + +/* getValidMode(options.mode, 'copyFile'): nullish selects 0, otherwise an + * integer in [0, 7]. */ +static bool cp_mode_option(const ScrDyn *options, int *out) { + ScrDyn *value = scr_fs_option_get(options, "mode"); + if (!value) return false; + bool ok = true; + *out = 0; + if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL) { + /* the default */ + } else if (value->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("mode", "of type number", value); + ok = false; + } else { + double mode = value->v.num; + char received[48]; + char text[160]; + if (mode != mode || mode - (double)(long long)mode != 0 || mode > 1e18 || mode < -1e18) { + scr_num_received(mode, received); + int n = snprintf(text, sizeof text, "The value of \"mode\" is out of range. It must be an integer. Received %s", received); + scr_throw_error_msg_code(SCR_ERR_RANGE, text, (size_t)n, "ERR_OUT_OF_RANGE"); + ok = false; + } else if (mode < 0 || mode > 7) { + scr_num_received(mode, received); + int n = snprintf(text, sizeof text, "The value of \"mode\" is out of range. It must be >= 0 && <= 7. Received %s", received); + scr_throw_error_msg_code(SCR_ERR_RANGE, text, (size_t)n, "ERR_OUT_OF_RANGE"); + ok = false; + } else { + *out = (int)mode; + } + } + scr_dyn_release(value); + return ok; +} + +void scr_fs_cp_free(ScrFsCp *job) { + if (!job) return; + scr_str_release(job->src); + scr_str_release(job->dest); + scr_dyn_release(job->filter); + free(job); +} + +ScrFsCp *scr_fs_cp_begin(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options) { + ScrFsCp *job = calloc(1, sizeof *job); + if (!job) scr_trap("scriptc: out of memory\n"); + job->force = true; + bool ok = true; + if (options->kind != SCR_DYN_UNDEF) { + /* validateObject(options, 'options') */ + bool object = (options->kind == SCR_DYN_OBJ || options->kind == SCR_DYN_TYPED_REF || options->kind == SCR_DYN_HANDLE); + if (!object) { + scr_dyn_arg_type_fail("options", "of type object", options); + ok = false; + } + ok = ok && cp_bool_option(options, "dereference", false, &job->dereference); + ok = ok && cp_bool_option(options, "errorOnExist", false, &job->error_on_exist); + ok = ok && cp_bool_option(options, "force", true, &job->force); + ok = ok && cp_bool_option(options, "preserveTimestamps", false, &job->preserve); + ok = ok && cp_bool_option(options, "recursive", false, &job->recursive); + ok = ok && cp_bool_option(options, "verbatimSymlinks", false, &job->verbatim); + ok = ok && cp_mode_option(options, &job->mode); + if (ok && job->dereference && job->verbatim) { + static const char message[] = "Option \"dereference\" cannot be used in combination with option \"verbatimSymlinks\""; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INCOMPATIBLE_OPTION_PAIR"); + ok = false; + } + if (ok) { + ScrDyn *filter = scr_fs_option_get(options, "filter"); + ok = filter != NULL; + if (ok && filter->kind != SCR_DYN_UNDEF) { + if (filter->kind != SCR_DYN_FUNC) { + scr_dyn_prop_type_fail("options.filter", "of type function", filter); + ok = false; + } else { + job->filter = scr_dyn_retain(filter); + } + } + scr_dyn_release(filter); + } + } + if (ok) job->src = scr_fs_validated_path(src, "src"); + if (ok && job->src) job->dest = scr_fs_validated_path(dest, "dest"); + if (!ok || !job->src || !job->dest || scr_exc_pending()) { + scr_fs_cp_free(job); + return NULL; + } + return job; +} + +#ifdef _WIN32 + +void scr_fs_cp_run(ScrFsCp *job, bool promise_form) { + (void)job; + (void)promise_form; + static const char message[] = "fs.cp has no native lowering on Windows"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); +} + +#else + +/* ── paths ──────────────────────────────────────────────────────────── */ + +static ScrStr *cp_str(const char *s) { return scr_str_new(s, strlen(s)); } + +static ScrStr *cp_path2(const ScrStr *a, const ScrStr *b, bool resolve) { + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 2); + scr_arr_push_ref(parts, scr_str_retain((ScrStr *)a)); + scr_arr_push_ref(parts, scr_str_retain((ScrStr *)b)); + ScrStr *out = resolve ? scr_path_resolve(parts) : scr_path_join(parts); + scr_arr_release(parts); + return out; +} + +/* path.join and path.resolve (+1). */ +static ScrStr *cp_join(const ScrStr *a, const ScrStr *b) { return cp_path2(a, b, false); } + +static ScrStr *cp_resolve(const ScrStr *a, const ScrStr *b) { return cp_path2(a, b, true); } + +static ScrStr *cp_resolve1(const ScrStr *a) { + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 1); + scr_arr_push_ref(parts, scr_str_retain((ScrStr *)a)); + ScrStr *out = scr_path_resolve(parts); + scr_arr_release(parts); + return out; +} + +static bool cp_is_absolute(const ScrStr *p) { return p->len > 0 && p->data[0] == '/'; } + +/* std::filesystem::path::operator/ (POSIX). */ +static ScrStr *fs_append(const ScrStr *a, const char *b) { + size_t bn = strlen(b); + if (bn && b[0] == '/') return scr_str_new(b, bn); + if (a->len == 0) return scr_str_new(b, bn); + bool slash = a->data[a->len - 1] == '/'; + size_t n = a->len + (slash ? 0 : 1) + bn; + char *buf = malloc(n + 1); + if (!buf) scr_trap("scriptc: out of memory\n"); + memcpy(buf, a->data, a->len); + size_t at = a->len; + if (!slash) buf[at++] = '/'; + memcpy(buf + at, b, bn); + ScrStr *out = scr_str_new(buf, n); + free(buf); + return out; +} + +/* std::filesystem::path::parent_path (POSIX): the path without its last + * element and the separators before it; a root-only path is its own + * parent; "a/b/" answers "a/b". */ +static ScrStr *fs_parent(const ScrStr *p) { + size_t n = p->len; + const char *d = p->data; + size_t root = n > 0 && d[0] == '/' ? 1 : 0; + while (root < n && d[root] == '/') root++; + if (root == n) return scr_str_new(d, root ? 1 : 0); + size_t last; + if (d[n - 1] == '/') { + last = n; + } else { + last = n; + while (last > 0 && d[last - 1] != '/') last--; + } + size_t end = last; + while (end > 0 && d[end - 1] == '/') end--; + if (end == 0 && root) end = 1; + return scr_str_new(d, end); +} + +static bool fs_has_parent(const ScrStr *p) { + ScrStr *parent = fs_parent(p); + bool has = parent->len > 0; + scr_str_release(parent); + return has; +} + +static bool cp_str_eq(const ScrStr *a, const ScrStr *b) { + return a->len == b->len && memcmp(a->data, b->data, a->len) == 0; +} + +static ScrStr *cp_cwd(void) { + char *buf = malloc(PATH_MAX); + if (!buf) scr_trap("scriptc: out of memory\n"); + ScrStr *cwd = cp_str(getcwd(buf, PATH_MAX) ? buf : ""); + free(buf); + return cwd; +} + +/* std::filesystem::absolute: the working directory joined in front of a + * relative path, without normalization. */ +static ScrStr *fs_absolute(const ScrStr *p) { + if (cp_is_absolute(p)) return scr_str_retain((ScrStr *)p); + ScrStr *cwd = cp_cwd(); + ScrStr *out = p->len ? fs_append(cwd, p->data) : scr_str_retain(cwd); + scr_str_release(cwd); + return out; +} + +/* The elements of a POSIX path as std::filesystem iterates them: the root + * "/", then each name; a trailing separator adds an empty element. */ +typedef struct { + char **v; + size_t n; +} FsParts; + +static void fs_parts_free(FsParts *parts) { + for (size_t i = 0; i < parts->n; i++) free(parts->v[i]); + free(parts->v); +} + +static void fs_parts_push(FsParts *parts, const char *s, size_t n) { + parts->v = realloc(parts->v, (parts->n + 1) * sizeof *parts->v); + if (!parts->v) scr_trap("scriptc: out of memory\n"); + char *copy = malloc(n + 1); + if (!copy) scr_trap("scriptc: out of memory\n"); + memcpy(copy, s, n); + copy[n] = 0; + parts->v[parts->n++] = copy; +} + +static FsParts fs_split(const ScrStr *p) { + FsParts parts = { NULL, 0 }; + size_t i = 0, n = p->len; + const char *d = p->data; + if (n && d[0] == '/') { + fs_parts_push(&parts, "/", 1); + while (i < n && d[i] == '/') i++; + } + while (i < n) { + size_t start = i; + while (i < n && d[i] != '/') i++; + fs_parts_push(&parts, d + start, i - start); + if (i < n) { + while (i < n && d[i] == '/') i++; + if (i == n) fs_parts_push(&parts, "", 0); + } + } + return parts; +} + +/* The C++ binding's isInsideDir: absolute(src)'s non-empty elements are a + * prefix of absolute(dest)'s. */ +static bool fs_inside_dir(const ScrStr *src, const ScrStr *dest) { + ScrStr *a = fs_absolute(src), *b = fs_absolute(dest); + FsParts pa = fs_split(a), pb = fs_split(b); + size_t na = 0, nb = 0; + for (size_t i = 0; i < pa.n; i++) na += pa.v[i][0] != 0; + for (size_t i = 0; i < pb.n; i++) nb += pb.v[i][0] != 0; + bool inside = na <= nb; + for (size_t i = 0, j = 0; inside && i < pa.n; i++) { + if (!pa.v[i][0]) continue; + while (j < pb.n && !pb.v[j][0]) j++; + inside = j < pb.n && strcmp(pa.v[i], pb.v[j]) == 0; + j++; + } + fs_parts_free(&pa); + fs_parts_free(&pb); + scr_str_release(a); + scr_str_release(b); + return inside; +} + +/* path::lexically_normal (POSIX). */ +static ScrStr *fs_lexically_normal(const ScrStr *p) { + FsParts parts = fs_split(p); + FsParts out = { NULL, 0 }; + bool rooted = parts.n && strcmp(parts.v[0], "/") == 0; + for (size_t i = rooted ? 1 : 0; i < parts.n; i++) { + const char *e = parts.v[i]; + if (strcmp(e, ".") == 0 || (e[0] == 0 && i + 1 < parts.n)) continue; + if (strcmp(e, "..") == 0) { + if (out.n && strcmp(out.v[out.n - 1], "..") != 0) { + free(out.v[--out.n]); + continue; + } + if (rooted) continue; + } + fs_parts_push(&out, e, strlen(e)); + } + size_t cap = 2; + for (size_t i = 0; i < out.n; i++) cap += strlen(out.v[i]) + 1; + char *buf = malloc(cap); + if (!buf) scr_trap("scriptc: out of memory\n"); + size_t at = 0; + if (rooted) buf[at++] = '/'; + for (size_t i = 0; i < out.n; i++) { + if (i) buf[at++] = '/'; + size_t len = strlen(out.v[i]); + memcpy(buf + at, out.v[i], len); + at += len; + } + if (at == 0) buf[at++] = '.'; + ScrStr *result = scr_str_new(buf, at); + free(buf); + fs_parts_free(&parts); + fs_parts_free(&out); + return result; +} + +static ScrStr *fs_realpath(const ScrStr *p) { + char *real = realpath(p->data, NULL); + if (!real) return NULL; + ScrStr *out = cp_str(real); + free(real); + return out; +} + +/* std::filesystem::weakly_canonical: the longest existing prefix resolved + * with realpath, the rest appended and lexically normalized. */ +static ScrStr *fs_weakly_canonical(const ScrStr *p) { + struct stat st; + if (stat(p->data, &st) == 0) { + ScrStr *real = fs_realpath(p); + if (real) return real; + } + FsParts parts = fs_split(p); + ScrStr *result = cp_str(""); + size_t i = 0; + for (; i < parts.n; i++) { + ScrStr *next = fs_append(result, parts.v[i]); + if (stat(next->data, &st) != 0) { + scr_str_release(next); + break; + } + scr_str_release(result); + result = next; + } + if (result->len) { + ScrStr *real = fs_realpath(result); + if (real) { + scr_str_release(result); + result = real; + } + } + for (; i < parts.n; i++) { + ScrStr *next = fs_append(result, parts.v[i]); + scr_str_release(result); + result = next; + } + ScrStr *normal = fs_lexically_normal(result); + scr_str_release(result); + fs_parts_free(&parts); + return normal; +} + +/* cp.js's isSrcSubdir: resolve(src)'s segments are a prefix of + * resolve(dest)'s. */ +static bool cp_is_src_subdir(const ScrStr *src, const ScrStr *dest) { + ScrStr *a = cp_resolve1(src), *b = cp_resolve1(dest); + FsParts pa = fs_split(a), pb = fs_split(b); + bool every = true; + size_t j = 0; + for (size_t i = 0; every && i < pa.n; i++) { + if (!pa.v[i][0] || strcmp(pa.v[i], "/") == 0) continue; + while (j < pb.n && (!pb.v[j][0] || strcmp(pb.v[j], "/") == 0)) j++; + every = j < pb.n && strcmp(pa.v[i], pb.v[j]) == 0; + j++; + } + fs_parts_free(&pa); + fs_parts_free(&pb); + scr_str_release(a); + scr_str_release(b); + return every; +} + +/* ── errors ─────────────────────────────────────────────────────────── */ + +static void cp_native(const char *code, const char *format, const char *a, const char *b) { + size_t cap = strlen(format) + strlen(a) + (b ? strlen(b) : 0) + 1; + char *text = malloc(cap); + if (!text) scr_trap("scriptc: out of memory\n"); + int n = b ? snprintf(text, cap, format, a, b) : snprintf(text, cap, format, a); + scr_fs_throw_native_coded(code, text, (size_t)n); + free(text); +} + +static void cp_system(const char *code, const char *prefix, int e, const char *format, const char *a, + const char *b, const ScrStr *path) { + size_t cap = strlen(format) + strlen(a) + (b ? strlen(b) : 0) + 1; + char *text = malloc(cap); + if (!text) scr_trap("scriptc: out of memory\n"); + int n = b ? snprintf(text, cap, format, a, b) : snprintf(text, cap, format, a); + ScrStr *message = scr_str_new(text, (size_t)n); + free(text); + scr_fs_throw_system_error(code, prefix, e, "cp", message, path); + scr_str_release(message); +} + +static void cp_eexist(const ScrStr *dest) { + cp_system("ERR_FS_CP_EEXIST", "Target already exists", EEXIST, "%s already exists", dest->data, NULL, dest); +} + +/* ── the user filter ────────────────────────────────────────────────── */ + +/* Calls options.filter(src, dest); false with the exception pending when + * it threw (or returned a promise in the synchronous form). */ +static bool cp_filter(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest, bool promise_form, bool *copy) { + ScrDyn *args[2] = { scr_dyn_new_str((ScrStr *)src), scr_dyn_new_str((ScrStr *)dest) }; + ScrDyn *result = scr_dyn_call(job->filter, args, 2, "options.filter"); + scr_dyn_release(args[0]); + scr_dyn_release(args[1]); + if (!result) return false; + bool ok = true; + if (result->kind == SCR_DYN_PROMISE) { + if (!promise_form) { + static const char message[] = "Expected boolean to be returned from the \"filter\" function but got an instance of Promise."; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_RETURN_VALUE"); + ok = false; + } else if (!scr_promise_settled_now(result->v.promise)) { + static const char message[] = "fs.cp filter promises that are still pending have no native lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + ok = false; + } else if (scr_exc_pending()) { + ok = false; + } else { + *copy = scr_promise_payload_truthy(result->v.promise); + } + } else { + *copy = scr_dyn_truthy(result); + } + scr_dyn_release(result); + return ok; +} + +/* ── file operations ────────────────────────────────────────────────── */ + +static int cp_stat(const ScrStr *path, bool follow, struct stat *st) { + return follow ? stat(path->data, st) : lstat(path->data, st); +} + +/* Copies in's remaining bytes to out; 0 or the errno. Linux copies in the + * kernel with sendfile (copy_file_range, which can share extents, took + * about twice as long per small file on xfs); otherwise, or when the + * kernel refuses, a read/write loop over a heap buffer (directory copies + * run deep, sometimes on a fiber's stack). */ +static int cp_copy_bytes(int in, int out) { +#if defined(__linux__) + for (bool first = true;; first = false) { + ssize_t n = sendfile(out, in, NULL, (size_t)1 << 30); + if (n > 0) continue; + if (n == 0) return 0; + if (errno == EINTR) continue; + if (!first || (errno != EINVAL && errno != ENOSYS)) return errno; + break; + } +#endif + enum { CP_CHUNK = 65536 }; + char *buf = malloc(CP_CHUNK); + if (!buf) scr_trap("scriptc: out of memory\n"); + int e = 0; + while (!e) { + ssize_t got = read(in, buf, CP_CHUNK); + if (got < 0) { + if (errno == EINTR) continue; + e = errno; + break; + } + if (got == 0) break; + for (ssize_t at = 0; at < got;) { + ssize_t wrote = write(out, buf + at, (size_t)(got - at)); + if (wrote < 0) { + if (errno == EINTR) continue; + e = errno; + break; + } + at += wrote; + } + } + free(buf); + return e; +} + +/* uv_fs_copyfile: the destination is created (or truncated) and takes the + * source's mode; COPYFILE_EXCL fails on an existing destination and + * COPYFILE_FICLONE_FORCE requires a reflink. Copying a file onto itself + * succeeds without touching it. Errors use `syscall` with both paths. */ +static void cp_copyfile(const ScrStr *src, const ScrStr *dest, int mode, const char *syscall) { + int in = open(src->data, O_RDONLY); + if (in < 0) { + scr_fs_throw2(errno, syscall, src, dest); + return; + } + struct stat sst; + if (fstat(in, &sst) != 0) { + int e = errno; + close(in); + scr_fs_throw2(e, syscall, src, dest); + return; + } + int flags = O_WRONLY | O_CREAT | ((mode & 1) ? O_EXCL : 0); + int out = open(dest->data, flags, sst.st_mode & 07777); + if (out < 0) { + int e = errno; + close(in); + scr_fs_throw2(e, syscall, src, dest); + return; + } + struct stat dst; + int e = 0; + if (fstat(out, &dst) != 0) e = errno; + else if (dst.st_dev == sst.st_dev && dst.st_ino == sst.st_ino) { + close(in); + close(out); + return; + } + if (!e && ftruncate(out, 0) != 0) e = errno; +#ifndef __wasi__ + if (!e && fchmod(out, sst.st_mode & 07777) != 0) e = errno; +#endif + bool cloned = false; + if (!e && (mode & 6)) { +#if defined(__linux__) && defined(FICLONE) + if (ioctl(out, FICLONE, in) == 0) cloned = true; + else if (mode & 4) e = errno; +#else + if (mode & 4) e = ENOSYS; +#endif + } + if (!e && !cloned) e = cp_copy_bytes(in, out); + close(in); + if (close(out) != 0 && !e) e = errno; + if (e) scr_fs_throw2(e, syscall, src, dest); +} + +/* utimes(dest, atime, mtime) in seconds, through the utimesSync path. */ +static bool cp_utime(const ScrStr *dest, double atime, double mtime) { + scr_fs_utimes((ScrStr *)dest, atime, mtime, false); + return !scr_exc_pending(); +} + +static double cp_stamp_ns(time_t sec, long nsec) { return (double)sec + (double)nsec / 1e9; } + +static double cp_stamp_ms(time_t sec, long nsec) { + /* A Stats Date: msFromTimeSpec rounded to whole milliseconds + * (dateFromMs), then toUnixTimestamp's seconds. */ + double ms = (double)sec * 1000 + (double)nsec / 1e6; + return round(ms) / 1000; +} + +#if defined(__APPLE__) +#define CP_ATIM(st) (st).st_atimespec +#define CP_MTIM(st) (st).st_mtimespec +#else +#define CP_ATIM(st) (st).st_atim +#define CP_MTIM(st) (st).st_mtim +#endif + +/* setDestTimestamps: statSync(src), then utimesSync(dest) with its Date + * values (millisecond precision). */ +static bool cp_set_dest_timestamps(const ScrStr *src, const ScrStr *dest) { + struct stat st; + if (stat(src->data, &st) != 0) { + scr_fs_throw(errno, "stat", src); + return false; + } + return cp_utime(dest, cp_stamp_ms(CP_ATIM(st).tv_sec, CP_ATIM(st).tv_nsec), + cp_stamp_ms(CP_MTIM(st).tv_sec, CP_MTIM(st).tv_nsec)); +} + +/* The binding's CopyUtimes: uv_fs_stat(src), then uv_fs_utime(dest) with + * nanosecond times. */ +static bool cp_copy_utimes(const ScrStr *src, const ScrStr *dest) { + struct stat st; + if (stat(src->data, &st) != 0) { + scr_fs_throw(errno, "stat", src); + return false; + } + return cp_utime(dest, cp_stamp_ns(CP_ATIM(st).tv_sec, CP_ATIM(st).tv_nsec), + cp_stamp_ns(CP_MTIM(st).tv_sec, CP_MTIM(st).tv_nsec)); +} + +static bool cp_chmod(const ScrStr *path, mode_t mode) { +#ifdef __wasi__ + (void)path; + (void)mode; + return true; +#else + if (chmod(path->data, mode & 07777) != 0) { + scr_fs_throw(errno, "chmod", path); + return false; + } + return true; +#endif +} + +static ScrStr *cp_readlink(const ScrStr *path, int *err) { + char *buf = malloc(PATH_MAX + 1); + if (!buf) scr_trap("scriptc: out of memory\n"); + ssize_t n = readlink(path->data, buf, PATH_MAX); + ScrStr *target = NULL; + if (n < 0) *err = errno; + else target = scr_str_new(buf, (size_t)n); + free(buf); + return target; +} + +/* mkdir -p with every error ignored (std::filesystem::create_directories + * with an error_code the caller drops). */ +static void cp_mkdir_parents(const ScrStr *dir) { + if (dir->len == 0) return; + char *buf = malloc(dir->len + 1); + if (!buf) scr_trap("scriptc: out of memory\n"); + memcpy(buf, dir->data, dir->len + 1); + for (size_t i = 1; i <= dir->len; i++) { + if (buf[i] != '/' && buf[i] != 0) continue; + char saved = buf[i]; + buf[i] = 0; + (void)mkdir(buf, 0777); + buf[i] = saved; + } + free(buf); +} + +/* A pending exception set aside while a `finally` block runs: the block's + * own error replaces it, as in JavaScript. */ +static void cp_finally_restore(ScrCaught *saved) { + if (!saved) return; + if (scr_exc_pending()) scr_caught_release(saved); + else { + scr_rethrow(saved); + scr_caught_release(saved); + } +} + +/* ── cpSync (cp-sync.js and the fs binding) ─────────────────────────── */ + +static void sync_get_stats(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest); + +/* std::filesystem::equivalent with an error_code: false when either path + * cannot be stat'ed. */ +static bool fs_equivalent(const ScrStr *a, const ScrStr *b) { + struct stat sa, sb; + if (stat(a->data, &sa) != 0 || stat(b->data, &sb) != 0) return false; + return sa.st_dev == sb.st_dev && sa.st_ino == sb.st_ino; +} + +/* fsBinding.cpSyncCheckPaths. Its stat calls are swapped relative to + * `dereference` (status for src when false), as in Node. */ +static bool sync_check_paths(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest) { + struct stat sst, dst; + if (cp_stat(src, !job->dereference, &sst) != 0) { + scr_fs_throw(errno, job->dereference ? "stat" : "lstat", src); + return false; + } + bool dest_ok = cp_stat(dest, job->dereference, &dst) == 0; + bool src_is_dir = S_ISDIR(sst.st_mode) || (job->dereference && S_ISLNK(sst.st_mode)); + if (dest_ok) { + if (fs_equivalent(src, dest)) { + cp_native("ERR_FS_CP_EINVAL", "src and dest cannot be the same %s", dest->data, NULL); + return false; + } + bool dest_is_dir = S_ISDIR(dst.st_mode); + if (src_is_dir && !dest_is_dir) { + cp_native("ERR_FS_CP_DIR_TO_NON_DIR", "Cannot overwrite non-directory %s with directory %s", dest->data, src->data); + return false; + } + if (!src_is_dir && dest_is_dir) { + cp_native("ERR_FS_CP_NON_DIR_TO_DIR", "Cannot overwrite directory %s with non-directory %s", dest->data, src->data); + return false; + } + } + /* The binding appends a separator to src before its prefix test. */ + ScrStr *src_slash; + if (src->len && src->data[src->len - 1] == '/') { + src_slash = scr_str_retain((ScrStr *)src); + } else { + ScrStr *separator = scr_str_new("/", 1); + src_slash = scr_str_concat((ScrStr *)src, separator); + scr_str_release(separator); + } + bool fail = false; + if (src_is_dir && dest->len >= src_slash->len && memcmp(dest->data, src_slash->data, src_slash->len) == 0) { + cp_native("ERR_FS_CP_EINVAL", "Cannot copy %s to a subdirectory of self %s", src_slash->data, dest->data); + fail = true; + } + if (!fail) { + ScrStr *dest_parent = fs_parent(dest); + ScrStr *src_parent = fs_parent(src); + ScrStr *grand = fs_parent(dest_parent); + if (!cp_str_eq(src_parent, dest_parent) && fs_has_parent(dest_parent) && !cp_str_eq(grand, dest_parent) && + fs_equivalent(src, dest_parent)) { + cp_native("ERR_FS_CP_EINVAL", "Cannot copy %s to a subdirectory of self %s", src_slash->data, dest->data); + fail = true; + } + scr_str_release(dest_parent); + scr_str_release(src_parent); + scr_str_release(grand); + } + if (!fail && src_is_dir && !job->recursive) { + cp_native("ERR_FS_EISDIR", "Recursive option not enabled, cannot copy a directory: %s", src_slash->data, NULL); + fail = true; + } + scr_str_release(src_slash); + if (fail) return false; + if (S_ISSOCK(sst.st_mode)) { + cp_native("ERR_FS_CP_SOCKET", "Cannot copy a socket file: %s", dest->data, NULL); + return false; + } + if (S_ISFIFO(sst.st_mode)) { + cp_native("ERR_FS_CP_FIFO_PIPE", "Cannot copy a FIFO pipe: %s", dest->data, NULL); + return false; + } + ScrStr *parent = fs_parent(dest); + struct stat pst; + if (!dest_ok || stat(parent->data, &pst) != 0) cp_mkdir_parents(parent); + scr_str_release(parent); + return true; +} + +enum { FS_COPY_SKIP, FS_COPY_OVERWRITE, FS_COPY_NONE }; + +static bool fs_copy_file(const ScrStr *from, const ScrStr *to, int existing, const ScrStr *error_path); + +/* fsBinding.cpSyncOverrideFile: std::filesystem::remove, then + * std::filesystem::copy_file (mode 0) or uv_fs_copyfile. */ +static void sync_override_file(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest) { + if (unlink(dest->data) != 0 && (errno != EISDIR || rmdir(dest->data) != 0)) { + scr_fs_throw_errno_exception(errno, "unlink", dest); + return; + } + if (job->mode == 0) { + if (!fs_copy_file(src, dest, FS_COPY_NONE, dest)) return; + } else { + cp_copyfile(src, dest, job->mode, "cp"); + if (scr_exc_pending()) return; + } + if (job->preserve) (void)cp_copy_utimes(src, dest); +} + +static void sync_copy_file(const ScrFsCp *job, const struct stat *sst, const ScrStr *src, const ScrStr *dest) { + cp_copyfile(src, dest, job->mode, "copyfile"); + if (scr_exc_pending()) return; + if (job->preserve) { + if ((sst->st_mode & 0200) == 0 && !cp_chmod(dest, sst->st_mode | 0200)) return; + if (!cp_set_dest_timestamps(src, dest)) return; + } + (void)cp_chmod(dest, sst->st_mode); +} + +static bool sync_copy_dir_contents(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest); + +/* libstdc++'s std::filesystem::copy_file(from, to, options, ec): the + * source's contents and mode into `to`; an existing `to` is skipped, + * overwritten or refused (file_exists) per `existing`, and a non-regular + * one is invalid_argument. The binding reports file_exists as + * ERR_FS_CP_EEXIST and any other error as an ErrnoException naming + * error_path. */ +static bool fs_copy_file(const ScrStr *from, const ScrStr *to, int existing, const ScrStr *error_path) { + struct stat tst, fst; + bool exists = stat(to->data, &tst) == 0; + if (stat(from->data, &fst) != 0) { + scr_fs_throw_errno_exception(errno, "cp", error_path); + return false; + } + if (exists) { + if (!S_ISREG(tst.st_mode)) { + scr_fs_throw_errno_exception(EINVAL, "cp", error_path); + return false; + } + bool same = tst.st_dev == fst.st_dev && tst.st_ino == fst.st_ino; + if (same || existing == FS_COPY_NONE) { + cp_native("ERR_FS_CP_EEXIST", "[ERR_FS_CP_EEXIST]: Target already exists: cp returned EEXIST (%s already exists)", to->data, NULL); + return false; + } + if (existing == FS_COPY_SKIP) return true; + } + int in = open(from->data, O_RDONLY); + int e = in < 0 ? errno : 0; + int out = -1; + if (!e) { + out = open(to->data, O_WRONLY | O_CREAT | (existing == FS_COPY_OVERWRITE ? O_TRUNC : O_EXCL), S_IWUSR); + if (out < 0) e = errno; + } +#ifndef __wasi__ + if (!e && fchmod(out, fst.st_mode & 07777) != 0) e = errno; +#endif + if (!e) e = cp_copy_bytes(in, out); + if (in >= 0) close(in); + if (out >= 0 && close(out) != 0 && !e) e = errno; + if (e) { + scr_fs_throw_errno_exception(e, "cp", error_path); + return false; + } + return true; +} + +/* A regular file inside cpSyncCopyDir. */ +static bool sync_copy_entry_file(const ScrFsCp *job, const ScrStr *from, const ScrStr *to, const ScrStr *dest_dir) { + int existing = job->force ? FS_COPY_OVERWRITE : job->error_on_exist ? FS_COPY_NONE : FS_COPY_SKIP; + if (!fs_copy_file(from, to, existing, dest_dir)) return false; + return !job->preserve || cp_copy_utimes(from, to); +} + +/* A symbolic link inside cpSyncCopyDir. */ +static bool sync_copy_entry_link(const ScrFsCp *job, const ScrStr *entry, const ScrStr *to, const ScrStr *src_dir, + const ScrStr *dest_dir) { + int err = 0; + ScrStr *target = cp_readlink(entry, &err); + if (!target) { + scr_fs_throw_errno_exception(err, "cp", dest_dir); + return false; + } + if (job->verbatim) { + bool ok = symlink(target->data, to->data) == 0; + if (!ok) scr_fs_throw_errno_exception(errno, "cp", dest_dir); + scr_str_release(target); + return ok; + } + struct stat tst, lst; + if (stat(to->data, &tst) == 0) { + if (lstat(to->data, &lst) == 0 && S_ISLNK(lst.st_mode)) { + ScrStr *current = cp_readlink(to, &err); + if (!current) { + scr_fs_throw_errno_exception(err, "cp", dest_dir); + scr_str_release(target); + return false; + } + struct stat ast; + bool fail = false; + if (!job->dereference && stat(target->data, &ast) == 0 && S_ISDIR(ast.st_mode) && fs_inside_dir(target, current)) { + cp_native("ERR_FS_CP_EINVAL", "Cannot copy \"%s\" to a subdirectory of self \"%s\"", target->data, current->data); + fail = true; + } else if (S_ISDIR(tst.st_mode) && fs_inside_dir(current, target)) { + cp_native("ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", "cannot overwrite \"%s\" with \"%s\"", current->data, target->data); + fail = true; + } else if (unlink(to->data) != 0) { + scr_fs_throw_errno_exception(errno, "cp", dest_dir); + fail = true; + } + scr_str_release(current); + if (fail) { + scr_str_release(target); + return false; + } + } else if (S_ISREG(tst.st_mode) && (!job->dereference || (!job->force && job->error_on_exist))) { + scr_fs_throw_errno_exception(EEXIST, "cp", to); + scr_str_release(target); + return false; + } + } + ScrStr *joined = fs_append(src_dir, target->data); + ScrStr *absolute = fs_absolute(joined); + ScrStr *canonical = fs_weakly_canonical(absolute); + bool ok = symlink(canonical->data, to->data) == 0; + if (!ok) scr_fs_throw_errno_exception(errno, "cp", dest_dir); + scr_str_release(joined); + scr_str_release(absolute); + scr_str_release(canonical); + scr_str_release(target); + return ok; +} + +/* An open directory in one of the walks below. The walks keep their + * frames on the heap, not the C stack: a directory tree is as deep as + * PATH_MAX allows, and the copy may run on a fiber's smaller stack. */ +typedef struct { + DIR *dir; + ScrStr *src, *dest; + bool flag; /* created (native walk), make (filtered), created (promise form) */ + mode_t mode; /* the source directory's mode */ +} CpFrame; + +typedef struct { + CpFrame *v; + size_t n, cap; +} CpFrames; + +static void cp_frames_push(CpFrames *frames, DIR *dir, const ScrStr *src, const ScrStr *dest, bool flag, mode_t mode) { + if (frames->n == frames->cap) { + frames->cap = frames->cap ? frames->cap * 2 : 16; + frames->v = realloc(frames->v, frames->cap * sizeof *frames->v); + if (!frames->v) scr_trap("scriptc: out of memory\n"); + } + frames->v[frames->n++] = (CpFrame){ dir, scr_str_retain((ScrStr *)src), scr_str_retain((ScrStr *)dest), flag, mode }; +} + +static void cp_frame_release(CpFrame *frame) { + if (frame->dir) closedir(frame->dir); + scr_str_release(frame->src); + scr_str_release(frame->dest); +} + +static void cp_frames_free(CpFrames *frames) { + while (frames->n) cp_frame_release(&frames->v[--frames->n]); + free(frames->v); +} + +/* cpSyncCopyDir's copy_dir_contents: each entry in readdir order, a + * subdirectory walked when it is reached, and a directory the walk + * created given its source's mode once its contents are in. */ +static bool sync_copy_dir_contents(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest) { + DIR *root = opendir(src->data); + if (!root) { + scr_fs_throw_errno_exception(errno, "cp", src); + return false; + } + CpFrames frames = { NULL, 0, 0 }; + cp_frames_push(&frames, root, src, dest, false, 0); + bool ok = true; + while (ok && frames.n) { + CpFrame *top = &frames.v[frames.n - 1]; + struct dirent *entry = readdir(top->dir); + if (!entry) { + CpFrame done = frames.v[--frames.n]; +#ifndef __wasi__ + if (done.flag && chmod(done.dest->data, done.mode & 07777) != 0) { + scr_fs_throw_errno_exception(errno, "cp", done.dest); + ok = false; + } +#endif + cp_frame_release(&done); + continue; + } + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + ScrStr *from = fs_append(top->src, entry->d_name); + ScrStr *to = fs_append(top->dest, entry->d_name); + /* directory_entry's cached type: d_type when the file system reports + * one, lstat otherwise (and for directories, whose mode is needed). */ + struct stat lst; + bool known = false; +#ifdef DT_REG + if (entry->d_type == DT_REG) { + lst.st_mode = S_IFREG; + known = true; + } else if (entry->d_type == DT_LNK) { + lst.st_mode = S_IFLNK; + known = true; + } +#endif + if (!known && lstat(from->data, &lst) != 0) { + scr_fs_throw_errno_exception(errno, "cp", top->dest); + ok = false; + } else if (S_ISLNK(lst.st_mode)) { + ok = sync_copy_entry_link(job, from, to, top->src, top->dest); + } else if (S_ISDIR(lst.st_mode)) { + bool created = mkdir(to->data, 0777) == 0; + struct stat tst; + if (!created && (errno != EEXIST || stat(to->data, &tst) != 0 || !S_ISDIR(tst.st_mode))) { + scr_fs_throw_errno_exception(errno == EEXIST ? EEXIST : errno, "cp", to); + ok = false; + } else { + DIR *child = opendir(from->data); + if (!child) { + scr_fs_throw_errno_exception(errno, "cp", from); + ok = false; + } else { + cp_frames_push(&frames, child, from, to, created, lst.st_mode); + } + } + } else if (S_ISREG(lst.st_mode)) { + ok = sync_copy_entry_file(job, from, to, top->dest); + } + scr_str_release(from); + scr_str_release(to); + } + cp_frames_free(&frames); + return ok; +} + +/* fsBinding.cpSyncCopyDir. */ +static void sync_copy_dir_native(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest) { + struct stat st; + bool existed = stat(dest->data, &st) == 0; + if (existed && !S_ISDIR(st.st_mode)) { + scr_fs_throw_errno_exception(ENOTDIR, "cp", dest); + return; + } + if (!existed) { + cp_mkdir_parents(dest); + if (stat(dest->data, &st) != 0) { + scr_fs_throw_errno_exception(errno, "cp", dest); + return; + } + } + if (!sync_copy_dir_contents(job, src, dest) || existed) return; +#ifndef __wasi__ + struct stat sst; + if (stat(src->data, &sst) == 0 && chmod(dest->data, sst.st_mode & 07777) != 0) + scr_fs_throw_errno_exception(errno, "cp", dest); +#endif +} + +static void sync_on_link(const ScrFsCp *job, bool has_dest, const ScrStr *src, const ScrStr *dest, bool promise_form); + +/* getStats for one path. A directory copied through the filter loop is + * opened as a frame for the caller to walk (mkdirSync and opendirSync + * done, true); everything else is handled here (false). */ +static bool sync_stats_one(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest, CpFrame *frame) { + struct stat sst, dst; + const char *op = job->dereference ? "stat" : "lstat"; + if (cp_stat(src, job->dereference, &sst) != 0) { + scr_fs_throw(errno, op, src); + return false; + } + bool has_dest = cp_stat(dest, job->dereference, &dst) == 0; + if (!has_dest && errno != ENOENT && errno != ENOTDIR) { + scr_fs_throw(errno, op, dest); + return false; + } + if (S_ISDIR(sst.st_mode) && job->recursive) { + if (has_dest && job->error_on_exist && !job->force) { + cp_eexist(dest); + return false; + } + if (!job->filter) { + sync_copy_dir_native(job, src, dest); + return false; + } + /* copyDir with a filter: mkdirSync, opendirSync, then the loop. */ + if (!has_dest && mkdir(dest->data, 0777) != 0) { + scr_fs_throw(errno, "mkdir", dest); + return false; + } + DIR *dir = opendir(src->data); + if (!dir) { + scr_fs_throw(errno, "opendir", src); + return false; + } + *frame = (CpFrame){ dir, scr_str_retain((ScrStr *)src), scr_str_retain((ScrStr *)dest), !has_dest, sst.st_mode }; + return true; + } + if (S_ISREG(sst.st_mode) || S_ISCHR(sst.st_mode) || S_ISBLK(sst.st_mode)) { + if (!has_dest) sync_copy_file(job, &sst, src, dest); + else if (job->force) sync_override_file(job, src, dest); + else if (job->error_on_exist) cp_eexist(dest); + } else if (S_ISLNK(sst.st_mode)) { + sync_on_link(job, has_dest, src, dest, false); + } else { + static const char message[] = + "Unreachable code\nThis is caused by either a bug in Node.js or incorrect usage of Node.js internals.\n" + "Please open an issue with this stack trace at https://github.com/nodejs/node/issues\n"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "ERR_INTERNAL_ASSERTION"); + } + return false; +} + +/* getStats, with cp-sync.js's filtered copyDir loop walked iteratively: + * each directory's `finally` (closeSync, the timestamps when + * preserveTimestamps, the source mode for a directory the loop created) + * runs when the directory ends, normally or with an error. */ +static void sync_get_stats(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest) { + CpFrame first; + if (!sync_stats_one(job, src, dest, &first)) return; + CpFrames frames = { NULL, 0, 0 }; + cp_frames_push(&frames, first.dir, first.src, first.dest, first.flag, first.mode); + scr_str_release(first.src); + scr_str_release(first.dest); + while (frames.n) { + CpFrame *top = &frames.v[frames.n - 1]; + struct dirent *entry = scr_exc_pending() ? NULL : readdir(top->dir); + if (!entry) { + CpFrame done = frames.v[--frames.n]; + closedir(done.dir); + done.dir = NULL; + ScrCaught *saved = scr_exc_pending() ? scr_exc_take() : NULL; + if (job->preserve) (void)cp_set_dest_timestamps(done.src, done.dest); + if (!scr_exc_pending() && done.flag) (void)cp_chmod(done.dest, done.mode); + cp_finally_restore(saved); + cp_frame_release(&done); + continue; + } + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + ScrStr *name = cp_str(entry->d_name); + ScrStr *from = cp_join(top->src, name); + ScrStr *to = cp_join(top->dest, name); + bool copy = true; + CpFrame child; + if (cp_filter(job, from, to, false, ©) && copy && sync_stats_one(job, from, to, &child)) { + cp_frames_push(&frames, child.dir, child.src, child.dest, child.flag, child.mode); + scr_str_release(child.src); + scr_str_release(child.dest); + } + scr_str_release(name); + scr_str_release(from); + scr_str_release(to); + } + cp_frames_free(&frames); +} + +/* onLink, shared by both implementations; they differ only in the stat + * that guards the symlink-to-subdirectory check. */ +static void sync_on_link(const ScrFsCp *job, bool has_dest, const ScrStr *src, const ScrStr *dest, bool promise_form) { + int err = 0; + ScrStr *resolved_src = cp_readlink(src, &err); + if (!resolved_src) { + scr_fs_throw(err, "readlink", src); + return; + } + if (!job->verbatim && !cp_is_absolute(resolved_src)) { + ScrStr *dir = scr_path_dirname((ScrStr *)src); + ScrStr *resolved = cp_resolve(dir, resolved_src); + scr_str_release(dir); + scr_str_release(resolved_src); + resolved_src = resolved; + } + struct stat ist; + bool src_is_dir = stat(src->data, &ist) == 0 && S_ISDIR(ist.st_mode); + if (has_dest) { + ScrStr *resolved_dest = cp_readlink(dest, &err); + if (!resolved_dest && err != EINVAL) { + scr_fs_throw(err, "readlink", dest); + scr_str_release(resolved_src); + return; + } + if (resolved_dest) { + if (!cp_is_absolute(resolved_dest)) { + ScrStr *dir = scr_path_dirname((ScrStr *)dest); + ScrStr *resolved = cp_resolve(dir, resolved_dest); + scr_str_release(dir); + scr_str_release(resolved_dest); + resolved_dest = resolved; + } + bool fail = false; + if (src_is_dir && cp_is_src_subdir(resolved_src, resolved_dest)) { + cp_system("ERR_FS_CP_EINVAL", "Invalid src or dest", EINVAL, "cannot copy %s to a subdirectory of self %s", + resolved_src->data, resolved_dest->data, dest); + fail = true; + } + struct stat gst; + const ScrStr *guard = promise_form ? src : dest; + if (!fail && stat(guard->data, &gst) != 0) { + scr_fs_throw(errno, "stat", guard); + fail = true; + } + if (!fail && S_ISDIR(gst.st_mode) && cp_is_src_subdir(resolved_dest, resolved_src)) { + cp_system("ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", "Cannot overwrite symlink in subdirectory of self", EINVAL, + "cannot overwrite %s with %s", resolved_dest->data, resolved_src->data, dest); + fail = true; + } + scr_str_release(resolved_dest); + if (!fail && unlink(dest->data) != 0) { + scr_fs_throw(errno, "unlink", dest); + fail = true; + } + if (fail) { + scr_str_release(resolved_src); + return; + } + } + } + if (symlink(resolved_src->data, dest->data) != 0) scr_fs_throw2(errno, "symlink", resolved_src, dest); + scr_str_release(resolved_src); +} + +static void cp_sync(const ScrFsCp *job) { + if (job->filter) { + bool copy = true; + if (!cp_filter(job, job->src, job->dest, false, ©) || !copy) return; + } + if (!sync_check_paths(job, job->src, job->dest)) return; + sync_get_stats(job, job->src, job->dest); +} + +/* ── fs.cp and fs/promises cp (cp.js) ───────────────────────────────── */ + +typedef struct { + bool skipped; + bool has_dest; + struct stat src, dest; +} CpChecked; + +static bool async_get_stats_for_copy(const ScrFsCp *job, bool has_dest, const ScrStr *src, const ScrStr *dest); + +/* checkPaths: the filter, then getStats and the identity, kind and + * subdirectory checks. */ +static bool async_check_paths(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest, CpChecked *out) { + out->skipped = false; + if (job->filter) { + bool copy = true; + if (!cp_filter(job, src, dest, true, ©)) return false; + if (!copy) { + out->skipped = true; + return true; + } + } + const char *op = job->dereference ? "stat" : "lstat"; + if (cp_stat(src, job->dereference, &out->src) != 0) { + scr_fs_throw(errno, op, src); + return false; + } + out->has_dest = cp_stat(dest, job->dereference, &out->dest) == 0; + if (!out->has_dest && errno != ENOENT) { + scr_fs_throw(errno, op, dest); + return false; + } + bool src_dir = S_ISDIR(out->src.st_mode); + if (out->has_dest) { + if (out->dest.st_ino && out->dest.st_dev && out->dest.st_ino == out->src.st_ino && + out->dest.st_dev == out->src.st_dev) { + cp_system("ERR_FS_CP_EINVAL", "Invalid src or dest", EINVAL, "%s", "src and dest cannot be the same", NULL, dest); + return false; + } + bool dest_dir = S_ISDIR(out->dest.st_mode); + if (src_dir && !dest_dir) { + cp_system("ERR_FS_CP_DIR_TO_NON_DIR", "Cannot overwrite non-directory with directory", EISDIR, + "cannot overwrite non-directory %s with directory %s", dest->data, src->data, dest); + return false; + } + if (!src_dir && dest_dir) { + cp_system("ERR_FS_CP_NON_DIR_TO_DIR", "Cannot overwrite directory with non-directory", ENOTDIR, + "cannot overwrite directory %s with non-directory %s", dest->data, src->data, dest); + return false; + } + } + if (src_dir && cp_is_src_subdir(src, dest)) { + cp_system("ERR_FS_CP_EINVAL", "Invalid src or dest", EINVAL, "cannot copy %s to a subdirectory of self %s", src->data, + dest->data, dest); + return false; + } + return true; +} + +/* checkParentPaths: no parent of dest is src itself. */ +static bool async_check_parent_paths(const ScrStr *src, const struct stat *sst, const ScrStr *dest) { + ScrStr *src_dir = scr_path_dirname((ScrStr *)src); + ScrStr *dest_dir = scr_path_dirname((ScrStr *)dest); + ScrStr *src_parent = cp_resolve1(src_dir); + ScrStr *dest_parent = cp_resolve1(dest_dir); + scr_str_release(src_dir); + scr_str_release(dest_dir); + bool ok = true; + bool done = cp_str_eq(src_parent, dest_parent) || (dest_parent->len == 1 && dest_parent->data[0] == '/'); + struct stat st; + if (!done) { + if (stat(dest_parent->data, &st) != 0) { + if (errno != ENOENT) { + scr_fs_throw(errno, "stat", dest_parent); + ok = false; + } + } else if (st.st_ino && st.st_dev && st.st_ino == sst->st_ino && st.st_dev == sst->st_dev) { + cp_system("ERR_FS_CP_EINVAL", "Invalid src or dest", EINVAL, "cannot copy %s to a subdirectory of self %s", src->data, + dest->data, dest); + ok = false; + } else { + ok = async_check_parent_paths(src, sst, dest_parent); + } + } + scr_str_release(src_parent); + scr_str_release(dest_parent); + return ok; +} + +static bool async_set_dest_mode(const ScrStr *dest, mode_t mode) { return cp_chmod(dest, mode); } + +static bool async_copy_file(const ScrFsCp *job, mode_t src_mode, const ScrStr *src, const ScrStr *dest) { + cp_copyfile(src, dest, job->mode, "copyfile"); + if (scr_exc_pending()) return false; + if (job->preserve) { + if ((src_mode & 0200) == 0 && !async_set_dest_mode(dest, src_mode | 0200)) return false; + if (!cp_set_dest_timestamps(src, dest)) return false; + } + return async_set_dest_mode(dest, src_mode); +} + +/* getStatsForCopy for one path. A directory to copy is opened as a frame + * for the caller to walk (true; mkdir done when dest was absent); + * everything else is handled here (false, with any error pending). */ +static bool async_stats_one(const ScrFsCp *job, bool has_dest, const ScrStr *src, const ScrStr *dest, CpFrame *frame) { + struct stat sst; + if (cp_stat(src, job->dereference, &sst) != 0) { + scr_fs_throw(errno, job->dereference ? "stat" : "lstat", src); + return false; + } + if (S_ISDIR(sst.st_mode) && job->recursive) { + if (!has_dest) { + if (mkdir(dest->data, 0777) != 0) { + scr_fs_throw(errno, "mkdir", dest); + return false; + } + } else if (job->error_on_exist && !job->force) { + cp_eexist(dest); + return false; + } + DIR *dir = opendir(src->data); + if (!dir) { + scr_fs_throw(errno, "opendir", src); + return false; + } + *frame = (CpFrame){ dir, scr_str_retain((ScrStr *)src), scr_str_retain((ScrStr *)dest), !has_dest, sst.st_mode }; + return true; + } + if (S_ISDIR(sst.st_mode)) { + cp_system("ERR_FS_EISDIR", "Path is a directory", EISDIR, "%s is a directory (not copied)", src->data, NULL, src); + } else if (S_ISREG(sst.st_mode) || S_ISCHR(sst.st_mode) || S_ISBLK(sst.st_mode)) { + if (!has_dest) { + (void)async_copy_file(job, sst.st_mode, src, dest); + } else if (job->force) { + if (unlink(dest->data) != 0) scr_fs_throw(errno, "unlink", dest); + else (void)async_copy_file(job, sst.st_mode, src, dest); + } else if (job->error_on_exist) { + cp_eexist(dest); + } + } else if (S_ISLNK(sst.st_mode)) { + sync_on_link(job, has_dest, src, dest, true); + } else if (S_ISSOCK(sst.st_mode)) { + cp_system("ERR_FS_CP_SOCKET", "Cannot copy a socket file", EINVAL, "cannot copy a socket file: %s", dest->data, NULL, + dest); + } else if (S_ISFIFO(sst.st_mode)) { + cp_system("ERR_FS_CP_FIFO_PIPE", "Cannot copy a FIFO pipe", EINVAL, "cannot copy a FIFO pipe: %s", dest->data, NULL, + dest); + } else { + cp_system("ERR_FS_CP_UNKNOWN", "Cannot copy an unknown file type", EINVAL, "cannot copy an unknown file type: %s", + dest->data, NULL, dest); + } + return false; +} + +/* getStatsForCopy, with cp.js's copyDir walked iteratively: each entry + * goes through checkPaths, and a finished directory gets the source + * timestamps (preserveTimestamps) and, when the walk created it, the + * source mode. An error ends the walk without those steps. */ +static bool async_get_stats_for_copy(const ScrFsCp *job, bool has_dest, const ScrStr *src, const ScrStr *dest) { + CpFrame first; + if (!async_stats_one(job, has_dest, src, dest, &first)) return !scr_exc_pending(); + CpFrames frames = { NULL, 0, 0 }; + cp_frames_push(&frames, first.dir, first.src, first.dest, first.flag, first.mode); + scr_str_release(first.src); + scr_str_release(first.dest); + bool ok = true; + while (ok && frames.n) { + CpFrame *top = &frames.v[frames.n - 1]; + struct dirent *entry = readdir(top->dir); + if (!entry) { + CpFrame done = frames.v[--frames.n]; + if (job->preserve && !cp_set_dest_timestamps(done.src, done.dest)) ok = false; + if (ok && done.flag && !async_set_dest_mode(done.dest, done.mode)) ok = false; + cp_frame_release(&done); + continue; + } + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + ScrStr *name = cp_str(entry->d_name); + ScrStr *from = cp_join(top->src, name); + ScrStr *to = cp_join(top->dest, name); + CpChecked checked; + CpFrame child; + if (!async_check_paths(job, from, to, &checked)) { + ok = false; + } else if (!checked.skipped) { + if (async_stats_one(job, checked.has_dest, from, to, &child)) { + cp_frames_push(&frames, child.dir, child.src, child.dest, child.flag, child.mode); + scr_str_release(child.src); + scr_str_release(child.dest); + } else if (scr_exc_pending()) { + ok = false; + } + } + scr_str_release(name); + scr_str_release(from); + scr_str_release(to); + } + cp_frames_free(&frames); + return ok; +} + +static void cp_async(const ScrFsCp *job) { + CpChecked checked; + if (!async_check_paths(job, job->src, job->dest, &checked) || checked.skipped) return; + if (!async_check_parent_paths(job->src, &checked.src, job->dest)) return; + /* checkParentDir */ + ScrStr *parent = scr_path_dirname(job->dest); + struct stat st; + if (stat(parent->data, &st) != 0) { + if (errno != ENOENT) { + scr_fs_throw(errno, "stat", parent); + scr_str_release(parent); + return; + } + scr_fs_mkdir_recursive_mode(parent, 0777); + if (scr_exc_pending()) { + scr_str_release(parent); + return; + } + } + scr_str_release(parent); + (void)async_get_stats_for_copy(job, checked.has_dest, job->src, job->dest); +} + +void scr_fs_cp_run(ScrFsCp *job, bool promise_form) { + if (promise_form) cp_async(job); + else cp_sync(job); +} + +#endif + +void scr_fs_cp_sync(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options) { + ScrFsCp *job = scr_fs_cp_begin(src, dest, options); + if (!job) return; + scr_fs_cp_run(job, false); + scr_fs_cp_free(job); +} + +ScrPromise *scr_fsp_cp(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options) { + ScrFsCp *job = scr_fs_cp_begin(src, dest, options); + if (job) scr_fs_cp_run(job, true); + scr_fs_cp_free(job); + return scr_promise_settled_void(); +} diff --git a/packages/runtime/src/scr_fs_cp.h b/packages/runtime/src/scr_fs_cp.h new file mode 100644 index 000000000..03edb9fc6 --- /dev/null +++ b/packages/runtime/src/scr_fs_cp.h @@ -0,0 +1,22 @@ +/* scriptc runtime — fs.cpSync, fs.cp and fs/promises cp (scr_fs_cp.c). + * Included by scr_runtime.h, which says where new declarations go. */ +#ifndef SCR_FS_CP_H +#define SCR_FS_CP_H + +#include "scr_async.h" +#include "scr_dyn.h" + +/* scr_fs_cp_begin runs validateCpOptions(options) and getValidatedPath for + * src and dest (all borrowed) and answers the copy job, or NULL with the + * validation error pending. scr_fs_cp_run copies with cpSync's semantics, + * or with fs/promises cp's when promise_form is set, leaving any copy + * error pending. scr_fs_cp_sync is fs.cpSync; scr_fsp_cp is fs/promises + * cp, settled before it returns (validation errors reject it). */ +typedef struct ScrFsCp ScrFsCp; +ScrFsCp *scr_fs_cp_begin(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options); +void scr_fs_cp_run(ScrFsCp *job, bool promise_form); +void scr_fs_cp_free(ScrFsCp *job); /* NULL-tolerant */ +void scr_fs_cp_sync(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options); +ScrPromise *scr_fsp_cp(const ScrDyn *src, const ScrDyn *dest, const ScrDyn *options); + +#endif /* SCR_FS_CP_H */ diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 4ad431e7f..e489b23b7 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -105,6 +105,12 @@ ScrDyn *scr_fs_callback_call(ScrStr *member, const ScrDyn *args); * becomes [err], otherwise [null, result(, buffer)]; the callback runs on * a later loop turn. result moves in. */ void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer); +/* getValidatedPath(value, name): a string, Buffer or file: URL path + * without NUL bytes (+1), or NULL with Node's TypeError pending. */ +ScrStr *scr_fs_validated_path(const ScrDyn *value, const char *name); +/* options[name] for an options object (+1; undefined when options is + * nullish), or NULL with an exception pending. */ +ScrDyn *scr_fs_option_get(const ScrDyn *options, const char *name); ScrBytes *scr_fs_read_fd_bytes(double fd); /* +1; the fd form (scr_lib.c) */ void scr_fs_write_file_bytes(ScrStr *path, const ScrBytes *data); void scr_fs_append_file_bytes(ScrStr *path, const ScrBytes *data); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index e0472aa1a..d727cc3d2 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5658,12 +5658,35 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { d->v.obj.entries[d->v.obj.len - 1].enumerable = e->message_enumerable; } if (e->stack) scr_dyn_obj_set(d, "stack", 5, scr_dyn_new_str(e->stack)); - /* errno rides fs errors (with syscall) and zlib errors (without). */ - if (e->system_call || e->system_errno != 0) + if (e->system_message) { + /* SystemError: code, the read-only `info` context ({ message, path, + * syscall, errno, code }), then errno, syscall, path and dest. */ + if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + ScrDyn *info = scr_dyn_new_obj(); + scr_dyn_obj_set(info, "message", 7, scr_dyn_new_str(e->system_message)); + if (e->system_path) scr_dyn_obj_set(info, "path", 4, scr_dyn_new_str(e->system_path)); + if (e->system_dest) scr_dyn_obj_set(info, "dest", 4, scr_dyn_new_str(e->system_dest)); + if (e->system_call) scr_dyn_obj_set(info, "syscall", 7, scr_dyn_new_str(e->system_call)); + scr_dyn_obj_set(info, "errno", 5, scr_dyn_new_num(e->system_errno)); + if (e->system_code) scr_dyn_obj_set(info, "code", 4, scr_dyn_new_str(e->system_code)); + scr_dyn_obj_set(d, "info", 4, info); + d->v.obj.entries[d->v.obj.len - 1].writable = false; scr_dyn_obj_set(d, "errno", 5, scr_dyn_new_num(e->system_errno)); - if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); - if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); - if (e->system_path) scr_dyn_obj_set(d, "path", 4, scr_dyn_new_str(e->system_path)); + if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); + } else if (e->errno_order) { + /* ErrnoException: errno, code, path, syscall. */ + if (e->system_call) scr_dyn_obj_set(d, "errno", 5, scr_dyn_new_num(e->system_errno)); + if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + if (e->system_path) scr_dyn_obj_set(d, "path", 4, scr_dyn_new_str(e->system_path)); + if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); + } else { + /* errno rides fs errors (with syscall) and zlib errors (without). */ + if (e->system_call || e->system_errno != 0) + scr_dyn_obj_set(d, "errno", 5, scr_dyn_new_num(e->system_errno)); + if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); + } + if (!e->errno_order && e->system_path) scr_dyn_obj_set(d, "path", 4, scr_dyn_new_str(e->system_path)); if (e->system_dest) scr_dyn_obj_set(d, "dest", 4, scr_dyn_new_str(e->system_dest)); if (e->error_cause) { scr_dyn_obj_set(d, "cause", 5, scr_dyn_retain(e->error_cause)); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e6ed4758d..bee2b26ca 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3027,6 +3027,69 @@ void scr_fs_throw(int e, const char *op, const ScrStr *path) { scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } +/* A native binding's coded Error (Node's THROW_ERR_* macros): "Error: + * message" with an own `code`, and no "[code]" in its name. */ +void scr_fs_throw_native_coded(const char *code, const char *message, size_t length) { + ScrStr *text = scr_str_new(message, length); + ScrError *error = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_error_set_code(error, code); + error->native_plain = true; + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + +/* Node's ErrnoException (env->ThrowErrnoException, used for std::error_code + * failures): "EEXIST, File exists 'path'" with errno (the positive errno), + * code, path and syscall. */ +void scr_fs_throw_errno_exception(int e, const char *syscall, const ScrStr *path) { + char namebuf[16]; + const char *name = scr_errno_name(e, namebuf, sizeof namebuf); + const char *text = strerror(e); + size_t cap = strlen(name) + strlen(text) + (path ? path->len : 0) + 8; + char *msg = malloc(cap); + if (!msg) scr_trap("scriptc: out of memory\n"); + int len = path ? snprintf(msg, cap, "%s, %s '%s'", name, text, path->data) : snprintf(msg, cap, "%s, %s", name, text); + ScrStr *message = scr_str_new(msg, (size_t)len); + free(msg); + ScrError *error = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_error_set_code(error, name); + error->system_errno = e; + error->system_call = scr_str_new(syscall, strlen(syscall)); + error->system_path = path ? scr_str_retain((ScrStr *)path) : NULL; + error->errno_order = true; + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + +/* Node's SystemError (internal/errors.js) with a { message, path, syscall, + * errno, code } context: ": returned + * () ", named SystemError, with code, info, errno, syscall + * and path own properties. */ +void scr_fs_throw_system_error(const char *code, const char *prefix, int e, const char *syscall, + const ScrStr *message, const ScrStr *path) { + char namebuf[16]; + const char *name = scr_errno_name(e, namebuf, sizeof namebuf); + size_t cap = strlen(prefix) + strlen(syscall) + strlen(name) + message->len + (path ? path->len : 0) + 32; + char *msg = malloc(cap); + if (!msg) scr_trap("scriptc: out of memory\n"); + int len = snprintf(msg, cap, "%s: %s returned %s (%s)", prefix, syscall, name, message->data); + if (path) len += snprintf(msg + len, cap - (size_t)len, " %s", path->data); + ScrStr *text = scr_str_new(msg, (size_t)len); + free(msg); + ScrError *error = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_str_release(error->name); + error->name = scr_str_new("SystemError", 11); + error->name_present = true; + scr_error_set_code(error, code); + error->system_errno = e; + error->system_call = scr_str_new(syscall, strlen(syscall)); + error->system_path = path ? scr_str_retain((ScrStr *)path) : NULL; + error->system_message = scr_str_retain((ScrStr *)message); + error->system_code = scr_str_new(name, strlen(name)); + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + /* ── fs operations ───────────────────────────────────────────────────── */ static FILE *scr_fs_fopen(const ScrStr *path, const char *mode) { @@ -4236,7 +4299,7 @@ int scr_fs_raw_close(int fd) { /* The two-path fs error shape — Node's copyfile errors quote both ends: * "ENOENT: no such file or directory, copyfile 'src' -> 'dest'". */ -static void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr *dest) { +void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr *dest) { char namebuf[16]; const char *name = scr_errno_name(e, namebuf, sizeof namebuf); const char *text = scr_errno_text(e); diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index e462624f2..64f3e77a5 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -288,6 +288,20 @@ bool scr_fs_access_mode_valid(double mode); bool scr_fs_truncate_len_valid(double length); void scr_fs_truncate(ScrStr *path, double length); void scr_fs_copyfile(ScrStr *src, ScrStr *dest); +/* The two-path form of scr_fs_throw: "ENOENT: no such file or directory, + * copyfile 'src' -> 'dest'", with path and dest own properties. */ +void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr *dest); +/* The error shapes Node's native fs bindings throw besides the libuv one + * (scr_fs_throw): a THROW_ERR_* coded Error ("Error: message", own `code`, + * no "[code]" in its name); an ErrnoException from a std::error_code + * ("EEXIST, File exists 'path'", positive errno, keys errno, code, path, + * syscall); and internal/errors.js's SystemError (": + * returned () ", named SystemError, keys code, + * info, errno, syscall, path). */ +void scr_fs_throw_native_coded(const char *code, const char *message, size_t length); +void scr_fs_throw_errno_exception(int e, const char *syscall, const ScrStr *path); +void scr_fs_throw_system_error(const char *code, const char *prefix, int e, const char *syscall, + const ScrStr *message, const ScrStr *path); void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath); /* The callback rename worker uses the non-throwing syscall seam off the * runtime thread, then materializes any Node-shaped Error back on the main diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 91eb5cf86..a66d1c554 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -73,5 +73,6 @@ #include "scr_test.h" #include "scr_assert.h" #include "scr_console.h" +#include "scr_fs_cp.h" #endif /* SCR_RUNTIME_H */ diff --git a/packages/runtime/test/bytes.test.ts b/packages/runtime/test/bytes.test.ts index 303f9d17a..7ca0606ec 100644 --- a/packages/runtime/test/bytes.test.ts +++ b/packages/runtime/test/bytes.test.ts @@ -34,6 +34,7 @@ beforeAll(async () => { join(testDir, "../src/scr_bytes.c"), join(testDir, "../src/scr_shared.c"), join(testDir, "../src/scr_bytes_io.c"), + join(testDir, "../src/scr_fs_cp.c"), join(testDir, "../src/scr_zlib.c"), join(testDir, "../src/scr_lib.c"), join(testDir, "../src/scr_hash.c"), diff --git a/packages/runtime/test/cipher.test.ts b/packages/runtime/test/cipher.test.ts index fef73e8ab..6b44a4b4b 100644 --- a/packages/runtime/test/cipher.test.ts +++ b/packages/runtime/test/cipher.test.ts @@ -14,6 +14,7 @@ const runtimeSources = [ "scr_bytes.c", "scr_bytes.test.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_zlib.c", "scr_lib.c", "scr_hash.c", diff --git a/packages/runtime/test/runtime.test.ts b/packages/runtime/test/runtime.test.ts index 4cc7e696d..11bdf75b4 100644 --- a/packages/runtime/test/runtime.test.ts +++ b/packages/runtime/test/runtime.test.ts @@ -16,6 +16,7 @@ const RUNTIME_SOURCES = [ "scr_bytes.c", "scr_shared.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", diff --git a/packages/runtime/test/sha-kernels.test.ts b/packages/runtime/test/sha-kernels.test.ts index db736faed..724841fb1 100644 --- a/packages/runtime/test/sha-kernels.test.ts +++ b/packages/runtime/test/sha-kernels.test.ts @@ -14,6 +14,7 @@ const runtimeSources = [ "scr_bytes.c", "scr_bytes.test.c", "scr_bytes_io.c", + "scr_fs_cp.c", "scr_zlib.c", "scr_hash.c", "scr_string.c", diff --git a/tests/corpus/5567-fs-cpsync.ts b/tests/corpus/5567-fs-cpsync.ts new file mode 100644 index 000000000..7e258ca85 --- /dev/null +++ b/tests/corpus/5567-fs-cpsync.ts @@ -0,0 +1,116 @@ +// fs.cpSync: files, recursive directories, the force / errorOnExist / +// mode / filter / preserveTimestamps options, and directory merges. +// Paths are relative to a scratch directory so messages are stable. +import * as fs from "node:fs"; + +function tail(path: string): string { + let i = path.length - 1; + while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; + return path.slice(i + 1); +} +const root = `/tmp/scr-cpsync-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); +fs.mkdirSync(root); +const home = process.cwd(); +process.chdir(root); + +function tree(dir: string): string[] { + const out: string[] = []; + const walk = (d: string): void => { + const names = fs.readdirSync(d).sort(); + for (const name of names) { + const p = `${d}/${name}`; + const st = fs.lstatSync(p); + if (st.isDirectory()) { + out.push(`${p}/`); + walk(p); + } else if (st.isSymbolicLink()) { + out.push(`${p} -> ${fs.readlinkSync(p).split(root).join("")}`); + } else { + out.push(`${p} = ${fs.readFileSync(p, "utf8")}`); + } + } + }; + walk(dir); + return out; +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "ok"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + } +} + +fs.mkdirSync("src/sub/deep", { recursive: true }); +fs.writeFileSync("src/a.txt", "a"); +fs.writeFileSync("src/sub/b.txt", "b"); +fs.writeFileSync("src/sub/deep/c.txt", "c"); +fs.writeFileSync("file.txt", "file"); + +// A single file, into a missing parent directory. +fs.cpSync("file.txt", "out/nested/file.txt"); +console.log(tree("out")); + +// A directory tree. +fs.cpSync("src", "copy", { recursive: true }); +console.log(tree("copy")); + +// force (the default) overwrites; force: false keeps the existing file. +fs.writeFileSync("other.txt", "other"); +fs.cpSync("other.txt", "out/nested/file.txt"); +console.log(fs.readFileSync("out/nested/file.txt", "utf8")); +fs.writeFileSync("other.txt", "changed"); +fs.cpSync("other.txt", "out/nested/file.txt", { force: false }); +console.log(fs.readFileSync("out/nested/file.txt", "utf8")); +attempt("errorOnExist", () => + fs.cpSync("other.txt", "out/nested/file.txt", { force: false, errorOnExist: true }), +); + +// Merging into an existing directory: force overwrites, force: false skips. +fs.writeFileSync("src/a.txt", "a2"); +fs.writeFileSync("copy/extra.txt", "extra"); +fs.cpSync("src", "copy", { recursive: true, force: false }); +console.log(tree("copy")); +fs.cpSync("src", "copy", { recursive: true }); +console.log(tree("copy")); +attempt("dir errorOnExist", () => + fs.cpSync("src", "copy", { recursive: true, force: false, errorOnExist: true }), +); + +// COPYFILE_EXCL through mode. +attempt("mode excl", () => fs.cpSync("other.txt", "out/nested/file.txt", { mode: fs.constants.COPYFILE_EXCL })); +fs.cpSync("other.txt", "fresh.txt", { mode: fs.constants.COPYFILE_EXCL }); +console.log(fs.readFileSync("fresh.txt", "utf8")); + +// filter: called for the top-level paths and each entry, in order. +const seen: string[] = []; +fs.cpSync("src", "filtered", { + recursive: true, + filter: (from: string, to: string) => { + seen.push(`${from} -> ${to}`); + return !from.endsWith("b.txt"); + }, +}); +console.log(seen.sort()); +console.log(tree("filtered")); +fs.cpSync("src", "skipped", { recursive: true, filter: () => false }); +console.log(fs.existsSync("skipped")); + +// preserveTimestamps keeps the source mtime on files. +const stamp = new Date("2020-01-02T03:04:05Z"); +fs.utimesSync("file.txt", stamp, stamp); +fs.cpSync("file.txt", "stamped.txt", { preserveTimestamps: true }); +console.log(new Date(fs.statSync("stamped.txt").mtimeMs).toISOString()); +fs.cpSync("file.txt", "unstamped.txt"); +console.log(fs.statSync("unstamped.txt").mtimeMs === stamp.getTime()); + +// A file: URL source. +fs.cpSync(new URL(`file://${root}/file.txt`), "url.txt"); +console.log(fs.readFileSync("url.txt", "utf8")); + +process.chdir(home); +fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5568-fs-cp-errors.js b/tests/corpus/5568-fs-cp-errors.js new file mode 100644 index 000000000..e9a87f14e --- /dev/null +++ b/tests/corpus/5568-fs-cp-errors.js @@ -0,0 +1,115 @@ +// fs.cpSync and fs/promises cp errors: option validation, path +// validation, and the copy errors, whose class, message, code and own +// keys differ between the synchronous and the promise form, as in Node. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; + +const any = (value) => /** @type {any} */ (value); + +function tail(path) { + let i = path.length - 1; + while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; + return path.slice(i + 1); +} +const root = `/tmp/scr-cp-errors-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); +fs.mkdirSync(root); +const home = process.cwd(); +process.chdir(root); +fs.mkdirSync("src/sub", { recursive: true }); +fs.writeFileSync("src/a.txt", "a"); +fs.writeFileSync("file.txt", "f"); +fs.writeFileSync("b.txt", "b"); + +function describe(label, e) { + const keys = Object.keys(e); + console.log(label, e instanceof Error, e.name, e.code, JSON.stringify(e.message), keys); + if (keys.includes("info")) console.log(" info", e.info, e.errno, e.syscall, e.path, String(e)); + else if (keys.includes("errno")) console.log(" errno", e.errno, e.syscall, e.path); +} +function sync(label, run) { + try { + run(); + console.log(label, "ok"); + } catch (e) { + describe(label, e); + } +} +async function promised(label, run) { + try { + await run(); + console.log(label, "ok"); + } catch (e) { + describe(label, e); + } +} + +sync("options type", () => fs.cpSync("file.txt", "x.txt", any(5))); +sync("options null", () => fs.cpSync("file.txt", "x.txt", any(null))); +sync("recursive type", () => fs.cpSync("file.txt", "x.txt", any({ recursive: 1 }))); +sync("force type", () => fs.cpSync("file.txt", "x.txt", any({ force: "yes" }))); +sync("mode range", () => fs.cpSync("file.txt", "x.txt", { mode: 99 })); +sync("mode type", () => fs.cpSync("file.txt", "x.txt", any({ mode: "1" }))); +sync("option pair", () => fs.cpSync("file.txt", "x.txt", { dereference: true, verbatimSymlinks: true })); +sync("filter type", () => fs.cpSync("file.txt", "x.txt", any({ filter: 1 }))); +sync("filter promise", () => fs.cpSync("file.txt", "x.txt", any({ filter: async () => true }))); +sync("filter throws", () => + fs.cpSync("file.txt", "x.txt", { + filter: () => { + throw new Error("no copy"); + }, + }), +); +sync("src type", () => fs.cpSync(any(5), "x.txt")); +sync("dest type", () => fs.cpSync("file.txt", any({}))); +sync("src nul", () => fs.cpSync("file\0.txt", "x.txt")); +sync("validation order", () => fs.cpSync(any(5), any(6), any({ force: 1 }))); + +sync("same", () => fs.cpSync("src", "src", { recursive: true })); +sync("not recursive", () => fs.cpSync("src", "out")); +sync("dir to file", () => fs.cpSync("src", "file.txt", { recursive: true })); +sync("file to dir", () => fs.cpSync("file.txt", "src")); +sync("into self", () => fs.cpSync("src", "src/sub/inner", { recursive: true })); +sync("missing", () => fs.cpSync("missing.txt", "x.txt")); +sync("exists", () => fs.cpSync("file.txt", "b.txt", { force: false, errorOnExist: true })); +fs.cpSync("src", "copy", { recursive: true }); +sync("dir file exists", () => fs.cpSync("src", "copy", { recursive: true, force: false, errorOnExist: true })); +fs.rmSync("copy/a.txt"); +fs.mkdirSync("copy/a.txt"); +sync("dir entry kind", () => fs.cpSync("src", "copy", { recursive: true })); + +await promised("p options type", () => fsp.cp("file.txt", "x.txt", any(5))); +await promised("p src type", () => fsp.cp(any(5), "x.txt")); +await promised("p same", () => fsp.cp("src", "src", { recursive: true })); +await promised("p not recursive", () => fsp.cp("src", "out")); +await promised("p dir to file", () => fsp.cp("src", "file.txt", { recursive: true })); +await promised("p file to dir", () => fsp.cp("file.txt", "src")); +await promised("p into self", () => fsp.cp("src", "src/sub/inner", { recursive: true })); +await promised("p missing", () => fsp.cp("missing.txt", "x.txt")); +await promised("p exists", () => fsp.cp("file.txt", "b.txt", { force: false, errorOnExist: true })); +await promised("p dir exists", () => fsp.cp("src", "copy", { recursive: true, force: false, errorOnExist: true })); +await promised("p dir entry kind", () => fsp.cp("src", "copy", { recursive: true })); + +// The copy takes the source's mode, and a Buffer path works. +function access(path) { + const can = (mode) => { + try { + fs.accessSync(path, mode); + return true; + } catch { + return false; + } + }; + return [can(fs.constants.R_OK), can(fs.constants.W_OK), can(fs.constants.X_OK)]; +} +fs.chmodSync("b.txt", 0o500); +fs.cpSync("b.txt", any(Buffer.from("moded.txt"))); +console.log(access("moded.txt")); +fs.chmodSync("src/sub", 0o500); +fs.cpSync("src", "moded-dir", { recursive: true }); +console.log(access("moded-dir/sub")); +fs.chmodSync("src/sub", 0o700); +fs.chmodSync("moded-dir/sub", 0o700); + +process.chdir(home); +fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5569-fs-cp-symlinks.ts b/tests/corpus/5569-fs-cp-symlinks.ts new file mode 100644 index 000000000..ae4621ce6 --- /dev/null +++ b/tests/corpus/5569-fs-cp-symlinks.ts @@ -0,0 +1,93 @@ +// fs.cpSync and fs/promises cp with symbolic links: a top-level link, +// links inside a copied directory (relative and absolute targets), +// verbatimSymlinks, dereference, and replacing an existing link. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; + +function tail(path: string): string { + let i = path.length - 1; + while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; + return path.slice(i + 1); +} +const root = `/tmp/scr-cp-links-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); +fs.mkdirSync(root); +const home = process.cwd(); +process.chdir(root); +const real = fs.realpathSync(root); + +function show(path: string): string { + const st = fs.lstatSync(path); + if (st.isSymbolicLink()) return `${path} -> ${fs.readlinkSync(path).split(real).join("")}`; + if (st.isDirectory()) return `${path}/`; + return `${path} = ${fs.readFileSync(path, "utf8")}`; +} +function tree(dir: string): string[] { + const out: string[] = []; + const walk = (d: string): void => { + for (const name of fs.readdirSync(d).sort()) { + const p = `${d}/${name}`; + out.push(show(p)); + if (!fs.lstatSync(p).isSymbolicLink() && fs.statSync(p).isDirectory()) walk(p); + } + }; + walk(dir); + return out; +} +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "ok"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message.split(real).join("")); + } +} + +fs.mkdirSync("src/sub", { recursive: true }); +fs.writeFileSync("src/a.txt", "a"); +fs.writeFileSync("src/sub/b.txt", "b"); +fs.writeFileSync("target.txt", "t"); +fs.symlinkSync("a.txt", "src/rel-link"); +fs.symlinkSync("sub", "src/dir-link"); +fs.symlinkSync(`${real}/target.txt`, "src/abs-link"); +fs.symlinkSync("target.txt", "top-link"); + +// A top-level link copies as a link; its relative target is resolved. +fs.cpSync("top-link", "copied-link"); +console.log(show("copied-link")); +fs.cpSync("top-link", "verbatim-link", { verbatimSymlinks: true }); +console.log(show("verbatim-link")); +// With dereference, the binding's path check treats a link as a directory. +attempt("deref file link", () => fs.cpSync("top-link", "deref.txt", { dereference: true })); +fs.cpSync("top-link", "deref.txt", { dereference: true, recursive: true }); +console.log(show("deref.txt")); + +// Links inside a directory: the synchronous copy points each one at its +// canonical absolute target. +fs.cpSync("src", "sync-copy", { recursive: true }); +console.log(tree("sync-copy")); +fs.cpSync("src", "sync-verbatim", { recursive: true, verbatimSymlinks: true }); +console.log(tree("sync-verbatim")); + +// The promise form resolves relative targets against the link's directory. +await fsp.cp("src", "async-copy", { recursive: true }); +console.log(tree("async-copy")); +await fsp.cp("src", "async-verbatim", { recursive: true, verbatimSymlinks: true }); +console.log(tree("async-verbatim")); +await fsp.cp("src", "async-deref", { recursive: true, dereference: true }); +console.log(tree("async-deref")); + +// Replacing an existing link, and a link onto a regular file. +fs.symlinkSync("src/a.txt", "other-link"); +fs.cpSync("top-link", "other-link"); +console.log(show("other-link")); +attempt("link onto file", () => fs.cpSync("top-link", "target.txt")); +attempt("sync link in dir onto file", () => { + fs.mkdirSync("merge", { recursive: true }); + fs.writeFileSync("merge/rel-link", "file"); + fs.cpSync("src", "merge", { recursive: true }); +}); + +process.chdir(home); +fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5570-fs-cp-async.ts b/tests/corpus/5570-fs-cp-async.ts new file mode 100644 index 000000000..9f3ce6c87 --- /dev/null +++ b/tests/corpus/5570-fs-cp-async.ts @@ -0,0 +1,101 @@ +// fs.cp (callback) and fs/promises cp: results and errors reach the +// callback after the calling code finishes; the promise form merges +// directories, takes async filters, and keeps timestamps. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; + +function tail(path: string): string { + let i = path.length - 1; + while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; + return path.slice(i + 1); +} +const root = `/tmp/scr-cp-async-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); +fs.mkdirSync(root); +const home = process.cwd(); +process.chdir(root); + +function tree(dir: string): string[] { + const out: string[] = []; + const walk = (d: string): void => { + for (const name of fs.readdirSync(d).sort()) { + const p = `${d}/${name}`; + if (fs.statSync(p).isDirectory()) { + out.push(`${p}/`); + walk(p); + } else { + out.push(`${p} = ${fs.readFileSync(p, "utf8")}`); + } + } + }; + walk(dir); + return out; +} + +fs.mkdirSync("src/sub", { recursive: true }); +fs.writeFileSync("src/a.txt", "a"); +fs.writeFileSync("src/sub/b.txt", "b"); +fs.writeFileSync("file.txt", "file"); + +await new Promise((resolve) => { + fs.cp("file.txt", "cb.txt", (err) => { + console.log("callback", err, fs.readFileSync("cb.txt", "utf8")); + resolve(); + }); + console.log("after call"); +}); + +await new Promise((resolve) => { + fs.cp("src", "cb-dir", { recursive: true }, (err) => { + console.log("dir callback", err, tree("cb-dir")); + resolve(); + }); +}); + +await new Promise((resolve) => { + fs.cp("src", "nope", (err) => { + const e = err as NodeJS.ErrnoException; + console.log("error callback", e.name, e.code, e.message); + resolve(); + }); +}); + +try { + fs.cp("file.txt", "x.txt", { recursive: true }, undefined as unknown as () => void); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("missing callback", err.name, err.code, err.message); +} + +// The promise form: filters may be async, merges overwrite by default. +const seen: string[] = []; +await fsp.cp("src", "p-dir", { + recursive: true, + filter: async (from: string) => { + seen.push(from); + return !from.endsWith("b.txt"); + }, +}); +console.log(seen.sort(), tree("p-dir")); + +fs.writeFileSync("src/a.txt", "a2"); +await fsp.cp("src", "p-dir", { recursive: true, force: false }); +console.log(tree("p-dir")); +await fsp.cp("src", "p-dir", { recursive: true }); +console.log(tree("p-dir")); + +const stamp = new Date("2021-05-06T07:08:09.123Z"); +fs.utimesSync("file.txt", stamp, stamp); +await fsp.cp("file.txt", "p-stamped.txt", { preserveTimestamps: true }); +console.log(new Date(fs.statSync("p-stamped.txt").mtimeMs).toISOString()); +await fsp.cp("src", "p-stamped-dir", { recursive: true, preserveTimestamps: true }); +console.log(fs.statSync("p-stamped-dir").mtimeMs === fs.statSync("src").mtimeMs); + +const rejected = await fsp.cp("missing", "x").then( + () => "fulfilled", + (e: NodeJS.ErrnoException) => `${e.code} ${e.message}`, +); +console.log(rejected); + +process.chdir(home); +fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5571-fs-cp-deep.ts b/tests/corpus/5571-fs-cp-deep.ts new file mode 100644 index 000000000..977be268c --- /dev/null +++ b/tests/corpus/5571-fs-cp-deep.ts @@ -0,0 +1,41 @@ +// fs.cpSync and fs/promises cp over a directory chain 1500 levels deep, +// from a module with top-level await (its code runs on a fiber stack). +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; + +function tail(path: string): string { + let i = path.length - 1; + while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; + return path.slice(i + 1); +} +const root = `/tmp/scr-cp-deep-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); +fs.mkdirSync(root); +const home = process.cwd(); +process.chdir(root); + +const depth = 1500; +let path = "src"; +for (let i = 0; i < depth; i++) path += "/d"; +fs.mkdirSync(path, { recursive: true }); +fs.writeFileSync(`${path}/leaf.txt`, "leaf"); + +function count(dir: string): number { + let levels = 0; + let at = dir; + while (fs.existsSync(`${at}/d`)) { + at += "/d"; + levels += 1; + } + return levels; +} + +fs.cpSync("src", "sync", { recursive: true }); +console.log("sync", count("sync"), fs.readFileSync(`sync${path.slice(3)}/leaf.txt`, "utf8")); +fs.cpSync("src", "filtered", { recursive: true, filter: () => true }); +console.log("filtered", count("filtered")); +await fsp.cp("src", "promise", { recursive: true }); +console.log("promise", count("promise")); + +process.chdir(home); +fs.rmSync(root, { recursive: true, force: true }); From 23e92e4cff41ca833c5be7b6f59cf3e321da420b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 21:53:32 -0500 Subject: [PATCH 2/2] Match libc++'s copy errors and macOS's PATH_MAX in the fs.cp tests std::filesystem::copy_file reports a destination that exists but is not a regular file as invalid_argument in libstdc++ and as not_supported in libc++, which Node's macOS builds use. cpSync on Apple platforms now throws ENOTSUP there, as Node does, and the fs errno names include ENOTSUP. Corpus 5571 copies a 400-level directory chain instead of 1500 levels, so its deepest path stays under macOS's 1024-byte PATH_MAX. --- packages/runtime/src/scr_fs_cp.c | 19 ++++++++++++------- packages/runtime/src/scr_lib.c | 1 + tests/corpus/5571-fs-cp-deep.ts | 7 ++++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c index 189c42294..8a9ebec8b 100644 --- a/packages/runtime/src/scr_fs_cp.c +++ b/packages/runtime/src/scr_fs_cp.c @@ -806,12 +806,17 @@ static void sync_copy_file(const ScrFsCp *job, const struct stat *sst, const Scr static bool sync_copy_dir_contents(const ScrFsCp *job, const ScrStr *src, const ScrStr *dest); -/* libstdc++'s std::filesystem::copy_file(from, to, options, ec): the - * source's contents and mode into `to`; an existing `to` is skipped, - * overwritten or refused (file_exists) per `existing`, and a non-regular - * one is invalid_argument. The binding reports file_exists as - * ERR_FS_CP_EEXIST and any other error as an ErrnoException naming - * error_path. */ +/* std::filesystem::copy_file(from, to, options, ec): the source's contents + * and mode into `to`; an existing `to` is skipped, overwritten or refused + * (file_exists) per `existing`. A non-regular `to` is invalid_argument in + * libstdc++ and not_supported in libc++, which Node's macOS builds use. + * The binding reports file_exists as ERR_FS_CP_EEXIST and any other error + * as an ErrnoException naming error_path. */ +#if defined(__APPLE__) +#define FS_COPY_NOT_REGULAR ENOTSUP +#else +#define FS_COPY_NOT_REGULAR EINVAL +#endif static bool fs_copy_file(const ScrStr *from, const ScrStr *to, int existing, const ScrStr *error_path) { struct stat tst, fst; bool exists = stat(to->data, &tst) == 0; @@ -821,7 +826,7 @@ static bool fs_copy_file(const ScrStr *from, const ScrStr *to, int existing, con } if (exists) { if (!S_ISREG(tst.st_mode)) { - scr_fs_throw_errno_exception(EINVAL, "cp", error_path); + scr_fs_throw_errno_exception(FS_COPY_NOT_REGULAR, "cp", error_path); return false; } bool same = tst.st_dev == fst.st_dev && tst.st_ino == fst.st_ino; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index bee2b26ca..a0ce71a80 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -2914,6 +2914,7 @@ static const char *scr_errno_name(int e, char *fallback, size_t cap) { case ESPIPE: return "ESPIPE"; case ELOOP: return "ELOOP"; case ENOSYS: return "ENOSYS"; + case ENOTSUP: return "ENOTSUP"; default: snprintf(fallback, cap, "E%d", e); return fallback; diff --git a/tests/corpus/5571-fs-cp-deep.ts b/tests/corpus/5571-fs-cp-deep.ts index 977be268c..a34e9e28d 100644 --- a/tests/corpus/5571-fs-cp-deep.ts +++ b/tests/corpus/5571-fs-cp-deep.ts @@ -1,5 +1,6 @@ -// fs.cpSync and fs/promises cp over a directory chain 1500 levels deep, -// from a module with top-level await (its code runs on a fiber stack). +// fs.cpSync and fs/promises cp over a directory chain 400 levels deep (its +// deepest path stays under macOS's 1024-byte PATH_MAX), from a module with +// top-level await (its code runs on a fiber stack). import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; @@ -14,7 +15,7 @@ fs.mkdirSync(root); const home = process.cwd(); process.chdir(root); -const depth = 1500; +const depth = 400; let path = "src"; for (let i = 0; i < depth; i++) path += "/d"; fs.mkdirSync(path, { recursive: true });