From 863f9148447d82544757ad802bdbe858ca7b2679 Mon Sep 17 00:00:00 2001 From: Taylor Madore Date: Fri, 10 Jan 2025 07:56:47 -0500 Subject: [PATCH 1/4] WIP: exclude_none=True for pydantic model_dump in tests Signed-off-by: Taylor Madore --- tests/unit/package_managers/test_generic.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/package_managers/test_generic.py b/tests/unit/package_managers/test_generic.py index a20b47e0b..87fb94a0d 100644 --- a/tests/unit/package_managers/test_generic.py +++ b/tests/unit/package_managers/test_generic.py @@ -255,7 +255,6 @@ def test_resolve_generic_lockfile_invalid( "properties": [{"name": "cachi2:found_by", "value": "cachi2"}], "purl": "pkg:generic/archive.zip?checksum=md5:3a18656e1cea70504b905836dee14db0&download_url=https://example.com/artifact", "type": "file", - "version": None, }, { "external_references": [ @@ -268,7 +267,6 @@ def test_resolve_generic_lockfile_invalid( "properties": [{"name": "cachi2:found_by", "value": "cachi2"}], "purl": "pkg:generic/file.tar.gz?checksum=md5:32112bed1914cfe3799600f962750b1d&download_url=https://example.com/more/complex/path/file.tar.gz%3Ffoo%3Dbar%23fragment", "type": "file", - "version": None, }, ], id="valid_lockfile", @@ -324,7 +322,8 @@ def test_resolve_generic_lockfile_valid( f.write(lockfile_content) assert [ - c.model_dump() for c in _resolve_generic_lockfile(lockfile_path.path, rooted_tmp_path) + c.model_dump(exclude_none=True) + for c in _resolve_generic_lockfile(lockfile_path.path, rooted_tmp_path) ] == expected_components mock_checksums.assert_called() From e48734989f490c91fd11811e3d2a14b01c1b37d3 Mon Sep 17 00:00:00 2001 From: Taylor Madore Date: Fri, 10 Jan 2025 08:03:02 -0500 Subject: [PATCH 2/4] WIP: handle optional, builtin patches for yarn v4 In yarn v4, optional, builtin patches are now denoted with the prefix `optional!`. Handle this in addition to the yarn v3 prefix for the same Signed-off-by: Taylor Madore --- cachi2/core/package_managers/yarn/locators.py | 4 +- .../package_managers/yarn/test_locators.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/cachi2/core/package_managers/yarn/locators.py b/cachi2/core/package_managers/yarn/locators.py index 53a74ffd2..08dedafbe 100644 --- a/cachi2/core/package_managers/yarn/locators.py +++ b/cachi2/core/package_managers/yarn/locators.py @@ -207,8 +207,8 @@ def _parse_patch_locator(locator: "_ParsedLocator") -> PatchLocator: # https://github.com/yarnpkg/berry/blob/b6026842dfec4b012571b5982bb74420c7682a73/packages/plugin-patch/sources/patchUtils.ts#L92 def process_patch_path(patch: str) -> Union[str, Path]: - # '~' denotes an optional patch (failing to apply the patch is not fatal, only a warning) - patch = patch.removeprefix("~") + # '~' or `optional!' denotes an optional patch (failing to apply the patch is not fatal, only a warning) + patch = patch.removeprefix("~").removeprefix("optional!") if re.match(r"^builtin<([^>]+)>$", patch): return patch else: diff --git a/tests/unit/package_managers/yarn/test_locators.py b/tests/unit/package_managers/yarn/test_locators.py index 4d2cc68f5..d1103936e 100644 --- a/tests/unit/package_managers/yarn/test_locators.py +++ b/tests/unit/package_managers/yarn/test_locators.py @@ -42,9 +42,11 @@ # optional custom patch for a registry dep "left-pad@npm:1.3.0", "left-pad@patch:left-pad@npm%3A1.3.0#~./my-patches/left-pad.patch::version=1.3.0&hash=629bda&locator=berryscary%40workspace%3A.", + "left-pad@patch:left-pad@npm%3A1.3.0#optional!./my-patches/left-pad.patch::version=1.3.0&hash=629bda&locator=berryscary%40workspace%3A.", # optional builtin patch "fsevents@npm:2.3.2", "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1", + "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1", # patched patch dependency "fsevents@patch:fsevents@patch%3Afsevents@npm%253A2.3.2%23./my-patches/fsevents.patch%3A%3Aversion=2.3.2&hash=cf0bf0&locator=berryscary%2540workspace%253A.#~builtin::version=2.3.2&hash=df0bf1", # non-optional builtin patch (in reality, the typescript patch is optional) @@ -201,6 +203,23 @@ }, ), ), + ( + _ParsedLocator( + scope=None, + name="left-pad", + raw_reference="patch:left-pad@npm%3A1.3.0#optional!./my-patches/left-pad.patch::version=1.3.0&hash=629bda&locator=berryscary%40workspace%3A.", + ), + _ParsedReference( + protocol="patch:", + source="left-pad@npm:1.3.0", + selector="optional!./my-patches/left-pad.patch", + params={ + "version": ["1.3.0"], + "hash": ["629bda"], + "locator": ["berryscary@workspace:."], + }, + ), + ), ( _ParsedLocator(scope=None, name="fsevents", raw_reference="npm:2.3.2"), _ParsedReference(protocol="npm:", source=None, selector="2.3.2", params=None), @@ -218,6 +237,19 @@ params={"version": ["2.3.2"], "hash": ["df0bf1"]}, ), ), + ( + _ParsedLocator( + scope=None, + name="fsevents", + raw_reference="patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1", + ), + _ParsedReference( + protocol="patch:", + source="fsevents@npm:2.3.2", + selector="optional!builtin", + params={"version": ["2.3.2"], "hash": ["df0bf1"]}, + ), + ), ( _ParsedLocator( scope=None, @@ -376,12 +408,22 @@ patches=(Path("my-patches/left-pad.patch"),), locator=WorkspaceLocator(scope=None, name="berryscary", relpath=Path(".")), ), + PatchLocator( + package=NpmLocator(scope=None, name="left-pad", version="1.3.0"), + patches=(Path("my-patches/left-pad.patch"),), + locator=WorkspaceLocator(scope=None, name="berryscary", relpath=Path(".")), + ), NpmLocator(scope=None, name="fsevents", version="2.3.2"), PatchLocator( package=NpmLocator(scope=None, name="fsevents", version="2.3.2"), patches=("builtin",), locator=None, ), + PatchLocator( + package=NpmLocator(scope=None, name="fsevents", version="2.3.2"), + patches=("builtin",), + locator=None, + ), PatchLocator( package=PatchLocator( package=NpmLocator(scope=None, name="fsevents", version="2.3.2"), From a3a2410cd118be6b6d6671306b690a972d70caf2 Mon Sep 17 00:00:00 2001 From: Taylor Madore Date: Tue, 17 Dec 2024 14:38:16 -0500 Subject: [PATCH 3/4] WIP: add Component Pedigree models Adds an optional Pedigree for the Component model according to: https://cyclonedx.org/docs/1.6/json/#components_items_pedigree_patches For the PatchDiff model, provide a URL but not a text diff in the SBOM since it is not required by the schema. Signed-off-by: Taylor Madore --- cachi2/core/models/sbom.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cachi2/core/models/sbom.py b/cachi2/core/models/sbom.py index 0152513e3..2515edd23 100644 --- a/cachi2/core/models/sbom.py +++ b/cachi2/core/models/sbom.py @@ -28,6 +28,25 @@ class ExternalReference(pydantic.BaseModel): type: Literal["distribution"] = "distribution" +class PatchDiff(pydantic.BaseModel): + """A Diff inside a Patch.""" + + url: str + + +class Patch(pydantic.BaseModel): + """A Patch inside a SBOM Component Pedigree.""" + + type: Literal["backport", "cherry-pick", "monkey", "unofficial"] = "unofficial" + diff: PatchDiff + + +class Pedigree(pydantic.BaseModel): + """A Pedigree inside a SBOM component.""" + + patches: list[Patch] + + FOUND_BY_CACHI2_PROPERTY: Property = Property(name="cachi2:found_by", value="cachi2") @@ -46,6 +65,7 @@ class Component(pydantic.BaseModel): external_references: Optional[list[ExternalReference]] = pydantic.Field( serialization_alias="externalReferences", default=None ) + pedigree: Optional[Pedigree] = None def key(self) -> str: """Uniquely identifies a package. From fb53a2ee8e6f09f5770615b42388056264b78a6d Mon Sep 17 00:00:00 2001 From: Taylor Madore Date: Thu, 9 Jan 2025 06:32:03 -0500 Subject: [PATCH 4/4] WIP: report yarn patches as pedigree not components Instead of reporting yarn patches as independent Components in the SBOM, report them instead as Pedigree for the parent, non-patch Component. Yarn has the concept of "builtin" patches that are applied by yarn itself to make certain features of yarn work. These are currently ignored and not reported. Signed-off-by: Taylor Madore --- cachi2/core/package_managers/yarn/resolver.py | 90 +++++--- .../test_data/yarn_e2e_test/bom.json | 48 +++-- tests/integration/test_data/yarn_v4/bom.json | 48 +++-- .../package_managers/yarn/test_resolver.py | 192 +++++++++++++----- 4 files changed, 283 insertions(+), 95 deletions(-) diff --git a/cachi2/core/package_managers/yarn/resolver.py b/cachi2/core/package_managers/yarn/resolver.py index ee1e43c8b..b246d85bd 100644 --- a/cachi2/core/package_managers/yarn/resolver.py +++ b/cachi2/core/package_managers/yarn/resolver.py @@ -18,7 +18,7 @@ from packageurl import PackageURL from cachi2.core.errors import PackageManagerError, PackageRejected, UnsupportedFeature -from cachi2.core.models.sbom import Component +from cachi2.core.models.sbom import Component, Patch, PatchDiff, Pedigree from cachi2.core.package_managers.yarn.locators import ( FileLocator, HttpsLocator, @@ -165,8 +165,18 @@ def create_components( packages: list[Package], project: Project, output_dir: RootedPath ) -> list[Component]: """Create SBOM components for all the packages parsed from the 'yarn info' output.""" - package_mapping = {package.parsed_locator: package for package in packages} - component_resolver = _ComponentResolver(package_mapping, project, output_dir) + package_mapping: dict[Locator, Package] = {} + patch_locators: list[PatchLocator] = [] + + # Patches are not components themselves, but they are necessary to + # resolve pedigree for their non-patch parent package components + for package in packages: + if isinstance(package.parsed_locator, PatchLocator): + patch_locators.append(package.parsed_locator) + else: + package_mapping[package.parsed_locator] = package + + component_resolver = _ComponentResolver(package_mapping, patch_locators, project, output_dir) return [component_resolver.get_component(package) for package in package_mapping.values()] @@ -192,11 +202,41 @@ class _CouldNotResolve(ValueError): class _ComponentResolver: def __init__( - self, package_mapping: Mapping[Locator, Package], project: Project, output_dir: RootedPath + self, + package_mapping: Mapping[Locator, Package], + patch_locators: list[PatchLocator], + project: Project, + output_dir: RootedPath, ) -> None: self._project = project self._output_dir = output_dir self._package_mapping = package_mapping + self._pedigree_mapping = self._get_pedigree_mapping(patch_locators) + + def _get_pedigree_mapping(self, patch_locators: list[PatchLocator]) -> dict[Locator, Pedigree]: + """Map locators for dependencies that get patched to their Pedigree.""" + pedigree_mapping: dict[Locator, Pedigree] = {} + for patch_locator in patch_locators: + # Filter out builtin patches + patch_paths: list[Path] = [p for p in patch_locator.patches if isinstance(p, Path)] + if not patch_paths: + continue + + patched_package = self._get_patched_package(patch_locator) + pedigree = pedigree_mapping.setdefault(patched_package, Pedigree(patches=[])) + for patch_path in patch_paths: + patch_url = self._get_patch_url(patch_locator, patch_path) + pedigree.patches.append(Patch(type="unofficial", diff=PatchDiff(url=patch_url))) + + return pedigree_mapping + + def _get_patched_package(self, patch_locator: PatchLocator) -> Locator: + """Return the non-patch parent package for a given patch locator.""" + patched_locator = patch_locator.package + while isinstance(patched_locator, PatchLocator): + patched_locator = patched_locator.package + + return patched_locator def get_component(self, package: Package) -> Component: """Create an SBOM component for a yarn Package.""" @@ -217,6 +257,7 @@ def get_component(self, package: Package) -> Component: name=resolved_package.name, version=resolved_package.version, purl=purl, + pedigree=self._pedigree_mapping.get(package.parsed_locator), ) @staticmethod @@ -262,10 +303,7 @@ def _generate_purl_for_package(package: _ResolvedPackage, project: Project) -> s subpath = str(normalized.subpath_from_root) elif isinstance(package.locator, PatchLocator): - # ignore patch locators - # the actual dependency that is patched is reported separately - # the patch itself will be reported via SBOM pedigree patches - pass + raise _CouldNotResolve("Patches cannot be resolved into Components") else: assert_never(package.locator) @@ -329,23 +367,7 @@ def log_for_locator(msg: str, *args: Any, level: int = logging.DEBUG) -> None: ) name, version = self._read_name_version_from_packjson(packjson) elif isinstance(locator, PatchLocator): - if ( - package.cache_path - # yarn info seems to always report the cache path for patch dependencies, - # but the path doesn't always exist - and (cache_path := self._cache_path_as_rooted(package.cache_path)).path.exists() - ): - log_for_locator("reading package name from %s", cache_path.subpath_from_root) - name = self._read_name_from_cache(cache_path) - elif orig_package := self._package_mapping.get(locator.package): - log_for_locator("resolving the name of the original package") - name = self._resolve_package(orig_package).name - else: - raise _CouldNotResolve( - "the 'yarn info' output does not include either an existing zip archive " - "or the original unpatched package", - ) - version = package.version + raise _CouldNotResolve("Patches cannot be resolved into Components") else: # This line can never be reached assuming type-checker checks are passing # https://typing.readthedocs.io/en/latest/source/unreachable.html#assert-never-and-exhaustiveness-checking @@ -412,3 +434,21 @@ def _cache_path_as_rooted(self, cache_path: str) -> RootedPath: return self._project_subpath(cache_path) else: return self._output_dir.join_within_root(cache_path) + + def _get_patch_url(self, patch_locator: PatchLocator, patch_path: Path) -> str: + """Return a PURL-style VCS URL qualifier with subpath for a Patch.""" + if patch_locator.locator is None: + raise UnsupportedFeature( + ( + f"{patch_locator} is missing an associated workspace locator " + "and Cachi2 expects all non-builtin yarn patches to have one" + ) + ) + + project_path = self._project.source_dir + workspace_path = patch_locator.locator.relpath + normalized = self._project.source_dir.join_within_root(workspace_path, patch_path) + repo_url = get_repo_id(project_path.root).as_vcs_url_qualifier() + subpath_from_root = str(normalized.subpath_from_root) + + return f"{repo_url}#{subpath_from_root}" diff --git a/tests/integration/test_data/yarn_e2e_test/bom.json b/tests/integration/test_data/yarn_e2e_test/bom.json index ace6704a6..197df48bf 100644 --- a/tests/integration/test_data/yarn_e2e_test/bom.json +++ b/tests/integration/test_data/yarn_e2e_test/bom.json @@ -314,18 +314,16 @@ }, { "name": "cachito-npm-without-deps", - "properties": [ - { - "name": "cachi2:found_by", - "value": "cachi2" - } - ], - "purl": "pkg:npm/cachito-npm-without-deps@1.0.0", - "type": "library", - "version": "1.0.0" - }, - { - "name": "cachito-npm-without-deps", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@70515793108df42547d3320c7ea4cd6b6e505c46#.yarn/patches/ccto-wo-deps-git@github.com-e0fce8c89c.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", @@ -662,6 +660,16 @@ }, { "name": "fsevents", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@70515793108df42547d3320c7ea4cd6b6e505c46#my-patches/fsevents.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", @@ -961,6 +969,22 @@ }, { "name": "left-pad", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@70515793108df42547d3320c7ea4cd6b6e505c46#my-patches/left-pad.patch" + }, + "type": "unofficial" + }, + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@70515793108df42547d3320c7ea4cd6b6e505c46#my-patches/left-pad-2.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", diff --git a/tests/integration/test_data/yarn_v4/bom.json b/tests/integration/test_data/yarn_v4/bom.json index e41656c00..f1b68c31e 100644 --- a/tests/integration/test_data/yarn_v4/bom.json +++ b/tests/integration/test_data/yarn_v4/bom.json @@ -314,18 +314,16 @@ }, { "name": "cachito-npm-without-deps", - "properties": [ - { - "name": "cachi2:found_by", - "value": "cachi2" - } - ], - "purl": "pkg:npm/cachito-npm-without-deps@1.0.0", - "type": "library", - "version": "1.0.0" - }, - { - "name": "cachito-npm-without-deps", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@53a2bfe8d5ee7ed9c2f752fe75831a881d54895f#.yarn/patches/ccto-wo-deps-git@github.com-e0fce8c89c.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", @@ -662,6 +660,16 @@ }, { "name": "fsevents", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@53a2bfe8d5ee7ed9c2f752fe75831a881d54895f#my-patches/fsevents.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", @@ -961,6 +969,22 @@ }, { "name": "left-pad", + "pedigree": { + "patches": [ + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@53a2bfe8d5ee7ed9c2f752fe75831a881d54895f#my-patches/left-pad.patch" + }, + "type": "unofficial" + }, + { + "diff": { + "url": "git+https://github.com/cachito-testing/cachi2-yarn-berry.git@53a2bfe8d5ee7ed9c2f752fe75831a881d54895f#my-patches/left-pad-2.patch" + }, + "type": "unofficial" + } + ] + }, "properties": [ { "name": "cachi2:found_by", diff --git a/tests/unit/package_managers/yarn/test_resolver.py b/tests/unit/package_managers/yarn/test_resolver.py index c38766eaa..0002ff6ef 100644 --- a/tests/unit/package_managers/yarn/test_resolver.py +++ b/tests/unit/package_managers/yarn/test_resolver.py @@ -9,7 +9,7 @@ import pytest from cachi2.core.errors import PackageRejected, UnsupportedFeature -from cachi2.core.models.sbom import Component +from cachi2.core.models.sbom import Component, Patch, PatchDiff, Pedigree from cachi2.core.package_managers.yarn.locators import parse_locator from cachi2.core.package_managers.yarn.project import PackageJson, Project, YarnRc from cachi2.core.package_managers.yarn.resolver import Package, create_components, resolve_packages @@ -549,13 +549,26 @@ def test_create_components_single_package( assert caplog.messages == expect_logs +@mock.patch("cachi2.core.package_managers.yarn.resolver.get_repo_id") def test_create_components_patched_packages( + mock_get_repo_id: mock.Mock, rooted_tmp_path: RootedPath, - caplog: pytest.LogCaptureFixture, ) -> None: + mock_get_repo_id.return_value = MOCK_REPO_ID project_dir = rooted_tmp_path mocked_packages = [ + MockedPackage( + Package( + raw_locator="fsevents@npm:2.3.2", + version="2.3.2", + checksum="97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f", + cache_path=project_dir.join_within_root( + ".yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-97ade64e75.zip" + ).path.as_posix(), + ), + is_hardlink=True, + ), MockedPackage( Package( raw_locator="fsevents@patch:fsevents@npm%3A2.3.2#./my-patches/fsevents.patch::version=2.3.2&hash=cf0bf0&locator=berryscary%40workspace%3A.", @@ -566,16 +579,12 @@ def test_create_components_patched_packages( ).path.as_posix(), ), is_hardlink=True, - packjson_path="node_modules/fsevents/package.json", - packjson_content=json.dumps({"name": "@patch1/fsevents"}), ), MockedPackage( Package( # Note: this package patches the patched package above - raw_locator="fsevents@patch:fsevents@patch%3Afsevents@npm%253A2.3.2%23./my-patches/fsevents.patch%3A%3Aversion=2.3.2&hash=cf0bf0&locator=berryscary%2540workspace%253A.#~builtin::version=2.3.2&hash=df0bf1", - # normally, the versions would almost certainly be the same, but we need something - # to tell the two packages apart - version="2.3.2-patch2", + raw_locator="fsevents@patch:fsevents@patch%3Afsevents@npm%253A2.3.2%23./my-patches/fsevents.patch%3A%3Aversion=2.3.2&hash=cf0bf0&locator=berryscary%2540workspace%253A.#./my-patches/fsevents-2.patch::version=2.3.2&hash=df0bf1&locator=berryscary%40workspace%3A.", + version="2.3.2", checksum=None, cache_path=project_dir.join_within_root( ".yarn/cache/fsevents-patch-e4409ad759-8.zip" @@ -585,11 +594,6 @@ def test_create_components_patched_packages( ), ] - # the first package has a zip archive in the cache - mock_package_json(mocked_packages[0], project_dir) - # the second one does not - # ~~mock_package_json(mocked_packages[1], project_dir)~~ - components = create_components( [mocked_package.package for mocked_package in mocked_packages], mock_project(project_dir), @@ -598,32 +602,146 @@ def test_create_components_patched_packages( expect_components = [ Component( - name="@patch1/fsevents", + name="fsevents", version="2.3.2", - purl=f"pkg:npm/{quote('@patch1')}/fsevents@2.3.2", + purl="pkg:npm/fsevents@2.3.2", + pedigree=Pedigree( + patches=[ + Patch( + type="unofficial", + diff=PatchDiff( + url="git+https://github.com/org/project.git@fffffff#my-patches/fsevents.patch" + ), + ), + Patch( + type="unofficial", + diff=PatchDiff( + url="git+https://github.com/org/project.git@fffffff#my-patches/fsevents-2.patch" + ), + ), + ], + ), + ), + ] + + assert components == expect_components + + +@mock.patch("cachi2.core.package_managers.yarn.resolver.get_repo_id") +def test_create_components_patched_packages_builtin_ignored( + mock_get_repo_id: mock.Mock, + rooted_tmp_path: RootedPath, +) -> None: + mock_get_repo_id.return_value = MOCK_REPO_ID + project_dir = rooted_tmp_path + + mocked_packages = [ + MockedPackage( + Package( + raw_locator="fsevents@npm:2.3.2", + version="2.3.2", + checksum="97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f", + cache_path=project_dir.join_within_root( + ".yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-97ade64e75.zip" + ).path.as_posix(), + ), + is_hardlink=True, ), + MockedPackage( + Package( + raw_locator="fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1", + version="2.3.2", + checksum=None, + cache_path=project_dir.join_within_root( + ".yarn/cache/fsevents-patch-e4409ad759-8.zip" + ).path.as_posix(), + ), + is_hardlink=True, + ), + ] + + components = create_components( + [mocked_package.package for mocked_package in mocked_packages], + mock_project(project_dir), + output_dir=RootedPath("/unused"), + ) + + # builtin patches are ignored + expect_components = [ Component( - name="@patch1/fsevents", - version="2.3.2-patch2", - purl=f"pkg:npm/{quote('@patch1')}/fsevents@2.3.2-patch2", + name="fsevents", + version="2.3.2", + purl="pkg:npm/fsevents@2.3.2", ), ] assert components == expect_components - patch_locator = "fsevents@patch:fsevents@npm%3A2.3.2#./my-patches/fsevents.patch::version=2.3.2&hash=cf0bf0&locator=berryscary%40workspace%3A." - patchpatch_locator = "fsevents@patch:fsevents@patch%3Afsevents@npm%253A2.3.2%23./my-patches/fsevents.patch%3A%3Aversion=2.3.2&hash=cf0bf0&locator=berryscary%2540workspace%253A.#~builtin::version=2.3.2&hash=df0bf1" - expect_logs = [ - # the first package has an archive in the cache - f"{patch_locator}: reading package name from .yarn/cache/fsevents-patch-9d1204d729-f73215b04b.zip", - # the second one does not, so we fall back to the original package - f"{patchpatch_locator}: resolving the name of the original package", - # ...which is the first package - f"{patch_locator}: reading package name from .yarn/cache/fsevents-patch-9d1204d729-f73215b04b.zip", +@mock.patch("cachi2.core.package_managers.yarn.resolver.get_repo_id") +def test_create_components_patched_packages_with_multiple_paths( + mock_get_repo_id: mock.Mock, + rooted_tmp_path: RootedPath, +) -> None: + mock_get_repo_id.return_value = MOCK_REPO_ID + project_dir = rooted_tmp_path + + mocked_packages = [ + MockedPackage( + Package( + raw_locator="fsevents@npm:2.3.2", + version="2.3.2", + checksum="97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f", + cache_path=project_dir.join_within_root( + ".yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-97ade64e75.zip" + ).path.as_posix(), + ), + is_hardlink=True, + ), + MockedPackage( + Package( + raw_locator="fsevents@patch:fsevents@npm%3A2.3.2#./my-patches/fsevents.patch&./my-patches/fsevents-2.patch::version=2.3.2&hash=cf0bf0&locator=berryscary%40workspace%3A.", + version="2.3.2", + checksum="f73215b04b52395389a612af4d30f7f412752cdfba1580c9e32c7ec259e448b57b464a4d0474427d6142f5ed9a6260fc1841d61834caf44706d77874fba6f17f", + cache_path=project_dir.join_within_root( + ".yarn/cache/fsevents-patch-9d1204d729-f73215b04b.zip" + ).path.as_posix(), + ), + is_hardlink=True, + ), + ] + + components = create_components( + [mocked_package.package for mocked_package in mocked_packages], + mock_project(project_dir), + output_dir=RootedPath("/unused"), + ) + + expect_components = [ + Component( + name="fsevents", + version="2.3.2", + purl="pkg:npm/fsevents@2.3.2", + pedigree=Pedigree( + patches=[ + Patch( + type="unofficial", + diff=PatchDiff( + url="git+https://github.com/org/project.git@fffffff#my-patches/fsevents.patch" + ), + ), + Patch( + type="unofficial", + diff=PatchDiff( + url="git+https://github.com/org/project.git@fffffff#my-patches/fsevents-2.patch" + ), + ), + ], + ), + ), ] - assert caplog.messages == expect_logs + assert components == expect_components @pytest.mark.parametrize( @@ -802,24 +920,6 @@ def test_create_components_patched_packages( ), id="https_no_cache_path", ), - # No cache_path for a Patch package, missing original package - pytest.param( - MockedPackage( - Package( - raw_locator="fsevents@patch:fsevents@npm%3A2.3.2#./my-patches/fsevents.patch::version=2.3.2&hash=cf0bf0&locator=berryscary%40workspace%3A.", - version="2.3.2", - checksum="f73215b04b52395389a612af4d30f7f412752cdfba1580c9e32c7ec259e448b57b464a4d0474427d6142f5ed9a6260fc1841d61834caf44706d77874fba6f17f", - cache_path=None, - ), - is_hardlink=True, - ), - ( - "Failed to resolve the name and version for " - "fsevents@patch:fsevents@npm%3A2.3.2#./my-patches/fsevents.patch::version=2.3.2&hash=cf0bf0&locator=berryscary%40workspace%3A.: " - "the 'yarn info' output does not include either an existing zip archive or the original unpatched package" - ), - id="patch_no_cache_path_no_orig_package", - ), ], ) def test_create_components_failed_to_resolve(