diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 04142dcc..aa581177 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -7,6 +7,7 @@ import mimetypes import os import secrets +import zipfile from base64 import b64encode from concurrent.futures import ThreadPoolExecutor from functools import partial @@ -26,10 +27,12 @@ status, ) from fastapi.responses import PlainTextResponse, StreamingResponse +from olefile.olefile import OleFileError # note: olefile <0.48 does not re-export it at top level from pypdf import PageObject, PdfReader, PdfWriter from pypdf.errors import FileNotDecryptedError, PdfReadError from starlette.datastructures import Headers from starlette.types import Send +from unstructured.errors import UnprocessableEntityError from prepline_general.api import __version__ as api_version from prepline_general.api.filetypes import get_validated_mimetype @@ -397,6 +400,24 @@ def pipeline_api( else: elements = partition(**partition_kwargs) # type: ignore # pyright: ignore[reportGeneralTypeIssues] + except OleFileError as e: + # OleFileError subclasses OSError, so it must be caught before the OSError + # handler below. Corrupt or truncated OLE files (.doc, .ppt, .msg) are a + # client-side problem and should not surface as a 500. + raise HTTPException( + status_code=400, + detail=f"File does not appear to be a valid OLE file: {e}", + ) + except zipfile.BadZipFile as e: + raise HTTPException( + status_code=400, + detail=f"File does not appear to be a valid OOXML file: {e}", + ) + except UnprocessableEntityError as e: + raise HTTPException( + status_code=400, + detail=str(e), + ) except OSError as e: if isinstance(e.args[0], str) and ( "chipper-fast-fine-tuning is not a local folder" in e.args[0] diff --git a/pyproject.toml b/pyproject.toml index 0668cd7e..706325f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "psutil >=7.0.0, <8.0.0", "pypdf >=6.0.0, <7.0.0", "requests >=2.32.0, <3.0.0", + # imported directly for OleFileError; also a transitive dep of unstructured + "olefile >=0.47, <1.0.0", ] [dependency-groups] diff --git a/test_general/api/test_app.py b/test_general/api/test_app.py index ac9cb6fb..999e6e4e 100644 --- a/test_general/api/test_app.py +++ b/test_general/api/test_app.py @@ -110,6 +110,34 @@ def test_general_api(example_filename, content_type): assert len(dfs) > 0 +@pytest.mark.parametrize( + "example_filename, content_type", + [ + ("fake.doc", "application/msword"), + ( + "fake-power-point.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ], +) +def test_truncated_binary_documents_return_400(example_filename, content_type): + """ + A truncated binary document is a client-side problem and must return 400, + not the catch-all 500. Truncating a valid fixture mid-file corrupts the + OLE/ZIP container without changing its detected file type. + """ + client = TestClient(app) + test_file_path = Path("sample-docs") / example_filename + with open(test_file_path, "rb") as f: + data = f.read() + response = client.post( + MAIN_API_ROUTE, + files=[("files", (example_filename, io.BytesIO(data[: len(data) // 2]), content_type))], + ) + + assert response.status_code == 400 + + def test_metadata_fields_removed(): """ Verify that responses do not include coordinates unless requested diff --git a/uv.lock b/uv.lock index 576451b6..7e09f575 100644 --- a/uv.lock +++ b/uv.lock @@ -2201,6 +2201,7 @@ source = { editable = "." } dependencies = [ { name = "backoff" }, { name = "fastapi" }, + { name = "olefile" }, { name = "pandas" }, { name = "psutil" }, { name = "pypdf" }, @@ -2237,6 +2238,7 @@ test = [ requires-dist = [ { name = "backoff", specifier = ">=2.2.1,<3.0.0" }, { name = "fastapi", specifier = ">=0.128.4,<1.0.0" }, + { name = "olefile", specifier = ">=0.47,<1.0.0" }, { name = "pandas", specifier = ">=3.0.0,<4.0.0" }, { name = "psutil", specifier = ">=7.0.0,<8.0.0" }, { name = "pypdf", specifier = ">=6.0.0,<7.0.0" },