diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 24149bf4..1ed36186 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -150,7 +150,10 @@ This document tracks security vulnerabilities found in DocuElevate and their rem **Fix:** Implemented configurable file upload size limits with the following features: - `MAX_UPLOAD_SIZE`: Maximum file upload size in bytes (default: 1GB) - `MAX_SINGLE_FILE_SIZE`: Optional maximum size for a single file chunk -- Automatic file splitting for large PDFs when max_single_file_size is configured +- **Automatic page-based PDF splitting** for large PDFs when max_single_file_size is configured + - Splits PDFs at **page boundaries** using PyPDF2, NOT by byte position + - Each output file is a structurally valid, complete PDF + - No risk of corrupted or broken PDF files - Split files are processed sequentially to prevent overwhelming the system - Clear error messages referencing SECURITY_AUDIT.md for configuration details diff --git a/app/utils/file_splitting.py b/app/utils/file_splitting.py index b2b12b21..ea501769 100644 --- a/app/utils/file_splitting.py +++ b/app/utils/file_splitting.py @@ -19,8 +19,16 @@ def split_pdf_by_size(pdf_path: str, max_size_bytes: int, output_dir: Optional[s """ Split a PDF file into multiple smaller PDF files based on size constraints. - The function splits the PDF by distributing pages across multiple output files, - ensuring each output file stays under the specified size limit. + IMPORTANT: This function splits PDFs at PAGE BOUNDARIES, not by byte position. + It uses PyPDF2 to properly parse the PDF structure and distribute complete pages + across multiple valid output PDFs. This ensures: + - All output files are structurally valid and readable PDFs + - No corrupted or broken PDF files are created + - Each output file contains complete pages from the original document + + The function adds pages to the current output file until adding another page + would exceed the size limit, then starts a new output file. This is NOT a + simple byte-level file split - it respects PDF page boundaries. Args: pdf_path: Path to the PDF file to split @@ -28,15 +36,18 @@ def split_pdf_by_size(pdf_path: str, max_size_bytes: int, output_dir: Optional[s output_dir: Directory to save split files. If None, uses same directory as input file. Returns: - List of paths to the generated PDF files (in order) + List of paths to the generated PDF files (in order). Each file is a valid PDF + containing complete pages from the original document. Raises: FileNotFoundError: If the input PDF file doesn't exist ValueError: If max_size_bytes is too small to fit even one page Example: - >>> split_files = split_pdf_by_size("large.pdf", 50 * 1024 * 1024) # 50MB max + >>> # Split a large PDF into chunks of max 50MB each + >>> split_files = split_pdf_by_size("large.pdf", 50 * 1024 * 1024) >>> print(f"Split into {len(split_files)} files") + >>> # Each file is a valid PDF with complete pages """ # Validate input file exists if not os.path.exists(pdf_path): diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index c87b500c..7f79aca8 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -57,6 +57,11 @@ MAX_SINGLE_FILE_SIZE=524288000 **File Splitting Behavior:** - When `MAX_SINGLE_FILE_SIZE` is configured and a PDF exceeds this size, it is automatically split into smaller chunks +- **IMPORTANT:** Splitting is done at **PAGE BOUNDARIES**, not by byte position + - Uses PyPDF2 to properly parse PDF structure + - Each output file is a complete, valid PDF containing whole pages + - No risk of corrupted or broken PDF files + - Pages are distributed across output files to stay under size limit - Each chunk is processed sequentially as a separate task - Only works for PDF files (images and office documents are converted to PDF first) - Original file is removed after successful splitting diff --git a/tests/test_file_splitting.py b/tests/test_file_splitting.py index 880e35ba..1ab04c2b 100644 --- a/tests/test_file_splitting.py +++ b/tests/test_file_splitting.py @@ -181,6 +181,44 @@ class TestSplitPdfBySize: if os.path.exists(split_file): os.remove(split_file) + def test_split_pdfs_are_valid_and_readable(self, sample_multipage_pdf): + """Test that split PDFs are valid, complete PDFs that can be opened and read. + + This test verifies that PDF splitting is done at PAGE BOUNDARIES, + not by byte position, ensuring no corrupted/broken PDFs are created. + """ + max_size = 5000 # Small size to force splitting + + split_files = split_pdf_by_size(sample_multipage_pdf, max_size) + + try: + # Verify each split file is a valid, readable PDF + for split_file in split_files: + assert os.path.exists(split_file), f"Split file {split_file} should exist" + + # Try to open and read the PDF - this will fail if PDF is corrupted + try: + reader = PdfReader(split_file) + # Verify it has pages (not an empty or broken PDF) + assert len(reader.pages) > 0, f"Split PDF {split_file} should have pages" + + # Try to access first page content to ensure PDF structure is valid + first_page = reader.pages[0] + # If the PDF was corrupted by byte-splitting, this would raise an error + _ = first_page.extract_text() # This validates PDF structure + + except Exception as e: + pytest.fail( + f"Split PDF {split_file} is corrupted or unreadable. " + f"This indicates byte-level splitting instead of page-level splitting. Error: {e}" + ) + + finally: + # Cleanup + for split_file in split_files: + if os.path.exists(split_file): + os.remove(split_file) + @pytest.mark.unit class TestShouldSplitFile: