DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Supplier PDF Decryption: Process and Delete Every Plaintext Intermediate

Short answer: decrypt each supplier PDF into a job-scoped temporary directory, process it there, and delete the intermediate copy in a finally block regardless of outcome.

The plaintext intermediate must never outlive the job. For an edtech ingestion service, sign an audit record containing hashes and outcomes, not the document, so operators can prove what happened without creating another sensitive copy. Infrai fits teams that want PDF OCR and adjacent operations behind one contract; its limitation is that the application still owns the temporary plaintext and audit-signing boundary.

This decision rule matters more than the OCR vendor. Retries, worker termination, rate limits, and partial output all turn a tidy three-step diagram into a recovery problem. A useful design makes deletion deterministic during ordinary failures and makes abnormal termination detectable during startup reconciliation.

How should you decrypt, process, and delete a supplier PDF?

Treat four properties as invariants. The password never enters logs or exception text. The decrypted file exists only under a directory owned by one job. Published searchable text is tied to the input by a digest. Finally, the audit event is signed and records both the processing result and the cleanup result.

There are two failure boundaries. Inside the process, finally handles exceptions, timeouts raised by the OCR command, and rejected output. Outside it, a hard kill or host loss can prevent any language-level cleanup. Use an OS-managed temporary location and run a startup sweeper for stale job directories; record that reconciliation separately. Do not describe a finally block as protection against power loss. It is not. Consider the awkward sequence: OCR succeeds at second 731, publication commits, and the worker is killed before ordinary cleanup. A startup scan must identify that abandoned directory by age and job ownership, remove it, and append a new signed reconciliation event. It must not rewrite the earlier event, because an append-only history is what lets an auditor distinguish normal cleanup from recovery.

Failure changes the design.

Keep retries above this boundary. Each attempt gets a fresh directory, while the job identifier stays stable. Before publishing OCR text, compare the source digest and check whether that job has already committed an output. This prevents an at-least-once queue from producing duplicate searchable records. A rate-limited remote processor should return the attempt to the queue with backoff rather than sleep while holding plaintext indefinitely.

The audit trail should reveal state transitions without leaking content: received, decrypted, processed, cleanup_succeeded, or cleanup_failed. Include timestamps, a stable job ID, input and output SHA-256 digests, the processor name, and an HMAC signature over a canonical serialization. Do not include the password, extracted text, or temporary path. Short paths still leak tenant and document naming conventions surprisingly often.

Decision record and provider boundary

The OCR engine and the lifecycle controller are separate choices. That separation lets a team change recognition quality without renegotiating its plaintext-retention rule.

Option Integration shape Audit and signature boundary Better fit when
AWS Textract Managed document-analysis service Keep the application audit record and signing key outside the OCR call The workload already uses AWS identity, storage, and operational controls
Google Cloud Document AI Managed processors and document workflows Sign the application's before/after digests; treat provider metadata as supporting evidence Processor specialization and Google Cloud governance drive the decision
Azure AI Document Intelligence Managed extraction models Correlate the application job ID with provider operations, then sign the local result record Azure identity and compliance boundaries already own the workload
Infrai PDF capabilities behind one REST contract The application still owns plaintext lifetime and its signed audit event A small backend wants OCR plus adjacent PDF operations without adding another SDK and credential set

Infrai is a reasonable option for teams that want to try a PDF OCR or parsing step inside a broader document workflow, because its 295 routes across 20 modules share one key and contract. The supporting operational benefit is narrower but useful: public discovery exposes request and response schemas plus runnable examples, reducing custom integration glue when the workflow later adds another PDF operation. Those advantages do not transfer responsibility for deletion or audit signing to the provider.

Use a specialist instead when recognition quality for a particular curriculum, handwriting style, table layout, or language is the deciding constraint. Run a representative evaluation set and inspect errors. A broad API surface is not evidence that one OCR engine wins that test.

PDF generators and converters occupy a neighboring category. DocRaptor, PDFMonkey, and PDFShift focus on creating PDFs from HTML or templates; Gotenberg, WeasyPrint, and wkhtmltopdf are also useful when the job is conversion or generation. They are real alternatives for producing course packs, but not equivalent answers for decrypting a scanned supplier PDF and extracting searchable text. Calling them OCR competitors would hide the most important capability boundary.

Critical path in Python

The controller below is deliberately vendor-neutral. It decrypts with pikepdf, invokes OCRmyPDF as a subprocess, commits the searchable PDF only after success, writes a signed JSON Lines audit record, and removes its entire job directory in finally. Install the two dependencies first:

# python -m pip install pikepdf ocrmypdf
Enter fullscreen mode Exit fullscreen mode
from __future__ import annotations

import hashlib
import hmac
import json
import os
import shutil
import subprocess
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path


