From cab392bd10c6d1a7ff4260063cb1074688efe0f5 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 20:14:13 -0700 Subject: [PATCH] Fix UTF-8 decoding of piped machine environment values --- .../machine-environment.test.ts | 57 +++++++++++++++++++ apps/cli/src/commands/machine-environment.ts | 13 +++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/__tests__/command-output/machine-environment.test.ts b/apps/cli/src/__tests__/command-output/machine-environment.test.ts index 9606a6ece5..0dcdfaaeac 100644 --- a/apps/cli/src/__tests__/command-output/machine-environment.test.ts +++ b/apps/cli/src/__tests__/command-output/machine-environment.test.ts @@ -69,4 +69,61 @@ describe("machine env commands", () => { }); } }); + it.each([ + ["split UTF-8", "café € 🌍\n", "café € 🌍"], + ["empty input", "", ""], + ["CRLF", "value\r\n", "value"], + ["one final newline", "value\n\n", "value\n"], + ["ASCII byte limit", "a".repeat(65536), "a".repeat(65536)], + ["UTF-8 byte limit", "é".repeat(32768), "é".repeat(32768)], + ["over byte limit", "é".repeat(32768) + "a", null], + ["limit before newline removal", "a".repeat(65536) + "\n", null], + ] as const)( + "preserves stdin semantics: %s", + async (_label, input, expected) => { + const requests: Request[] = []; + vi.mocked(fetch).mockImplementation(async (url, init) => { + requests.push(new Request(url, init)); + return Response.json(result); + }); + const bytes = Buffer.from(input); + vi.spyOn(process.stdin, Symbol.asyncIterator).mockImplementation( + async function* () { + const start = Math.max(0, bytes.length - 16); + yield bytes.subarray(0, start); + for (let i = start; i < bytes.length; i++) + yield bytes.subarray(i, i + 1); + }, + ); + const descriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", + )!; + Object.defineProperty(process.stdin, "isTTY", { value: false }); + try { + const run = runCommand( + ["machine", "env", "set", "VALUE", "--json"], + register, + ); + if (expected === null) { + await expect(run).rejects.toThrow("process.exit:1"); + expect(requests.map((request) => request.method)).toEqual(["GET"]); + expect(collectLogPayloads(vi.mocked(console.error))).toContain( + "Error: Environment value exceeds 65536 bytes.", + ); + } else { + await run; + expect(await requests[1].json()).toEqual({ + variables: [ + { name: "GH_TOKEN", value: null, note: null }, + { name: "VALUE", value: expected, note: null }, + ], + }); + expect(requests[1].method).toBe("PUT"); + } + } finally { + Object.defineProperty(process.stdin, "isTTY", descriptor); + } + }, + ); }); diff --git a/apps/cli/src/commands/machine-environment.ts b/apps/cli/src/commands/machine-environment.ts index 5f9686b6b4..437491b382 100644 --- a/apps/cli/src/commands/machine-environment.ts +++ b/apps/cli/src/commands/machine-environment.ts @@ -23,13 +23,18 @@ async function readValue(): Promise { throw new Error( "Pipe the value to stdin; environment values are never accepted in command arguments.", ); - let value = ""; + const chunks: Buffer[] = []; + let bytes = 0; for await (const chunk of process.stdin) { - value += String(chunk); - if (Buffer.byteLength(value) > 65536) + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > 65536) throw new Error("Environment value exceeds 65536 bytes."); + chunks.push(buffer); } - return value.replace(/\r?\n$/u, ""); + return Buffer.concat(chunks) + .toString("utf8") + .replace(/\r?\n$/u, ""); } export function registerMachineEnvironmentCommands(