diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index ccd49c570..b5393190c 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -15,11 +15,18 @@ import type { ProviderProcessSupervisor, SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; +import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; /** Maximum wall-clock duration for one native command. */ export const NATIVE_COMMAND_TIMEOUT_MS = 60_000; /** Retain complete command output up to a bounded aggregate size. */ export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024; +/** + * Failure diagnostics keep only an excerpt of each stream. Successful + * analysis needs complete output, but a failed command's error would + * otherwise repeat up to the whole output budget to the caller. + */ +export const NATIVE_FAILURE_DIAGNOSTIC_BYTES = 64 * 1024; export interface NativeCommandCapture { readonly tool: string; @@ -383,18 +390,30 @@ type ProcessCapture = Omit< | "arguments" >; -const failureCapture = ( +/** Bound one command's output for failure diagnostics, keeping observed byte totals. */ +export const failureCapture = ( capture: ProcessCapture, truncated: boolean, -): NativeCommandFailureCapture => ({ - stdout: capture.stdout, - stderr: capture.stderr, - stdoutBytes: capture.stdoutBytes, - stderrBytes: capture.stderrBytes, - exitCode: capture.exitCode, - signal: capture.signal, - truncated, -}); +): NativeCommandFailureCapture => { + const stdout = boundedUtf8Text( + capture.stdout, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + const stderr = boundedUtf8Text( + capture.stderr, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + return { + stdout: stdout.text, + stderr: stderr.text, + // Byte counts stay the observed totals, not the excerpt lengths. + stdoutBytes: capture.stdoutBytes, + stderrBytes: capture.stderrBytes, + exitCode: capture.exitCode, + signal: capture.signal, + truncated: truncated || stdout.truncated || stderr.truncated, + }; +}; const captureProcess = async ( executable: string, diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 333569e47..f4d06ae84 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -62,6 +62,7 @@ import { NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, NativeCommandFailure, XcrunCommandRunner, + failureCapture, type NativeCommandCleanupFailure, type NativeCommandCapture, type NativeCommandRunner, @@ -997,15 +998,8 @@ const translateCodeSignExitFailure = ( "nonzero-exit", capture.exitCode, undefined, - { - stdout: capture.stdout, - stderr: capture.stderr, - stdoutBytes: capture.stdoutBytes, - stderrBytes: capture.stderrBytes, - exitCode: capture.exitCode, - signal: capture.signal, - truncated: false, - }, + // Accepted output stays complete until the provider decides it failed. + failureCapture(capture, false), ), ); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index a1e7d63ce..426b2f9f3 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -12,6 +12,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, NativeCommandFailure, XcrunCommandRunner, type NativeCommandRunner, @@ -381,6 +382,36 @@ describe("native command failure capture and limits", () => { }); }); + it("bounds both failure streams at a UTF-8 boundary while keeping their totals", async () => { + const runner = new XcrunCommandRunner({}, () => + Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })), + ); + // "é" is two bytes and straddles the excerpt boundary, so it is dropped whole. + const stdout = `${"x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}é${"y".repeat(1024)}`; + const stderr = "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024); + // Build the output in the child: one Linux argument is limited to 128 KiB. + const result = await runner.run( + "file", + [ + "-e", + `const stdout = "x".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}) + "\u00e9" + "y".repeat(1024); const stderr = "z".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024)}); process.stdout.write(stdout, () => process.stderr.write(stderr, () => process.exit(3)));`, + ], + {}, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.reason).toBe("nonzero-exit"); + expect(result.error.capture).toMatchObject({ + stdout: "x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1), + stderr: "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES), + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: Buffer.byteLength(stderr), + exitCode: 3, + truncated: true, + }); + }); + it("rejects output beyond the complete native-command budget", async () => { const runner = new XcrunCommandRunner({}, () => Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })), @@ -404,6 +435,13 @@ describe("native command failure capture and limits", () => { (result.error.capture?.stdoutBytes ?? 0) + (result.error.capture?.stderrBytes ?? 0), ).toBeLessThanOrEqual(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES); + // The failure carries an excerpt, not the whole retained output budget. + expect(result.error.capture?.stdout.length).toBe( + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + expect(result.error.capture?.stdoutBytes).toBeGreaterThan( + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); }); }); @@ -834,6 +872,43 @@ describe("native signature entitlements", () => { }); }); +/** Fail every codesign call nonzero after writing more than one excerpt. */ +class VerboseCodeSignFailureRunner extends FixtureRunner { + override async run(tool: string, arguments_: readonly string[]) { + const result = await super.run(tool, arguments_); + if (!result.ok || tool !== "codesign") return result; + const stderr = "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2); + return ok({ + ...result.value, + stderr, + stderrBytes: Buffer.byteLength(stderr), + exitCode: 1, + }); + } +} + +describe("native signature failure diagnostics", () => { + it("bounds accepted nonzero codesign output once it becomes a failure", async () => { + directory = await createTestTempDirectory("rea-signature-failure-"); + const executable = join(directory, "fixture"); + await writeFile(executable, "fixture"); + const client = new NativeMacOSProvider( + {}, + new VerboseCodeSignFailureRunner(), + "darwin", + ).createClient(await nativeMachoTargetForFile(executable)); + + const signature = await client.execute("inspect_signature", {}); + + if (signature.ok) throw new Error("A failed codesign run must fail"); + expect(signature.error.capturedOutput).toMatchObject({ + stderr: "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES), + stderr_bytes: NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2, + truncated: true, + }); + }); +}); + describe("native dispatch metadata error results", () => { it("preserves tagged cancellation and integrity failures", async () => { directory = await createTestTempDirectory("rea-dispatch-errors-");