diff --git a/ChangeLog.md b/ChangeLog.md index 5f996fc1..a55dfa22 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,12 @@ # CaPyCli - Clearing Automation Python Command Line Tool for SW360 +## NEXT + +* `bom map`: The options `--dbx` and `-all` were replaced by `--matchmode`. +* `bom map`: new `--matchmode` options `full-search` (report all best matches) and + `qualifier-match` (consider PackageURL qualifiers). See `Readme_Mapping.md`. + ## 2.9.1 * `bom map` will provide the `purl` from SW360 in the output BOM's components diff --git a/Readme_Mapping.md b/Readme_Mapping.md index aa7cc256..7b400369 100644 --- a/Readme_Mapping.md +++ b/Readme_Mapping.md @@ -23,7 +23,7 @@ informs about the mapping result: * **`INVALID` (0)** => Invalid SBOM entry, could not get processed * **`FULL_MATCH_BY_ID` (1)** => Full match by identifier -* **`FULL_MATCH_BY_HASH` (2)** => Full match by source file hash +* **`FULL_MATCH_BY_HASH` (2)** => Full match by source or binary file hash * **`FULL_MATCH_BY_NAME_AND_VERSION` (3)** => Full match by name and version * **`MATCH_BY_FILENAME` (4)** => Match by source code filename * **`GOOD_MATCH_FOUND`** == `MATCH_BY_FILENAME` => successfully found a sufficiently good match @@ -31,24 +31,50 @@ informs about the mapping result: * **`SIMILAR_COMPONENT_FOUND` (6)** => Component with similar name found, no version check done * **`NO_MATCH` (100)** => Component was not found -In general you can say that the lower the number, the better the match. +We consider lower numbers as better matches. By default, CaPyCli will stop the +search when a "good" match (match code between 1 and 4) is found and add this +release to the output BOM. If there are multiple good matches in SW360, the +output thus depends on the order the results are returned by SW360 (or found in +the CaPyCli cache). + +The "bom map --matchmode full-search" option allows to change that behaviour so that +CaPyCli will always search through all releases in the API answer or cache, and +report *all best* matches found. If there are matches by ID, other matches are +ignored; matches by (source or binary) file hash will win over matches by name +and version etc. ## Notes on id mapping / PackageURL mapping -CaPyCli supports mapping releases by the PackageURL. As encoding of a -PackageURL is not unique (some characters *may* use URL encoding, qualifiers +CaPyCli supports mapping **releases** by the PackageURL. As encoding of a +PackageURL is not unique (some characters may be percent-encoded, qualifiers can be given in random order etc.), we can't just do a string comparison, but instead *all* SW360 releases with PackageURLs (using external id `package-url`) are retrieved and decoded. When your input BOM specifies a `purl` field, then the PackageURL is compared field by field (type, namespace, name, version) for a `FULL_MATCH_BY_ID`. -Also, components will be mapped by PackageURL and if a match is found, the +Also, **components** will be mapped by PackageURL and if a match is found, the `capycli:componentId` property will be added to the output BOM item. Components can be identified directly by their external id `package-url` or as fallback also by the `package-url`s of their releases. -PackageURL subpath and qualifiers are currently ignored during PURL matching. +PackageURL **qualifiers** (like `?distro=alpine-3.21&package-id=3a23`) will be +considered when using `bom map --matchmode qualifier-match`. In some cases, +qualifiers are essential for correct mapping, but many scanners also include +non-essential qualifiers in their SBOMs. And the distinction might be +challenging: while `distro` is crucial for correct mapping of Alpine packages +(same package release can have different patches in different Alpine releases), +but for Debian, `distro` is unnecessary since package versions are already +unique. So we use the following rules to balance accuracy and practicality: + +* Only the qualifiers specified in the input BOM are considered during matching, + qualifiers only present in SW360 releases are ignored. So you can control + matching by removing the unwanted qualifiers in your SBOM. +* If one or more SW360 releases are found where *all* qualifiers specified in the + input BOM match, *only* these releases are added to the output BOM. Otherwise, + qualifiers will be ignored, so all release matches will be added. + +PackageURL subpath is currently ignored during PURL matching. ## Example 1: Very Simple, Full Match diff --git a/capycli/bom/create_components.py b/capycli/bom/create_components.py index 0f392cde..d6256a2c 100644 --- a/capycli/bom/create_components.py +++ b/capycli/bom/create_components.py @@ -399,7 +399,7 @@ def update_release(self, cx_comp: Component, release_data: Dict[str, Any]) -> No bom_purl = packageurl.PackageURL.from_string( data["externalIds"][repository_type]) sw360_purls = PurlUtils.get_purl_list_from_sw360_object(release_data) - id_match = PurlUtils.contains(sw360_purls, bom_purl) + id_match = PurlUtils.contains(sw360_purls, bom_purl, compare_qualifiers=True) except ValueError: pass if not id_match: diff --git a/capycli/bom/map_bom.py b/capycli/bom/map_bom.py index f082bbef..2a512181 100644 --- a/capycli/bom/map_bom.py +++ b/capycli/bom/map_bom.py @@ -6,6 +6,7 @@ # SPDX-License-Identifier: MIT # ------------------------------------------------------------------------------- +import copy import json import logging import os @@ -58,6 +59,8 @@ def __init__(self) -> None: self.mode = MapMode.ALL self.purl_service: Optional[PurlService] = None self.no_match_by_name_only = True + self.full_search = False + self.qualifier_match = False def is_id_match(self, release: Dict[str, Any], component: Component) -> bool: """Determines whether this release is a match via identifier for the specified SBOM item""" @@ -194,31 +197,22 @@ def map_bom_item(self, component: Component, check_similar: bool, result_require # first check: unique id if release["Sw360Id"] in result_release_ids or self.is_id_match(release, component): self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_ID) - break - - # second check: name AND version - if (component.name and release.get("Name")): - if release["ComponentId"] in result_component_ids: - name_match = True + if self.full_search: + continue else: - name_match = component.name.lower() == release["Name"].lower() - version_exists = "Version" in release - if (name_match - and version_exists and component.version - and (component.version.lower() == release["Version"].lower())): - self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_NAME_AND_VERSION) break - else: - name_match = False - # third check unique(?) file hashes + # second check unique(?) file hashes cmp_hash = CycloneDxSupport.get_source_file_hash(component) if (("SourceFileHash" in release) and cmp_hash and release["SourceFileHash"]): if (cmp_hash.lower() == release["SourceFileHash"].lower()): self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_HASH) - break + if self.full_search: + continue + else: + break cmp_hash = CycloneDxSupport.get_binary_file_hash(component) if (("BinaryFileHash" in release) @@ -226,7 +220,28 @@ def map_bom_item(self, component: Component, check_similar: bool, result_require and release["BinaryFileHash"]): if (cmp_hash.lower() == release["BinaryFileHash"].lower()): self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_HASH) - break + if self.full_search: + continue + else: + break + + # third check: name AND version + if (component.name and release.get("Name")): + if release["ComponentId"] in result_component_ids: + name_match = True + else: + name_match = component.name.lower() == release["Name"].lower() + version_exists = "Version" in release + if (name_match + and version_exists and component.version + and (component.version.lower() == release["Version"].lower())): + self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_NAME_AND_VERSION) + if self.full_search: + continue + else: + break + else: + name_match = False # fourth check: source filename cmp_src_file = CycloneDxSupport.get_ext_ref_source_file(component) @@ -235,7 +250,10 @@ def map_bom_item(self, component: Component, check_similar: bool, result_require and release["SourceFile"]): if cmp_src_file.lower() == release["SourceFile"].lower(): self.add_match_if_better(result, release, MapResult.MATCH_BY_FILENAME) - break + if self.full_search: + continue + else: + break # fifth check: name and ANY version if name_match: @@ -299,8 +317,10 @@ def get_release_details(href: str) -> Optional[Dict[str, Any]]: release = get_release_details(href) if release: self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_ID) - # If we have release matches by PURL, we're done - return result + if not self.full_search: + return result + # If we have release matches by PURL, we're done + return result if result.component_hrefs: components += result.component_hrefs @@ -343,22 +363,17 @@ def get_release_details(href: str) -> Optional[Dict[str, Any]]: self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_ID) break - # second check: name AND version (we don't need to check the name - # again as we checked it when compiling component list) - version_exists = "Version" in release - if (version_exists - and ((component.version or "").lower() == release.get("Version", "").lower())): - self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_NAME_AND_VERSION) - break - - # third check unique(?) file hashes + # second check unique(?) file hashes cmp_hash = CycloneDxSupport.get_source_file_hash(component) if (("SourceFileHash" in release) and cmp_hash and release["SourceFileHash"]): if (cmp_hash.lower() == release["SourceFileHash"].lower()): self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_HASH) - break + if self.full_search: + continue + else: + break cmp_hash = CycloneDxSupport.get_binary_file_hash(component) if (("BinaryFileHash" in release) @@ -366,6 +381,20 @@ def get_release_details(href: str) -> Optional[Dict[str, Any]]: and release["BinaryFileHash"]): if (cmp_hash.lower() == release["BinaryFileHash"].lower()): self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_HASH) + if self.full_search: + continue + else: + break + + # third check: name AND version (we don't need to check the name + # again as we checked it when compiling component list) + version_exists = "Version" in release + if (version_exists + and ((component.version or "").lower() == release.get("Version", "").lower())): + self.add_match_if_better(result, release, MapResult.FULL_MATCH_BY_NAME_AND_VERSION) + if self.full_search: + continue + else: break # fifth check: name and ANY version @@ -506,6 +535,9 @@ def update_bom_item(self, component: Optional[Component], match: Dict[str, Any]) name=match.get("Name", ""), version=match.get("Version", "")) else: + # copy component so we don't overwrite the input component + component = copy.deepcopy(component) + # always overwrite the following properties name = match.get("Name", "") if name: @@ -730,7 +762,9 @@ def map_bom_commons(self, component: Component) -> MapResult: # search release and component by purl which is independent of the component cache. if component.purl: result.component_hrefs = self.external_id_svc.search_components_by_purl(component.purl) - result.release_hrefs = self.external_id_svc.search_releases_by_purl(component.purl) + r = self.external_id_svc.search_releases_by_purl(component.purl, self.qualifier_match) + result.release_hrefs = r["hrefs"] + result.release_hrefs_results = r["results"] return result @@ -815,9 +849,14 @@ def show_help(self) -> None: print(" all = default, write everything to resulting SBOM") print(" found = resulting SBOM shows only components that were found") print(" notfound = resulting SBOM shows only components that were not found") - print(" --dbx relaxed Debian version handling: *completely* ignore Debian revision,") - print(" so SBOM version 3.1 will match SW360 version 3.1-3.debian") - print(" -all also report matches for name, but different version") + print(" --matchmode MATCHMODE matching mode, comma separated list of:") + print(" full-search = report best matches, don't abort on first match (recommended)") + print(" all-versions = also report matches for name, but different version") + print(" qualifier-match = consider qualifiers for PURL matching") + print(" ignore-debian = ignore Debian revision in version comparison, so SBOM") + print(" version 3.1 will match SW360 version 3.1-3.debian") + print(" -all deprecated, please use --matchmode all-versions") + print(" --dbx deprecated, please use --matchmode ignore-debian") def run(self, args: Any) -> None: """Main method()""" @@ -849,16 +888,29 @@ def run(self, args: Any) -> None: if args.verbose: self.verbosity = 2 - if args.dbx: + if not args.matchmode: + args.matchmode = "" + + if "ignore-debian" in args.matchmode or args.dbx: + if args.dbx: + print_yellow("bom map --dbx is deprecated, use --matchmode ignore-debian instead") print_text("Using relaxed debian version checks") self.relaxed_debian_parsing = True if args.mode: self.mode = args.mode - if args.all: + if "all-versions" in args.matchmode or args.all: + if args.all: + print_yellow("bom map -all is deprecated, use --matchmode all-versions instead") self.no_match_by_name_only = False + if "full-search" in args.matchmode: + self.full_search = True + + if "qualifier-match" in args.matchmode: + self.qualifier_match = True + print_text("Loading SBOM file", args.inputfile) try: sbom = CaPyCliBom.read_sbom(args.inputfile) diff --git a/capycli/common/capycli_bom_support.py b/capycli/common/capycli_bom_support.py index d3ff8bab..22e5ba58 100644 --- a/capycli/common/capycli_bom_support.py +++ b/capycli/common/capycli_bom_support.py @@ -60,6 +60,7 @@ class CycloneDxSupport(): CDX_PROP_COMPONENT_ID = "capycli:componentId" CDX_PROP_FILENAME = "siemens:filename" CDX_PROP_MAPRESULT = "capycli:mapResult" + CDX_PROP_MAPRESULT_BY_ID = "capycli:mapResultById" CDX_PROP_SW360_HREF = "capycli:sw360Href" CDX_PROP_SW360_URL = "capycli:sw360Url" CDX_PROP_REL_STATE = "capycli:releaseMainlineState" diff --git a/capycli/common/map_result.py b/capycli/common/map_result.py index 653a29fd..f2cc235b 100644 --- a/capycli/common/map_result.py +++ b/capycli/common/map_result.py @@ -7,12 +7,20 @@ # ------------------------------------------------------------------------------- from typing import Any, List, Optional +from enum import Enum from cyclonedx.model.component import Component from capycli.common.capycli_bom_support import CycloneDxSupport +class MapResultByIdQualifiers(Enum): + FULL_MATCH = "qualifiers-full-match" + IGNORED = "qualifiers-ignored" + UNKNOWN = "qualifiers-unknown-match" + NO_QUALIFIER_MAPPING = "" + + class MapResult: """Result of mapping a SBOM item to the list of releases""" @@ -50,8 +58,22 @@ def __init__(self, component: Optional[Component] = None) -> None: self.result: str = MapResult.NO_MATCH self._component_hrefs: List[str] = [] self._release_hrefs: List[str] = [] + self._release_hrefs_results: List[str] = [] self.releases: List[Any] = [] + @property + def release_hrefs_results(self) -> list[str]: + return self._release_hrefs_results + + @release_hrefs_results.setter + def release_hrefs_results(self, value: list[str]) -> None: + self._release_hrefs_results = value + if not self.input_component or not value: + return + CycloneDxSupport.update_or_set_property( + self.input_component, CycloneDxSupport.CDX_PROP_MAPRESULT_BY_ID, + " ".join(value)) + @property def component_hrefs(self) -> List[str]: return self._component_hrefs diff --git a/capycli/common/purl_service.py b/capycli/common/purl_service.py index 727979fd..b1415545 100644 --- a/capycli/common/purl_service.py +++ b/capycli/common/purl_service.py @@ -60,7 +60,10 @@ def build_purl_cache(self, purl_types: Any = tuple(), no_warnings: bool = True) if purl_types and purl.type not in purl_types: continue if not no_warnings: - for e in self.purl_cache.get_by_version(purl): + already_in_cache = self.purl_cache.get_by_version(purl) + _, already_in_cache = PurlStore.filter_by_qualifiers( + already_in_cache, purl) + for e in already_in_cache: if e["purl"] == purl: print_yellow("-> Multiple entries for purl:", purl) print_yellow( @@ -78,14 +81,18 @@ def build_purl_cache(self, purl_types: Any = tuple(), no_warnings: bool = True) print_yellow("-> Ignoring invalid purl entry in", entry["_links"]["self"]["href"]) print_yellow(purl_string) - def search_releases_by_purl(self, purl: packageurl.PackageURL) -> List[str]: + def search_releases_by_purl(self, purl: packageurl.PackageURL, qualifier_match: bool = False) -> Dict[str, Any]: """Get SW360 releases by Package URL using the purl cache - :return: list of release urls + :return: tuple of release hrefs and list of notes about mapping """ self.build_purl_cache((purl.type,)) result = self.purl_cache.get_by_version(purl) + if qualifier_match: + qualifier_result, result = PurlStore.filter_by_qualifiers(result, purl) + else: + qualifier_result = None unique_hrefs = {r["href"] for r in result} if len(unique_hrefs) > 1: @@ -94,7 +101,12 @@ def search_releases_by_purl(self, purl: packageurl.PackageURL) -> List[str]: print_yellow(" Candidate", self.client.get_id_from_href(r["href"]), "has purl", r["purl"]) - return list(unique_hrefs) + search_result = { + "hrefs": list(unique_hrefs), + # can be extended with more details in the future + "results": [qualifier_result.value] if (qualifier_result and qualifier_result.value) else [] + } + return search_result def search_components_by_purl(self, purl: packageurl.PackageURL) -> List[str]: """ diff --git a/capycli/common/purl_store.py b/capycli/common/purl_store.py index 4d3f9e66..c3d02295 100644 --- a/capycli/common/purl_store.py +++ b/capycli/common/purl_store.py @@ -6,9 +6,10 @@ # SPDX-License-Identifier: MIT # ------------------------------------------------------------------------------- -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from packageurl import PackageURL +from capycli.common.map_result import MapResultByIdQualifiers class PurlStore: @@ -84,3 +85,27 @@ def get_by_version(self, purl: PackageURL) -> List[Dict[str, Any]]: return entries[purl.version] return [] + + @staticmethod + def filter_by_qualifiers(entries: List[Dict[str, Any]], purl: PackageURL) -> Tuple[MapResultByIdQualifiers, + List[Dict[str, Any]]]: + """ + Filter entries based on the qualifiers in the given PackageURL and return the match type. + + :param entries: A list of entries to filter as returned by get_by_version. + :param purl: The PackageURL object containing qualifiers to match. + :return: A tuple (qualifier_result, list of entries) + """ + if not purl.qualifiers or len(entries) == 0: + return MapResultByIdQualifiers.NO_QUALIFIER_MAPPING, entries + + assert isinstance(purl.qualifiers, dict) + qualifiers_items = purl.qualifiers.items() + filtered_entries = [ + entry for entry in entries + if all(entry["purl"].qualifiers.get(key) == value for key, value in qualifiers_items) + ] + + if filtered_entries: + return MapResultByIdQualifiers.FULL_MATCH, filtered_entries + return MapResultByIdQualifiers.IGNORED, entries diff --git a/capycli/common/purl_utils.py b/capycli/common/purl_utils.py index baac5c50..a03d657f 100644 --- a/capycli/common/purl_utils.py +++ b/capycli/common/purl_utils.py @@ -56,17 +56,23 @@ def parse_purls_from_external_id(purl_entries: Any) -> list: # type: ignore return [] @staticmethod - def contains(purls: list, search_purl: packageurl.PackageURL) -> bool: # type: ignore + def contains(purls: list, search_purl: packageurl.PackageURL, # type: ignore + compare_qualifiers: bool = False) -> bool: """ Search the given PackageURL in the provided list Important: The matching is only based on type, namespace, name and version. - We do not consider qualifiers and subpath. + If `compare_qualifiers` is set, the qualifiers present in the search_purl are also checked. + We do not consider other qualifiers and subpath. """ for entry in purls: if (entry.type == search_purl.type and entry.namespace == search_purl.namespace and entry.name == search_purl.name and entry.version == search_purl.version): + if compare_qualifiers and isinstance(search_purl.qualifiers, dict): + for key, value in search_purl.qualifiers.items(): + if key not in entry.qualifiers or entry.qualifiers[key] != value: + return False return True return False diff --git a/capycli/main/options.py b/capycli/main/options.py index 9e886140..4421cad8 100644 --- a/capycli/main/options.py +++ b/capycli/main/options.py @@ -279,7 +279,7 @@ def register_options(self) -> None: help="source folder or additional source file" ) - # special parsing flag for MapBom + # special parsing flag for BomCreateComponents self.parser.add_argument( "--dbx", dest="dbx", @@ -360,6 +360,14 @@ def register_options(self) -> None: help="specific mode for some commands", ) + # special flag for MapBom + self.parser.add_argument( + "--matchmode", + dest="matchmode", + default="", + help="see \"bom map --help\"", + ) + # used by bom convert self.parser.add_argument( "-if", diff --git a/tests/test_base.py b/tests/test_base.py index ef4df4e0..db75db4a 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -35,6 +35,7 @@ def __init__(self) -> None: self.help: bool = False self.id: str = "" self.inputfile: str = "" + self.matchmode: str = "" self.name: str = "" self.ncli: bool = False self.nconf: bool = False diff --git a/tests/test_bom_map2.py b/tests/test_bom_map2.py index 357e7eb9..3f615cd8 100644 --- a/tests/test_bom_map2.py +++ b/tests/test_bom_map2.py @@ -11,7 +11,7 @@ from typing import Any, Dict import responses -from cyclonedx.model import ExternalReferenceType, XsUri +from cyclonedx.model import ExternalReference, ExternalReferenceType, XsUri, HashType, HashAlgorithm from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component from packageurl import PackageURL @@ -225,13 +225,125 @@ def test_map_bom_item_purl_release_conflict(self) -> None: # component found by PURL, version match by string comparison res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) assert res.result == MapResult.FULL_MATCH_BY_ID + assert len(res.releases) == 1 + + self.app.full_search = True + + res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) + assert res.result == MapResult.FULL_MATCH_BY_ID + assert len(res.releases) == 2 if res.releases[0]["Sw360Id"] == "1234": assert res.releases[0]["ComponentId"] == "a035" + assert res.releases[1]["Sw360Id"] == "1236" + assert res.releases[1]["ComponentId"] == "a034" elif res.releases[0]["Sw360Id"] == "1236": assert res.releases[0]["ComponentId"] == "a034" + assert res.releases[1]["Sw360Id"] == "1234" + assert res.releases[1]["ComponentId"] == "a035" else: assert False, "Unexpected release id" - assert len(res.releases) == 1 + + @responses.activate + def test_map_bom_item_purl_release_w_qualifiers(self) -> None: + """test bom mapping: search for releases by PURL with qualifiers + """ + if not self.app.client: + return + + self.app.purl_service = PurlService(self.app.client, cache={'maven': { + 'com.fasterxml.jackson.core': {'jackson-core': { + None: [{ + "purl": PackageURL("maven", "com.fasterxml.jackson.core", "jackson-core"), + "href": SW360_BASE_URL + "components/a035"}], + "2.18.0": [ + {"purl": PackageURL("maven", "com.fasterxml.jackson.core", "jackson-core", version="2.18.0", + qualifiers={"classifier": "sources"}), + "href": SW360_BASE_URL + "releases/1234"}, + {"purl": PackageURL("maven", "com.fasterxml.jackson.core", "jackson-core", version="2.18.0", + qualifiers={"classifier": "javadoc"}), + "href": SW360_BASE_URL + "releases/1235"}, + {"purl": PackageURL("maven", "com.fasterxml.jackson.core", "jackson-core", version="2.18.0", + qualifiers={"classifier": "sources", "packaging": "jar"}), + "href": SW360_BASE_URL + "releases/1236"}]}}}}) + + self.app.releases = [{"Id": "1234", "ComponentId": "a035", + "Name": "Jackson Core", "Version": "2.18.0", + "ExternalIds": { + "package-url": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.18.0" + "?classifier=sources"}}, + {"Id": "1235", "ComponentId": "a034", + "Name": "com.fasterxml.jackson.core:jackson-core", "Version": "2.18.0_javadoc", + "ExternalIds": { + "package-url": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.18.0" + "?classifier=javadoc"}}, + {"Id": "1236", "ComponentId": "a034", + "Name": "com.fasterxml.jackson.core:jackson-core", "Version": "2.18.0_jar", + "ExternalIds": { + "package-url": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.18.0" + "?classifier=sources&packaging=jar"}}] + + bomitem = Component( + name="jackson-core", + version="2.18.0", + purl=PackageURL.from_string("pkg:maven/com.fasterxml.jackson.core/jackson-core@2.18.0" + "?classifier=sources")) + + self.app.full_search = True + + # 3 matches when ignoring qualifiers + res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) + assert res.result == MapResult.FULL_MATCH_BY_ID + assert len(res.releases) == 3 + + self.app.qualifier_match = True + + # 2 matches for classifier=sources + bomitem.properties.clear() # resert properties to remove results from previous mapping + res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) + assert res.result == MapResult.FULL_MATCH_BY_ID + assert len(res.releases) == 2 + + if res.releases[0]["Sw360Id"] == "1234": + assert res.releases[0]["ComponentId"] == "a035" + assert res.releases[1]["Sw360Id"] == "1236" + assert res.releases[1]["ComponentId"] == "a034" + elif res.releases[0]["Sw360Id"] == "1236": + assert res.releases[0]["ComponentId"] == "a034" + assert res.releases[1]["Sw360Id"] == "1234" + assert res.releases[1]["ComponentId"] == "a035" + else: + assert False, "Unexpected release id" + assert res.input_component is not None + assert ( + CycloneDxSupport.get_property(res.input_component, CycloneDxSupport.CDX_PROP_MAPRESULT_BY_ID).value + == "qualifiers-full-match") + + self.app.qualifier_match = False + + # bomitem has unknown qualifier -> all PURL version matches returned + assert bomitem.purl is not None + assert type(bomitem.purl.qualifiers) is dict + bomitem.purl.qualifiers["themorequalifiers"] = "thebetter" + bomitem.properties.clear() # resert properties to remove results from previous mapping + res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) + assert res.result == MapResult.FULL_MATCH_BY_ID + assert len(res.releases) == 3 + all_results = [r["Sw360Id"] for r in res.releases] + assert all_results == ["1234", "1235", "1236"] + assert res.input_component is not None + assert ( + CycloneDxSupport.get_property(res.input_component, CycloneDxSupport.CDX_PROP_MAPRESULT_BY_ID) + is None) + + self.app.qualifier_match = True + + bomitem.properties.clear() # resert properties to remove results from previous mapping + res = self.app.map_bom_item(bomitem, check_similar=False, result_required=False) + assert len(res.releases) == 3 + assert res.input_component is not None + assert ( + CycloneDxSupport.get_property(res.input_component, CycloneDxSupport.CDX_PROP_MAPRESULT_BY_ID).value + == "qualifiers-ignored") @responses.activate def test_map_bom_item_mixed_match(self) -> None: @@ -288,6 +400,70 @@ def test_map_bom_item_mixed_match(self) -> None: assert res.result == MapResult.MATCH_BY_NAME assert len(res.releases) == 2 + @responses.activate + def test_map_bom_item_mixed_good_matches(self) -> None: + bomitem = Component( + name="mail", + version="1.4", + external_references=[ExternalReference( + type=ExternalReferenceType.DISTRIBUTION, + comment=CaPyCliBom.SOURCE_FILE_COMMENT, + url=XsUri("file:mail-1.4.tar.gz"))]) + + self.app.releases = [{"Id": "1111", "ComponentId": "b001", + "Name": "mail (Python)", "Version": "1.4", + "SourceFile": "file:mail-1.4.tar.gz", + "ExternalIds": {}}, + {"Id": "1112", "ComponentId": "b002", + "Name": "Mail", "Version": "1.4", + "ExternalIds": {}}] + + self.app.full_search = True + + res = self.app.map_bom_item(bomitem, False, False) + assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION + assert len(res.releases) == 1 + + self.app.releases = self.app.releases[::-1] # reverse order + + res = self.app.map_bom_item(bomitem, False, False) + assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION + assert len(res.releases) == 1 + + @responses.activate + def test_map_bom_item_multiple_equal_good_matches(self) -> None: + bomitem = Component( + name="mail", + version="1.4", + external_references=[ExternalReference( + type=ExternalReferenceType.DISTRIBUTION, + comment=CaPyCliBom.SOURCE_FILE_COMMENT, + url=XsUri("file:mail-1.4.tar.gz"))]) + + self.app.releases = [{"Id": "1111", "ComponentId": "b001", + "Name": "mail", "Version": "1.4", + "SourceFile": "file:mail-1.4.tar.gz", + "ExternalIds": {}}, + {"Id": "1112", "ComponentId": "b002", + "Name": "Mail", "Version": "1.4", + "ExternalIds": {}}] + + res = self.app.map_bom_item(bomitem, False, False) + assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION + assert len(res.releases) == 1 + + self.app.full_search = True + + res = self.app.map_bom_item(bomitem, False, False) + assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION + assert len(res.releases) == 2 + + self.app.releases = self.app.releases[::-1] # reverse order + + res = self.app.map_bom_item(bomitem, False, False) + assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION + assert len(res.releases) == 2 + @responses.activate def test_map_bom_item_mixed_match_similar(self) -> None: """test mixed match with name match and similar name match""" @@ -408,6 +584,173 @@ def test_map_bom_item_nocache_mixed_match(self) -> None: assert res.result == MapResult.FULL_MATCH_BY_NAME_AND_VERSION assert len(res.releases) == 1 + @responses.activate + def test_map_bom_item_nocache_mixed_good_matches(self) -> None: + """Test mixed match with two good matches (version match and source file match)""" + + bomitem = Component( + name="mail", version="1.4", external_references=[ExternalReference( + type=ExternalReferenceType.DISTRIBUTION, + comment=CaPyCliBom.SOURCE_FILE_COMMENT, + url=XsUri("file:mail-1.4.tar.gz"), + hashes=[HashType(alg=HashAlgorithm.SHA_1, + content="0c9ab87312fa065a06bd68b050671c1d290b9559")])]) + + component_matches = {"_embedded": {"sw360:components": [ + {"name": "mail", + "_links": {"self": {"href": SW360_BASE_URL + 'components/b001'}}}, + {"name": "Mail", + "_links": {"self": {"href": SW360_BASE_URL + 'components/b002'}}}]}} + component_data1 = {"_embedded": {"sw360:releases": [{ + "_links": {"self": {"href": SW360_BASE_URL + 'releases/1111'}}}]}} + component_data2 = {"_embedded": {"sw360:releases": [{ + "_links": {"self": {"href": SW360_BASE_URL + 'releases/1112'}}}]}} + release_data1 = { + "name": "mail", "version": "1.4_pypi", + "_links": { + "self": {"href": SW360_BASE_URL + 'releases/1111'}, + "sw360:component": {"href": SW360_BASE_URL + "components/b001"}}, + "_embedded": {'sw360:attachments': [ + {'filename': 'mail-1.4.tar.gz', 'sha1': '0c9ab87312fa065a06bd68b050671c1d290b9559', + 'attachmentType': 'SOURCE', + '_links': {'self': {'href': SW360_BASE_URL + 'attachments/6f1e'}}}]}} + release_data2 = {"name": "Mail", "version": "1.4", "_links": { + "self": {"href": SW360_BASE_URL + 'releases/1112'}, + "sw360:component": {"href": SW360_BASE_URL + "components/b002"}}} + + # release matches in two components + responses.add(responses.GET, SW360_BASE_URL + 'components?name=mail', + json=component_matches) + responses.add(responses.GET, SW360_BASE_URL + 'components/b001', + json=component_data1) + responses.add(responses.GET, SW360_BASE_URL + 'components/b002', + json=component_data2) + responses.add(responses.GET, SW360_BASE_URL + 'releases/1111', + json=release_data1) + responses.add(responses.GET, SW360_BASE_URL + 'releases/1112', + json=release_data2) + + self.app.full_search = True + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 1 + + # reverse component order + component_matches["_embedded"]["sw360:components"] = component_matches["_embedded"]["sw360:components"][::-1] + responses.add(responses.GET, SW360_BASE_URL + 'components?name=mail', + json=component_matches) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 1 + + # release matches in one component + component_data1["_embedded"]["sw360:releases"] += component_data2["_embedded"]["sw360:releases"] + component_data2["_embedded"]["sw360:releases"] = [] + responses.replace(responses.GET, SW360_BASE_URL + 'components/b001', json=component_data1) + responses.replace(responses.GET, SW360_BASE_URL + 'components/b002', json=component_data2) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 1 + + # reverse release order + component_data1["_embedded"]["sw360:releases"] = component_data1["_embedded"]["sw360:releases"][::-1] + responses.replace(responses.GET, SW360_BASE_URL + 'components/b001', json=component_data1) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 1 + + @responses.activate + def test_map_bom_item_nocache_multiple_equal_good_matches(self) -> None: + """Test mixed match with two good matches (version match and source file match)""" + + bomitem = Component( + name="mail", version="1.4", external_references=[ExternalReference( + type=ExternalReferenceType.DISTRIBUTION, + comment=CaPyCliBom.SOURCE_FILE_COMMENT, + url=XsUri("file:mail-1.4.tar.gz"), + hashes=[HashType(alg=HashAlgorithm.SHA_1, + content="0c9ab87312fa065a06bd68b050671c1d290b9559")])]) + + component_matches = {"_embedded": {"sw360:components": [ + {"name": "mail", + "_links": {"self": {"href": SW360_BASE_URL + 'components/b001'}}}, + {"name": "Mail", + "_links": {"self": {"href": SW360_BASE_URL + 'components/b002'}}}]}} + component_data1 = {"_embedded": {"sw360:releases": [{ + "_links": {"self": {"href": SW360_BASE_URL + 'releases/1111'}}}]}} + component_data2 = {"_embedded": {"sw360:releases": [{ + "_links": {"self": {"href": SW360_BASE_URL + 'releases/1112'}}}]}} + release_data1 = { + "name": "mail", "version": "1.4", + "_links": { + "self": {"href": SW360_BASE_URL + 'releases/1111'}, + "sw360:component": {"href": SW360_BASE_URL + "components/b001"}}, + "_embedded": {'sw360:attachments': [ + {'filename': 'mail-1.4.tar.gz', 'sha1': '0c9ab87312fa065a06bd68b050671c1d290b9559', + 'attachmentType': 'SOURCE', + '_links': {'self': {'href': SW360_BASE_URL + 'attachments/6f1e'}}}]}} + release_data2 = {"name": "Mail", "version": "1.4", "_links": { + "self": {"href": SW360_BASE_URL + 'releases/1112'}, + "sw360:component": {"href": SW360_BASE_URL + "components/b002"}}, + "_embedded": {'sw360:attachments': [ + {'filename': 'Mail_1.4.tar.gz', 'sha1': '0c9ab87312fa065a06bd68b050671c1d290b9559', + 'attachmentType': 'SOURCE', + '_links': {'self': {'href': SW360_BASE_URL + 'attachments/aa2b'}}}]} + } + + # release matches in two components + responses.add(responses.GET, SW360_BASE_URL + 'components?name=mail', + json=component_matches) + responses.add(responses.GET, SW360_BASE_URL + 'components/b001', + json=component_data1) + responses.add(responses.GET, SW360_BASE_URL + 'components/b002', + json=component_data2) + responses.add(responses.GET, SW360_BASE_URL + 'releases/1111', + json=release_data1) + responses.add(responses.GET, SW360_BASE_URL + 'releases/1112', + json=release_data2) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 2 + + # reverse component order + component_matches["_embedded"]["sw360:components"] = component_matches["_embedded"]["sw360:components"][::-1] + responses.add(responses.GET, SW360_BASE_URL + 'components?name=mail', + json=component_matches) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 2 + + # release matches in one component + component_data1["_embedded"]["sw360:releases"] += component_data2["_embedded"]["sw360:releases"] + component_data2["_embedded"]["sw360:releases"] = [] + responses.replace(responses.GET, SW360_BASE_URL + 'components/b001', json=component_data1) + responses.replace(responses.GET, SW360_BASE_URL + 'components/b002', json=component_data2) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 1 + + self.app.full_search = True + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 2 + + # reverse release order + component_data1["_embedded"]["sw360:releases"] = component_data1["_embedded"]["sw360:releases"][::-1] + responses.replace(responses.GET, SW360_BASE_URL + 'components/b001', json=component_data1) + + res = self.app.map_bom_item_no_cache(bomitem) + assert res.result == MapResult.FULL_MATCH_BY_HASH + assert len(res.releases) == 2 + @responses.activate def test_map_bom_item_nocache_invalid_version(self) -> None: bomitem = Component( @@ -1091,10 +1434,16 @@ def test_mapping_multiple_match_by_id(self) -> None: ) out = TestBase.capture_stdout(sut.run, args) + sbom = CaPyCliBom.read_sbom(self.OUTPUTFILE) + assert len(sbom.components) == 1 assert "1 component read from SBOM" in out assert "Retrieving package-url ids, filter: {'pypi'}" in out - assert ("ADDED (1-full-match-by-id) 3765276512" in out - or "ADDED (1-full-match-by-id) 1234" in out) + + args.matchmode = "full-search" + out = TestBase.capture_stdout(sut.run, args) + + assert "ADDED (1-full-match-by-id) 3765276512" in out + assert "ADDED (1-full-match-by-id) 1234" in out assert "Release 3765276512 with purl pkg:pypi/colorama@0.4.3 points to component 678dstzd8" in out assert "Release 1234 with purl pkg:pypi/colorama@0.4.3 points to component 12345678" in out assert "Candidate 3765276512 has purl pkg:pypi/colorama@0.4.3" in out @@ -1103,20 +1452,29 @@ def test_mapping_multiple_match_by_id(self) -> None: # check result BOM sbom = CaPyCliBom.read_sbom(self.OUTPUTFILE) assert sbom is not None - assert len(sbom.components) == 1 + assert len(sbom.components) == 2 assert sbom.components[0].version == "0.4.3" + assert sbom.components[1].version == "0.4.3" # assure we get the PURL from the release, not from input BOM # (which has PURL with file_name qualifier) assert sbom.components[0].purl == PackageURL.from_string("pkg:pypi/colorama@0.4.3") + assert sbom.components[1].purl == PackageURL.from_string("pkg:pypi/colorama@0.4.3") prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_MAPRESULT) assert prop == MapResult.FULL_MATCH_BY_ID - prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_COMPONENT_ID) - if prop == "678dstzd8": - prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_SW360ID) - assert prop == "3765276512" - elif prop == "12345678": + prop = CycloneDxSupport.get_property_value(sbom.components[1], CycloneDxSupport.CDX_PROP_MAPRESULT) + assert prop == MapResult.FULL_MATCH_BY_ID + + prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_SW360ID) + if prop == "3765276512": + prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_COMPONENT_ID) + assert prop == "678dstzd8" + assert sbom.components[0].name == "colorama" + assert sbom.components[1].name == "python-colorama" + elif prop == "1234": prop = CycloneDxSupport.get_property_value(sbom.components[0], CycloneDxSupport.CDX_PROP_SW360ID) - assert prop == "1234" + assert prop == "12345678" + assert sbom.components[0].name == "python-colorama" + assert sbom.components[1].name == "colorama" else: assert False, "Unexpected component id: " + prop diff --git a/tests/test_purl_service.py b/tests/test_purl_service.py index 7dbb32dc..3328333b 100644 --- a/tests/test_purl_service.py +++ b/tests/test_purl_service.py @@ -14,6 +14,7 @@ from capycli.bom.map_bom import MapBom from capycli.common.purl_service import PurlService +from capycli.common.map_result import MapResultByIdQualifiers from tests.test_base import SW360_BASE_URL sw360_purl_releases: List[Dict[str, Any]] = [ @@ -139,8 +140,9 @@ def test_multiple_purls(self) -> None: # returns all candidates including duplicates res = purl_service.search_releases_by_purl(PackageURL("deb", "debian", "sed", "4.4+1~2")) # all purls point to the same release - assert len(res) == 1 - assert res[0] == sw360_purl_releases[0]["_links"]["self"]["href"] + assert len(res["hrefs"]) == 1 + assert res["hrefs"][0] == sw360_purl_releases[0]["_links"]["self"]["href"] + assert res["results"] == [] # returns all candidates including duplicates res = purl_service.search_components_by_purl(PackageURL("deb", "debian", "sed")) @@ -179,7 +181,8 @@ def test_purl_duplicates(self) -> None: purl_service.build_purl_cache() # returns all candidates including duplicates res = purl_service.search_releases_by_purl(PackageURL("gem", name="mini_portile2", version="2.4.0")) - assert len(res) == 2 + assert len(res["hrefs"]) == 2 + assert res["results"] == [] # returns all candidates including duplicates res = purl_service.search_components_by_purl(PackageURL("deb", "debian", "sed", @@ -201,11 +204,14 @@ def test_purl_duplicates(self) -> None: purl_service = PurlService(self.app.client) purl_service.build_purl_cache() res = purl_service.search_releases_by_purl(PackageURL("gem", name="mini_portile2", version="2.4.0")) - assert len(res) == 2 + assert len(res["hrefs"]) == 2 + assert res["results"] == [] res = purl_service.search_releases_by_purl(PackageURL("deb", "debian", "sed", "4.4+1~2", - qualifiers={"type": "source"})) - assert len(res) == 1 - assert res[0] == sw360_purl_releases[0]["_links"]["self"]["href"] + qualifiers={"type": "source"}), + qualifier_match=True) + assert len(res["hrefs"]) == 1 + assert res["results"] == [MapResultByIdQualifiers.FULL_MATCH.value] + assert res["hrefs"][0] == sw360_purl_releases[0]["_links"]["self"]["href"] res = purl_service.search_components_by_purl(PackageURL("deb", "debian", "sed", qualifiers={"type": "source"})) @@ -243,8 +249,10 @@ def test_purl_search_release(self) -> None: purl_service = self.purl_build_cache() res = purl_service.search_releases_by_purl( - PackageURL("deb", "debian", "sed", "4.4+1~2", qualifiers={"type": "source"})) - assert res[0] == sw360_purl_releases[0]["_links"]["self"]["href"] + PackageURL("deb", "debian", "sed", "4.4+1~2", qualifiers={"type": "source"}), + qualifier_match=True) + assert res["hrefs"][0] == sw360_purl_releases[0]["_links"]["self"]["href"] + assert res["results"] == [MapResultByIdQualifiers.FULL_MATCH.value] @responses.activate def test_purl_search_component(self) -> None: @@ -353,7 +361,7 @@ def test_purl_search_component_and_release(self) -> None: self.assertIsNotNone(c) self.assertIsNotNone(r) self.assertEqual(c[0], "self/href/c1") - self.assertEqual(r[0], "self/href/r1") + self.assertEqual(r["hrefs"][0], "self/href/r1") if __name__ == "__main__": diff --git a/tests/test_purl_store.py b/tests/test_purl_store.py index fdc00027..f6364c71 100644 --- a/tests/test_purl_store.py +++ b/tests/test_purl_store.py @@ -10,6 +10,7 @@ from capycli.common.purl_store import PurlStore from tests.test_base import TestBase +from capycli.common.map_result import MapResultByIdQualifiers class TestPurlStore(TestBase): @@ -57,11 +58,12 @@ def test_add_duplicate_version(self) -> None: self.assertEqual(cache_entries[0]["purl"].qualifiers["classifier"], "sources") self.assertEqual(cache_entries[1]["href"], entry2) self.assertEqual(cache_entries[1]["purl"].qualifiers["classifier"], "dist") - res = sut.get_by_version(purl1) - if res is None: - self.fail("Expected results to be not None") - else: - self.assertEqual(len(res), 2) + for p in (purl1, purl2): + res = sut.get_by_version(p) + if res is None: + self.fail("Expected results to be not None for", p) + else: + self.assertEqual(len(res), 2) def test_add_duplicate_qualifiers(self) -> None: sut = PurlStore() @@ -80,3 +82,77 @@ def test_add_duplicate_qualifiers(self) -> None: self.fail("Expected results to be not None") else: self.assertEqual(len(res), 2) + + def test_get_with_filter_qualifiers(self) -> None: + sut = PurlStore() + + purl1 = packageurl.PackageURL.from_string("pkg:maven/test/test@1?classifier=sources") + entry1 = "https://sw360.org/api/releases/123" + sut.add(purl1, entry1) + + entry1_duplicate = "https://sw360.org/api/releases/124" + sut.add(purl1, entry1_duplicate) + + purl2 = packageurl.PackageURL.from_string("pkg:maven/test/test@1?classifier=dist&type=zip") + entry2 = "https://sw360.org/api/releases/456" + sut.add(purl2, entry2) + + purl3 = packageurl.PackageURL.from_string("pkg:maven/test/test@1") + entry3 = "https://sw360.org/api/releases/789" + sut.add(purl3, entry3) + + # For known qualifiers, we should get the full match(es) + entries = sut.get_by_version(purl1) + result, entries = sut.filter_by_qualifiers(entries, purl1) + self.assertEqual(len(entries), 2) + hrefs = {entry["href"]: entry for entry in entries} + self.assertIn(entry1, hrefs) + self.assertEqual(hrefs[entry1]["purl"].qualifiers["classifier"], "sources") + self.assertIn(entry1_duplicate, hrefs) + self.assertEqual(hrefs[entry1_duplicate]["purl"].qualifiers["classifier"], "sources") + assert result == MapResultByIdQualifiers.FULL_MATCH + + entries = sut.get_by_version(purl2) + result, entries = sut.filter_by_qualifiers(entries, purl2) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["href"], entry2) + self.assertEqual(entries[0]["purl"].qualifiers["classifier"], "dist") + assert result == MapResultByIdQualifiers.FULL_MATCH + + # For the same version without qualifiers, we should get all entries + entries = sut.get_by_version(purl3) + result, entries = sut.filter_by_qualifiers(entries, purl3) + self.assertEqual(len(entries), 4) + hrefs = {entry["href"]: entry for entry in entries} + self.assertIn(entry1, hrefs) + self.assertEqual(hrefs[entry1]["purl"].qualifiers["classifier"], "sources") + self.assertIn(entry2, hrefs) + self.assertEqual(hrefs[entry2]["purl"].qualifiers["classifier"], "dist") + self.assertIn(entry3, hrefs) + self.assertEqual(hrefs[entry3]["purl"].qualifiers, {}) + self.assertIn(entry1_duplicate, hrefs) + self.assertEqual(hrefs[entry1_duplicate]["purl"].qualifiers["classifier"], "sources") + assert result == MapResultByIdQualifiers.NO_QUALIFIER_MAPPING + + # If all given qualifiers match, we should get the full match + purl4 = packageurl.PackageURL.from_string("pkg:maven/test/test@1?classifier=dist") + entries = sut.get_by_version(purl4) + result, entries = sut.filter_by_qualifiers(entries, purl4) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["href"], entry2) + self.assertEqual(entries[0]["purl"].qualifiers["classifier"], "dist") + assert result == MapResultByIdQualifiers.FULL_MATCH + + # For the same version with an unknown qualifier, we should get all entries + purl4 = packageurl.PackageURL.from_string("pkg:maven/test/test@1?classifier=x86") + entries = sut.get_by_version(purl4) + result, entries = sut.filter_by_qualifiers(entries, purl4) + self.assertEqual(len(entries), 4) + assert result == MapResultByIdQualifiers.IGNORED + + # Same if not all qualifiers match + purl5 = packageurl.PackageURL.from_string("pkg:maven/test/test@1?classifier=dist&type=jar") + entries = sut.get_by_version(purl5) + result, entries = sut.filter_by_qualifiers(entries, purl5) + self.assertEqual(len(entries), 4) + assert result == MapResultByIdQualifiers.IGNORED diff --git a/tests/test_purl_utils.py b/tests/test_purl_utils.py index 661e9b81..cc16ce43 100644 --- a/tests/test_purl_utils.py +++ b/tests/test_purl_utils.py @@ -102,3 +102,9 @@ def test_contains(self) -> None: input_purl = PackageURL.from_string("pkg:maven/org.springframework.boot/spring-boot-actuator@2.7.1?type=jar") search_purl = PackageURL.from_string("pkg:maven/org.springframework.boot/spring-boot-actuator@2.7.1") self.assertTrue(PurlUtils.contains([input_purl], search_purl)) + # we are only comparing qualifiers existing in search_purl + self.assertTrue(PurlUtils.contains([input_purl], search_purl, compare_qualifiers=True)) + + search_purl = PackageURL.from_string("pkg:maven/org.springframework.boot/spring-boot-actuator@2.7.1?type=dist") + self.assertTrue(PurlUtils.contains([input_purl], search_purl)) + self.assertFalse(PurlUtils.contains([input_purl], search_purl, compare_qualifiers=True))