diff --git a/main.py b/main.py
index be12a26..aed81b9 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
import argparse
+import base64
import hashlib
import mimetypes
import os
@@ -10,9 +11,8 @@ from glob import glob
from io import BytesIO
from pathlib import Path
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import (
- FileResponse,
HTMLResponse,
RedirectResponse,
StreamingResponse,
@@ -67,25 +67,81 @@ class FileIndexer:
class ZipFileIndexer(FileIndexer):
+ def __init__(self, path: str, salt: str | None = None):
+ super().__init__(path, salt)
+ self._password_cache: dict[str, bool] = {}
+
+ def _open_zip(self):
+ """Open the ZIP file, using pyzipper if available for AES support."""
+ try:
+ import pyzipper
+
+ return pyzipper.AESZipFile(self.path, "r")
+ except ImportError:
+ return zipfile.ZipFile(self.path, "r")
+
def _index(self) -> dict[str, str]:
"""Index all files in the zip file"""
mapping = {}
- with zipfile.ZipFile(self.path, "r") as zip_file:
+ with self._open_zip() as zip_file:
for file_info in zip_file.infolist():
if not file_info.is_dir():
file_hash = self._hash_path(file_info.filename)
mapping[file_hash] = file_info.filename
return mapping
- def get_file_by_hash(self, file_hash: str):
- """Get file content by hash"""
+ def _needs_password(self, filename: str) -> bool:
+ """Check if a file inside the zip requires a password.
+
+ Uses lazy detection: tries to read the file without a password.
+ Results are cached so the ZIP is not reopened on subsequent calls.
+
+ Args:
+ filename: The internal filename inside the zip.
+
+ Returns:
+ True if the file is password-protected, False otherwise.
+ """
+ if filename in self._password_cache:
+ return self._password_cache[filename]
+
+ try:
+ with self._open_zip() as zip_file:
+ zip_file.read(filename)
+ self._password_cache[filename] = False
+ return False
+ except Exception:
+ self._password_cache[filename] = True
+ return True
+
+ def is_file_encrypted(self, file_hash: str) -> bool:
+ """Check if the file identified by hash is password-protected.
+
+ Args:
+ file_hash: The hash of the file.
+
+ Returns:
+ True if the file requires a password, False otherwise.
+ """
+ filename = self._file_mapping.get(file_hash)
+ if filename is None:
+ return False
+ return self._needs_password(filename)
+
+ def get_file_by_hash(self, file_hash: str, password: bytes | None = None):
+ """Get file content by hash.
+
+ Args:
+ file_hash: The hash of the file.
+ password: Optional password for encrypted files.
+ """
if file_hash not in self._file_mapping:
return None
filename = self._file_mapping[file_hash]
- with zipfile.ZipFile(self.path, "r") as zip_file:
- yield from BytesIO(zip_file.read(filename))
+ with self._open_zip() as zip_file:
+ yield from BytesIO(zip_file.read(filename, pwd=password))
def get_filename_by_hash(self, file_hash: str) -> str | None:
"""Get filename by hash"""
@@ -133,8 +189,8 @@ async def health_check():
@app.get("/api/{file_hash}/data")
-async def get_file_data(file_hash: str):
- """Serve a specific file by its hash"""
+async def get_file_data(file_hash: str, request: Request):
+ """Serve a specific file by its hash."""
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
@@ -142,36 +198,68 @@ async def get_file_data(file_hash: str):
if not indexer:
raise HTTPException(status_code=404, detail="File not found")
+ password = _get_zip_password(file_hash, request)
+ if (
+ password is None
+ and isinstance(indexer, ZipFileIndexer)
+ and indexer.is_file_encrypted(file_hash)
+ ):
+ _raise_unauthorized()
+
filename = indexer.get_filename_by_hash(file_hash)
content_type, _ = mimetypes.guess_type(filename or "")
if not content_type:
content_type = "application/octet-stream"
- return StreamingResponse(
- indexer.get_file_by_hash(file_hash),
- media_type=content_type,
- headers={
- "Content-Disposition": f"inline; filename={os.path.basename(filename or '')}",
- },
- )
+ if isinstance(indexer, ZipFileIndexer):
+ try:
+ content = b"".join(indexer.get_file_by_hash(file_hash, password=password))
+ except RuntimeError:
+ _raise_unauthorized()
+ return StreamingResponse(
+ iter([content]),
+ media_type=content_type,
+ headers={
+ "Content-Disposition": f"inline; filename={os.path.basename(filename or '')}",
+ },
+ )
+ else:
+ return StreamingResponse(
+ indexer.get_file_by_hash(file_hash),
+ media_type=content_type,
+ headers={
+ "Content-Disposition": f"inline; filename={os.path.basename(filename or '')}",
+ },
+ )
def _build_url(
file_hash: str, order: str | None = None, delay: int | None = None
) -> str:
"""Build a URL with optional order/delay query parameters."""
- base = "/{hash}".format(hash=file_hash)
+ base = f"/{file_hash}"
if order is not None and delay is not None:
- return "{base}?order={order}&delay={delay}".format(
- base=base, order=order, delay=delay
- )
+ return f"{base}?order={order}&delay={delay}"
return base
@app.get("/")
-async def root(order: str | None = None, delay: int | None = None):
- """Redirect to a random file hash"""
+async def root(
+ order: str | None = None, delay: int | None = None, request: Request = None
+):
+ """Redirect to a random file hash."""
random_hash = _get_random_hash()
+
+ # Check if the random file is encrypted and requires auth
+ if request is not None:
+ password = _get_zip_password(random_hash, request)
+ if password is None:
+ indexer = _find_indexer_for_hash(random_hash)
+ if isinstance(indexer, ZipFileIndexer) and indexer.is_file_encrypted(
+ random_hash
+ ):
+ _raise_unauthorized()
+
return RedirectResponse(url=_build_url(random_hash, order, delay))
@@ -216,7 +304,7 @@ def _render_page(
current_delay: int | None = None,
) -> HTMLResponse:
"""Render the frontend page with navigation data"""
- with open("frontend.html", "r") as f:
+ with open("frontend.html") as f:
content = f.read()
template = string.Template(content)
@@ -253,7 +341,12 @@ def _render_page(
@app.get("/{file_hash}")
-async def hash_page(file_hash: str, order: str | None = None, delay: int | None = None):
+async def hash_page(
+ file_hash: str,
+ order: str | None = None,
+ delay: int | None = None,
+ request: Request = None,
+):
"""Serve a page for a specific file hash with optional auto-refresh navigation.
Args:
@@ -269,6 +362,16 @@ async def hash_page(file_hash: str, order: str | None = None, delay: int | None
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
)
+ # Check if the file is encrypted and requires auth
+ if request is not None:
+ password = _get_zip_password(file_hash, request)
+ if password is None:
+ indexer = _find_indexer_for_hash(file_hash)
+ if isinstance(indexer, ZipFileIndexer) and indexer.is_file_encrypted(
+ file_hash
+ ):
+ _raise_unauthorized()
+
navigation_data = _get_navigation_data(file_hash, order=order)
if order is not None and delay is not None:
@@ -281,9 +384,7 @@ async def hash_page(file_hash: str, order: str | None = None, delay: int | None
# Create pause button to stop auto-refresh
pause_button = (
- '⏸'.format(
- file_hash=file_hash
- )
+ f'⏸'
)
return _render_page(
@@ -296,8 +397,9 @@ async def hash_page(file_hash: str, order: str | None = None, delay: int | None
)
else:
# Browse mode
- play_button = '⏵'.format(
- file_hash=file_hash
+ play_button = (
+ f'⏵'
)
return _render_page(
navigation_data,
@@ -315,6 +417,55 @@ def _find_indexer_for_hash(file_hash: str):
return None
+def _get_zip_password(file_hash: str, request: Request) -> bytes | None:
+ """Get ZIP password from HTTP Basic Auth if the file is encrypted.
+
+ If the file is not encrypted, returns None (no password needed).
+ If the file is encrypted and valid credentials are provided, returns
+ the password as bytes.
+ If the file is encrypted but no Authorization header is present, returns
+ None so the caller can raise a 401.
+
+ Args:
+ file_hash: The hash of the file to check.
+ request: The incoming HTTP request.
+
+ Returns:
+ The ZIP password as bytes if encrypted and auth provided, None otherwise.
+ """
+ indexer = _find_indexer_for_hash(file_hash)
+ if not isinstance(indexer, ZipFileIndexer):
+ return None
+ if not indexer.is_file_encrypted(file_hash):
+ return None
+
+ # File is encrypted - check for Authorization header
+ auth_header = request.headers.get("authorization")
+ if not auth_header:
+ return None
+
+ scheme, _, params = auth_header.partition(" ")
+ if scheme.lower() != "basic":
+ return None
+
+ try:
+ decoded = base64.b64decode(params).decode()
+ except Exception:
+ return None
+
+ _, _, password = decoded.partition(":")
+ return password.encode()
+
+
+def _raise_unauthorized() -> None:
+ """Raise an HTTP 401 Unauthorized with Basic auth challenge header."""
+ raise HTTPException(
+ status_code=401,
+ detail="Authentication required",
+ headers={"WWW-Authenticate": 'Basic realm="zip"'},
+ )
+
+
def _get_random_hash() -> str:
"""Get a random file hash from the indexed files"""
if not file_mapping:
diff --git a/pyproject.toml b/pyproject.toml
index 4ba98e6..b3ff56c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,5 +36,6 @@ dev = [
"httpx>=0.28.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
+ "pyzipper>=0.3.6",
"ruff>=0.15.5",
]
diff --git a/tests/conftest.py b/tests/conftest.py
index aee6e1a..3681389 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,14 +2,19 @@
import argparse
import zipfile
+from collections.abc import Generator
from pathlib import Path
-from typing import Generator
import pytest
from httpx import ASGITransport, AsyncClient
import main
+try:
+ import pyzipper
+except ImportError:
+ pyzipper = None
+
def _reset_state() -> None:
"""Reset all global state to defaults."""
@@ -18,7 +23,7 @@ def _reset_state() -> None:
@pytest.fixture(autouse=True)
-def reset_globals() -> Generator[None, None, None]:
+def reset_globals() -> Generator[None]:
"""Reset global state before and after each test."""
_reset_state()
yield
@@ -109,7 +114,7 @@ def initialized_zip(args_zip: argparse.Namespace) -> None:
@pytest.fixture
-async def client_dir(initialized_dir: None) -> Generator[AsyncClient, None, None]:
+async def client_dir(initialized_dir: None) -> Generator[AsyncClient]:
"""Async HTTP client against the app initialized with directory files."""
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -117,8 +122,63 @@ async def client_dir(initialized_dir: None) -> Generator[AsyncClient, None, None
@pytest.fixture
-async def client_zip(initialized_zip: None) -> Generator[AsyncClient, None, None]:
+async def client_zip(initialized_zip: None) -> Generator[AsyncClient]:
"""Async HTTP client against the app initialized with zip files."""
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
+
+
+@pytest.fixture
+def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]:
+ """Create a ZIP file with password-protected files.
+
+ Returns:
+ Tuple of (zip_path, inner_files dict, password).
+ inner_files maps logical names to internal paths.
+ """
+ if pyzipper is None:
+ pytest.skip("pyzipper not installed")
+
+ zip_path = tmp_path / "encrypted.zip"
+ password = "secret123"
+ inner_files: dict[str, str] = {}
+
+ with pyzipper.AESZipFile(zip_path, "w", encryption=pyzipper.WZ_AES) as zf:
+ zf.setpassword(password.encode())
+ zf.writestr("protected.txt", "secret content")
+ inner_files["protected"] = "protected.txt"
+ zf.writestr("folder/secret.png", b"\x89PNG fake")
+ inner_files["secret_image"] = "folder/secret.png"
+
+ return zip_path, inner_files, password
+
+
+@pytest.fixture
+def args_encrypted_zip(
+ sample_encrypted_zip: tuple[Path, dict[str, str], str], tmp_path: Path
+) -> argparse.Namespace:
+ """Argparse namespace pointing at the encrypted sample zip."""
+ zip_path, _, _ = sample_encrypted_zip
+ return argparse.Namespace(
+ source=str(zip_path),
+ host="127.0.0.1",
+ port=0,
+ salt="test-salt",
+ )
+
+
+@pytest.fixture
+def initialized_encrypted_zip(args_encrypted_zip: argparse.Namespace) -> None:
+ """Initialize the server with encrypted zip files."""
+ main.initialize_server(args_encrypted_zip)
+
+
+@pytest.fixture
+async def client_encrypted_zip(
+ initialized_encrypted_zip: None,
+) -> Generator[AsyncClient]:
+ """Async HTTP client against the app initialized with encrypted zip files."""
+ transport = ASGITransport(app=main.app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
diff --git a/tests/test_encrypted_zip.py b/tests/test_encrypted_zip.py
new file mode 100644
index 0000000..a44f58d
--- /dev/null
+++ b/tests/test_encrypted_zip.py
@@ -0,0 +1,188 @@
+"""Tests for password-protected ZIP file handling."""
+
+import base64
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+import main
+
+
+class TestLazyEncryptionDetection:
+ """Tests for ZipFileIndexer._needs_password and is_file_encrypted."""
+
+ def test_detects_encrypted_file(
+ self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
+ ) -> None:
+ """Encrypted files are detected as needing a password."""
+ zip_path, inner_files, _ = sample_encrypted_zip
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test")
+ protected_hash = indexer._hash_path(inner_files["protected"])
+ assert indexer.is_file_encrypted(protected_hash) is True
+
+ def test_detects_unencrypted_file(
+ self, sample_zip: dict[str, Path], tmp_path: Path
+ ) -> None:
+ """Unencrypted files are detected as not needing a password."""
+ zip_path = tmp_path / "test_archive.zip"
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt")
+ top_hash = indexer._hash_path("top.txt")
+ assert indexer.is_file_encrypted(top_hash) is False
+
+ def test_caches_detection_result(
+ self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
+ ) -> None:
+ """Second call to is_file_encrypted uses the cache."""
+ zip_path, inner_files, _ = sample_encrypted_zip
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test")
+ protected_hash = indexer._hash_path(inner_files["protected"])
+
+ # First call populates the cache
+ assert indexer.is_file_encrypted(protected_hash) is True
+ assert "protected.txt" in indexer._password_cache
+
+ # Second call returns cached value
+ assert indexer.is_file_encrypted(protected_hash) is True
+
+ def test_returns_false_for_unknown_hash(
+ self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
+ ) -> None:
+ """Unknown hash returns False (not encrypted)."""
+ zip_path, _, _ = sample_encrypted_zip
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test")
+ assert indexer.is_file_encrypted("nonexistent-hash") is False
+
+ def test_get_file_with_correct_password(
+ self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
+ ) -> None:
+ """Reading an encrypted file with the correct password succeeds."""
+ zip_path, inner_files, password = sample_encrypted_zip
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test")
+ protected_hash = indexer._hash_path(inner_files["protected"])
+ content = b"".join(
+ indexer.get_file_by_hash(protected_hash, password=password.encode())
+ )
+ assert content == b"secret content"
+
+ def test_get_file_with_wrong_password_raises(
+ self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
+ ) -> None:
+ """Reading an encrypted file with the wrong password raises."""
+ zip_path, inner_files, _ = sample_encrypted_zip
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test")
+ protected_hash = indexer._hash_path(inner_files["protected"])
+ with pytest.raises(RuntimeError):
+ list(indexer.get_file_by_hash(protected_hash, password=b"wrong"))
+
+ def test_get_unencrypted_file_without_password(
+ self, sample_zip: dict[str, Path], tmp_path: Path
+ ) -> None:
+ """Reading an unencrypted file works without a password."""
+ zip_path = tmp_path / "test_archive.zip"
+ indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt")
+ top_hash = indexer._hash_path("top.txt")
+ content = b"".join(indexer.get_file_by_hash(top_hash))
+ assert content == b"top level content"
+
+
+class TestEncryptedZipEndpoints:
+ """Tests for endpoints serving encrypted ZIP files."""
+
+ def _basic_auth(self, password: str) -> str:
+ """Build a Basic Auth header value."""
+ credentials = f"user:{password}"
+ return f"Basic {base64.b64encode(credentials.encode()).decode()}"
+
+ async def test_encrypted_file_requires_auth_on_data_endpoint(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """Encrypted file returns 401 without auth on /api/{hash}/data."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(f"/api/{hash_val}/data")
+ assert response.status_code == 401
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_encrypted_file_requires_auth_on_hash_page(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """Encrypted file returns 401 without auth on /{hash}."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(f"/{hash_val}")
+ assert response.status_code == 401
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_encrypted_file_succeeds_with_correct_auth(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """Encrypted file returns 200 with correct auth on /api/{hash}/data."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(
+ f"/api/{hash_val}/data",
+ headers={"Authorization": self._basic_auth("secret123")},
+ )
+ assert response.status_code == 200
+ assert response.content == b"secret content"
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_encrypted_file_wrong_password_returns_401(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """Encrypted file returns 401 with wrong password."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(
+ f"/api/{hash_val}/data",
+ headers={"Authorization": self._basic_auth("wrong")},
+ )
+ assert response.status_code == 401
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_www_authenticate_header_present(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """401 response includes WWW-Authenticate header."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(f"/api/{hash_val}/data")
+ assert response.status_code == 401
+ assert "www-authenticate" in response.headers
+ assert "Basic" in response.headers["www-authenticate"]
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_encrypted_hash_page_succeeds_with_auth(
+ self, client_encrypted_zip: AsyncClient
+ ) -> None:
+ """Encrypted file hash page returns 200 with correct auth."""
+ for hash_val, filepath in main.file_mapping.items():
+ if filepath == "protected.txt":
+ response = await client_encrypted_zip.get(
+ f"/{hash_val}",
+ headers={"Authorization": self._basic_auth("secret123")},
+ )
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ break
+ else:
+ pytest.fail("protected.txt not found in file_mapping")
+
+ async def test_unencrypted_zip_no_auth_required(
+ self, client_zip: AsyncClient
+ ) -> None:
+ """Files from an unencrypted ZIP work without any auth."""
+ file_hash = list(main.file_mapping.keys())[0]
+ response = await client_zip.get(f"/api/{file_hash}/data")
+ assert response.status_code == 200
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
index a2eb5e2..f94ce2e 100644
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -1,6 +1,5 @@
"""Tests for FastAPI endpoints."""
-import pytest
from httpx import AsyncClient
import main
diff --git a/tests/test_file_indexer.py b/tests/test_file_indexer.py
index 23ae171..2f7f810 100644
--- a/tests/test_file_indexer.py
+++ b/tests/test_file_indexer.py
@@ -3,8 +3,6 @@
import hashlib
from pathlib import Path
-import pytest
-
from main import FileIndexer
diff --git a/tests/test_zip_indexer.py b/tests/test_zip_indexer.py
index 7140b39..0260626 100644
--- a/tests/test_zip_indexer.py
+++ b/tests/test_zip_indexer.py
@@ -2,8 +2,6 @@
from pathlib import Path
-import pytest
-
from main import ZipFileIndexer
diff --git a/uv.lock b/uv.lock
index 9b8a7b3..9acde75 100644
--- a/uv.lock
+++ b/uv.lock
@@ -124,6 +124,7 @@ dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "pyzipper" },
{ name = "ruff" },
]
@@ -141,6 +142,7 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
+ { name = "pyzipper", specifier = ">=0.3.6" },
{ name = "ruff", specifier = ">=0.15.5" },
]
@@ -244,6 +246,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" },
+ { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" },
+ { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" },
+ { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" },
+ { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" },
+ { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -373,6 +405,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
+[[package]]
+name = "pyzipper"
+version = "0.3.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycryptodomex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/97/2f03c67b40e531b30f0e1357476b4db989097a92cd30c6d2389cfa12db49/pyzipper-0.3.6.tar.gz", hash = "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc", size = 31377, upload-time = "2022-07-31T09:58:34.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/b8/9d5d7cf4d96db8efa39f232fb152e87231fdaa5072229e6517f77a18d9c7/pyzipper-0.3.6-py2.py3-none-any.whl", hash = "sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87", size = 67652, upload-time = "2022-07-31T09:58:31.945Z" },
+]
+
[[package]]
name = "ruff"
version = "0.15.5"