Skip to content
Open
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
41 changes: 40 additions & 1 deletion src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 });
}
});
});
40 changes: 34 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old.json> <new.json> [--format text|json|markdown]';
const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown'];
Expand Down Expand Up @@ -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<SBOM> {
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<void> {
const { positional, format } = parseArgs(process.argv.slice(2));

Expand All @@ -68,13 +98,11 @@ async function main(): Promise<void> {

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));
Expand Down
Loading