Skip to content

Commit cbabc52

Browse files
committed
fix(cdxgen): report why cdxgen failed instead of exiting 1 in silence
1 parent 0bd8b9e commit cbabc52

6 files changed

Lines changed: 383 additions & 3 deletions

File tree

packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
1010
import { isPath } from '@socketsecurity/lib-stable/paths/normalize'
1111
import { pluralize } from '@socketsecurity/lib-stable/words/pluralize'
1212

13+
import { formatCdxgenFailureMessage } from '../../util/dlx/cdxgen-diagnostics.mts'
1314
import {
1415
detectNodejsCdxgenSources,
1516
isNodejsCdxgenType,
@@ -336,15 +337,28 @@ export async function run(
336337
}
337338
}
338339

340+
// Assume failure until cdxgen reports otherwise, so an unexpected early exit
341+
// cannot be mistaken for a successful scan.
339342
process.exitCode = 1
340343

341-
const { spawnPromise } = await runCdxgen(yargv)
344+
let result
345+
try {
346+
const { spawnPromise } = await runCdxgen(yargv)
347+
result = await spawnPromise
348+
} catch (e) {
349+
// cdxgen runs with stdio: 'inherit', so a failure to start it produces no
350+
// child output at all. Say where we looked and what to try next.
351+
logger.fail(formatCdxgenFailureMessage(e))
352+
return
353+
}
342354

343-
// Wait for the spawn promise to resolve and handle the result.
344-
const result = await spawnPromise
345355
if (result.signal) {
346356
process.kill(process.pid, result.signal)
347357
} else if (typeof result.code === 'number') {
348358
process.exit(result.code)
359+
} else {
360+
// Neither an exit code nor a signal. Without this branch the command
361+
// returns here with the exit code armed above and prints nothing at all.
362+
logger.fail(formatCdxgenFailureMessage())
349363
}
350364
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Diagnostics for the `socket cdxgen` command.
3+
*
4+
* Cdxgen runs as a child process, so when it cannot start there is nothing on
5+
* stdout or stderr to explain why. These helpers turn that into a message that
6+
* names where the CLI looked for cdxgen and what to try next.
7+
*/
8+
9+
import { existsSync } from 'node:fs'
10+
11+
import { errorMessage } from '@socketsecurity/lib-stable/errors/message'
12+
13+
import { resolveCdxgen } from './resolve-binary.mjs'
14+
15+
/**
16+
* Describe where the CLI resolved cdxgen from, so an error can say which of
17+
* the two sources actually failed.
18+
*/
19+
export function describeCdxgenSource(): string {
20+
const resolution = resolveCdxgen()
21+
if (resolution.type === 'local') {
22+
return `the local override SOCKET_CLI_CDXGEN_LOCAL_PATH=${resolution.path}`
23+
}
24+
if (resolution.type === 'dlx') {
25+
const { name, version } = resolution.details
26+
return `${name}@${version}, downloaded and cached by Socket's dlx`
27+
}
28+
return "a binary downloaded and cached by Socket's dlx"
29+
}
30+
31+
/**
32+
* Build the message shown when cdxgen fails to produce a result.
33+
*
34+
* Pass the underlying error when there is one. Pass nothing for the case where
35+
* the child process ended without reporting either an exit code or a signal,
36+
* which is the shape that used to exit 1 with no output at all.
37+
*/
38+
export function formatCdxgenFailureMessage(
39+
cause?: unknown | undefined,
40+
): string {
41+
const detail = cause
42+
? errorMessage(cause)
43+
: 'the cdxgen process ended without reporting an exit code or a signal'
44+
45+
return [
46+
'socket cdxgen could not run cdxgen.',
47+
` Where: ${describeCdxgenSource()}`,
48+
` Saw: ${detail || 'no error message was reported'}`,
49+
' Fix: Re-run with SOCKET_CLI_DEBUG=1 to see the full error. If cdxgen',
50+
' cannot be downloaded on this machine, install it yourself and',
51+
' point SOCKET_CLI_CDXGEN_LOCAL_PATH at the binary.',
52+
].join('\n')
53+
}
54+
55+
/**
56+
* Build the message shown when SOCKET_CLI_CDXGEN_LOCAL_PATH points at
57+
* something that is not there.
58+
*
59+
* Without this check the override is silently ignored and the failure looks
60+
* identical to a download failure, which makes the override appear to have no
61+
* effect at all.
62+
*/
63+
export function formatMissingCdxgenLocalPathMessage(localPath: string): string {
64+
return [
65+
'SOCKET_CLI_CDXGEN_LOCAL_PATH points at a file that does not exist.',
66+
` Where: ${localPath}`,
67+
' Saw: nothing at that path',
68+
' Fix: Correct the path, or unset SOCKET_CLI_CDXGEN_LOCAL_PATH to let',
69+
' Socket download cdxgen itself. On a CI runner, check that the',
70+
' path exists in the same step that runs socket cdxgen.',
71+
].join('\n')
72+
}
73+
74+
/**
75+
* True when a local cdxgen override is configured but missing from disk.
76+
*/
77+
export function isMissingCdxgenLocalPath(localPath: string): boolean {
78+
return !existsSync(localPath)
79+
}

