diff --git a/src/lib/go-bridge.ts b/src/lib/go-bridge.ts index 2e9335a8f5..9f069ceeb2 100644 --- a/src/lib/go-bridge.ts +++ b/src/lib/go-bridge.ts @@ -1,12 +1,21 @@ import { CLI } from '@snyk/error-catalog-nodejs-public'; import * as childProcess from 'child_process'; import { debug as Debug } from 'debug'; +import { StringDecoder } from 'string_decoder'; +import { abridgeErrorMessage } from './error-format'; const debug = Debug('snyk:go-bridge'); const SNYK_INTERNAL_CLI_EXECUTABLE_PATH_ENV = 'SNYK_INTERNAL_CLI_EXECUTABLE_PATH'; const MAX_BUFFER = 50 * 1024 * 1024; +const GO_BRIDGE_STDERR_PREFIX = '[go-bridge] '; +const STDERR_TRUNCATION_ELLIPSIS = ' ...(stderr truncated) ... '; + +interface PrefixedChunkResult { + chunk: string; + isAtLineStart: boolean; +} export interface GoCommandResult { exitCode: number; @@ -29,6 +38,9 @@ export interface GoCommandResult { * - The child process fails to spawn (e.g., binary not found) * - stdout exceeds the maximum buffer size * + * stderr output is soft-capped: once it reaches the maximum buffer size, it is + * truncated with an ellipsis marker and no further stderr is accumulated. + * * @param args - The arguments to pass to the Go Snyk CLI binary (e.g., ['depgraph', '--file=uv.lock']) * @param options - Optional settings for the child process * @returns A result object with the exitCode, stdout, and stderr @@ -49,6 +61,7 @@ export function execGoCommand( } debug('executing Go command: %s %s', execPath, args.join(' ')); + const shouldStreamStderr = args.includes('--debug'); const commandEnv = restoreSystemEnvironment({ ...process.env, }); @@ -56,6 +69,45 @@ export function execGoCommand( return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; + let stderrSize = 0; + let isStderrTruncated = false; + let isStderrAtLineStart = true; + const stderrDecoder = new StringDecoder('utf8'); + + const appendStderrChunk = (stderrChunk: string): void => { + if (!stderrChunk) { + return; + } + + if (shouldStreamStderr) { + const result = prefixChunkLines( + stderrChunk, + GO_BRIDGE_STDERR_PREFIX, + isStderrAtLineStart, + ); + isStderrAtLineStart = result.isAtLineStart; + process.stderr.write(result.chunk); + } + + if (isStderrTruncated) { + return; + } + + const stderrChunkSize = Buffer.byteLength(stderrChunk, 'utf8'); + if (stderrSize + stderrChunkSize > MAX_BUFFER) { + stderr = abridgeErrorMessage( + `${stderr}${stderrChunk}`, + MAX_BUFFER, + STDERR_TRUNCATION_ELLIPSIS, + ); + stderrSize = Buffer.byteLength(stderr, 'utf8'); + isStderrTruncated = true; + return; + } + + stderr += stderrChunk; + stderrSize += stderrChunkSize; + }; const proc = childProcess.spawn(execPath, args, { cwd: options?.cwd, @@ -78,8 +130,10 @@ export function execGoCommand( } if (proc.stderr) { - proc.stderr.on('data', (data: Buffer) => { - stderr += data; + proc.stderr.on('data', (data: Buffer | string) => { + const stderrChunk = + typeof data === 'string' ? data : stderrDecoder.write(data); + appendStderrChunk(stderrChunk); }); } @@ -93,6 +147,9 @@ export function execGoCommand( }); proc.on('close', (code) => { + const trailingStderrChunk = stderrDecoder.end(); + appendStderrChunk(trailingStderrChunk); + debug('Go command exited with code %d', code); resolve({ exitCode: code ?? 1, stdout, stderr }); }); @@ -123,3 +180,19 @@ function restoreSystemEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { } return env; } + +function prefixChunkLines( + chunk: string, + prefix: string, + isAtLineStart: boolean, +): PrefixedChunkResult { + if (!chunk) { + return { chunk, isAtLineStart }; + } + + const prefixedChunkBody = chunk.replace(/\n(?!$)/g, `\n${prefix}`); + return { + chunk: isAtLineStart ? `${prefix}${prefixedChunkBody}` : prefixedChunkBody, + isAtLineStart: chunk.endsWith('\n'), + }; +} diff --git a/test/jest/unit/lib/go-bridge.spec.ts b/test/jest/unit/lib/go-bridge.spec.ts index 6ecbdb195c..9805da1275 100644 --- a/test/jest/unit/lib/go-bridge.spec.ts +++ b/test/jest/unit/lib/go-bridge.spec.ts @@ -2,6 +2,7 @@ import { CLI, ProblemError } from '@snyk/error-catalog-nodejs-public'; import * as childProcess from 'child_process'; import { EventEmitter } from 'events'; import { Readable } from 'stream'; +import * as errorFormat from '../../../../src/lib/error-format'; import { execGoCommand, GoCommandResult } from '../../../../src/lib/go-bridge'; @@ -195,5 +196,149 @@ describe('go-bridge', () => { jest.restoreAllMocks(); }); + + it('streams child stderr when --debug is passed', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as any); + + const promise = execGoCommand(['depgraph', '--debug']); + + mockProc.stderr.emit('data', Buffer.from('go debug log\n')); + mockProc.emit('close', 0); + + const result = await promise; + expect(result.stderr).toBe('go debug log\n'); + expect(stderrWriteSpy).toHaveBeenCalledWith('[go-bridge] go debug log\n'); + + jest.restoreAllMocks(); + }); + + it('prefixes each stderr line when streaming in debug mode', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as any); + + const promise = execGoCommand(['depgraph', '--debug']); + + mockProc.stderr.emit('data', Buffer.from('line one\nline two\n')); + mockProc.emit('close', 0); + + await promise; + expect(stderrWriteSpy).toHaveBeenCalledWith( + '[go-bridge] line one\n[go-bridge] line two\n', + ); + + jest.restoreAllMocks(); + }); + + it('prefixes each stderr line across chunk boundaries in debug mode', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as any); + + const promise = execGoCommand(['depgraph', '--debug']); + + mockProc.stderr.emit('data', Buffer.from('line one')); + mockProc.stderr.emit('data', Buffer.from('\nline two\nline three\n')); + mockProc.emit('close', 0); + + await promise; + const streamedOutput = stderrWriteSpy.mock.calls + .map((call) => call[0] as string) + .join(''); + expect(streamedOutput).toBe( + '[go-bridge] line one\n[go-bridge] line two\n[go-bridge] line three\n', + ); + + jest.restoreAllMocks(); + }); + + it('decodes UTF-8 stderr chunks safely in debug mode', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as any); + + const promise = execGoCommand(['depgraph', '--debug']); + const utf8Chunk = Buffer.from('🙂\n'); + + mockProc.stderr.emit('data', utf8Chunk.subarray(0, 2)); + mockProc.stderr.emit('data', utf8Chunk.subarray(2)); + mockProc.emit('close', 0); + + const result = await promise; + expect(result.stderr).toBe('🙂\n'); + expect(stderrWriteSpy).toHaveBeenCalledWith('[go-bridge] 🙂\n'); + + jest.restoreAllMocks(); + }); + + it('soft-caps stderr when it exceeds maximum buffer size', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + mockProc.kill = jest.fn() as any; + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const abridgeErrorMessageSpy = jest + .spyOn(errorFormat, 'abridgeErrorMessage') + .mockReturnValue('truncated stderr'); + jest + .spyOn(Buffer, 'byteLength') + .mockReturnValueOnce(50 * 1024 * 1024 + 1); + + const promise = execGoCommand(['depgraph']); + + mockProc.stderr.emit('data', Buffer.from('too much stderr output')); + mockProc.emit('close', 0); + + const result: GoCommandResult = await promise; + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe('truncated stderr'); + expect(mockProc.kill).not.toHaveBeenCalled(); + expect(abridgeErrorMessageSpy).toHaveBeenCalledWith( + expect.any(String), + 50 * 1024 * 1024, + expect.stringContaining('stderr truncated'), + ); + + jest.restoreAllMocks(); + }); + + it('does not stream child stderr without --debug', async () => { + process.env.SNYK_INTERNAL_CLI_EXECUTABLE_PATH = '/usr/local/bin/snyk'; + + const mockProc = createMockProcess(); + jest.spyOn(childProcess, 'spawn').mockReturnValue(mockProc); + const stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as any); + + const promise = execGoCommand(['depgraph']); + + mockProc.stderr.emit('data', Buffer.from('hidden debug log\n')); + mockProc.emit('close', 0); + + const result = await promise; + expect(result.stderr).toBe('hidden debug log\n'); + expect(stderrWriteSpy).not.toHaveBeenCalled(); + + jest.restoreAllMocks(); + }); }); });