From f237708c5cb3b689ce7108b66800b99ec94d889d 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 | 40 ++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 3532b91..9166883 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 } from '../cli.js'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs, loadSbom } from '../cli.js'; describe('parseArgs', () => { it('defaults to text format when no flag is given', () => { @@ -38,3 +41,39 @@ describe('parseArgs', () => { expect(() => parseArgs(['old.json', 'new.json', '--bogus'])).toThrow(/Unknown option/); }); }); + +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 969f741..3b8bf36 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; -import type { ReportFormat } from './types.js'; +import type { ReportFormat, SBOM } from './types.js'; const USAGE = 'Usage: sbom-diff [--format text|json|markdown]'; const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown']; @@ -58,6 +58,36 @@ function assertFormat(value: string | undefined): ReportFormat { ); } +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 } = parseArgs(process.argv.slice(2)); @@ -68,13 +98,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));