diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 984c37f5d..e30b282a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,6 +482,10 @@ jobs: env: SCRIPTC_NO_CACHE: "0" run: pnpm exec vitest run packages/cli/test/windows-subsystem.test.ts + - name: Windows filesystem links differential + run: >- + pnpm exec vitest run tests/harness/differential.test.ts tests/harness/project-config.test.ts + --testNamePattern "node24-fs-links|filesystem links" - name: Windows CA-store differential shell: pwsh run: | diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 4d3d6b7e9..71b4aac8d 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.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "e731e2807c40f14ba1b1", + "artifactVersion": "4236932a217647103f1b", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index ae56d6dc8..aad827b42 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -458,9 +458,9 @@ "apiEntries": 276, "static": { "supported": 0, - "partial": 86, + "partial": 94, "refused": 0, - "not-implemented": 190, + "not-implemented": 182, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -36105,9 +36105,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -36357,9 +36357,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "partial", @@ -36553,9 +36553,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -37337,9 +37337,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Creates hard or symbolic links and reads link targets using string, Buffer, or file URL paths, with encoded results and deferred error-first callbacks; complete filesystem error metadata remains unsupported.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -37673,9 +37673,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Creates hard or symbolic links and reads link targets using string, Buffer, or file URL paths, with encoded results and deferred error-first callbacks; complete filesystem error metadata remains unsupported.", + "verification": "test-backed" }, "dynamic": { "status": "partial", @@ -37925,9 +37925,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Creates hard or symbolic links and reads link targets using string, Buffer, or file URL paths, with encoded results and deferred error-first callbacks; complete filesystem error metadata remains unsupported.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -39073,9 +39073,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "partial", @@ -39381,9 +39381,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index ccf66edc6..0f16b5539 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 42, - "verify-gap": 2466, - "audit-partial": 705, + "verify-gap": 2458, + "audit-partial": 713, "classify": 2, "implement": 80 }, @@ -35505,12 +35505,16 @@ "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": "surface-manifest:node-builtin.fs.promises.link", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -35839,12 +35843,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": "surface-manifest:node-builtin.fs.promises.readlink", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -36105,12 +36114,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": "surface-manifest:node-builtin.fs.promises.symlink", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -37103,12 +37117,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.link", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -37526,12 +37545,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.readlink", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -37851,12 +37875,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.symlink", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -39033,7 +39062,10 @@ "confidence": "high", "source": "surface-manifest:node-builtin.fs.linkSync", "tests": [ - "tests/corpus/2950-fs-descriptor-publication.ts" + "tests/corpus/2950-fs-descriptor-publication.ts", + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" ] }, "dynamic": { @@ -39374,12 +39406,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": "surface-manifest:node-builtin.fs.readlinkSync", + "tests": [ + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -39803,12 +39840,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": "surface-manifest:node-builtin.fs.symlinkSync", + "tests": [ + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index deb28dfa4..904ff42e2 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -507,9 +507,9 @@ "apiEntries": 276, "static": { "supported": 0, - "partial": 86, + "partial": 94, "refused": 0, - "not-implemented": 190, + "not-implemented": 182, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -50948,11 +50948,18 @@ "tests/corpus/3136-fs-file-handle-options.ts", "tests/corpus/3137-fs-vector-callbacks.cjs", "tests/corpus/fs-native-realpath.ts", + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links.ts", "tests/corpus/node24-fs-timestamps-callbacks.cjs", "tests/corpus/node24-fs-timestamps-errors.cjs", "tests/corpus/node24-fs-timestamps-promises.ts", "tests/corpus/node24-fs-timestamps-symlinks.ts", "tests/corpus/node24-fs-timestamps.ts", + "tests/fixtures/node-types/fs-links.ts", "tests/fixtures/node-types/fs-timestamps.ts" ] }, @@ -52268,8 +52275,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.promises.link", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -52559,8 +52571,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.promises.readlink", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -52793,8 +52811,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.promises.symlink", + "tests": [ + "tests/corpus/node24-fs-links-promises.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-promise-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -53701,8 +53725,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.link", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -54115,8 +54145,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.readlink", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -54395,8 +54431,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.symlink", + "tests": [ + "tests/corpus/node24-fs-links-callbacks.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/corpus/node24-fs-links-options.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -55553,7 +55595,10 @@ "status": "partial", "evidence": "surface-manifest:node-builtin.fs.linkSync", "tests": [ - "tests/corpus/2950-fs-descriptor-publication.ts" + "tests/corpus/2950-fs-descriptor-publication.ts", + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" ] }, "dynamic": { @@ -55856,8 +55901,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.readlinkSync", + "tests": [ + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "partial", @@ -56237,8 +56288,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.symlinkSync", + "tests": [ + "tests/corpus/node24-fs-links.ts", + "tests/corpus/node24-fs-links-options.cjs", + "tests/corpus/node24-fs-links-errors.cjs", + "tests/fixtures/node-types/fs-links.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index 2940cc798..73a985284 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -601,6 +601,9 @@ function publicDetail(tier) { } if (source.startsWith("compiler-dedicated:")) return "Implemented by a dedicated static compiler/runtime path."; if (source.startsWith("compiler-feature:")) { + if (/^compiler-feature:fs\.fs\.(?:link|symlink|readlink)$/.test(source)) { + return "Creates hard or symbolic links and reads link targets using string, Buffer, or file URL paths, with encoded results and deferred error-first callbacks; complete filesystem error metadata remains unsupported."; + } if (/^compiler-feature:fs\.(?:fs\.(?:utimes|futimes|lutimes)|filehandle\.utimes)$/.test(source)) { return "Changes timestamps using numeric seconds, numeric strings, and Dates with tested argument validation; link variants update the symlink itself."; } diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 0edc0e63e..907cae15b 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -65,7 +65,12 @@ "node-builtin.fs.readv": ["tests/corpus/3137-fs-vector-callbacks.cjs"], "node-builtin.fs.writev": ["tests/corpus/3137-fs-vector-callbacks.cjs"], "node-builtin.fs/promises.open": ["tests/corpus/2686-fs-file-handle.ts", "tests/corpus/3135-fs-file-handle-controls.ts"], - "node-builtin.fs.linkSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], + "node-builtin.fs.linkSync": ["tests/corpus/2950-fs-descriptor-publication.ts", "tests/corpus/node24-fs-links.ts", "tests/corpus/node24-fs-links-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], + "node-builtin.fs.symlinkSync": ["tests/corpus/node24-fs-links.ts", "tests/corpus/node24-fs-links-options.cjs", "tests/corpus/node24-fs-links-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], + "node-builtin.fs.readlinkSync": ["tests/corpus/node24-fs-links.ts", "tests/corpus/node24-fs-links-options.cjs", "tests/corpus/node24-fs-links-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], + "node-builtin.fs.promises.link": ["tests/corpus/node24-fs-links-promises.ts", "tests/corpus/node24-fs-links-promise-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], + "node-builtin.fs.promises.symlink": ["tests/corpus/node24-fs-links-promises.ts", "tests/corpus/node24-fs-links-options.cjs", "tests/corpus/node24-fs-links-promise-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], + "node-builtin.fs.promises.readlink": ["tests/corpus/node24-fs-links-promises.ts", "tests/corpus/node24-fs-links-options.cjs", "tests/corpus/node24-fs-links-promise-errors.cjs", "tests/fixtures/node-types/fs-links.ts"], "node-builtin.http": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2700-http-static-trailers.ts", "tests/corpus/2701-http-static-trailer-wire.ts", "tests/corpus/2702-http-static-trailer-end.ts", "tests/corpus/2957-http-static-response-controls.ts", "tests/fixtures/server/cases/http-trailers/main.ts"], "node-builtin.http2": ["tests/corpus/2260-http2-constants.cjs"], "node-builtin.https": ["tests/corpus/2645-https-client-url-argument.ts", "tests/corpus/2690-https-server-timeout-option.ts"], @@ -126,6 +131,7 @@ { "chapter": "fs", "symbols": ["FileHandle", "filehandle.fd", "filehandle.close", "filehandle.read", "filehandle.write", "filehandle.readFile", "filehandle.writeFile", "filehandle.appendFile", "filehandle.stat"], "status": "partial", "evidence": ["tests/corpus/2686-fs-file-handle.ts", "tests/corpus/3136-fs-file-handle-options.ts"] }, { "chapter": "fs", "symbols": ["filehandle.sync", "filehandle.datasync", "filehandle.truncate", "filehandle.chmod", "filehandle.readv", "filehandle.writev"], "status": "partial", "evidence": ["tests/corpus/3135-fs-file-handle-controls.ts"] }, { "chapter": "fs", "symbols": ["filehandle.utimes"], "status": "partial", "evidence": ["tests/corpus/node24-fs-timestamps-promises.ts", "tests/fixtures/node-types/fs-timestamps.ts"] }, + { "chapter": "fs", "symbols": ["fs.link", "fs.symlink", "fs.readlink"], "status": "partial", "evidence": ["tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", "tests/corpus/node24-fs-links-options.cjs", "tests/fixtures/node-types/fs-links.ts"] }, { "chapter": "fs", "symbols": ["fs.utimes", "fs.futimes", "fs.lutimes"], "status": "partial", "evidence": ["tests/corpus/node24-fs-timestamps-callbacks.cjs", "tests/corpus/node24-fs-timestamps-symlinks.ts", "tests/corpus/node24-fs-timestamps-errors.cjs"] }, { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index cf6f289d7..53e9d1eac 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1099,7 +1099,18 @@ declare module "node:fs" { export function chmodSync(path: string, mode: number): void; export function fchmodSync(fd: number, mode: number): void; export function fsyncSync(fd: number): void; - export function linkSync(existingPath: string, newPath: string): void; + export function linkSync(existingPath: string | Uint8Array | URL, newPath: string | Uint8Array | URL): void; + export function symlinkSync(target: string | Uint8Array | URL, path: string | Uint8Array | URL, type?: string | null): void; + export function symlink(target: string | Uint8Array | URL, path: string | Uint8Array | URL, callback: (err: NodeJS.ErrnoException | null) => void): void; + export function symlink(target: string | Uint8Array | URL, path: string | Uint8Array | URL, type: string | null | undefined, callback: (err: NodeJS.ErrnoException | null) => void): void; + export function link(existingPath: string | Uint8Array | URL, newPath: string | Uint8Array | URL, callback: (err: NodeJS.ErrnoException | null) => void): void; + export function readlink(path: string | Uint8Array | URL, callback: (err: NodeJS.ErrnoException | null, target: string) => void): void; + export function readlink(path: string | Uint8Array | URL, options: "buffer" | { encoding: "buffer" }, callback: (err: NodeJS.ErrnoException | null, target: Buffer) => void): void; + export function readlink(path: string | Uint8Array | URL, options: BufferEncoding | { encoding?: BufferEncoding | null } | null, callback: (err: NodeJS.ErrnoException | null, target: string) => void): void; + export function readlink(path: string | Uint8Array | URL, options: string | { encoding?: string | null } | null, callback: (err: NodeJS.ErrnoException | null, target: string | Buffer) => void): void; + export function readlinkSync(path: string | Uint8Array | URL, options: "buffer" | { encoding: "buffer" }): Buffer; + export function readlinkSync(path: string | Uint8Array | URL, options?: BufferEncoding | { encoding?: BufferEncoding | null } | null): string; + export function readlinkSync(path: string | Uint8Array | URL, options: string | { encoding?: string | null } | null): string | Buffer; export function chownSync(path: string, uid: number, gid: number): void; /* The 2-argument form only (Node's mode flags have no lowering). The * destination is created or truncated carrying the SOURCE's mode. */ @@ -1324,6 +1335,11 @@ declare module "fs/promises" { [Symbol.asyncDispose](): Promise; } export function open(path: string, flags?: string, mode?: number): Promise; + export function link(existingPath: string | Uint8Array | URL, newPath: string | Uint8Array | URL): Promise; + export function symlink(target: string | Uint8Array | URL, path: string | Uint8Array | URL, type?: string | null): Promise; + export function readlink(path: string | Uint8Array | URL, options: "buffer" | { encoding: "buffer" }): Promise; + export function readlink(path: string | Uint8Array | URL, options?: BufferEncoding | { encoding?: BufferEncoding | null } | null): Promise; + export function readlink(path: string | Uint8Array | URL, options: string | { encoding?: string | null } | null): Promise; export function utimes(path: string | Uint8Array | URL, atime: number | string | Date, mtime: number | string | Date): Promise; export function lutimes(path: string | Uint8Array | URL, atime: number | string | Date, mtime: number | string | Date): Promise; export function readFile(path: string, encoding: "utf8" | "utf-8"): Promise; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 3e84059a8..89525c8ce 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -355,7 +355,11 @@ export const LIB_FN_SYMS: Record = { "fs.ftruncateSync": "scr_fs_ftruncate", "fs.readvSync": "scr_fs_readv_sync", "fs.writevSync": "scr_fs_writev_sync", - "fs.linkSync": "scr_fs_link", + "fs.linkSync": "scr_fs_link_checked", + "fs.symlinkSync": "scr_fs_symlink_checked", + "fs.readlinkSync": "scr_fs_readlink_dyn", + "fs.readlinkSyncStr": "scr_fs_readlink_str", + "fs.readlinkSyncBuffer": "scr_fs_readlink_buffer", "fs.openSync": "scr_fs_open", "fs.openNumericSync": "scr_fs_open_numeric", "fs.readSync": "scr_fs_read_sync", @@ -545,6 +549,11 @@ export const LIB_FN_SYMS: Record = { "fsp.mkdirRecursiveMode": "scr_fsp_mkdir_recursive_mode", "fsp.unlink": "scr_fsp_unlink", "fsp.chmod": "scr_fsp_chmod", + "fsp.link": "scr_fsp_link", + "fsp.symlink": "scr_fsp_symlink", + "fsp.readlink": "scr_fsp_readlink_dyn", + "fsp.readlinkStr": "scr_fsp_readlink_str", + "fsp.readlinkBuffer": "scr_fsp_readlink_buffer", "fsp.utimes": "scr_fsp_utimes", "fsp.lutimes": "scr_fsp_lutimes", "fsp.rename": "scr_fsp_rename", diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 114da6ad8..475c95fb4 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -1672,6 +1672,27 @@ function lowerFsSyncBufferWindow( ? lowerFsTimestampValue(lowerer, arg, loc) : lowerer.lowerExprExpecting(arg, DYN)), type: DYN, loc }; return { kind: "libCall", fn: "fs.callbackCall", args: [{ kind: "strLit", value: bi.member, type: STRING, loc }, args], type: DYN, loc }; } + if ((bi.module === "fs" && ["linkSync", "symlinkSync", "readlinkSync"].includes(bi.member)) || + (bi.module === "fs/promises" && ["link", "symlink", "readlink"].includes(bi.member))) { + const symbolic = bi.member === "symlinkSync" || bi.member === "symlink"; + const reading = bi.member === "readlinkSync" || bi.member === "readlink"; + const arity = symbolic ? 3 : 2; + if (expr.arguments.length > arity || expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering(`${name} with this argument shape`, expr); + const args = Array.from({ length: arity }, (_, index) => expr.arguments[index] + ? lowerer.lowerExprExpecting(expr.arguments[index]!, DYN) : dynUndefinedExpr(loc)); + if (reading) { + const promise = bi.module === "fs/promises"; + const result = lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? fn.result; + const inner = result.kind === "promise" ? result.inner : result; + const bytes = inner.kind === "bytes"; + const text = inner.kind === "string"; + const selected = promise ? bytes ? "fsp.readlinkBuffer" : text ? "fsp.readlinkStr" : "fsp.readlink" + : bytes ? "fs.readlinkSyncBuffer" : text ? "fs.readlinkSyncStr" : "fs.readlinkSync"; + const valueType = bytes ? BYTES_U8 : text ? STRING : DYN; + return { kind: "libCall", fn: selected, args, type: promise ? { kind: "promise", inner: valueType } : valueType, loc }; + } + return { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; + } if ((bi.module === "fs" && ["utimesSync", "futimesSync", "lutimesSync"].includes(bi.member)) || (bi.module === "fs/promises" && ["utimes", "lutimes"].includes(bi.member))) { if (expr.arguments.length > 3 || expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering(`${name} with this argument shape`, expr); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index f74139b11..8cd96c5ec 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -728,7 +728,13 @@ export const BUILTIN_MODULE_FNS: Record = new Set([ "fs.readvSync", "fs.writevSync", "fs.linkSync", + "fs.symlinkSync", + "fs.readlinkSync", + "fs.readlinkSyncStr", + "fs.readlinkSyncBuffer", "fs.writeFileModeSync", "fs.mkdirModeSync", "fs.mkdirRecursiveModeSync", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 14680cef1..aa260507f 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -455,7 +455,11 @@ export const LIB_FN_SIGS: Record/tests/corpus/node24-fs-links-callbacks.cjs": { + "order": [ + "/tests/corpus/node24-fs-links-callbacks.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-fs-links-errors.cjs": { + "order": [ + "/tests/corpus/node24-fs-links-errors.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-fs-links-options.cjs": { + "order": [ + "/tests/corpus/node24-fs-links-options.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-fs-links-promise-errors.cjs": { + "order": [ + "/tests/corpus/node24-fs-links-promise-errors.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-fs-links-promises.ts": { + "order": [ + "/tests/corpus/node24-fs-links-promises.ts" + ], + "diags": [] + }, + "/tests/corpus/node24-fs-links.ts": { + "order": [ + "/tests/corpus/node24-fs-links.ts" + ], + "diags": [] + }, "/tests/corpus/node24-fs-timestamps-callbacks.cjs": { "order": [ "/tests/corpus/node24-fs-timestamps-callbacks.cjs" @@ -13319,6 +13355,12 @@ ], "diags": [] }, + "/tests/fixtures/node-types/fs-links.ts": { + "order": [ + "/tests/fixtures/node-types/fs-links.ts" + ], + "diags": [] + }, "/tests/fixtures/node-types/fs-timestamps.ts": { "order": [ "/tests/fixtures/node-types/fs-timestamps.ts" diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 047db826c..fb88973f7 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1763,6 +1763,35 @@ ScrPromise *scr_fsp_utimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn return scr_promise_settled_void(); } +ScrPromise *scr_fsp_link(const ScrDyn *existing, const ScrDyn *destination) { + scr_fs_link_checked(existing, destination); + return scr_promise_settled_void(); +} + +ScrPromise *scr_fsp_symlink(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type) { + scr_fs_symlink_promise_checked(target, destination, type); + return scr_promise_settled_void(); +} + +ScrPromise *scr_fsp_readlink_dyn(const ScrDyn *path, const ScrDyn *options) { + ScrDyn *result = scr_fs_readlink_checked(path, options, true); + return scr_promise_settled_ref(result, scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); +} + +ScrPromise *scr_fsp_readlink_str(const ScrDyn *path, const ScrDyn *options) { + ScrDyn *result = scr_fs_readlink_checked(path, options, true); + ScrStr *text = result && result->kind == SCR_DYN_STR ? scr_str_retain(result->v.str) : NULL; + scr_dyn_release(result); + return scr_promise_settled_str(text); +} + +ScrPromise *scr_fsp_readlink_buffer(const ScrDyn *path, const ScrDyn *options) { + ScrDyn *result = scr_fs_readlink_checked(path, options, true); + ScrBytes *bytes = result ? scr_dyn_bytes_unbox(result) : NULL; + scr_dyn_release(result); + return scr_promise_settled_ref(bytes, scr_bytes_retain_v, scr_bytes_release_v, NULL); +} + ScrPromise *scr_fsp_lutimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn *mtime) { scr_fs_lutimes_checked(path, atime, mtime); return scr_promise_settled_void(); diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 76238d1c5..b352fd499 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -279,24 +279,180 @@ static ScrStr *scr_fs_cb_path(const ScrDyn *value, const char *name) { ScrStr *path = value->kind == SCR_DYN_STR ? scr_str_retain(value->v.str) : scr_str_new((const char *)value->v.bytes->data, value->v.bytes->len); if (memchr(path->data, 0, path->len)) { - if (!strcmp(name, "path")) scr_fs_invalid_path(value, path); - else scr_dyn_arg_value_fail(name, "must be a string, Uint8Array, or URL without null bytes", value); + scr_fs_invalid_named_path(value, path, name); scr_str_release(path); return NULL; } return path; } -static ScrStr *scr_fs_timestamp_path(const ScrDyn *value) { - if (!scr_dyn_native_url_is(value)) return scr_fs_cb_path(value, "path"); +static ScrStr *scr_fs_checked_path(const ScrDyn *value, const char *name) { + if (!scr_dyn_native_url_is(value)) return scr_fs_cb_path(value, name); ScrStr *path = scr_url_checked_to_path(value); if (!path) return NULL; ScrDyn input = { .kind = SCR_DYN_STR, .v.str = path }; - ScrStr *checked = scr_fs_cb_path(&input, "path"); + ScrStr *checked = scr_fs_cb_path(&input, name); scr_str_release(path); return checked; } +static ScrStr *scr_fs_timestamp_path(const ScrDyn *value) { + return scr_fs_checked_path(value, "path"); +} + +/* validateOneOf runs before either path, even on POSIX where the type has + * no syscall effect. -1 means infer the target's kind on Windows. */ +static int scr_fs_symlink_type(const ScrDyn *type) { + if (scr_fs_dyn_absent(type)) return -1; + if (type->kind == SCR_DYN_STR) { + const ScrStr *s = type->v.str; + if (s->len == 4 && !memcmp(s->data, "file", 4)) return 0; + if (s->len == 3 && !memcmp(s->data, "dir", 3)) return 1; + if (s->len == 8 && !memcmp(s->data, "junction", 8)) return 2; + } + scr_dyn_arg_value_fail("type", "must be one of: 'dir', 'file', 'junction', null, undefined", type); + return -1; +} + +void scr_fs_link_checked(const ScrDyn *existing, const ScrDyn *destination) { + ScrStr *from = scr_fs_checked_path(existing, "existingPath"); + if (!from) return; + ScrStr *to = scr_fs_checked_path(destination, "newPath"); + if (to) scr_fs_link(from, to); + scr_str_release(from); scr_str_release(to); +} + +static void scr_fs_symlink_checked_core(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type, bool promise) { + int kind = scr_fs_symlink_type(type); + if (scr_exc_pending()) return; +#ifdef _WIN32 + if (kind == -1) { + // Sync/promise inference stringifies the raw destination and target before + // getValidatedPath. Sync stat exposes null-byte errors in the resolved path; + // the promise form catches inference failures and continues as a file link. + ScrStr *link = scr_dyn_string_coerce_js(destination); + ScrStr *source = !scr_exc_pending() ? scr_dyn_string_coerce_js(target) : NULL; + ScrStr *absolute = NULL; + if (link && source && !scr_exc_pending()) { + ScrArr *parts = scr_arr_new_ref(scr_str_retain_v, scr_str_release_v, NULL, 3); + scr_arr_push_ref(parts, scr_str_retain(link)); + scr_arr_push_ref(parts, scr_str_new("..", 2)); + scr_arr_push_ref(parts, scr_str_retain(source)); + absolute = scr_path_win32_resolve(parts); + scr_arr_release(parts); + } + kind = 0; + if (absolute && !scr_exc_pending()) { + ScrDyn input = { .kind = SCR_DYN_STR, .v.str = absolute }; + ScrStr *checked = scr_fs_cb_path(&input, "path"); + if (checked) kind = scr_fs_symlink_infer(checked); + scr_str_release(checked); + } + scr_str_release(link); scr_str_release(source); scr_str_release(absolute); + if (scr_exc_pending()) { + if (!promise) return; + scr_caught_release(scr_exc_take()); + } + } +#else + (void)promise; +#endif + ScrStr *from = scr_fs_checked_path(target, "target"); + if (!from) return; + ScrStr *to = scr_fs_checked_path(destination, "path"); +#ifdef _WIN32 + if (to && kind == 2 && scr_dyn_bytes_is(destination, SCR_BYTES_U8)) { + scr_dyn_arg_type_fail("paths[0]", "of type string", destination); + scr_str_release(to); to = NULL; + } +#endif + if (to) scr_fs_symlink(from, to, kind); + scr_str_release(from); scr_str_release(to); +} + +void scr_fs_symlink_checked(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type) { + scr_fs_symlink_checked_core(target, destination, type, false); +} + +void scr_fs_symlink_promise_checked(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type) { + scr_fs_symlink_checked_core(target, destination, type, true); +} + +/* getOptions accepts functions as defaults and validates signals without + * cancelling readlink. Unlike readFile, an aborted signal is ignored. */ +static ScrDyn *scr_fs_readlink_encoding(const ScrDyn *options) { + if (scr_fs_dyn_absent(options) || options->kind == SCR_DYN_FUNC) return scr_dyn_retain(scr_dyn_undefined()); + if (options->kind != SCR_DYN_STR && options->kind != SCR_DYN_OBJ && + options->kind != SCR_DYN_ARR && options->kind != SCR_DYN_TYPED_REF && + options->kind != SCR_DYN_HANDLE && options->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("options", "one of type string or object", options); + return NULL; + } + ScrDyn *encoding = options->kind == SCR_DYN_STR ? scr_dyn_retain((ScrDyn *)options) : scr_fs_cb_option(options, "encoding"); + if (!encoding) return NULL; + bool buffer = encoding->kind == SCR_DYN_STR && encoding->v.str->len == 6 && !memcmp(encoding->v.str->data, "buffer", 6); + if (!buffer && scr_dyn_truthy(encoding) && + (encoding->kind != SCR_DYN_STR || !scr_bytes_is_encoding(encoding->v.str))) { + scr_dyn_arg_value_fail("encoding", "is invalid encoding", encoding); + scr_dyn_release(encoding); + return NULL; + } + if (options->kind != SCR_DYN_STR) { + ScrDyn *signal = scr_fs_cb_option(options, "signal"); + if (!signal) { scr_dyn_release(encoding); return NULL; } + bool valid = signal->kind == SCR_DYN_UNDEF || (signal->kind == SCR_DYN_HANDLE && signal->v.handle.tag == SCR_DYNH_ABORT_SIGNAL); + if (!valid) scr_dyn_prop_type_fail("options.signal", "an instance of AbortSignal", signal); + scr_dyn_release(signal); + if (!valid) { scr_dyn_release(encoding); return NULL; } + } + return encoding; +} + +static ScrDyn *scr_fs_readlink_result(ScrStr *path, const ScrDyn *encoding) { + ScrBytes *bytes = scr_fs_readlink_bytes(path); + if (!bytes) return NULL; + ScrDyn *result; + if (encoding->kind == SCR_DYN_STR && encoding->v.str->len == 6 && !memcmp(encoding->v.str->data, "buffer", 6)) { + result = scr_dyn_new_buffer(bytes); + } + else { + ScrStr *codec = encoding->kind == SCR_DYN_STR && encoding->v.str->len ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + ScrStr *text = scr_bytes_to_str(bytes, codec); + scr_str_release(codec); + result = text ? scr_dyn_new_str(text) : NULL; + scr_str_release(text); + } + scr_bytes_release(bytes); + return result; +} + +ScrDyn *scr_fs_readlink_checked(const ScrDyn *input, const ScrDyn *options, bool promise) { + ScrDyn *encoding = scr_fs_readlink_encoding(options); + if (!encoding) return NULL; + ScrStr *path = scr_fs_checked_path(input, promise ? "oldPath" : "path"); + ScrDyn *result = path ? scr_fs_readlink_result(path, encoding) : NULL; + scr_str_release(path); scr_dyn_release(encoding); + return result; +} + +ScrDyn *scr_fs_readlink_dyn(const ScrDyn *path, const ScrDyn *options) { + return scr_fs_readlink_checked(path, options, false); +} + +ScrStr *scr_fs_readlink_str(const ScrDyn *path, const ScrDyn *options) { + ScrDyn *result = scr_fs_readlink_dyn(path, options); + ScrStr *text = result && result->kind == SCR_DYN_STR ? scr_str_retain(result->v.str) : NULL; + scr_dyn_release(result); + return text; +} + +ScrBytes *scr_fs_readlink_buffer(const ScrDyn *path, const ScrDyn *options) { + ScrDyn *result = scr_fs_readlink_dyn(path, options); + ScrBytes *bytes = result ? scr_dyn_bytes_unbox(result) : NULL; + scr_dyn_release(result); + return bytes; +} + static ScrDyn *scr_fs_cb_string(ScrStr *text) { if (!text) return NULL; ScrDyn *value = scr_dyn_new_str(text); @@ -517,6 +673,46 @@ 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, "link") || !strcmp(op, "symlink") || !strcmp(op, "readlink")) { + const ScrDyn *first = scr_fs_cb_arg(args, argc, 0); + const ScrDyn *second = scr_fs_cb_arg(args, argc, 1); + bool symbolic = !strcmp(op, "symlink"), reading = !strcmp(op, "readlink"); + const ScrDyn *third = scr_fs_cb_arg(args, argc, 2); + const ScrDyn *fourth = scr_fs_cb_arg(args, argc, 3); + const ScrDyn *callback = symbolic && fourth->kind != SCR_DYN_UNDEF ? fourth + : reading && second->kind == SCR_DYN_FUNC ? second : third; + int kind = -1; + if (symbolic && fourth->kind != SCR_DYN_UNDEF) { + kind = scr_fs_symlink_type(third); + if (scr_exc_pending()) return NULL; + } else if (!scr_fs_cb_chk(callback, "cb")) return NULL; + ScrDyn *encoding = reading ? scr_fs_readlink_encoding(second) : NULL; + if (reading && !encoding) return NULL; + ScrStr *from = scr_fs_checked_path(first, symbolic ? "target" : reading ? "path" : "existingPath"); + if (!from) { scr_dyn_release(encoding); return NULL; } + ScrStr *to = reading ? NULL : scr_fs_checked_path(second, symbolic ? "path" : "newPath"); + if ((!reading && !to) || (symbolic && fourth->kind != SCR_DYN_UNDEF && !scr_fs_cb_chk(callback, "cb"))) { + scr_str_release(from); scr_str_release(to); scr_dyn_release(encoding); return NULL; + } +#ifdef _WIN32 + if (symbolic) { + if (kind == 2 && scr_dyn_bytes_is(second, SCR_BYTES_U8)) { + scr_dyn_arg_type_fail("paths[0]", "of type string", second); + scr_str_release(from); scr_str_release(to); return NULL; + } + // Callback inference passes validated paths directly to path.resolve; + // Buffer paths fail that probe and fall back to a file link. + if (kind == -1 && (scr_dyn_bytes_is(first, SCR_BYTES_U8) || scr_dyn_bytes_is(second, SCR_BYTES_U8))) kind = 0; + } +#endif + ScrDyn *result = NULL; + if (reading) result = scr_fs_readlink_result(from, encoding); + else if (symbolic) scr_fs_symlink(from, to, kind); + else scr_fs_link(from, to); + scr_str_release(from); scr_str_release(to); scr_dyn_release(encoding); + scr_fs_cb_schedule(callback, result, NULL); + return scr_dyn_retain(scr_dyn_undefined()); + } bool vector = !strcmp(op, "readv") || !strcmp(op, "writev"); bool timestamp = !strcmp(op, "utimes") || !strcmp(op, "lutimes") || !strcmp(op, "futimes"); bool timestamp_fd = !strcmp(op, "futimes"); @@ -673,11 +869,10 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc scr_dyn_release(ownedBuffer); scr_str_release(path); return scr_dyn_retain(scr_dyn_undefined()); } - } else if (!strcmp(op, "copyFile") || !strcmp(op, "rename") || !strcmp(op, "link") || !strcmp(op, "symlink")) { + } else if (!strcmp(op, "copyFile") || !strcmp(op, "rename")) { ScrStr *destination = scr_fs_cb_path(ARG(1), "dest"); if (destination) { if (!strcmp(op, "rename")) scr_fs_rename(path, destination); - else if (!strcmp(op, "link")) scr_fs_link(path, destination); else if (!strcmp(op, "copyFile")) { double flags = scr_fs_cb_number(ARG(2), "mode", 0); if (flags == 1 && scr_fs_exists(destination)) scr_fs_throw(EEXIST, "copyfile", destination); @@ -686,13 +881,6 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); } else if (!scr_exc_pending()) scr_fs_copyfile(path, destination); } - else { -#if !defined(_WIN32) - if (symlink(path->data, destination->data) < 0) scr_fs_throw(errno, "symlink", path); -#else - scr_fs_throw(ENOSYS, "symlink", path); -#endif - } scr_str_release(destination); } } else if (!strcmp(op, "truncate") || !strcmp(op, "ftruncate")) { @@ -701,23 +889,6 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc if (descriptor) scr_fs_ftruncate(fd, length); else if (truncate(path->data, (off_t)length) < 0) scr_fs_throw(errno, "truncate", path); } - } else if (!strcmp(op, "readlink")) { -#ifndef _WIN32 - size_t capacity = 256; - char *text = NULL; - for (;;) { - char *grown = realloc(text, capacity); - if (!grown) scr_bytes_io_oom(); - text = grown; - ssize_t size = readlink(path->data, text, capacity); - if (size < 0) { scr_fs_throw(errno, "readlink", path); break; } - if ((size_t)size < capacity) { result = scr_fs_cb_string(scr_str_new(text, (size_t)size)); break; } - capacity *= 2; - } - free(text); -#else - scr_fs_throw(ENOSYS, "readlink", path); -#endif } else if (timestamp) { if (timestamp_fd) scr_fs_futimes(fd, access, modified); else scr_fs_utimes(path, access, modified, !strcmp(op, "lutimes")); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9f24e1248..0377e5a4c 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -55,6 +55,8 @@ static WCHAR *scr_fs_win_wide(const ScrStr *path); static int scr_fs_win_errno(DWORD error); +static bool scr_stats_is_link_tag(DWORD tag); +static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr **text_out); static ScrStr *scr_fs_win_utf8(const WCHAR *text, size_t length) { if (length > INT_MAX) { SetLastError(ERROR_FILENAME_EXCED_RANGE); return NULL; } @@ -869,10 +871,12 @@ ScrDyn *scr_util_parse_env(const ScrDyn *content) { return scr_env_parse_content(content->v.str, true); } -void scr_fs_invalid_path(const ScrDyn *value, const ScrStr *path) { +void scr_fs_invalid_named_path(const ScrDyn *value, const ScrStr *path, const char *name) { ScrJsonBuf b; scr_jb_init(&b); - scr_jb_puts(&b, "The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received "); + scr_jb_puts(&b, "The argument '"); + scr_jb_puts(&b, name); + scr_jb_puts(&b, "' must be a string, Uint8Array, or URL without null bytes. Received "); if (value->kind == SCR_DYN_BYTES) { const ScrBytes *bytes = value->v.bytes; if (value->buffer) { @@ -916,6 +920,10 @@ void scr_fs_invalid_path(const ScrDyn *value, const ScrStr *path) { scr_str_release(message); } +void scr_fs_invalid_path(const ScrDyn *value, const ScrStr *path) { + scr_fs_invalid_named_path(value, path, "path"); +} + void scr_process_load_env_file(const ScrDyn *value) { ScrStr *path; if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL) path = scr_str_new("./.env", 6); @@ -2501,7 +2509,45 @@ void scr_fs_mkdir_mode(ScrStr *path, double mode) { if (scr_sys_mkdir(path->data, (mode_t)mode) != 0) scr_fs_throw(errno, "mkdir", path); } +#ifdef _WIN32 +/* The CRT's stat follows junctions. Recognize real link reparse records + * before unlink/rm dispatch so a tree walk never descends into their targets. + * Return 1 for a removed link, 0 for an ordinary entry, -1 on failure. */ +static int scr_fs_win_remove_link(const char *path, size_t len) { + ScrStr *text = scr_str_new(path, len); + WCHAR *wide = scr_fs_win_wide(text); + scr_str_release(text); + if (!wide) { errno = scr_fs_win_errno(GetLastError()); return -1; } + DWORD attributes = GetFileAttributesW(wide); + if (attributes == INVALID_FILE_ATTRIBUTES || !(attributes & FILE_ATTRIBUTE_REPARSE_POINT)) { free(wide); return 0; } + HANDLE h = CreateFileW(wide, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (h == INVALID_HANDLE_VALUE) { DWORD error = GetLastError(); free(wide); errno = scr_fs_win_errno(error); return -1; } + FILE_ATTRIBUTE_TAG_INFO tagged; + if (!GetFileInformationByHandleEx(h, FileAttributeTagInfo, &tagged, sizeof tagged)) { + DWORD error = GetLastError(); CloseHandle(h); free(wide); errno = scr_fs_win_errno(error); return -1; + } + double size; + bool link = scr_stats_is_link_tag(tagged.ReparseTag); + // Mount-point tags also represent mounted volumes. Only junction payloads + // are links; keep the existing ordinary-directory behavior for volumes. + if (link && tagged.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT) link = scr_stats_link_target(h, tagged.ReparseTag, &size, NULL); + CloseHandle(h); + if (!link) { free(wide); return 0; } + BOOL ok = attributes & FILE_ATTRIBUTE_DIRECTORY ? RemoveDirectoryW(wide) : DeleteFileW(wide); + DWORD error = ok ? 0 : GetLastError(); + free(wide); + if (!ok) { errno = scr_fs_win_errno(error); return -1; } + return 1; +} +#endif + void scr_fs_unlink(ScrStr *path) { +#ifdef _WIN32 + int link = scr_fs_win_remove_link(path->data, path->len); + if (link != 0) { if (link < 0) scr_fs_throw(errno, "unlink", path); return; } +#endif if (unlink(path->data) != 0) scr_fs_throw(errno, "unlink", path); } @@ -3239,18 +3285,15 @@ void scr_fs_link(ScrStr *source, ScrStr *dest) { WCHAR *from = scr_fs_win_wide(source); WCHAR *to = scr_fs_win_wide(dest); if (!from || !to) { + DWORD error = GetLastError(); free(from); free(to); - scr_fs_throw2(EINVAL, "link", source, dest); + scr_fs_throw2(scr_fs_win_errno(error), "link", source, dest); return; } BOOL ok = CreateHardLinkW(to, from, NULL); DWORD error = ok ? 0 : GetLastError(); free(from); free(to); - if (!ok) { - int code = error == ERROR_ALREADY_EXISTS || error == ERROR_FILE_EXISTS ? EEXIST : - error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? ENOENT : EACCES; - scr_fs_throw2(code, "link", source, dest); - } + if (!ok) scr_fs_throw2(scr_fs_win_errno(error), "link", source, dest); #else if (link(source->data, dest->data) != 0) scr_fs_throw2(errno, "link", source, dest); #endif @@ -3421,6 +3464,10 @@ void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath) { static int scr_rm_unlink(const char *path, size_t len); void scr_fs_rm(ScrStr *path) { +#ifdef _WIN32 + int link = scr_fs_win_remove_link(path->data, path->len); + if (link != 0) { if (link < 0) scr_fs_throw(errno, "unlink", path); return; } +#endif /* Node's rmSync: lstat first (a missing path reports the lstat syscall), * refuse directories (Node requires `recursive`, which the scriptc * surface doesn't declare — the message wording diverges from Node's @@ -3575,6 +3622,10 @@ static int scr_rm_unlink(const char *path, size_t len) { /* Post-order tree removal for rmSync's recursive form. Stops at (and * records) the first failure, with the failing path and syscall name. */ static void scr_rm_tree_e(const char *path, size_t len, ScrRmFail *f) { +#ifdef _WIN32 + int link = scr_fs_win_remove_link(path, len); + if (link != 0) { if (link < 0) scr_rm_fail_set(f, errno, "unlink", path, len); return; } +#endif struct stat st; if (lstat(path, &st) != 0) { scr_rm_fail_set(f, errno, "lstat", path, len); @@ -3615,6 +3666,10 @@ static void scr_rm_tree_e(const char *path, size_t len, ScrRmFail *f) { * force's ENOENT swallow, the non-recursive directory rejection, and the * tree walk — failures recorded in `f`, never thrown. */ static void scr_fs_rm_attempt(ScrStr *path, bool recursive, bool force, ScrRmFail *f) { +#ifdef _WIN32 + int link = scr_fs_win_remove_link(path->data, path->len); + if (link != 0) { if (link < 0) scr_rm_fail_set(f, errno, "unlink", path->data, path->len); return; } +#endif struct stat st; if (lstat(path->data, &st) != 0) { if (force && errno == ENOENT) return; /* Node: force swallows ENOENT */ @@ -4068,7 +4123,7 @@ typedef struct { /* Node/libuv reports a Windows link's target-text length as st_size. The * ordinary handle information does not carry that value, so read the reparse * payload while the no-follow handle is live. */ -static bool scr_stats_link_size(HANDLE h, DWORD tag, double *size_out) { +static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr **text_out) { union { ScrStatsReparseData align; unsigned char bytes[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; @@ -4113,6 +4168,7 @@ static bool scr_stats_link_size(HANDLE h, DWORD tag, double *size_out) { /* WSL's LX symlink payload is a version word followed by UTF-8 bytes. */ if (data->data_len < sizeof(ULONG)) return false; *size_out = (double)(data->data_len - sizeof(ULONG)); + if (text_out) *text_out = scr_str_new((const char *)data->body.generic.bytes + sizeof(ULONG), data->data_len - sizeof(ULONG)); return true; } else { /* App execution links carry a counted UTF-16 string list; the third @@ -4141,6 +4197,10 @@ static bool scr_stats_link_size(HANDLE h, DWORD tag, double *size_out) { double size = scr_stats_utf16_len(target, len); if (size < 0) return false; *size_out = size; + if (text_out) { + *text_out = scr_fs_win_utf8(target, len); + if (!*text_out) return false; + } return true; } @@ -4166,6 +4226,10 @@ static bool scr_stats_link_size(HANDLE h, DWORD tag, double *size_out) { double size = scr_stats_utf16_len(target, len); if (size < 0) return false; *size_out = size; + if (text_out) { + *text_out = scr_fs_win_utf8(target, len); + if (!*text_out) return false; + } return true; } @@ -4179,8 +4243,21 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, HANDLE h = CreateFileW(wide, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, flags, NULL); + if (h == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + DWORD attributes = GetFileAttributesW(wide); + free(wide); + /* CRT stat can report the link entry when its target cannot be opened, + * hiding dangling links and Windows file/directory type mismatches. + * libuv preserves the failed follow operation for reparse points. */ + if (!no_follow && attributes != INVALID_FILE_ATTRIBUTES && + (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + scr_fs_throw(scr_fs_win_errno(error), op, path); + return NULL; + } + return scr_stats_crt_fallback(path, op); + } free(wide); - if (h == INVALID_HANDLE_VALUE) return scr_stats_crt_fallback(path, op); DWORD file_type = GetFileType(h); if (file_type != FILE_TYPE_DISK) { @@ -4204,7 +4281,7 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, CloseHandle(h); return scr_stats_of_path(path, op, false); } - if (!scr_stats_link_size(h, tagged.ReparseTag, &link_size)) { + if (!scr_stats_link_target(h, tagged.ReparseTag, &link_size, NULL)) { /* A mount-point tag may name a mounted volume rather than a junction. * libuv treats those (and malformed/unsupported link payloads) as * ordinary reparse points and retries with the final component @@ -4281,6 +4358,145 @@ static ScrStats *scr_stats_of(const struct stat *st) { } #endif +/* Symbolic link targets are stored verbatim on POSIX. Windows junctions + * instead carry an absolute NT path in a bounded mount-point reparse record. */ +#ifdef _WIN32 +static DWORD scr_fs_win_junction(const WCHAR *target, const WCHAR *destination) { + size_t len = wcslen(target); + /* libuv permits drive-absolute junctions, not UNC mount targets. */ + size_t skip = len >= 4 && !wcsncmp(target, L"\\\\?\\", 4) ? 4 : 0; + const WCHAR *plain = target + skip; + size_t chars = len - skip; + if (chars < 3 || !((plain[0] >= L'A' && plain[0] <= L'Z') || (plain[0] >= L'a' && plain[0] <= L'z')) || plain[1] != L':' || plain[2] != L'\\') return ERROR_INVALID_PARAMETER; + size_t fixed = offsetof(ScrStatsReparseData, body.mount.path); + size_t total = fixed + (4 + chars + 1 + chars + 1) * sizeof(WCHAR); + if (total > MAXIMUM_REPARSE_DATA_BUFFER_SIZE) return ERROR_FILENAME_EXCED_RANGE; + ScrStatsReparseData *data = calloc(1, total); + if (!data) return ERROR_NOT_ENOUGH_MEMORY; + data->tag = IO_REPARSE_TAG_MOUNT_POINT; + data->data_len = (USHORT)(total - offsetof(ScrStatsReparseData, body)); + data->body.mount.substitute_offset = 0; + data->body.mount.substitute_len = (USHORT)((4 + chars) * sizeof(WCHAR)); + data->body.mount.print_offset = (USHORT)((4 + chars + 1) * sizeof(WCHAR)); + data->body.mount.print_len = (USHORT)(chars * sizeof(WCHAR)); + memcpy(data->body.mount.path, L"\\??\\", 4 * sizeof(WCHAR)); + memcpy(data->body.mount.path + 4, plain, chars * sizeof(WCHAR)); + memcpy(data->body.mount.path + 4 + chars + 1, plain, chars * sizeof(WCHAR)); + DWORD error = 0, used; + bool created = CreateDirectoryW(destination, NULL) != 0; + HANDLE h = INVALID_HANDLE_VALUE; + if (!created) error = GetLastError(); + else { + h = CreateFileW(destination, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (h == INVALID_HANDLE_VALUE) error = GetLastError(); + else if (!DeviceIoControl(h, FSCTL_SET_REPARSE_POINT, data, (DWORD)total, NULL, 0, &used, NULL)) error = GetLastError(); + } + if (h != INVALID_HANDLE_VALUE) CloseHandle(h); + if (error && created) RemoveDirectoryW(destination); + free(data); + return error; +} +#endif + +#ifdef _WIN32 +/* libuv keeps this fallback for later calls once Windows rejects the flag. */ +static DWORD scr_fs_symlink_usermode_flag = 0x2; /* ALLOW_UNPRIVILEGED_CREATE */ +#endif + +int scr_fs_symlink_infer(ScrStr *absolute) { +#ifdef _WIN32 + WCHAR *probe = scr_fs_win_wide(absolute); + DWORD attributes = probe ? GetFileAttributesW(probe) : INVALID_FILE_ATTRIBUTES; + free(probe); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) ? 1 : 0; +#else + (void)absolute; + return 0; +#endif +} + +void scr_fs_symlink(ScrStr *target, ScrStr *destination, int kind) { +#ifdef _WIN32 + ScrArr *parts = scr_arr_new_ref(scr_str_retain_v, scr_str_release_v, NULL, 3); + scr_arr_push_ref(parts, scr_str_retain(destination)); + scr_arr_push_ref(parts, scr_str_new("..", 2)); + scr_arr_push_ref(parts, scr_str_retain(target)); + ScrStr *absolute = scr_path_win32_resolve(parts); + scr_arr_release(parts); + if (kind == -1) kind = scr_fs_symlink_infer(absolute); + ScrStr *stored; + if (kind == 2) stored = scr_path_win32_to_namespaced_path(absolute); + else if (scr_path_win32_is_absolute(target)) stored = scr_path_win32_to_namespaced_path(target); + else { + char *text = malloc(target->len ? target->len : 1); + if (!text) scr_trap("scriptc: out of memory\n"); + for (size_t i = 0; i < target->len; i++) text[i] = target->data[i] == '/' ? '\\' : target->data[i]; + stored = scr_str_new(text, target->len); + free(text); + } + scr_str_release(absolute); + /* Node's binding namespaces the link path before passing it to libuv. */ + ScrStr *link_path = scr_path_win32_to_namespaced_path(destination); + WCHAR *from = scr_fs_win_wide(stored), *to = scr_fs_win_wide(link_path); + DWORD error = !from || !to ? GetLastError() : 0; + if (!error && kind == 2) error = scr_fs_win_junction(from, to); + else if (!error) { + DWORD flags = (kind == 1 ? SYMBOLIC_LINK_FLAG_DIRECTORY : 0) | scr_fs_symlink_usermode_flag; + if (!CreateSymbolicLinkW(to, from, flags)) { + error = GetLastError(); + if (error == ERROR_INVALID_PARAMETER && scr_fs_symlink_usermode_flag) { + scr_fs_symlink_usermode_flag = 0; + error = CreateSymbolicLinkW(to, from, flags & ~0x2u) ? 0 : GetLastError(); + } + } + } + free(from); free(to); scr_str_release(stored); scr_str_release(link_path); + if (error) scr_fs_throw2(scr_fs_win_errno(error), "symlink", target, destination); +#else + (void)kind; + if (symlink(target->data, destination->data) != 0) scr_fs_throw2(errno, "symlink", target, destination); +#endif +} + +ScrBytes *scr_fs_readlink_bytes(ScrStr *path) { +#ifdef _WIN32 + WCHAR *wide = scr_fs_win_wide(path); + if (!wide) { scr_fs_throw(scr_fs_win_errno(GetLastError()), "readlink", path); return NULL; } + HANDLE h = CreateFileW(wide, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wide); + if (h == INVALID_HANDLE_VALUE) { scr_fs_throw(scr_fs_win_errno(GetLastError()), "readlink", path); return NULL; } + FILE_ATTRIBUTE_TAG_INFO tagged; + ScrStr *text = NULL; + double size; + bool ok = GetFileInformationByHandleEx(h, FileAttributeTagInfo, &tagged, sizeof tagged) && + scr_stats_link_target(h, tagged.ReparseTag, &size, &text); + CloseHandle(h); + if (!ok || !text) { scr_str_release(text); scr_fs_throw(EINVAL, "readlink", path); return NULL; } + ScrBytes *bytes = scr_bytes_from_data((const uint8_t *)text->data, text->len); + scr_str_release(text); + return bytes; +#else + size_t capacity = 256; + uint8_t *text = NULL; + for (;;) { + uint8_t *grown = realloc(text, capacity); + if (!grown) { free(text); scr_trap("scriptc: out of memory\n"); } + text = grown; + ssize_t size = readlink(path->data, (char *)text, capacity); + if (size < 0) { int error = errno; free(text); scr_fs_throw(error, "readlink", path); return NULL; } + if ((size_t)size < capacity) { + ScrBytes *bytes = scr_bytes_from_data(text, (size_t)size); + free(text); + return bytes; + } + if (capacity > SIZE_MAX / 2) { free(text); scr_fs_throw(ENAMETOOLONG, "readlink", path); return NULL; } + capacity *= 2; + } +#endif +} + ScrStats *scr_fs_stat(ScrStr *path) { #ifdef _WIN32 return scr_stats_of_path(path, "stat", false); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 6ac93951e..65239f08e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2638,8 +2638,24 @@ void scr_fs_fdatasync(double fd); void scr_fs_ftruncate(double fd, double length); void scr_fs_utimes(ScrStr *path, double atime, double mtime, bool nofollow); void scr_fs_invalid_path(const ScrDyn *value, const ScrStr *path); +void scr_fs_invalid_named_path(const ScrDyn *value, const ScrStr *path, const char *name); void scr_fs_futimes(double fd, double atime, double mtime); void scr_fs_link(ScrStr *source, ScrStr *dest); +void scr_fs_symlink(ScrStr *target, ScrStr *destination, int kind); +int scr_fs_symlink_infer(ScrStr *absolute); +ScrBytes *scr_fs_readlink_bytes(ScrStr *path); +void scr_fs_link_checked(const ScrDyn *existing, const ScrDyn *destination); +void scr_fs_symlink_checked(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type); +void scr_fs_symlink_promise_checked(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type); +ScrDyn *scr_fs_readlink_checked(const ScrDyn *path, const ScrDyn *options, bool promise); +ScrDyn *scr_fs_readlink_dyn(const ScrDyn *path, const ScrDyn *options); +ScrStr *scr_fs_readlink_str(const ScrDyn *path, const ScrDyn *options); +ScrBytes *scr_fs_readlink_buffer(const ScrDyn *path, const ScrDyn *options); +ScrPromise *scr_fsp_link(const ScrDyn *existing, const ScrDyn *destination); +ScrPromise *scr_fsp_symlink(const ScrDyn *target, const ScrDyn *destination, const ScrDyn *type); +ScrPromise *scr_fsp_readlink_dyn(const ScrDyn *path, const ScrDyn *options); +ScrPromise *scr_fsp_readlink_str(const ScrDyn *path, const ScrDyn *options); +ScrPromise *scr_fsp_readlink_buffer(const ScrDyn *path, const ScrDyn *options); ScrStats *scr_stats_retain(ScrStats *s); void scr_stats_release(ScrStats *s); void *scr_stats_retain_v(void *p); diff --git a/tests/corpus/node24-fs-links-callbacks.cjs b/tests/corpus/node24-fs-links-callbacks.cjs new file mode 100644 index 000000000..ed834af33 --- /dev/null +++ b/tests/corpus/node24-fs-links-callbacks.cjs @@ -0,0 +1,53 @@ +// Direct and captured callbacks validate synchronously and complete later. +const fs = require("node:fs"); +const { pathToFileURL } = require("node:url"); +const { join } = require("node:path"); +const root = fs.mkdtempSync(join(require("node:os").tmpdir(), "scriptc-cb-links-")); +const call = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, result) => err ? reject(err) : resolve(result))); +async function main() { + const file = join(root, "file"); + fs.writeFileSync(file, "shared"); + fs.mkdirSync(join(root, "dir")); + try { + let immediate = true; + await new Promise((resolve, reject) => { + fs.link(Buffer.from(file), pathToFileURL(join(root, "hard")), err => { + console.log("deferred", immediate, err === null); + if (err) reject(err); else resolve(undefined); + }, "ignored"); + immediate = false; + }); + await call(fs.link, pathToFileURL(file), Buffer.from(join(root, "stored-hard"))); + console.log("hard", fs.readFileSync(join(root, "stored-hard"), "utf8")); + await call(fs.symlink, "dir", pathToFileURL(join(root, "junction")), "junction"); + const target = await call(fs.readlink, Buffer.from(join(root, "junction"))); + const bytes = await call(fs.readlink, pathToFileURL(join(root, "junction")), { encoding: "buffer" }); + console.log("stored readlink", typeof target, Buffer.isBuffer(bytes), bytes.toString("utf8") === target); + await new Promise((resolve, reject) => fs.readlink(join(root, "junction"), "hex", (err, value) => { + if (err) reject(err); else { console.log("hex", value === bytes.toString("hex")); resolve(undefined); } + }, "ignored")); + // String and URL paths participate in Windows's directory inference. + try { + await call(fs.symlink, "dir", join(root, "inferred-string")); + console.log("inferred string", fs.statSync(join(root, "inferred-string")).isDirectory()); + await call(fs.symlink, "dir", pathToFileURL(join(root, "inferred-url")), null); + console.log("inferred URL", fs.statSync(join(root, "inferred-url")).isDirectory()); + await call(fs.symlink, Buffer.from("file"), join(root, "buffer-file")); + console.log("buffer file", fs.statSync(join(root, "buffer-file")).isFile()); + } catch (e) { if (process.platform === "win32" && e.code === "EPERM") console.log("inference privilege boundary"); else throw e; } + // Buffer paths bypass that probe and use the file-link fallback. + try { + await call(fs.symlink, "dir", Buffer.from(join(root, "inferred"))); + console.log("inferred link", fs.lstatSync(join(root, "inferred")).isSymbolicLink()); + try { console.log("inferred stat", fs.statSync(join(root, "inferred")).isDirectory()); } + catch (e) { console.log("inferred stat", e.name, e.code, e.message.includes("stat")); } + } catch (e) { if (process.platform === "win32" && e.code === "EPERM") console.log("symlink privilege boundary"); else throw e; } + let synchronous = true; + await new Promise(resolve => { + fs.readlink(file, (err) => { console.log("error deferred", synchronous, err.name, err.code); resolve(undefined); }); + synchronous = false; + }); + try { await call(fs.link, file + ".missing", join(root, "missing")); } catch (e) { console.log("missing", e.name, e.code, e.message.includes(" -> ")); } + } finally { fs.rmSync(root, { recursive: true, force: true }); } +} +main(); diff --git a/tests/corpus/node24-fs-links-errors.cjs b/tests/corpus/node24-fs-links-errors.cjs new file mode 100644 index 000000000..b0e4fa1a4 --- /dev/null +++ b/tests/corpus/node24-fs-links-errors.cjs @@ -0,0 +1,43 @@ +// Validation order is observable before any filesystem operation. +const fs = require("node:fs"); +const cwd = process.cwd().replaceAll("\\", "\\\\"); +const report = (label, action) => { + try { action(); console.log(label, "unexpected success"); } + catch (e) { console.log(label, e.name, e.code, e.message.replaceAll(cwd, "")); } +}; +// Object inspection in Received tails remains a separate runtime limitation. +const reportObjectEncoding = (label, encoding) => { + try { fs.readlinkSync(null, { encoding }); console.log(label, "unexpected success"); } + catch (e) { console.log(label, e.name, e.code, e.message.startsWith("The argument 'encoding' is invalid encoding. Received ")); } +}; +report("symlink type first", () => fs.symlinkSync(null, null, "other")); +if (process.platform === "win32") report("junction buffer destination", () => fs.symlinkSync("target", Buffer.from("link"), "junction")); +report("numeric type", () => fs.symlinkSync(null, null, 1)); +report("symlink target", () => fs.symlinkSync(null, null)); +report("symlink path", () => fs.symlinkSync("target", null, "file")); +report("nul target", () => fs.symlinkSync("a\0b", "link")); +report("nul buffer target", () => fs.symlinkSync(Buffer.from("a\0b"), "link")); +report("hard source", () => fs.linkSync(null, null)); +report("hard destination", () => fs.linkSync("existing", null)); +report("nul source", () => fs.linkSync("a\0b", "new")); +report("nul destination", () => fs.linkSync("existing", Buffer.from("a\0b"))); +report("scheme", () => fs.symlinkSync(new URL("https://example.com/target"), "link")); +report("encoded slash", () => fs.linkSync(new URL("file:///tmp/a%2Fb"), "new")); +report("readlink options first", () => fs.readlinkSync(null, 1)); +report("readlink encoding first", () => fs.readlinkSync(null, "invalid")); +report("readlink numeric encoding", () => fs.readlinkSync(null, { encoding: 1 })); +reportObjectEncoding("readlink object encoding", {}); +reportObjectEncoding("readlink nested encoding", { encoding: "hex" }); +report("readlink signal", () => fs.readlinkSync(null, { signal: null })); +report("readlink path", () => fs.readlinkSync(null, { encoding: "buffer" })); +report("readlink nul", () => fs.readlinkSync(Buffer.from("a\0b"))); +report("link callback first", () => fs.link(null, null, null)); +report("link fixed callback", () => fs.link("existing", "new")); +report("symlink omitted callback", () => fs.symlink(null, null)); +report("symlink bad callback", () => fs.symlink(null, null, null)); +report("symlink callback type first", () => fs.symlink(null, null, "other", () => {})); +report("symlink callback target", () => fs.symlink(null, null, () => {})); +report("readlink callback first", () => fs.readlink(null, 1, null)); +report("readlink callback options", () => fs.readlink(null, 1, () => {})); +report("readlink callback encoding", () => fs.readlink(null, "invalid", () => {})); +report("readlink callback path", () => fs.readlink(null, () => {})); diff --git a/tests/corpus/node24-fs-links-options.cjs b/tests/corpus/node24-fs-links-options.cjs new file mode 100644 index 000000000..68583ee2d --- /dev/null +++ b/tests/corpus/node24-fs-links-options.cjs @@ -0,0 +1,37 @@ +// Runtime encodings, falsy defaults, and valid aborted signals retain readlink semantics. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const { join } = require("node:path"); +const root = fs.mkdtempSync(join(require("node:os").tmpdir(), "scriptc-link-options-")); +async function main() { + fs.mkdirSync(join(root, "dir")); + const path = join(root, "link"); + try { + const create = fs.symlinkSync; + create("dir", path, "junction"); + const read = fs.readlinkSync; + const expected = read(path); + for (const options of [null, undefined, "", {}, [], { encoding: null }, { encoding: false }, { encoding: 0 }]) { + console.log("default", fs.readlinkSync(path, options) === expected); + } + for (const encoding of ["utf8", "hex", "base64", "base64url", "latin1", "ascii", "utf16le", "buffer"]) { + const result = fs.readlinkSync(path, { encoding }); + const raw = fs.readlinkSync(path, "buffer"); + console.log(encoding, encoding === "buffer" ? Buffer.isBuffer(result) && result.toString("hex") === raw.toString("hex") : result === raw.toString(encoding)); + } + const controller = new AbortController(); + controller.abort(); + const options = { signal: controller.signal, encoding: "utf8" }; + console.log("signal sync", fs.readlinkSync(path, options) === expected); + console.log("signal promise", await fsp.readlink(path, options) === expected); + await new Promise((resolve, reject) => fs.readlink(path, options, (err, target) => { + if (err) reject(err); else { console.log("signal callback", target === expected); resolve(undefined); } + })); + const storedPromise = fsp.readlink; + console.log("stored promise", await storedPromise(path) === expected); + const storedCreate = fsp.symlink; + await storedCreate("dir", join(root, "stored"), "junction"); + console.log("stored create", fs.statSync(join(root, "stored")).isDirectory()); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +} +main(); diff --git a/tests/corpus/node24-fs-links-promise-errors.cjs b/tests/corpus/node24-fs-links-promise-errors.cjs new file mode 100644 index 000000000..e54a95832 --- /dev/null +++ b/tests/corpus/node24-fs-links-promise-errors.cjs @@ -0,0 +1,24 @@ +// Promise validation rejects, including options-first and oldPath naming. +const fsp = require("node:fs/promises"); +const report = async (label, action) => { + let promise; + try { promise = action(); console.log(label, "returned promise"); } + catch (e) { console.log(label, "synchronous throw", e.name); return; } + try { await promise; console.log(label, "unexpected success"); } + catch (e) { console.log(label, e.name, e.code, e.message); } +}; +async function main() { + await report("type", () => fsp.symlink(null, null, "other")); + await report("target", () => fsp.symlink(null, null)); + await report("destination", () => fsp.symlink("target", null)); + await report("source", () => fsp.link(null, null)); + await report("new path", () => fsp.link("existing", null)); + await report("options", () => fsp.readlink(null, 1)); + await report("encoding", () => fsp.readlink(null, "invalid")); + await report("old path", () => fsp.readlink(null)); + await report("signal", () => fsp.readlink(null, { signal: 1 })); + // Captured functions preserve promise rejection behavior too. + const stored = fsp.link; + await report("stored", () => stored("existing", null)); +} +main(); diff --git a/tests/corpus/node24-fs-links-promises.ts b/tests/corpus/node24-fs-links-promises.ts new file mode 100644 index 000000000..84bf22604 --- /dev/null +++ b/tests/corpus/node24-fs-links-promises.ts @@ -0,0 +1,27 @@ +// Promise adapters fulfill typed string/Buffer results and reject argument errors. +import * as fs from "node:fs"; +import { link, symlink, readlink } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +async function main(): Promise { + const root = fs.mkdtempSync(join(tmpdir(), "scriptc-promise-links-")); + const file = join(root, "file"); + fs.writeFileSync(file, "contents"); + fs.mkdirSync(join(root, "directory")); + try { + await link(Buffer.from(file), pathToFileURL(join(root, "hard"))); + console.log("link fulfilled"); + console.log("hard", fs.readFileSync(join(root, "hard"), "utf8")); + await symlink(Buffer.from("directory"), pathToFileURL(join(root, "junction")), "junction"); + console.log("symlink fulfilled"); + const target = await readlink(pathToFileURL(join(root, "junction"))); + const bytes = await readlink(Buffer.from(join(root, "junction")), "buffer"); + console.log("readlink", target === (process.platform === "win32" ? join(root, "directory") : "directory"), bytes.toString("utf8") === target); + console.log("hex", await readlink(join(root, "junction"), { encoding: "hex" }) === bytes.toString("hex")); + try { await link(file, join(root, "hard")); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("existing", err.name, err.code); } + try { await readlink(file); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("regular", err.name, err.code); } + try { await symlink("directory", join(root, "junction"), "junction"); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("symlink existing", err.name, err.code); } + } finally { fs.rmSync(root, { recursive: true, force: true }); } +} +main(); diff --git a/tests/corpus/node24-fs-links.ts b/tests/corpus/node24-fs-links.ts new file mode 100644 index 000000000..043eb309e --- /dev/null +++ b/tests/corpus/node24-fs-links.ts @@ -0,0 +1,69 @@ +// Relative targets stay relative to the link's parent; hard links share contents. +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +const root = fs.mkdtempSync(join(tmpdir(), "scriptc-links-")); +const outside = fs.mkdtempSync(join(tmpdir(), "scriptc-link-target-")); +fs.writeFileSync(join(outside, "keep"), "preserved"); +const file = join(root, "target.txt"); +const hard = join(root, "hard.txt"); +const nested = join(root, "nested"); +fs.writeFileSync(file, "first"); +fs.mkdirSync(nested); +try { + fs.linkSync(pathToFileURL(file), Buffer.from(hard)); + const storedLink = fs.linkSync; + storedLink(Buffer.from(file), join(root, "stored-hard")); + fs.writeFileSync(hard, "shared"); + console.log("hard", fs.readFileSync(file, "utf8"), fs.statSync(file).ino === fs.statSync(hard).ino); + try { fs.linkSync(file, hard); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("existing", err.name, err.code, err.message.includes("link")); } + try { fs.linkSync(file + ".missing", hard + ".missing"); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("missing", err.name, err.code, err.message.includes(" -> ")); } + const relative = join(nested, "relative"); + let canSymlink = true; + try { fs.symlinkSync("../target.txt", relative); } + catch (e) { const err = e as NodeJS.ErrnoException; if (process.platform === "win32" && err.code === "EPERM") { canSymlink = false; console.log("symlink privilege boundary"); } else throw e; } + if (canSymlink) { + const text = fs.readlinkSync(pathToFileURL(relative)); + const bytes = fs.readlinkSync(Buffer.from(relative), { encoding: "buffer" }); + console.log("relative", text === (process.platform === "win32" ? "..\\target.txt" : "../target.txt"), fs.readFileSync(relative, "utf8"), bytes.toString("utf8") === text); + console.log("encodings", fs.readlinkSync(relative, "hex") === bytes.toString("hex"), fs.readlinkSync(relative, { encoding: "base64" }) === bytes.toString("base64"), fs.readlinkSync(relative, "latin1") === bytes.toString("latin1")); + fs.symlinkSync(pathToFileURL(file), join(root, "absolute"), "file"); + console.log("absolute", fs.readlinkSync(join(root, "absolute")) === file); + fs.symlinkSync("missing-target", join(root, "dangling"), null); + console.log("dangling", fs.readlinkSync(join(root, "dangling")), fs.lstatSync(join(root, "dangling")).isSymbolicLink()); + try { fs.statSync(join(root, "dangling")); } catch (e) { const err = e as NodeJS.ErrnoException; console.log("dangling stat", err.name, err.code); } + fs.symlinkSync("nested", join(root, "directory"), "dir"); + console.log("directory", fs.statSync(join(root, "directory")).isDirectory()); + fs.symlinkSync("nested", join(root, "file-directory"), "file"); + console.log("file-directory link", fs.lstatSync(join(root, "file-directory")).isSymbolicLink(), fs.readlinkSync(join(root, "file-directory")) === "nested"); + try { console.log("file-directory stat", fs.statSync(join(root, "file-directory")).isDirectory()); } + catch (e) { const err = e as NodeJS.ErrnoException; console.log("file-directory stat", err.name, err.code); } + fs.symlinkSync("target.txt", join(root, "directory-file"), "dir"); + console.log("directory-file link", fs.lstatSync(join(root, "directory-file")).isSymbolicLink()); + try { console.log("directory-file stat", fs.statSync(join(root, "directory-file")).isFile()); } + catch (e) { const err = e as NodeJS.ErrnoException; console.log("directory-file stat", err.name, err.code); } + if (process.platform !== "win32") { + fs.symlinkSync("x".repeat(600), join(root, "long"), "file"); + console.log("long", fs.readlinkSync(join(root, "long")).length); + fs.symlinkSync(Buffer.from([0x61, 0xff, 0x62]), Buffer.from(join(root, "raw"))); + console.log("raw", fs.readlinkSync(join(root, "raw"), "buffer").toString("hex"), fs.readlinkSync(join(root, "raw"))); + } + } + // Junctions do not require Windows symlink privileges. POSIX ignores type. + fs.symlinkSync("nested", join(root, "junction"), "junction"); + console.log("junction", fs.statSync(join(root, "junction")).isDirectory(), fs.readlinkSync(join(root, "junction")) === (process.platform === "win32" ? nested : "nested")); + fs.unlinkSync(join(root, "junction")); + console.log("unlink directory link", fs.statSync(nested).isDirectory()); + fs.symlinkSync("nested", join(root, "remove"), "junction"); + fs.rmSync(join(root, "remove")); + console.log("remove directory link", fs.statSync(nested).isDirectory()); + fs.symlinkSync(outside, join(root, "outside"), "junction"); + fs.unlinkSync(file); + console.log("survives", fs.readFileSync(hard, "utf8")); +} finally { + try { + fs.rmSync(root, { recursive: true, force: true }); + console.log("no-follow cleanup", fs.readFileSync(join(outside, "keep"), "utf8")); + } finally { fs.rmSync(outside, { recursive: true, force: true }); } +} diff --git a/tests/corpus/node24-fs-timestamps.ts b/tests/corpus/node24-fs-timestamps.ts index 2d2fa87b3..188fc19c1 100644 --- a/tests/corpus/node24-fs-timestamps.ts +++ b/tests/corpus/node24-fs-timestamps.ts @@ -29,7 +29,8 @@ try { const before = Date.now() - 2000; utimesSync(file, -1, "Infinity"); const current = statSync(file); - console.log("current", current.atimeMs >= before && current.atimeMs <= Date.now(), current.mtimeMs >= before && current.mtimeMs <= Date.now()); + // UTIME_NOW can preserve submillisecond precision; Date.now is integral. + console.log("current", current.atimeMs >= before && current.atimeMs <= Date.now() + 1, current.mtimeMs >= before && current.mtimeMs <= Date.now() + 1); utimesSync(file, "-Infinity", new Date(NaN)); console.log("negative infinity", statSync(file).atimeMs >= before); let order = ""; diff --git a/tests/fixtures/node-types/fs-links.ts b/tests/fixtures/node-types/fs-links.ts new file mode 100644 index 000000000..84e7f8537 --- /dev/null +++ b/tests/fixtures/node-types/fs-links.ts @@ -0,0 +1,29 @@ +import { symlinkSync, readlinkSync, linkSync, symlink, readlink, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { symlink as symlinkPromise, readlink as readlinkPromise, link as linkPromise } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +async function main(): Promise { + const root = mkdtempSync(join(tmpdir(), "scriptc-typed-links-")); + const file = join(root, "file"); + writeFileSync(file, "typed"); + mkdirSync(join(root, "dir")); + try { + linkSync(pathToFileURL(file), Buffer.from(join(root, "hard"))); + await linkPromise(Buffer.from(file), pathToFileURL(join(root, "promise-hard"))); + console.log("hard", readFileSync(join(root, "promise-hard"), "utf8")); + const path = join(root, "junction"); + symlinkSync("dir", pathToFileURL(path), "junction"); + const text: string = readlinkSync(Buffer.from(path)); + const bytes: Buffer = readlinkSync(pathToFileURL(path), { encoding: "buffer" }); + console.log("sync", text === bytes.toString()); + await symlinkPromise(Buffer.from("dir"), pathToFileURL(join(root, "promise")), "junction"); + const promised: Buffer = await readlinkPromise(join(root, "promise"), "buffer"); + console.log("promise", await readlinkPromise(join(root, "promise")) === promised.toString()); + await new Promise((resolve, reject) => symlink("dir", join(root, "callback"), "junction", err => err ? reject(err) : resolve())); + await new Promise((resolve, reject) => readlink(join(root, "callback"), "buffer", (err, target) => { + if (err) reject(err); else { console.log("callback", target.toString() === text); resolve(); } + })); + } finally { rmSync(root, { recursive: true, force: true }); } +} +main(); diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 8e3181f38..75fe7f4b9 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -352,7 +352,7 @@ async function compileAndRun(file: string): Promise { const outDir = join(cacheDir, key); mkdirSync(outDir, { recursive: true }); const result = await compile(file, { - outPath: join(outDir, "program"), + outPath: join(outDir, process.platform === "win32" ? "program.exe" : "program"), outDir, sanitize, dynamic, diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 273b91d5f..91bb8366b 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -31,6 +31,17 @@ function comparableStderr(text: string): string { : text; } +test("node-types: filesystem links accept URL and Buffer paths and typed readlink results under @types/node", async () => { + const outDir = outDirFor("fs-links"); + const entry = join(nodeTypesDir, "fs-links.ts"); + const result = await compile(entry, { outPath: join(outDir, process.platform === "win32" ? "fs-links.exe" : "fs-links"), outDir, sanitize }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([execFileAsync(result.binaryPath), execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry])]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: filesystem timestamps accept Date, Buffer, URL, and FileHandle inputs under @types/node", async () => { const outDir = outDirFor("fs-timestamps"); const entry = join(nodeTypesDir, "fs-timestamps.ts");