diff --git a/requirements.txt b/requirements.txt index da155d76..b61a51c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ sentry-kafka-schemas==2.1.2 sentry-sdk>=2.36.0 sortedcontainers>=2.4.0 typing-extensions>=4.15.0 +zipfile-zstd==0.0.4 diff --git a/src/launchpad/__init__.py b/src/launchpad/__init__.py index f102a9ca..de83ada5 100644 --- a/src/launchpad/__init__.py +++ b/src/launchpad/__init__.py @@ -1 +1,4 @@ +# Monkey patches - import these first to register handlers globally +import zipfile_zstd # noqa: F401 - Registers zstd compression support with zipfile module. Should not be required after upgrading to python 3.14 + __version__ = "0.0.1" diff --git a/src/launchpad/artifacts/providers/zip_provider.py b/src/launchpad/artifacts/providers/zip_provider.py index 3ca73be2..8ae8d72a 100644 --- a/src/launchpad/artifacts/providers/zip_provider.py +++ b/src/launchpad/artifacts/providers/zip_provider.py @@ -92,12 +92,15 @@ def extract_to_temp_directory(self) -> Path: self._temp_dirs.append(temp_dir) self._safe_extract(str(self.path), str(temp_dir)) - logger.debug(f"Extracted zip contents to {temp_dir} using system unzip") + logger.debug(f"Extracted zip contents to {temp_dir}") return temp_dir def _safe_extract(self, zip_path: str, extract_path: str): - """Extract the zip contents to a temporary directory, ensuring that the paths are safe from path traversal attacks.""" + """Extract the zip contents to a temporary directory, ensuring that the paths are safe from path traversal attacks. + + Supports both standard compression methods and Zstandard compression. + """ base_dir = Path(extract_path) with zipfile.ZipFile(zip_path, "r") as zip_ref: check_reasonable_zip(zip_ref) diff --git a/tests/unit/artifacts/providers/test_zip_provider.py b/tests/unit/artifacts/providers/test_zip_provider.py index dee1f9b8..b39a3825 100644 --- a/tests/unit/artifacts/providers/test_zip_provider.py +++ b/tests/unit/artifacts/providers/test_zip_provider.py @@ -125,3 +125,22 @@ def test_max_file_size(self, hackernews_xcarchive: Path) -> None: # iOS fixture is ~32MB uncompressed, so limit of 10MB should fail with pytest.raises(UnreasonableZipError, match="exceeding the limit of 10.0MB"): check_reasonable_zip(zf, max_uncompressed_size=10 * 1024 * 1024) + + def test_extract_zstd_zip(self) -> None: + """Test that zstd-compressed zips can be extracted.""" + with tempfile.NamedTemporaryFile(suffix=".zip") as temp_file: + temp_path = Path(temp_file.name) + + # Create a zstd-compressed zip (compression method 93) + with zipfile.ZipFile(temp_path, "w") as zf: + zf.writestr("test.txt", "content", compress_type=93) + + try: + provider = ZipProvider(temp_path) + temp_dir = provider.extract_to_temp_directory() + + assert temp_dir.exists() + assert (temp_dir / "test.txt").exists() + assert (temp_dir / "test.txt").read_text() == "content" + finally: + temp_path.unlink(missing_ok=True)