Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions src/lib/go-bridge.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -49,13 +61,53 @@ export function execGoCommand(
}

debug('executing Go command: %s %s', execPath, args.join(' '));
const shouldStreamStderr = args.includes('--debug');
const commandEnv = restoreSystemEnvironment({
...process.env,
});

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,
Expand All @@ -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);
});
}

Expand All @@ -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 });
});
Expand Down Expand Up @@ -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'),
};
}
145 changes: 145 additions & 0 deletions test/jest/unit/lib/go-bridge.spec.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this one needs a format.

Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
});
});
Loading