From a5dc72e90f269356bdba54507d2363ca1d37704d Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Tue, 4 Aug 2026 17:21:27 +0300 Subject: [PATCH] SBOM: fix five CycloneDX generator defects Fixing defects about how components are typed, versioned and deduplicated. Each fix below was pre-registered with a falsifiable row delta and verified against a 72-model corpus; a main-vs-final diff over that corpus classified every behavioural difference, with zero unclassified. G1 Container images were emitted as "type": "library", because the value was hardcoded. Map the purl type to the CycloneDX component type instead, so docker/oci yield "container". G3 MSBuild property references such as $(Version) reached purls verbatim, matching no package and silently misreporting the version. The existing unresolved-version guard covered only Maven's ${...} and applied only inside maven_purl. Widen it to both dialects behind one shared predicate used for every type. Still fullmatch, deliberately: 1.0-$(Suffix) is partly known and keeps its version. G4 The internal __slash__ placeholder was decoded in names but never in versions, so an npm specifier survived raw into the purl. Decode it in extract_version so the disclosed version field is corrected too. 51 rows. G5 Case-variant NuGet ids were emitted as two components, splitting any vulnerability match across two identities. Deduplicate on a case-folded key for the ecosystems whose purl type definitions declare identity case-insensitive (nuget, pypi). The key only: the emitted purl keeps the model's casing, since normalising it would change component identity. G9 A binary committed into source control was typed nuget by extension inference, asserting a public package that does not exist -- a dependency-confusion-shaped false positive. Fall back to generic when the element's name is the stem of a file referencing it, citing the referencing file names so the decision is auditable. 16 rows. --- .../converters/sbom_cyclonedx_generator.py | 236 +++++++++++++++-- .../modelfile_for_sbom_emission_tests.xml | 105 ++++++++ .../sbom_cyclonedx_generator_test.py | 245 ++++++++++++++++++ 3 files changed, 570 insertions(+), 16 deletions(-) create mode 100644 tests/converters/modelfile_for_sbom_emission_tests.xml diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 1ac2600..eaee68c 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -32,14 +32,26 @@ def valid_for_bom(elem): def extract_version(elem): + """The element's version, with sgraph's path-separator encoding decoded. + + Decoding happens here rather than in purl_for so the component's disclosed version field + carries the true value too, not only the purl. Every source is decoded, not just the two that + read the version out of a name: an analyzer that copies a name-derived version into the + attribute carries the encoding with it, and which source a given element used is not + something the caller can see. + """ + version = None if 'version' in elem.attrs: - return elem.attrs['version'] - if 'versions' in elem.attrs: - return elem.attrs['versions'] - if ' of version ' in elem.name: - return elem.name.split(' of version ')[-1].strip() - if ' of tag ' in elem.name: - return elem.name.split(' of tag ')[-1].strip() + version = elem.attrs['version'] + elif 'versions' in elem.attrs: + version = elem.attrs['versions'] + elif ' of version ' in elem.name: + version = elem.name.split(' of version ')[-1].strip() + elif ' of tag ' in elem.name: + version = elem.name.split(' of tag ')[-1].strip() + if version is None: + return None + return version.replace(VERSION_PATH_SEPARATOR_ENCODING, '/') def incoming_deps(elem, elem_name_patterns, deptypes): @@ -95,17 +107,128 @@ def parents_parent_or_parent_name_equals(elem, name): PURL_TYPE_SOURCE_PROPERTY = 'purlTypeResolution' +# CycloneDX classifies a component by what it is, and 'library' — the value every component +# carried before this mapping — is wrong for an image. Consumers route components by type, so an +# image classified as a library is scanned as application code rather than as a base layer: the +# misclassification changes what a consumer does with the row, not merely how it reads. +# 'container' is a value of the component type enum in the CycloneDX 1.7 schema +# (bom-1.7.schema.json), described there as a packaging and/or runtime format that isolates +# software through virtualization technology. +# +# Keyed on the purl type rather than on the branch of purl_for that produced it, so a future +# producer of the same purl type is classified correctly without a second edit here. +# +# 'oci' is the purl type registered for the same artifact class, and nothing emits it today: the +# docker branch of purl_for is the only producer of an image purl in this module. It is mapped for +# forward compatibility, which is also why the oci case is asserted directly on the function below +# rather than through a fixture — no model can reach a purl the generator never builds, and a +# fixture that appeared to would be testing a hand-written string, not this code. +CYCLONEDX_TYPE_BY_PURL_TYPE = { + 'docker': 'container', + 'oci': 'container', +} + +DEFAULT_CYCLONEDX_TYPE = 'library' + +# Package identity is case-insensitive in some ecosystems and case-significant in others, so the +# deduplication key is folded only where the ecosystem's own purl type definition says two +# spellings are one package. nuget's records that the name is "case-preserving, but +# case-insensitive"; pypi's sets case_sensitive false. +# +# npm is deliberately absent even though its names have been lowercase by rule since 2015: its +# type definition sets case_sensitive true, because mixed-case packages predating that rule were +# grandfathered in, so JSONStream and jsonstream are two real packages. maven is case-significant +# in both coordinates, golang's names are lowercase by rule so folding would be a rule that never +# fires, and generic names are opaque. Folding any of them would merge two distinct components +# into one and lose a row — a worse defect than the duplicate it set out to remove. +# +# pypi also normalizes '_' to '-', which is deliberately NOT applied: that rewrites the name +# rather than its case, and belongs with the percent-encoding migration that is still deferred. +CASE_INSENSITIVE_PURL_TYPES = {'nuget', 'pypi'} + + +def purl_type_of(purl): + """The type segment of a purl, or '' when the string is not one.""" + if not purl.startswith('pkg:'): + return '' + return purl[len('pkg:'):].split('/', 1)[0] + + +def cyclonedx_component_type(purl): + """Map a purl to its CycloneDX component type, defaulting to 'library'. + + The type is read off the emitted purl rather than passed in alongside it, so a component's + declared class and its identifier cannot disagree: there is one source of truth, the string + the consumer actually receives. + + A string that is not a purl, including an empty one, takes the default. That is the value + every component carried before this mapping existed, so an unparseable purl leaves the row + exactly as it was rather than dropping it or inventing a class for it. None is not handled: + purl_for always returns a string, and a guard for a caller that does not exist would be + untested code asserting a contract nothing relies on. + """ + return CYCLONEDX_TYPE_BY_PURL_TYPE.get(purl_type_of(purl), DEFAULT_CYCLONEDX_TYPE) + + +def dedup_key(bom_ref): + """The identity under which two emitted components count as the same package. + + Folds the key only. The emitted purl keeps whatever casing the model gave it, because + rewriting it to a folded form would change the identifier a consumer resolves — that is a + migration, not a deduplication, and it is not what this fix buys. + + .lower() rather than .casefold(): these type definitions are about ASCII case, and Unicode + caseless matching would additionally merge names the ecosystems keep distinct. + """ + if purl_type_of(bom_ref) in CASE_INSENSITIVE_PURL_TYPES: + return bom_ref.lower() + return bom_ref + + # Maven's model validator restricts groupId and artifactId to [A-Za-z0-9._-] at ERROR severity # for every POM, and that set is a strict subset of the characters purl leaves unencoded. So a # coordinate that passes this guard is spliced into the purl verbatim and needs no encoding # step, which is what makes deferring encoding coherent rather than a shortcut. MAVEN_COORDINATE = re.compile(r'^[A-Za-z0-9._-]+$') -# A Maven version the analyzer captured before the build resolved it. Matched with fullmatch, +# A version the analyzer captured before the build resolved it, in either dialect it is written +# in: ${...} is Maven and Gradle property syntax, $(...) is MSBuild's. Matched with fullmatch, # deliberately: a version that merely contains an expression, such as '1.0-${suffix}', is partly # known, and dropping the known part would change which components exist under a rule nobody has # measured. Widening this to a substring search is the tempting simplification to refuse. -UNRESOLVED_VERSION = re.compile(r'\$\{.+\}') +# +# '${}' and '$()' do not match, because '.+' requires content. An expression naming no property +# resolves to nothing in any build either, so the narrower reading costs nothing. +UNRESOLVED_VERSION = re.compile(r'\$\{.+\}|\$\(.+\)') + +# sgraph encodes '/' inside an element name as '__slash__', and a version lifted out of such a +# name inherits the encoding. clean_name already decodes it out of names; versions were left raw, +# so both the disclosed version and the purl advertised a literal '__slash__' that matches +# nothing in any ecosystem. +VERSION_PATH_SEPARATOR_ENCODING = '__slash__' + +# A version that is a URL names a place to fetch from, not a release. It identifies no published +# version and matches no advisory, so carrying it in the purl asserts a version that exists +# nowhere. Anchored at the start: a version that merely begins with a scheme-like word is not a +# URL, and one that merely contains a URL is partly known — the same reasoning that keeps +# UNRESOLVED_VERSION a fullmatch. +URL_SHAPED_VERSION = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.-]*://') + +# Names the reason a component has a disclosed version but a versionless purl, so a consumer can +# tell "no version was known" from "the known value was not a version". +VERSION_SOURCE_PROPERTY = 'versionSource' + + +def is_unresolved_version(version): + """Whether a version is wholly a build-property expression, and so names no published release. + + One predicate for every purl type. The rule began inside maven_purl and was applied nowhere + else, so every other ecosystem spliced the raw expression into its purl — a string that is + either non-canonical raw or canonical-but-unmatchable percent-encoded, and matches nothing + either way. maven_purl still routes through this rather than touching the pattern directly, + so the maven path and the shared path cannot drift apart. + """ + return bool(UNRESOLVED_VERSION.fullmatch(version)) def is_maven_coordinate(value): @@ -150,6 +273,44 @@ def infer_pkgtype_from_referencing_files(elem): return winner, sorted(extensions_by_pkgtype[winner]) +def file_stem(elem): + """The name of a referencing element with its own extension removed. + + The extension comes from file_extension rather than an independent rsplit('.'), so this + inherits that helper's t="file" handling instead of adding a second, subtly different + assumption about what a filename looks like. When file_extension resolved the extension from + an ancestor rather than from this element, the name does not end with it and the whole name is + returned — which is correct, since such an element is not a file and has no stem to speak of. + """ + extension = file_extension(elem) + if extension and elem.name.lower().endswith('.' + extension.lower()): + return elem.name[:-(len(extension) + 1)] + return elem.name + + +def referencing_files_named_after(elem): + """The referencing files whose stem is this element's own name, sorted and deduplicated. + + An external whose name is exactly the stem of a file referencing it is not a package that file + depends on — it IS that committed binary, recorded as an external because the analyzer had + nowhere else to put it. A real package reference never looks like this: Ionic.Zip is referenced + from Consumer.dll, not from Ionic.Zip.dll. + + Returns names, not paths, and sorted: the same binary is often referenced from several + directories (the acceptance data has one referenced from both an installer output and an + update directory), and those collapse to a single citation, while a genuine Foo.exe + Foo.dll + pair stays two. Sorting mirrors the tie-breaking in infer_pkgtype_from_referencing_files and + exists for the same reason: without it the emitted provenance would depend on association + traversal order and the SBOM would stop being byte-stable between runs. + """ + name = clean_name(elem.name).lower() + matches = set() + for association in elem.incoming + (elem.parent.incoming if elem.parent else []): + if file_stem(association.fromElement).lower() == name: + matches.add(association.fromElement.name) + return sorted(matches) + + def maven_purl(elem, version): """Build the maven purl from an element's coordinates, or None when it has none usable. @@ -170,7 +331,7 @@ def maven_purl(elem, version): 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 is_unresolved_version(version): return f'pkg:maven/{group_id}/{artifact_id}' return f'pkg:maven/{group_id}/{artifact_id}@{version}' @@ -218,7 +379,38 @@ def purl_for(elem, v): pkgid = pkgid.split(' of tag ')[0] else: pkgtype, extensions = infer_pkgtype_from_referencing_files(elem) - if pkgtype is not None: + # A committed binary must not be typed from the extension of the file that IS it. The + # inference would answer nuget for a .exe, and pkg:nuget/softagram_windows-x64@1.95.0 + # asserts a public NuGet package that does not exist — a dependency-confusion-shaped false + # positive for any consumer that resolves purls. 'generic' is the purl type with no default + # package repository, which is exactly what a binary committed into source control is. + # + # Checked only when a type was actually inferred: with no inferred type the element already + # takes the fallback, and claiming this reason there would cite evidence nothing acted on. + # The provenance value REPLACES the inference one rather than joining it, so the component + # never cites extension evidence the code declined to use. + # + # Known and accepted false positive: a vendored third-party binary — Newtonsoft.Json.dll + # committed into the repo, referencing external Newtonsoft.Json — matches this rule and is + # downgraded to generic, losing a real vulnerability match. Shipping as designed, on two + # grounds that are measured rather than assumed: this branch is reached at all by only 17 + # components across 72 stored models (16 via exe, 1 via whl; no .dll has ever won a vote), + # and every downgrade carries provenance naming the referencing files, so the decision is + # auditable rather than silent. + # + # What is NOT claimed: that the vendored shape is rare. It does not occur anywhere in that + # corpus, but the corpus holds essentially no vendored .NET content, so this is absence of + # evidence, not evidence of absence. The one real discriminating case measured — aiortc, a + # genuine public PyPI package inferred from a .whl in odoo-fullstack — is correctly spared, + # because its name is not the stem of the file referencing it. + self_reference_files = referencing_files_named_after(elem) + if pkgtype is not None and self_reference_files: + pkgtype = FALLBACK_PURL_TYPE + properties.append({ + 'name': PURL_TYPE_SOURCE_PROPERTY, + 'value': 'referencing file is the binary itself: ' + ','.join(self_reference_files) + }) + elif pkgtype is not None: properties.append({ 'name': PURL_TYPE_SOURCE_PROPERTY, 'value': 'inferred from referencing file extension: ' + ','.join(extensions) @@ -228,6 +420,16 @@ def purl_for(elem, v): properties.append({'name': PURL_TYPE_SOURCE_PROPERTY, 'value': 'ecosystem unresolved'}) v = v.lstrip('^') + # Both rules live here rather than in each branch above, so a purl type added later inherits + # them. The maven branch returns before reaching this point and applies the unresolved rule + # itself through the same predicate; a maven version is a POM and never a URL. + if is_unresolved_version(v): + return f'pkg:{pkgtype}/{pkgid}', properties + if URL_SHAPED_VERSION.match(v): + # Disclosed rather than dropped: the raw value stays in the component's version field, + # and this property records why the purl carries no version. + properties.append({'name': VERSION_SOURCE_PROPERTY, 'value': v}) + return f'pkg:{pkgtype}/{pkgid}', properties return f'pkg:{pkgtype}/{pkgid}@{v}', properties @@ -415,7 +617,7 @@ def elem_as_bom_data(elem, other_externals_by_name, external_root, noisy=False): 'version': v, 'bom-ref': ref, 'purl': ref, - 'type': 'library', + 'type': cyclonedx_component_type(ref), 'licenses': licenses, 'scope': 'required', 'properties': custom_properties, @@ -525,8 +727,9 @@ def analyze_3rdparty(external_root, sbom): while stack: elem = stack.pop(0) for bom_component in elem_as_bom_data(elem, other_externals_by_name, external_root): - if bom_component['bom-ref'] not in seen_refs: - seen_refs.add(bom_component['bom-ref']) + key = dedup_key(bom_component['bom-ref']) + if key not in seen_refs: + seen_refs.add(key) sbom.components.append(bom_component) stack += elem.children @@ -634,8 +837,9 @@ def _collect_3rdparty_for_subtree(subtree_root, external_root, other_externals_b target = assoc.toElement if target.isDescendantOf(external_root) or target == external_root: for component in elem_as_bom_data(target, other_externals_by_name, external_root): - if component['bom-ref'] not in seen_refs: - seen_refs.add(component['bom-ref']) + key = dedup_key(component['bom-ref']) + if key not in seen_refs: + seen_refs.add(key) components.append(component) stack += elem.children return components diff --git a/tests/converters/modelfile_for_sbom_emission_tests.xml b/tests/converters/modelfile_for_sbom_emission_tests.xml new file mode 100644 index 0000000..08ee3ed --- /dev/null +++ b/tests/converters/modelfile_for_sbom_emission_tests.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/converters/sbom_cyclonedx_generator_test.py b/tests/converters/sbom_cyclonedx_generator_test.py index cbf112f..7d93a3e 100644 --- a/tests/converters/sbom_cyclonedx_generator_test.py +++ b/tests/converters/sbom_cyclonedx_generator_test.py @@ -492,3 +492,248 @@ def test_a_caret_prefixed_version_is_normalised_as_on_every_other_branch(): purl, properties = sbom_cyclonedx_generator.purl_for(elem, '^1.0') assert purl == 'pkg:maven/org.example/example-lib@1.0' assert properties == [] + + +# --- component emission tests --- +# +# Five defects measured against the customer's 674 SBOMs, all local to this module: an image +# emitted as a library, a build-property version spliced into a purl on every non-maven type, an +# undecoded __slash__ in a version, one NuGet package inventoried twice under two spellings, and +# a committed binary asserted to be a published NuGet package. + +EMISSION_MODEL = 'converters/modelfile_for_sbom_emission_tests.xml' + + +def get_emission_components(): + """Generate the emission SBOM and return its components as a list. + + A list rather than a by-name index: the case-differing spellings this fixture carries collide + in a by-name dict until deduplication folds them together, and a dict would hide exactly the + defect the fixture exists to pin. + """ + model, _ = get_model_and_model_api(EMISSION_MODEL) + return sbom_cyclonedx_generator.generate_from_sgraph(model)['components'] + + +def find_property(component, name): + """Return the value of a named CycloneDX property of a component, or None when absent. + + Distinct from purl_type_resolution above, which reads one fixed property name; this reads any + of them, and the versionless-purl rules below each need a different one. + """ + for prop in component.get('properties', []): + if prop['name'] == name: + return prop['value'] + return None + + +def emission_component(name): + """Return the one component with this name, failing when it is absent or not unique.""" + matches = [component for component in get_emission_components() if component['name'] == name] + assert len(matches) == 1, matches + return matches[0] + + +def test_a_docker_image_is_emitted_as_a_container_not_a_library(): + """CycloneDX has a component type for an image, and the purl type already says it is one. + + 'library' on an image is not a harmless default: consumers route components by type, and an + image classified as a library is scanned as application code rather than as a base layer. + """ + component = emission_component('nginx') + assert component['purl'] == 'pkg:docker/nginx@1.25' + assert component['type'] == 'container' + + +def test_a_package_that_is_not_an_image_stays_a_library(): + """Anti-vacuity for the mapping: it must retype images only, not everything.""" + assert emission_component('Ionic.Zip')['type'] == 'library' + + +def test_the_oci_purl_type_is_a_container_too(): + """oci names the same artifact class docker does, and no fixture can reach that branch. + + Nothing in this module emits pkg:oci today, so the mapping is asserted directly rather than + through a model. The nuget case is the control: without it a mapping that answered + 'container' unconditionally would pass. + """ + assert sbom_cyclonedx_generator.cyclonedx_component_type('pkg:oci/nginx@1.25') == 'container' + assert sbom_cyclonedx_generator.cyclonedx_component_type('pkg:nuget/X@1.0') == 'library' + + +def test_an_msbuild_property_version_yields_a_versionless_purl(): + """$(...) is MSBuild's expression syntax and names no published version, exactly as ${...}. + + The rule already existed for maven and was applied nowhere else, so every other ecosystem + kept splicing the unresolved expression into the purl. + """ + component = emission_component('MsbuildLib') + assert component['purl'] == 'pkg:nuget/MsbuildLib' + assert component['version'] == '$(VersionPrefix)' + + +def test_an_unresolved_expression_is_unresolved_on_every_type_not_only_maven(): + """The same ${...} the maven branch already refused must not be spliced in on a nuget purl.""" + component = emission_component('MavenStyleLib') + assert component['purl'] == 'pkg:nuget/MavenStyleLib' + assert component['version'] == '${Version}' + + +def test_a_partly_resolved_msbuild_version_keeps_its_version(): + """The fullmatch discipline the maven branch established survives the widening. + + A version that merely contains an expression is partly known, and dropping the known part + would change which components exist under a rule nobody has measured. + """ + component = emission_component('PartlyResolvedLib') + assert component['purl'] == 'pkg:nuget/PartlyResolvedLib@1.0-$(Suffix)' + + +def test_the_unresolved_version_guard_matches_both_expression_dialects(): + """Direct guard on the pattern, including the empty-expression and bare-dollar controls.""" + for value in ('${project.version}', '$(VersionPrefix)', '$(Ver)'): + assert sbom_cyclonedx_generator.UNRESOLVED_VERSION.fullmatch(value), value + for value in ('1.0', '1.0-${suffix}', '1.0-$(suffix)', '$', '${}', '$()', ''): + assert not sbom_cyclonedx_generator.UNRESOLVED_VERSION.fullmatch(value), value + + +def test_a_slash_encoded_version_is_decoded_like_a_name(): + """__slash__ is sgraph's encoding of '/' and clean_name already decodes it out of names. + + A version carries the same encoding and was left raw, so both the disclosed version and the + purl advertised a literal '__slash__' that matches nothing anywhere. + """ + component = emission_component('BranchVersionLib') + assert component['version'] == 'feature/1.0' + assert component['purl'] == 'pkg:nuget/BranchVersionLib@feature/1.0' + + +def test_a_url_shaped_version_yields_a_versionless_purl_and_records_its_source(): + """A URL is a location, not a version: it names no release and matches no advisory. + + Omitting it keeps the purl canonical and package-level matchable, and the URL stays disclosed + twice over — in the version field, and in a property saying why the purl has no version. + """ + component = emission_component('UrlVersionLib') + assert component['purl'] == 'pkg:nuget/UrlVersionLib' + assert component['version'] == 'https://github.com/example/lib.git' + assert find_property(component, 'versionSource') == 'https://github.com/example/lib.git' + + +def test_case_differing_nuget_spellings_are_one_package(): + """NuGet package ids are case-insensitive, so two spellings are one package, not two. + + Emitting both inflates the inventory and makes the same advisory arrive twice, once per + spelling. The surviving spelling is the first in document order so output stays byte-stable. + """ + nlog_components = [component for component in get_emission_components() + if component['name'].lower() == 'nlog'] + assert len(nlog_components) == 1 + assert nlog_components[0]['purl'] == 'pkg:nuget/NLog@5.0.0' + + +def test_case_differing_maven_artifacts_stay_two_packages(): + """Control against over-broad folding: Maven identity is case-sensitive in both coordinates. + + Folding every type would collapse these two real, distinct artifacts into one and lose a + component outright — a strictly worse defect than the duplicate it set out to remove. + """ + maven_purls = {component['purl'] for component in get_emission_components() + if component['purl'].startswith('pkg:maven/')} + assert maven_purls == {'pkg:maven/org.example/CaseLib@1.0', + 'pkg:maven/org.example/caselib@1.0'} + + +def test_case_differing_npm_names_stay_two_packages(): + """Control against widening the fold set: npm identity is case-sensitive by type definition. + + The 2015 rule that a new package name must not contain uppercase letters is about *new* + names; mixed-case packages predating it were grandfathered in, which is why purl marks the + npm type case-sensitive. This is the control most worth having, because every npm purl in the + largest local model is already lowercase — no real-model measurement can catch this + regression, so only this fixture stands between the fold set and a silent identity merge. + """ + npm_purls = {component['purl'] for component in get_emission_components() + if component['purl'].startswith('pkg:npm/')} + assert npm_purls == {'pkg:npm/JSONStream@1.3.5', 'pkg:npm/jsonstream@1.3.5'} + + +def test_a_binary_that_is_its_own_referencing_file_is_not_a_nuget_package(): + """An external whose name is the stem of the file referencing it IS that committed binary. + + pkg:nuget/ asserts a public NuGet package that does not exist — a + dependency-confusion-shaped false positive for any consumer resolving purls. generic is the + purl type with no default package repository, which is exactly what a committed binary is. + """ + component = emission_component('softagram_windows-x64_1_93_1') + assert component['purl'] == 'pkg:generic/softagram_windows-x64_1_93_1@1.95.0' + assert purl_type_resolution(component) == \ + 'referencing file is the binary itself: softagram_windows-x64_1_93_1.exe' + + +def test_the_self_reference_provenance_is_sorted_and_deduplicated(): + """Several referencing files must yield one deterministic citation, not a traversal-order one. + + The fixture references TwoRefBinary from two identically named .exe files in different + directories and from one .dll. Citing names rather than paths collapses the two .exe rows to a + single entry — the real acceptance data has exactly this shape, a binary referenced from both + an installer output and an update directory — while the .dll stays a second entry. Sorting is + what makes the pair's order independent of which association the walk reached first, which is + the same byte-stability guarantee infer_pkgtype_from_referencing_files already provides. + """ + component = emission_component('TwoRefBinary') + assert component['purl'] == 'pkg:generic/TwoRefBinary@2.0' + assert purl_type_resolution(component) == \ + 'referencing file is the binary itself: TwoRefBinary.dll,TwoRefBinary.exe' + + +def test_folding_uses_ascii_lowercasing_not_unicode_casefolding(): + """Defends the documented choice of .lower() over .casefold() with the pair that separates them. + + 'ß'.casefold() is 'ss', so casefolding would declare Straße and STRASSE the same NuGet package + and drop one of them. .lower() leaves 'ß' alone and keeps them distinct. Every ASCII fixture + passes under either function, so only a non-ASCII pair can fail if the two are swapped. + """ + assert 'Straße'.casefold() == 'strasse'.casefold() # the premise: casefold would merge these + assert sbom_cyclonedx_generator.dedup_key('pkg:nuget/Straße@1.0') != \ + sbom_cyclonedx_generator.dedup_key('pkg:nuget/STRASSE@1.0') + + +def test_a_version_that_merely_contains_a_url_keeps_its_version(): + """Defends the URL rule's anchoring: it matches a version that IS a URL, not one holding one. + + The negative assertion uses search, not match, deliberately. re.match anchors at position 0 by + definition, so a match-based assertion here would hold for any pattern that fails at position 0 + — including one with the '^' deleted — and would be true for the wrong reason, closing nothing. + search is the only call that can distinguish an anchored pattern from an unanchored one, so + this is the assertion that fails if the '^' is dropped and a partly known version such as + '1.0+https://…' silently loses its version. + + Written against the pattern directly because no stored model carries such a version. + """ + assert sbom_cyclonedx_generator.URL_SHAPED_VERSION.match('https://example.com/x.git') + assert not sbom_cyclonedx_generator.URL_SHAPED_VERSION.search('1.0+https://example.com/x.git') + + +def test_a_package_referenced_by_a_differently_named_file_keeps_its_inferred_type(): + """Anti-vacuity for the rule above: a real package reference never shares the file's stem. + + Ionic.Zip is referenced from Consumer.dll, the ordinary shape, and must keep both its + inferred type and the provenance that says the type was inferred. + """ + component = emission_component('Ionic.Zip') + assert component['purl'] == 'pkg:nuget/Ionic.Zip@1.9.1.8' + assert purl_type_resolution(component) == 'inferred from referencing file extension: dll' + + +def test_the_emission_fixture_yields_fourteen_distinct_components(): + """Anti-vacuity for the fixture, and the collapse guard for the two versionless rules. + + Fourteen, not fifteen: the two NuGet spellings are one package, while the two npm spellings + and the two Maven artifacts are four separate ones. 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 — and omitting a version is exactly what two of these rules do. + """ + components = get_emission_components() + assert len(components) == 14 + assert len({component['bom-ref'] for component in components}) == 14