Skip to content

Swap the PDF engine to pikepdf - #1

Open
Radko-D wants to merge 15 commits into
mainfrom
pikepdf-engine
Open

Radko-D wants to merge 15 commits into
mainfrom
pikepdf-engine

Conversation

@Radko-D

@Radko-D Radko-D commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Executes the engine plan recorded in D-002 (see docs/DECISIONS.md, now D-023): the runtime engine moves from pypdf to pikepdf (qpdf-backed), behind the unchanged PdfEngine Protocol - a single-module replacement, which was the point of the seam. pypdf drops to the dev group, where it still builds every test fixture and independently verifies outputs, so each green test is a two-library cross-check.

What changes for an operator

  • Light damage now repairs instead of failing. qpdf reconstructs a mangled or truncated xref; every repair surfaces as a pdf_library_message event (open-time warnings, plus a post-write/post-read drain because qpdf reads streams lazily). Genuinely unrecoverable files still exit 4.
  • Event fields are unchanged: input_opened still reports user/owner/empty (qpdf records which document password matched) and the exact algorithm label - including on failed opens, where the label now comes from a raw scan scoped to the actual /Encrypt object.
  • Output encryption pins R=6: always AES-256, never a legacy scheme.
  • Non-ASCII attachment names now decode per spec (PDFDocEncoding/UTF-16) instead of as UTF-8 mojibake.

Hostile-input hardening

qpdf hands malformed PDF structures over as native Python values, so the direct /Names/EmbeddedFiles walk (needed because Pdf.attachments collapses duplicate names) guards node types and reference cycles, classifies structural garbage as a data problem (exit 4) rather than a retryable internal error, refuses to turn an integer name-tree key into an attacker-sized bytes() allocation (the payload still extracts under the deterministic fallback name), caps the logged original_name, and bounds every token match in the failed-open /Encrypt scan so decoy digit walls neither crash int() nor mislabel.

The merged output is saved through the atomic-write layer's already-open temp file rather than by path - handed a path to an existing file, pikepdf routes through its own second hidden temp, whose post-kill debris the stale-temp cleanup could never match.

Second commit: one home for secrets

