diff --git a/.gitignore b/.gitignore index 434ee8ad1..c485eb0cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees +secret_fixtures/ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 000000000..85745bc2b --- /dev/null +++ b/backend/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI(title="AI Email Client API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/") +def read_root() -> dict[str, str]: + return {"status": "ok", "message": "AI Email Client API"} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..4d7b0ddd2 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +pytest==8.0.0 +httpx==0.26.0 \ No newline at end of file diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/services/archive.py b/backend/services/archive.py new file mode 100644 index 000000000..4461a29f2 --- /dev/null +++ b/backend/services/archive.py @@ -0,0 +1,82 @@ +import asyncio +import zipfile +from pathlib import Path + +from .exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError + +MAX_EXTRACT_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB +MAX_FILE_COUNT = 100000 + +def extract_backup(zip_path: str | Path, output_dir: str | Path) -> list[Path]: + """ + Extracts a zip archive to the specified output directory. + + WARNING: This function performs blocking I/O operations. When called from + an async web layer (e.g. FastAPI), it must be executed in a thread pool using + `fastapi.concurrency.run_in_threadpool` or `asyncio.to_thread()`. + + Args: + zip_path: The path to the zip archive to extract. + output_dir: The directory where the contents should be extracted. + + Returns: + A list of Path objects for the extracted files (excluding directories). + + Raises: + InvalidArchiveError: If the zip file is not found or corrupted. + ArchiveSizeExceededError: If the uncompressed size exceeds the maximum allowed limit. + ArchiveFileCountExceededError: If the archive exceeds the maximum allowed file count. + """ + zip_path = Path(zip_path) + output_dir = Path(output_dir).resolve() + + output_dir.mkdir(parents=True, exist_ok=True) + extracted_paths = [] + + try: + with zipfile.ZipFile(zip_path, 'r') as z: + total_size = 0 + file_count = 0 + + for info in z.infolist(): + if info.is_dir(): + continue + + file_count += 1 + if file_count > MAX_FILE_COUNT: + raise ArchiveFileCountExceededError(f"Archive exceeds maximum allowed file count of {MAX_FILE_COUNT}.") + + # Sanitize path to prevent traversal + parts = [p for p in info.filename.replace('\\', '/').split('/') if p not in ('', '.', '..')] + if not parts: + continue + safe_name = "/".join(parts) + + target_path = (output_dir / safe_name).resolve() + + # Double-check that it is within output_dir + if not str(target_path).startswith(str(output_dir)): + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + + with z.open(info) as source, open(target_path, "wb") as target: + while chunk := source.read(8192): + total_size += len(chunk) + if total_size > MAX_EXTRACT_SIZE: + raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.") + target.write(chunk) + + extracted_paths.append(target_path) + + except (zipfile.BadZipFile, FileNotFoundError) as e: + raise InvalidArchiveError(f"Failed to extract archive: {e}") from e + + return extracted_paths + +async def extract_backup_async(zip_path: str | Path, output_dir: str | Path) -> list[Path]: + """ + Async wrapper for extract_backup to be used safely in async contexts. + """ + return await asyncio.to_thread(extract_backup, zip_path, output_dir) + diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py new file mode 100644 index 000000000..9ef033232 --- /dev/null +++ b/backend/services/exceptions.py @@ -0,0 +1,15 @@ +class ArchiveError(Exception): + """Base exception for archive operations.""" + pass + +class InvalidArchiveError(ArchiveError): + """Raised when an archive is invalid, corrupted, or not found.""" + pass + +class ArchiveSizeExceededError(ArchiveError): + """Raised when an archive exceeds the maximum allowed extracted size.""" + pass + +class ArchiveFileCountExceededError(ArchiveError): + """Raised when an archive exceeds the maximum allowed number of files.""" + pass diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py new file mode 100644 index 000000000..f08149b06 --- /dev/null +++ b/backend/tests/test_archive.py @@ -0,0 +1,92 @@ +import os +import zipfile +import pytest +import asyncio +from pathlib import Path + +from services.archive import extract_backup, extract_backup_async +from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError + +def test_extract_backup_success(tmp_path): + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("test.eml", b"Subject: Test Email") + z.writestr("folder/", b"") + z.writestr("folder/test2.eml", b"Subject: Test Email 2") + + out_dir = tmp_path / "output" + extracted_files = extract_backup(zip_path, out_dir) + + # Check only files are returned, not directories + assert len(extracted_files) == 2 + assert any(f.name == "test.eml" for f in extracted_files) + assert any(f.name == "test2.eml" for f in extracted_files) + assert not any(f.name == "folder" for f in extracted_files) + + for f in extracted_files: + assert f.exists() + assert f.is_file() + +def test_extract_backup_file_not_found(tmp_path): + with pytest.raises(InvalidArchiveError, match="Failed to extract archive"): + extract_backup(tmp_path / "missing.zip", tmp_path / "output") + +def test_extract_backup_bad_zip_file(tmp_path): + bad_zip = tmp_path / "bad.zip" + bad_zip.write_text("This is not a zip file") + + with pytest.raises(InvalidArchiveError, match="Failed to extract archive"): + extract_backup(bad_zip, tmp_path / "output") + +def test_extract_backup_size_exceeded(tmp_path, monkeypatch): + import services.archive + monkeypatch.setattr(services.archive, "MAX_EXTRACT_SIZE", 10) # 10 bytes limit + + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("large.txt", b"A" * 20) # 20 bytes + + with pytest.raises(ArchiveSizeExceededError, match="Archive exceeds maximum allowed extraction size"): + extract_backup(zip_path, tmp_path / "output") + +def test_extract_backup_malformed_path(tmp_path): + zip_path = tmp_path / "malformed.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + # Create a file with a relative path trying to escape directory + z.writestr("../malformed.eml", b"Subject: Malformed Email") + + out_dir = tmp_path / "output" + extracted_files = extract_backup(zip_path, out_dir) + + assert len(extracted_files) == 1 + assert extracted_files[0].name == "malformed.eml" + assert ".." not in str(extracted_files[0]) + for f in extracted_files: + assert f.exists() + assert f.is_file() + +def test_extract_backup_file_count_exceeded(tmp_path, monkeypatch): + import services.archive + monkeypatch.setattr(services.archive, "MAX_FILE_COUNT", 2) + + zip_path = tmp_path / "test_count.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("file1.txt", b"1") + z.writestr("file2.txt", b"2") + z.writestr("file3.txt", b"3") + + with pytest.raises(ArchiveFileCountExceededError, match="Archive exceeds maximum allowed file count"): + extract_backup(zip_path, tmp_path / "output") + +def test_extract_backup_async(tmp_path): + zip_path = tmp_path / "test_async.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("test.eml", b"Subject: Test Email") + + out_dir = tmp_path / "output" + extracted_files = asyncio.run(extract_backup_async(zip_path, out_dir)) + + assert len(extracted_files) == 1 + assert extracted_files[0].name == "test.eml" + assert extracted_files[0].exists() + diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 000000000..893bbe7ad --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,9 @@ +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "message": "AI Email Client API"} \ No newline at end of file