3737 PyPiRepo ,
3838)
3939from cpex .framework .utils import find_package_path
40+ from cpex .tools .integrity import (
41+ IntegrityVerificationError ,
42+ fetch_pypi_package_hashes ,
43+ find_matching_hash ,
44+ verify_package_integrity ,
45+ )
4046from cpex .tools .settings import get_catalog_settings
4147
4248logger = logging .getLogger (__name__ )
@@ -613,14 +619,15 @@ def find(self, plugin_name: str) -> Optional[PluginManifest]:
613619 return manifest
614620 return None
615621
616- def install_folder_via_pip (self , manifest : PluginManifest ) -> Path | None :
622+ def install_folder_via_pip (self , manifest : PluginManifest , verify_integrity : bool = True ) -> Path | None :
617623 """
618624 Runs a pip install using subfolder syntax for monorepo plugins.
619625 For isolated_venv plugins, checks manifest kind BEFORE installing to avoid dependency conflicts.
620626 e.g. "git+https://github.com[extra]&subdirectory=folder_name"
621627
622628 Args:
623629 manifest: The PluginManifest of the plugin to be installed
630+ verify_integrity: Whether to compute and log package hash for verification
624631
625632 Raises:
626633 RuntimeError: If package installation fails.
@@ -635,7 +642,7 @@ def install_folder_via_pip(self, manifest: PluginManifest) -> Path | None:
635642 if manifest .kind == "isolated_venv" :
636643 logger .info ("Detected isolated_venv plugin from monorepo: %s" , manifest .name )
637644 # Install the package to make it available for venv initialization
638- package_path = self ._download_monorepo_folder_to_temp (repo_url , manifest .name )
645+ package_path = self ._download_monorepo_folder_to_temp (repo_url , manifest .name , verify_integrity = verify_integrity )
639646 plugin_path = self ._initialize_isolated_venv (manifest , package_path )
640647 logger .info ("Isolated venv initialized. Plugin will be auto-installed via requirements.txt" )
641648 else :
@@ -828,12 +835,13 @@ def _extract_package_archive(self, package_file: Path, extract_dir: Path) -> Non
828835 else :
829836 raise RuntimeError (f"Unsupported package format: { package_file } " )
830837
831- def _download_monorepo_folder_to_temp (self , repo_url : str , package_name : str ) -> Path :
838+ def _download_monorepo_folder_to_temp (self , repo_url : str , package_name : str , verify_integrity : bool = True ) -> Path :
832839 """Download monorepo folder to temporary directory.
833840
834841 Args:
835842 repo_url: The URL of the monorepo.
836843 package_name: Name used in error messages.
844+ verify_integrity: Whether to compute and log package hash for verification.
837845
838846 Returns:
839847 Path to the extracted package directory. Caller is responsible for cleanup.
@@ -860,6 +868,24 @@ def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str) ->
860868 if not downloaded_files :
861869 raise RuntimeError (f"No files downloaded for { package_name } " )
862870 package_file = downloaded_files [0 ]
871+
872+ # Compute and log hash for integrity verification
873+ if verify_integrity :
874+ try :
875+ from cpex .tools .integrity import compute_file_hash
876+ package_hash = compute_file_hash (package_file )
877+ logger .info (
878+ "Package integrity hash for %s (%s): SHA256=%s" ,
879+ package_name ,
880+ package_file .name ,
881+ package_hash
882+ )
883+ logger .info (
884+ "Store this hash for future verification or to detect tampering"
885+ )
886+ except Exception as e :
887+ logger .warning ("Failed to compute package hash: %s" , str (e ))
888+
863889 extract_dir = temp_dir / "extracted"
864890 extract_dir .mkdir ()
865891
@@ -876,20 +902,22 @@ def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str) ->
876902 raise RuntimeError (f"Unexpected error downloading { package_name } : { str (e )} " ) from e
877903
878904 def _download_package_to_temp (
879- self , package_name : str , version_constraint : str | None , use_test : bool = False
905+ self , package_name : str , version_constraint : str | None , use_test : bool = False , verify_integrity : bool = True
880906 ) -> Path :
881907 """Download package to a temporary directory without installing it.
882908
883909 Args:
884910 package_name: The PyPI package name to download.
885911 version_constraint: Optional version constraint.
886912 use_test: Whether to use test.pypi.org.
913+ verify_integrity: Whether to verify package integrity using SHA256 hashes.
887914
888915 Returns:
889916 Path to the downloaded package directory.
890917
891918 Raises:
892919 RuntimeError: If download fails.
920+ IntegrityVerificationError: If hash verification fails.
893921 """
894922
895923 try :
@@ -902,6 +930,33 @@ def _download_package_to_temp(
902930 if ppi .version_constraint is not None :
903931 tgt = f"{ tgt } { ppi .version_constraint } "
904932
933+ # Fetch expected hashes from PyPI before downloading (if verification enabled)
934+ expected_hashes = {}
935+ if verify_integrity :
936+ try :
937+ logger .info ("Fetching package hashes from PyPI for %s" , package_name )
938+ # Extract version from constraint if available, otherwise fetch latest
939+ version_to_fetch = None
940+ if version_constraint :
941+ # Try to extract exact version from constraint (e.g., "==1.0.0" -> "1.0.0")
942+ import re
943+ version_match = re .search (r'==\s*([0-9.]+)' , version_constraint )
944+ if version_match :
945+ version_to_fetch = version_match .group (1 )
946+
947+ expected_hashes = fetch_pypi_package_hashes (
948+ package_name = package_name ,
949+ version = version_to_fetch ,
950+ use_test = use_test
951+ )
952+ if expected_hashes :
953+ logger .info ("Retrieved hashes for %d distribution files" , len (expected_hashes ))
954+ else :
955+ logger .warning ("No hashes available from PyPI for %s" , package_name )
956+ except Exception as e :
957+ logger .warning ("Failed to fetch hashes from PyPI: %s. Proceeding without verification." , str (e ))
958+ expected_hashes = {}
959+
905960 # Download package without installing
906961 download_args = [
907962 self .python_executable ,
@@ -926,6 +981,24 @@ def _download_package_to_temp(
926981 raise RuntimeError (f"No files downloaded for { package_name } " )
927982
928983 package_file = downloaded_files [0 ]
984+
985+ # Verify package integrity if hashes are available
986+ if verify_integrity and expected_hashes :
987+ expected_hash = find_matching_hash (package_file , expected_hashes , package_name )
988+ if expected_hash :
989+ logger .info ("Verifying integrity of %s" , package_file .name )
990+ verify_package_integrity (
991+ file_path = package_file ,
992+ expected_hash = expected_hash ,
993+ package_name = package_name ,
994+ strict = True
995+ )
996+ else :
997+ logger .warning (
998+ "No matching hash found for %s. Proceeding without verification." ,
999+ package_file .name
1000+ )
1001+
9291002 extract_dir = temp_dir / "extracted"
9301003 extract_dir .mkdir ()
9311004
@@ -935,9 +1008,15 @@ def _download_package_to_temp(
9351008 logger .info ("Downloaded and extracted %s to %s" , package_name , extract_dir )
9361009 return extract_dir
9371010
1011+ except IntegrityVerificationError :
1012+ # Re-raise integrity errors without wrapping
1013+ shutil .rmtree (temp_dir , ignore_errors = True )
1014+ raise
9381015 except subprocess .CalledProcessError as e :
1016+ shutil .rmtree (temp_dir , ignore_errors = True )
9391017 raise RuntimeError (f"Failed to download { package_name } : { e .stderr } " ) from e
9401018 except Exception as e :
1019+ shutil .rmtree (temp_dir , ignore_errors = True )
9411020 raise RuntimeError (f"Unexpected error downloading { package_name } : { str (e )} " ) from e
9421021
9431022 def _find_manifest_in_extracted_package (self , extract_dir : Path , package_name : str ) -> Path :
@@ -1251,7 +1330,11 @@ def _finalize_plugin_installation(
12511330 return actual_plugin_path if actual_plugin_path is not None else plugin_path
12521331
12531332 def install_from_pypi (
1254- self , plugin_package_name : str , version_constraint : str | None = None , use_pytest : bool = False
1333+ self ,
1334+ plugin_package_name : str ,
1335+ version_constraint : str | None = None ,
1336+ use_pytest : bool = False ,
1337+ verify_integrity : bool = True ,
12551338 ) -> tuple [PluginManifest , Path | None ]:
12561339 """Install Python package from PyPI and load its plugin-manifest.yaml.
12571340
@@ -1268,17 +1351,22 @@ def install_from_pypi(
12681351 Args:
12691352 plugin_package_name: The name of the package hosted on PyPI.
12701353 version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0").
1354+ use_pytest: Whether to use test.pypi.org instead of pypi.org.
1355+ verify_integrity: Whether to verify package integrity using SHA256 hashes from PyPI.
12711356
12721357 Returns:
12731358 The loaded and validated plugin manifest.
12741359
12751360 Raises:
12761361 RuntimeError: If any step of the installation process fails.
12771362 FileNotFoundError: If plugin-manifest.yaml is not found in the package.
1363+ IntegrityVerificationError: If package hash verification fails.
12781364 """
12791365
1280- # Step 1: Download package to temporary location to read manifest
1281- temp_extract_dir = self ._download_package_to_temp (plugin_package_name , version_constraint , use_pytest )
1366+ # Step 1: Download package to temporary location to read manifest (with integrity verification)
1367+ temp_extract_dir = self ._download_package_to_temp (
1368+ plugin_package_name , version_constraint , use_pytest , verify_integrity = verify_integrity
1369+ )
12821370
12831371 try :
12841372 # Step 2: Find and load the manifest file
@@ -1312,7 +1400,7 @@ def install_from_pypi(
13121400 if temp_extract_dir .exists ():
13131401 shutil .rmtree (temp_extract_dir .parent )
13141402
1315- def install_from_git (self , url : str ) -> tuple [PluginManifest , Path | None ]:
1403+ def install_from_git (self , url : str , verify_integrity : bool = True ) -> tuple [PluginManifest , Path | None ]:
13161404 """Install Python package from Git repository and load its plugin-manifest.yaml.
13171405
13181406 This method performs the following steps:
@@ -1331,6 +1419,7 @@ def install_from_git(self, url: str) -> tuple[PluginManifest, Path | None]:
13311419 - MyProject @ git+ssh://git@git.example.com/MyProject
13321420 - MyProject @ git+https://git.example.com/MyProject
13331421 - MyProject @ git+https://git.example.com/MyProject@master
1422+ verify_integrity: Whether to compute and log package hash for verification
13341423
13351424 Returns:
13361425 Tuple of (PluginManifest, Path to plugin or None)
@@ -1416,6 +1505,23 @@ def install_from_git(self, url: str) -> tuple[PluginManifest, Path | None]:
14161505 archive_path = archives [0 ]
14171506 logger .info ("Downloaded archive: %s" , archive_path .name )
14181507
1508+ # Compute and log hash for integrity verification
1509+ if verify_integrity :
1510+ try :
1511+ from cpex .tools .integrity import compute_file_hash
1512+ package_hash = compute_file_hash (archive_path )
1513+ logger .info (
1514+ "Package integrity hash for %s (%s): SHA256=%s" ,
1515+ package_name ,
1516+ archive_path .name ,
1517+ package_hash
1518+ )
1519+ logger .info (
1520+ "Store this hash for future verification or to detect tampering"
1521+ )
1522+ except Exception as e :
1523+ logger .warning ("Failed to compute package hash: %s" , str (e ))
1524+
14191525 # Extract the archive using common helper
14201526 self ._extract_package_archive (archive_path , temp_extract_dir )
14211527
0 commit comments