The secret machinery had spread across three modules (wrapper in secret.py, source refs and resolution in config.py, scrub registration inline in main.py). It now lives in src/pdf_ops/secrets.py as one readable lifecycle: the Secret wrapper, EnvSecret/FileSecret refs with resolve()/describe() methods, and resolve_and_register wiring resolved values into log scrubbing. config.py shrinks ~100 lines back to pure parsing; resolution stays deliberately lazy (merge's skip short-circuit reads nothing, not even the mounted password file).

Why not a library (recorded as D-024): pydantic's SecretStr means a compiled dependency for one masked-repr class; pydantic-settings would rewrite the config layer, and its secrets_dir convention (field-named files in a fixed directory) is a different contract from PDFOPS_PASSWORD_FILE=, plus its ValidationErrors would need retranslating into the error_code taxonomy; scanner-style log redactors are pattern heuristics, strictly weaker than the exact-value field-restricted scrub this codebase uses precisely because naive scrubbing becomes a password oracle. One deliberate, pinned ordering choice: when both password sources are broken, the failure event names the primary (input) password's problem first.

Tests

270 local + 5 container tests, 95% line+branch coverage. Corruption fixtures became genuinely unrecoverable (qpdf repairs the old ones - that upgrade is pinned as its own test), and new pins cover malformed tree shapes, integer name keys, UTF-16/PDFDoc name fidelity, write-time repair warnings, and the failed-open label scan.

Design notes: docs/DESIGN.md (library section), docs/DESIGN_NOTES.md section 1 and 8, docs/DECISIONS.md D-023 and D-024.

Third commit: measured resource behavior

scripts/benchmark.py generates large fixtures (never committed) and measures each scenario in the real container: peak process memory is linear in total input bytes - roughly input size plus ~40 MB fixed overhead, indifferent to file count - crypto costs time (~1 s per 250 MB) rather than memory, and the cgroup peak runs 2-3x the RSS purely from reclaimable page cache. Practical sizing (requests/limits = total expected input + 128 MB) and the measured table are in docs/OPERATIONS.md; the design doc's unmeasured-resources limitation is now a measured, linear profile.

Fourth commit: hardened runtime image and a deployable Argo example

The image becomes a two-stage build - a digest-pinned uv stage resolving the lockfile into a self-contained virtualenv with compiled bytecode, and a digest-pinned python:3.14-slim runtime carrying only that venv, with the base image's pip uninstalled and stdlib ensurepip removed (its bundled wheel would restore pip in one command). The posture is proven by tests, not promised: the golden merge runs under --read-only --cap-drop ALL --security-opt no-new-privileges, and an installer probe checks BOTH interpreters (the venv python cannot see base site-packages, so a venv-only probe would be vacuous). deploy/argo-example.yaml ships the full WorkflowTemplate: security context, fsGroup, secret-mounted password, memory per the measured sizing rule, and a retry expression that also covers pod-level Error nodes - a lost pod never produces an exit code (Argo substitutes -1), so the common exit-code-only expression silently skips exactly the lost-pod scenario the ON_EXISTS=skip pairing exists for; the README snippet is fixed the same way. Recorded as D-025. Container suite is now 7 tests.

Fifth commit: audit closure

A taxonomy-to-test walk found one error code without a pin (UNSUPPORTED_ENCRYPTION, the certificate-security-handler path) - now covered by a raw /Adobe.PubSec fixture asserting exit 5. A fresh-clone walkthrough surfaced the README's one friction point - the quick start now opens with a snippet that creates every input the examples use. And the register's three open decisions settle as-is with their revisit triggers evaluated: the colon separator, strict lowercase operations, and hard unknown-var rejection all stand. No open decisions remain; 271 local + 7 container tests.

Sixth commit: interactive system diagrams

Four self-contained interactive HTML diagrams in docs/diagrams, each compiled from a typed JSON source that lives beside it: the module architecture (every node links to the exact source lines it describes, pinned to a commit), the run/retry state machine, the extract trust boundary, and the password flow. index.html unites them behind a hash-anchored tab bar. The design doc and operator guide deep-link into the right tab.

Follow-up commits: toolchain coherence, typed vocabularies, docs exactness

  • One toolchain (D-026). The pre-commit hooks pinned their own ruff and pyright releases and had drifted behind uv.lock; the Python tools now run as local hooks through uv run --locked, so hooks, CI and a developer's shell resolve the single pinned version. CI gets a read-only token, per-ref concurrency, timeouts, SHA-pinned actions and uv sync --locked. The bare "scripts" ruff exclude had silently left docs/scripts - including the CI-run decision validator - unlinted; both script directories join the gates and the nine findings from the widened rule set are fixed. Dependabot watches the three pinned surfaces: lockfile, actions, image digests.
  • Input validation in its own module (D-027). extract.py imported validate_inputs from merge.py - the one import edge between two modules the design doc presents as parallel peers. The validation moves byte-for-byte into inputs.py.
  • Typed error vocabulary (D-028). The 32 error_code literals become one ErrorCode StrEnum - a typo is now a pyright error, and the JSON wire format is byte-identical (the log-parsing tests that compare raw strings pin that independently). docs/OPERATIONS.md gains the complete per-exit-class table with a drift test that fails in either direction.
  • Say each repeated thing once. One _translating context manager replaces four identical except-pairs around the structure walks, and the previously duplicated branches get a direct pin: every builtin failure inside a walk classifies as CORRUPT_PDF, never exit 1. OpenedInput.event_fields() becomes the single input_opened payload; tests/helpers.py takes over what tests were importing from conftest.
  • Repo files (D-029). SECURITY.md with an in-scope list that maps one-to-one onto the test-pinned guarantees; project.urls and classifiers led by Private :: Do Not Upload; from-future imports dropped on a 3.14-only tree.
  • Architecture in rendered views; the operator guide made exact. docs/ARCHITECTURE.md draws eight views that render directly on GitHub, each linking into the interactive diagrams, with a docs-sync test (every module named on the page, every emitted event documented). A line-by-line pass corrected the operator guide against the code: stale-temp cleanup timing, the scrub floor and both scrubbed variables, exact terminal-event shapes, casefolded dedupe and the name cap, and merge-lazy versus extract-eager secret resolution. The interactive module map gains inputs.py with its evidence re-pinned.

Tests: 280 local + 7 container. Register: 29 decisions, none open.

Execute the planned engine swap (D-023): engine_pikepdf.py replaces
engine_pypdf.py behind the unchanged Protocol. qpdf brings the
production qualities the seam was built for - it repairs light damage
(truncation, mangled xref) the old engine refused, with every repair
surfaced as a pdf_library_message event rather than silently absorbed:
open-time warnings ride on OpenedInput.warnings, and because qpdf reads
stream data lazily, repairs discovered during the write or during
attachment reads are drained afterwards via collect_warnings.

Password reporting survives intact (qpdf records which document
password matched, so input_opened still says user/owner/empty), output
encryption pins R=6 (always AES-256), and the merged output is saved
through the atomic layer's already-open temp file - handed a path,
pikepdf would create a second hidden temp whose post-kill debris no
cleanup could ever match.

Hostile-input hardening, since qpdf hands malformed structures over as
native Python values: the /Names/EmbeddedFiles walk (needed because the
attachments mapping collapses duplicate names) guards node types and
reference cycles, classifies structural garbage as a data problem
instead of a retryable internal error, refuses to turn an integer
name-tree key into an attacker-sized bytes() allocation (the payload
still extracts under the fallback name), and caps the logged
original_name. Failed opens keep their algorithm label via a raw scan
scoped to the actual /Encrypt object - bounded token matches, so decoy
digit walls neither crash int() nor mislabel. Attachment names now
decode per spec (PDFDocEncoding/UTF-16) instead of as UTF-8 mojibake.

Corruption fixtures became genuinely unrecoverable (qpdf repairs the
old ones - that upgrade is pinned as behavior), and pypdf moves to the
dev group where it builds every fixture and independently verifies
outputs: each green test is a two-library cross-check.

269 tests + 5 container, 95% line+branch coverage.
The secret machinery had spread across three modules: the wrapper in
secret.py, source refs and resolution in config.py, and the
register-for-scrubbing dance inline in main.py's closure. It now lives
in secrets.py as one readable lifecycle - wrapper, EnvSecret/FileSecret
refs with resolve()/describe() methods, the Secrets bundle, and
resolve_and_register wiring resolved values into log scrubbing. The
config module goes back to doing only what its docstring promises:
parsing. No behavior changes except one deliberate ordering choice, now
pinned: when both password sources are broken, the failure names the
primary (input) password's problem first.

Considered and rejected before writing any of this: pydantic's
SecretStr (a compiled dependency for one masked-repr class),
pydantic-settings (a config-layer rewrite whose secrets_dir convention
- field-named files in a fixed directory - is a different contract from
PDFOPS_PASSWORD_FILE pointing at any mounted path, plus a
ValidationError-to-error_code translation layer roughly the size of the
parser it replaces), and scanner-style log redactors (pattern
heuristics, strictly weaker than the exact-value field-restricted scrub
that exists precisely because naive scrubbing becomes a password
oracle). Recorded as D-024.
scripts/benchmark.py generates large fixtures (incompressible random
bytes, never committed) and runs each scenario through the real
container image, reporting the operation's peak RSS and the cgroup
memory peak Kubernetes actually meters.

The headline: peak process memory is linear in total input bytes -
roughly input size plus ~40 MB of fixed overhead, indifferent to file
count (2 x 250 MB and 20 x 25 MB profile identically), because the
writer holds copied stream data until save() completes. Crypto costs
time (~1 s per 250 MB), not memory. The cgroup peak runs 2-3x the RSS
purely from reclaimable page cache, so limits are sized against RSS:
total expected input + 128 MB, now documented in OPERATIONS.md with
the measured table. The design doc's unmeasured-resources limitation
becomes a measured, linear profile.
The image becomes a two-stage build: a digest-pinned uv stage resolves
the lockfile into a self-contained virtualenv with compiled bytecode;
the runtime stage (digest-pinned python:3.14-slim) carries only that
venv, uninstalls the base image's pip AND removes stdlib ensurepip -
its bundled wheel would restore pip in one command - and runs as fixed
non-root UID 10001.

The posture is proven, not promised: one container test runs the
golden merge under --read-only --cap-drop ALL --security-opt
no-new-privileges (all writes land in the output mount by design), and
another probes BOTH interpreters for pip and ensurepip - the venv
python cannot see base site-packages, so a venv-only probe would stay
green even with the uninstall reverted.

deploy/argo-example.yaml ships the full WorkflowTemplate: the security
context, fsGroup for output writability, the password as a mounted
secret file, memory sized by the measured input+128MB rule, and a
retry expression covering exit 1 plus pod-level errors - an
Error-phase node (the lost-pod case) never produces an exit code, Argo
substitutes -1, so the common exit-code-only expression silently skips
exactly the scenario the ON_EXISTS=skip pairing exists for. The README
snippet is fixed the same way. Recorded as D-025.
…pen decisions settled

A walk of the failure taxonomy against the test suite found exactly one
error code without a pinning test: UNSUPPORTED_ENCRYPTION, the
certificate-security-handler path. A raw fixture with an
/Adobe.PubSec encryption dictionary now pins it - exit 5, password
class, never corrupt and never a retryable internal error.

A fresh-clone walkthrough surfaced the one README friction: the quick
start assumed the reader had PDFs at hand. It now opens with a snippet
that conjures every input the examples use (and the encrypted example
references a file the snippet actually creates).

The three open decisions in the register settle as-is, each with its
revisit trigger evaluated rather than waved off: the colon separator
survived the container suite and the deployment example (D-007),
operation values come from templates so case tolerance adds surface
without value (D-008), and the deployment example injects no foreign
PDFOPS_* variables, so hard unknown-var rejection keeps its typo
protection (D-009). The register now carries no open decisions.
Four diagrams as self-contained interactive HTML, each authored from a
typed JSON source kept beside it and validated before render:

- pdf-ops-architecture: the module map, every node linking to the
  exact source lines it describes, pinned to a public commit
- retry-machine: the run lifecycle as a state machine - the skip
  short-circuit, the fail refusal, and the pod-lost -> cleanup ->
  retry loop
- extract-trust: the path an attacker-controlled attachment name
  travels (sanitizer, casefolded dedupe, containment recheck) with the
  payload bytes bypassing path handling entirely
- password-flow: the call sequence for lazy secret resolution, the
  supplied-password try, and the spec-standard empty fallback

index.html unites them behind a tab bar (hash-anchored, keys 1-4)
using relative frames, so it always shows the latest delivered version
of each diagram. The design doc and the operator guide deep-link into
the right tab.
The four diagram JSON files were saved without final newlines, so
pre-commit's end-of-file-fixer rewrote them on every full run and left
the tree dirty. Add the newlines once so the hook chain stays clean.
The pre-commit hooks pinned their own ruff and pyright releases, which
had drifted behind uv.lock; the Python tools now run as local hooks
through uv run --locked, so pre-commit, CI and a developer's shell all
use the single pinned version. CI gets a read-only token, per-ref
concurrency, job timeouts, SHA-pinned actions and uv sync --locked.

The bare "scripts" exclude in the ruff config silently covered both
scripts/ and docs/scripts/, leaving the decision validator that CI runs
on every push unlinted; both directories join the ruff and pyright
gates, and the nine findings from the widened rule set (SIM, PTH, PIE,
RET, PERF, FURB, N; ASYNC dropped - there is no async code) are fixed
in place. pyright now reports ignore comments that suppress nothing;
four dead ones removed. Dependabot watches the three pinned surfaces
weekly: the uv lockfile, the actions, the Docker digests.

Recorded as D-026.
extract.py imported validate_inputs from merge.py - the one import edge
between two modules the design doc presents as parallel peers. The
validation, the magic-bytes probe and the problem classification set
move byte-for-byte into inputs.py; both operations now depend on the
shared module instead of one depending on the other. Error codes, the
collect-all contract and the context.problems log shape are unchanged.

Recorded as D-027.
The fine-grained error_code tokens were string literals at each raise
site - 32 of them, readable nowhere as a whole, and a typo would have
shipped a new code silently. They become one ErrorCode StrEnum, and
PdfOpsError now takes error_code: ErrorCode, so pyright catches a bad
code at the raise site. StrEnum serializes exactly like the raw string,
so the JSON log contract is byte-identical; the log-parsing tests that
compare raw strings pin that independently.

docs/OPERATIONS.md gains the complete table, grouped by the exit code
each code travels with, and a unit test fails when the enum and the
table drift in either direction. The remaining small vocabularies get
pyright-checked Literal aliases: password_type (user/owner/empty), the
output-password source, and the existing-output action.

Recorded as D-028.
Four byte-identical except-pairs around the engine's structure walks
collapse into one _translating context manager (the open and save paths
keep their finer handling), and the previously untestable duplicated
branches get a direct pin: every _STRUCTURE_FAILURES member classifies
as CORRUPT_PDF, exit 4, never as an internal error. The hand-built
input_opened payload in merge.py and extract.py moves into
OpenedInput.event_fields() so the two operations cannot drift apart.

On the test side, tests/helpers.py (a plain module) takes over the
RunApp alias, the raw-PDF builder and a new shared make_record helper,
so no test imports from conftest.py - a pytest plugin, not an import
target. Two identical per-class autouse cleanup fixtures in
test_secrets.py become one module-level fixture.
SECURITY.md documents private vulnerability reporting; its in-scope
list is a one-to-one summary of the guarantees the test suite pins.
pyproject gains project.urls and classifiers, led by
Private :: Do Not Upload - the package is a container payload, not a
library, and the index refuses that classifier if a publish ever runs
by mistake.

from __future__ import annotations is dropped across the tree: the
project pins Python 3.14, where deferred annotation evaluation is the
default, so the import was noise carried from habit. README's dev
commands add the format check CI enforces and the one-time
pre-commit install.

Recorded as D-029.
…e exact

docs/ARCHITECTURE.md shows the system in eight views that render
directly on GitHub - deployment context, the module import graph, one
run end to end, the failure taxonomy, the extract trust boundary, the
retry state machine, the test-oracle map and the commit-to-image path -
each linking into the interactive diagrams for the deep-dive. A unit
test keeps every module named on the page.

docs/OPERATIONS.md's logging section becomes a complete event table -
all 16 events with level and fields - pinned by a test that harvests
every event name the code emits. A line-by-line pass against the code
fixes the inaccuracies the old prose carried: stale-temp cleanup runs
before the first write, not at startup; both password variables are
scrubbed from the environment; the scrub has a four-character floor;
duplicate attachment names dedupe casefolded with a 200-byte cap;
original_name is null when nothing was renamed; a directory at any
output path is refused under every policy; the no-attachments flag
requires the literal true. Merge resolves secrets only after the skip
decision - extract up front - and the docs now say exactly that.

README points the env-var table at the operator guide anchors; the
design doc links the rendered views.
The module map gains inputs.py between the two operations - both call
the shared collect-all validation before any engine work - and the
evidence pins move to the current revision with every source range
re-checked against the tree.
The counts line still said 23 decided after four decisions landed - it
is 27. The config_loaded row now says plainly that the two output
fields appear only on merge runs; an extract event carries neither key.
The register scripts' comments described the tooling checkout they were
originally copied from; they now describe only the in-repo layout.
@Radko-D

Radko-D commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Architecture delta, base main vs this branch (from the authored diagram sources): 0 components added or removed, 2 changed - the engine node (pypdf, pure-Python, no repair path -> pikepdf, qpdf-backed taxonomy translation) and the secrets node (wrapper with resolution spread into config.py -> one consolidated module). Connections, boundaries, and the operation topology are unchanged, which is the point of the seam: the whole swap plus the secrets cleanup alters exactly two boxes on the map. The interactive map itself is in docs/diagrams/index.html on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant