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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,6 +1279,16 @@ def copy_final(
|
||||
log.debug('%s -> %s', input_file, output_file)
|
||||
with input_file.open('rb') as input_stream:
|
||||
if output_file == '-':
|
||||
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'):
|
||||
|
||||
@@ -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)
|
||||
@@ -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 "
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user