from __future__ import annotations import binascii from datetime import datetime import secrets import stat import struct import zipfile from pathlib import Path from typing import Iterable import zlib try: import pyzipper except ImportError: # pragma: no cover pyzipper = None ArchiveMember = tuple[Path, str] ZIP_METHOD_AES = "aes" ZIP_METHOD_STANDARD = "zip_standard" def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]: members: list[ArchiveMember] = [] used_names: set[str] = set() for item in files: path, requested_name = item if isinstance(item, tuple) else (item, item.name) requested = Path(requested_name).name or path.name stem = Path(requested).stem suffix = Path(requested).suffix candidate = requested counter = 2 while candidate.casefold() in used_names: candidate = f"{stem} ({counter}){suffix}" counter += 1 used_names.add(candidate.casefold()) members.append((path, candidate)) return members def create_zip_archive( output_path: Path, files: Iterable[Path | ArchiveMember], password: str = "", # nosec B107 - empty means an unencrypted archive. method: str = ZIP_METHOD_AES, ) -> Path: """Create a ZIP archive, optionally using AES or legacy ZipCrypto encryption.""" output_path.parent.mkdir(parents=True, exist_ok=True) members = _normalized_members(files) if password: if method == ZIP_METHOD_STANDARD: _create_zipcrypto_archive(output_path, members, password) return output_path _create_aes_archive(output_path, members, password) return output_path with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: for file_path, archive_name in members: zip_file.write(file_path, arcname=archive_name) return output_path def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path: """Backward-compatible wrapper for the original per-rule ZIP helper.""" return create_zip_archive(output_path, files, password, method) def _create_aes_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None: if pyzipper is None: raise RuntimeError("pyzipper is required for writing AES password-protected ZIP files") with pyzipper.AESZipFile( output_path, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES, ) as zip_file: zip_file.setpassword(password.encode("utf-8")) for file_path, archive_name in members: zip_file.write(file_path, arcname=archive_name) def _create_zipcrypto_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None: entries: list[_CentralDirectoryEntry] = [] password_bytes = password.encode("utf-8") with output_path.open("wb") as zip_file: for file_path, archive_name in members: local_header_offset = zip_file.tell() file_data = file_path.read_bytes() crc = binascii.crc32(file_data) & 0xFFFFFFFF compressed_data = _deflate(file_data) encrypted_data = _zipcrypto_encrypt(compressed_data, password_bytes, crc) compressed_size = len(encrypted_data) uncompressed_size = len(file_data) modified_at = datetime.fromtimestamp(file_path.stat().st_mtime) dos_time, dos_date = _dos_timestamp(modified_at) filename_bytes, flags = _filename_bytes(archive_name, encrypted=True) zip_file.write( struct.pack( " None: self.filename_bytes = filename_bytes self.flags = flags self.dos_time = dos_time self.dos_date = dos_date self.crc = crc self.compressed_size = compressed_size self.uncompressed_size = uncompressed_size self.external_attributes = external_attributes self.local_header_offset = local_header_offset def _deflate(data: bytes) -> bytes: compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS) return compressor.compress(data) + compressor.flush() def _zipcrypto_encrypt(data: bytes, password: bytes, crc: int) -> bytes: cipher = _ZipCrypto(password) header = secrets.token_bytes(11) + bytes([(crc >> 24) & 0xFF]) return cipher.encrypt(header + data) class _ZipCrypto: def __init__(self, password: bytes) -> None: self.key0 = 0x12345678 self.key1 = 0x23456789 self.key2 = 0x34567890 for value in password: self._update_keys(value) def encrypt(self, data: bytes) -> bytes: encrypted = bytearray() for value in data: encrypted.append(value ^ self._decrypt_byte()) self._update_keys(value) return bytes(encrypted) def _decrypt_byte(self) -> int: temp = self.key2 | 2 return ((temp * (temp ^ 1)) >> 8) & 0xFF def _update_keys(self, value: int) -> None: self.key0 = _zipcrypto_crc32_byte(value, self.key0) self.key1 = (self.key1 + (self.key0 & 0xFF)) & 0xFFFFFFFF self.key1 = (self.key1 * 134775813 + 1) & 0xFFFFFFFF self.key2 = _zipcrypto_crc32_byte((self.key1 >> 24) & 0xFF, self.key2) def _build_zipcrypto_crc(value: int) -> int: for _ in range(8): if value & 1: value = 0xEDB88320 ^ (value >> 1) else: value >>= 1 return value _ZIPCRYPTO_CRC_TABLE = [_build_zipcrypto_crc(value) for value in range(256)] def _zipcrypto_crc32_byte(value: int, crc: int) -> int: return ((crc >> 8) ^ _ZIPCRYPTO_CRC_TABLE[(crc ^ value) & 0xFF]) & 0xFFFFFFFF def _filename_bytes(filename: str, *, encrypted: bool) -> tuple[bytes, int]: flags = 0x1 if encrypted else 0 try: return filename.encode("ascii"), flags except UnicodeEncodeError: return filename.encode("utf-8"), flags | 0x800 def _dos_timestamp(value: datetime) -> tuple[int, int]: year = min(max(value.year, 1980), 2107) month = value.month if value.year == year else 1 day = value.day if value.year == year else 1 dos_time = value.hour << 11 | value.minute << 5 | value.second // 2 dos_date = (year - 1980) << 9 | month << 5 | day return dos_time, dos_date def _external_attributes(path: Path) -> int: try: permissions = stat.S_IMODE(path.stat().st_mode) except OSError: return 0 return permissions << 16