Coverage for src/local_deep_research/security/client_safe_errors.py: 100%
8 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Caller-safe download skip-reason tokens (single source of truth).
3PR #6564 follow-up: ``research_library.services.download_service`` and
4``research_library.downloaders.html`` each carried their own copy of the
5exception-class -> fixed-token table. The copies drifted (``DataError``,
6``InterfaceError``, ``InvalidRequestError`` and the ``OSError`` errno branch
7existed only on the services side), and the "mirrors" comment overstated the
8parity. This module owns the table and the mapping function; both call sites
9import from here so they cannot drift again.
11Contract (CWE-209): never pass ``str(exc)`` to the caller. The exception text
12can carry SQL text (``INSERT INTO document_collections ...``), target URLs,
13or absolute file paths. Caller-visible ``skip_reason`` values are fixed
14tokens or ``download_error:<ClassName>`` only. Server-side logs retain the
15exception class; ``DownloadAttempt.error_message`` stores scrubbed detail.
16``sanitize_error_for_client()`` is NOT a substitute — it scrubs credential
17shapes only, not SQL/paths/URLs.
18"""
20import os
22# Client-safe skip-reason mapping for download exception paths.
23#
24# sanitize_error_for_client() scrubs credential *shapes* but explicitly does
25# NOT remove SQL text, provider endpoints, or dependency internals. The
26# exception's text here can be a SQLAlchemy IntegrityError
27# ("...INSERT INTO document_collections..."), a requests ConnectionError
28# carrying the target host, or a PDF library message with absolute paths.
29CLIENT_SAFE_DOWNLOAD_MESSAGES: dict[str, str] = {
30 # Network surface — never echo the URL / host the caller already knows.
31 "ConnectionError": "network_unavailable",
32 "ConnectionResetError": "network_reset",
33 "Timeout": "network_timeout",
34 "HTTPError": "http_error",
35 "ChunkedEncodingError": "network_unavailable",
36 "ContentDecodingError": "network_unavailable",
37 "TooManyRedirects": "network_redirect_limit",
38 # SQL surface — never echo SQL text, table names, or driver internals.
39 # Maps to the SQLAlchemy class names; the IntegrityError case is the
40 # one the FK-ON regression surfaced ("FOREIGN KEY constraint failed
41 # [SQL: INSERT INTO document_collections ...]").
42 "IntegrityError": "database_constraint",
43 "OperationalError": "database_unavailable",
44 "DatabaseError": "database_error",
45 "DataError": "database_data_error",
46 "InterfaceError": "database_interface_error",
47 "InvalidRequestError": "database_invalid_request",
48 # PDF / extraction surface — never echo absolute paths or parser internals.
49 "PdfReadError": "pdf_parse_failed",
50 "PdfStreamError": "pdf_parse_failed",
51}
54def client_safe_download_message(exc: BaseException) -> str:
55 """Return a caller-safe skip_reason for *exc*.
57 Author-written constant for known classes; the exception's class name
58 for everything else. Never passes ``str(exc)`` through — that can carry
59 SQL text, target URLs, or file paths the caller should not see. The
60 downloader service logs only the exception class and stores a
61 credential-scrubbed message in ``DownloadAttempt.error_message`` (see its
62 outer PDF handler).
64 ``OSError`` is the one exception family where ``str(exc)`` is *not*
65 free of app-controlled text — Python formats it as
66 ``"[Errno N] <strerror>: <filename>"`` and the filename field is
67 arbitrary. We can't safely pass ``str(exc)`` through. What we *can*
68 safely surface is ``exc.strerror`` when it matches the OS-level
69 canonical message (i.e. ``os.strerror(exc.errno)``). That string is
70 provided by the OS — not by the application — so it carries no SQL,
71 URL, or path content. Custom constructions like
72 ``OSError(28, "leak: secret file contents")`` deliberately don't match the
73 canonical message and fall through to the class-name default.
74 """
75 if isinstance(exc, OSError) and exc.errno is not None:
76 canonical = os.strerror(exc.errno)
77 if exc.strerror and exc.strerror == canonical:
78 return f"filesystem_error:{exc.strerror}"
79 return CLIENT_SAFE_DOWNLOAD_MESSAGES.get(
80 type(exc).__name__, f"download_error:{type(exc).__name__}"
81 )