The behavior contract for operators: paths and permissions, password semantics, output policies, and what the log stream carries. The short reference tables (environment variables, exit codes, retryability) live in the README.
- Input files and the output location are mounted volumes; use absolute in-container paths.
- The container runs as non-root UID 10001: input mounts need to be readable and
the output mount writable by that UID (
chmod/chownfor plain Docker;fsGroupin a podsecurityContexton Kubernetes). - Outputs get regular
open()-style permissions (the container umask,0644by default), so a later step running as a different user can read them from a shared volume.
- One password (via
PDFOPS_PASSWORD_FILE, preferably) is tried against every encrypted input; owner-only "permissions-locked" PDFs open without a password via the spec-standard empty-password try, exactly like every PDF viewer. Wrong password -> exit 5 naming the failing input. - The password itself never appears in any output: the in-process
Secretwrapper renders as***, the logging layer scrubs registered secret values from every event including tracebacks, and the process scrubsPDFOPS_PASSWORDfrom its own environment on startup. - Each
input_openedevent reports the encryption algorithm (read from the PDF's plaintext/Encryptdictionary) and how the file opened (user/owner/empty). - A permissions-locked input among user-locked ones never fails just because a password was supplied: the empty try still applies per input.
- Passwords containing control characters are rejected (exit 2) as encoding accidents.
- Note the env channel's inherent limit: the initial environment block stays visible
to
docker inspectand/proc/<pid>/environ- the file channel is the one that keeps the value out of the process's environment entirely.
PDFOPS_OUTPUT_ENCRYPTION: never (default) writes a plaintext output and emits a
loud security_downgrade warning when inputs were encrypted; inherit encrypts the
output iff at least one input was encrypted - confidentiality never decreases through
this step; always encrypts unconditionally. The output password comes from
PDFOPS_OUTPUT_PASSWORD_FILE/PDFOPS_OUTPUT_PASSWORD, falling back to the
explicitly supplied input password (never the empty auto-try). Output encryption is
always AES-256, whatever the inputs used. Supplying an output password while the mode
is never is a hard configuration error.
- Merge order is exactly the order of
PDFOPS_INPUTS- deterministic across retries. - Output is written atomically: work goes to a temp file in the output directory, then a single rename. The final path either holds a complete PDF or nothing - a failed or killed run never leaves a partial file where a downstream step could read it.
- Existing outputs follow
PDFOPS_ON_EXISTS:fail(default) refuses with exit 6;overwritereplaces atomically (readers see old bytes or new bytes, never a mix);skiptreats the existing output as a completed prior run. For merge,skipis a whole-run no-op - exit 0 withskipped: true, without even reading the inputs. For extract,skipis per-file completion: only missing attachments are written (attachments_skippedreports the rest), so a crashed run's partial set gets finished by the retry - sound because every file this tool writes is atomic and therefore whole. Bothskipmodes trust that an existing file is a completed prior output. - Temp debris from a crashed prior run (
.name.*.tmpmatching this run's own targets) is removed at startup with astale_temp_removedevent. One writer per output path at a time is assumed - which a workflow engine guarantees per step. - All inputs are validated (existence, readability, PDF header) before anything is written, and every bad input is reported in a single failure event.
- Attachment names are treated as untrusted input: extraction reduces every name
to a sanitized basename (path separators, traversal segments, and control
characters removed; deterministic
attachment_<n>fallback), so a hostile PDF can never write outsidePDFOPS_OUTPUT_DIR. Duplicate names get deterministic-1/-2suffixes; the original name is logged whenever sanitization changed it. - Extraction order is the PDF's name-tree order - deterministic across runs. Each
file is written atomically; under the default
failpolicy any pre-existing file (or symlink) at a target name refuses the whole run before anything is written (exit 6) - seePDFOPS_ON_EXISTSabove for the retry-friendly modes. A directory at an output path is refused under every policy (OUTPUT_IS_DIRECTORY). A PDF with zero attachments is a success withattachments_extracted=0unlessPDFOPS_FAIL_ON_NO_ATTACHMENTSis set.
One JSON object per line on stdout; stderr stays empty. Lifecycle events narrate
progress at their log levels (config_loaded, input_opened, merge_written,
attachment_extracted, stale_temp_removed, pdf_library_message for damage the
PDF engine repaired, security_downgrade, password_unused, ...). The terminal
event is never suppressed by PDFOPS_LOG_LEVEL:
operation_complete- merge:pages,bytes_written,output_path,output_encrypted; extract:attachments_extracted,bytes_written, plusattachments_skippedunderskip. Always:exit_code,duration_s.operation_failed-error_code(machine-readable, finer-grained than the exit code),error_message,exit_code,context(e.g. the failing input), and atracebackfor unexpected errors.