Skip to content
Merged
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
36 changes: 36 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,40 @@ describe('diff ordering', () => {
expect(report.upgraded).toHaveLength(1);
expect(report.hashChanges).toHaveLength(0);
});

it('reports components that share a key instead of silently dropping them (issue #50)', () => {
// Two purl-less components with the same name coexist in the same SBOM
// (common in OS-package / container SBOMs). The old last-write-wins map
// silently discarded the first, hiding a real removal.
const a = makesbom([
{ name: 'kernel', version: '5.15.0' },
{ name: 'kernel', version: '5.19.0' },
]);
const b = makesbom([
{ name: 'kernel', version: '5.15.0' },
]);
const report = diff(a, b);
// Both entries survived: one matches (unchanged), the other is a removal.
expect(report.removed).toHaveLength(1);
expect(report.removed[0].version).toBe('5.19.0');
});

it('matches same-key components across SBOMs by occurrence order', () => {
const a = makesbom([
{ name: 'dup', version: '1.0.0' },
{ name: 'dup', version: '2.0.0' },
]);
const b = makesbom([
{ name: 'dup', version: '1.0.0' },
{ name: 'dup', version: '3.0.0' },
]);
const report = diff(a, b);
// First occurrence pairs 1.0.0<->1.0.0 (unchanged); second pairs
// 2.0.0<->3.0.0 (upgrade). No add/remove false positives.
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].from).toBe('2.0.0');
expect(report.upgraded[0].to).toBe('3.0.0');
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
});
});
20 changes: 19 additions & 1 deletion src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,28 @@ function toIdentity(sbom: SBOM): SBOMIdentity {
};
}

/**
* Build a component lookup map, disambiguating components that share a key.
*
* buildComponentMap previously used last-write-wins: when a single SBOM
* contained two components mapping to the same `purl ?? name` key, every entry
* but the last was silently discarded *before* the diff ran, so added/removed
* packages could vanish from the report entirely (issue #50).
*
* Instead, every component gets a unique key: the first occurrence keeps the
* bare key, subsequent collisions get a `#2`, `#3`, … suffix. All entries
* survive into the map and are compared. Ordering is deterministic (stable
* input order) so the same SBOM always yields the same keys.
*/
function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
const seen = new Map<string, number>();
for (const comp of components) {
map.set(componentKey(comp), comp);
const base = componentKey(comp);
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
const key = count === 0 ? base : `${base}#${count + 1}`;
map.set(key, comp);
}
return map;
}
Expand Down