Protect stdout from corruption when writing PDF to standard output

Writing the output PDF to stdout (ocrmypdf in.pdf -) previously relied on
an honor system: no in-process code -- third-party libraries, plugins, or
stray print() calls -- was supposed to write to stdout, enforced only
indirectly. A single accidental write to fd 1 would silently corrupt the
output PDF.

Enforce this at the OS level. At CLI startup, before plugins load or any
worker process/thread starts, save the real stdout via os.dup() and point
fd 1 at stderr, so stray writes are diverted to stderr while only the final
"produce the PDF" step writes to the preserved descriptor. Exposed as the
opt-in public API function configure_stdout_protection(), mirroring
configure_logging(); it is not enabled inside ocr() so in-process library
users keep their own stdout.

Also fix check_requested_output_file() to test the preserved real stdout
for tty-ness, since after the redirect sys.stdout reports stderr's status.

Fold unreleased v17.7.2 notes into v17.8.0.
This commit is contained in:
James R. Barlow
2026-06-28 12:02:34 -07:00
parent ea7ad7d683
commit a13d27bfb5
9 changed files with 228 additions and 5 deletions
+14 -1
View File
@@ -3,8 +3,21 @@
# v17
## v17.7.2
## v17.8.0
- Writing the output PDF to standard output (`ocrmypdf input.pdf -`) is now
protected against corruption at the operating system level. Previously
OCRmyPDF relied on no in-process code — third-party libraries, plugins, or
stray `print()` calls — ever writing to stdout; a single accidental write
would silently corrupt the PDF. The command line program now saves the real
stdout at startup, before plugins are loaded or any worker process/thread is
started, and redirects file descriptor 1 to stderr, so that only OCRmyPDF's
final PDF output can reach stdout. A consequence is that a plugin which
intentionally prints to stdout will have that output redirected to stderr.
- Added the public API function {func}`ocrmypdf.configure_stdout_protection`,
which installs this same protection. Like {func}`ocrmypdf.configure_logging`,
it is optional and intended for callers that want command-line-like behavior;
applications that manage their own standard output should not call it.
- Fixed an uncaught `UnicodeDecodeError` when processing a PDF whose
`/DocumentInfo` dictionary contains a `/Name` key encoded in Latin-1 (or
another non-UTF-8 encoding), such as `/Saks#e5r`. `repair_docinfo_nuls` now
+2
View File
@@ -19,6 +19,7 @@ from ocrmypdf._version import __version__
from ocrmypdf.api import (
Verbosity,
configure_logging,
configure_stdout_protection,
ocr,
)
from ocrmypdf.exceptions import (
@@ -53,6 +54,7 @@ __all__ = [
'BoundingBox',
'configure_debug_logging',
'configure_logging',
'configure_stdout_protection',
'DpiError',
'EncryptedPdfError',
'Executor',
+6 -1
View File
@@ -16,7 +16,7 @@ from contextlib import suppress
from ocrmypdf import __version__
from ocrmypdf._pipelines.ocr import run_pipeline_cli
from ocrmypdf._validation import check_options
from ocrmypdf.api import Verbosity, configure_logging
from ocrmypdf.api import Verbosity, configure_logging, configure_stdout_protection
from ocrmypdf.cli import get_options_and_plugins
from ocrmypdf.exceptions import (
BadArgsError,
@@ -39,6 +39,11 @@ def sigbus(*args):
def run(args=None):
"""Run the ocrmypdf command line interface."""
# Protect the real stdout before loading plugins or starting any worker
# processes/threads, so that only our final PDF output can reach it and
# stray writes from plugins or libraries are diverted to stderr.
configure_stdout_protection()
options, plugin_manager = get_options_and_plugins(args=args)
with suppress(AttributeError, PermissionError):
+13 -2
View File
@@ -30,6 +30,7 @@ from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._metadata import repair_docinfo_nuls
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes
from ocrmypdf._stdoutprotect import get_protected_stdout_fd
from ocrmypdf.exceptions import (
DigitalSignatureError,
DpiError,
@@ -1278,8 +1279,18 @@ def copy_final(
log.debug('%s -> %s', input_file, output_file)
with input_file.open('rb') as input_stream:
if output_file == '-':
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
fd = get_protected_stdout_fd()
if fd is not None:
# Stdout protection is active: write to the preserved real
# stdout. dup the saved fd so the with-block's close() does not
# close our long-lived descriptor.
with os.fdopen(os.dup(fd), 'wb') as stdout_stream:
copyfileobj(input_stream, stdout_stream)
stdout_stream.flush()
else:
# No protection installed (e.g. plain API use): legacy behavior.
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
elif hasattr(output_file, 'writable'):
output_stream = cast(BinaryIO, output_file)
copyfileobj(input_stream, output_stream) # type: ignore[misc]
+83
View File
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Protect the real standard output from corruption by stray writes.
When OCRmyPDF writes its final PDF to standard output (``ocrmypdf in.pdf -``),
the bytes on stdout must be exactly the PDF and nothing else. Any accidental
write to file descriptor 1 anywhere in the process -- from a third-party
library, a plugin, or a stray ``print()`` -- would silently corrupt the output.
This module enforces that guarantee at the operating system level. It saves a
private duplicate of the real stdout and points file descriptor 1 at standard
error, so that anything that writes to stdout lands harmlessly on stderr. Only
OCRmyPDF's final "produce the PDF" step writes to the preserved real stdout, via
:func:`get_protected_stdout_fd`.
"""
from __future__ import annotations
import os
import sys
import threading
_lock = threading.Lock()
_saved_fd: int | None = None
_active = False
def protect_stdout() -> bool:
"""Redirect file descriptor 1 to stderr and preserve the real stdout.
After this call, any write to file descriptor 1 -- including ``print()`` and
writes from third-party C libraries -- is redirected to standard error and
cannot corrupt the real standard output. The real stdout is preserved on a
private file descriptor available from :func:`get_protected_stdout_fd`.
This mutates process-global state and affects the whole process. It must be
called once, early, before any plugins are loaded or any worker
process/thread is started, so that all of them inherit the redirected
descriptor.
Returns:
True if protection was installed (or was already active). False if
stdout is not backed by a real OS file descriptor -- for example under
a test harness that captures stdout -- in which case nothing is changed.
"""
global _saved_fd, _active
with _lock:
if _active:
return True
try:
fd1 = sys.stdout.fileno()
except (AttributeError, OSError, ValueError):
# stdout is not backed by a real file descriptor (e.g. captured by
# a test harness or replaced with an in-memory stream).
return False
try:
sys.stdout.flush()
saved = os.dup(fd1)
os.dup2(2, fd1) # point stdout at stderr
except OSError:
return False
_saved_fd = saved
_active = True
return True
def get_protected_stdout_fd() -> int | None:
"""Return the preserved real stdout file descriptor, or None if inactive."""
return _saved_fd if _active else None
def protected_stdout_isatty() -> bool | None:
"""Whether the preserved real stdout is a terminal.
Returns None if protection is not active, in which case the caller should
fall back to ``sys.stdout.isatty()``. When protection is active,
``sys.stdout`` reports the terminal status of stderr (its descriptor was
redirected), so this consults the saved real-stdout descriptor instead.
"""
if not _active or _saved_fd is None:
return None
return os.isatty(_saved_fd)
+8 -1
View File
@@ -19,6 +19,7 @@ from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OcrOptions, ProcessingMode
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._stdoutprotect import protected_stdout_isatty
from ocrmypdf.exceptions import (
BadArgsError,
InputFileError,
@@ -231,7 +232,13 @@ def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str
def check_requested_output_file(options: OcrOptions) -> None:
if options.output_file == '-':
if sys.stdout.isatty():
# When stdout protection is active, fd 1 has been redirected to stderr,
# so sys.stdout.isatty() would report stderr's status. Consult the
# preserved real stdout instead, falling back when protection is off.
is_tty = protected_stdout_isatty()
if is_tty is None:
is_tty = sys.stdout.isatty()
if is_tty:
raise BadArgsError(
"Output was set to stdout '-' but it looks like stdout "
"is connected to a terminal. Please redirect stdout to a "
+33
View File
@@ -56,6 +56,7 @@ from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
from ocrmypdf._stdoutprotect import protect_stdout
from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.exceptions import ExitCode
@@ -233,6 +234,37 @@ def configure_logging(
return log
def configure_stdout_protection() -> bool:
"""Protect the process's real standard output from corruption.
When OCRmyPDF writes its final PDF to standard output (``output_file='-'``),
the bytes on stdout must be exactly the PDF and nothing else. By default
OCRmyPDF relies on no in-process code -- third party libraries, plugins, or
stray ``print()`` calls -- ever writing to stdout. This function makes that
guarantee real: it redirects file descriptor 1 to standard error and
preserves a private copy of the real stdout, so that any accidental write to
stdout lands harmlessly on stderr while OCRmyPDF still emits its final PDF to
the preserved descriptor.
This is the same protection the ``ocrmypdf`` command line program installs.
It is optional for API users and works like :func:`configure_logging`: call
it before :func:`ocr` if you want command-line-like behavior. It must be
called once, early -- before any plugins are loaded or any worker
process/thread is started -- so that they inherit the redirected descriptor.
Because it mutates process-global file descriptors and affects the entire
process, applications that manage their own standard output (for example,
a long-lived service that calls :func:`ocr` in-process) should **not** call
this function.
Returns:
True if protection was installed (or was already active). False if
stdout is not backed by a real operating system file descriptor, in
which case nothing is changed.
"""
return protect_stdout()
def _check_no_conflicting_ocr_params(
locals_dict: dict,
kwargs: dict,
@@ -965,6 +997,7 @@ __all__ = [
'Verbosity',
'check_options',
'configure_logging',
'configure_stdout_protection',
'create_options',
'get_parser',
'get_plugin_manager',
+42
View File
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MIT
"""Test plugin that deliberately writes garbage to stdout.
Used to verify that OCRmyPDF's stdout protection diverts stray writes (from
plugins or libraries) to stderr, so that a PDF written to stdout is never
corrupted. Pollutes at three points: plugin import (main process), the
``validate`` hook (main process), and the ``filter_ocr_image`` hook (worker
process/thread).
"""
from __future__ import annotations
import os
import sys
from ocrmypdf import hookimpl
POLLUTION = b'POLLUTION'
def _pollute(where: bytes) -> None:
# Write to file descriptor 1 directly (as a careless C library might) and
# via Python's sys.stdout (as a stray print() might).
os.write(1, POLLUTION + b'-fd1-' + where + b'\n')
print(POLLUTION.decode() + '-stdout-' + where.decode())
sys.stdout.flush()
# Pollute at import time, which happens while plugins are being loaded.
_pollute(b'import')
@hookimpl
def validate(pdfinfo, options):
_pollute(b'validate')
@hookimpl
def filter_ocr_image(page, image):
_pollute(b'filter_ocr_image')
return image
+27
View File
@@ -48,6 +48,33 @@ def test_stdout(ocrmypdf_exec, resources, outpdf):
assert check_pdf(output_file)
def test_stdout_protected_from_pollution(ocrmypdf_exec, resources, outpdf):
if 'COV_CORE_DATAFILE' in os.environ:
pytest.skip("Coverage uses stdout")
input_file = str(resources / 'francais.pdf')
output_file = str(outpdf)
# A plugin deliberately writes garbage to stdout during the run. With stdout
# protection active, that garbage must be diverted to stderr and never reach
# the PDF we are writing to stdout.
with open(output_file, 'wb') as output_stream:
p_args = ocrmypdf_exec + [
input_file,
'-',
'--plugin',
'tests/plugins/tesseract_noop.py',
'--plugin',
'tests/plugins/stdout_polluter.py',
]
p = run(p_args, stdout=output_stream, stderr=PIPE, stdin=DEVNULL, check=True)
assert check_pdf(output_file), "PDF on stdout was corrupted"
with open(output_file, 'rb') as f:
assert b'POLLUTION' not in f.read(), "pollution leaked into the PDF"
assert b'POLLUTION' in p.stderr, "pollution was not diverted to stderr"
@pytest.mark.skipif(os.name == 'nt', reason='Windows does not support /dev/null')
def test_dev_null(resources):
if 'COV_CORE_DATAFILE' in os.environ: