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():