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 });