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

it('detects version upgrades', () => {
it('detects version upgrades when matched by versioned purl', () => {
// Real SBOM generators embed the version in the purl. The upgrade must be
// matched on the version-independent coordinate, not surfaced as add/remove.
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 versioned purls with qualifiers and scoped namespaces', () => {
const a = makesbom([
{ name: '@angular/core', version: '15.0.0', purl: 'pkg:npm/%40angular/core@15.0.0' },
{ name: 'zlib', version: '1.2.13', purl: 'pkg:generic/zlib@1.2.13?download_url=https://example.com' },
]);
const b = makesbom([
{ name: '@angular/core', version: '16.0.0', purl: 'pkg:npm/%40angular/core@16.0.0' },
{ name: 'zlib', version: '1.3.0', purl: 'pkg:generic/zlib@1.3.0?download_url=https://example.com' },
]);
const report = diff(a, b);
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
expect(report.upgraded).toHaveLength(2);
expect(report.upgraded.find(u => u.component.name === '@angular/core')?.isMajorBump).toBe(true);
});

it('detects version upgrades when matched by name (no purl)', () => {
Expand Down
32 changes: 29 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,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;
}

/**
* A version-independent identity for a component.
*
* Real-world SBOMs (syft, cdxgen, trivy, …) embed the version in the purl
* (e.g. "pkg:npm/lodash@4.17.21"), so keying on the raw purl would give an
* upgraded package two different keys — surfacing it as a spurious
* remove + add pair instead of an upgrade. Strip the version so the same
* package matches across SBOMs and real upgrades are detected.
*/
function componentKey(comp: Component): string {
if (comp.purl) return stripPurlVersion(comp.purl);
return comp.name;
}

/**
* Remove the `@version` segment from a purl, leaving the coordinate.
*
* purl layout: `pkg:type/namespace/name@version?qualifiers#subpath`. The
* version is delimited by the last unescaped `@` (scoped npm namespaces
* encode their leading `@` as `%40`, so it never collides), bounded by any
* `?qualifiers` / `#subpath` suffix.
*/
function stripPurlVersion(purl: string): string {
const core = purl.split('?')[0].split('#')[0];
const at = core.lastIndexOf('@');
return at > 0 ? core.slice(0, at) : core;
}

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