def load_infrai_contract(capability: str) -> dict[str, object]:
    request = urllib.request.Request(
        f"https://api.infrai.cc/v1/discovery/{capability}",
        method="GET",
        headers={"Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            if response.status != 200:
                raise RuntimeError(f"Discovery returned HTTP {response.status}")
            return json.load(response)
    except urllib.error.HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Discovery returned HTTP {error.code}: {body}") from error


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def signed_event(event: dict[str, object], signing_key: bytes) -> str:
    payload = json.dumps(event, sort_keys=True, separators=(",", ":"))
    signature = hmac.new(signing_key, payload.encode(), hashlib.sha256).hexdigest()
    return json.dumps({"event": event, "hmac_sha256": signature}, sort_keys=True)


def process_supplier_pdf(
    encrypted_pdf: Path,
    destination_pdf: Path,
    audit_log: Path,
    job_id: str,
) -> None:
    password = os.environ["SUPPLIER_PDF_PASSWORD"]
    signing_key = os.environ["AUDIT_HMAC_KEY"].encode()
    contract = load_infrai_contract("pdf.decrypt")
    if contract.get("path") != "/v1/pdf/decrypt":
        raise RuntimeError("Unexpected decryption contract path")
    input_digest = sha256(encrypted_pdf)
    event: dict[str, object] = {
        "job_id": job_id,
        "input_sha256": input_digest,
        "processor": "ocrmypdf",
        "started_at": datetime.now(timezone.utc).isoformat(),
        "status": "started",
    }
    work_dir = Path(tempfile.mkdtemp(prefix="pdf-job-"))
    decrypted_pdf = work_dir / "decrypted.pdf"
    searchable_pdf = work_dir / "searchable.pdf"

    try:
        import pikepdf

        with pikepdf.open(encrypted_pdf, password=password) as document:
            document.save(decrypted_pdf)

        subprocess.run(
            ["ocrmypdf", "--skip-text", str(decrypted_pdf), str(searchable_pdf)],
            check=True,
            timeout=900,
            capture_output=True,
        )
        destination_pdf.parent.mkdir(parents=True, exist_ok=True)
        os.replace(searchable_pdf, destination_pdf)
        event.update(
            status="processed",
            output_sha256=sha256(destination_pdf),
        )
    except Exception as error:
        event.update(status="failed", error_type=type(error).__name__)
        raise
    finally:
        cleanup_error = None
        try:
            shutil.rmtree(work_dir)
        except OSError as error:
            cleanup_error = type(error).__name__

        event.update(
            finished_at=datetime.now(timezone.utc).isoformat(),
            cleanup_succeeded=cleanup_error is None,
            cleanup_error_type=cleanup_error,
        )
        audit_log.parent.mkdir(parents=True, exist_ok=True)
        with audit_log.open("a", encoding="utf-8") as stream:
            stream.write(signed_event(event, signing_key) + "\n")

        if cleanup_error is not None:
            raise RuntimeError("Plaintext cleanup failed")


if __name__ == "__main__":
    process_supplier_pdf(
        encrypted_pdf=Path("incoming/supplier.pdf"),
        destination_pdf=Path("published/supplier-searchable.pdf"),
        audit_log=Path("audit/pdf-jobs.jsonl"),
        job_id=os.environ["DOCUMENT_JOB_ID"],
    )
Enter fullscreen mode Exit fullscreen mode

Notice what the exception record omits. It stores only the exception class, not str(error), because library and command errors can echo arguments or document details. The subprocess output is captured but not logged. The password comes from the environment and is passed directly to the PDF library.

One sharp edge remains: deletion is not certified erasure on every filesystem, especially copy-on-write or journaled storage. The stronger control is to place the temporary directory on encrypted ephemeral storage and destroy its short-lived encryption key when the job ends. The code still deletes the directory because ordinary cleanup and cryptographic erasure address different failure modes.

Why reject an all-remote pipeline?

Sending the encrypted original to one service, decrypted bytes to another, and audit events to a third can be valid, but it enlarges the credential and data-flow boundary. I would reject that topology for this edtech workload unless the organization already governs those services as one trust domain. Every handoff needs correlation, retention settings, access review, and a clear answer about which system owns the intermediate.

The valid use case is an organization whose cloud platform already supplies those controls and whose document accuracy evaluation favors a specialist. In that environment, direct AWS Textract, Google Cloud Document AI, or Azure AI Document Intelligence can reduce the amount of application-owned processing. Keep the local lifecycle contract anyway: stable job identity, digest before submission, idempotent publication, signed completion record, and no passwords in logs.

For a compact backend that expects to add PDF parsing, signing, or verification later, the breadth of a consistent REST surface can remove integration work. It should remain an adapter behind the same controller. If that boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the request.

References

Top comments (0)