diff --git a/docs/data-formats.md b/docs/data-formats.md index dfe7247..71d00ee 100644 --- a/docs/data-formats.md +++ b/docs/data-formats.md @@ -442,6 +442,47 @@ document's own subject, and not a 3rd-party package. It appears as a component m - Components describing internal packages carry **no** `dependencyDepth`. That property counts package hops through the 3rd-party closure, and an element of the estate is not one. +### Where a package was fetched from + +When the analyzer records the registry a 3rd-party package was restored from, the component says +so. A package from a private feed carries it in the purl as the `repository_url` qualifier: + +```json +{ "bom-ref": "pkg:nuget/Example.Internal.Core@1.4.0", + "purl": "pkg:nuget/Example.Internal.Core@1.4.0?repository_url=https:%2F%2Fpkgs.example.org%2Ffeed%2Fnuget%2Fv3%2Findex.json", + "properties": [ + { "name": "softagram:packageSource", "value": "https://pkgs.example.org/feed/nuget/v3/index.json" } + ] } +``` + +| Field | Meaning | +|-------|---------| +| `purl` `repository_url` qualifier | The registry the package came from, **only when it is not the type's default registry**. An unqualified purl means the default registry, as the purl specification defines. | +| `properties[softagram:packageSource]` | The same registry, **whenever it is known**, the public one included. Absent when the source is unknown. | + +- **The property is what tells "public" from "unknown".** Both leave the purl unqualified, so a + consumer looking for packages from outside the public registry reads the qualifier, and one + that needs to know the source was actually recorded reads the property. +- **`bom-ref` never carries the qualifier.** One package restored from two feeds is still one + component, and every dependency edge names it by the same ref. +- **When the merged occurrences of a package disagree about their source, or some of them do not + state one, both the qualifier and the property are dropped.** The document does not pick one + checkout's answer for all of them. +- **The default registries** are the purl type definitions' own, recognised by host together + with the other spellings of the same registry: `registry.npmjs.org` and `registry.yarnpkg.com` + for npm; `www.nuget.org`, `nuget.org` and `api.nuget.org` for NuGet; `pypi.org` and + `pypi.python.org`; `repo.maven.apache.org` and `repo1.maven.org`; `hub.docker.com`; + `rubygems.org`. `deb`, `golang` and `generic` have no default registry, so any known source is + qualified. +- **Only the scheme, host, port and path are published.** User info, query and fragment are + removed, because that is where registry credentials usually travel. **A token embedded in the + path itself is not detected** — no rule can tell such a segment from a feed name — so keeping + it out of the model is the analyzer's responsibility. A source that is not an http(s) URL, + such as a local feed directory, or that contains a backslash, is treated as unknown. +- **A purl whose version already contains `?` or `#`** — a git-shaped version such as + `github:user/repo#abc123` — gets no qualifier, because it would land inside the subpath. The + property still states the source. + ### What the document does not cover A component list cannot say what is missing from it. An external the analyzer saw and the diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 9062b54..1d68302 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -5,6 +5,7 @@ import json import sys from collections import Counter, defaultdict +from urllib.parse import quote, urlsplit, urlunsplit from sgraph import SGraph, SElement from sgraph.converters.external_root_semantics import (canonical_purl_name, @@ -584,6 +585,130 @@ def bom_ref(elem, v): return purl_for(elem, v)[0] +# The attribute an analyzer stamps with the registry a package was actually fetched from. Neutral +# across ecosystems on purpose, the same way 'package_name'/'version'/'ecosystem' are: NuGet reads +# it from the '.nupkg.metadata' the restore writes beside every package, npm from the lockfile's +# resolved URL reduced to the registry it names, and a third ecosystem needs no branch here to be +# served. The value is expected to be the registry, not the artifact: a tarball URL would give +# every version of a package a different source and make agreement across a merge accidental. +PACKAGE_SOURCE_ATTRIBUTE = 'package_source' + +# Where the source is stated whenever it is known, the public registry included. purl cannot say +# "unknown": an absent repository_url already means "the type's default registry", so without +# this property a package confirmed to come from the public registry and one whose source nobody +# recorded would be indistinguishable in the document. +PACKAGE_SOURCE_PROPERTY = 'softagram:packageSource' + +# purl defines this qualifier for exactly this fact: the repository a package came from when it is +# not the ecosystem's default one. Emitting it is what lets a consumer tell a package from the +# public registry from one that only an authenticated internal feed serves - which, for a +# vulnerability consumer, is the difference between a match it can trust and a name collision. +REPOSITORY_URL_QUALIFIER = 'repository_url' + +# The hosts of each purl type's default registry, for which the qualifier is omitted. The first +# host of each entry is the 'Default Repository URL' of the purl type definition; the others are +# spellings of that same public registry - the ones the type definition itself names (maven's +# repo1 mirror, pypi's previous host), and the ones the ecosystems' own clients write: Yarn v1 +# lockfiles resolve public packages through registry.yarnpkg.com, a NuGet restore records the +# api.nuget.org service index, and older nuget.config files name the bare nuget.org host. Matched +# on the host alone, because the public registry is the host whatever path the client recorded. +# +# Qualifying a package from the default registry would not be false, but it would be redundant by +# the spec's own definition, and it would change the purl string of nearly every public component +# in the document: a consumer comparing purls as strings would stop matching the very rows whose +# identity is least in doubt. +DEFAULT_REPOSITORY_HOSTS = { + 'npm': {'registry.npmjs.org', 'registry.yarnpkg.com'}, + 'nuget': {'www.nuget.org', 'nuget.org', 'api.nuget.org'}, + 'pypi': {'pypi.org', 'pypi.python.org'}, + 'maven': {'repo.maven.apache.org', 'repo1.maven.org'}, + 'docker': {'hub.docker.com'}, + 'gem': {'rubygems.org'}, +} + +# The purl types this module emits whose type definition names no default registry, so any known +# source is qualified. Every emitted type is in exactly one of these two tables; a test derives the +# emitted types from the code that builds purls and holds both tables to that, so a purl type +# added later cannot silently qualify its public registry. +NO_DEFAULT_REPOSITORY_TYPES = {'deb', 'golang', 'generic'} + + +DEFAULT_PORT_BY_SCHEME = {'http': 80, 'https': 443} + + +def package_source_of(elem): + """The element's package source as a URL fit to publish, or None when there is none. + + Absent or blank means unknown, and so does a value that is not an http(s) URL with a host. A + NuGet source can be a local directory, and a filesystem path is not a repository URL: it would + publish the layout of the analysis host, user names included, and identify nothing a consumer + could reach. Treated as unknown rather than guessed at. + + What is published is the scheme, host, port and path, and nothing else. User info and the + query are where registry credentials usually travel - 'https://user:token@host/feed', + '?token=...' - and an SBOM is a document that leaves the organisation, so they are dropped + here, at the last point before it does. The fragment names nothing on the server. What this + does NOT catch is a token embedded in the path itself, as some hosted feeds issue them: no + rule could tell such a segment from a feed name, so keeping it out of the model is the + analyzer's job. + + The host is normalised so that spellings of one registry agree when duplicates are merged + and when the default registry is recognised: its trailing dot, a port that is the scheme's + default, and the path's trailing slash are dropped. + + A backslash anywhere makes the value unknown. urlsplit reads 'https://a.example\\@b.example' + as host b.example where a WHATWG parser reads a.example, so the host this function would + publish, and judge public or private, is not necessarily the one the client fetched from. + """ + raw = (elem.attrs.get(PACKAGE_SOURCE_ATTRIBUTE) or '').strip() + if not raw or '\\' in raw: + return None + try: + parts = urlsplit(raw) + port = parts.port + except ValueError: + return None + scheme = parts.scheme.lower() + host = (parts.hostname or '').rstrip('.') + if scheme not in DEFAULT_PORT_BY_SCHEME or not host: + return None + if ':' in host: + host = f'[{host}]' + netloc = host if port in (None, DEFAULT_PORT_BY_SCHEME[scheme]) else f'{host}:{port}' + return urlunsplit((scheme, netloc, parts.path.rstrip('/'), '', '')) + + +def source_qualified_purl(purl, source): + """`purl` with a package source appended as a qualifier, unless it is the default registry. + + Applied to the emitted 'purl' and deliberately NOT to 'bom-ref'. dedup_key folds on the ref + and every dependency edge resolves through it, so a qualifier there would turn one package + fetched from two feeds into two components and leave each consumer pointing at a different + one. The identity stays the package; the qualifier states where these bytes came from. + + The value is percent-encoded as the purl standard requires of a qualifier value: everything + but the alphanumerics, '.-_~' and ':' - the slashes included, as in every repository_url + example the standard gives. Left raw, '&' or '=' would split the qualifier segment and + silently truncate the URL. + + A purl that already contains '?' or '#' gets no qualifier. Versions are spliced in unencoded, + so a git-shaped version such as 'github:user/repo#abc123' already opens a subpath, and a + qualifier appended after it would be read as part of that subpath rather than as a + qualifier. It also keeps purl_without_qualifiers exact: the first '?' is always the one + added here. The softagram:packageSource property still states the source for such a row. + """ + if source is None or '?' in purl or '#' in purl: + return purl + if urlsplit(source).hostname in DEFAULT_REPOSITORY_HOSTS.get(purl_type_of(purl), ()): + return purl + return f'{purl}?{REPOSITORY_URL_QUALIFIER}={quote(source, safe=":")}' + + +def purl_without_qualifiers(purl): + """The identity half of a purl: everything before the qualifier segment.""" + return purl.split('?', 1)[0] + + # The SPDX identifiers this converter is prepared to put in license.id, each with its canonical # url. Deliberately a short list of values someone has actually checked rather than a mapping # guessed from license strings: CycloneDX $refs the SPDX identifier list from license.id, so a @@ -763,6 +888,23 @@ def merge_component_evidence(surviving, duplicate): prop for prop in surviving['properties'] if prop['name'] != NAME_RESOLUTION_PROPERTY ] + # Same rule, same reason, for where the bytes came from: the source describes THIS row, and + # after a merge the row is about every element that folded into it. It survives only when all + # of them say the same thing. A disagreement is not resolved to whichever element document + # order happened to traverse first, and an element that says nothing does not corroborate one + # that does -- 'unknown' is not agreement. The property is compared rather than the qualifier: + # the qualifier is derived from it and omits the public registry, so two rows without one may + # still disagree. Dropped from 'purl' only, which is why the qualifier was kept out of + # 'bom-ref': identity cannot change here, because callers have already resolved edges through + # it. + surviving_source = _property(surviving, PACKAGE_SOURCE_PROPERTY) + duplicate_source = _property(duplicate, PACKAGE_SOURCE_PROPERTY) + if surviving_source != duplicate_source: + if surviving_source is not None: + surviving['properties'].remove(surviving_source) + if 'purl' in surviving: + surviving['purl'] = purl_without_qualifiers(surviving['purl']) + def finalize_components(components): """Re-render the evidence-derived properties and drop the collection-time key. @@ -851,6 +993,9 @@ def elem_as_bom_data(elem, other_externals_by_name, external_root, noisy=False): custom_properties = [] # noqa custom_properties.append({'name': 'sourceCodeReferences', 'value': direct_deps_paths}) custom_properties.extend(purl_properties) + source = package_source_of(elem) + if source is not None: + custom_properties.append({'name': PACKAGE_SOURCE_PROPERTY, 'value': source}) if indirect_deps: # str(): CycloneDX types properties[].value as a string, so a bare int here @@ -868,7 +1013,7 @@ def elem_as_bom_data(elem, other_externals_by_name, external_root, noisy=False): 'name': repaired_name if repaired_name is not None else clean_name(elem.name), 'version': v, 'bom-ref': ref, - 'purl': ref, + 'purl': source_qualified_purl(ref, source), 'type': cyclonedx_component_type(ref), 'licenses': licenses, 'scope': 'required', diff --git a/tests/converters/test_package_source_provenance.py b/tests/converters/test_package_source_provenance.py new file mode 100644 index 0000000..95557e7 --- /dev/null +++ b/tests/converters/test_package_source_provenance.py @@ -0,0 +1,347 @@ +"""Where a package was fetched from, carried into the document. + +A consumer reading this document cannot today tell a package that came from the public registry +from one that came from a feed only the organisation can reach. Both halves of that question -- +"where did this come from" and "is this ours" -- are answered by the same fact, and the analyzers +can state it: a NuGet restore writes the feed into `.nupkg.metadata` beside every package, and an +npm lockfile records the registry each package resolved from. + +This module is the export half. The analyzer stamps the ecosystem-neutral attribute +`package_source` on the package element -- the same convention `_package_identity_in_subtree` +documents for `package_name`/`version`/`ecosystem` -- and the converter publishes it twice: as the +`softagram:packageSource` property whenever it is known, and as the purl qualifier the spec +defines for exactly this whenever it is not the type's default registry. + +What it must not do is change what a component IS. The qualifier goes in `purl` only; `bom-ref` +keeps the unqualified spelling, because `dedup_key` folds on it and two checkouts of one package +that happen to name different feeds must still be one component. +""" +import ast +import inspect +import json +import re +import textwrap + +import pytest + +from sgraph import SElement, SElementAssociation, SGraph +from sgraph.converters import sbom_cyclonedx_generator + +NPM_PUBLIC = 'https://registry.npmjs.org' +NPM_PRIVATE = 'https://npm.internal.example/repository/npm-private' +NUGET_PUBLIC = 'https://api.nuget.org/v3/index.json' +NUGET_PRIVATE = ('https://pkgs.dev.azure.com/example-org/example-project' + '/_packaging/internal-feed/nuget/v3/index.json') + +SOURCE_PROPERTY = 'softagram:packageSource' + + +def model_with(packages, parent='NPM'): + """A model whose External/ holds these (name, version, attrs) packages.""" + model = SGraph(SElement(None, '')) + root = model.createOrGetElementFromPath(f'/Org/External/{parent}') + referrer = model.createOrGetElementFromPath('/Org/repoA/src/app.js') + for name, version, attrs in packages: + elem = SElement(root, name) + elem.attrs['version'] = version + for key, value in (attrs or {}).items(): + elem.attrs[key] = value + SElementAssociation(referrer, elem, 'use').initElems() + return model + + +def components_of(model): + sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) + return {c['name']: c for c in sbom['components']} + + +def component_for(name, version, attrs, parent='NPM'): + return components_of(model_with([(name, version, attrs)], parent))[name] + + +def source_property(component): + values = [p['value'] for p in component.get('properties', []) if p['name'] == SOURCE_PROPERTY] + assert len(values) <= 1 + return values[0] if values else None + + +def lodash_from_two_checkouts(first_source, second_source): + """One package reached from two repositories, each checkout naming its own source.""" + first = {'package_source': first_source} if first_source is not None else {} + model = model_with([('lodash', '4.17.21', first)]) + other = model.createOrGetElementFromPath('/Org/External/NPM2') + elem = SElement(other, 'lodash') + elem.attrs['version'] = '4.17.21' + elem.attrs['repotype'] = 'NPM' + if second_source is not None: + elem.attrs['package_source'] = second_source + referrer = model.createOrGetElementFromPath('/Org/repoB/src/app.js') + SElementAssociation(referrer, elem, 'use').initElems() + + sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) + return [c for c in sbom['components'] if c['name'] == 'lodash'] + + +class TestAPrivateSourceIsQualified: + def test_a_package_from_a_private_npm_registry_says_so(self): + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['purl'] == ('pkg:npm/lodash@4.17.21?repository_url=' + 'https:%2F%2Fnpm.internal.example%2Frepository%2Fnpm-private') + assert source_property(component) == NPM_PRIVATE + + def test_a_package_from_a_private_nuget_feed_says_so(self): + component = component_for('Example.Internal.Core', '1.4.0', + {'package_source': NUGET_PRIVATE}, parent='Assemblies') + + assert component['purl'].startswith('pkg:nuget/Example.Internal.Core@1.4.0?') + assert component['purl'].endswith( + '?repository_url=https:%2F%2Fpkgs.dev.azure.com%2Fexample-org%2Fexample-project' + '%2F_packaging%2Finternal-feed%2Fnuget%2Fv3%2Findex.json') + + +class TestThePublicRegistryIsNotQualified: + """An absent repository_url already means the type's default registry. Stating it would be + redundant, and would change the purl string of nearly every public component.""" + + @pytest.mark.parametrize('source', [ + NPM_PUBLIC, + NPM_PUBLIC + '/', + 'https://registry.yarnpkg.com', + 'https://registry.npmjs.org:443/', + 'https://registry.npmjs.org./', + ]) + def test_npm(self, source): + component = component_for('lodash', '4.17.21', {'package_source': source}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + + def test_nuget_as_a_restore_records_it(self): + component = component_for('Newtonsoft.Json', '13.0.3', {'package_source': NUGET_PUBLIC}, + parent='Assemblies') + + assert component['purl'] == 'pkg:nuget/Newtonsoft.Json@13.0.3' + + @pytest.mark.parametrize('parent, name, source', [ + ('Assemblies', 'Newtonsoft.Json', 'https://www.nuget.org/api/v2'), + ('Assemblies', 'Newtonsoft.Json', 'https://nuget.org/api/v2'), + ('PIP', 'requests', 'https://pypi.python.org/simple'), + ]) + def test_the_other_spellings_of_a_default_registry(self, parent, name, source): + component = component_for(name, '1.0.0', {'package_source': source}, parent=parent) + + assert '?' not in component['purl'] + assert source_property(component) == source + + def test_the_property_still_says_where_it_came_from(self): + """Without it, 'confirmed public' and 'nobody recorded a source' would look the same.""" + component = component_for('lodash', '4.17.21', {'package_source': NPM_PUBLIC + '/'}) + + assert source_property(component) == NPM_PUBLIC + + def test_a_default_host_of_another_type_is_not_a_default_here(self): + component = component_for('lodash', '4.17.21', {'package_source': 'https://pypi.org'}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21?repository_url=https:%2F%2Fpypi.org' + + +def emitted_purl_types(): + """Every purl type the generator can emit, read from the code that builds purls.""" + generator = sbom_cyclonedx_generator + tree = ast.parse(textwrap.dedent(inspect.getsource(generator.resolved_purl))) + types = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == 'pkgtype' + for target in node.targets): + if isinstance(node.value, ast.Constant): + types.add(node.value.value) + elif isinstance(node.value, ast.Name): + types.add(getattr(generator, node.value.id)) + types |= set(re.findall(r'pkg:([a-z]+)/', inspect.getsource(generator.maven_purl))) + types |= set(generator.PURL_TYPE_BY_REFERENCING_EXTENSION.values()) + return types + + +class TestEveryEmittedTypeIsClassified: + """A purl type missing from both tables would qualify its own public registry.""" + + def test_the_derivation_sees_every_branch(self): + assert {'npm', 'deb', 'pypi', 'golang', 'nuget', 'docker', 'maven', 'gem', + 'generic'} <= emitted_purl_types() + + def test_each_type_is_in_exactly_one_table(self): + with_default = set(sbom_cyclonedx_generator.DEFAULT_REPOSITORY_HOSTS) + without_default = sbom_cyclonedx_generator.NO_DEFAULT_REPOSITORY_TYPES + + assert not with_default & without_default + assert emitted_purl_types() <= with_default | without_default + + +class TestAnUnknownSourceStatesNothing: + def test_no_attribute(self): + """Absent is 'unknown'. A default would relabel every private package as public, which + is the exact failure this is meant to remove.""" + component = component_for('lodash', '4.17.21', {}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + @pytest.mark.parametrize('value', ['', ' ']) + def test_an_empty_attribute(self, value): + """purl gives an empty qualifier value the same meaning as no qualifier.""" + component = component_for('lodash', '4.17.21', {'package_source': value}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + @pytest.mark.parametrize('value', [ + '/home/alice/nuget-local', + 'C:\\Users\\alice\\nuget-local', + 'file:///home/alice/nuget-local', + 'registry.npmjs.org', + 'https://host:notaport/feed', + 'https://evil.example\\@registry.npmjs.org/', + ]) + def test_a_source_that_is_not_an_http_url(self, value): + """A local feed directory is not a repository URL, and publishing it would publish the + analysis host's paths and user names. A backslash is refused outright, because parsers + disagree about which host such a URL names.""" + component = component_for('lodash', '4.17.21', {'package_source': value}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + +class TestNothingSecretIsPublished: + def test_user_info_is_dropped(self): + component = component_for( + 'lodash', '4.17.21', + {'package_source': 'https://ci-user:s3cret-token@npm.internal.example/repo'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + assert 's3cret' not in json.dumps(component) + assert 'ci-user' not in json.dumps(component) + + def test_query_and_fragment_are_dropped(self): + component = component_for( + 'lodash', '4.17.21', + {'package_source': 'https://npm.internal.example/repo?token=s3cret#frag'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + assert 's3cret' not in json.dumps(component) + + def test_the_port_is_kept(self): + component = component_for('lodash', '4.17.21', + {'package_source': 'https://npm.internal.example:8443/repo'}) + + assert source_property(component) == 'https://npm.internal.example:8443/repo' + assert component['purl'].endswith('repository_url=https:%2F%2Fnpm.internal.example:8443' + '%2Frepo') + + def test_a_port_that_is_the_scheme_default_is_dropped(self): + """So 'host:443' and 'host' are one source when duplicates are merged.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://npm.internal.example:443/repo'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + + +class TestIdentityIsNotAffected: + def test_the_bom_ref_keeps_the_unqualified_purl(self): + """`dedup_key` folds on bom-ref and dependency edges resolve through it. A qualifier + there would split one package into one component per feed.""" + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['bom-ref'] == 'pkg:npm/lodash@4.17.21' + assert component['purl'] != component['bom-ref'] + + def test_the_component_type_is_still_read_from_the_purl_type(self): + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['type'] == 'library' + + +class TestTheValueIsEncodedAsTheStandardRequires: + def test_separators_in_the_path_are_encoded(self): + """'&' and '=' would split the qualifier segment. '/' is encoded as in every + repository_url example the standard gives, and '@' like every other separator character + outside its separator role. Only ':' stays raw.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://host.example/a&b=c/@d e'}) + + qualifier = component['purl'].split('?', 1)[1] + assert qualifier == 'repository_url=https:%2F%2Fhost.example%2Fa%26b%3Dc%2F%40d%20e' + + def test_an_already_escaped_path_is_escaped_again(self): + """The qualifier value is the URL string itself, so its '%' is data and must be encoded; + decoding the qualifier gives back the URL unchanged.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://host.example/feed%2Fx'}) + + assert component['purl'].endswith('repository_url=https:%2F%2Fhost.example%2Ffeed%252Fx') + + +class TestAVersionlessPurl: + def test_the_qualifier_follows_the_name_with_no_stray_at(self): + """Four of the five returns in resolved_purl emit a versionless purl. The qualifier has + to attach to all of them, and must not resurrect the trailing '@' those returns exist to + avoid.""" + component = component_for('lodash', 'https://github.com/x/y.git', + {'package_source': NPM_PRIVATE}) + + assert component['purl'].startswith('pkg:npm/lodash?repository_url=') + assert '@?' not in component['purl'] + # The raw value still reaches the consumer; only the purl declines to carry it. + assert component['version'] == 'https://github.com/x/y.git' + + +class TestAPurlThatAlreadyOpensASubpath: + def test_a_git_shaped_version_gets_no_qualifier(self): + """Versions are spliced in unencoded, so '#' already starts a subpath, and a qualifier + appended after it would be read as part of that subpath.""" + component = component_for('dep1', 'github:user/repo#abc123', + {'package_source': NPM_PRIVATE}) + + assert 'repository_url' not in component['purl'] + assert source_property(component) == NPM_PRIVATE + + +class TestTwoSourcesForOnePackage: + """One package, two checkouts, two sources. The document must not pick a winner.""" + + def test_the_package_is_still_one_component(self): + assert len(lodash_from_two_checkouts(NPM_PUBLIC, NPM_PRIVATE)) == 1 + + @pytest.mark.parametrize('first, second', [ + (NPM_PUBLIC, NPM_PRIVATE), + (NPM_PRIVATE, NPM_PUBLIC), + (NPM_PRIVATE, 'https://npm2.internal.example'), + ]) + def test_a_disagreement_drops_the_source_rather_than_choosing(self, first, second): + [lodash] = lodash_from_two_checkouts(first, second) + + assert lodash['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(lodash) is None + + @pytest.mark.parametrize('source', [NPM_PUBLIC, NPM_PRIVATE]) + def test_agreement_survives_the_merge(self, source): + [lodash] = lodash_from_two_checkouts(source, source) + + assert source_property(lodash) == source + assert lodash['purl'] == sbom_cyclonedx_generator.source_qualified_purl( + 'pkg:npm/lodash@4.17.21', source) + + def test_two_spellings_of_one_registry_agree(self): + [lodash] = lodash_from_two_checkouts(NPM_PRIVATE, NPM_PRIVATE + '/') + + assert source_property(lodash) == NPM_PRIVATE + + @pytest.mark.parametrize('first, second', [(NPM_PRIVATE, None), (None, NPM_PRIVATE)]) + def test_silence_on_one_side_does_not_corroborate_the_other(self, first, second): + """'unknown' is not agreement. The same retraction the repair provenance already makes + when any merged element did not support the claim.""" + [lodash] = lodash_from_two_checkouts(first, second) + + assert lodash['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(lodash) is None