From eb85ff014b5490eef52a15e6f10b6690438fe0ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 03:11:17 +0000 Subject: [PATCH] fix(cli): attach file path and role to SBOM load errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a file passed to the CLI is missing, unreadable, or not valid JSON, main() surfaced the raw low-level error (e.g. "Expected property name or '}' in JSON at position 2") with no indication of which of the two inputs failed or that the SBOM-load step is where it broke. For the tool's headline use case — CI/CD gates — a wrong or corrupt artifact is a common misconfiguration, and the bare message makes it needlessly hard to debug. Introduce loadSbom(path, label), which reads and parses a file and wraps any failure as "Failed to read/parse SBOM '': ", distinguishing read failures from parse failures. Valid runs are unaffected. Add tests covering the missing-file, malformed-JSON, and happy paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JS76safVDXSZwcLRAh8CPv --- src/__tests__/cli.test.ts | 41 ++++++++++++++++++++++++++++++++++++++- src/cli.ts | 37 ++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 2c44eea..0fddebf 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { parseArgs, gateFailures, gateWarning } from '../cli.js'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs, loadSbom, gateFailures, gateWarning } from '../cli.js'; import type { ChangeReport, CVEEntry, SBOM } from '../types.js'; describe('parseArgs', () => { @@ -161,3 +164,39 @@ describe('gateWarning', () => { ); }); }); + +describe('loadSbom', () => { + it('wraps a missing file with its path and role', async () => { + await expect(loadSbom('/no/such/sbom-diff-missing.json', 'old')).rejects.toThrowError( + "Failed to read old SBOM '/no/such/sbom-diff-missing.json'", + ); + }); + + it('wraps malformed JSON with its path and role', async () => { + const dir = await mkdtemp(join(tmpdir(), 'sbom-diff-')); + const path = join(dir, 'bad.json'); + await writeFile(path, '{ not valid json'); + try { + await expect(loadSbom(path, 'new')).rejects.toThrowError( + `Failed to parse new SBOM '${path}'`, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('parses a valid SBOM file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'sbom-diff-')); + const path = join(dir, 'good.json'); + await writeFile( + path, + JSON.stringify({ bomFormat: 'CycloneDX', specVersion: '1.5', components: [] }), + ); + try { + const sbom = await loadSbom(path, 'old'); + expect(sbom.format).toBe('cyclonedx'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 8f1f27a..7353a89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -138,6 +138,35 @@ export function gateWarning(oldSBOM: SBOM, newSBOM: SBOM, failOn: FailOn): strin 'attach a scan/VEX step that emits a CycloneDX 1.4+ "vulnerabilities" list to enable CVE gating.' ); } +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Read and parse an SBOM file, attaching the file path and role to any failure. + * + * The underlying `readFile`/`JSON.parse` errors (e.g. `ENOENT` or + * `Expected property name or '}' in JSON at position 2`) don't say which of the + * two inputs failed or that the SBOM-load step is where it broke. In a CI gate a + * wrong or corrupt artifact is a common misconfiguration, so surface the path and + * whether the read or the parse failed instead of a bare low-level message. + * + * @param label human-readable role of the file, e.g. `old` or `new`. + * @throws if the file cannot be read or is not valid JSON / SBOM. + */ +export async function loadSbom(path: string, label: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err) { + throw new Error(`Failed to read ${label} SBOM '${path}': ${errorMessage(err)}`); + } + try { + return parse(raw); + } catch (err) { + throw new Error(`Failed to parse ${label} SBOM '${path}': ${errorMessage(err)}`); + } +} async function main(): Promise { const { positional, format, failOn } = parseArgs(process.argv.slice(2)); @@ -149,13 +178,11 @@ async function main(): Promise { const [oldPath, newPath] = positional; - const [oldRaw, newRaw] = await Promise.all([ - readFile(oldPath, 'utf-8'), - readFile(newPath, 'utf-8'), + const [oldSBOM, newSBOM] = await Promise.all([ + loadSbom(oldPath, 'old'), + loadSbom(newPath, 'new'), ]); - const oldSBOM = parse(oldRaw); - const newSBOM = parse(newRaw); const report = diff(oldSBOM, newSBOM); console.log(renderReport(report, format));