diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 59e410b0d..eb056c524 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -3925,6 +3925,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"); diff --git a/packages/compiler/src/backend/emission/emit-walkers.ts b/packages/compiler/src/backend/emission/emit-walkers.ts index dd3b44e0a..e74ad4a53 100644 --- a/packages/compiler/src/backend/emission/emit-walkers.ts +++ b/packages/compiler/src/backend/emission/emit-walkers.ts @@ -1463,6 +1463,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 611342667..cbd26d88a 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1272,6 +1272,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/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 3cd9758d7..962246679 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -10,7 +10,7 @@ * rejection, never silence. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { ladderFenceExpr } from "./lowerer.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 { @@ -1364,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) { @@ -2424,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, ... }; @@ -2581,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 @@ -2593,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 60792a60b..f366996ac 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/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index df8658918..d1446d796 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -2378,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" @@ -5048,7 +5058,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; } @@ -6135,6 +6152,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", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 605e7e7e4..155255b2d 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -497,6 +497,8 @@ export const LIB_FN_SIGS: Recordv.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; @@ -728,7 +732,7 @@ void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDy * renders determineSpecificType: strings quote, scalars print plain. * Deep shapes render their bracket sketch (enough for the validators' * ladders; nothing observable pins the deep forms). */ -static const char *scr_dyn_inspect_lite(const ScrDyn *v, char *buf, size_t cap) { +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"; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 488af1364..4a88f7a9e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2999,6 +2999,9 @@ void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn * 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); @@ -4759,6 +4762,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); diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 14c4e344a..9774a73a9 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/2581-tls-arg-ladders.cjs b/tests/corpus/2581-tls-arg-ladders.cjs new file mode 100644 index 000000000..fadfd42d7 --- /dev/null +++ b/tests/corpus/2581-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/2582-stream-arg-ladders.cjs b/tests/corpus/2582-stream-arg-ladders.cjs new file mode 100644 index 000000000..2f9d52ea7 --- /dev/null +++ b/tests/corpus/2582-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(); + }); +});