From a13d27bfb5138478817e629200134a463bde7d69 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 28 Jun 2026 12:02:34 -0700 Subject: [PATCH] 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. --- docs/releasenotes/version17.md | 15 +++++- src/ocrmypdf/__init__.py | 2 + src/ocrmypdf/__main__.py | 7 ++- src/ocrmypdf/_pipeline.py | 15 +++++- src/ocrmypdf/_stdoutprotect.py | 83 ++++++++++++++++++++++++++++++++ src/ocrmypdf/_validation.py | 9 +++- src/ocrmypdf/api.py | 33 +++++++++++++ tests/plugins/stdout_polluter.py | 42 ++++++++++++++++ tests/test_stdio.py | 27 +++++++++++ 9 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/ocrmypdf/_stdoutprotect.py create mode 100644 tests/plugins/stdout_polluter.py diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index eeb65043..9b96cae2 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -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 diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 8a43c2e6..d1b13696 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -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', diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 74ffb3e6..7b2532f4 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -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): diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 161d77df..a01a7e61 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -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] diff --git a/src/ocrmypdf/_stdoutprotect.py b/src/ocrmypdf/_stdoutprotect.py new file mode 100644 index 00000000..c75962a9 --- /dev/null +++ b/src/ocrmypdf/_stdoutprotect.py @@ -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) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index c8f26dcc..1f3c6116 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -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 " diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 1bd30995..9a0073b7 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -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', diff --git a/tests/plugins/stdout_polluter.py b/tests/plugins/stdout_polluter.py new file mode 100644 index 00000000..78279b62 --- /dev/null +++ b/tests/plugins/stdout_polluter.py @@ -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 diff --git a/tests/test_stdio.py b/tests/test_stdio.py index ba45d058..b3783d85 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -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: