From 5f59fdc150a40f0d476bcb863d460ab615119221 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:28:43 +0000 Subject: [PATCH 1/2] tls: ignore a late setSession() instead of aborting the process BoringSSL's SSL_set_session calls abort() once the handshake has begun. patches/boringssl/set-session-return-0.patch makes it return 0 and leave the SSL unchanged. The setSession() host function returns undefined for that result, as it does for a session that does not parse. --- .claude/commands/upgrade-boringssl.md | 1 + packages/bun-types/bun.d.ts | 11 ++++- patches/boringssl/set-session-return-0.patch | 49 ++++++++++++++++++++ scripts/build/deps/boringssl.ts | 2 + src/boringssl_sys/boringssl.rs | 1 + src/runtime/socket/tls_socket_functions.rs | 11 ++--- 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 patches/boringssl/set-session-return-0.patch diff --git a/.claude/commands/upgrade-boringssl.md b/.claude/commands/upgrade-boringssl.md index b18ee4fc4051..79da365c2d61 100644 --- a/.claude/commands/upgrade-boringssl.md +++ b/.claude/commands/upgrade-boringssl.md @@ -90,6 +90,7 @@ Then `bun run ci:watch` and fix anything that turns up. - **`EVP_PBE_validate_scrypt_params`** — `crypto/evp/scrypt.cc`, `include/openssl/evp.h` - **Electron `SSL_want` / `EVP_CIPHER_do_all_sorted`** — `ssl/ssl_lib.cc` (return `rwstate` directly), `ssl/ssl_test.cc` (drops the corresponding test block), `decrepit/evp/evp_do_all.cc`, `crypto/cipher/get_cipher.cc`, `include/openssl/cipher.h` - **MLDSA stack-frame pragma** — `crypto/fipsmodule/mldsa/mldsa.cc.inc` +- **`SSL_set_session` returns 0 once the handshake has begun (upstream calls `abort()`)** — `ssl/ssl_session.cc`, `include/openssl/ssl.h`. Not in the fork yet: Bun applies `patches/boringssl/set-session-return-0.patch` on top of the pin. It is the only entry here that is not in the fork, and a bump does not carry it: land the same diff on the fork, then delete the patch file and the `patches:` entry in `scripts/build/deps/boringssl.ts` in the PR that moves the pin to the merged SHA. If upstream upstreams any of these (check `git grep` on `upstream/main` before re-applying), drop the fork's copy. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e6e36301b614..3846f4741443 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7116,7 +7116,16 @@ declare module "bun" { getSession(): void; /** - * Sets the session of the socket. + * Sets the TLS session to offer for resumption. + * + * The call has an effect only before the TLS handshake starts. Bun ignores + * a later one. + * + * To reach that window: call it inside `open`, before the first `await` + * and before any `write()`, on a socket that also has a `handshake` + * handler. The handshake starts when `open` returns, and does not wait for + * a promise `open` returned. `isSessionReused()` is the only way to tell + * whether the session was offered and accepted. * * @param session The session to set. */ diff --git a/patches/boringssl/set-session-return-0.patch b/patches/boringssl/set-session-return-0.patch new file mode 100644 index 000000000000..58888c1b18b0 --- /dev/null +++ b/patches/boringssl/set-session-return-0.patch @@ -0,0 +1,49 @@ +SSL_set_session returns 0 once the handshake has begun. + +Upstream documents that a call after the handshake has begun is an error, +and enforces it with abort(). Bun exposes this function to user JavaScript +as socket.setSession() (node:tls, Bun.connect, Bun.listen), so a late call +with a valid session killed the whole process with no catchable error. + +This patch refuses the late call with 0 and leaves the SSL unchanged. No +error is queued: callers that discard the result (lsquic) would leave it +for an unrelated later call to report. The three checks that decide the +refusal are the ones upstream uses. + +There is no upstream issue to link. Upstream aborts on purpose, so this is a +fork divergence with nothing to send upstream. + +Removal: this diff belongs on oven-sh/boringssl master, beside the other +BoringSSL divergences. Whoever lands it there deletes this file and the +`patches:` entry in scripts/build/deps/boringssl.ts in the same PR that moves +BORINGSSL_COMMIT to the merged SHA. Do not leave that to whichever pin bump +happens to come next: an open one does not carry it. + +--- a/include/openssl/ssl.h ++++ b/include/openssl/ssl.h +@@ -2258,7 +2258,8 @@ + // `SSL_SESSION_get0_signed_cert_timestamp_list`, and + // `SSL_SESSION_get0_ocsp_response`. + // +-// It is an error to call this function after the handshake has begun. ++// Bun: once the handshake has begun, this function returns zero and leaves ++// `ssl` unchanged. Upstream aborts the process on that call. + OPENSSL_EXPORT int SSL_set_session(SSL *ssl, SSL_SESSION *session); + + // SSL_DEFAULT_SESSION_TIMEOUT is the default lifetime, in seconds, of a +--- a/ssl/ssl_session.cc ++++ b/ssl/ssl_session.cc +@@ -1128,10 +1128,12 @@ + int SSL_set_session(SSL *ssl, SSL_SESSION *session) { + auto *ssl_impl = FromOpaque(ssl); + // SSL_set_session may only be called before the handshake has started. ++ // Bun: upstream aborts here. User JavaScript reaches this call through ++ // setSession() at any time, so a late call returns 0 instead. + if (ssl_impl->s3->initial_handshake_complete || // + ssl_impl->s3->hs == nullptr || // + ssl_impl->s3->hs->state != 0) { +- abort(); ++ return 0; + } + + ssl_set_session(ssl_impl, session); diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 9db3b212578d..0ead3a85b7c2 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -36,6 +36,8 @@ export const boringssl: Dependency = { commit: BORINGSSL_COMMIT, }), + patches: ["patches/boringssl/set-session-return-0.patch"], + build: cfg => { // win-x64 uses NASM-syntax .asm; everything else (including win-aarch64) // uses gas .S that clang assembles. diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index fcf5ac41b303..e2267a2057ec 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -1151,6 +1151,7 @@ unsafe extern "C" { /// Returns a BORROWED reference to the local certificate, or null. pub fn SSL_get_certificate(ssl: *const SSL) -> *mut X509; + /// Returns 0 and changes nothing once the handshake has begun. pub fn SSL_set_session(ssl: *mut SSL, session: *mut SSL_SESSION) -> c_int; pub fn SSL_SESSION_free(session: *mut SSL_SESSION); } diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 2130a73e31d0..e5e80cb2f187 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -106,8 +106,7 @@ pub(super) mod ffi { // ── SSL_SESSION ─────────────────────────────────────────────────── pub(crate) safe fn SSL_get_session(ssl: &SSL) -> *mut SSL_SESSION; pub(crate) fn SSL_SESSION_up_ref(session: *mut SSL_SESSION) -> c_int; - // Both handles are opaque-ZST refs (`UnsafeCell` body); BoringSSL bumps - // `session`'s refcount internally — no caller-side precondition. + // Returns 0 and changes nothing once the handshake has begun. pub(crate) safe fn SSL_set_session(ssl: &SSL, session: &SSL_SESSION) -> c_int; // SAFETY (unsafe fn): consumes a +1 reference; `session` must be uniquely owned or null. pub(crate) fn SSL_SESSION_free(session: *mut SSL_SESSION); @@ -1179,13 +1178,11 @@ pub(super) fn set_session( // so we must release the one returned by d2i_SSL_SESSION on every path. // SAFETY: `s` is the +1 SSL_SESSION reference returned by d2i_SSL_SESSION; we own it. let _guard = scopeguard::guard(session, |s| unsafe { ffi::SSL_SESSION_free(s) }); - if ffi::SSL_set_session( + // 0 means the handshake has begun and the session was not offered. + ffi::SSL_set_session( boringssl::SSL::opaque_ref(ssl_ptr), ffi::SSL_SESSION::opaque_ref(session), - ) != 1 - { - return Err(global.throw_value(get_ssl_exception(global, b"SSL_set_session error"))); - } + ); Ok(JSValue::UNDEFINED) } else { Err(global.throw(format_args!( From 7b00a05ac046bd27d728dd7575151a838ce33e0d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:28:45 +0000 Subject: [PATCH 2/2] test: a late setSession() on every door, and the legal window --- test/js/bun/net/socket.test.ts | 46 +++ test/js/node/tls/node-tls-connect.test.ts | 34 ++ ...ode-tls-set-session-after-start.fixture.ts | 328 ++++++++++++++++++ 3 files changed, 408 insertions(+) create mode 100644 test/js/node/tls/node-tls-set-session-after-start.fixture.ts diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 44a5482f23a6..6e708c2732a1 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5027,3 +5027,49 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async await Promise.all([serverClosed.promise, clientClosed.promise]); }); + +// BoringSSL's SSL_set_session may only be called before the handshake starts; +// upstream aborts the process otherwise. Bun patches it to return 0, so a late +// offer is ignored. Every door below killed the process with SIGABRT before +// the patch. +// +// `finished` is what setServername() reports (it throws once the handshake has +// finished), so it separates the two states BoringSSL refuses: a finished +// handshake, and one still in flight. `reused` is isSessionReused(): only an +// offer that reached the wire makes it true, so the legal door can fail. +it("setSession() after the handshake started is ignored on every Bun socket door", async () => { + const expected = { + // A finished handshake: BoringSSL's initial_handshake_complete. + "bun-connect-handshake": { threw: null, finished: true, reused: false }, + // No handshake handler, so open() runs after the handshake. The default + // timing of this API needs no unusual setup to reach. + "bun-connect-open-late": { threw: null, finished: true, reused: false }, + "bun-listen-handshake": { threw: null, finished: true }, + "bun-upgrade-tls-half": { threw: null, finished: true }, + "bun-upgrade-raw-half": { threw: null, finished: true }, + // A handshake in flight, never finished: BoringSSL's hs->state != 0. The + // chain is refused here, so the handshake fails after it started. + "bun-connect-failed-handshake": { threw: null, finished: false, success: false }, + // A write in open() starts the handshake without finishing it, so the + // call after it is late even though open() is otherwise the legal window. + "bun-connect-open-after-write": { threw: null, finished: false }, + // The legal window, which must keep working: open() before any write, on + // a socket that also has a handshake handler. The session is offered, so + // the handshake resumes. + "bun-connect-open-legal": { threw: null, finished: false, reused: true }, + }; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dirname, "../../node/tls/node-tls-set-session-after-start.fixture.ts"), + ...Object.keys(expected), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 2dbb669d7f79..90e5d4ef2bd3 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -586,6 +586,40 @@ for (const { name, connect } of tests) { }); } +// BoringSSL's SSL_set_session may only be called before the handshake starts; +// upstream aborts the process otherwise. Bun patches it to return 0, so a late +// offer is ignored and the connection keeps working. Every door below killed +// the process with SIGABRT before that patch. +// +// Node returns `undefined` from the same late call. OpenSSL accepts it there, +// and the connection can then fail with ERR_SSL_UNEXPECTED_MESSAGE. Bun keeps +// the connection instead. `reused` is isSessionReused(): the refused offer +// must not have reached the wire. +it("setSession() after the handshake started is ignored on every node:tls door", async () => { + const client = { threw: null, echo: "ping", reused: false }; + const server = { threw: null, side: "server" }; + const expected = { + "node-client": client, + // TLS over a Duplex: a second SSL owner, not the uSockets socket. + "node-duplex": client, + // tls.connect({ socket }) over a connected net.Socket: the adopt-TLS path. + "node-wrap": client, + "node-server": server, + // new TLSSocket(socket, { isServer: true }): the adopt-TLS path as a server. + "node-server-wrap": server, + }; + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dirname, "node-tls-set-session-after-start.fixture.ts"), ...Object.keys(expected)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); +}); + it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", async () => { // d2i_SSL_SESSION returns an owned SSL_SESSION; SSL_set_session takes its own // reference ("the caller retains ownership"), so the caller's reference must diff --git a/test/js/node/tls/node-tls-set-session-after-start.fixture.ts b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts new file mode 100644 index 000000000000..72a80d99eba7 --- /dev/null +++ b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts @@ -0,0 +1,328 @@ +// Fixture for the "setSession() after the handshake started" tests in +// node-tls-connect.test.ts and test/js/bun/net/socket.test.ts. +// +// BoringSSL's SSL_set_session may only be called before the handshake starts. +// Upstream enforces that with abort(); Bun patches it to return 0 +// (patches/boringssl/set-session-return-0.patch), so setSession() ignores the +// late offer and the connection keeps working. +// +// Each door below reaches setSession() through a different JS entry point with +// the handshake already started. The doors named on the command line run +// together, and the fixture prints one JSON object, door -> result. Without +// the patch the first late call kills the process with SIGABRT and nothing is +// printed. To find which door does it, run them one at a time: +// bun node-tls-set-session-after-start.fixture.ts node-client +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { Duplex } from "node:stream"; +import tls from "node:tls"; + +type Result = Record; + +// Read the cert from disk rather than importing "harness": that import costs +// about two seconds under a debug build, most of this fixture's runtime. +const keys = path.join(import.meta.dirname, "fixtures"); +const key = fs.readFileSync(path.join(keys, "agent1-key.pem")); +const cert = fs.readFileSync(path.join(keys, "agent1-cert.pem")); + +// An echo server for the client-side doors. +// TLS 1.2: a TLS 1.3 getSession() blob taken at secureConnect carries no +// ticket, so BoringSSL never offers it and the legal door could not tell a +// refused offer from an accepted one. +const serverOptions = { key, cert, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" } as const; +const server = tls.createServer(serverOptions, socket => { + socket.on("error", () => {}); + socket.on("data", chunk => socket.write(chunk)); +}); +await once(server.listen(0, "127.0.0.1"), "listening"); +const port = (server.address() as net.AddressInfo).port; +const clientOptions = { host: "127.0.0.1", port, rejectUnauthorized: false } as const; + +// A first connection produces the session every door feeds back in. Only a +// session that parses reaches SSL_set_session. +const first = tls.connect(clientOptions); +await once(first, "secureConnect"); +const session = first.getSession()!; +first.destroy(); +await once(first, "close"); + +/** Call setSession() and report whether it threw. */ +function attempt(call: () => void): { threw: string | null } { + try { + call(); + return { threw: null }; + } catch (e) { + return { threw: (e as Error).message }; + } +} + +/** + * Has the handshake FINISHED? setServername() throws "Already started." once + * SSL_is_init_finished() is true, so it separates the two states in which + * SSL_set_session refuses: a finished handshake, and one still in flight. + * BoringSSL refuses both (initial_handshake_complete, and hs->state != 0). + * A Bun socket only. + */ +function handshakeFinished(socket: { setServername(name: string): void }): boolean { + try { + socket.setServername("probe.test"); + return false; + } catch { + return true; + } +} + +/** Did the offer reach the wire? Only a resumed handshake reports true. */ +function reused(socket: { isSessionReused(): boolean }): boolean { + return socket.isSessionReused(); +} + +/** + * Call setSession() on a connected client, then prove the connection still + * carries data. A refused offer must leave the socket usable. + */ +async function lateOnClient(client: tls.TLSSocket): Promise { + const result = attempt(() => client.setSession(session)); + const echoed = new Promise(resolve => client.once("data", d => resolve(String(d)))); + client.write("ping"); + const echo = await Promise.race([echoed, once(client, "close").then(() => "")]); + const offered = reused(client); + client.destroy(); + return { ...result, echo, reused: offered }; +} + +/** Connect a throwaway node:tls client, for the doors that act on the server side. */ +function poke(serverPort: number) { + const client = tls.connect({ ...clientOptions, port: serverPort }); + client.on("error", () => {}); +} + +const bunTls = { rejectUnauthorized: false } as const; + +const doors: Record Promise> = { + // node:tls client, in its own secureConnect handler. + async "node-client"() { + const client = tls.connect(clientOptions); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // TLS over a user Duplex. A separate SSL owner (the Rust SSLWrapper), not + // the uSockets socket the other client doors use. + async "node-duplex"() { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const proxy = new Duplex({ + read() {}, + write(chunk, _enc, cb) { + raw.write(chunk, cb); + }, + }); + raw.on("data", chunk => proxy.push(chunk)); + raw.on("end", () => proxy.push(null)); + const client = tls.connect({ ...clientOptions, socket: proxy }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // tls.connect({socket}) over an already connected net.Socket: the adopt-TLS + // path, a third way to reach the same SSL. + async "node-wrap"() { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const client = tls.connect({ ...clientOptions, socket: raw }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // node:tls server, in the connection handler. The server's handshake is + // finished by the time that handler runs. + async "node-server"() { + const { promise, resolve } = Promise.withResolvers(); + const own = tls.createServer(serverOptions, socket => { + socket.on("error", () => {}); + resolve({ ...attempt(() => socket.setSession(session)), side: "server" }); + }); + await once(own.listen(0, "127.0.0.1"), "listening"); + poke((own.address() as net.AddressInfo).port); + return promise; + }, + + // new tls.TLSSocket(socket, {isServer: true}) over an accepted net.Socket: + // the adopt-TLS path again, in its server role. + async "node-server-wrap"() { + const { promise, resolve } = Promise.withResolvers(); + const plain = net.createServer(raw => { + const secure = new tls.TLSSocket(raw, { + isServer: true, + secureContext: tls.createSecureContext(serverOptions), + }); + secure.on("error", () => {}); + secure.on("secure", () => resolve({ ...attempt(() => secure.setSession(session)), side: "server" })); + }); + await once(plain.listen(0, "127.0.0.1"), "listening"); + poke((plain.address() as net.AddressInfo).port); + return promise; + }, + + // Bun.connect, from the handshake handler. + "bun-connect-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + handshake(socket) { + const finished = handshakeFinished(socket); + resolve({ ...attempt(() => socket.setSession(session)), finished, reused: reused(socket) }); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // Bun.connect with no handshake handler: open() then fires after the + // handshake, which is the default timing for this API. + "bun-connect-open-late"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + const finished = handshakeFinished(socket); + resolve({ ...attempt(() => socket.setSession(session)), finished, reused: reused(socket) }); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // Bun.listen, from the server's handshake handler. + "bun-listen-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: key.toString(), cert: cert.toString() }, + socket: { + handshake(socket) { + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + data() {}, + error() {}, + }, + }); + poke(listener.port); + return promise; + }, + + // A handshake that failed: the peer's chain is refused, so the handshake + // never finishes, but it did start. SSL_is_init_finished() is still 0 here, + // so a guard built on it alone lets this call through. + "bun-connect-failed-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + // No CA for the self-signed chain, and rejectUnauthorized stays on. + tls: true, + socket: { + handshake(socket, success) { + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket), success }); + }, + data() {}, + error() {}, + close() {}, + }, + }).catch(() => {}); + return promise; + }, + + // open() with a handshake handler is the legal window, but a write there + // starts the handshake from inside SSL_write. The call after it is late. + "bun-connect-open-after-write"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + socket.write("x"); + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + handshake() {}, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // socket.upgradeTLS() returns [raw, tls]. Both halves reach the one SSL. + "bun-upgrade-tls-half": () => upgraded(1), + "bun-upgrade-raw-half": () => upgraded(0), + + // The legal window: with both open and handshake handlers, open() runs + // before the ClientHello. This door must keep working. + "bun-connect-open-legal"() { + const { promise, resolve } = Promise.withResolvers(); + let offer: Result = {}; + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + offer = { ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }; + }, + handshake(socket) { + resolve({ ...offer, reused: reused(socket) }); + socket.end(); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, +}; + +async function upgraded(half: 0 | 1): Promise { + const { promise, resolve } = Promise.withResolvers(); + const plain = await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { open() {}, data() {}, error() {} }, + }); + const halves = plain.upgradeTLS({ + tls: bunTls, + socket: { + handshake() { + const socket = halves[half]; + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + data() {}, + error() {}, + }, + }); + return promise; +} + +const names = process.argv.slice(2); +for (const name of names) if (!doors[name]) throw new Error(`unknown door ${name}`); +const results = await Promise.all(names.map(async name => [name, await doors[name]()] as const)); +console.log(JSON.stringify(Object.fromEntries(results))); +process.exit(0);