packages/cli/src/util/dlx/spawn-cdxgen.mts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@
1313
import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect'
1414
import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'
1515

16+
import {
17+
formatMissingCdxgenLocalPathMessage,
18+
isMissingCdxgenLocalPath,
19+
} from './cdxgen-diagnostics.mts'
1620
import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts'
1721
import { spawnDlx } from './spawn.mts'
1822
import { resolveCdxgen } from './resolve-binary.mjs'
23+
import { InputError } from '../error/errors.mts'
1924
import { buildSystemToolEnv } from '../spawn/system-tool.mts'
2025
import { resolveNodeExecutable } from '../spawn/spawn-node.mts'
2126

@@ -37,6 +42,12 @@ export async function spawnCdxgenDlx(
3742

3843
// Use local cdxgen if available.
3944
if (resolution.type === 'local') {
45+
// Check the override before spawning. Otherwise a wrong path surfaces as a
46+
// bare ENOENT that never mentions the environment variable, so the
47+
// override looks like it was ignored.
48+
if (isMissingCdxgenLocalPath(resolution.path)) {
49+
throw new InputError(formatMissingCdxgenLocalPathMessage(resolution.path))
50+
}
4051
const detection = detectExecutableType(resolution.path)
4152
const { env: spawnEnv, ...dlxOptions } = {
4253
__proto__: null,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Unit tests for the cdxgen command's failure reporting.
3+
*
4+
* Purpose: The command arms process.exitCode = 1 before spawning cdxgen, and
5+
* cdxgen itself runs with stdio: 'inherit'. Together that means any path out
6+
* of the spawn that neither exits deliberately nor prints leaves the user with
7+
* exit code 1 and an empty log. These tests cover every one of those paths.
8+
*
9+
* Related Files: - src/commands/manifest/cmd-manifest-cdxgen.mts.
10+
*/
11+
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
import { cmdManifestCdxgen } from '../../../../src/commands/manifest/cmd-manifest-cdxgen.mts'
15+
16+
const mockLogger = vi.hoisted(() => ({
17+
error: vi.fn(),
18+
fail: vi.fn(),
19+
info: vi.fn(),
20+
log: vi.fn(),
21+
success: vi.fn(),
22+
warn: vi.fn(),
23+
}))
24+
25+
vi.mock(import('@socketsecurity/lib-stable/logger/default'), () => ({
26+
getDefaultLogger: () => mockLogger,
27+
}))
28+
29+
const mockRunCdxgen = vi.hoisted(() => vi.fn())
30+
const mockDetectNodejsCdxgenSources = vi.hoisted(() =>
31+
vi.fn().mockResolvedValue({ hasLockfile: true, hasNodeModules: true }),
32+
)
33+
const mockIsNodejsCdxgenType = vi.hoisted(() => vi.fn().mockReturnValue(true))
34+
35+
vi.mock(import('../../../../src/commands/manifest/run-cdxgen.mts'), () => ({
36+
detectNodejsCdxgenSources: mockDetectNodejsCdxgenSources,
37+
isNodejsCdxgenType: mockIsNodejsCdxgenType,
38+
runCdxgen: mockRunCdxgen,
39+
}))
40+
41+
describe('cmd-manifest-cdxgen failure reporting', () => {
42+
const importMeta = { url: 'file:///test/cmd-manifest-cdxgen.mts' }
43+
const context = { parentName: 'socket manifest' }
44+
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
mockDetectNodejsCdxgenSources.mockResolvedValue({
48+
hasLockfile: true,
49+
hasNodeModules: true,
50+
})
51+
mockIsNodejsCdxgenType.mockReturnValue(true)
52+
process.exitCode = undefined
53+
})
54+
55+
describe('never fails silently', () => {
56+
// The command arms process.exitCode = 1 before spawning cdxgen. Every
57+
// way out of the spawn must therefore either exit deliberately or print
58+
// something, otherwise the user gets exit code 1 and an empty log.
59+
it('reports an actionable error when cdxgen cannot be started', async () => {
60+
mockRunCdxgen.mockRejectedValue(new Error('spawn cdxgen ENOENT'))
61+
const mockExit = vi
62+
.spyOn(process, 'exit')
63+
.mockImplementation((() => {}) as unknown)
64+
65+
await cmdManifestCdxgen.run(['.'], importMeta, context)
66+
67+
expect(mockLogger.fail).toHaveBeenCalledTimes(1)
68+
const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '')
69+
expect(message.trim()).not.toBe('')
70+
expect(message).toContain('socket cdxgen could not run cdxgen.')
71+
expect(message).toContain('spawn cdxgen ENOENT')
72+
expect(process.exitCode).toBe(1)
73+
mockExit.mockRestore()
74+
})
75+
76+
it('reports an actionable error when the spawn itself rejects', async () => {
77+
mockRunCdxgen.mockResolvedValue({
78+
spawnPromise: Promise.reject(new Error('cdxgen download failed')),
79+
})
80+
const mockExit = vi
81+
.spyOn(process, 'exit')
82+
.mockImplementation((() => {}) as unknown)
83+
84+
await cmdManifestCdxgen.run(['.'], importMeta, context)
85+
86+
expect(mockLogger.fail).toHaveBeenCalledTimes(1)
87+
const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '')
88+
expect(message).toContain('cdxgen download failed')
89+
expect(process.exitCode).toBe(1)
90+
mockExit.mockRestore()
91+
})
92+
93+
it('reports an actionable error when cdxgen ends with no exit code and no signal', async () => {
94+
mockRunCdxgen.mockResolvedValue({
95+
spawnPromise: Promise.resolve({ code: undefined, signal: undefined }),
96+
})
97+
const mockExit = vi
98+
.spyOn(process, 'exit')
99+
.mockImplementation((() => {}) as unknown)
100+
101+
await cmdManifestCdxgen.run(['.'], importMeta, context)
102+
103+
expect(mockExit).not.toHaveBeenCalled()
104+
expect(mockLogger.fail).toHaveBeenCalledTimes(1)
105+
const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '')
106+
expect(message).toContain('without reporting an exit code or a signal')
107+
expect(process.exitCode).toBe(1)
108+
mockExit.mockRestore()
109+
})
110+
111+
it('stays quiet on the success path', async () => {
112+
mockRunCdxgen.mockResolvedValue({
113+
spawnPromise: Promise.resolve({ code: 0, signal: undefined }),
114+
})
115+
const mockExit = vi
116+
.spyOn(process, 'exit')
117+
.mockImplementation((() => {}) as unknown)
118+
119+
await cmdManifestCdxgen.run(['.'], importMeta, context)
120+
121+
expect(mockLogger.fail).not.toHaveBeenCalled()
122+
expect(mockExit).toHaveBeenCalledWith(0)
123+
mockExit.mockRestore()
124+
})
125+
})
126+
})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Unit tests for the cdxgen failure diagnostics.
3+
*
4+
* Purpose: cdxgen runs with stdio: 'inherit', so when it cannot start there is
5+
* no child output to explain the failure. These tests pin the property that
6+
* matters to a user staring at a CI log: the message is non-empty, names where
7+
* the CLI looked, and says what to do next.
8+
*
9+
* Related Files: - src/util/dlx/cdxgen-diagnostics.mts (implementation)
10+
*/
11+
12+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
13+
14+
import {
15+
describeCdxgenSource,
16+
formatCdxgenFailureMessage,
17+
formatMissingCdxgenLocalPathMessage,
18+
isMissingCdxgenLocalPath,
19+
} from '../../../../src/util/dlx/cdxgen-diagnostics.mts'
20+
21+
describe('formatCdxgenFailureMessage', () => {
22+
it('is never empty, even with no underlying error', () => {
23+
const message = formatCdxgenFailureMessage()
24+
25+
expect(message.trim()).not.toBe('')
26+
expect(message.length).toBeGreaterThan(40)
27+
})
28+
29+
it('explains the case where the process reported no exit code and no signal', () => {
30+
const message = formatCdxgenFailureMessage()
31+
32+
expect(message).toContain('without reporting an exit code or a signal')
33+
})
34+
35+
it('quotes the underlying error when there is one', () => {
36+
const message = formatCdxgenFailureMessage(new Error('spawn cdxgen ENOENT'))
37+
38+
expect(message).toContain('spawn cdxgen ENOENT')
39+
})
40+
41+
it('survives a thrown non-Error value', () => {
42+
const message = formatCdxgenFailureMessage('just a string')
43+
44+
expect(message.trim()).not.toBe('')
45+
expect(message).toContain('socket cdxgen could not run cdxgen.')
46+
})
47+
48+
it('names where cdxgen was looked for and how to get more detail', () => {
49+
const message = formatCdxgenFailureMessage(new Error('boom'))
50+
51+
expect(message).toContain('Where:')
52+
expect(message).toContain('Fix:')
53+
expect(message).toContain('SOCKET_CLI_DEBUG=1')
54+
expect(message).toContain('SOCKET_CLI_CDXGEN_LOCAL_PATH')
55+
})
56+
})
57+
58+
describe('describeCdxgenSource', () => {
59+
const originalPath = process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH']
60+
61+
afterEach(() => {
62+
if (originalPath === undefined) {
63+
delete process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH']
64+
} else {
65+
process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] = originalPath
66+
}
67+
})
68+
69+
it('names the dlx package when no override is configured', () => {
70+
delete process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH']
71+
72+
expect(describeCdxgenSource()).toContain('@cyclonedx/cdxgen')
73+
})
74+
})
75+
76+
describe('SOCKET_CLI_CDXGEN_LOCAL_PATH validation', () => {
77+
it('reports a path that is not on disk as missing', () => {
78+
expect(isMissingCdxgenLocalPath('/definitely/not/here/acme-cdxgen')).toBe(
79+
true,
80+
)
81+
})
82+
83+
it('does not report an existing file as missing', () => {
84+
expect(isMissingCdxgenLocalPath(process.execPath)).toBe(false)
85+
})
86+
87+
it('names the variable and the offending path in the message', () => {
88+
const message = formatMissingCdxgenLocalPathMessage(
89+
'/definitely/not/here/acme-cdxgen',
90+
)
91+
92+
expect(message).toContain('SOCKET_CLI_CDXGEN_LOCAL_PATH')
93+
expect(message).toContain('/definitely/not/here/acme-cdxgen')
94+
expect(message).toContain('Fix:')
95+
})
96+
})
97+
98+
describe('message shape', () => {
99+
let messages: string[] = []
100+
101+
beforeEach(() => {
102+
messages = [
103+
formatCdxgenFailureMessage(),
104+
formatCdxgenFailureMessage(new Error('boom')),
105+
formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen'),
106+
]
107+
})
108+
109+
it('emits no ANSI escape codes, so a CI log stays clean', () => {
110+
for (let i = 0, { length } = messages; i < length; i += 1) {
111+
// oxlint-disable-next-line no-control-regex -- matching escapes is the point.
112+
expect(messages[i]!).not.toMatch(/\[[0-9;]*m/)
113+
}
114+
})
115+
116+
it('leads with what went wrong before any detail', () => {
117+
for (let i = 0, { length } = messages; i < length; i += 1) {
118+
const firstLine = messages[i]!.split('\n')[0]!
119+
expect(firstLine).not.toMatch(/^\s/)
120+
expect(firstLine.length).toBeGreaterThan(20)
121+
}
122+
})
123+
})

0 commit comments

Comments
 (0)