diff --git a/src/process/spawn-secret-input.test.ts b/src/process/spawn-secret-input.test.ts new file mode 100644 index 000000000000..a2c32b5113d1 --- /dev/null +++ b/src/process/spawn-secret-input.test.ts @@ -0,0 +1,61 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { writeSecretInputToChild } from "./spawn-secret-input.js"; + +class ControlledSecretStream extends EventEmitter { + private endCallback: ((error?: Error | null) => void) | undefined; + + end(_data: Buffer, callback?: (error?: Error | null) => void): this { + this.endCallback = callback; + return this; + } + + finishWrite(error?: Error): void { + if (!this.endCallback) { + throw new Error("secret write callback was not registered"); + } + this.endCallback(error); + } +} + +function childWithSecretStream(stream: ControlledSecretStream): ChildProcess { + return { stdio: [null, null, null, stream] } as unknown as ChildProcess; +} + +function writeSecret(stream: ControlledSecretStream): Promise { + return writeSecretInputToChild(childWithSecretStream(stream), { + fd: 3, + createData: () => Buffer.from("selected-secret"), + }); +} + +describe("writeSecretInputToChild", () => { + it("consumes pipe errors after delivery until the stream closes", async () => { + const stream = new ControlledSecretStream(); + const write = writeSecret(stream); + + stream.finishWrite(); + await expect(write).resolves.toBeUndefined(); + + const reset = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); + expect(() => stream.emit("error", reset)).not.toThrow(); + expect(stream.listenerCount("error")).toBe(1); + + stream.emit("close"); + expect(stream.listenerCount("error")).toBe(0); + }); + + it("rejects delivery errors before consuming their later stream event", async () => { + const stream = new ControlledSecretStream(); + const write = writeSecret(stream); + const deliveryError = new Error("secret delivery failed"); + + stream.finishWrite(deliveryError); + await expect(write).rejects.toBe(deliveryError); + expect(() => stream.emit("error", deliveryError)).not.toThrow(); + + stream.emit("close"); + expect(stream.listenerCount("error")).toBe(0); + }); +}); diff --git a/src/process/spawn-secret-input.ts b/src/process/spawn-secret-input.ts index 4ad8454a4cbe..a1e6a95313f9 100644 --- a/src/process/spawn-secret-input.ts +++ b/src/process/spawn-secret-input.ts @@ -37,15 +37,28 @@ export async function writeSecretInputToChild( // End the parent pipe immediately after delivery so descendants cannot // inherit a still-readable credential stream. await new Promise((resolve, reject) => { - const onError = (error: Error) => { - stream.off("error", onError); - reject(error); + let settled = false; + const settle = (error?: Error | null) => { + if (settled) { + return; + } + settled = true; + if (error) { + reject(error); + } else { + resolve(); + } }; - stream.once("error", onError); - stream.end(data, () => { + const onError = (error: Error) => { + settle(error); + }; + // A child-process pipe can emit its terminal error after end's callback. + // Keep it handled until close while only the first outcome settles delivery. + stream.on("error", onError); + stream.once("close", () => { stream.off("error", onError); - resolve(); }); + stream.end(data, settle); }); } finally { data?.fill(0);