diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py
index 1ac2600..3d57cf1 100644
--- a/src/sgraph/converters/sbom_cyclonedx_generator.py
+++ b/src/sgraph/converters/sbom_cyclonedx_generator.py
@@ -27,8 +27,26 @@ def slugify_bom_ref(name: str) -> str:
def valid_for_bom(elem):
+ # The coordinate branch demands an incoming reference where the version branches do not:
+ # version-management redirection leaves versionless elements behind after re-pointing their
+ # references at versioned ones, and coordinates alone cannot tell those husks apart from a
+ # BOM-managed dependency something still uses.
+ #
+ # parent_version alone deliberately does NOT qualify. Stored models predating
+ # coordinate-carrying parents hold parent_version-only elements, and admitting them splices
+ # the space-bearing element name into a generic purl. On models that do carry coordinates,
+ # the coordinate branch already admits every parent, so a parent_version clause would add
+ # nothing there and regress everything before.
+ #
+ # The coordinates must also be usable, not merely present: charset-rejected ones (a ${}
+ # groupId resolved only in an external parent) build no maven purl, and versionless there
+ # is nothing spec-clean left to emit — the fallback would splice the space-bearing element
+ # name into a generic purl.
return 'version' in elem.attrs or ' of version ' in elem.name or ' of tag ' in elem.name \
- or 'license' in elem.attrs
+ or 'license' in elem.attrs \
+ or (is_maven_coordinate(elem.attrs.get('groupId', ''))
+ and is_maven_coordinate(elem.attrs.get('artifactId', ''))
+ and bool(elem.incoming))
def extract_version(elem):
@@ -40,6 +58,8 @@ def extract_version(elem):
return elem.name.split(' of version ')[-1].strip()
if ' of tag ' in elem.name:
return elem.name.split(' of tag ')[-1].strip()
+ if 'parent_version' in elem.attrs:
+ return elem.attrs['parent_version']
def incoming_deps(elem, elem_name_patterns, deptypes):
@@ -164,13 +184,17 @@ def maven_purl(elem, version):
An unresolved version yields a versionless purl rather than one carrying the expression. A
purl version must be percent-encoded, so the expression would be either non-canonical raw or
canonical-but-unmatchable encoded; omitting it yields a purl that is canonical and still
- matches at package level.
+ matches at package level. An empty version takes the same branch: appending it would leave a
+ trailing '@', which is not a canonical versionless purl but a malformed versioned one. So
+ does a semicolon-joined multi-value, the shape attribute transfer produces when two poms
+ name one parent at different versions: it asserts a version that exists nowhere, while the
+ raw value stays disclosed in the component's version field.
"""
group_id = elem.attrs.get('groupId', '')
artifact_id = elem.attrs.get('artifactId', '')
if not (is_maven_coordinate(group_id) and is_maven_coordinate(artifact_id)):
return None
- if UNRESOLVED_VERSION.fullmatch(version):
+ if not version or ';' in version or UNRESOLVED_VERSION.fullmatch(version):
return f'pkg:maven/{group_id}/{artifact_id}'
return f'pkg:maven/{group_id}/{artifact_id}@{version}'
@@ -228,6 +252,10 @@ def purl_for(elem, v):
properties.append({'name': PURL_TYPE_SOURCE_PROPERTY, 'value': 'ecosystem unresolved'})
v = v.lstrip('^')
+ # The versionless-inclusion rule made an empty version reachable here, not only in
+ # maven_purl: charset-rejected coordinates drop a versionless element to this splice.
+ if not v:
+ return f'pkg:{pkgtype}/{pkgid}', properties
return f'pkg:{pkgtype}/{pkgid}@{v}', properties
@@ -641,7 +669,86 @@ def _collect_3rdparty_for_subtree(subtree_root, external_root, other_externals_b
return components
-def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3) -> list[dict]:
+def _transitive_components_and_dependencies(root_path, gen_elem_by_path, orig_elem_by_path,
+ elem_serials, elem_bom_refs, orig_external_root,
+ other_externals_by_name):
+ """Inline everything reachable from root_path into one self-contained BOM.
+
+ Dependency-Track resolves dependency refs only within a single uploaded BOM: the BOM-Link
+ URNs of the default mode are never followed into other projects, so the exposure chain
+ root -> internal element -> vulnerable 3rd-party component stays invisible there. Inlining
+ the reachable internal elements as components, together with the 3rd-party components of
+ the whole chain, makes the chain resolvable inside one project. Each inlined internal
+ component still points to its own standalone SBOM via an externalReference of type 'bom'.
+
+ :return: (components, dependencies) for the SBOM of the element at root_path
+ """
+ # Breadth-first walk over the generalized cross-element graph
+ order = [root_path]
+ direct_internal = {}
+ queue = [root_path]
+ while queue:
+ path = queue.pop(0)
+ targets = []
+ gen_elem = gen_elem_by_path.get(path)
+ if gen_elem is not None:
+ for assoc in gen_elem.outgoing:
+ target_path = assoc.toElement.getPath()
+ if target_path in elem_bom_refs and target_path != path \
+ and target_path not in targets:
+ targets.append(target_path)
+ direct_internal[path] = targets
+ for target_path in targets:
+ if target_path not in order:
+ order.append(target_path)
+ queue.append(target_path)
+
+ # 3rd-party components of every element in the chain, deduplicated; the ones pulled in
+ # through an internal element are annotated with the element that routed them here.
+ components = []
+ seen_refs = set()
+ external_refs_of = {}
+ for path in order:
+ elem = orig_elem_by_path[path]
+ ext_components = _collect_3rdparty_for_subtree(elem, orig_external_root,
+ other_externals_by_name)
+ external_refs_of[path] = [c['bom-ref'] for c in ext_components]
+ for component in ext_components:
+ if component['bom-ref'] in seen_refs:
+ continue
+ seen_refs.add(component['bom-ref'])
+ if path != root_path:
+ component.setdefault('properties', []).append({
+ 'name': 'softagram:via',
+ 'value': elem.name
+ })
+ components.append(component)
+
+ # Reachable internal elements become components of this BOM
+ for path in order[1:]:
+ serial_uuid = elem_serials[path].replace('urn:uuid:', '')
+ components.append({
+ 'bom-ref': elem_bom_refs[path],
+ 'type': 'library',
+ 'name': orig_elem_by_path[path].name,
+ 'version': '',
+ 'purl': '',
+ 'properties': [{'name': 'softagram:internal', 'value': 'true'}],
+ 'externalReferences': [{'url': f'urn:cdx:{serial_uuid}/1', 'type': 'bom'}],
+ })
+
+ # Multi-entry dependency graph: every ref resolves within this BOM
+ dependencies = []
+ for path in order:
+ depends_on = list(external_refs_of[path])
+ depends_on += [elem_bom_refs[target] for target in direct_internal[path]]
+ dependencies.append({'ref': elem_bom_refs[path], 'dependsOn': depends_on})
+
+ return components, dependencies
+
+
+def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3,
+ transitive: bool = False) -> list[dict]:
"""Generate one CycloneDX 1.7 SBOM per element at the given level.
Uses the ORIGINAL model for 3rd-party component collection (preserves version info),
@@ -649,6 +756,9 @@ def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3) -> list[dict]:
:param sgraph: The loaded SGraph model
:param level: Tree depth at which to split into separate SBOMs
+ :param transitive: Inline the transitive closure of internal dependencies into each SBOM
+ so consumers that cannot follow BOM-Links across uploads (e.g. Dependency-Track) see
+ the full exposure chain within one BOM
:return: List of CycloneDX SBOM dicts
"""
from sgraph.algorithms.generalizer import generalize_model
@@ -705,10 +815,12 @@ def collect_gen(elem, current_level):
# Build path -> serial/ref mappings for all content elements (using original paths)
elem_serials = {}
elem_bom_refs = {}
+ orig_elem_by_path = {}
for elem in orig_content_elements:
path = elem.getPath()
elem_serials[path] = deterministic_serial(path)
elem_bom_refs[path] = slugify_bom_ref(elem.name)
+ orig_elem_by_path[path] = elem
# Build generalized element lookup by path for cross-repo deps
gen_elem_by_path = {elem.getPath(): elem for elem in gen_content_elements}
@@ -730,31 +842,37 @@ def collect_gen(elem, current_level):
'externalReferences': []
}
- # 3rd party components from original model (preserves version info)
- sbom.components = _collect_3rdparty_for_subtree(
- orig_elem, orig_external_root, other_externals_by_name
- )
-
- # Dependencies section
- depends_on = []
-
- # 3rd party purl refs
- for component in sbom.components:
- depends_on.append(component['bom-ref'])
-
- # Internal cross-repo dependencies from generalized model (BOM-Link URNs)
- gen_elem = gen_elem_by_path.get(path)
- if gen_elem is not None:
- for assoc in gen_elem.outgoing:
- target_path = assoc.toElement.getPath()
- if target_path in elem_serials and target_path != path:
- target_serial_uuid = elem_serials[target_path].replace('urn:uuid:', '')
- target_ref = elem_bom_refs[target_path]
- bom_link = f"urn:cdx:{target_serial_uuid}/1#{target_ref}"
- if bom_link not in depends_on:
- depends_on.append(bom_link)
-
- dependencies = [{'ref': ref, 'dependsOn': depends_on}]
+ if transitive:
+ sbom.components, dependencies = _transitive_components_and_dependencies(
+ path, gen_elem_by_path, orig_elem_by_path, elem_serials, elem_bom_refs,
+ orig_external_root, other_externals_by_name
+ )
+ else:
+ # 3rd party components from original model (preserves version info)
+ sbom.components = _collect_3rdparty_for_subtree(
+ orig_elem, orig_external_root, other_externals_by_name
+ )
+
+ # Dependencies section
+ depends_on = []
+
+ # 3rd party purl refs
+ for component in sbom.components:
+ depends_on.append(component['bom-ref'])
+
+ # Internal cross-repo dependencies from generalized model (BOM-Link URNs)
+ gen_elem = gen_elem_by_path.get(path)
+ if gen_elem is not None:
+ for assoc in gen_elem.outgoing:
+ target_path = assoc.toElement.getPath()
+ if target_path in elem_serials and target_path != path:
+ target_serial_uuid = elem_serials[target_path].replace('urn:uuid:', '')
+ target_ref = elem_bom_refs[target_path]
+ bom_link = f"urn:cdx:{target_serial_uuid}/1#{target_ref}"
+ if bom_link not in depends_on:
+ depends_on.append(bom_link)
+
+ dependencies = [{'ref': ref, 'dependsOn': depends_on}]
# Serialize
data = sbom.as_cyclonedx_json()
@@ -774,12 +892,20 @@ def collect_gen(elem, current_level):
parser.add_argument('--level', type=int, default=None,
help='Generate one SBOM per element at this tree depth. '
'Without this flag, generates a single SBOM (legacy behavior).')
+ parser.add_argument('--transitive', action='store_true',
+ help='Inline the transitive closure of internal dependencies into '
+ 'each SBOM so the full exposure chain resolves within one BOM '
+ '(for consumers like Dependency-Track that do not follow '
+ 'BOM-Links across uploads). Requires --level.')
args = parser.parse_args()
+ if args.transitive and args.level is None:
+ parser.error('--transitive requires --level')
+
g = SGraph.parse_xml_or_zipped_xml(args.model)
if args.level is not None:
- result = generate_multi_from_sgraph(g, level=args.level)
+ result = generate_multi_from_sgraph(g, level=args.level, transitive=args.transitive)
else:
result = generate_from_sgraph(g)
diff --git a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml
index a17424d..ea3abda 100644
--- a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml
+++ b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml
@@ -11,6 +11,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/converters/sbom_cyclonedx_generator_test.py b/tests/converters/sbom_cyclonedx_generator_test.py
index cbf112f..bbf5a5d 100644
--- a/tests/converters/sbom_cyclonedx_generator_test.py
+++ b/tests/converters/sbom_cyclonedx_generator_test.py
@@ -1,6 +1,6 @@
import re
-from sgraph import SElement, SGraph
+from sgraph import SElement, SElementAssociation, SGraph
from sgraph.converters import sbom_cyclonedx_generator
from sgraph.converters.sbom_cyclonedx_generator import (
deterministic_serial, slugify_bom_ref, generate_multi_from_sgraph,
@@ -111,6 +111,178 @@ def test_multi_sbom_internal_dependencies():
assert any(repo_b_serial in link for link in bom_link_deps)
+# --- Transitive multi-SBOM tests ---
+#
+# Dependency-Track resolves dependency graphs strictly within one uploaded BOM: BOM-Link URNs
+# in dependsOn are never followed into other projects, so the exposure chain
+# repoA -> repoB -> vulnerable-component is invisible when each repo uploads its own SBOM.
+# Transitive mode inlines reachable internal elements and their 3rd-party components into each
+# SBOM so the whole chain resolves inside a single BOM.
+
+MULTI_MODEL = 'converters/modelfile_for_sbom_multi_tests.xml'
+
+
+def find_property(component, name):
+ """Return the value of a named CycloneDX property of a component, or None when absent."""
+ for prop in component.get('properties', []):
+ if prop['name'] == name:
+ return prop['value']
+ return None
+
+
+def sbom_of(result, name):
+ return next(s for s in result if s['metadata']['component']['name'] == name)
+
+
+def test_transitive_mode_includes_internal_elements_as_components():
+ """repoA's SBOM carries repoB inline as an internal library component."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3, transitive=True)
+
+ repo_a_sbom = sbom_of(result, 'repoA')
+ internal = [c for c in repo_a_sbom['components']
+ if find_property(c, 'softagram:internal') == 'true']
+ assert [c['name'] for c in internal] == ['repoB']
+ assert internal[0]['type'] == 'library'
+ assert internal[0]['bom-ref'] == slugify_bom_ref('repoB')
+
+
+def test_transitive_internal_component_links_to_its_standalone_sbom():
+ """The inlined internal component carries a BOM-Link to repoB's own SBOM serial."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3, transitive=True)
+
+ repo_a_sbom = sbom_of(result, 'repoA')
+ repo_b_component = next(c for c in repo_a_sbom['components'] if c['name'] == 'repoB')
+ bom_refs = [r for r in repo_b_component['externalReferences'] if r['type'] == 'bom']
+
+ repo_b_serial = sbom_of(result, 'repoB')['serialNumber'].replace('urn:uuid:', '')
+ assert [r['url'] for r in bom_refs] == [f'urn:cdx:{repo_b_serial}/1']
+
+
+def test_transitive_mode_pulls_indirect_externals_with_provenance():
+ """repoB's 3rd-party dependency lands in repoA's SBOM, annotated with its route."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3, transitive=True)
+
+ repo_a_sbom = sbom_of(result, 'repoA')
+ components_by_name = {c['name']: c for c in repo_a_sbom['components']}
+
+ commons_lang = next(c for name, c in components_by_name.items() if 'commons-lang3' in name)
+ assert find_property(commons_lang, 'softagram:via') == 'repoB'
+
+ # repoA's own direct dependency is not annotated as indirect
+ assert find_property(components_by_name['Newtonsoft.Json'], 'softagram:via') is None
+
+
+def test_transitive_mode_emits_the_exposure_chain_in_dependencies():
+ """dependencies expresses repoA -> repoB -> commons-lang3 as a multi-entry graph."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3, transitive=True)
+
+ repo_a_sbom = sbom_of(result, 'repoA')
+ entries = {d['ref']: d['dependsOn'] for d in repo_a_sbom['dependencies']}
+
+ root_ref = repo_a_sbom['metadata']['component']['bom-ref']
+ repo_b_ref = slugify_bom_ref('repoB')
+
+ assert repo_b_ref in entries[root_ref]
+ assert any('Newtonsoft.Json' in ref for ref in entries[root_ref])
+ assert any('commons-lang3' in ref for ref in entries[repo_b_ref])
+
+ # No BOM-Link URNs in dependsOn: Dependency-Track drops them as dangling refs
+ for depends_on in entries.values():
+ assert not any(ref.startswith('urn:cdx:') for ref in depends_on)
+
+
+def test_transitive_mode_dependson_refs_all_resolve_within_the_bom():
+ """Every ref and dependsOn entry points at a component of the same BOM (DT-import safety)."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3, transitive=True)
+
+ for sbom in result:
+ known_refs = {c['bom-ref'] for c in sbom['components']}
+ known_refs.add(sbom['metadata']['component']['bom-ref'])
+ for entry in sbom['dependencies']:
+ assert entry['ref'] in known_refs
+ for ref in entry['dependsOn']:
+ assert ref in known_refs
+
+
+def test_default_mode_is_unchanged_by_the_transitive_feature():
+ """Without transitive=True there are no inlined internal components and BOM-Links remain."""
+ model, _ = get_model_and_model_api(MULTI_MODEL)
+ result = generate_multi_from_sgraph(model, level=3)
+
+ repo_a_sbom = sbom_of(result, 'repoA')
+ assert all(find_property(c, 'softagram:internal') is None
+ for c in repo_a_sbom['components'])
+ assert len(repo_a_sbom['dependencies']) == 1
+ assert any(ref.startswith('urn:cdx:')
+ for ref in repo_a_sbom['dependencies'][0]['dependsOn'])
+
+
+def test_transitive_mode_terminates_on_dependency_cycles():
+ """Mutually dependent repos inline each other once and both externals appear in both SBOMs."""
+ model = SGraph(SElement(None, ''))
+ a_file = model.createOrGetElementFromPath('/Org/repoA/src/a.cs')
+ b_file = model.createOrGetElementFromPath('/Org/repoB/src/b.cs')
+ ext_a = model.createOrGetElementFromPath('/Org/External/NuGet/LibA')
+ ext_a.attrs['version'] = '1.0.0'
+ ext_b = model.createOrGetElementFromPath('/Org/External/NuGet/LibB')
+ ext_b.attrs['version'] = '2.0.0'
+ SElementAssociation(a_file, b_file, 'use').initElems()
+ SElementAssociation(b_file, a_file, 'use').initElems()
+ SElementAssociation(a_file, ext_a, 'use').initElems()
+ SElementAssociation(b_file, ext_b, 'use').initElems()
+
+ result = generate_multi_from_sgraph(model, level=2, transitive=True)
+ assert len(result) == 2
+
+ for name, other in (('repoA', 'repoB'), ('repoB', 'repoA')):
+ sbom = sbom_of(result, name)
+ component_names = [c['name'] for c in sbom['components']]
+ assert component_names.count(other) == 1
+ assert 'LibA' in component_names
+ assert 'LibB' in component_names
+
+
+def test_cli_supports_the_transitive_flag(tmp_path):
+ """python -m ...sbom_cyclonedx_generator model out.json --level 3 --transitive works."""
+ import json
+ import subprocess
+ import sys
+ import os
+
+ model_path = os.path.join(os.path.dirname(__file__), 'modelfile_for_sbom_multi_tests.xml')
+ out_path = tmp_path / 'sboms.json'
+ proc = subprocess.run(
+ [sys.executable, '-m', 'sgraph.converters.sbom_cyclonedx_generator',
+ model_path, str(out_path), '--level', '3', '--transitive'],
+ capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+
+ result = json.loads(out_path.read_text())
+ repo_a_sbom = sbom_of(result, 'repoA')
+ assert any(find_property(c, 'softagram:internal') == 'true'
+ for c in repo_a_sbom['components'])
+
+
+def test_cli_rejects_transitive_without_level(tmp_path):
+ """--transitive is only meaningful with --level; without it the CLI refuses."""
+ import subprocess
+ import sys
+ import os
+
+ model_path = os.path.join(os.path.dirname(__file__), 'modelfile_for_sbom_multi_tests.xml')
+ proc = subprocess.run(
+ [sys.executable, '-m', 'sgraph.converters.sbom_cyclonedx_generator',
+ model_path, str(tmp_path / 'out.json'), '--transitive'],
+ capture_output=True, text=True)
+ assert proc.returncode != 0
+ assert '--transitive requires --level' in proc.stderr
+
+
# --- purl type inference tests ---
BINARY_REFS_MODEL = 'converters/modelfile_for_sbom_binary_refs_tests.xml'
@@ -452,16 +624,142 @@ def test_maven_coordinate_guard_does_more_than_reject_whitespace():
assert len(whitespace_bearing) == 3
-def test_maven_coordinates_fixture_yields_five_distinct_components():
+def test_maven_coordinates_fixture_yields_eight_distinct_components():
"""Anti-vacuity for the fixture, and the collapse guard for version omission.
A lookup above raises when an element vanishes, but an element added, or two bom-refs
- collapsed into one when a version is omitted, would otherwise go unnoticed.
+ collapsed into one when a version is omitted, would otherwise go unnoticed. Eight, not
+ ten: husk-lib and legacy-parent exist in the fixture and must not be counted here.
"""
model, _ = get_model_and_model_api(MAVEN_COORDINATES_MODEL)
sbom = sbom_cyclonedx_generator.generate_from_sgraph(model)
- assert len(sbom['components']) == 5
- assert len({component['bom-ref'] for component in sbom['components']}) == 5
+ assert len(sbom['components']) == 8
+ assert len({component['bom-ref'] for component in sbom['components']}) == 8
+
+
+# --- version-managed dependency tests ---
+
+
+def test_a_referenced_versionless_dependency_is_still_a_component():
+ """A dependency version-managed by an imported BOM has no version anywhere in the model.
+
+ The version is real but lives inside an artifact the analyzer never parses, so requiring a
+ version for inclusion drops exactly the dependencies modern Maven declares: the more a
+ project centralizes versions in parents and BOMs, the emptier its SBOM. A versionless maven
+ purl is canonical and still matches at package level — the same trade the
+ unresolved-expression case above already accepted.
+ """
+ component = get_maven_coordinate_components()['org.example.managed managed-lib']
+ assert component['purl'] == 'pkg:maven/org.example.managed/managed-lib'
+ assert component['version'] == ''
+ assert purl_type_resolution(component) is None
+
+
+def test_an_unreferenced_versionless_element_is_not_swept_in():
+ """The inclusion rule for versionless elements is incoming references, not existence.
+
+ Version-management redirection re-points references at versioned elements and leaves the
+ versionless originals behind with none. husk-lib is the control for managed-lib: identical
+ shape, no incoming reference. Without it the rule could decay into plain coordinate
+ presence and every other assertion would stay green.
+ """
+ assert 'org.example.husk husk-lib' not in get_maven_coordinate_components()
+
+
+def test_parent_version_supplies_the_version_of_an_external_parent_pom():
+ """A parent pom reference records its exact version under parent_version, not version.
+
+ The analyzer read that version out of the block it parsed, so dropping the
+ component, or emitting it versionless, discards information the model already holds.
+ """
+ component = get_maven_coordinate_components()['org.example.parentpom parent-pom']
+ assert component['purl'] == 'pkg:maven/org.example.parentpom/parent-pom@7.1'
+ assert component['version'] == '7.1'
+
+
+def test_an_explicit_version_outranks_parent_version():
+ """An element that is both a parent and an ordinary dependency keeps its own version.
+
+ No fixture element carries both attributes, deliberately: this ordering is a property of
+ extract_version alone, and a fixture pinning it would couple two orthogonal guards.
+ """
+ elem = SElement(None, 'org.example both')
+ elem.attrs.update(version='1.0', parent_version='2.0')
+ assert sbom_cyclonedx_generator.extract_version(elem) == '1.0'
+
+
+def test_a_legacy_parent_without_coordinates_is_not_emitted():
+ """Models persisted before the analyzer wrote coordinates onto parents must stay excluded.
+
+ SBOMs are generated on demand from stored models with a multi-month lifetime, so the
+ generator meets old shapes long after the analyzer moved on. A parent_version-only element
+ has no coordinates to build a maven purl from; emitting it would splice the space-bearing
+ element name into a generic purl — the exact class the maven-purl work eliminated. The
+ fixture element is referenced, deliberately: exclusion must rest on the missing coordinates,
+ not on a missing reference.
+ """
+ assert 'org.example.legacyparent legacy-parent' not in get_maven_coordinate_components()
+
+
+def test_an_ambiguous_parent_version_stays_out_of_the_purl():
+ """Two poms naming one parent at different versions collide on one versionless element.
+
+ The attribute transfer joins their versions with a semicolon. A purl carrying the joined
+ value asserts a version that exists nowhere and matches nothing; omitting it keeps the purl
+ canonical and package-level matchable, while the raw value stays disclosed in the version
+ field — the same split the unresolved-expression case established.
+ """
+ component = get_maven_coordinate_components()['org.example.multiparent multi-parent']
+ assert component['purl'] == 'pkg:maven/org.example.multiparent/multi-parent'
+ assert component['version'] == '4.1.0;3.2.0'
+
+
+def test_versionless_inclusion_requires_usable_coordinates():
+ """Charset-rejected coordinates plus no version leave nothing spec-clean to emit.
+
+ A ${} groupId whose property lives in an external parent fails the coordinate guard, so no
+ maven purl can be built; versionless, the element predates this feature in no BOM at all,
+ and admitting it now would splice the space-bearing element name into a generic purl. A
+ versioned element with the same broken coordinates still takes the generic residual as
+ before — this rule is about what the versionless-inclusion branch may admit, not about
+ tightening the residual.
+ """
+ assert 'org.example.propgroup prop-group-lib' not in get_maven_coordinate_components()
+
+
+def test_the_generic_fallback_never_splices_an_empty_version():
+ """The versionless-inclusion rule made an empty version reachable on the fallback path.
+
+ Coordinates that fail the charset guard, such as an unresolved ${project.groupId}, drop the
+ element to the generic branch, and a versionless element then reaches the final splice with
+ an empty version. Appending it would emit a trailing '@' — not a canonical versionless purl
+ but a malformed versioned one, the same shape maven_purl already refuses.
+ """
+ maven_bucket = SElement(None, 'Maven')
+ elem = SElement(maven_bucket, 'caffeine')
+ elem.attrs.update(groupId='${project.groupId}', artifactId='caffeine')
+ purl, properties = sbom_cyclonedx_generator.purl_for(elem, '')
+ assert purl == 'pkg:generic/caffeine'
+ assert properties == [{'name': 'purlTypeResolution', 'value': 'maven coordinates unavailable'}]
+
+
+def test_no_fixture_purl_carries_a_space_a_semicolon_or_a_trailing_at():
+ """The cross-shape invariant the individual guards above defend, stated once directly.
+
+ Findings against stored models all took one of these three shapes; asserting the invariant
+ over every fixture generation catches a regression in any of them even if the targeted
+ test for that shape is later weakened.
+ """
+ for model_file in (MAVEN_COORDINATES_MODEL, BINARY_REFS_MODEL,
+ 'converters/modelfile_for_sbom_tests.xml',
+ 'converters/modelfile_for_sbom_multi_tests.xml'):
+ model, _ = get_model_and_model_api(model_file)
+ sbom = sbom_cyclonedx_generator.generate_from_sgraph(model)
+ for component in sbom['components']:
+ for ref in (component['purl'], component['bom-ref']):
+ assert ' ' not in ref, ref
+ assert ';' not in ref, ref
+ assert not ref.endswith('@'), ref
def test_a_partly_resolved_version_keeps_its_version():