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
31 changes: 26 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,35 @@ describe('diff', () => {
expect(report.removed[0].name).toBe('moment');
});

it('detects version upgrades', () => {
it('detects version upgrades when purls embed the version', () => {
// Real CycloneDX/SPDX SBOMs put the version in the purl, so the identity
// key must ignore it — otherwise a bump reads as add + remove, not upgrade.
const a = makesbom([{ name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20' }]);
const b = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }]);
const report = diff(a, b);
// Different purl = treated as add/remove (purl includes version)
// With our current purl-based key: 4.17.20 -> removed, 4.17.21 -> added
// This is correct behavior — different purls are different packages
expect(report.added.length + report.removed.length + report.upgraded.length).toBeGreaterThan(0);
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].from).toBe('4.17.20');
expect(report.upgraded[0].to).toBe('4.17.21');
});

it('matches scoped-package purls across an upgrade', () => {
// npm scopes encode "@" as "%40"; only the literal "@" delimits the version.
const a = makesbom([{ name: '@angular/core', version: '15.0.0', purl: 'pkg:npm/%40angular/core@15.0.0' }]);
const b = makesbom([{ name: '@angular/core', version: '16.1.0', purl: 'pkg:npm/%40angular/core@16.1.0' }]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].isMajorBump).toBe(true);
});

it('does not confuse different packages that share a version bump', () => {
const a = makesbom([{ name: 'left-pad', version: '1.0.0', purl: 'pkg:npm/left-pad@1.0.0' }]);
const b = makesbom([{ name: 'right-pad', version: '1.0.0', purl: 'pkg:npm/right-pad@1.0.0' }]);
const report = diff(a, b);
expect(report.added).toHaveLength(1);
expect(report.removed).toHaveLength(1);
expect(report.upgraded).toHaveLength(0);
});

it('detects version upgrades when matched by name (no purl)', () => {
Expand Down
39 changes: 33 additions & 6 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange } from './t
/**
* Compare two parsed SBOMs and produce a ChangeReport.
*
* Matching strategy:
* 1. By purl (most precise)
* 2. By name (fallback)
* Matching strategy — a component's *identity* is version-independent so that a
* version bump reads as an upgrade rather than an add + remove:
* 1. By purl with the version stripped (e.g. "pkg:npm/lodash"), most precise
* 2. By name (fallback when no purl is present)
*/
export function diff(a: SBOM, b: SBOM): ChangeReport {
const aMap = buildComponentMap(a.components);
Expand Down Expand Up @@ -63,13 +64,39 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
for (const comp of components) {
// Prefer purl as key, fall back to name
const key = comp.purl ?? comp.name;
map.set(key, comp);
map.set(componentKey(comp), comp);
}
return map;
}

/**
* Version-independent identity for a component.
*
* A purl normally embeds the version (e.g. "pkg:npm/lodash@4.17.21"), so keying
* on the raw purl would make every version change look like a separate package
* being added and removed — the "upgraded" bucket would stay empty for any real
* SBOM. Strip the version so the same package across two SBOMs shares one key.
*/
function componentKey(comp: Component): string {
if (comp.purl) return stripPurlVersion(comp.purl);
return `name:${comp.name}`;
}

/**
* Remove the "@version" segment from a purl, preserving the type/namespace/name.
*
* purl syntax: "pkg:type/namespace/name@version?qualifiers#subpath". The version
* is delimited by a literal "@"; an "@" inside a name (e.g. an npm scope) is
* percent-encoded as "%40", so a literal "@" unambiguously begins the version.
* Qualifiers ("?") and subpath ("#") are dropped first so an "@" there can't be
* mistaken for the version delimiter.
*/
function stripPurlVersion(purl: string): string {
const base = purl.split('#')[0].split('?')[0];
const at = base.indexOf('@', base.indexOf('/'));
return at === -1 ? base : base.slice(0, at);
}

/**
* Returns true if the major version changed (semver-style).
* Handles versions like "1.2.3", "2.0.0-beta", etc.
Expand Down
Loading