diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 4a155293..b2d3fd5a 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -3143,6 +3143,17 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { case "net.connect": E.usesTimers = true; // a connecting/open socket holds the loop open return finish(`scr_net_connect(${arg(0)}, ${arg(1)}, NULL)`); + case "net.connectAttempt": + // The validated autoSelectFamilyAttemptTimeout form: Node's + // range ladder runs first, the dial follows. + E.usesTimers = true; + return finish(`scr_net_connect_attempt(${arg(0)}, ${arg(1)}, ${arg(2)})`); + case "net.connectOptsChk": + // The runtime option-bag ladder (always throws — a validation + // error or the trailing fence; the error.nodeThrow dummy). + return finish( + `(scr_net_connect_opts_chk(${arg(0)}, ${arg(1)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); case "net.connectCb": { E.usesTimers = true; const cb = args[2]!; @@ -3245,6 +3256,11 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { case "dgram.sendBytes": E.usesTimers = true; return finish(`scr_dgram_send_bytes(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)})`); + case "dgram.sendChk": + E.usesTimers = true; // a validated send implicit-binds + return finish( + `scr_dgram_send_chk(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, ${arg(5)}, ${arg(6)})`, + ); case "dgram.address": { // The AddressInfo record, built here from runtime parts (the // frontend pinned the {address, family, port} shape). The @@ -3956,6 +3972,15 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { return finish( `scr_tls_create_secure_context((const char *)${arg(0)}->data, ${arg(0)}->len, (const char *)${arg(1)}->data, ${arg(1)}->len)`, ); + case "tls.createSecureContextDyn": + // The runtime option-bag form: Node's typed validations, then + // the pem walk (throws catchably on both ladders). + return finish(`scr_tls_create_secure_context_dyn(${arg(0)})`); + case "tls.caCertsChk": + // Always throws (validation error or the trailing fence). + return finish( + `(scr_tls_ca_certs_chk(${arg(0)}, ${arg(1)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); case "http.serverOnRequest": { const cbT = e.args[1]!.type; if (cbT.kind !== "func") throw new Error("emitter bug: http.serverOnRequest handler not a func"); @@ -4392,12 +4417,59 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { return finish(`scr_buffer_new_string_fail(${arg(0)})`); case "fs.toUnixTimestamp": return finish(`scr_fs_to_unix_timestamp(${arg(0)})`); + // The fs argument-validation ladders: the always-throw Chk + // forms (validation error or the trailing fence) take the + // error.nodeThrow dummy pattern; mkdtempSyncChk and the lchmod + // pair answer real results on a validated pass. + case "fs.existsChk": + E.usesTimers = true; // the scheduled answer holds the loop open + return finish(`scr_fs_exists_async(${arg(0)}, ${arg(1)})`); + case "fs.mkdtempChk": + return finish( + `(scr_fs_mkdtemp_chk(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.mkdtempSyncChk": + return finish(`scr_fs_mkdtemp_sync_chk(${arg(0)}, ${arg(1)}, ${arg(2)})`); + case "fs.readFileChk": + return finish( + `(scr_fs_read_file_chk(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.opendirChk": + return finish( + `(scr_fs_opendir_chk(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.watchFileChk": + return finish( + `(scr_fs_watch_file_chk(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.lchmodChk": + return finish( + `(scr_fs_lchmod_chk(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.lchmodSyncChk": + return finish(`scr_fs_lchmod_sync_chk(${arg(0)}, ${arg(1)})`); + case "fsp.lchmodChk": + return finish(`scr_fsp_lchmod_chk(${arg(0)}, ${arg(1)})`); + case "fs.readChk": + return finish( + `(scr_fs_read_chk(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, ${arg(5)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); + case "fs.streamOptsChk": + return finish( + `(scr_fs_stream_opts_chk(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); case "error.argTypeThrow": // Always throws with the runtime-rendered Received tail (the // error.nodeThrow dummy pattern). Borrows all three. return finish( `(scr_throw_arg_type(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, ); + case "error.propTypeThrow": + // The property flavor ("The \"options.x\" property must be + // ...") — same always-throw dummy pattern. + return finish( + `(scr_throw_prop_type(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`, + ); // The fs Buffer forms (scr_bytes_io.c): the sync pair throws // like the utf8 forms (may-throw seed set); the promise form // rejects instead. diff --git a/packages/compiler/src/backend/emission/emit-walkers.ts b/packages/compiler/src/backend/emission/emit-walkers.ts index f50642e3..3454eb38 100644 --- a/packages/compiler/src/backend/emission/emit-walkers.ts +++ b/packages/compiler/src/backend/emission/emit-walkers.ts @@ -1481,6 +1481,10 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { d.push(` case ${i}: return scr_dyn_new_num(scr_union_get_f64(v));`); } else if (arm.kind === "bool") { d.push(` case ${i}: return scr_dyn_new_bool(scr_union_get_bool(v));`); + } else if (arm.kind === "func") { + // A boxable function arm crosses through the checked-dynamic + // function boundary (the dynFrom func special case, sans name). + d.push(` case ${i}: return ${dynFuncBoxHelper(E, arm)}((ScrClosure *)scr_union_peek(v), NULL);`); } else { d.push(` case ${i}: return ${E.toDynHelper(arm)}((${cType(arm).trim()})scr_union_peek(v));`); } diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index a2dee01c..04807235 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1273,6 +1273,16 @@ export class LlDyn { B.line(`${x} = call zeroext i1 @scr_union_get_bool(ptr %v)`); B.line(`${r} = call ptr @scr_dyn_new_bool(i1 ${x})`); B.terminate(`ret ptr ${r}`); + } else if (arm.kind === "func") { + // A boxable function arm crosses through the checked-dynamic + // function boundary (the dynFrom func special case, sans name). + const pp = B.tmp(); + const p = B.tmp(); + B.line(`${pp} = getelementptr inbounds %ScrUnion, ptr %v, i64 0, i32 5`); + B.line(`${p} = load ptr, ptr ${pp}`); + const r = B.tmp(); + B.line(`${r} = call ptr @${this.dynFuncBoxHelper(arm)}(ptr ${p}, ptr null)`); + B.terminate(`ret ptr ${r}`); } else { const pp = B.tmp(); const p = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a6abe431..464bf6f9 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -322,6 +322,11 @@ const LIB_FN_SYMS: Record = { "bytes.compareChk": "scr_bytes_compare_chk", "buffer.newStringFail": "scr_buffer_new_string_fail", "fs.toUnixTimestamp": "scr_fs_to_unix_timestamp", + "fs.existsChk": "scr_fs_exists_async", + "fs.mkdtempSyncChk": "scr_fs_mkdtemp_sync_chk", + "net.connectAttempt": "scr_net_connect_attempt", + "fs.lchmodSyncChk": "scr_fs_lchmod_sync_chk", + "fsp.lchmodChk": "scr_fsp_lchmod_chk", "fs.readFileSyncBuf": "scr_fs_read_file_bytes", "fs.readFileSyncBytes": "scr_fs_read_file_bytes", "fs.writeFileSyncBytes": "scr_fs_write_file_bytes", @@ -738,7 +743,8 @@ const USES_TIMERS_LIB_FNS = new Set([ "stream.finished", "stream.finishedDyn", "stream.pipeline", "stream.pipelineDyn", "sp.finished", "sp.pipeline", "net.listen", "net.listenCb", "net.listenOpts", "net.listenOptsCb", - "net.connect", "net.connectCb", "net.connectLookup", + "net.connect", "net.connectCb", "net.connectLookup", "net.connectAttempt", + "fs.existsChk", "http.createServer", "http.createServerEmpty", "http.request", "http.requestCb", "http.requestUrl", "http.requestUrlCb", "http.requestConn", "http.requestConnCb", @@ -9488,6 +9494,54 @@ class LlEmitter { this.emitPendingCheck(); return out; } + if (e.fn === "error.propTypeThrow") { + // The property flavor of argTypeThrow — same always-throw shape. + const an = this.emitExpr(e.args[0]!); + const ex = this.emitExpr(e.args[1]!); + const got = this.emitExpr(e.args[2]!); + this.declare(`declare void @scr_throw_prop_type(ptr, ptr, ptr)`); + B.line(`call void @scr_throw_prop_type(ptr ${an.name}, ptr ${ex.name}, ptr ${got.name})`); + const ty = this.llType(e.type); + if (ty === "void") { + this.emitPendingCheck(); + return { name: "", type: e.type }; + } + const dummy = ty === "double" ? f64Lit(0) : ty === "i1" ? "false" : "null"; + const out = this.own({ name: dummy, type: e.type }); + this.emitPendingCheck(); + return out; + } + { + // The fs validation-ladder Chk forms that ALWAYS throw (a + // validation error or the trailing compiler-rendered fence): every + // argument is a ptr (dyns + the fence string), and the typed dummy + // is abandoned by the pending check's unwind. + const FS_CHK_THROW_SYMS: Record = { + "fs.mkdtempChk": "scr_fs_mkdtemp_chk", + "fs.readFileChk": "scr_fs_read_file_chk", + "fs.opendirChk": "scr_fs_opendir_chk", + "fs.watchFileChk": "scr_fs_watch_file_chk", + "fs.lchmodChk": "scr_fs_lchmod_chk", + "fs.readChk": "scr_fs_read_chk", + "fs.streamOptsChk": "scr_fs_stream_opts_chk", + "net.connectOptsChk": "scr_net_connect_opts_chk", + }; + const sym = FS_CHK_THROW_SYMS[e.fn]; + if (sym !== undefined) { + const args = e.args.map((a) => this.emitExpr(a)); + this.declare(`declare void @${sym}(${args.map(() => "ptr").join(", ")})`); + B.line(`call void @${sym}(${args.map((a) => `ptr ${a.name}`).join(", ")})`); + const ty = this.llType(e.type); + if (ty === "void") { + this.emitPendingCheck(); + return { name: "", type: e.type }; + } + const dummy = ty === "double" ? f64Lit(0) : ty === "i1" ? "false" : "null"; + const out = this.own({ name: dummy, type: e.type }); + this.emitPendingCheck(); + return out; + } + } if (e.fn === "error.nodeThrow") { // The compiler-resolved Node-parity throw (always throws — the // typed dummy is abandoned by the pending check's unwind). diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 480582b4..149871ea 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -5,7 +5,7 @@ import { builtinModules } from "node:module"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { dynUndefinedExpr, nodeThrowExpr, own } from "./lowerer.js"; +import { dynUndefinedExpr, ladderFenceExpr, nodeThrowExpr, own } from "./lowerer.js"; import { canonicalBuiltinModule, isJsSourceFile, locOf, requireSpecOf } from "../program.js"; import { BuiltinModuleFn, @@ -396,6 +396,141 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts return null; } + /** The fs validation-ladder spoke (checked-dynamic lane, JS sources + * only — TypeScript keeps its compile fences): implemented-namespace + * calls whose misuse Node rejects with typed errors lower to fs.*Chk + * libCalls that replicate the validation ladder over DOM values and + * throw Node's exact ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE / + * ERR_OUT_OF_RANGE — the honest tail (the real operation where one + * exists, the compiler-rendered SC2020 fence otherwise) runs only + * after every validation passes, exactly Node's order. Null when this + * is not a claimed member/shape (the table or fence path stands). */ + export function lowerFsLadderCall(L: Lowerer, expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc,): IrExpr | null { + if (bi.module !== "fs" && bi.module !== "fs/promises") return null; + if (!isJsSourceFile(expr.getSourceFile())) return null; + const args = expr.arguments; + if (args.some(ts.isSpreadElement)) return null; + // Every ladder argument crosses as a DOM value; an argument that + // cannot leaves the historical fence in place. + const dynArg = (node: ts.Expression | undefined): IrExpr | null => { + if (!node) return dynUndefinedExpr(loc); + const raw = L.lowerExpr(node); + if (raw.type.kind === "dyn") return raw; + if (raw.kind === "unitLit" || L.dynConvertible(raw.type)) { + return { kind: "dynFrom", value: raw, type: DYN, loc }; + } + return null; + }; + const dynArgs = (nodes: (ts.Expression | undefined)[]): IrExpr[] | null => { + const out: IrExpr[] = []; + for (const n of nodes) { + const v = dynArg(n); + if (v === null) return null; + out.push(v); + } + return out; + }; + const resultT = L.mapTypeOf(L.typeOf(expr)) ?? DYN; + const chk = (fn: IrLibFn, chkArgs: IrExpr[], type: IrType): IrExpr => + ({ kind: "libCall", fn, args: chkArgs, type, loc }); + if (bi.module === "fs/promises") { + if (bi.member !== "lchmod" || args.length !== 2) return null; + const a = dynArgs([args[0], args[1]]); + return a && chk("fsp.lchmodChk", a, { kind: "promise", inner: VOID }); + } + switch (bi.member) { + case "exists": { + // The REAL deprecated-API shape: the callback validates + // synchronously (Node's one throwing arm), invalid paths answer + // false THROUGH it, and the answer is asynchronous. + // A provably-non-file `new URL('')` path (the suite's + // https://foo probe): Node answers false through the callback + // synchronously — the DOM has no URL kind, so the path slot + // carries the unvalidatable token instead (construction of a + // parseable literal is effect-free; file: URLs keep the fence — + // they would need the real path conversion). + let pathNode: ts.Expression | undefined = args[0]; + let pathExpr: IrExpr | null = null; + if (pathNode && ts.isNewExpression(pathNode) && ts.isIdentifier(pathNode.expression) && + pathNode.expression.text === "URL" && L.mapTypeOf(L.typeOf(pathNode))?.kind === "url" && + pathNode.arguments?.length === 1 && ts.isStringLiteral(pathNode.arguments[0]!)) { + let parsed: URL | null = null; + try { + parsed = new URL(pathNode.arguments[0]!.text); + } catch { + parsed = null; + } + if (parsed === null || parsed.protocol === "file:") return null; + pathExpr = dynUndefinedExpr(loc); + pathNode = undefined; + } + pathExpr ??= dynArg(pathNode); + const cbExpr = dynArg(args[1]); + if (pathExpr === null || cbExpr === null) return null; + return chk("fs.existsChk", [pathExpr, cbExpr], DYN); + } + case "mkdtemp": { + // mkdtemp(prefix[, options], callback) — the callback is the + // LAST argument (makeCallback runs first, then the prefix). + const a = dynArgs([args[0], args.length >= 2 ? args[args.length - 1] : undefined]); + return a && chk("fs.mkdtempChk", [...a, ladderFenceExpr(L, "fs.mkdtemp", expr)], resultT); + } + case "mkdtempSync": { + // The table serves the plain string 1-arg form; the ladder takes + // every other shape — prefix/encoding validation, then the REAL + // mkdtemp when the options leave utf8 semantics. + if (args.length === 1 && L.mapTypeOf(L.typeOf(args[0]!))?.kind === "string") return null; + if (args.length > 2) return null; + const a = dynArgs([args[0], args[1]]); + return a && chk("fs.mkdtempSyncChk", [...a, ladderFenceExpr(L, "fs.mkdtempSync with these options", expr)], STRING); + } + case "readFile": { + // readFile(path[, options], callback): callback, assertEncoding, + // path — then the async read fences. + const a = dynArgs([args[0], args.length >= 3 ? args[1] : undefined, args.length >= 2 ? args[args.length - 1] : undefined]); + return a && chk("fs.readFileChk", [...a, ladderFenceExpr(L, "fs.readFile", expr)], resultT); + } + case "opendirSync": { + const a = dynArgs([args[0], args[1]]); + return a && chk("fs.opendirChk", [...a, ladderFenceExpr(L, "fs.opendirSync", expr)], resultT); + } + case "watchFile": { + // watchFile(filename[, options], listener): the path first, the + // listener's function contract second; real watching fences. + const a = dynArgs([args[0], args.length >= 2 ? args[args.length - 1] : undefined]); + return a && chk("fs.watchFileChk", [...a, ladderFenceExpr(L, "fs.watchFile", expr)], resultT); + } + case "lchmod": { + // lchmod(path, mode, callback): callback, path, mode — macOS + // shapes (non-APPLE answers Node's not-a-function TypeError). + const a = dynArgs([args[0], args[1], args[2]]); + return a && chk("fs.lchmodChk", [...a, ladderFenceExpr(L, "fs.lchmod", expr)], resultT); + } + case "lchmodSync": { + if (args.length > 2) return null; + const a = dynArgs([args[0], args[1]]); + return a && chk("fs.lchmodSyncChk", a, DYN); + } + case "read": { + // read(fd, buffer, offset, length, position, callback) — the + // positional form's full ladder; options-object forms keep the + // fence (their misuse arms are not in the target set). + if (args.length < 4) return null; + const a = dynArgs([args[0], args[1], args[2], args[3], args.length >= 6 ? args[4] : undefined]); + return a && chk("fs.readChk", [...a, ladderFenceExpr(L, "fs.read", expr)], resultT); + } + case "createReadStream": + case "createWriteStream": { + const a = dynArgs([args[0], args[1]]); + return a && chk("fs.streamOptsChk", [...a, ladderFenceExpr(L, `fs.${bi.member}`, expr)], resultT); + } + default: + return null; + } + } + export function lowerBuiltinModuleCall(L: Lowerer, expr: ts.CallExpression, bi: { module: string; member: string }, fn: BuiltinModuleFn, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 63584094..d2707c90 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3130,6 +3130,11 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { if (streamServed) return streamServed; const fsTs = L.lowerFsToUnixTimestampCall(expr, bi, loc); if (fsTs) return fsTs; + // The fs validation-ladder spoke (checked-dynamic lane): misuse + // of implemented-namespace members throws Node's typed errors + // instead of meeting the table fence. + const fsLadder = L.lowerFsLadderCall(expr, bi, loc); + if (fsLadder) return fsLadder; const builtinFn = builtinModuleFnOf(L, bi.module, bi.member); if (!builtinFn) { // Typed by @types/node (the fallback declarations only declare diff --git a/packages/compiler/src/frontend/lowering/lower-dgram.ts b/packages/compiler/src/frontend/lowering/lower-dgram.ts index 525b18ea..ea0bdfd9 100644 --- a/packages/compiler/src/frontend/lowering/lower-dgram.ts +++ b/packages/compiler/src/frontend/lowering/lower-dgram.ts @@ -9,8 +9,9 @@ * dgram multicast); its members fence with a named hint. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { locOf } from "../program.js"; -import { BOOL, DGRAMSOCK_T, F64, IrExpr, IrLibFn, IrType, SrcLoc, STRING, VOID } from "../../ir/nodes.js"; +import { ladderFenceExpr } from "./lowerer.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { BOOL, canBoxFuncIntoDyn, DGRAMSOCK_T, DYN, F64, funcOf, IrExpr, IrLibFn, IrType, SrcLoc, STRING, UNDEFINED_T, VOID } from "../../ir/nodes.js"; import { DNS_LOOKUP_DOCUMENTED_OPTIONS, fenceOrDropOptionKey } from "./surfaces.js"; const DGRAM_SURFACE_HINT = @@ -46,7 +47,13 @@ function lowerCallbackArg( paramOk: (p: IrType, i: number) => boolean, paramHint: string, ): { cb: IrExpr; nparams: number } { - const cb = L.lowerExpr(node); + let cb = L.lowerExpr(node); + // A checked-dynamic callback (test/common's mustCall wrapper — a dyn + // value): the zero-parameter slots adapt through the dynCheck function + // boundary, the lower-server listen-callback precedent. + if (cb.type.kind === "dyn" && maxParams === 0) { + cb = { kind: "dynCheck", value: cb, type: funcOf([], VOID), loc: locOf(node) }; + } if (cb.type.kind !== "func" || cb.type.params.length > maxParams) { L.unsupported( "SC1090", @@ -162,6 +169,32 @@ export function lowerDgramDnsModuleCall(L: Lowerer, expr: ts.CallExpression, sawType = true; } else if (name === "reuseAddr") { reuseAddr = L.lowerExprExpecting(prop.initializer, BOOL); + } else if (name === "signal" && isJsSourceFile(expr.getSourceFile())) { + // A provably-non-AbortSignal signal (the invalid-input probes: + // strings, numbers, plain records) throws Node's + // validateAbortSignal ladder; plausible signal values keep the + // fence — abort-driven close has no lowering yet. + const raw = L.lowerExpr(prop.initializer); + const provablyNot = raw.type.kind === "string" || raw.type.kind === "f64" || + raw.type.kind === "bool" || raw.type.kind === "record" || raw.type.kind === "array"; + if (provablyNot && L.dynConvertible(raw.type)) { + return { + kind: "libCall", + fn: "error.propTypeThrow", + args: [ + { kind: "strLit", value: "options.signal", type: STRING, loc }, + { kind: "strLit", value: "an instance of AbortSignal", type: STRING, loc }, + { kind: "dynFrom", value: raw, type: DYN, loc }, + ], + type: DGRAMSOCK_T, + loc, + }; + } + L.noLowering( + `createSocket option 'signal'`, + prop, + "abort-driven close has no lowering yet — type and reuseAddr are the supported options", + ); } else { L.noLowering( `createSocket option '${name}'`, @@ -328,29 +361,70 @@ export function lowerDgramMethodCall(L: Lowerer, call: ts.CallExpression, } if (name === "send") { requireStatementPosition(L, call, "socket.send(...)"); - // send(msg, port, address) — one datagram to an explicit destination. - // The connected send and callback forms have no lowering yet. - if (args.length !== 3) { - L.noLowering( - `send with ${args.length} arguments`, - call, - "the supported form is send(msg, port, address) — one string or Buffer datagram", - ); + // send(msg, port, address) — one datagram to an explicit destination + // (the static fast path). Every OTHER shape in a JS source rides the + // checked-dynamic ladder (dgram.sendChk): Node's signature shuffle, + // slice bounds, list/type contracts, port/address validation, and + // the connected-state errors — with the compiler-rendered fence as + // the post-validation tail for the callback/list/connected forms. + const staticShape = + args.length === 3 && !args.some(ts.isSpreadElement) && + (() => { + const dataT = L.mapTypeOf(L.typeOf(args[0]!)); + const portT = L.mapTypeOf(L.typeOf(args[1]!)); + const hostT = L.mapTypeOf(L.typeOf(args[2]!)); + return (dataT?.kind === "string" || (dataT?.kind === "bytes" && dataT.elem === "u8")) && + portT?.kind === "f64" && hostT?.kind === "string"; + })(); + if (staticShape) { + const receiver = L.lowerExpr(access.expression); + const data = L.lowerExpr(args[0]!); + const port = L.lowerExprExpecting(args[1]!, F64); + const host = L.lowerExprExpecting(args[2]!, STRING); + const fn: IrLibFn = data.type.kind === "string" ? "dgram.sendStr" : "dgram.sendBytes"; + return { kind: "libCall", fn, args: [receiver, data, port, host], type: VOID, loc }; } - const receiver = L.lowerExpr(access.expression); - const data = L.lowerExpr(args[0]!); - const port = L.lowerExprExpecting(args[1]!, F64); - const host = L.lowerExprExpecting(args[2]!, STRING); - if (data.type.kind === "string") { - return { kind: "libCall", fn: "dgram.sendStr", args: [receiver, data, port, host], type: VOID, loc }; - } - if (data.type.kind === "bytes" && data.type.elem === "u8") { - return { kind: "libCall", fn: "dgram.sendBytes", args: [receiver, data, port, host], type: VOID, loc }; + if (isJsSourceFile(call.getSourceFile()) && args.length <= 5 && !args.some(ts.isSpreadElement)) { + const receiver = L.lowerExpr(access.expression); + const slots: IrExpr[] = []; + let ok = true; + for (let i = 0; i < 5; i++) { + const n = args[i]; + if (!n) { + slots.push({ + kind: "dynFrom", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: DYN, + loc, + }); + continue; + } + const raw = L.lowerExpr(n); + if (raw.type.kind === "dyn") slots.push(raw); + else if (raw.kind === "unitLit" || L.dynConvertible(raw.type) || + (raw.type.kind === "func" && + canBoxFuncIntoDyn(raw.type, (id) => L.shapes.get(id), (id) => L.unions.get(id)))) { + slots.push({ kind: "dynFrom", value: raw, type: DYN, loc }); + } else { + ok = false; + break; + } + } + if (ok) { + return { + kind: "libCall", + fn: "dgram.sendChk", + args: [receiver, ...slots, ladderFenceExpr(L, `send in this form`, call, + "send(msg, port, address) — one string or Buffer datagram — is the lowered form; callback, list, and connected sends have no lowering yet")], + type: VOID, + loc, + }; + } } L.noLowering( - `send of '${L.fmt(data.type)}' data`, - args[0] ?? call, - "send takes one string or one Uint8Array/Buffer datagram (narrow unions first)", + `send with ${args.length} arguments`, + call, + "the supported form is send(msg, port, address) — one string or Buffer datagram", ); } if (name === "address") { diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 432f856f..96224667 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -10,7 +10,8 @@ * rejection, never silence. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { locOf } from "../program.js"; +import { ladderFenceExpr, nodeThrowExpr } from "./lowerer.js"; +import { isJsSourceFile, locOf } from "../program.js"; import { arrayOf, BOOL, BYTES_U8, canBoxFuncIntoDyn, canConvertToDyn, DYN, DYN_HANDLE_KINDS, F64, funcOf, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, IrExpr, IrLibFn, IrStmt, IrType, NETSERVER_T, NETSOCKET_T, NULL_T, SECURECTX_T, STRING, UNDEFINED_T, SrcLoc, typeKey, VOID } from "../../ir/nodes.js"; import { builtinFenceHintOf, @@ -460,6 +461,28 @@ export function lowerNetModuleCall(L: Lowerer, expr: ts.CallExpression, return { kind: "libCall", fn: "net.createServer", args: [], type: NETSERVER_T, loc }; } if (args.length === 1) { + // A provably-non-object, non-function argument (the invalid-input + // probes: createServer('path'), createServer(0)): Node throws + // ERR_INVALID_ARG_TYPE on 'options' before any server exists. DYN + // result — the checked-dynamic lane's concise arrows box it, and + // the throw means it never materializes. + if (isJsSourceFile(expr.getSourceFile())) { + const t = L.mapTypeOf(L.typeOf(args[0]!)); + if (t !== null && (t.kind === "string" || t.kind === "f64" || t.kind === "bool")) { + const raw = L.lowerExpr(args[0]!); + return { + kind: "libCall", + fn: "error.argTypeThrow", + args: [ + { kind: "strLit", value: "options", type: STRING, loc }, + { kind: "strLit", value: "of type object", type: STRING, loc }, + { kind: "dynFrom", value: raw, type: DYN, loc }, + ], + type: NETSERVER_T, + loc, + }; + } + } const { cb } = lowerCallbackArg( L, args[0]!, "connection handlers", 1, (p) => p.kind === "netSocket", @@ -487,6 +510,27 @@ export function lowerNetModuleCall(L: Lowerer, expr: ts.CallExpression, if (args.length >= 1 && ts.isObjectLiteralExpression(args[0]!)) { return lowerNetConnectOptions(L, expr, bi.member, loc); } + // A RUNTIME option bag (a record binding or dyn value — the + // invalid-input probes build theirs with computed keys): the + // checked-dynamic walk validates Node-order and the compiler-rendered + // fence is the post-validation tail. + if (args.length === 1 && isJsSourceFile(expr.getSourceFile())) { + const t = L.mapTypeOf(L.typeOf(args[0]!)); + if (t !== null && (t.kind === "dyn" || t.kind === "record")) { + const raw = L.lowerExpr(args[0]!); + if (raw.type.kind === "dyn" || L.dynConvertible(raw.type)) { + const bag: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; + return { + kind: "libCall", + fn: "net.connectOptsChk", + args: [bag, ladderFenceExpr(L, `${bi.member} with a runtime options record`, expr, + "pass the options as an object literal — port, host, autoSelectFamily, autoSelectFamilyAttemptTimeout, and lookup are the supported options")], + type: NETSOCKET_T, + loc, + }; + } + } + } if (args.length < 1 || args.length > 3) { L.noLowering( `${bi.member} with ${args.length} arguments`, @@ -565,10 +609,54 @@ function lookupFnShapeOk(L: Lowerer, t: IrType): boolean { function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: string, loc: SrcLoc): IrExpr { const args = expr.arguments; const optsNode = args[0] as ts.ObjectLiteralExpression; + const isJs = isJsSourceFile(expr.getSourceFile()); + // The checked-dynamic option-bag route (JS sources): a literal with + // computed keys (the invalid-input probes' spelling) or an objectMode- + // trio member rides WHOLE to the runtime walk — Node's Socket-ctor + // validation order (the trio's ERR_INVALID_ARG_VALUE first, then + // port/host/autoSelectFamily), with the compiler-rendered fence as the + // post-validation tail. + if (isJs) { + const needsBag = optsNode.properties.some((p) => + (!ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p)) || + (ts.isPropertyAssignment(p) && ts.isComputedPropertyName(p.name)) || + ["objectMode", "readableObjectMode", "writableObjectMode"].includes( + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : "", + ), + ); + if (needsBag) { + const raw = L.lowerExpr(optsNode); + if (raw.type.kind === "dyn" || L.dynConvertible(raw.type)) { + const bag: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; + return { + kind: "libCall", + fn: "net.connectOptsChk", + args: [bag, ladderFenceExpr(L, `${member} with these options`, optsNode, + "port, host, autoSelectFamily, autoSelectFamilyAttemptTimeout, and lookup are the supported options")], + type: NETSOCKET_T, + loc, + }; + } + } + } let port: IrExpr | null = null; let host: IrExpr | null = null; let lookup: IrExpr | null = null; let autoSelect = false; + let attempt: IrExpr | null = null; + let optionThrow: IrExpr | null = null; + const propThrow = (name: string, expected: string, got: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "error.propTypeThrow", + args: [ + { kind: "strLit", value: name, type: STRING, loc }, + { kind: "strLit", value: expected, type: STRING, loc }, + got.type.kind === "dyn" ? got : { kind: "dynFrom", value: got, type: DYN, loc }, + ], + type: NETSOCKET_T, + loc, + }); for (const prop of optsNode.properties) { let initializer: ts.Expression | null; if (ts.isPropertyAssignment(prop) && @@ -596,6 +684,13 @@ function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: str } else if (key === "host") { host = lowerVal(); if (host.type.kind !== "string") { + // A provably-non-string host (the invalid-input probes): Node's + // lookupAndConnect throws ERR_INVALID_ARG_TYPE at connect time. + if (isJs && (host.type.kind === "dyn" || L.dynConvertible(host.type))) { + optionThrow ??= propThrow("options.host", "of type string", host); + host = null; + continue; + } L.noLowering(`a ${member} 'host' option of '${L.fmt(host.type)}' values`, prop, "the host is a string here"); } } else if (key === "autoSelectFamily") { @@ -603,6 +698,14 @@ function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: str // dial below (false/dynamic would mean Node's single-address dial, // which the lookup form does not implement). if (initializer === null || initializer.kind !== ts.SyntaxKind.TrueKeyword) { + // A provably-non-boolean value throws Node's validateBoolean + // ladder instead of fencing. + const raw = initializer !== null && isJs ? L.lowerExpr(initializer) : null; + if (raw !== null && raw.type.kind !== "bool" && + (raw.type.kind === "dyn" || L.dynConvertible(raw.type))) { + optionThrow ??= propThrow("options.autoSelectFamily", "of type boolean", raw); + continue; + } L.noLowering( `${member} with a non-literal autoSelectFamily option`, prop, @@ -610,6 +713,19 @@ function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: str ); } autoSelect = true; + } else if (key === "autoSelectFamilyAttemptTimeout") { + // The attempt budget validates at runtime (Node's validateInt32- + // from-1 ladder) and is then inert — the single dial has nothing + // to time, the autoSelectFamily simplification's sibling. + const raw = lowerVal(); + if (!(raw.type.kind === "dyn" || raw.kind === "unitLit" || L.dynConvertible(raw.type))) { + L.noLowering( + `a ${member} 'autoSelectFamilyAttemptTimeout' option of '${L.fmt(raw.type)}' values`, + prop, + "the budget is a number here", + ); + } + attempt = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; } else if (key === "lookup") { if (initializer === null) { L.noLowering(`${member} with a shorthand lookup option`, prop, "spell it out: lookup: theResolver"); @@ -630,6 +746,10 @@ function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: str ); } } + // A collected option-contract violation replaces the whole call: Node + // throws it from Socket.connect before dialing (port validation held — + // the port arm above fenced non-number ports already). + if (optionThrow !== null) return optionThrow; if (port === null) { L.noLowering( `${member} options without a port`, @@ -638,6 +758,16 @@ function lowerNetConnectOptions(L: Lowerer, expr: ts.CallExpression, member: str ); } host ??= { kind: "strLit", value: "localhost", type: STRING, loc }; + if (attempt !== null) { + if (lookup !== null || args.length !== 1) { + L.noLowering( + `${member} with an autoSelectFamilyAttemptTimeout beside a lookup or connect listener`, + expr, + "the validated-budget form is the bare options call — register listeners separately", + ); + } + return { kind: "libCall", fn: "net.connectAttempt", args: [port, host, attempt], type: NETSOCKET_T, loc }; + } if (lookup !== null) { if (!autoSelect) { L.noLowering( @@ -878,6 +1008,33 @@ function lowerNetServerMethodCall(L: Lowerer, call: ts.CallExpression, } v6only = v; } + } else if (key === "signal" && ts.isPropertyAssignment(prop) && + isJsSourceFile(call.getSourceFile())) { + // A provably-non-AbortSignal signal (the invalid-input probes: + // strings, numbers, plain records) throws Node's + // validateAbortSignal ladder; plausible signal values keep the + // fence — abort-driven close has no lowering yet. + const raw = L.lowerExpr(prop.initializer); + const provablyNot = raw.type.kind === "string" || raw.type.kind === "f64" || + raw.type.kind === "bool" || raw.type.kind === "record" || raw.type.kind === "array"; + if (provablyNot && L.dynConvertible(raw.type)) { + return { + kind: "libCall", + fn: "error.propTypeThrow", + args: [ + { kind: "strLit", value: "options.signal", type: STRING, loc }, + { kind: "strLit", value: "an instance of AbortSignal", type: STRING, loc }, + { kind: "dynFrom", value: raw, type: DYN, loc }, + ], + type: ts.isExpressionStatement(call.parent) ? VOID : NETSERVER_T, + loc, + }; + } + L.noLowering( + `listen option 'signal'`, + prop, + "abort-driven close has no lowering yet — port, host, and ipv6Only are the supported listen options", + ); } else { L.noLowering( `listen option '${key}'`, @@ -1207,6 +1364,32 @@ function lowerNetSocketMethodCall(L: Lowerer, call: ts.CallExpression, const args = call.arguments; if (name === "write" || name === "end") { requireStatementPosition(L, call, `socket.${name}(...)`); + // write(chunk, encoding) — the two-argument encoding form: 'buffer' + // beside a string chunk is Node's stream_base typecheck ("Second + // argument must be a buffer", thrown synchronously on an established + // socket — the invalid-input probes' shape); utf8 spellings are the + // plain write (that IS the encoding written); a Buffer chunk ignores + // the encoding like Node does. Other encodings keep the fence. + if (name === "write" && args.length === 2) { + const encT = L.typeOf(args[1]!); + const chunkT = L.mapTypeOf(L.typeOf(args[0]!)); + if (encT.isStringLiteralType() && chunkT !== null) { + if (encT.value === "buffer" && chunkT.kind === "string" && + isJsSourceFile(call.getSourceFile())) { + L.lowerExpr(args[0]!); // evaluation order (effect-free in practice) + return nodeThrowExpr(1, "ERR_INVALID_ARG_TYPE", "Second argument must be a buffer", VOID, loc); + } + const passthrough = + (chunkT.kind === "string" && (encT.value === "utf8" || encT.value === "utf-8")) || + (chunkT.kind === "bytes" && chunkT.elem === "u8"); + if (passthrough) { + const receiver2 = handleReceiver(L, access.expression, NETSOCKET_T); + const data2 = L.lowerExpr(args[0]!); + const fn: IrLibFn = data2.type.kind === "string" ? "net.sockWrite" : "net.sockWriteBytes"; + return { kind: "libCall", fn, args: [receiver2, data2], type: VOID, loc }; + } + } + } const maxArgs = name === "write" ? 1 : 1; const minArgs = name === "write" ? 1 : 0; if (args.length < minArgs || args.length > maxArgs) { @@ -2267,10 +2450,34 @@ function lowerTlsServerOptions(L: Lowerer, node: ts.Expression, what: string): { * checked-dynamic JS lane's record — passes whole to the runtime walk * (scr_tls_srv_opts_walk: same member split, fences thrown at runtime, * the divergence-66 stance); anything else keeps the compile fence. */ +/** The option keys whose Node argument contracts the runtime walker + * validates (scr_tls_opts_validate) — a literal carrying any of them + * rides WHOLE to the walker so the typed ladders run in Node's order + * (the static walk would fence them before validating). */ +const TLS_VALIDATED_OPTIONS: ReadonlySet = new Set([ + "ciphers", "passphrase", "ecdhCurve", "sessionIdContext", + "clientCertEngine", "privateKeyEngine", "privateKeyIdentifier", + "minVersion", "maxVersion", "handshakeTimeout", "keepAliveInitialDelay", + "sessionTimeout", "ticketKeys", +]); + +/** True when a literal options bag carries a runtime-validated key (and + * the source is JS — TypeScript keeps its compile fences). */ +function tlsLiteralNeedsRuntimeWalk(node: ts.ObjectLiteralExpression): boolean { + if (!isJsSourceFile(node.getSourceFile())) return false; + return node.properties.some((p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + TLS_VALIDATED_OPTIONS.has(p.name.text), + ); +} + function lowerTlsServerOptionsOrDyn( L: Lowerer, node: ts.Expression, what: string, ): { cert: IrExpr; key: IrExpr; dyn?: undefined } | { dyn: IrExpr } { - if (ts.isObjectLiteralExpression(node)) return lowerTlsServerOptions(L, node, what); + if (ts.isObjectLiteralExpression(node) && !tlsLiteralNeedsRuntimeWalk(node)) { + return lowerTlsServerOptions(L, node, what); + } const v = L.lowerExpr(node); if (v.type.kind === "dyn") return { dyn: v }; // A typed options RECORD binding (`const options = { key, cert, ... }; @@ -2424,6 +2631,23 @@ function lowerTlsModuleCall(L: Lowerer, expr: ts.CallExpression, if (bi.member === "connect") { return lowerTlsConnectCall(L, expr, loc); } + if (bi.member === "getCACertificates" && args.length === 1 && !args.some(ts.isSpreadElement) && + isJsSourceFile(expr.getSourceFile())) { + // The type-argument ladder (validateString + the documented name + // set); the real CA list has no lowering, so a valid name meets the + // compiler-rendered fence after the validation. + const raw = L.lowerExpr(args[0]!); + if (raw.type.kind === "dyn" || raw.kind === "unitLit" || L.dynConvertible(raw.type)) { + const t: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; + return { + kind: "libCall", + fn: "tls.caCertsChk", + args: [t, ladderFenceExpr(L, "tls.getCACertificates", expr)], + type: L.mapTypeOf(L.typeOf(expr)) ?? DYN, + loc, + }; + } + } if (bi.member === "createSecureContext") { // createSecureContext({ cert, key }) → the opaque SecureContext handle // an SNI callback answers with. The minimal honest form: exactly the @@ -2436,8 +2660,11 @@ function lowerTlsModuleCall(L: Lowerer, expr: ts.CallExpression, "the supported form is createSecureContext({ cert, key })", ); } - const { cert, key } = lowerTlsServerOptions(L, args[0]!, "tls.createSecureContext"); - return { kind: "libCall", fn: "tls.createSecureContext", args: [cert, key], type: SECURECTX_T, loc }; + const opts = lowerTlsServerOptionsOrDyn(L, args[0]!, "tls.createSecureContext"); + if (opts.dyn !== undefined) { + return { kind: "libCall", fn: "tls.createSecureContextDyn", args: [opts.dyn], type: SECURECTX_T, loc }; + } + return { kind: "libCall", fn: "tls.createSecureContext", args: [opts.cert, opts.key], type: SECURECTX_T, loc }; } L.noLowering( `tls.${bi.member}`, diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 60792a60..f366996a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -28,9 +28,9 @@ * its Node signature (the emitter listener rule). */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { dynFallbackType } from "./lowerer.js"; +import { dynFallbackType, nodeThrowExpr } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; -import { locOf } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; import { newFnCtx, own } from "./lowerer.js"; import { appendImplicitUndefinedReturn } from "./lower-calls.js"; import { bufEncoding } from "./lower-containers.js"; @@ -1002,6 +1002,27 @@ export function lowerStreamStaticCall(L: Lowerer, call: ts.CallExpression, const member = access.name.text; const loc = locOf(call); const cls = info.def.name; + // Readable.toWeb's type-option ladder (JS sources): a provably-invalid + // `type` throws Node's ERR_INVALID_ARG_VALUE before any web stream + // exists; valid shapes keep the fence — the web bridge has no lowering. + if (member === "toWeb" && cls === "%Readable" && call.arguments.length === 2 && + isJsSourceFile(call.getSourceFile()) && ts.isObjectLiteralExpression(call.arguments[1]!)) { + for (const p of call.arguments[1]!.properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "type") { + const t = L.typeOf(p.initializer); + if (t.isStringLiteralType() && t.value !== "bytes") { + L.lowerExpr(call.arguments[0]!); // evaluation order (the stream argument) + return nodeThrowExpr( + 1, + "ERR_INVALID_ARG_VALUE", + `The property 'options.type' must be one of: 'bytes', undefined. Received '${t.value}'`, + L.mapTypeOf(L.typeOf(call)) ?? DYN, + loc, + ); + } + } + } + } if (member !== "from") { L.noLowering(`${cls.slice(1)}.${member}`, call); } @@ -1058,6 +1079,25 @@ function lowerStreamArg(L: Lowerer, node: ts.Expression, what: string): IrExpr { const v = L.lowerExpr(node); const info = v.type.kind === "object" ? L.classes.get(v.type.className) : undefined; if (!streamSidesOf(L, info)) { + // A provably-non-stream value in a JS source (the invalid-input + // probes: finished({}, cb)): Node's isNodeStream gate throws + // ERR_INVALID_ARG_TYPE before any watcher exists. + if (isJsSourceFile(node.getSourceFile()) && + (v.type.kind === "record" || v.type.kind === "string" || v.type.kind === "f64" || + v.type.kind === "bool" || v.type.kind === "array") && + L.dynConvertible(v.type)) { + throw new StreamArgTypeThrow({ + kind: "libCall", + fn: "error.argTypeThrow", + args: [ + { kind: "strLit", value: "stream", type: STRING, loc: locOf(node) }, + { kind: "strLit", value: "an instance of ReadableStream, WritableStream, or Stream", type: STRING, loc: locOf(node) }, + { kind: "dynFrom", value: v, type: DYN, loc: locOf(node) }, + ], + type: VOID, + loc: locOf(node), + }); + } L.noLowering( `${what} over a '${L.fmt(v.type)}'`, node, @@ -1067,6 +1107,15 @@ function lowerStreamArg(L: Lowerer, node: ts.Expression, what: string): IrExpr { return v; } +/** The always-throw replacement lowerStreamArg surfaces when the stream + * slot holds a provably-non-stream value — the CALL's lowering catches it + * and answers the throw as the whole call's value (Node throws before + * registering anything, so the other arguments never evaluate their + * effects; listener arguments are effect-free in practice). */ +class StreamArgTypeThrow { + constructor(readonly expr: IrExpr) {} +} + /** The finished/pipeline completion callback: an inline function lowers * through the option-callback machinery (leading `this`, an `Error | * null` prefix — Node binds the stream and passes the error); any other @@ -1145,7 +1194,13 @@ export function lowerStreamModuleCall(L: Lowerer, call: ts.CallExpression, args.length === 2 ? "the options argument has no lowering yet — the one-argument form is supported" : "the supported form is finished(stream)", ); } - const recv = lowerStreamArg(L, args[0]!, "finished"); + let recv: IrExpr; + try { + recv = lowerStreamArg(L, args[0]!, "finished"); + } catch (e) { + if (e instanceof StreamArgTypeThrow) return e.expr; + throw e; + } return { kind: "libCall", fn: "sp.finished", args: [recv], type: { kind: "promise", inner: VOID }, loc }; } // pipeline(...streams) → the callback pipeline's chaining/destroyer @@ -1190,7 +1245,13 @@ export function lowerStreamModuleCall(L: Lowerer, call: ts.CallExpression, args.length === 3 ? "the options argument has no lowering yet — the two-argument form is supported" : "the supported form is finished(stream, callback)", ); } - const recv = lowerStreamArg(L, args[0]!, "finished"); + let recv: IrExpr; + try { + recv = lowerStreamArg(L, args[0]!, "finished"); + } catch (e) { + if (e instanceof StreamArgTypeThrow) return e.expr; + throw e; + } const { cb, dyn } = lowerEosCallback(L, "finished", args[1]!, recv.type); return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 724dc972..3982eb1e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -92,7 +92,7 @@ import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } fro import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction } from "./lower-calls.js"; import { lowerArrayMethodCall, lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn, lowerRegexMethodCall, lowerStringMethodCall } from "./lower-containers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; -import { builtinImportOf, lowerBuiltinModuleCall, lowerFsToUnixTimestampCall, lowerChildArgsArg, lowerSpawnSyncCall, lowerSpawnCall, lowerExecSyncCall, recordToEnvPairs, lowerJsonMethodCall, fencedBuiltinImportOf, lowerCryptoComposedCall, lowerUrlMethodCall, lowerSearchParamsMethodCall, lowerStatsMethodCall, lowerChildMethodCall, lowerAtomicsCall, lowerBuiltinExtraProperty, promisifiedExecFileDecl, lowerExecFileAsyncCall, execFileAsyncHelper, lowerStringDecoderMethodCall, strdecHelper, lowerReadlineMethodCall, lowerDcChannelMethodCall, lowerDcChannelProperty, lowerAlsMethodCall, lowerDcTracingChannelMethodCall, lowerDcTracingChannelProperty, lowerJsonProperty, lowerErrorCodeProperty, lowerProcessProperty, isProcessEnv, envValueType, lowerProcessEnvGet, lowerProcessMethodCall, lowerProcessOptionalMethodCall, lowerTimeoutMethodCall, envSnapshotHelper, isConsoleLog, consoleCallMember, lowerNumberStaticCall, lowerNumberStaticProperty, lowerDateCall, lowerTextCodecCall, lowerFsConstantsProperty, lowerHttp2ConstantsProperty, http2ConstantBindingOf, http2ConstantsDestructureDecl, lowerProcessStreamProperty, lowerStringStaticCall, lowerStringLastIndexOfCall, lowerPromiseStaticCall } from "./lower-builtins.js"; +import { builtinImportOf, lowerBuiltinModuleCall, lowerFsToUnixTimestampCall, lowerFsLadderCall, lowerChildArgsArg, lowerSpawnSyncCall, lowerSpawnCall, lowerExecSyncCall, recordToEnvPairs, lowerJsonMethodCall, fencedBuiltinImportOf, lowerCryptoComposedCall, lowerUrlMethodCall, lowerSearchParamsMethodCall, lowerStatsMethodCall, lowerChildMethodCall, lowerAtomicsCall, lowerBuiltinExtraProperty, promisifiedExecFileDecl, lowerExecFileAsyncCall, execFileAsyncHelper, lowerStringDecoderMethodCall, strdecHelper, lowerReadlineMethodCall, lowerDcChannelMethodCall, lowerDcChannelProperty, lowerAlsMethodCall, lowerDcTracingChannelMethodCall, lowerDcTracingChannelProperty, lowerJsonProperty, lowerErrorCodeProperty, lowerProcessProperty, isProcessEnv, envValueType, lowerProcessEnvGet, lowerProcessMethodCall, lowerProcessOptionalMethodCall, lowerTimeoutMethodCall, envSnapshotHelper, isConsoleLog, consoleCallMember, lowerNumberStaticCall, lowerNumberStaticProperty, lowerDateCall, lowerTextCodecCall, lowerFsConstantsProperty, lowerHttp2ConstantsProperty, http2ConstantBindingOf, http2ConstantsDestructureDecl, lowerProcessStreamProperty, lowerStringStaticCall, lowerStringLastIndexOfCall, lowerPromiseStaticCall } from "./lower-builtins.js"; import { isIslandExpr, islandFuncValueFence, islandRegexpOf, jsvalIn, requireDynamicApi, islandGlobalFnOf, lowerDynamicImportCall, lowerFetchCall, lowerIslandMethodCall, lowerMathProperty, npmPackageOf, npmMemberFence, npmPackageOfSymbol } from "./lower-island.js"; import { lowerHttpHeadersElement, lowerNetModuleCall, lowerServerMethodCall, lowerServerProperty } from "./lower-server.js"; import { lowerDgramDnsModuleCall, lowerDgramMethodCall } from "./lower-dgram.js"; @@ -536,6 +536,27 @@ export function nodeThrowExpr(kind: 0 | 1 | 2, code: string, message: string, ty }; } +/** The post-validation fence STRING a validation-ladder Chk libCall + * throws after its Node-order checks pass: the same SC2020 text the + * per-statement runtime fence would have thrown (message + "[code at + * file:line]"), rendered eagerly so the runtime can throw it verbatim + * (scr_throw_lowering_fence). The diagnostic joins the runtime-fence + * ledger exactly like a deferred statement fence — nothing silently + * drops off the coverage report. */ +export function ladderFenceExpr(L: Lowerer, surface: string, node: ts.Node, hint?: string): IrExpr { + const loc = locOf(node); + const d = noLoweringDiag(surface, loc, hint); + L.runtimeFences.push(d); + const sf = node.getSourceFile(); + const pos = ts.getLineAndCharacterOfPosition(sf, loc.start); + return { + kind: "strLit", + value: `${d.message} [${d.code} at ${loc.file}:${pos.line + 1}]`, + type: STRING, + loc, + }; +} + /** The checked-dynamic declaration fallback for unmappable binding types * (see irTypeOf), two gates over one story: * @@ -7195,6 +7216,25 @@ export class Lowerer { const spec = requireSpecOf(decl.initializer); return spec !== null ? canonicalBuiltinModule(spec) : null; } + // A DESTRUCTURED sub-namespace binding — `const { promises } = + // fs` / `= require('fs')`: the member is itself a supported module + // ("fs/promises"), so the binding carries that module's namespace + // surface. canonicalBuiltinModule gates the composition (an + // ordinary destructured FUNCTION binding composes to an unknown + // name and answers null — builtinImportOf owns those). + if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent) && + ts.isVariableDeclaration(decl.parent.parent) && decl.parent.parent.initializer !== undefined && + decl.propertyName === undefined && decl.initializer === undefined) { + const name = decl.name; + if (name === undefined || !ts.isIdentifier(name)) return null; + const init = decl.parent.parent.initializer; + const spec = requireSpecOf(init); + const outer = spec !== null + ? canonicalBuiltinModule(spec) + : ts.isIdentifier(init) ? this.builtinNamespaceModuleOf(init) : null; + if (outer !== null) return canonicalBuiltinModule(`${outer}/${name.text}`); + return null; + } } // The INLINE CommonJS spelling: `require("cluster").isPrimary` — the // call expression IS the module namespace (Node evaluates the member @@ -7293,6 +7333,11 @@ export class Lowerer { // internal), served by its own spoke before the table fence. const fsTs = this.lowerFsToUnixTimestampCall(call, bi, locOf(access)); if (fsTs) return fsTs; + // The fs validation-ladder spoke (checked-dynamic lane): misuse of + // implemented-namespace members throws Node's typed errors instead + // of meeting the table fence. + const fsLadder = this.lowerFsLadderCall(call, bi, locOf(access)); + if (fsLadder) return fsLadder; const builtinFn = builtinModuleFnOf(this, bi.module, bi.member); if (!builtinFn) { this.noLowering( @@ -7371,6 +7416,12 @@ export class Lowerer { return lowerFsToUnixTimestampCall(this, expr, bi, loc); } + lowerFsLadderCall(expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc,): IrExpr | null { + return lowerFsLadderCall(this, expr, bi, loc); + } + lowerChildArgsArg(node: ts.Expression | undefined, loc: SrcLoc): IrExpr { return lowerChildArgsArg(this, node, loc); } diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index dcacd0be..1fc43687 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -2043,6 +2043,17 @@ export type IrLibFn = * exactly Node's split. */ | "net.serverOnSecureConnection" | "net.connect" + /** connect with a validated autoSelectFamilyAttemptTimeout option (the + * budget runs Node's validateInt32-from-1 ladder and is then inert — + * the single dial has nothing to time). May-throw. */ + | "net.connectAttempt" + /** net.connect/createConnection over a RUNTIME option bag (computed + * keys — the invalid-input probes): Node-order validation (the + * objectMode trio's ERR_INVALID_ARG_VALUE, validatePort, host string, + * autoSelectFamily boolean, the attempt budget), then the trailing + * compiler-rendered fence — ALWAYS THROWS (the error.nodeThrow + * polymorphic-result carve-out). May-throw seed. */ + | "net.connectOptsChk" | "net.connectCb" /** connect({ port, host, autoSelectFamily: true, lookup }) — the * caller-resolver dial (portless's createLoopbackConnection): args @@ -2105,6 +2116,12 @@ export type IrLibFn = | "dgram.connectCb" | "dgram.sendStr" | "dgram.sendBytes" + /** The send argument-validation ladder over DOM arguments (Node's + * signature shuffle: slice bounds, list/type contracts, port/address + * validation, connected-state errors) — a fully-validated unconnected + * single-payload send RUNS; callback/list/connected forms meet the + * trailing fence. May-throw. */ + | "dgram.sendChk" | "dgram.address" | "dgram.close" | "dgram.closeCb" @@ -2361,6 +2378,16 @@ export type IrLibFn = * opaque SecureContext handle (secureCtx kind) for SNI callbacks to * answer with; cert/key are PEM strings or Buffers like createServer's. */ | "tls.createSecureContext" + /** createSecureContext over a RUNTIME options record (the checked- + * dynamic lane): Node's typed option validations first (the ciphers/ + * passphrase/engine/version/timeout/ticketKeys ladders), then the pem + * walk — a validated { cert, key } bag builds the real context. + * May-throw. */ + | "tls.createSecureContextDyn" + /** tls.getCACertificates(type): validateString + the documented name + * set, then the trailing compiler-rendered fence — ALWAYS THROWS (the + * error.nodeThrow polymorphic-result carve-out). May-throw seed. */ + | "tls.caCertsChk" | "https.createServer" | "https.request" | "https.requestCb" @@ -2570,11 +2597,35 @@ export type IrLibFn = * finite numbers coerce (negatives answer now/1000, Node's shape); * everything else throws Node's ERR_INVALID_ARG_TYPE. May-throw. */ | "fs.toUnixTimestamp" + /** The fs argument-validation ladders (checked-dynamic lane): each Chk + * replicates its API's Node-order validation over DOM values (Node's + * exact typed errors — ERR_INVALID_ARG_TYPE/VALUE, ERR_OUT_OF_RANGE), + * and a full pass meets the trailing compiler-rendered SC2020 fence + * string — so the ALWAYS-THROW forms take the error.nodeThrow + * polymorphic-result carve-out. mkdtempSyncChk and the lchmod sync/ + * promise pair run the REAL operation on a validated pass instead + * (macOS lchmod(2); non-APPLE answers Node's not-a-function / + * ERR_METHOD_NOT_IMPLEMENTED shapes). May-throw seeds, all of them. */ + | "fs.existsChk" + | "fs.mkdtempChk" + | "fs.mkdtempSyncChk" + | "fs.readFileChk" + | "fs.opendirChk" + | "fs.watchFileChk" + | "fs.lchmodChk" + | "fs.lchmodSyncChk" + | "fsp.lchmodChk" + | "fs.readChk" + | "fs.streamOptsChk" /** The compiler-resolved ERR_INVALID_ARG_TYPE throw with a RUNTIME- * rendered Received tail: args [argname, "of type ..." clause, the * offending DOM value]. ALWAYS THROWS; polymorphic result (the * error.nodeThrow pattern). May-throw seed. */ | "error.argTypeThrow" + /** The property flavor of argTypeThrow ("The \"options.x\" property + * must be ..."): the option-bag ladders' provably-invalid arms. ALWAYS + * THROWS; polymorphic result. May-throw seed. */ + | "error.propTypeThrow" /** The checked-dynamic max-listeners ladders: setMaxChk is the * instance form over a DOM n (non-numbers ERR_INVALID_ARG_TYPE, * negatives/NaN ERR_OUT_OF_RANGE; +1 receiver back — chaining); @@ -5046,7 +5097,14 @@ export function canConvertToDyn( } if (t.kind === "union") { const def = getUnion(t.unionId); - return !!def && def.arms.every((a) => a.kind === "undefinedT" || isJsonSafeType(a, getRecord, getUnion)); + // JSON-safe arms box as before; BOXABLE FUNCTION arms join them (the + // invalid-input probes iterate `[1, null, () => {}, true]` — the + // union's func arm crosses through the checked-dynamic function + // boundary exactly like a bare func dynFrom). + return !!def && def.arms.every((a) => + a.kind === "undefinedT" || isJsonSafeType(a, getRecord, getUnion) || + (a.kind === "func" && canBoxFuncIntoDyn(a, getRecord, getUnion)), + ); } return false; } @@ -6140,6 +6198,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // divergence-66 stance). "tls.pemDyn", "tls.createServerDyn", + "tls.createSecureContextDyn", + "tls.caCertsChk", "tls.createServerDynCb", "https.createServerDyn", "https.createServerDynCb", @@ -6351,7 +6411,22 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "bytes.compareChk", "buffer.newStringFail", "fs.toUnixTimestamp", + "fs.existsChk", + "fs.mkdtempChk", + "fs.mkdtempSyncChk", + "fs.readFileChk", + "fs.opendirChk", + "fs.watchFileChk", + "fs.lchmodChk", + "fs.lchmodSyncChk", + "fsp.lchmodChk", + "fs.readChk", + "fs.streamOptsChk", + "net.connectAttempt", + "net.connectOptsChk", + "net.setAutoSelTimeout", "error.argTypeThrow", + "error.propTypeThrow", "emitter.setMaxChk", "emitter.setDefaultMaxChk", "fs.readFileSyncBytes", @@ -6374,6 +6449,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dgram.connectCb", "dgram.sendStr", "dgram.sendBytes", + "dgram.sendChk", "dgram.address", "dgram.close", "dgram.closeCb", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 6f20c91c..87b8b672 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -327,6 +327,8 @@ export const LIB_FN_SIGS: Record/tests/corpus/2591-ambient-generic-traps.ts": { + "/tests/corpus/2595-fs-arg-ladders.cjs": { "order": [ - "/tests/corpus/2591-ambient-generic-traps.ts" + "/tests/corpus/2595-fs-arg-ladders.cjs" ], "diags": [] }, @@ -4874,27 +4874,33 @@ ], "diags": [] }, - "/tests/corpus/2592-ambient-trap-uncaught.ts": { - "order": [ - "/tests/corpus/2592-ambient-trap-uncaught.ts" - ], - "diags": [] - }, "/tests/corpus/2579-jsval-object-param-crossing.js": { "order": [ "/tests/corpus/2579-jsval-object-param-crossing.js" ], "diags": [] }, - "/tests/corpus/2593-generic-inert-bindings.ts": { + "/tests/corpus/2596-net-arg-ladders.cjs": { "order": [ - "/tests/corpus/2593-generic-inert-bindings.ts" + "/tests/corpus/2596-net-arg-ladders.cjs" ], "diags": [] }, - "/tests/corpus/2594-nullish-generic-bindings.ts": { + "/tests/corpus/2597-dgram-send-ladders.cjs": { "order": [ - "/tests/corpus/2594-nullish-generic-bindings.ts" + "/tests/corpus/2597-dgram-send-ladders.cjs" + ], + "diags": [] + }, + "/tests/corpus/2598-tls-arg-ladders.cjs": { + "order": [ + "/tests/corpus/2598-tls-arg-ladders.cjs" + ], + "diags": [] + }, + "/tests/corpus/2599-stream-arg-ladders.cjs": { + "order": [ + "/tests/corpus/2599-stream-arg-ladders.cjs" ], "diags": [] }, @@ -4928,6 +4934,30 @@ ], "diags": [] }, + "/tests/corpus/2591-ambient-generic-traps.ts": { + "order": [ + "/tests/corpus/2591-ambient-generic-traps.ts" + ], + "diags": [] + }, + "/tests/corpus/2592-ambient-trap-uncaught.ts": { + "order": [ + "/tests/corpus/2592-ambient-trap-uncaught.ts" + ], + "diags": [] + }, + "/tests/corpus/2593-generic-inert-bindings.ts": { + "order": [ + "/tests/corpus/2593-generic-inert-bindings.ts" + ], + "diags": [] + }, + "/tests/corpus/2594-nullish-generic-bindings.ts": { + "order": [ + "/tests/corpus/2594-nullish-generic-bindings.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 5eb4f0be..36981b8a 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -927,7 +927,7 @@ ScrBytes *scr_bytes_from_arr(ScrBytesElem elem, const ScrArr *arr) { * the '>= 0 && <= max' render, ERR_OUT_OF_RANGE's Received rules — and * copy's C++-side ladder; pinned by corpus 1663) ─────────────────────── */ -static size_t scr_bytes_received(double v, char out[48]); /* the numeric section below */ +size_t scr_num_received(double v, char out[48]); /* the numeric section below */ /* validateOffset(name, 0, max): non-integers are 'an integer' (Number. * isInteger — ±Infinity render 'an integer' too), the rest '>= 0 && <= @@ -938,7 +938,7 @@ static size_t scr_bytes_received(double v, char out[48]); /* the numeric section bool scr_bytes_validate_off(const char *name, double value, double max) { if (isfinite(value) && floor(value) == value && value >= 0 && (max < 0 || value <= max)) return true; char recv[48]; - scr_bytes_received(value, recv); + scr_num_received(value, recv); char msg[160]; int mlen; if (floor(value) != value || !isfinite(value)) { @@ -1250,8 +1250,9 @@ ScrBytes *scr_bytes_concat(const ScrArr *list) { /* ERR_OUT_OF_RANGE's "Received" rendering: integers with |v| > 2^32 get * Node's addNumericalSeparator underscores applied to the plain String() * form — INCLUDING its quirks on exponent renderings ("1e_+21", - * "1e+_300"); everything else is the shortest-roundtrip number. */ -static size_t scr_bytes_received(double v, char out[48]) { + * "1e+_300"); everything else is the shortest-roundtrip number. Shared + * with the fs/net option-ladder validators (scr_num_received). */ +size_t scr_num_received(double v, char out[48]) { char plain[32]; size_t n = scr_f64_to_str(v, plain); if (!(isfinite(v) && trunc(v) == v && fabs(v) > 4294967296.0)) { @@ -1281,7 +1282,7 @@ static size_t scr_bytes_received(double v, char out[48]) { * "byteLength" renders min 1. All catchable RangeErrors. */ static void scr_bytes_bounds_error(double value, double length, const char *type) { char recv[48]; - scr_bytes_received(value, recv); + scr_num_received(value, recv); char msg[160]; int mlen; if (floor(value) != value) { @@ -1322,7 +1323,7 @@ static bool scr_bytes_check_int(const ScrBytes *b, double value, double offset, double min = sign ? -exp2((double)(8 * width - 1)) : 0; if (value > max || value < min) { char recv[48]; - scr_bytes_received(value, recv); + scr_num_received(value, recv); char msg[160]; int mlen; if (width > 4) { diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 1e063a76..96297b01 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -12,6 +12,9 @@ #include #include #include +#ifdef __APPLE__ +#include /* lchmod(2) — the fs.lchmodSync ladder's real tail */ +#endif static void scr_bytes_io_oom(void) { scr_trap("scriptc: out of memory\n"); @@ -254,3 +257,400 @@ double scr_fs_to_unix_timestamp(const ScrDyn *t) { scr_dyn_arg_type_fail("time", "an instance of Date or an Time in seconds", t); return 0; } + +/* ── the fs argument-validation ladders (checked-dynamic lane) ───────── + * Each fs.*Chk libCall replicates its API's Node-order validation over + * DOM values and throws Node's exact typed errors; when every validation + * passes, the honest tail runs — the real operation where one exists + * (mkdtempSync, lchmodSync on macOS), the compiler-rendered SC2020 fence + * otherwise (scr_throw_lowering_fence). All arguments borrowed. */ + +static bool scr_fs_dyn_absent(const ScrDyn *v) { + return v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL; +} + +static bool scr_fs_str_is(const ScrStr *s, const char *lit) { + size_t n = strlen(lit); + return s->len == n && memcmp(s->data, lit, n) == 0; +} + +/* Node's maybeCallback/validateFunction over a callback slot. */ +static bool scr_fs_cb_chk(const ScrDyn *cb, const char *name) { + if (cb->kind == SCR_DYN_FUNC) return true; + scr_dyn_arg_type_fail(name, "of type function", cb); + return false; +} + +/* getValidatedPath: strings and Buffers pass (URL instances never reach + * these ladders — the DOM has no URL kind here, and Node would accept + * only file: URLs anyway). */ +static bool scr_fs_path_chk(const ScrDyn *p, const char *name) { + if (p->kind == SCR_DYN_STR || p->kind == SCR_DYN_BYTES) return true; + scr_dyn_arg_type_fail(name, "of type string or an instance of Buffer or URL", p); + return false; +} + +/* assertEncoding over an options slot (a bare encoding string or an + * options record's `encoding` member): Node throws ERR_INVALID_ARG_VALUE + * for any truthy value Buffer.isEncoding rejects. */ +static bool scr_fs_encoding_chk(const ScrDyn *opts) { + const ScrDyn *enc = opts; + if (opts->kind == SCR_DYN_OBJ) { + enc = scr_dyn_obj_get(opts, "encoding", 8); + if (enc == NULL) return true; + } + if (scr_fs_dyn_absent(enc)) return true; + if (enc->kind == SCR_DYN_STR) { + if (enc->v.str->len == 0) return true; /* falsy: assertEncoding's `encoding &&` gate */ + if (scr_bytes_is_encoding(enc->v.str)) return true; + } + if (enc->kind == SCR_DYN_BOOL && !enc->v.b) return true; + if (enc->kind == SCR_DYN_NUM && enc->v.num == 0) return true; + scr_dyn_arg_value_fail("encoding", "is invalid encoding", enc); + return false; +} + +/* parseFileMode: integers 0..2^32-1 pass, octal strings parse, and the + * rest throw Node's exact ladder (validateUint32's wording). */ +static bool scr_fs_mode_chk(const ScrDyn *m, const char *name) { + if (m->kind == SCR_DYN_STR) { + const ScrStr *s = m->v.str; + bool octal = s->len > 0; + for (size_t i = 0; octal && i < s->len; i++) { + if (s->data[i] < '0' || s->data[i] > '7') octal = false; + } + if (octal) return true; + scr_dyn_arg_value_fail(name, "must be a 32-bit unsigned integer or an octal string", m); + return false; + } + if (m->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail(name, "of type number", m); + return false; + } + double v = m->v.num; + if (!(isfinite(v) && trunc(v) == v)) { + char recv[48], msg[160]; + scr_num_received(v, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be an integer. Received %s", + name, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (v < 0 || v > 4294967295.0) { + char recv[48], msg[160]; + scr_num_received(v, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be >= 0 && <= 4294967295. Received %s", + name, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + return true; +} + +/* fs.exists(path, cb) — the REAL deprecated-API shape: the callback + * validates synchronously (Node's one throwing arm), and the answer + * arrives asynchronously through it — string/Buffer paths run the + * existsSync probe at fire time, every other path kind answers `false` + * (Node swallows getValidatedPath failures there). The loop-held timer + * matches Node's I/O-completion timing closely enough for the + * sequential CLI corpus. */ +static void scr_fs_exists_fire(ScrClosure *self) { + ScrDyn *path = scr_box_get_ref(self->caps[0]); /* +1 */ + ScrDyn *cb = scr_box_get_ref(self->caps[1]); /* +1 */ + bool ans = false; + if (path->kind == SCR_DYN_STR) { + ScrStr *p = scr_str_retain(path->v.str); + ans = scr_fs_exists(p); + scr_str_release(p); + } else if (path->kind == SCR_DYN_BYTES) { + ScrStr *p = scr_str_new((const char *)path->v.bytes->data, path->v.bytes->len); + ans = scr_fs_exists(p); + scr_str_release(p); + } + ScrDyn *arg = scr_dyn_new_bool(ans); + ScrDyn *r = scr_dyn_call(cb, &arg, 1, "the fs.exists callback"); + scr_dyn_release(arg); + scr_dyn_release(r); + scr_dyn_release(path); + scr_dyn_release(cb); +} + +ScrDyn *scr_fs_exists_async(const ScrDyn *path, const ScrDyn *cb) { + if (!scr_fs_cb_chk(cb, "cb")) return NULL; + if (path->kind != SCR_DYN_STR && path->kind != SCR_DYN_BYTES) { + /* Node's wart, kept exactly: a path getValidatedPath rejects answers + * false through the callback SYNCHRONOUSLY (`return callback(false)` + * in lib/fs.js exists). */ + ScrDyn *arg = scr_dyn_new_bool(false); + ScrDyn *r = scr_dyn_call(cb, &arg, 1, "the fs.exists callback"); + scr_dyn_release(arg); + scr_dyn_release(r); + if (scr_exc_pending()) return NULL; + return scr_dyn_retain(scr_dyn_undefined()); + } + ScrClosure *clo = scr_closure_new((void *)scr_fs_exists_fire, 2); + clo->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, NULL); + scr_box_set_ref(clo->caps[0], scr_dyn_retain((ScrDyn *)path)); + clo->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, NULL); + scr_box_set_ref(clo->caps[1], scr_dyn_retain((ScrDyn *)cb)); + scr_set_timeout(clo, 0); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* fs.mkdtemp(prefix, options?, cb): callback first (makeCallback), then + * the prefix (getValidatedPath's 'prefix' slot); the async op fences. */ +void scr_fs_mkdtemp_chk(const ScrDyn *prefix, const ScrDyn *cb, const ScrStr *fence) { + if (!scr_fs_cb_chk(cb, "cb")) return; + if (!scr_fs_path_chk(prefix, "prefix")) return; + scr_throw_lowering_fence(fence); +} + +/* fs.mkdtempSync(prefix, options?): prefix validates (Node's 'prefix' + * slot), the options walk accepts only shapes that leave utf8 semantics + * (absent/empty records, utf8 spellings — invalid encodings throw the + * assertEncoding ladder, other effectful options fence), then the REAL + * mkdtemp runs. +1 result or NULL with the exception pending. */ +ScrStr *scr_fs_mkdtemp_sync_chk(const ScrDyn *prefix, const ScrDyn *opts, const ScrStr *fence) { + if (!scr_fs_path_chk(prefix, "prefix")) return NULL; + if (!scr_fs_encoding_chk(opts)) return NULL; + bool utf8 = true; + if (opts->kind == SCR_DYN_OBJ) { + for (size_t i = 0; i < opts->v.obj.len; i++) { + const ScrDynEntry *e = &opts->v.obj.entries[i]; + if (scr_fs_dyn_absent(e->value)) continue; + if (strcmp(e->key, "encoding") == 0) { + const ScrDyn *enc = e->value; + if (!(enc->kind == SCR_DYN_STR && + (scr_fs_str_is(enc->v.str, "utf8") || scr_fs_str_is(enc->v.str, "utf-8")))) { + utf8 = false; + } + continue; + } + utf8 = false; /* an unmodeled effectful option */ + } + } else if (opts->kind == SCR_DYN_STR) { + utf8 = scr_fs_str_is(opts->v.str, "utf8") || scr_fs_str_is(opts->v.str, "utf-8"); + } else if (!scr_fs_dyn_absent(opts)) { + utf8 = false; + } + if (!utf8 || prefix->kind != SCR_DYN_STR) { + scr_throw_lowering_fence(fence); + return NULL; + } + ScrStr *p = scr_str_retain(prefix->v.str); + ScrStr *r = scr_fs_mkdtemp(p); + scr_str_release(p); + return r; +} + +/* fs.readFile(path, options?, cb): Node's order — the callback + * (maybeCallback), the options walk's assertEncoding, then the path; + * the async read itself fences. */ +void scr_fs_read_file_chk(const ScrDyn *path, const ScrDyn *opts, const ScrDyn *cb, + const ScrStr *fence) { + if (!scr_fs_cb_chk(cb, "cb")) return; + if (!scr_fs_encoding_chk(opts)) return; + if (!scr_fs_path_chk(path, "path")) return; + scr_throw_lowering_fence(fence); +} + +/* fs.opendirSync(path, options?): getValidatedPath, then getOptions' + * assertEncoding; the Dir machinery fences. */ +void scr_fs_opendir_chk(const ScrDyn *path, const ScrDyn *opts, const ScrStr *fence) { + if (!scr_fs_path_chk(path, "path")) return; + if (!scr_fs_encoding_chk(opts)) return; + scr_throw_lowering_fence(fence); +} + +/* fs.watchFile(path, options?, listener): the path first, the listener's + * function check second (Node's watchFile order); real watching fences. */ +void scr_fs_watch_file_chk(const ScrDyn *path, const ScrDyn *listener, const ScrStr *fence) { + if (!scr_fs_path_chk(path, "path")) return; + if (listener->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("listener", "of type function", listener); + return; + } + scr_throw_lowering_fence(fence); +} + +/* fs.lchmod / lchmodSync / fs.promises.lchmod — macOS-only in Node (the + * callback/sync pair is not even exported elsewhere, so non-APPLE builds + * answer the not-a-function TypeError; the promise form rejects + * ERR_METHOD_NOT_IMPLEMENTED, Node's own linux shape). On macOS the + * validation ladder runs in Node's order and the real lchmod(2) applies + * where an operation survives it. */ +static bool scr_fs_lchmod_defined(const char *api) { +#ifdef __APPLE__ + (void)api; + return true; +#else + char msg[64]; + int len = snprintf(msg, sizeof msg, "%s is not a function", api); + scr_throw_error_msg(SCR_ERR_TYPE, msg, (size_t)len); + return false; +#endif +} + +void scr_fs_lchmod_chk(const ScrDyn *path, const ScrDyn *mode, const ScrDyn *cb, + const ScrStr *fence) { + if (!scr_fs_lchmod_defined("fs.lchmod")) return; + if (!scr_fs_cb_chk(cb, "cb")) return; + if (!scr_fs_path_chk(path, "path")) return; + if (!scr_fs_mode_chk(mode, "mode")) return; + scr_throw_lowering_fence(fence); /* the async op + callback dispatch */ +} + +#ifdef __APPLE__ +static void scr_fs_lchmod_apply(const ScrDyn *path, const ScrDyn *mode) { + ScrStr *p = path->kind == SCR_DYN_STR ? scr_str_retain(path->v.str) + : scr_str_new((const char *)path->v.bytes->data, path->v.bytes->len); + double m = mode->kind == SCR_DYN_NUM ? mode->v.num : (double)strtol(mode->v.str->data, NULL, 8); + if (lchmod(p->data, (mode_t)m) != 0) scr_fs_throw(errno, "lchmod", p); + scr_str_release(p); +} +#endif + +/* Answers the DOM undefined (+1) on success — lchmodSync's JS value, so + * return-position uses lower; NULL with the exception pending. */ +ScrDyn *scr_fs_lchmod_sync_chk(const ScrDyn *path, const ScrDyn *mode) { + if (!scr_fs_lchmod_defined("fs.lchmodSync")) return NULL; + if (!scr_fs_path_chk(path, "path")) return NULL; + if (!scr_fs_mode_chk(mode, "mode")) return NULL; +#ifdef __APPLE__ + scr_fs_lchmod_apply(path, mode); + if (scr_exc_pending()) return NULL; +#endif + return scr_dyn_retain(scr_dyn_undefined()); +} + +ScrPromise *scr_fsp_lchmod_chk(const ScrDyn *path, const ScrDyn *mode) { +#ifndef __APPLE__ + (void)path; + (void)mode; + static const char ni[] = "The lchmod() method is not implemented"; + scr_throw_error_msg_code(SCR_ERR_ERROR, ni, sizeof ni - 1, "ERR_METHOD_NOT_IMPLEMENTED"); + return scr_promise_settled_void(); +#else + if (scr_fs_path_chk(path, "path") && scr_fs_mode_chk(mode, "mode")) { + scr_fs_lchmod_apply(path, mode); + } + return scr_promise_settled_void(); +#endif +} + +/* fs.read(fd, buffer, offset, length, position, cb) — the full argument + * ladder in Node's order (buffer, fd, offset, length, position); the + * async read fences. Bounds follow lib/fs.js read(): offset within the + * buffer, length within buffer - offset. */ +static bool scr_fs_int_range_chk(const ScrDyn *v, const char *name, double min, double max, + const char *range) { + if (v->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail(name, "of type number", v); + return false; + } + double n = v->v.num; + char recv[48], msg[192]; + if (!(isfinite(n) && trunc(n) == n)) { + scr_num_received(n, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be an integer. Received %s", + name, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (n < min || n > max) { + scr_num_received(n, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be %s. Received %s", + name, range, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + return true; +} + +void scr_fs_read_chk(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, + const ScrDyn *length, const ScrDyn *position, const ScrStr *fence) { + if (buffer->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buffer", "an instance of Buffer, TypedArray, or DataView", buffer); + return; + } + if (fd->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("fd", "of type number", fd); + return; + } + double buflen = (double)buffer->v.bytes->len; + if (!scr_fs_dyn_absent(offset)) { + /* validateInteger's MAX_SAFE range first, the buffer bound second — + * Node renders each with its own max. */ + if (!scr_fs_int_range_chk(offset, "offset", 0, 9007199254740991.0, + ">= 0 && <= 9007199254740991")) { + return; + } + if (offset->v.num > buflen) { + char range[64]; + snprintf(range, sizeof range, ">= 0 && <= %.0f", buflen); + if (!scr_fs_int_range_chk(offset, "offset", 0, buflen, range)) return; + } + } + double off = offset->kind == SCR_DYN_NUM ? offset->v.num : 0; + if (!scr_fs_dyn_absent(length)) { + if (length->kind != SCR_DYN_NUM || !(isfinite(length->v.num) && trunc(length->v.num) == length->v.num) || + length->v.num < 0) { + /* Node renders the bare ">= 0" form here (checkPosition's cousin). */ + if (length->kind == SCR_DYN_NUM) { + char recv[48], msg[160]; + scr_num_received(length->v.num, recv); + const char *shape = (isfinite(length->v.num) && trunc(length->v.num) == length->v.num) + ? "It must be >= 0." + : "It must be an integer."; + int len = snprintf(msg, sizeof msg, + "The value of \"length\" is out of range. %s Received %s", shape, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + scr_dyn_arg_type_fail("length", "of type number", length); + return; + } + if (length->v.num > buflen - off) { + char recv[48], msg[160]; + scr_num_received(length->v.num, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"length\" is out of range. It must be <= %.0f. Received %s", + buflen - off, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + } + if (!scr_fs_dyn_absent(position)) { + if (position->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("position", "of type bigint or integer", position); + return; + } + if (!scr_fs_int_range_chk(position, "position", -1, 9007199254740991.0, + ">= -1 && <= 9007199254740991")) { + return; + } + } + scr_throw_lowering_fence(fence); +} + +/* createReadStream/createWriteStream(path, options?): getOptions' + * assertEncoding, the fd member's FileHandle-or-integer contract when + * present, the path contract otherwise; the stream machinery fences. */ +void scr_fs_stream_opts_chk(const ScrDyn *path, const ScrDyn *opts, const ScrStr *fence) { + if (!scr_fs_encoding_chk(opts)) return; + const ScrDyn *fd = opts->kind == SCR_DYN_OBJ ? scr_dyn_obj_get(opts, "fd", 2) : NULL; + if (fd != NULL && !scr_fs_dyn_absent(fd)) { + if (fd->kind != SCR_DYN_NUM) { + scr_dyn_prop_type_fail("options.fd", "of type number or an instance of FileHandle", fd); + return; + } + if (!scr_fs_int_range_chk(fd, "fd", 0, 2147483647.0, ">= 0 && <= 2147483647")) return; + } else if (!scr_fs_path_chk(path, "path")) { + return; + } + scr_throw_lowering_fence(fence); +} diff --git a/packages/runtime/src/scr_dgram.c b/packages/runtime/src/scr_dgram.c index 80e60efb..dd96923d 100644 --- a/packages/runtime/src/scr_dgram.c +++ b/packages/runtime/src/scr_dgram.c @@ -64,6 +64,7 @@ #include #include +#include #include #include #include @@ -466,7 +467,18 @@ ScrDgramSocket *scr_dgram_create(bool reuse_addr) { } static void scr_dgram_throw(const char *msg) { - scr_throw_error_msg(0 /* Error */, msg, strlen(msg)); + /* Node's state errors carry their ERR_SOCKET_* codes; the message IS + * the discriminant (each arm throws exactly one text). */ + const char *code = + strcmp(msg, "Already connected") == 0 ? "ERR_SOCKET_DGRAM_IS_CONNECTED" + : strcmp(msg, "Not running") == 0 ? "ERR_SOCKET_DGRAM_NOT_RUNNING" + : strcmp(msg, "Socket is already bound") == 0 ? "ERR_SOCKET_ALREADY_BOUND" + : NULL; + if (code != NULL) { + scr_throw_error_msg_code(0 /* Error */, msg, strlen(msg), code); + } else { + scr_throw_error_msg(0 /* Error */, msg, strlen(msg)); + } } /* Resolve a numeric host into a sockaddr_in; "" means any (bind's @@ -1005,3 +1017,166 @@ void scr_dgram_install(void) { atexit(scr_dgram_cleanup_atexit); scr_loop_set_dgram(&scr_dgram_pending, &scr_dgram_dispatch, &scr_dgram_pollfd); } + +/* ── the send argument-validation ladder (checked-dynamic lane) ───────── + * Node's Socket.prototype.send signature shuffle and validation order + * over DOM arguments, byte-for-byte: the connected/unconnected split + * decides whether (a1, a2) are an offset/length slice or the port/address + * pair; sliceBuffer validates the buffer's type and bounds + * (ERR_BUFFER_OUT_OF_BOUNDS); list payloads validate per element with the + * LIST as the Received tail; unconnected sends validate the port + * (ERR_SOCKET_BAD_PORT, > 0 and < 65536 with the specific-type tail and + * Node's trailing period) and the address's string contract; a send with + * a port or address on a connected socket answers ERR_SOCKET_DGRAM_IS_ + * CONNECTED. A fully-validated unconnected single-payload send RUNS — + * the callback form and the connected sends keep the compiler-rendered + * fence. All dyn arguments borrowed. */ + +static bool scr_dgram_dyn_truthy(const ScrDyn *v) { + switch (v->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: return false; + case SCR_DYN_BOOL: return v->v.b; + case SCR_DYN_NUM: return v->v.num == v->v.num && v->v.num != 0; + case SCR_DYN_STR: return v->v.str->len > 0; + default: return true; + } +} + +static uint32_t scr_dgram_to_u32(const ScrDyn *v) { + if (v->kind != SCR_DYN_NUM) return 0; /* >>> 0 over the ladder's shapes */ + double t = v->v.num; + if (t != t || isinf(t)) return 0; + t = trunc(t); + t = fmod(t, 4294967296.0); + if (t < 0) t += 4294967296.0; + return (uint32_t)t; +} + +static const char SCR_DGRAM_BUF_EXPECTED[] = + "of type string or an instance of Buffer, TypedArray, or DataView"; + +void scr_dgram_send_chk(ScrDgramSocket *s, const ScrDyn *buffer, const ScrDyn *a1, + const ScrDyn *a2, const ScrDyn *a3, const ScrDyn *a4, + const ScrStr *fence) { + const ScrDyn *offset = a1, *length = a2, *port = a3, *address = a4; + const ScrDyn *callback = scr_dyn_undefined(); + bool connected = s->connected; + bool sliced = false; + if (!connected) { + if (scr_dgram_dyn_truthy(address) || + (scr_dgram_dyn_truthy(port) && port->kind != SCR_DYN_FUNC)) { + sliced = true; + } else { + callback = port; + port = offset; + address = length; + } + } else { + if (length->kind == SCR_DYN_NUM) { + sliced = true; + if (port->kind == SCR_DYN_FUNC) callback = port; + } else { + callback = offset; + port = a3; + address = a4; + } + } + size_t slice_off = 0, slice_len = 0; + if (sliced) { + double bytelen; + if (buffer->kind == SCR_DYN_STR) bytelen = (double)buffer->v.str->len; + else if (buffer->kind == SCR_DYN_BYTES) bytelen = scr_bytes_byte_len(buffer->v.bytes); + else { + scr_dyn_arg_type_fail("buffer", SCR_DGRAM_BUF_EXPECTED, buffer); + return; + } + uint32_t off = scr_dgram_to_u32(offset); + uint32_t len = scr_dgram_to_u32(length); + if ((double)off > bytelen) { + static const char msg[] = "\"offset\" is outside of buffer bounds"; + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, sizeof msg - 1, "ERR_BUFFER_OUT_OF_BOUNDS"); + return; + } + if ((double)off + (double)len > bytelen) { + static const char msg[] = "\"length\" is outside of buffer bounds"; + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, sizeof msg - 1, "ERR_BUFFER_OUT_OF_BOUNDS"); + return; + } + slice_off = off; + slice_len = len; + } else if (buffer->kind == SCR_DYN_ARR) { + for (size_t i = 0; i < buffer->v.arr.len; i++) { + const ScrDyn *e = buffer->v.arr.items[i]; + if (e->kind != SCR_DYN_STR && e->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buffer list arguments", SCR_DGRAM_BUF_EXPECTED, buffer); + return; + } + } + } else if (buffer->kind != SCR_DYN_STR && buffer->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buffer", SCR_DGRAM_BUF_EXPECTED, buffer); + return; + } + if (connected) { + if (scr_dgram_dyn_truthy(port) || scr_dgram_dyn_truthy(address)) { + scr_dgram_throw("Already connected"); + return; + } + scr_throw_lowering_fence(fence); /* connected sends have no lowering yet */ + return; + } + /* validatePort(port, 'Port', false): integers (or numeric strings) + * strictly between 0 and 65536; everything else renders the specific + * type with Node's trailing period. */ + double portnum = -1; + { + bool ok = false; + if (port->kind == SCR_DYN_NUM && trunc(port->v.num) == port->v.num && + port->v.num > 0 && port->v.num < 65536) { + ok = true; + portnum = port->v.num; + } else if (port->kind == SCR_DYN_STR && port->v.str->len > 0) { + ScrStr *ps = scr_str_retain(port->v.str); + double n = scr_string_to_number(ps); + scr_str_release(ps); + if (n == n && trunc(n) == n && n > 0 && n < 65536) { + ok = true; + portnum = n; + } + } + if (!ok) { + char detail[64], msg[160]; + const char *d = scr_dyn_specific_type(port, detail, sizeof detail); + int len = snprintf(msg, sizeof msg, "Port should be > 0 and < 65536. Received %s.", d); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_SOCKET_BAD_PORT"); + return; + } + } + if (address->kind == SCR_DYN_FUNC) { + callback = address; + address = scr_dyn_undefined(); + } else if (address->kind != SCR_DYN_UNDEF && address->kind != SCR_DYN_NULL && + address->kind != SCR_DYN_STR) { + /* Node's gate is null/undefined-only: a falsy 0 still throws. */ + scr_dyn_arg_type_fail("address", "of type string", address); + return; + } + if (callback->kind == SCR_DYN_FUNC || buffer->kind == SCR_DYN_ARR) { + /* completion callbacks and list concatenation have no lowering yet — + * refuse loudly after the full validation ladder, never drop */ + scr_throw_lowering_fence(fence); + return; + } + const char *data = buffer->kind == SCR_DYN_STR ? buffer->v.str->data + : (const char *)buffer->v.bytes->data; + size_t datalen = buffer->kind == SCR_DYN_STR ? buffer->v.str->len + : (size_t)scr_bytes_byte_len(buffer->v.bytes); + if (sliced) { + data += slice_off; + datalen = slice_len; + } + ScrStr *host = address->kind == SCR_DYN_STR ? scr_str_retain(address->v.str) + : scr_str_new("127.0.0.1", 9); + scr_dgram_send_raw(s, data, datalen, portnum, host); + scr_str_release(host); +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2008ea1b..4c3a9ecb 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -674,7 +674,11 @@ const char *scr_dyn_specific_type(const ScrDyn *cb, char *detail, size_t cap) { case SCR_DYN_OBJ: d = "an instance of Object"; break; case SCR_DYN_ARR: d = "an instance of Array"; break; case SCR_DYN_BYTES: d = "an instance of Uint8Array"; break; - case SCR_DYN_FUNC: d = "function"; break; /* callers usually return before this */ + case SCR_DYN_FUNC: + /* determineSpecificType: `function ${value.name}` — anonymous + * functions keep Node's trailing space. */ + snprintf(detail, cap, "function %s", cb->v.fn.name != NULL ? cb->v.fn.name : ""); + break; case SCR_DYN_HANDLE: snprintf(detail, cap, "an instance of %s", scr_dyn_handle_cls(cb)); break; @@ -736,6 +740,81 @@ void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrD scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_INVALID_ARG_TYPE"); } +/* The property flavor of the same ladder — Node renders option-bag + * members as "The \"options.x\" property must be ..." (errors.js keys the + * wording on the name, but every property-path caller here knows it is + * one). Same runtime-rendered Received tail; always throws catchably. */ +void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got) { + char detail[64]; + const char *d = scr_dyn_specific_type(got, detail, sizeof detail); + char msg[224]; + int len = snprintf(msg, sizeof msg, + "The \"%s\" property must be %s. Received %s", name, expected, d); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_INVALID_ARG_TYPE"); +} + +/* The compiler-resolved property-typed throw (error.propTypeThrow — + * argTypeThrow's option-bag sibling). Borrows all three; always throws. */ +void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got) { + scr_dyn_prop_type_fail(name->data, expected->data, got); +} + +/* ERR_INVALID_ARG_VALUE's "Received" tail — util.inspect where ARG_TYPE + * renders determineSpecificType: strings quote, scalars print plain. + * Deep shapes render their bracket sketch (enough for the validators' + * ladders; nothing observable pins the deep forms). */ +const char *scr_dyn_inspect_lite(const ScrDyn *v, char *buf, size_t cap) { + switch (v->kind) { + case SCR_DYN_NULL: return "null"; + case SCR_DYN_UNDEF: return "undefined"; + case SCR_DYN_BOOL: return v->v.b ? "true" : "false"; + case SCR_DYN_NUM: { + scr_f64_to_str(v->v.num, buf); + return buf; + } + case SCR_DYN_STR: { + const ScrStr *s = v->v.str; + size_t n = 0; + buf[n++] = '\''; + for (size_t i = 0; i < s->len && n + 5 < cap; i++) buf[n++] = s->data[i]; + if (s->len + 2 + 5 > cap) { + memcpy(buf + n, "...", 3); + n += 3; + } + buf[n++] = '\''; + buf[n] = 0; + return buf; + } + case SCR_DYN_ARR: return "[ ... ]"; + case SCR_DYN_OBJ: return "{ ... }"; + case SCR_DYN_BYTES: return ""; + default: return "[object]"; + } +} + +/* Node's ERR_INVALID_ARG_VALUE thrower: "The '' + * . Received " — the argument/property choice follows + * errors.js (a dotted name is a property path). `reason` defaults to + * "is invalid" when NULL. TypeError, like Node's default. */ +void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got) { + char insp[64]; + const char *d = scr_dyn_inspect_lite(got, insp, sizeof insp); + char msg[256]; + int len = snprintf(msg, sizeof msg, "The %s '%s' %s. Received %s", + strchr(name, '.') != NULL ? "property" : "argument", name, + reason != NULL ? reason : "is invalid", d); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_INVALID_ARG_VALUE"); +} + +/* The deferred JS lowering fence, thrown from a ladder's post-validation + * tail: the compiler renders the message (the statement fence's own text, + * "[SC2020 at file:line]" included) and the ladder throws it verbatim + * AFTER its Node-order validations pass — Node's validation errors come + * first, the honest refuse second. Borrowed; always throws catchably. */ +void scr_throw_lowering_fence(const ScrStr *msg) { + scr_throw_error_msg_code(SCR_ERR_ERROR, msg->data, msg->len, "SC2020"); +} + static void scr_dyn_handle_release(void *h, ScrDynHandleTag tag) { scr_dyn_handle_ops(tag)->release(h); } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index a6353980..8ab7ace1 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1347,7 +1347,27 @@ static double scr_net_autosel_timeout_ms = 250; double scr_net_get_autosel_timeout(void) { return scr_net_autosel_timeout_ms; } -void scr_net_set_autosel_timeout(double ms) { scr_net_autosel_timeout_ms = ms; } +/* Node's setDefaultAutoSelectFamilyAttemptTimeout: validateInt32(value, + * 'value', 1), then the sub-10ms floor (Node clamps small budgets to + * 10ms). Throws ERR_OUT_OF_RANGE catchably. */ +void scr_net_set_autosel_timeout(double ms) { + char recv[48], msg[160]; + if (!(isfinite(ms) && trunc(ms) == ms)) { + scr_num_received(ms, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"value\" is out of range. It must be an integer. Received %s", recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + if (ms < 1 || ms > 2147483647.0) { + scr_num_received(ms, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"value\" is out of range. It must be >= 1 && <= 2147483647. Received %s", recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + scr_net_autosel_timeout_ms = ms < 10 ? 10 : ms; +} /* ── fs error formatting ───────────────────────────────────────────── * Node's fs errors read ": , ''" diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 387348a3..22549f67 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -92,6 +92,7 @@ #include "scr_runtime.h" #include +#include #include #include #include @@ -1601,6 +1602,117 @@ ScrNetSocket *scr_net_connect(double port, ScrStr *host /*borrowed, nullable*/, return s; } +/* ── the connect option-bag validation ladders (checked-dynamic lane) ── + * Node-order validation over DOM option values with Node's exact typed + * errors; the honest tail (connect for the validated forms, the + * compiler-rendered fence for bags with unmodeled keys) runs only after + * every validation passes. */ + +static bool scr_net_attempt_timeout_chk(const ScrDyn *t, const char *name) { + if (t->kind != SCR_DYN_NUM) { + scr_dyn_prop_type_fail(name, "of type number", t); + return false; + } + char recv[48], msg[192]; + if (!(isfinite(t->v.num) && trunc(t->v.num) == t->v.num)) { + scr_num_received(t->v.num, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be an integer. Received %s", + name, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (t->v.num < 1 || t->v.num > 2147483647.0) { + scr_num_received(t->v.num, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be >= 1 && <= 2147483647. Received %s", + name, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + return true; +} + +/* connect({ ..., autoSelectFamilyAttemptTimeout }): the budget validates + * (validateInt32 from 1, Node's exact texts) and is then inert — this + * slice's single dial has nothing to time, the same simplification the + * autoSelectFamily flag already takes. NULL with the throw pending. */ +ScrNetSocket *scr_net_connect_attempt(double port, ScrStr *host, const ScrDyn *t) { + if (!scr_net_attempt_timeout_chk(t, "options.autoSelectFamilyAttemptTimeout")) return NULL; + return scr_net_connect(port, host, NULL); +} + +static bool scr_net_dyn_truthy(const ScrDyn *v) { + switch (v->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: return false; + case SCR_DYN_BOOL: return v->v.b; + case SCR_DYN_NUM: return v->v.num == v->v.num && v->v.num != 0; + case SCR_DYN_STR: return v->v.str->len > 0; + default: return true; + } +} + +/* net.connect/createConnection over a RUNTIME option bag (computed keys + * — the invalid-input probes): Node's Socket-constructor order — the + * objectMode trio throws ERR_INVALID_ARG_VALUE first, then the port + * (validatePort), the host's string contract, autoSelectFamily's boolean + * contract, and the attempt budget. A bag that survives everything meets + * the compiler-rendered fence: an unmodeled key must refuse loudly, never + * silently drop. Always leaves an exception pending. */ +void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence) { + if (opts == NULL || opts->kind != SCR_DYN_OBJ) { + scr_dyn_arg_type_fail("options", "of type object", + opts ? opts : scr_dyn_undefined()); + return; + } + static const char *const om[] = { "objectMode", "readableObjectMode", "writableObjectMode" }; + for (size_t i = 0; i < 3; i++) { + const ScrDyn *v = scr_dyn_obj_get(opts, om[i], strlen(om[i])); + if (v != NULL && scr_net_dyn_truthy(v)) { + char name[48]; + snprintf(name, sizeof name, "options.%s", om[i]); + scr_dyn_arg_value_fail(name, "is not supported", v); + return; + } + } + const ScrDyn *port = scr_dyn_obj_get(opts, "port", 4); + if (port != NULL && port->kind != SCR_DYN_UNDEF) { + bool ok = port->kind == SCR_DYN_NUM && trunc(port->v.num) == port->v.num && + port->v.num >= 0 && port->v.num < 65536; + if (!ok && port->kind == SCR_DYN_STR) { + ScrStr *ps = scr_str_retain(port->v.str); + double n = scr_string_to_number(ps); + scr_str_release(ps); + ok = n == n && trunc(n) == n && n >= 0 && n < 65536 && port->v.str->len > 0; + } + if (!ok) { + char detail[64], msg[160]; + const char *d = scr_dyn_specific_type(port, detail, sizeof detail); + int len = snprintf(msg, sizeof msg, + "options.port should be >= 0 and < 65536. Received %s", d); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_SOCKET_BAD_PORT"); + return; + } + } + const ScrDyn *host = scr_dyn_obj_get(opts, "host", 4); + if (host != NULL && host->kind != SCR_DYN_UNDEF && host->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.host", "of type string", host); + return; + } + const ScrDyn *asf = scr_dyn_obj_get(opts, "autoSelectFamily", 16); + if (asf != NULL && asf->kind != SCR_DYN_UNDEF && asf->kind != SCR_DYN_BOOL) { + scr_dyn_prop_type_fail("options.autoSelectFamily", "of type boolean", asf); + return; + } + const ScrDyn *att = scr_dyn_obj_get(opts, "autoSelectFamilyAttemptTimeout", 30); + if (att != NULL && att->kind != SCR_DYN_UNDEF && + !scr_net_attempt_timeout_chk(att, "options.autoSelectFamilyAttemptTimeout")) { + return; + } + scr_throw_lowering_fence(fence); +} + /* ── the caller-lookup dial (net.connect with a lookup option) ───────── * * portless's createLoopbackConnection: connect({ host, port, diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 5c18a1a2..e1ed7150 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -3136,6 +3136,24 @@ const char *scr_dyn_specific_type(const ScrDyn *v, char *buf, size_t cap); * error.argTypeThrow libCall). Borrows all three; always throws. */ void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got); void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got); +/* The property flavor ("The \"options.x\" property must be ...") — the + * option-bag validators' gate (error.propTypeThrow). Always throws. */ +void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got); +void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got); +/* Node's ERR_INVALID_ARG_VALUE ("The argument 'encoding' is invalid + * encoding. Received 'no'") — reason NULL renders "is invalid". + * TypeError; always throws catchably. */ +void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got); +/* The ERR_INVALID_ARG_VALUE/%j "Received" renderer (inspect-lite: + * strings quote, scalars print plain, deep shapes sketch). */ +const char *scr_dyn_inspect_lite(const ScrDyn *v, char *buf, size_t cap); +/* A ladder's post-validation refuse: throws the compiler-rendered SC2020 + * statement-fence text verbatim (Node's validation errors run first). */ +void scr_throw_lowering_fence(const ScrStr *msg); +/* ERR_OUT_OF_RANGE's "Received" number rendering (Node's + * addNumericalSeparator underscores past 2^32) — scr_bytes.c's renderer, + * shared by the fs/net/tls option-ladder validators. */ +size_t scr_num_received(double v, char out[48]); /* Listener-closure builders for the handle dispatchers' .on(...) paths: * a runtime-built ScrClosure whose capture is the boxed dyn listener and * whose invoke boxes the event tuple back into the DOM and calls through @@ -4403,6 +4421,23 @@ ScrBytes *scr_buffer_new_string_fail(const ScrDyn *got); * numbers coerce (negatives answer now/1000), the rest throw Node's * ERR_INVALID_ARG_TYPE. Borrowed. */ double scr_fs_to_unix_timestamp(const ScrDyn *t); +/* The fs argument-validation ladders (the fs.*Chk libCalls): Node-order + * validation over DOM values with Node's exact typed errors; a pass + * meets the real operation where one exists (mkdtempSync, macOS + * lchmodSync) or the compiler-rendered fence. All borrowed; the Chk + * forms without results always leave an exception pending. */ +ScrDyn *scr_fs_exists_async(const ScrDyn *path, const ScrDyn *cb); +void scr_fs_mkdtemp_chk(const ScrDyn *prefix, const ScrDyn *cb, const ScrStr *fence); +ScrStr *scr_fs_mkdtemp_sync_chk(const ScrDyn *prefix, const ScrDyn *opts, const ScrStr *fence); +void scr_fs_read_file_chk(const ScrDyn *path, const ScrDyn *opts, const ScrDyn *cb, const ScrStr *fence); +void scr_fs_opendir_chk(const ScrDyn *path, const ScrDyn *opts, const ScrStr *fence); +void scr_fs_watch_file_chk(const ScrDyn *path, const ScrDyn *listener, const ScrStr *fence); +void scr_fs_lchmod_chk(const ScrDyn *path, const ScrDyn *mode, const ScrDyn *cb, const ScrStr *fence); +ScrDyn *scr_fs_lchmod_sync_chk(const ScrDyn *path, const ScrDyn *mode); +ScrPromise *scr_fsp_lchmod_chk(const ScrDyn *path, const ScrDyn *mode); +void scr_fs_read_chk(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, + const ScrDyn *length, const ScrDyn *position, const ScrStr *fence); +void scr_fs_stream_opts_chk(const ScrDyn *path, const ScrDyn *opts, const ScrStr *fence); /* The checked-dynamic max-listeners ladders (scr_events_emitter.c). */ ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n); void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name); @@ -4670,6 +4705,16 @@ void scr_net_server_on_connection(ScrNetServer *s, ScrClosure *cb /*moves*/, Scr * server never fires it, like Node). */ void scr_net_server_on_secure_connection(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrNetConnFn fn, bool once); ScrNetSocket *scr_net_connect(double port, ScrStr *host /*borrowed, nullable*/, ScrClosure *cb /*moves, nullable*/); /* +1 */ +/* connect with a validated autoSelectFamilyAttemptTimeout option: the + * budget runs Node's validateInt32-from-1 ladder (ERR_OUT_OF_RANGE / + * ERR_INVALID_ARG_TYPE) and is then inert — the single dial has nothing + * to time. +1, or NULL with the throw pending. */ +ScrNetSocket *scr_net_connect_attempt(double port, ScrStr *host /*borrowed*/, const ScrDyn *t /*borrowed*/); +/* net.connect/createConnection over a RUNTIME option bag (computed + * keys): Node-order validation (objectMode trio, port, host, + * autoSelectFamily, attempt budget), then the compiler-rendered fence — + * always leaves an exception pending. Borrowed. */ +void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence); /* connect with a caller lookup (net.connect({ ..., lookup })): invokes * lookup(hostname, options, answer-closure) synchronously; answer_fn is * the emitted per-shape thunk that decodes the answer down to @@ -4884,6 +4929,13 @@ void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, bool * the default pair, exactly Node. */ typedef struct ScrSecureCtx ScrSecureCtx; ScrSecureCtx *scr_tls_create_secure_context(const char *cert, size_t cert_len, const char *key, size_t key_len); /* +1 */ +/* createSecureContext over a RUNTIME options record: Node's typed option + * validations first, then the pem walk (+1, or NULL with the exception + * pending). Borrowed. */ +ScrSecureCtx *scr_tls_create_secure_context_dyn(const ScrDyn *opts); +/* tls.getCACertificates(type): validateString + the documented name set, + * then the compiler-rendered fence — always leaves an exception pending. */ +void scr_tls_ca_certs_chk(const ScrDyn *type, const ScrStr *fence); ScrSecureCtx *scr_secure_ctx_retain(ScrSecureCtx *c); void scr_secure_ctx_release(ScrSecureCtx *c); void *scr_secure_ctx_retain_v(void *p); @@ -5307,6 +5359,13 @@ void scr_dgram_bind(ScrDgramSocket *s, double port, ScrStr *host /*borrowed*/, S void scr_dgram_connect(ScrDgramSocket *s, double port, ScrStr *host /*borrowed*/, ScrClosure *cb /*moves, nullable*/); void scr_dgram_send_str(ScrDgramSocket *s, ScrStr *data /*borrowed*/, double port, ScrStr *host /*borrowed*/); void scr_dgram_send_bytes(ScrDgramSocket *s, ScrBytes *data /*borrowed*/, double port, ScrStr *host /*borrowed*/); +/* The send argument-validation ladder over DOM arguments (Node's + * signature shuffle, slice bounds, list/type contracts, port/address + * validation, and the connected-state errors); a fully-validated + * unconnected single-payload send RUNS, the rest meet the fence. */ +void scr_dgram_send_chk(ScrDgramSocket *s, const ScrDyn *buffer, const ScrDyn *a1, + const ScrDyn *a2, const ScrDyn *a3, const ScrDyn *a4, + const ScrStr *fence); /* address() parts: ip THROWS "Not running" before bind/connect (+1 * otherwise); family/port are only called after ip succeeded. */ ScrStr *scr_dgram_addr_ip(ScrDgramSocket *s); /* +1, may throw */ diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 14c4e344..9774a73a 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -73,6 +73,7 @@ #include "scr_runtime.h" #include +#include #include #include #include @@ -1102,6 +1103,121 @@ static const char *const SCR_TLS_SRV_FENCED_OPTIONS[] = { "sessionTimeout", "sigalgs", "ticketKeys", NULL, }; +/* ── the typed option-validation ladders (checked-dynamic lane) ──────── + * Node's configSecureContext / Server-constructor argument contracts over + * every PRESENT validated key, run BEFORE the pem walk and its fences so + * Node's typed errors always come first (the invalid-input probes isolate + * one bad option per call, so the fixed order below matches each). + * false = exception pending. */ +static bool scr_tls_opts_validate(const ScrDyn *opts) { + if (opts == NULL || opts->kind != SCR_DYN_OBJ) return true; + static const char *const STR_OPTS[] = { "ciphers", "passphrase", "ecdhCurve", "sessionIdContext", NULL }; + for (size_t i = 0; STR_OPTS[i] != NULL; i++) { + const ScrDyn *v = scr_dyn_obj_get(opts, STR_OPTS[i], strlen(STR_OPTS[i])); + if (v != NULL && v->kind != SCR_DYN_UNDEF && v->kind != SCR_DYN_NULL && v->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "options.%s", STR_OPTS[i]); + scr_dyn_prop_type_fail(name, "of type string", v); + return false; + } + } + static const char *const ENGINE_OPTS[] = { "clientCertEngine", "privateKeyEngine", "privateKeyIdentifier", NULL }; + for (size_t i = 0; ENGINE_OPTS[i] != NULL; i++) { + const ScrDyn *v = scr_dyn_obj_get(opts, ENGINE_OPTS[i], strlen(ENGINE_OPTS[i])); + if (v != NULL && v->kind != SCR_DYN_UNDEF && v->kind != SCR_DYN_NULL && v->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "options.%s", ENGINE_OPTS[i]); + scr_dyn_prop_type_fail(name, "of type string or one of null or undefined", v); + return false; + } + } + static const char *const VERSION_OPTS[] = { "minVersion", "maxVersion", NULL }; + for (size_t i = 0; VERSION_OPTS[i] != NULL; i++) { + const ScrDyn *v = scr_dyn_obj_get(opts, VERSION_OPTS[i], strlen(VERSION_OPTS[i])); + if (v == NULL || v->kind == SCR_DYN_UNDEF) continue; + bool valid = false; + if (v->kind == SCR_DYN_STR) { + static const char *const KNOWN[] = { "TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", NULL }; + for (size_t k = 0; KNOWN[k] != NULL; k++) { + if (v->v.str->len == strlen(KNOWN[k]) && memcmp(v->v.str->data, KNOWN[k], v->v.str->len) == 0) { + valid = true; + break; + } + } + } + if (!valid) { + /* %j: strings render JSON-quoted, everything else inspect-lite. */ + char rendered[96]; + if (v->kind == SCR_DYN_STR) { + snprintf(rendered, sizeof rendered, "\"%.*s\"", + (int)(v->v.str->len < 80 ? v->v.str->len : 80), v->v.str->data); + } else { + /* %j over non-strings: JSON.stringify's scalar renderings. */ + char lite[64]; + snprintf(rendered, sizeof rendered, "%s", scr_dyn_inspect_lite(v, lite, sizeof lite)); + } + char msg[192]; + int len = snprintf(msg, sizeof msg, "%s is not a valid %s TLS protocol version", + rendered, VERSION_OPTS[i][2] == 'n' ? "minimum" : "maximum"); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_TLS_INVALID_PROTOCOL_VERSION"); + return false; + } + } + static const char *const NUM_OPTS[] = { "handshakeTimeout", "keepAliveInitialDelay", NULL }; + for (size_t i = 0; NUM_OPTS[i] != NULL; i++) { + const ScrDyn *v = scr_dyn_obj_get(opts, NUM_OPTS[i], strlen(NUM_OPTS[i])); + if (v != NULL && v->kind != SCR_DYN_UNDEF && v->kind != SCR_DYN_NULL && v->kind != SCR_DYN_NUM) { + char name[48]; + snprintf(name, sizeof name, "options.%s", NUM_OPTS[i]); + scr_dyn_prop_type_fail(name, "of type number", v); + return false; + } + } + { + const ScrDyn *v = scr_dyn_obj_get(opts, "sessionTimeout", 14); + if (v != NULL && v->kind != SCR_DYN_UNDEF && v->kind != SCR_DYN_NULL) { + if (v->kind != SCR_DYN_NUM) { + scr_dyn_prop_type_fail("options.sessionTimeout", "of type number", v); + return false; + } + double n = v->v.num; + char recv[48], msg[192]; + if (!(isfinite(n) && trunc(n) == n)) { + scr_num_received(n, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"options.sessionTimeout\" is out of range. It must be an integer. Received %s", recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (n < 0 || n > 2147483647.0) { + scr_num_received(n, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"options.sessionTimeout\" is out of range. It must be >= 0 && <= 2147483647. Received %s", recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + } + } + { + const ScrDyn *v = scr_dyn_obj_get(opts, "ticketKeys", 10); + if (v != NULL && v->kind != SCR_DYN_UNDEF && v->kind != SCR_DYN_NULL) { + if (v->kind != SCR_DYN_BYTES) { + scr_dyn_prop_type_fail("options.ticketKeys", "an instance of Buffer, TypedArray, or DataView", v); + return false; + } + double bytelen = scr_bytes_byte_len(v->v.bytes); + if (bytelen != 48) { + char msg[160]; + int len = snprintf(msg, sizeof msg, + "The property 'options.ticketKeys' must be exactly 48 bytes. Received %.0f", bytelen); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_INVALID_ARG_VALUE"); + return false; + } + } + } + return true; +} + /* The server-options walk. Fills the cert/key out-params (+1 each) from a * DOM options record; false = exception pending (partial results released). */ static bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_out, @@ -1112,6 +1228,7 @@ static bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes scr_dyn_arg_type_fail("options", "of type object", opts ? opts : scr_dyn_undefined()); return false; } + if (!scr_tls_opts_validate(opts)) return false; /* Node's typed errors first */ bool ok = true; for (size_t i = 0; ok && i < opts->v.obj.len; i++) { const ScrDynEntry *e = &opts->v.obj.entries[i]; @@ -1176,6 +1293,45 @@ static bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes return ok; } +/* createSecureContext over a RUNTIME options record: Node's typed + * validations first (scr_tls_opts_validate), then the pem walk — a bag + * that validates AND carries both cert and key builds the real context; + * everything else met its ladder error or the walk's per-key fence. + * +1, or NULL with the exception pending. */ +ScrSecureCtx *scr_tls_create_secure_context_dyn(const ScrDyn *opts /*borrowed*/) { + ScrBytes *cert, *key; + if (!scr_tls_srv_opts_walk(opts, "tls.createSecureContext", &cert, &key)) return NULL; + ScrSecureCtx *c = scr_tls_create_secure_context((const char *)cert->data, cert->len, + (const char *)key->data, key->len); + scr_bytes_release(cert); + scr_bytes_release(key); + return c; +} + +/* tls.getCACertificates(type): validateString, then the documented name + * set — an unknown name answers ERR_INVALID_ARG_VALUE; the real CA list + * has no lowering, so a valid name meets the fence. Always throws. */ +void scr_tls_ca_certs_chk(const ScrDyn *type, const ScrStr *fence) { + if (type->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("type", "of type string", type); + return; + } + static const char *const KNOWN[] = { "default", "system", "bundled", "extra", NULL }; + bool valid = false; + for (size_t i = 0; KNOWN[i] != NULL; i++) { + if (type->v.str->len == strlen(KNOWN[i]) && + memcmp(type->v.str->data, KNOWN[i], type->v.str->len) == 0) { + valid = true; + break; + } + } + if (!valid) { + scr_dyn_arg_value_fail("type", NULL, type); + return; + } + scr_throw_lowering_fence(fence); +} + ScrNetServer *scr_tls_create_server_dyn(const ScrDyn *opts /*borrowed*/, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn) { @@ -1241,6 +1397,22 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable for (size_t i = 0; ok && i < opts->v.obj.len; i++) { const ScrDynEntry *e = &opts->v.obj.entries[i]; const ScrDyn *v = e->value; + if (strcmp(e->key, "checkServerIdentity") == 0) { + /* Node spreads user options over the defaults, so a PRESENT key + * replaces the builtin verifier even when its value is undefined + * — validateFunction then throws for anything non-callable. A + * real function keeps the fence (custom verification has no + * lowering). */ + if (v->kind != SCR_DYN_FUNC) { + scr_dyn_prop_type_fail("options.checkServerIdentity", "of type function", v); + ok = false; + } else { + scr_tls_opt_fence("tls.connect", "checkServerIdentity", + "custom identity verification has no lowering — the runtime verifies against the servername/host"); + ok = false; + } + continue; + } if (v->kind == SCR_DYN_UNDEF) continue; if (strcmp(e->key, "port") == 0) { if (port >= 0) continue; /* the argument form wins, like Node */ diff --git a/tests/corpus/2595-fs-arg-ladders.cjs b/tests/corpus/2595-fs-arg-ladders.cjs new file mode 100644 index 00000000..6ac77b84 --- /dev/null +++ b/tests/corpus/2595-fs-arg-ladders.cjs @@ -0,0 +1,88 @@ +// The fs argument-validation ladders: misuse of implemented-namespace fs +// APIs answers Node's exact typed errors (ERR_INVALID_ARG_TYPE / +// ERR_INVALID_ARG_VALUE / ERR_OUT_OF_RANGE) instead of fencing — exists' +// callback contract (and its REAL async answers, including the +// synchronous false for unvalidatable paths, Node's own wart), mkdtemp's +// prefix slot (the sync form running the real mkdtemp through the ladder), +// readFile/opendirSync's assertEncoding, watchFile's path/listener pair, +// createReadStream/createWriteStream's path and options.fd contracts, and +// fs.read's buffer/fd/offset/length/position ladder. The lchmod family is +// per-platform like Node itself: macOS validates (cb, path, then mode — +// octal strings, uint32 range) and non-macOS answers the not-a-function / +// ERR_METHOD_NOT_IMPLEMENTED shapes. +'use strict'; +const fs = require('fs'); +const os = require('os'); +const { promises } = fs; +const show = (fn) => { + try { + const r = fn(); + console.log('ret', typeof r === 'string' ? 'string' : r); + } catch (e) { + console.log(`${e.name}|${e.code}|${e.message}`); + } +}; + +// exists: the one throwing arm is the callback contract +show(() => fs.exists(__filename)); +show(() => fs.exists()); +show(() => fs.exists(__filename, {})); + +// mkdtemp/mkdtempSync: prefix validation, then the real operation +show(() => fs.mkdtempSync(0, {})); +show(() => fs.mkdtempSync(null, {})); +show(() => fs.mkdtemp(true, () => {})); +const made = fs.mkdtempSync(os.tmpdir() + '/scrladder-', {}); +console.log('made', typeof made, made.length > os.tmpdir().length, fs.existsSync(made)); +fs.rmdirSync(made); + +// readFile / opendirSync: assertEncoding before everything else +show(() => fs.readFile('bar.txt', { encoding: 'foo-8' }, () => {})); +show(() => fs.readFile('bar.txt')); +show(() => fs.opendirSync('.', { encoding: 'no' })); + +// watchFile: path first, listener's function contract second +show(() => fs.watchFile('./some-file')); +show(() => fs.watchFile('./another-file', {}, 'bad listener')); +show(() => fs.watchFile(new Object(), () => {})); + +// createReadStream/createWriteStream: options.fd, then the path contract +show(() => fs.createReadStream(46)); +show(() => fs.createWriteStream(46)); +show(() => fs.createReadStream(null, { fd: 'k' })); +show(() => fs.createWriteStream(null, { fd: 'k' })); + +// fs.read: buffer, fd, offset, length, position — Node's order +show(() => fs.read(3, 4, 0, 'utf-8', () => {})); +show(() => fs.read(true, Buffer.allocUnsafe(4), 0, 4, 0, () => {})); +show(() => fs.read(3, Buffer.allocUnsafe(4), NaN, 4, 0, () => {})); +show(() => fs.read(3, Buffer.allocUnsafe(4), -1, 4, 0, () => {})); +show(() => fs.read(3, Buffer.allocUnsafe(4), 0, -1, 0, () => {})); +show(() => fs.read(3, Buffer.allocUnsafe(4), 0, 4, true, () => {})); +show(() => fs.read(3, Buffer.allocUnsafe(4), 0, 4, 0.5, () => {})); + +// lchmod: per-platform, exactly like Node (macOS validates; the rest +// answer not-a-function / ERR_METHOD_NOT_IMPLEMENTED) +show(() => fs.lchmod(__filename)); +show(() => fs.lchmod(__filename, {})); +show(() => fs.lchmod(false, 0o777, () => {})); +show(() => fs.lchmodSync(1)); +show(() => fs.lchmodSync([])); +show(() => fs.lchmodSync(__filename, false)); +show(() => fs.lchmodSync(__filename, '123x')); +show(() => fs.lchmodSync(__filename, -1)); +show(() => fs.lchmodSync(__filename, 2 ** 32)); +// exists' synchronous false for a path getValidatedPath rejects — Node +// calls back before returning +fs.exists({}, (y) => console.log('cb invalid', y)); + +(async () => { + try { await promises.lchmod(__filename, {}); } catch (e) { console.log('rejected', e.code); } + try { await promises.lchmod(__filename, -1); } catch (e) { console.log('rejected', e.code, e.message); } + // exists' real async answers, registered after both rejections so the + // completion order is deterministic under Node's threadpool AND the + // compiled runtime's settled promises (divergence 23). + fs.exists(__filename, (y) => console.log('cb file', y)); + fs.exists(`${__filename}-NO`, (y) => console.log('cb missing', y)); +})(); +console.log('sync tail'); diff --git a/tests/corpus/2596-net-arg-ladders.cjs b/tests/corpus/2596-net-arg-ladders.cjs new file mode 100644 index 00000000..6ef1cd74 --- /dev/null +++ b/tests/corpus/2596-net-arg-ladders.cjs @@ -0,0 +1,36 @@ +// The net argument-validation ladders: misuse of the implemented net +// surface answers Node's exact typed errors instead of fencing — +// createServer's options-type contract, the connect option bag's +// Socket-constructor order (the objectMode trio's ERR_INVALID_ARG_VALUE, +// host's string contract, autoSelectFamily's boolean contract, the +// autoSelectFamilyAttemptTimeout range ladder — validated and then inert, +// the single-dial simplification), listen's options.signal AbortSignal +// contract, and the setDefaultAutoSelectFamilyAttemptTimeout value ladder +// with Node's sub-10ms floor. +'use strict'; +const net = require('net'); +const show = (fn) => { try { fn(); console.log('ok'); } catch (e) { console.log(`${e.name}|${e.code}|${e.message}`); } }; +show(() => { net.createServer('path'); }); +show(() => { net.createServer(0); }); +show(() => { net.createConnection({ port: 8080, host: ['192.168.0.1'] }); }); +show(() => { net.connect({ port: 8080, autoSelectFamily: 'INVALID' }); }); +show(() => { net.connect({ port: 8080, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: -10 }); }); +show(() => { net.connect({ port: 8080, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 0 }); }); +for (const autoSelectFamilyAttemptTimeout of [-10, 0]) { + show(() => { net.connect({ port: 8080, autoSelectFamily: true, autoSelectFamilyAttemptTimeout }); }); + show(() => { net.setDefaultAutoSelectFamilyAttemptTimeout(autoSelectFamilyAttemptTimeout); }); +} +show(() => net.setDefaultAutoSelectFamilyAttemptTimeout(2.5)); +for (const v of [1, 9, 25]) { + net.setDefaultAutoSelectFamilyAttemptTimeout(v); + console.log('budget', net.getDefaultAutoSelectFamilyAttemptTimeout()); +} +{ + const server = net.createServer(); + show(() => { server.listen({ port: 0, signal: 'INVALID_SIGNAL' }); }); +} +const invalidKeys = ['objectMode', 'readableObjectMode', 'writableObjectMode']; +for (const invalidKey of invalidKeys) { + const option = { port: 8080, [invalidKey]: true }; + show(() => { net.createConnection(option); }); +} diff --git a/tests/corpus/2597-dgram-send-ladders.cjs b/tests/corpus/2597-dgram-send-ladders.cjs new file mode 100644 index 00000000..648340f9 --- /dev/null +++ b/tests/corpus/2597-dgram-send-ladders.cjs @@ -0,0 +1,37 @@ +// dgram.Socket.send's argument-validation ladder: Node's signature +// shuffle over DOM arguments — the offset/length slice's type and bounds +// contracts (ERR_BUFFER_OUT_OF_BOUNDS, DataViews and subarrays included), +// the buffer-list per-element contract with the LIST as the Received +// tail, the unconnected port ladder (ERR_SOCKET_BAD_PORT with the +// specific-type tail and Node's trailing period), the address string +// contract (null/undefined-only gate — a falsy 0 still throws), and the +// connected-state ERR_SOCKET_DGRAM_IS_CONNECTED arms. createSocket's +// options.signal validates the AbortSignal contract the same way. +'use strict'; +const dgram = require('dgram'); +const sock = dgram.createSocket('udp4'); +const buf = Buffer.from('test'); +const host = '127.0.0.1'; +const show = (fn) => { try { fn(); console.log('ok'); } catch (e) { console.log(`${e.name}|${e.code}|${e.message}`); } }; +show(() => { dgram.createSocket({ type: 'udp4', signal: {} }); }); +show(() => sock.send()); +show(() => sock.send(buf, 1, 1, -1, host)); +show(() => sock.send(buf, 1, 1, 0, host)); +show(() => sock.send(buf, 1, 1, 65536, host)); +show(() => sock.send(23, 12345, host)); +show(() => sock.send([buf, 23], 12345, host)); +show(() => sock.send(buf, 6, 0)); +show(() => sock.send('hello', 6, 0, 12345, host)); +show(() => sock.send('hello', 0, 6, 12345, host)); +show(() => sock.send('hello', 3, 4, 12345, host)); +show(() => sock.send(new Uint8Array([1, 2, 3, 4, 5]).subarray(0, 5), 6, 0, 12345, host)); +show(() => sock.send(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).subarray(2, 7), 0, 6, 12345, host)); +show(() => sock.send(new DataView(new ArrayBuffer(7), 1, 5), 3, 4, 12345, host)); +sock.connect(12345, () => { + show(() => sock.send(buf, 1, 1, -1, host)); + show(() => sock.send(buf, 1234, '127.0.0.1', () => {})); + show(() => sock.send('hello', 6, 0)); + show(() => sock.send('hello', 0, 6)); + show(() => sock.send(23, 12345, host)); + sock.close(); +}); diff --git a/tests/corpus/2598-tls-arg-ladders.cjs b/tests/corpus/2598-tls-arg-ladders.cjs new file mode 100644 index 00000000..fadfd42d --- /dev/null +++ b/tests/corpus/2598-tls-arg-ladders.cjs @@ -0,0 +1,39 @@ +// The tls option-bag validation ladders: misuse of the implemented tls +// surface answers Node's exact typed errors instead of fencing — the +// createSecureContext/createServer string contracts (ciphers, passphrase, +// ecdhCurve), the engine trio's string-or-null-or-undefined clause, the +// number contracts (handshakeTimeout, sessionTimeout with validateInt32's +// range), ticketKeys' view contract and exact-48-bytes value ladder, +// minVersion/maxVersion's ERR_TLS_INVALID_PROTOCOL_VERSION with %j +// rendering, tls.connect's checkServerIdentity function contract (a +// PRESENT key replaces the builtin verifier even holding undefined — +// Node's defaults spread), and getCACertificates' type/value ladder. +'use strict'; +const tls = require('tls'); +const show = (fn) => { try { fn(); console.log('ok'); } catch (e) { console.log(`${e.name}|${e.code}|${e.message}`); } }; +show(() => { tls.createSecureContext({ ciphers: 1 }); }); +show(() => { tls.createServer({ ciphers: 1 }); }); +show(() => { tls.createSecureContext({ key: 'dummykey', passphrase: 1 }); }); +show(() => { tls.createServer({ key: 'dummykey', passphrase: 1 }); }); +show(() => { tls.createServer({ ecdhCurve: 1 }); }); +show(() => { tls.createServer({ handshakeTimeout: 'abcd' }); }); +show(() => { tls.createServer({ sessionTimeout: 'abcd' }); }); +show(() => { tls.createServer({ ticketKeys: 'abcd' }); }); +show(() => { tls.createServer({ ticketKeys: Buffer.alloc(0) }); }); +show(() => { tls.createServer({ ticketKeys: Buffer.alloc(51) }); }); +show(() => { tls.createSecureContext({ clientCertEngine: 0 }); }); +show(() => { tls.createSecureContext({ privateKeyEngine: 0, privateKeyIdentifier: 'key' }); }); +show(() => { tls.createSecureContext({ privateKeyEngine: 'engine', privateKeyIdentifier: 0 }); }); +show(() => { tls.createSecureContext({ minVersion: 'fhqwhgads' }); }); +show(() => { tls.createSecureContext({ maxVersion: 'fhqwhgads' }); }); +show(() => { tls.createSecureContext({ minVersion: 42 }); }); +show(() => { tls.createSecureContext({ sessionTimeout: -1 }); }); +show(() => { tls.createSecureContext({ sessionTimeout: 2 ** 31 }); }); +show(() => { tls.createSecureContext({ sessionTimeout: 1.5 }); }); +for (const checkServerIdentity of [undefined, null, 1, true]) { + show(() => { tls.connect({ checkServerIdentity }); }); +} +for (const invalid of [1, null, () => {}, true]) { + show(() => tls.getCACertificates(invalid)); +} +show(() => tls.getCACertificates('test')); diff --git a/tests/corpus/2599-stream-arg-ladders.cjs b/tests/corpus/2599-stream-arg-ladders.cjs new file mode 100644 index 00000000..2f9d52ea --- /dev/null +++ b/tests/corpus/2599-stream-arg-ladders.cjs @@ -0,0 +1,32 @@ +// The stream-surface validation ladders: finished() over a provably-non- +// stream value answers Node's isNodeStream ERR_INVALID_ARG_TYPE (the +// watcher never registers), socket write's two-argument encoding form +// implements Node's stream_base typecheck — write(string, 'buffer') is +// the synchronous "Second argument must be a buffer" TypeError on an +// established socket, utf8 spellings are the plain write — and +// Readable.toWeb's `type` option answers Node's one-of ladder before any +// web-stream machinery. +'use strict'; +const net = require('net'); +const { Duplex, Readable, finished } = require('stream'); +const show = (fn) => { try { fn(); console.log('ok'); } catch (e) { console.log(`${e.name}|${e.code}|${e.message}`); } }; + +show(() => { finished({}, () => {}); }); +show(() => { finished('nope', () => {}); }); + +const nodeStream = new Readable({ read() {} }); +show(() => { Readable.toWeb(nodeStream, { type: 'wrong type' }); }); +nodeStream.destroy(); + +const streamObj = new Duplex(); +streamObj.end(); +finished(streamObj, () => console.log('finished fired')); + +const server = net.createServer().listen(0, () => { + const client = net.connect(server.address().port, () => { + show(() => { client.write('broken', 'buffer'); }); + client.write('fine', 'utf8'); + client.destroy(); + server.close(); + }); +});