fix(cv): isolate document parsing

Run untrusted document decoders in a secret-free, resource-bounded child process and terminate its process tree on deadline. Harden the production container and enforce parser and lint gates in CI.
This commit is contained in:
cesnimda
2026-08-30 11:12:25 +02:00
parent a8bf505ce5
commit 19c5251612
13 changed files with 677 additions and 176 deletions
+9 -1
View File
@@ -2,8 +2,11 @@ FROM python:3.12.10-slim-bookworm
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TRANSFORMERS_NO_TF=1 \
HF_HUB_DISABLE_TELEMETRY=1
HF_HUB_DISABLE_TELEMETRY=1 \
HF_HOME=/home/app/.cache/huggingface \
PARSER_TEMP_ROOT=/tmp
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
@@ -12,5 +15,10 @@ COPY requirements-linux.lock ./
RUN python -m pip install --upgrade pip setuptools wheel \
&& python -m pip install --require-hashes --extra-index-url https://download.pytorch.org/whl/cpu -r requirements-linux.lock
COPY . .
RUN groupadd --system app \
&& useradd --system --gid app --home-dir /home/app --create-home app \
&& mkdir -p /home/app/.cache/huggingface \
&& chown -R app:app /app /home/app
USER app
EXPOSE 8001
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
+8
View File
@@ -39,6 +39,14 @@ If the host is missing `python3-venv` or `pip`, use the bootstrap script instead
## Docker
The Dockerfile installs Tesseract OCR so scanned PDFs and supported images can be processed inside the container.
## Document parser isolation
`/extract-text` performs only bounded upload and signature checks in the web process. Actual TXT, Markdown, PDF, DOCX and image decoding runs in a single-capacity child process with a minimal environment. Linux children receive CPU, address-space, output-size and file-descriptor limits; every platform enforces a parent deadline and terminates the parser process tree on timeout.
Production Compose runs the service as a non-root user with a read-only root filesystem, no Linux capabilities, `no-new-privileges`, PID/CPU/memory limits and a bounded `/tmp` tmpfs. The Hugging Face cache is the only named writable volume. Parser work directories are removed after each request and stale `work-*` directories are reconciled on the next extraction.
Parser limits can be made more conservative with `PARSER_TIMEOUT_SECONDS`, `PARSER_CPU_SECONDS`, `PARSER_MEMORY_MIB` and `PARSER_STALE_WORK_SECONDS`. Increasing them should follow measured benign-CV evidence; disabling import is the safe fallback when the budget is insufficient.
## Tests
Run the summarizer unit tests with:
+158 -141
View File
@@ -3,18 +3,20 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from cachetools import TTLCache
from PIL import Image
from pypdf import PdfReader
from docx import Document
import fitz
import asyncio
import hashlib
import hmac
import io
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import torch
import pytesseract
import threading
import time
import zipfile
@@ -94,17 +96,22 @@ MAX_INPUT_CHARS = 20000
MAX_CONTEXT_CHARS = 2200
MAX_EXTRACT_FILE_BYTES = 5 * 1024 * 1024
MAX_EXTRACT_CHARS = 200_000
MAX_PDF_PAGES = 40
MAX_DOCX_ENTRIES = 256
MAX_DOCX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
MAX_DOCX_ENTRY_BYTES = 8 * 1024 * 1024
MAX_DOCX_COMPRESSION_RATIO = 100
MAX_IMAGE_DIMENSION = 12_000
MAX_IMAGE_PIXELS = 40_000_000
MAX_PDF_PAGE_PIXELS = 12_000_000
MAX_PDF_OCR_PIXELS = 120_000_000
OCR_LANGUAGES = "eng"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
PARSER_CHILD_PATH = Path(__file__).with_name("parser_child.py")
PARSER_TIMEOUT_SECONDS = max(5, min(int(os.getenv("PARSER_TIMEOUT_SECONDS", "25")), 120))
PARSER_CPU_SECONDS = max(2, min(int(os.getenv("PARSER_CPU_SECONDS", "20")), PARSER_TIMEOUT_SECONDS))
PARSER_MEMORY_BYTES = max(256, min(int(os.getenv("PARSER_MEMORY_MIB", "512")), 2048)) * 1024 * 1024
PARSER_MAX_RESULT_BYTES = 1024 * 1024
_parser_capacity = threading.BoundedSemaphore(value=1)
PARSER_TEMP_ROOT = Path(os.getenv("PARSER_TEMP_ROOT", tempfile.gettempdir()), "jobtracker-cv-parser").resolve()
PARSER_STALE_WORK_SECONDS = max(300, min(int(os.getenv("PARSER_STALE_WORK_SECONDS", "3600")), 86_400))
_parser_temp_lock = threading.Lock()
_parser_temp_ready = False
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "")
@@ -280,6 +287,11 @@ async def health():
"gpu_name": GPU_NAME,
"ocr_available": True,
"ocr_languages": OCR_LANGUAGES,
"parser_isolated": True,
"parser_concurrency": 1,
"parser_timeout_seconds": PARSER_TIMEOUT_SECONDS,
"parser_cpu_seconds": PARSER_CPU_SECONDS,
"parser_memory_mib": PARSER_MEMORY_BYTES // (1024 * 1024),
"model_loaded": MODEL_LOADED,
"model_disabled": MODEL_DISABLED,
"summarize_available": MODEL_LOADED and not MODEL_DISABLED,
@@ -1055,47 +1067,6 @@ async def purge_cache():
return {"cleared": cleared}
def _normalize_text(value: str) -> str:
"""Normalize extraction noise without destroying section, bullet, or table boundaries."""
value = value.replace("\x00", " ").replace("\r\n", "\n").replace("\r", "\n")
lines = []
blank = False
for raw_line in value.split("\n"):
line = re.sub(r"[\t\f\v]+", " ", raw_line).strip()
if not line:
if lines and not blank:
lines.append("")
blank = True
continue
lines.append(line)
blank = False
return "\n".join(lines).strip()
def _ocr_image(image: Image.Image) -> str:
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
text = pytesseract.image_to_string(image, lang=OCR_LANGUAGES)
return _normalize_text(text)
def _check_extracted_size(text: str) -> str:
if len(text) > MAX_EXTRACT_CHARS:
raise HTTPException(status_code=422, detail="The document contains too much extracted text.")
return text
def _check_image_size(image: Image.Image, *, pixel_limit: int | None = None) -> None:
pixel_limit = MAX_IMAGE_PIXELS if pixel_limit is None else pixel_limit
width, height = image.size
if width <= 0 or height <= 0 or width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
raise HTTPException(status_code=422, detail="The document image dimensions are not supported.")
if width * height > pixel_limit:
raise HTTPException(status_code=422, detail="The document image contains too many pixels.")
if getattr(image, "n_frames", 1) != 1:
raise HTTPException(status_code=422, detail="Multi-frame document images are not supported.")
def _validate_docx_container(data: bytes) -> None:
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
@@ -1137,76 +1108,144 @@ def _validate_file_signature(extension: str, data: bytes) -> None:
raise HTTPException(status_code=400, detail="The uploaded text file contains binary data.")
def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
page_count = 0
extracted_pages = []
try:
reader = PdfReader(io.BytesIO(data))
page_count = len(reader.pages)
if page_count > MAX_PDF_PAGES:
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
for page in reader.pages:
_PARSER_ERRORS = {
"unsupported_type": (400, "This file type is not supported for AI extraction."),
"text_encoding": (400, "The uploaded text file is not valid UTF-8."),
"docx_signature": (400, "The uploaded file does not match its DOCX extension."),
"docx_entry_limit": (422, "The DOCX contains too many files."),
"docx_entry_size": (422, "The DOCX contains an oversized file."),
"docx_ratio": (422, "The DOCX compression ratio is not supported."),
"docx_expanded_size": (422, "The DOCX expands beyond the supported size."),
"pdf_page_limit": (422, "The PDF contains too many pages."),
"pdf_ocr_limit": (422, "The PDF requires too much OCR processing."),
"image_dimensions": (422, "The document image dimensions are not supported."),
"image_pixel_limit": (422, "The document image contains too many pixels."),
"image_frames": (422, "Multi-frame document images are not supported."),
"extracted_text_limit": (422, "The document contains too much extracted text."),
"no_text": (422, "AI extraction did not find readable text in the uploaded file."),
"parser_failed": (422, "Document extraction failed."),
}
def _parser_environment(temp_root: str) -> dict[str, str]:
allowed = ("PATH", "LANG", "LC_ALL", "TESSDATA_PREFIX", "SYSTEMROOT", "WINDIR")
environment = {key: os.environ[key] for key in allowed if os.environ.get(key)}
environment.update({
"PYTHONUTF8": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"OMP_NUM_THREADS": "1",
"OPENBLAS_NUM_THREADS": "1",
"TMPDIR": temp_root,
"TEMP": temp_root,
"TMP": temp_root,
})
return environment
def _prepare_parser_temp_root() -> Path:
global _parser_temp_ready
with _parser_temp_lock:
PARSER_TEMP_ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
if _parser_temp_ready:
return PARSER_TEMP_ROOT
cutoff = time.time() - PARSER_STALE_WORK_SECONDS
for candidate in PARSER_TEMP_ROOT.glob("work-*"):
try:
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
except TypeError:
extracted_pages.append(page.extract_text() or "")
except HTTPException:
raise
except Exception:
extracted_pages = []
text = _check_extracted_size(_normalize_text("\n".join(extracted_pages)))
if len(text) >= 80:
return text, False, page_count
doc = fitz.open(stream=data, filetype="pdf")
page_count = doc.page_count
if page_count > MAX_PDF_PAGES:
doc.close()
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
ocr_pages = []
total_pixels = 0
for page in doc:
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
page_pixels = pix.width * pix.height
total_pixels += page_pixels
if page_pixels > MAX_PDF_PAGE_PIXELS or total_pixels > MAX_PDF_OCR_PIXELS:
doc.close()
raise HTTPException(status_code=422, detail="The PDF requires too much OCR processing.")
image = Image.open(io.BytesIO(pix.tobytes("png")))
_check_image_size(image, pixel_limit=MAX_PDF_PAGE_PIXELS)
ocr_pages.append(_ocr_image(image))
_check_extracted_size("\n".join(ocr_pages))
doc.close()
return _check_extracted_size(_normalize_text("\n".join(ocr_pages))), True, page_count
if candidate.is_symlink() or not candidate.is_dir():
continue
resolved = candidate.resolve()
if resolved.parent != PARSER_TEMP_ROOT or candidate.stat().st_mtime > cutoff:
continue
shutil.rmtree(resolved)
except OSError:
continue
_parser_temp_ready = True
return PARSER_TEMP_ROOT
def _extract_docx_text(data: bytes) -> str:
_validate_docx_container(data)
document = Document(io.BytesIO(data))
parts = []
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
for block in blocks:
if hasattr(block, "rows"):
for row in block.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text and cell.text.strip()]
if cells:
parts.append(" | ".join(cells))
elif getattr(block, "text", "").strip():
text = block.text.strip()
style = (getattr(getattr(block, "style", None), "name", "") or "").lower()
if "role" in style and parts:
parts.append("")
parts.append(f"- {text}" if "bullet" in style else text)
return _check_extracted_size(_normalize_text("\n".join(parts)))
def _terminate_parser_process(process: subprocess.Popen) -> None:
if process.poll() is not None:
return
if os.name == "posix":
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
try:
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
process.kill()
def _extract_plain_text(data: bytes) -> str:
def _raise_parser_error(code: str) -> None:
fallback = _PARSER_ERRORS["parser_failed"]
status_code, detail = _PARSER_ERRORS.get(code, fallback) if isinstance(code, str) else fallback
raise HTTPException(status_code=status_code, detail=detail)
def _run_parser_child(extension: str, data: bytes) -> dict[str, object]:
if not _parser_capacity.acquire(blocking=False):
raise HTTPException(status_code=503, detail="Document extraction is busy. Try again shortly.")
try:
decoded = data.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="The uploaded text file is not valid UTF-8.") from exc
return _check_extracted_size(_normalize_text(decoded))
with tempfile.TemporaryDirectory(prefix="work-", dir=_prepare_parser_temp_root()) as temp_root:
input_path = Path(temp_root, "input.bin")
output_path = Path(temp_root, "result.json")
input_path.write_bytes(data)
command = [
sys.executable,
str(PARSER_CHILD_PATH),
"--input", str(input_path),
"--output", str(output_path),
"--extension", extension,
"--cpu-seconds", str(PARSER_CPU_SECONDS),
"--memory-bytes", str(PARSER_MEMORY_BYTES),
]
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
try:
process = subprocess.Popen(
command,
cwd=temp_root,
env=_parser_environment(temp_root),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=os.name == "posix",
creationflags=creation_flags,
)
except OSError:
_raise_parser_error("parser_failed")
try:
process.wait(timeout=PARSER_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired as exc:
_terminate_parser_process(process)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
raise HTTPException(status_code=504, detail="Document extraction timed out.") from exc
if not output_path.exists() or output_path.stat().st_size > PARSER_MAX_RESULT_BYTES:
_raise_parser_error("parser_failed")
try:
result = json.loads(output_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
_raise_parser_error("parser_failed")
if not isinstance(result, dict) or result.get("ok") is not True:
_raise_parser_error(result.get("code") if isinstance(result, dict) else "parser_failed")
text = result.get("text")
if not isinstance(text, str) or not text or len(text) > MAX_EXTRACT_CHARS:
_raise_parser_error("parser_failed")
return result
finally:
_parser_capacity.release()
@app.post("/extract-text")
@@ -1228,36 +1267,14 @@ async def extract_text(file: UploadFile = File(...)):
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
_validate_file_signature(extension, data)
try:
if extension in {".txt", ".md"}:
text = _extract_plain_text(data)
ocr_used = False
page_count = None
elif extension == ".docx":
text = _extract_docx_text(data)
ocr_used = False
page_count = None
elif extension == ".pdf":
text, ocr_used, page_count = _extract_pdf_text(data)
elif extension in IMAGE_EXTENSIONS:
image = Image.open(io.BytesIO(data))
_check_image_size(image)
text = _ocr_image(image)
ocr_used = True
page_count = 1
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail="Document extraction failed.") from exc
if not text:
raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.")
result = await asyncio.to_thread(_run_parser_child, extension, data)
text = result["text"]
return {
"text": text,
"ocr_used": ocr_used,
"ocr_used": result.get("ocr_used", False),
"content_type": file.content_type,
"page_count": page_count,
"page_count": result.get("page_count"),
"characters": len(text),
"file_name": filename,
}
+253
View File
@@ -0,0 +1,253 @@
"""Resource-bounded CV document decoder invoked only as a child process."""
from __future__ import annotations
import argparse
import io
import json
import os
from pathlib import Path
import re
import sys
import zipfile
MAX_EXTRACT_CHARS = 200_000
MAX_PDF_PAGES = 40
MAX_DOCX_ENTRIES = 256
MAX_DOCX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
MAX_DOCX_ENTRY_BYTES = 8 * 1024 * 1024
MAX_DOCX_COMPRESSION_RATIO = 100
MAX_IMAGE_DIMENSION = 12_000
MAX_IMAGE_PIXELS = 40_000_000
MAX_PDF_PAGE_PIXELS = 12_000_000
MAX_PDF_OCR_PIXELS = 120_000_000
OCR_LANGUAGES = "eng"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
class ParserError(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _apply_resource_limits(cpu_seconds: int, memory_bytes: int) -> None:
if os.name != "posix":
return
import resource
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds + 1))
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
resource.setrlimit(resource.RLIMIT_FSIZE, (1024 * 1024, 1024 * 1024))
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
def normalize_text(value: str) -> str:
value = value.replace("\x00", " ").replace("\r\n", "\n").replace("\r", "\n")
lines: list[str] = []
blank = False
for raw_line in value.split("\n"):
line = re.sub(r"[\t\f\v]+", " ", raw_line).strip()
if not line:
if lines and not blank:
lines.append("")
blank = True
continue
lines.append(line)
blank = False
return "\n".join(lines).strip()
def _check_extracted_size(text: str, limit: int = MAX_EXTRACT_CHARS) -> str:
if len(text) > limit:
raise ParserError("extracted_text_limit")
return text
def _check_image_size(image, *, pixel_limit: int = MAX_IMAGE_PIXELS) -> None:
width, height = image.size
if width <= 0 or height <= 0 or width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
raise ParserError("image_dimensions")
if width * height > pixel_limit:
raise ParserError("image_pixel_limit")
if getattr(image, "n_frames", 1) != 1:
raise ParserError("image_frames")
def validate_docx_container(
data: bytes,
*,
entry_limit: int = MAX_DOCX_ENTRIES,
total_limit: int = MAX_DOCX_UNCOMPRESSED_BYTES,
entry_size_limit: int = MAX_DOCX_ENTRY_BYTES,
ratio_limit: int = MAX_DOCX_COMPRESSION_RATIO,
) -> None:
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entries = archive.infolist()
names = {entry.filename for entry in entries}
if "[Content_Types].xml" not in names or "word/document.xml" not in names:
raise ParserError("docx_signature")
if len(entries) > entry_limit:
raise ParserError("docx_entry_limit")
total_size = 0
for entry in entries:
total_size += entry.file_size
if entry.file_size > entry_size_limit:
raise ParserError("docx_entry_size")
if entry.file_size > 0 and entry.compress_size == 0:
raise ParserError("docx_ratio")
if entry.compress_size > 0 and entry.file_size / entry.compress_size > ratio_limit:
raise ParserError("docx_ratio")
if total_size > total_limit:
raise ParserError("docx_expanded_size")
except ParserError:
raise
except (zipfile.BadZipFile, OSError) as exc:
raise ParserError("docx_signature") from exc
def extract_plain_text(data: bytes) -> str:
try:
decoded = data.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ParserError("text_encoding") from exc
return _check_extracted_size(normalize_text(decoded))
def extract_docx_text(data: bytes) -> str:
validate_docx_container(data)
from docx import Document
document = Document(io.BytesIO(data))
parts: list[str] = []
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
for block in blocks:
if hasattr(block, "rows"):
for row in block.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text and cell.text.strip()]
if cells:
parts.append(" | ".join(cells))
elif getattr(block, "text", "").strip():
text = block.text.strip()
style = (getattr(getattr(block, "style", None), "name", "") or "").lower()
if "role" in style and parts:
parts.append("")
parts.append(f"- {text}" if "bullet" in style else text)
return _check_extracted_size(normalize_text("\n".join(parts)))
def _ocr_image(image) -> str:
import pytesseract
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
return normalize_text(pytesseract.image_to_string(image, lang=OCR_LANGUAGES))
def extract_image_text(data: bytes, *, pixel_limit: int = MAX_IMAGE_PIXELS) -> str:
from PIL import Image
with Image.open(io.BytesIO(data)) as image:
_check_image_size(image, pixel_limit=pixel_limit)
return _check_extracted_size(_ocr_image(image))
def extract_pdf_text(data: bytes, *, page_limit: int = MAX_PDF_PAGES) -> tuple[str, bool, int]:
from pypdf import PdfReader
page_count = 0
extracted_pages: list[str] = []
try:
reader = PdfReader(io.BytesIO(data))
page_count = len(reader.pages)
if page_count > page_limit:
raise ParserError("pdf_page_limit")
for page in reader.pages:
try:
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
except TypeError:
extracted_pages.append(page.extract_text() or "")
except ParserError:
raise
except Exception:
extracted_pages = []
text = _check_extracted_size(normalize_text("\n".join(extracted_pages)))
if len(text) >= 80:
return text, False, page_count
import fitz
from PIL import Image
document = fitz.open(stream=data, filetype="pdf")
try:
page_count = document.page_count
if page_count > page_limit:
raise ParserError("pdf_page_limit")
ocr_pages: list[str] = []
total_pixels = 0
for page in document:
pixmap = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
page_pixels = pixmap.width * pixmap.height
total_pixels += page_pixels
if page_pixels > MAX_PDF_PAGE_PIXELS or total_pixels > MAX_PDF_OCR_PIXELS:
raise ParserError("pdf_ocr_limit")
with Image.open(io.BytesIO(pixmap.tobytes("png"))) as image:
_check_image_size(image, pixel_limit=MAX_PDF_PAGE_PIXELS)
ocr_pages.append(_ocr_image(image))
_check_extracted_size("\n".join(ocr_pages))
return _check_extracted_size(normalize_text("\n".join(ocr_pages))), True, page_count
finally:
document.close()
def extract(extension: str, data: bytes) -> dict[str, object]:
if extension in {".txt", ".md"}:
text = extract_plain_text(data)
return {"text": text, "ocr_used": False, "page_count": None}
if extension == ".docx":
text = extract_docx_text(data)
return {"text": text, "ocr_used": False, "page_count": None}
if extension == ".pdf":
text, ocr_used, page_count = extract_pdf_text(data)
return {"text": text, "ocr_used": ocr_used, "page_count": page_count}
if extension in IMAGE_EXTENSIONS:
text = extract_image_text(data)
return {"text": text, "ocr_used": True, "page_count": 1}
raise ParserError("unsupported_type")
def _write_result(path: Path, result: dict[str, object]) -> None:
path.write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--extension", required=True)
parser.add_argument("--cpu-seconds", type=int, default=20)
parser.add_argument("--memory-bytes", type=int, default=512 * 1024 * 1024)
args = parser.parse_args()
output_path = Path(args.output)
try:
_apply_resource_limits(args.cpu_seconds, args.memory_bytes)
data = Path(args.input).read_bytes()
result = extract(args.extension.lower(), data)
if not result.get("text"):
raise ParserError("no_text")
_write_result(output_path, {"ok": True, **result})
return 0
except ParserError as exc:
_write_result(output_path, {"ok": False, "code": exc.code})
return 2
except BaseException:
_write_result(output_path, {"ok": False, "code": "parser_failed"})
return 3
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,3 +1,3 @@
-r requirements.txt
pytest==8.3.5
httpx==0.28.1
httpx2==2.12.0
+153 -23
View File
@@ -1,10 +1,14 @@
import importlib
import io
import json
import os
import subprocess
import sys
import textwrap
import zipfile
from pathlib import Path
from docx import Document
from fastapi.testclient import TestClient
from PIL import Image
from pypdf import PdfWriter
@@ -67,6 +71,10 @@ def test_health_reports_runtime_without_ollama_and_without_forcing_model_load(mo
assert payload["model_loaded"] is False
assert payload["model_disabled"] is True
assert payload["summarize_available"] is False
assert payload["parser_isolated"] is True
assert payload["parser_concurrency"] == 1
assert payload["parser_timeout_seconds"] == 25
assert payload["parser_memory_mib"] == 512
assert "disabled" in payload["model_load_error"].lower()
assert payload["ollama_configured"] is False
assert payload["ollama_model"] is None
@@ -517,7 +525,7 @@ def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch):
def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_EXTRACT_FILE_BYTES", 32)
monkeypatch.setattr(module, "_extract_plain_text", lambda data: (_ for _ in ()).throw(AssertionError("parser must not run")))
monkeypatch.setattr(module, "_run_parser_child", lambda extension, data: (_ for _ in ()).throw(AssertionError("parser must not run")))
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("large.txt", b"x" * 64, "text/plain")})
@@ -547,33 +555,36 @@ def test_extract_text_rejects_binary_plain_text(monkeypatch):
def test_extract_text_rejects_pdf_over_page_limit(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_PDF_PAGES", 1)
load_app_module(monkeypatch)
import parser_child
writer = PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_blank_page(width=100, height=100)
payload = io.BytesIO()
writer.write(payload)
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.pdf", payload.getvalue(), "application/pdf")})
assert response.status_code == 422
assert response.json()["detail"] == "The PDF contains too many pages."
try:
parser_child.extract_pdf_text(payload.getvalue(), page_limit=1)
except parser_child.ParserError as exc:
assert exc.code == "pdf_page_limit"
else:
raise AssertionError("Expected the PDF page limit to reject the document")
def test_extract_text_rejects_image_pixel_limit_before_ocr(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_IMAGE_PIXELS", 50)
monkeypatch.setattr(module, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
load_app_module(monkeypatch)
import parser_child
monkeypatch.setattr(parser_child, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
payload = io.BytesIO()
Image.new("RGB", (10, 10), "white").save(payload, format="PNG")
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.png", payload.getvalue(), "image/png")})
assert response.status_code == 422
assert response.json()["detail"] == "The document image contains too many pixels."
try:
parser_child.extract_image_text(payload.getvalue(), pixel_limit=50)
except parser_child.ParserError as exc:
assert exc.code == "image_pixel_limit"
else:
raise AssertionError("Expected the image pixel limit to reject the document")
def test_docx_container_rejects_excessive_entries(monkeypatch):
@@ -596,7 +607,7 @@ def test_docx_container_rejects_excessive_entries(monkeypatch):
def test_extract_text_does_not_leak_parser_exception(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "_extract_pdf_text", lambda data: (_ for _ in ()).throw(RuntimeError("private path C:/secret")))
monkeypatch.setattr(module, "_run_parser_child", lambda extension, data: module._raise_parser_error("parser_failed"))
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.pdf", b"%PDF-invalid", "application/pdf")})
@@ -607,16 +618,19 @@ def test_extract_text_does_not_leak_parser_exception(monkeypatch):
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
module = load_app_module(monkeypatch)
load_app_module(monkeypatch)
import parser_child
normalized = module._normalize_text("PROFILE\n\nEngineer\n• Built APIs\n• Shipped services")
normalized = parser_child.normalize_text("PROFILE\n\nEngineer\n• Built APIs\n• Shipped services")
assert normalized.splitlines() == ["PROFILE", "", "Engineer", "• Built APIs", "• Shipped services"]
def test_docx_extraction_preserves_paragraph_and_table_boundaries(monkeypatch):
module = load_app_module(monkeypatch)
document = module.Document()
load_app_module(monkeypatch)
import parser_child
document = Document()
document.add_heading("Technical Skills", level=1)
table = document.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Backend"
@@ -626,13 +640,129 @@ def test_docx_extraction_preserves_paragraph_and_table_boundaries(monkeypatch):
payload = io.BytesIO()
document.save(payload)
extracted = module._extract_docx_text(payload.getvalue())
extracted = parser_child.extract_docx_text(payload.getvalue())
assert "Technical Skills" in extracted
assert "Backend | C#, .NET" in extracted
assert "DevOps | Docker, Linux" in extracted
def test_extract_text_runs_in_isolated_child(monkeypatch):
module = load_app_module(monkeypatch)
client = TestClient(module.app)
response = client.post(
"/extract-text",
files={"file": ("resume.md", b"# Ada Lovelace\n\n## Skills\nC#", "text/markdown")},
)
assert response.status_code == 200
assert response.json()["text"] == "# Ada Lovelace\n\n## Skills\nC#"
def test_parser_child_receives_no_provider_secrets(monkeypatch, tmp_path):
module = load_app_module(monkeypatch)
monkeypatch.setenv("GEMINI_API_KEY", "must-not-leak")
monkeypatch.setenv("GROQ_API_KEY", "must-not-leak")
child = tmp_path / "environment_probe.py"
child.write_text(textwrap.dedent("""
import argparse, json, os
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('--input')
parser.add_argument('--output')
parser.add_argument('--extension')
parser.add_argument('--cpu-seconds')
parser.add_argument('--memory-bytes')
args = parser.parse_args()
leaked = any(os.environ.get(key) for key in ('GEMINI_API_KEY', 'GROQ_API_KEY', 'AI_SERVICE_TOKEN'))
Path(args.output).write_text(json.dumps({'ok': not leaked, 'text': 'safe', 'ocr_used': False, 'page_count': None} if not leaked else {'ok': False, 'code': 'parser_failed'}), encoding='utf-8')
"""), encoding="utf-8")
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
assert module._run_parser_child(".txt", b"safe")["text"] == "safe"
def test_parser_child_timeout_is_stable_and_releases_capacity(monkeypatch, tmp_path):
module = load_app_module(monkeypatch)
child = tmp_path / "sleeping_parser.py"
child.write_text("import time\ntime.sleep(30)\n", encoding="utf-8")
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
monkeypatch.setattr(module, "PARSER_TIMEOUT_SECONDS", 0.1)
try:
module._run_parser_child(".txt", b"safe")
except module.HTTPException as exc:
assert exc.status_code == 504
assert exc.detail == "Document extraction timed out."
else:
raise AssertionError("Expected the parser deadline to terminate the child")
assert module._parser_capacity.acquire(blocking=False)
module._parser_capacity.release()
def test_parser_timeout_terminates_descendants(monkeypatch, tmp_path):
module = load_app_module(monkeypatch)
pid_path = tmp_path / "descendant.pid"
child = tmp_path / "parser_with_descendant.py"
child.write_text(textwrap.dedent(f"""
import subprocess, sys, time
from pathlib import Path
descendant = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])
Path({str(pid_path)!r}).write_text(str(descendant.pid), encoding='ascii')
time.sleep(30)
"""), encoding="utf-8")
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
monkeypatch.setattr(module, "PARSER_TIMEOUT_SECONDS", 0.2)
try:
module._run_parser_child(".txt", b"safe")
except module.HTTPException as exc:
assert exc.status_code == 504
else:
raise AssertionError("Expected the parser deadline to terminate the process tree")
descendant_pid = int(pid_path.read_text(encoding="ascii"))
if os.name == "nt":
listing = subprocess.run(
["tasklist", "/FI", f"PID eq {descendant_pid}", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
check=False,
).stdout
assert f'"{descendant_pid}"' not in listing
else:
try:
os.kill(descendant_pid, 0)
except ProcessLookupError:
pass
else:
os.kill(descendant_pid, 9)
raise AssertionError("Parser descendant survived the parent deadline")
def test_parser_temp_cleanup_removes_only_stale_work_directories(monkeypatch, tmp_path):
module = load_app_module(monkeypatch)
parser_root = tmp_path / "parser-root"
stale = parser_root / "work-stale"
recent = parser_root / "work-recent"
unrelated = parser_root / "keep-me"
stale.mkdir(parents=True)
recent.mkdir()
unrelated.mkdir()
old = 1_000_000
os.utime(stale, (old, old))
monkeypatch.setattr(module, "PARSER_TEMP_ROOT", parser_root.resolve())
monkeypatch.setattr(module, "PARSER_STALE_WORK_SECONDS", 300)
monkeypatch.setattr(module, "_parser_temp_ready", False)
assert module._prepare_parser_temp_root() == parser_root.resolve()
assert not stale.exists()
assert recent.exists()
assert unrelated.exists()
def test_cache_purge_requires_service_token_and_clears_content(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
module.cache["synthetic-key"] = "synthetic-summary"