diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 0459ad989..6ff17414f 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -36,7 +36,8 @@ const results = await Promise.all(jobs); // number[] — compiles - CommonJS module metadata (`module.id`, `filename`, `path`, `paths`, `loaded`, `isPreloading`, `parent`, `children`, `require.main`, and read-only `require.cache` lookup/enumeration) compiles natively. A compiled binary has a fixed module graph, so cache deletion/reloading, metadata writes, `module.paths` mutation, and `require.extensions` remain explicit refusals. - The type checker sees the full standard library; only the supported surface compiles. Reaching declared-but-unlowered surface is SC2020 with the supported alternatives in the hint — e.g. parts of the regex API (`re.exec`), `Symbol`, `globalThis`, array/Map/Set methods beyond the lowered sets. - `Date` values support zero-argument construction, one number/string argument, storage and passing, `getTime`/`valueOf`, `toISOString`, the local and UTC calendar getters, and `getTimezoneOffset`. `Date.parse(dateString)` accepts one string using the same bounded parser as `new Date(dateString).getTime()`. Date-armed unions, the year/month field constructor, setters, identity comparisons, throwing `Date` values, and locale/string formatters remain fenced; the supported string grammar is described below. -- `URL` supports construction from one absolute string and read-only `protocol`, `origin`, `username`, `pathname`, `href`, `host`, `hostname`, `port`, `search`, and `hash` getters. `searchParams` remains a live view. Relative inputs with a base argument and setters remain fenced. +- `URL` supports construction from one absolute string and read-only `protocol`, `origin`, `username`, `password`, `pathname`, `href`, `host`, `hostname`, `port`, `search`, and `hash` getters. `searchParams` remains a live view. Relative inputs with a base argument and setters remain fenced. +- `fs.openSync` accepts inline bitwise OR expressions of `fs.constants.O_RDONLY`, `O_WRONLY`, `O_RDWR`, `O_CREAT`, `O_EXCL`, `O_NOFOLLOW`, `O_NONBLOCK`, `O_TRUNC`, and `O_APPEND`, with an optional creation mode. Computed numeric flags remain fenced because their bit values differ by target. `fstatSync`, `fchmodSync`, `fsyncSync`, and `linkSync` compile at direct call sites. On Windows, a numeric open with `O_NOFOLLOW` and `fchmodSync` throw `ENOSYS` because the CRT cannot enforce those contracts; programs requiring them should use a POSIX target. - Numeric `process.exitCode = value` writes in statement position set the implicit exit status; `process.exit()` with no argument uses that status. Reading or resetting `process.exitCode`, and assignments used as values, remain fenced. - Map keys and Set elements are strings and numbers; other key types are fenced. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index bb1608b22..9cab62a04 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": "bbbe77bce52b68292e6d", + "artifactVersion": "adbd8f7460bcf5854031", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index d8bfacdec..210838b15 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": 44, + "partial": 48, "refused": 0, - "not-implemented": 232, + "not-implemented": 228, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -38513,9 +38513,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", @@ -38597,9 +38597,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", @@ -38625,9 +38625,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", @@ -38821,9 +38821,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 486c0a6a3..26cd924e5 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": 46, - "verify-gap": 2703, - "audit-partial": 463, + "verify-gap": 2699, + "audit-partial": 467, "classify": 2, "implement": 81 }, @@ -38114,12 +38114,14 @@ "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.fchmodSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38213,12 +38215,14 @@ "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.fstatSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38246,12 +38250,14 @@ "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.fsyncSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38477,12 +38483,14 @@ "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.linkSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 7ae5d4886..27639c26c 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": 44, + "partial": 48, "refused": 0, - "not-implemented": 232, + "not-implemented": 228, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -50575,7 +50575,8 @@ "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/2940-fsp-realpath.ts", "tests/corpus/2941-fsp-lstat-identity.ts", - "tests/corpus/2942-fsp-readdir-dirent.ts" + "tests/corpus/2942-fsp-readdir-dirent.ts", + "tests/corpus/2950-fs-descriptor-publication.ts" ] }, "dynamic": { @@ -54535,8 +54536,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.fchmodSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -54616,8 +54620,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.fstatSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -54643,8 +54650,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.fsyncSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -54832,8 +54842,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.linkSync", + "tests": [ + "tests/corpus/2950-fs-descriptor-publication.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 91fa59d05..5e5562f57 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -45,6 +45,10 @@ "node-builtin.dns": ["tests/corpus/2597-dgram-send-ladders.cjs"], "node-builtin.events": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"], "node-builtin.fs": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts"], + "node-builtin.fs.fchmodSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], + "node-builtin.fs.fstatSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], + "node-builtin.fs.fsyncSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], + "node-builtin.fs.linkSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], "node-builtin.http": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.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"], diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1e85e5639..d0f73a21b 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1041,6 +1041,9 @@ declare module "node:fs" { export function mkdirSync(path: string, options: { recursive?: boolean; mode?: number }): void; export function unlinkSync(path: string): void; 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 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. */ @@ -1079,6 +1082,15 @@ declare module "node:fs" { readonly R_OK: number; readonly W_OK: number; readonly X_OK: number; + readonly O_RDONLY: number; + readonly O_WRONLY: number; + readonly O_RDWR: number; + readonly O_CREAT: number; + readonly O_EXCL: number; + readonly O_NOFOLLOW: number; + readonly O_NONBLOCK: number; + readonly O_TRUNC: number; + readonly O_APPEND: number; }; /* A stat(2) snapshot (statSync follows symlinks, lstatSync does not — * Node's split) — immutable; the supported surface is exactly these @@ -1101,9 +1113,9 @@ declare module "node:fs" { export function statSync(path: string): Stats; export function lstatSync(path: string): Stats; /* The fd pair behind spawn's fd-stdio form: openSync(path, flags) → - * the raw fd (string flags only — "r", "w", "a" and the +/x/s - * variants), closeSync(fd). */ + * the raw fd. Numeric flags use an inline OR of the O_* constants. */ export function openSync(path: string, flags: string): number; + export function openSync(path: string, flags: number, mode?: number): number; export function closeSync(fd: number): void; /* Read into a caller buffer from the fd's current position when position * is omitted/null, or from a numeric byte position without advancing the @@ -1384,6 +1396,7 @@ interface URL { readonly protocol: string; readonly origin: string; readonly username: string; + readonly password: string; readonly pathname: string; readonly href: string; readonly host: string; diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index b9cfef6c8..598603726 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -4499,6 +4499,16 @@ function emitFilesystemLibCall(state: LibCallState): Temp { // Throws Node-shaped fs errors (may-throw seed set); the fd // comes back as f64. return finish(`scr_fs_open(${arg(0)}, ${arg(1)})`); + case "fs.openNumericSync": + return finish(`scr_fs_open_numeric(${arg(0)}, ${arg(1)}, ${arg(2)})`); + case "fs.fstatSync": + return finish(`scr_fs_fstat(${arg(0)})`); + case "fs.fchmodSync": + return finish(`scr_fs_fchmod(${arg(0)}, ${arg(1)})`); + case "fs.fsyncSync": + return finish(`scr_fs_fsync(${arg(0)})`); + case "fs.linkSync": + return finish(`scr_fs_link(${arg(0)}, ${arg(1)})`); case "fs.readSync": return finish(`scr_fs_read_sync(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)})`); case "fs.writeSync": @@ -4954,6 +4964,8 @@ function emitPathUrlLibCall(state: LibCallState): Temp { return finish(`scr_url_origin(${arg(0)})`); case "url.username": return finish(`scr_url_username(${arg(0)})`); + case "url.password": + return finish(`scr_url_password(${arg(0)})`); case "url.host": return finish(`scr_url_host(${arg(0)})`); case "url.hostname": diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index b259bc946..df484e71a 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -244,7 +244,12 @@ export const LIB_FN_SYMS: Record = { // sync fs calls; the getters are pure reads. "fs.statSync": "scr_fs_stat", "fs.lstatSync": "scr_fs_lstat", + "fs.fstatSync": "scr_fs_fstat", + "fs.fchmodSync": "scr_fs_fchmod", + "fs.fsyncSync": "scr_fs_fsync", + "fs.linkSync": "scr_fs_link", "fs.openSync": "scr_fs_open", + "fs.openNumericSync": "scr_fs_open_numeric", "fs.readSync": "scr_fs_read_sync", "fs.writeSync": "scr_fs_write_sync", "fs.writeStrSync": "scr_fs_write_str_sync", @@ -326,6 +331,7 @@ export const LIB_FN_SYMS: Record = { "url.protocol": "scr_url_protocol", "url.origin": "scr_url_origin", "url.username": "scr_url_username", + "url.password": "scr_url_password", "url.host": "scr_url_host", "url.hostname": "scr_url_hostname", "url.port": "scr_url_port", diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index cbbd8044e..047f28d41 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -1498,6 +1498,52 @@ function lowerFsSyncBufferWindow( fn: BuiltinModuleFn, loc: SrcLoc,): IrExpr { const name = expr.expression.getText(); + // Numeric open flags are interpreted symbolically at the call site. The + // O_* bit values differ between Darwin and Linux, so emit a stable mask + // and let the target runtime select its own native constants. + if (bi.module === "fs" && bi.member === "openSync" && expr.arguments.length >= 2 && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments[1]!))?.kind === "f64") { + if (expr.arguments.length > 3 || expr.arguments.some(ts.isSpreadElement)) { + lowerer.noLowering("openSync with numeric flags and this argument shape", expr); + } + const bits: Record = { + O_RDONLY: 0, O_WRONLY: 1, O_RDWR: 2, O_CREAT: 4, O_EXCL: 8, + O_NOFOLLOW: 16, O_NONBLOCK: 32, O_TRUNC: 64, O_APPEND: 128, + }; + const flagsOf = (input: ts.Expression): number | null => { + let node = input; + while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) node = node.expression; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarToken) { + const left = flagsOf(node.left); + const right = flagsOf(node.right); + return left === null || right === null ? null : left | right; + } + if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) { + const imported = lowerer.builtinImportOf(node.expression); + if (imported?.module === "fs" && imported.member === "constants") { + return bits[node.name.text] ?? null; + } + } + if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const imported = lowerer.builtinMemberOf(node.expression); + if (imported?.module === "fs" && imported.member === "constants") { + return bits[node.name.text] ?? null; + } + } + return null; + }; + const mask = flagsOf(expr.arguments[1]!); + if (mask === null) { + lowerer.noLowering("openSync with computed numeric flags", expr.arguments[1]!, + "use an inline bitwise OR of fs.constants.O_RDONLY/O_WRONLY/O_RDWR/O_CREAT/O_EXCL/O_NOFOLLOW/O_NONBLOCK/O_TRUNC/O_APPEND"); + } + const path = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING); + const mode = expr.arguments[2] + ? lowerer.lowerExprExpecting(expr.arguments[2]!, F64) + : { kind: "numLit", value: 0o666, type: F64, loc } satisfies IrExpr; + return { kind: "libCall", fn: "fs.openNumericSync", args: [path, + { kind: "numLit", value: mask, type: F64, loc }, mode], type: F64, loc }; + } if (bi.module === "zlib") return lowerZlibModuleCall(lowerer, expr, bi, loc); if (bi.module === "child_process" && bi.member === "spawnSync") { return lowerer.lowerSpawnSyncCall(expr, loc); @@ -5054,7 +5100,7 @@ function errorFirstBytesCallback(lowerer: Lowerer, node: ts.Expression, api: str lowerer.noLowering( `URL.${name}`, call, - "protocol, pathname, href, and toString() are the supported URL members", + "protocol, origin, username, password, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members", lowerer.checker.getSymbolAtLocation(access.name), ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 13482ccee..001786136 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -3431,7 +3431,7 @@ function lowerPromiseThenPresence( // a non-default port is present (scr_url_host — Node-exact, // opaque-path URLs answer ""); `hostname` is the stored port-less // host field verbatim, including IPv6 brackets. - if (name === "protocol" || name === "origin" || name === "username" || name === "pathname" || name === "href" || name === "host" || name === "hostname" || name === "port" || name === "search" || name === "hash") { + if (name === "protocol" || name === "origin" || name === "username" || name === "password" || name === "pathname" || name === "href" || name === "host" || name === "hostname" || name === "port" || name === "search" || name === "hash") { const receiver = lowerer.lowerExpr(expr.expression); const fn = name === "protocol" @@ -3440,8 +3440,10 @@ function lowerPromiseThenPresence( ? "url.origin" : name === "username" ? "url.username" - : name === "pathname" - ? "url.pathname" + : name === "password" + ? "url.password" + : name === "pathname" + ? "url.pathname" : name === "host" ? "url.host" : name === "hostname" @@ -3468,7 +3470,7 @@ function lowerPromiseThenPresence( lowerer.noLowering( `URL.${name}`, expr, - "protocol, origin, username, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members", + "protocol, origin, username, password, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members", lowerer.checker.getSymbolAtLocation(expr.name), ); } diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index e64b982e2..2e48cc95a 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -663,14 +663,18 @@ export const BUILTIN_MODULE_FNS: Record | undefined> = { fs: { + // Inline numeric O_* flags use a target-neutral runtime entry point. + openSync: ["fs.openNumericSync"], // The Buffer form (no encoding), the fd forms (readFileSync(fd[, // "utf8"])), and the checked-dynamic encoding form. readFileSync: ["fs.readFileSyncBuf", "fs.readFileSyncBytes", "fs.readFileSyncDyn", "fs.readFdSync", "fs.readFdSyncBytes"], diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index fd20107f4..ee65f6bf2 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2657,8 +2657,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // handle: in a compiled program the only VALUE producer is spawn // itself (a mock object literal in this slot fences at its // construction site), and every member the interface may declare is - // exactly a lowered child member, so uses typecheck against the - // interface and lower against the handle. + // exactly a lowered child member WITH the corresponding handle type, so + // uses typecheck against the interface and lower against the handle. + // Names alone are insufficient: a record of stdout/stderr callbacks is + // an ordinary data shape, not a child process. if ( flags & ts.TypeFlags.Object && callSigs.length === 0 && @@ -2675,10 +2677,24 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const CHILD_CORE = new Set(["kill", "on", "stdin", "stdout", "stderr", "unref", "exitCode"]); const props = checker.getPropertiesOfType(widened); let core = 0; + const childMemberType = (p: ts.Symbol): boolean => { + if (["kill", "on", "once", "off", "removeListener", "unref", "ref"].includes(p.name)) { + return checker.getCallSignatures(checker.getTypeOfSymbol(p)).length > 0; + } + const expected = p.name === "stdout" || p.name === "stderr" ? "childStream" + : p.name === "stdin" ? "childWriter" + : p.name === "killed" ? "bool" : "f64"; + const unit = p.name === "pid" ? "undefinedT" : p.name === "exitCode" || p.name === "stdin" || p.name === "stdout" || p.name === "stderr" ? "nullT" : null; + const mapped = mapType(checker.getTypeOfSymbol(p), ctx); + if (!mapped) return false; + const arms = mapped.kind === "union" ? ctx.unions.get(mapped.unionId)?.arms : [mapped]; + return arms !== undefined && arms.some((arm) => arm.kind === expected) && + arms.every((arm) => arm.kind === expected || arm.kind === unit); + }; const childShaped = props.length > 0 && props.every((p) => { - if (!CHILD_SURFACE.has(p.name)) return false; + if (!CHILD_SURFACE.has(p.name) || !childMemberType(p)) return false; if (CHILD_CORE.has(p.name)) core++; return true; }); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 37323d21e..36a122cd9 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2257,6 +2257,7 @@ export type IrLibFn = | "url.protocol" | "url.origin" | "url.username" + | "url.password" | "url.host" | "url.hostname" | "url.port" @@ -4371,6 +4372,10 @@ export type IrLibFn = | "fs.renameSync" | "fs.renameCb" | "fs.lstatSync" + | "fs.fstatSync" + | "fs.fchmodSync" + | "fs.fsyncSync" + | "fs.linkSync" /** fs.openSync(path, flags) → the raw fd as f64; fs.readSync/fs.writeSync * over Buffer windows perform sequential I/O when position is -1 and * offset-preserving positioned I/O otherwise; fs.writeStrSync is the @@ -4382,6 +4387,7 @@ export type IrLibFn = * fs errors (openSync ENOENT/EACCES..., readSync EBADF/range errors, * closeSync EBADF). */ | "fs.openSync" + | "fs.openNumericSync" | "fs.readSync" | "fs.writeSync" | "fs.writeStrSync" @@ -7657,10 +7663,15 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.copyFileSync", "fs.renameSync", "fs.lstatSync", + "fs.fstatSync", + "fs.fchmodSync", + "fs.fsyncSync", + "fs.linkSync", "fs.writeFileModeSync", "fs.mkdirModeSync", "fs.mkdirRecursiveModeSync", "fs.openSync", + "fs.openNumericSync", "fs.readSync", "fs.writeSync", "fs.writeStrSync", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index b879d1077..3848d7ebe 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -292,6 +292,7 @@ export const LIB_FN_SIGS: Record/tests/corpus/2948-stdio-callback-record.ts": { + "order": [ + "/tests/corpus/2948-stdio-callback-record.ts" + ], + "diags": [] + }, + "/tests/corpus/2949-url-password.ts": { + "order": [ + "/tests/corpus/2949-url-password.ts" + ], + "diags": [] + }, + "/tests/corpus/2950-fs-descriptor-publication.ts": { + "order": [ + "/tests/corpus/2950-fs-descriptor-publication.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 4f90f5485..83e65fe53 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1765,6 +1765,8 @@ static const char *scr_errno_name(int e, char *fallback, size_t cap) { case EFBIG: return "EFBIG"; case EPIPE: return "EPIPE"; case ESPIPE: return "ESPIPE"; + case ELOOP: return "ELOOP"; + case ENOSYS: return "ENOSYS"; default: snprintf(fallback, cap, "E%d", e); return fallback; @@ -1793,6 +1795,8 @@ static const char *scr_errno_text(int e) { case EFBIG: return "file too large"; case EPIPE: return "broken pipe"; case ESPIPE: return "invalid seek"; + case ELOOP: return "too many levels of symbolic links"; + case ENOSYS: return "function not implemented"; default: return strerror(e); } } @@ -2113,6 +2117,62 @@ double scr_fs_open(ScrStr *path, ScrStr *flags) { return (double)fd; } +/* fs.openSync(path, fs.constants.O_* [, mode]). The compiler passes a + * platform-independent mask for inline flag expressions; native flags are + * chosen here so cross-compiled programs retain the target's values. */ +double scr_fs_open_numeric(ScrStr *path, double flags, double mode) { + int bits = (int)flags; + int access = bits & 3; + if (access == 3 || !(isfinite(mode) && trunc(mode) == mode && mode >= 0 && mode <= 4294967295.0)) { + scr_throw_error_msg(SCR_ERR_RANGE, "Invalid open flags or mode", 26); + return 0; + } + int native = access == 1 ? O_WRONLY : access == 2 ? O_RDWR : O_RDONLY; + if (bits & 4) native |= O_CREAT; + if (bits & 8) native |= O_EXCL; + if (bits & 64) native |= O_TRUNC; + if (bits & 128) native |= O_APPEND; +#ifdef _WIN32 + /* The CRT cannot open a reparse point without following it. Fail closed + * until a handle-based no-follow open is available on this target. */ + if (bits & 16) { + scr_fs_throw(ENOSYS, "open", path); + return 0; + } +#else + if (bits & 16) native |= O_NOFOLLOW; + if (bits & 32) native |= O_NONBLOCK; +#endif + int fd = open(path->data, native | O_BINARY, (mode_t)mode); + if (fd < 0) { + scr_fs_throw(errno, "open", path); + return 0; + } + return (double)fd; +} + +static void scr_fs_throw_nopath(int e, const char *op); + +void scr_fs_fchmod(double fd, double mode) { +#ifdef _WIN32 + (void)fd; + (void)mode; + /* The CRT cannot enforce Unix permission bits through a descriptor. + * A silent success would misrepresent a security-sensitive operation. */ + scr_fs_throw_nopath(ENOSYS, "fchmod"); +#else + if (fchmod((int)fd, (mode_t)mode) != 0) scr_fs_throw_nopath(errno, "fchmod"); +#endif +} + +void scr_fs_fsync(double fd) { +#ifdef _WIN32 + if (_commit((int)fd) != 0) scr_fs_throw_nopath(errno, "fsync"); +#else + if (fsync((int)fd) != 0) scr_fs_throw_nopath(errno, "fsync"); +#endif +} + /* fs.closeSync(fd) — close(2); failure throws Node's path-less fs error * shape ("EBADF: bad file descriptor, close"). */ @@ -2541,6 +2601,28 @@ static void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr free(msg); } +void scr_fs_link(ScrStr *source, ScrStr *dest) { +#ifdef _WIN32 + WCHAR *from = scr_fs_win_wide(source); + WCHAR *to = scr_fs_win_wide(dest); + if (!from || !to) { + free(from); free(to); + scr_fs_throw2(EINVAL, "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); + } +#else + if (link(source->data, dest->data) != 0) scr_fs_throw2(errno, "link", source, dest); +#endif +} + /* copyFileSync(src, dest): contents copied into a created-or-truncated * destination carrying the SOURCE's permission bits — libuv's * uv_fs_copyfile behavior behind Node's copyFileSync (umask applies at @@ -3556,6 +3638,31 @@ ScrStats *scr_fs_lstat(ScrStr *path) { #endif } +ScrStats *scr_fs_fstat(double fd) { + struct stat st; + if (fstat((int)fd, &st) != 0) { + scr_fs_throw_nopath(errno, "fstat"); + return NULL; + } +#ifdef _WIN32 + ScrStats *s = scr_stats_new(); + s->is_file = S_ISREG(st.st_mode); + s->is_dir = S_ISDIR(st.st_mode); + s->is_symlink = false; + s->dev = (double)st.st_dev; + s->ino = (double)st.st_ino; + s->size = (double)st.st_size; + s->blocks = s->size <= 0 ? 0 : ceil(s->size / 512.0); + s->nlink = (double)st.st_nlink; + s->atime_ms = (double)st.st_atime * 1000.0; + s->mtime_ms = (double)st.st_mtime * 1000.0; + s->ctime_ms = (double)st.st_ctime * 1000.0; + return s; +#else + return scr_stats_of(&st); +#endif +} + ScrArr *scr_fs_readdir(ScrStr *path) { DIR *d = opendir(path->data); if (!d) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 3e36fb733..16406b7ae 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2400,6 +2400,10 @@ typedef struct ScrPromise ScrPromise; /* full section further down */ ScrStats *scr_fs_stat(ScrStr *path); /* +1, or throws */ ScrStats *scr_fs_lstat(ScrStr *path); /* +1, or throws; NO follow (lstat) */ +ScrStats *scr_fs_fstat(double fd); /* +1, or throws */ +void scr_fs_fchmod(double fd, double mode); +void scr_fs_fsync(double fd); +void scr_fs_link(ScrStr *source, ScrStr *dest); ScrStats *scr_stats_retain(ScrStats *s); void scr_stats_release(ScrStats *s); void *scr_stats_retain_v(void *p); @@ -2876,6 +2880,7 @@ ScrStr *scr_path_win32_to_namespaced_path(ScrStr *path); * (failure throws the path-less "EBADF: bad file descriptor, close"). * The pair behind spawn's fd-stdio form. */ double scr_fs_open(ScrStr *path, ScrStr *flags); +double scr_fs_open_numeric(ScrStr *path, double flags, double mode); ScrFileHandle *scr_file_handle_open(ScrStr *path, ScrStr *flags, double mode); ScrFileHandle *scr_file_handle_retain(ScrFileHandle *h); void scr_file_handle_release(ScrFileHandle *h); @@ -2930,6 +2935,7 @@ void scr_url_release_v(void *p); ScrStr *scr_url_protocol(ScrUrl *u); /* +1 "https:" */ ScrStr *scr_url_origin(ScrUrl *u); /* +1 "https://host" or "null" */ ScrStr *scr_url_username(ScrUrl *u); /* +1 encoded username, possibly empty */ +ScrStr *scr_url_password(ScrUrl *u); /* +1 encoded password, possibly empty */ ScrStr *scr_url_host(ScrUrl *u); /* +1 "host[:port]" (defaults stripped) */ ScrStr *scr_url_hostname(ScrUrl *u); /* +1 port-less host ("" when none) */ ScrStr *scr_url_port(ScrUrl *u); /* +1 "" or normalized non-default port */ diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 38f9038e7..6925dbb11 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -337,6 +337,13 @@ static bool parse_authority(const char *raw, size_t len, bool special, bool is_f } } *userinfo = ub_take(&ub); + /* An empty password is omitted from the serialized credentials. When + * both username and password are empty, the authority has no userinfo. */ + if ((*userinfo)->len > 0 && (*userinfo)->data[(*userinfo)->len - 1] == ':') { + ScrStr *canonical = scr_str_new((*userinfo)->data, (*userinfo)->len - 1); + scr_str_release(*userinfo); + *userinfo = canonical; + } const char *hp = at >= 0 ? raw + at + 1 : raw; size_t hp_len = at >= 0 ? len - (size_t)at - 1 : len; /* host[:port], with IPv6 literals bracketed per WHATWG. */ @@ -697,6 +704,13 @@ ScrStr *scr_url_username(ScrUrl *u) { return scr_str_new(u->userinfo->data, len); } +ScrStr *scr_url_password(ScrUrl *u) { + const char *separator = memchr(u->userinfo->data, ':', u->userinfo->len); + if (!separator) return scr_str_new("", 0); + const char *start = separator + 1; + return scr_str_new(start, u->userinfo->len - (size_t)(start - u->userinfo->data)); +} + ScrStr *scr_url_href(ScrUrl *u) { UrlBuf b; ub_init(&b); diff --git a/tests/corpus/2948-stdio-callback-record.ts b/tests/corpus/2948-stdio-callback-record.ts new file mode 100644 index 000000000..58a79d800 --- /dev/null +++ b/tests/corpus/2948-stdio-callback-record.ts @@ -0,0 +1,10 @@ +interface Hooks { + stdout: (value: string) => void; + stderr: (value: string) => void; +} +function write(hooks: Hooks): void { + hooks.stdout("ok"); + hooks.stderr("err"); +} +function output(value: string): void { console.log(value); } +write({ stdout: output, stderr: output }); diff --git a/tests/corpus/2949-url-password.ts b/tests/corpus/2949-url-password.ts new file mode 100644 index 000000000..51dfda6fc --- /dev/null +++ b/tests/corpus/2949-url-password.ts @@ -0,0 +1,14 @@ +const urls = [ + "https://user:pass@Example.COM/a", + "https://us%3Aer:p%3Ass@host.test/", + "https://user:@host.test/", + "https://host.test/", + "ftp://name:pa%20ss@host.test/", + "custom://:secret@host.test/", + "file:///tmp/a", + "data:text/plain,a", +]; +for (const raw of urls) { + const url = new URL(raw); + console.log(JSON.stringify([url.username, url.password, url.href])); +} diff --git a/tests/corpus/2950-fs-descriptor-publication.ts b/tests/corpus/2950-fs-descriptor-publication.ts new file mode 100644 index 000000000..76f589aae --- /dev/null +++ b/tests/corpus/2950-fs-descriptor-publication.ts @@ -0,0 +1,42 @@ +// Numeric O_* opens retain POSIX no-follow/exclusive semantics; +// descriptor stat/chmod/sync and link publication keep the same fd identity. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scr-fd-publish-")); +const source = path.join(dir, "pending.txt"); +const published = path.join(dir, "published.txt"); +const symlink = path.join(dir, "alias.txt"); +try { + if (process.platform !== "win32") { + const fd = fs.openSync(source, fs.constants.O_WRONLY | fs.constants.O_CREAT | + fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600); + console.log("created", fs.fstatSync(fd).isFile()); + fs.fchmodSync(fd, 0o600); + const data = Buffer.from("atomic\n", "utf8"); + console.log("wrote", fs.writeSync(fd, data, 0, data.length)); + fs.fsyncSync(fd); + fs.closeSync(fd); + fs.linkSync(source, published); + const input = fs.openSync(published, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + console.log("read", fs.fstatSync(input).isFile(), fs.readFileSync(input, "utf8").trim()); + fs.closeSync(input); + try { + fs.linkSync(source, published); + } catch (error) { + if (error instanceof Error) console.log("exclusive", error.message.includes("EEXIST")); + } + execFileSync("ln", ["-s", source, symlink]); + try { + const unsafe = fs.openSync(symlink, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + fs.closeSync(unsafe); + console.log("followed"); + } catch (error) { + if (error instanceof Error) console.log("no-follow", error.message.includes("ELOOP")); + } + } +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/tests/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index da8486194..91a6410bc 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -39,12 +39,12 @@ import { win32 } from "path"; watchFile("x", () => {}); console.log(cpus().length); console.log(win32.sep); -/* URL getters including port/hash lower under @types/node, but +/* URL getters including port/hash/password lower under @types/node, but * unimplemented members still fence by member name with the * supported list; searchParams and its method surface lower under * @types/node's declarations (provenance-mapped like URL itself). */ const u = new URL("https://example.com/x?a=1"); -console.log(u.password); +console.log(u.toJSON()); u.searchParams.get("a"); /* The one-shot zlib/raw/gzip codecs lower for strings and Buffers; * explicit options remain fenced, while Brotli remains a member-qualified diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index ba2c6e463..12722c718 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -75,18 +75,18 @@ fenced.ts:41:13 - error SC2020: 'PlatformPath.sep' is typed by @types/node but h 40 | console.log(cpus().length); 41 | console.log(win32.sep); | ^~~~~~~~~ - 42 | /* URL getters including port/hash lower under @types/node, but + 42 | /* URL getters including port/hash/password lower under @types/node, but hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations) -fenced.ts:47:13 - error SC2020: 'URL.password' is typed by @types/node but has no scriptc lowering yet +fenced.ts:47:13 - error SC2020: 'URL.toJSON' is typed by @types/node but has no scriptc lowering yet 46 | const u = new URL("https://example.com/x?a=1"); - 47 | console.log(u.password); + 47 | console.log(u.toJSON()); | ^~~~~~~~~~ 48 | u.searchParams.get("a"); - hint: protocol, origin, username, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members + hint: protocol, origin, username, password, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members fenced.ts:53:21 - error SC2020: 'deflateSync with explicit options' is part of the standard library types but has no scriptc lowering yet