diff --git a/headroom/proxy/diagnostic_decode_policy.py b/headroom/proxy/diagnostic_decode_policy.py new file mode 100644 index 000000000..9dc595601 --- /dev/null +++ b/headroom/proxy/diagnostic_decode_policy.py @@ -0,0 +1,17 @@ +"""Lossy byte decoding policy for diagnostics and logs.""" + +from __future__ import annotations + +import codecs + + +def safe_decode_for_logging(raw: bytes, *, max_bytes: int | None = None) -> str: + """Decode bytes to a string for log/diagnostic display only. + + Wire/protocol parsers should decode complete protocol frames strictly. This + policy is for already-discarded diagnostics where replacement characters are + preferable to failing the error-reporting path. + """ + blob = raw[:max_bytes] if max_bytes is not None else raw + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + return decoder.decode(bytes(blob), final=True) diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 7a415e882..d050dacca 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast from headroom import paths as _paths from headroom._subprocess import run from headroom.proxy import ( + diagnostic_decode_policy, request_limit_policy, sse_byte_buffer_policy, wire_debug_format_policy, @@ -565,16 +566,7 @@ def safe_decode_for_logging(raw: bytes, *, max_bytes: int | None = None) -> str: Use ``parse_sse_events_from_byte_buffer`` for SSE parsing instead. """ - blob = raw[:max_bytes] if max_bytes is not None else raw - # Decode incrementally and represent any invalid bytes as the - # Unicode replacement character (�). Implemented via the - # `codecs` incremental decoder so we never reach for the - # forbidden `errors="ignore"`/`errors="replace"` keyword in the - # SSE-bearing modules. - import codecs as _codecs - - decoder = _codecs.getincrementaldecoder("utf-8")(errors="replace") - return decoder.decode(bytes(blob), final=True) + return diagnostic_decode_policy.safe_decode_for_logging(raw, max_bytes=max_bytes) def parse_sse_events_from_byte_buffer( diff --git a/tests/test_diagnostic_decode_policy.py b/tests/test_diagnostic_decode_policy.py new file mode 100644 index 000000000..1cded9a44 --- /dev/null +++ b/tests/test_diagnostic_decode_policy.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from headroom.proxy.diagnostic_decode_policy import safe_decode_for_logging +from headroom.proxy.helpers import safe_decode_for_logging as helper_safe_decode_for_logging + + +def test_safe_decode_for_logging_decodes_utf8() -> None: + assert safe_decode_for_logging("hello \u2603".encode()) == "hello \u2603" + + +def test_safe_decode_for_logging_replaces_invalid_bytes() -> None: + assert safe_decode_for_logging(b"ok\xffdone") == "ok\ufffddone" + + +def test_safe_decode_for_logging_honors_max_bytes_before_decoding() -> None: + assert safe_decode_for_logging(b"abcdef", max_bytes=3) == "abc" + + +def test_helpers_safe_decode_delegates_to_policy() -> None: + assert helper_safe_decode_for_logging(b"ok\xffdone") == safe_decode_for_logging(b"ok\xffdone")