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

1"""Caller-safe download skip-reason tokens (single source of truth). 

2 

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. 

10 

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""" 

19 

20import os 

21 

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} 

52 

53 

54def client_safe_download_message(exc: BaseException) -> str: 

55 """Return a caller-safe skip_reason for *exc*. 

56 

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). 

63 

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 )