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 d5546f9e2..8c0489c34 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 e78288558..f1e1faa40 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": "fba8a4c99de7c5424f50",
+ "artifactVersion": "ae3ec89c0c8935ca6c07",
"rowCount": 3880
}
diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json
index 8e2807d58..2db9a5db2 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 19ec1e964..b597d091c 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": 1058,
- "verify-gap": 2287,
+ "audit-partial": 1061,
+ "verify-gap": 2284,
"replace-refusal": 40,
"classify": 2,
"implement": 85
@@ -26214,12 +26214,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"
+ ]
}
}
},
@@ -27168,12 +27173,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"
+ ]
}
}
},
@@ -28700,12 +28710,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 d9903c815..1c5821b6a 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
@@ -44021,6 +44021,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",
@@ -45067,8 +45071,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"
},
@@ -46027,8 +46037,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"
},
@@ -47655,8 +47671,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 33f8b3ee7..b4fafe62d 100644
--- a/internal/compatibility/static-support.json
+++ b/internal/compatibility/static-support.json
@@ -312,6 +312,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 eb6aaa2ba..8aaf7a28e 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 7eeb9379c..0feec1b01 100644
--- a/packages/compiler/src/backend/llvm/lib-shared.ts
+++ b/packages/compiler/src/backend/llvm/lib-shared.ts
@@ -461,6 +461,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",
@@ -712,6 +713,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 11421e30f..bd35c3062 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 859cd7305..d8f5a548b 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 99029472e..c4347f0e6 100644
--- a/packages/compiler/src/ir/builtin-effects.ts
+++ b/packages/compiler/src/ir/builtin-effects.ts
@@ -813,6 +813,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 179375bd3..70a01f796 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 },
@@ -2682,6 +2685,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 87f212aee..3fcf1c9a0 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 57c5857c9..c75528377 100644
--- a/packages/runtime/src/scr_error.c
+++ b/packages/runtime/src/scr_error.c
@@ -53,6 +53,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);
@@ -102,6 +104,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);
@@ -284,7 +288,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 a3616f303..03ab7ebbf 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 {
@@ -150,6 +156,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..8a9ebec8b
--- /dev/null
+++ b/packages/runtime/src/scr_fs_cp.c
@@ -0,0 +1,1469 @@
+/* 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);
+
+/* 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;
+ 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(FS_COPY_NOT_REGULAR, "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 00e974a49..355165333 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..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;
@@ -3027,6 +3028,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 +4300,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..a34e9e28d
--- /dev/null
+++ b/tests/corpus/5571-fs-cp-deep.ts
@@ -0,0 +1,42 @@
+// 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";
+
+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 = 400;
+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 });