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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Identity authority remains:
2. `bom_ref`, when no purl is present;
3. normalized `(ecosystem, package_name)` coordinate.

When no purl is present, `bom_ref` is an opaque local identifier: trim
surrounding whitespace only, preserve case, and do not infer aliases. A record
with a purl uses the purl identity even when `bom_ref` is also populated; a
bom_ref-only record never aliases a purl string.

When a purl is present, its type, package name, and version must agree with
explicit component fields. Disagreement fails closed as `conflicting_metadata`.
Within a single input, repeated identical canonical identities fail as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ Identity authority remains `purl`, then `bom_ref`, then the normalized
its ecosystem and package coordinate. Explicit metadata that disagrees with
that coordinate is a conflict, not an alternative identity.

When no purl is present, `bom_ref` is an opaque local identifier: only
surrounding whitespace is trimmed, case is preserved, and aliases are not
inferred. A purl-bearing record keeps purl authority even when `bom_ref` is
also populated.

Canonical identity also drives change comparison: lexical PyPI variants that
normalize to the same identity do not create a metadata change, and a version
change carried only by the purl is still classified as `version_changed`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def canonicalize_component_identity(component: Component) -> CanonicalComponentI

if component.purl is None:
if component.bom_ref:
component_key = f"bom-ref:{component.bom_ref.strip().lower()}"
component_key = f"bom-ref:{component.bom_ref.strip()}"
else:
component_key = f"coord:{explicit_ecosystem}:{explicit_name}"
return CanonicalComponentIdentity(
Expand Down
2 changes: 1 addition & 1 deletion tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _component_signature(component: Component) -> tuple[object, ...]:
_normalized_metadata(component.license_id),
_normalized_metadata(component.supplier),
_normalized_metadata(component.source_url),
_normalized_metadata(component.bom_ref, lower=True),
_normalized_metadata(component.bom_ref),
_normalized_metadata(component.raw_type, lower=True),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"components": [
{
"bom-ref": "Component-A",
"type": "library",
"name": "opaque-lib",
"version": "1.0.0"
},
{
"bom-ref": "component-a",
"type": "library",
"name": "opaque-lib",
"version": "1.0.0"
}
]
}
71 changes: 71 additions & 0 deletions tools/sbom-diff-and-risk/tests/test_diffing.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,77 @@ def test_component_key_prefers_purl() -> None:
assert component_key(component) == "purl:pkg:pypi/requests"


def test_component_key_does_not_alias_bom_ref_only_identity_to_purl() -> None:
purl_component = Component(
name="requests",
version="2.31.0",
ecosystem="pypi",
purl="pkg:pypi/requests@2.31.0",
bom_ref="legacy-ref",
)
bom_ref_only_component = Component(
name="requests",
version="2.31.0",
ecosystem="pypi",
bom_ref="legacy-ref",
)

assert component_key(purl_component) == "purl:pkg:pypi/requests"
assert component_key(bom_ref_only_component) == "bom-ref:legacy-ref"


def test_diff_components_preserves_case_in_opaque_bom_ref_fixture() -> None:
fixture = Path(__file__).parent / "fixtures" / "cdx_bom_ref_case_variants.json"
_, components, _ = normalize_input(fixture)

added, removed, changed = diff_components([], components)

assert {component_key(component) for component in added} == {
"bom-ref:Component-A",
"bom-ref:component-a",
}
assert removed == []
assert changed == []


def test_diff_components_fails_on_duplicate_exact_bom_ref() -> None:
duplicate_before = [
Component(name="opaque-lib", version="1.0.0", ecosystem="generic", bom_ref="Component-A"),
Component(name="opaque-lib", version="1.0.0", ecosystem="generic", bom_ref="Component-A"),
]

with pytest.raises(ComponentIdentityError, match="duplicate_component") as exc_info:
diff_components(duplicate_before, [])

assert exc_info.value.code is ComponentIdentityDiagnosticCode.DUPLICATE_COMPONENT
assert exc_info.value.component_key == "bom-ref:Component-A"


def test_diff_components_fails_on_same_bom_ref_with_conflicting_metadata() -> None:
conflicting_before = [
Component(
name="opaque-lib",
version="1.0.0",
ecosystem="generic",
bom_ref="Component-A",
supplier="Supplier A",
),
Component(
name="opaque-lib",
version="1.0.0",
ecosystem="generic",
bom_ref="Component-A",
supplier="Supplier B",
),
]

with pytest.raises(ComponentIdentityError, match="conflicting_metadata") as exc_info:
diff_components(conflicting_before, [])

assert exc_info.value.code is ComponentIdentityDiagnosticCode.CONFLICTING_METADATA
assert exc_info.value.component_key == "bom-ref:Component-A"


def test_diff_components_empty_inputs() -> None:
added, removed, changed = diff_components([], [])
assert added == []
Expand Down