core/core/_safe_display.py

109 lines
4.1 KiB
Python

"""Centralised safe-display sanitiser for user-controlled text.
Many surfaces in CORE need to echo a user-controlled fragment into an error
message, log line, or report (e.g. an out-of-vocabulary token, an unknown
pack id, a refused identity-override attempt). Doing this naively lets a
caller inject ANSI control sequences, newlines that break log parsers,
null bytes, or arbitrarily long strings that obscure surrounding evidence.
This module exposes a single helper, :func:`safe_display`, which all such
sites should route user-controlled text through *before* it is concatenated
into an error string or written to a log sink.
Doctrine alignment
------------------
- This file is the canonical *sanitiser*, not a normaliser. It belongs to
the logging/display trust boundary, not to the algebra or generation
paths. It must never be imported by ``algebra/``, ``generate/``,
``field/``, or ``vault/`` runtime code paths.
- The transformation is **deterministic**: identical input produces
identical output. No randomness, no clock, no environment.
- The transformation is **lossy on purpose**: it is a display helper, not
a round-trip codec. Callers must not rely on being able to recover the
original token from the sanitised form.
ADR: ADR-0051 (trust-boundary hardening pass).
"""
from __future__ import annotations
# A conservative cap. Long enough to retain useful evidence (e.g. a short
# OOV token, an unknown pack id), short enough that a maliciously long
# user-controlled string cannot push surrounding context off a log line.
_DEFAULT_MAX_LEN = 64
# Sentinel used when the input is None or empty. Keeps log lines parseable
# and avoids the surface "..." which is reserved for truncation.
_EMPTY_MARK = "<empty>"
def safe_display(value: object, *, max_len: int = _DEFAULT_MAX_LEN) -> str:
"""Return a log/error-safe rendering of a user-controlled fragment.
Rules applied in order:
1. ``None`` and empty strings collapse to the sentinel ``"<empty>"``.
2. Non-strings are coerced via :class:`repr` so callers cannot smuggle
a custom ``__str__`` into a log line.
3. Control characters (``\\x00``-``\\x1f`` plus DEL, plus the C1 range
``\\x80``-``\\x9f``) are replaced with the literal ``"?"``. This
neutralises ANSI escape sequences (which require ``\\x1b``) and
embedded newlines / carriage returns that would break log parsers.
4. The result is truncated to ``max_len`` characters; truncation is
signalled by a trailing ``"..."``.
The function is intentionally simple, pure, and easy to audit.
"""
if value is None:
return _EMPTY_MARK
if isinstance(value, str):
text = value
else:
text = repr(value)
if text == "":
return _EMPTY_MARK
cleaned_chars: list[str] = []
for ch in text:
code = ord(ch)
if code < 0x20 or code == 0x7F or 0x80 <= code <= 0x9F:
cleaned_chars.append("?")
else:
cleaned_chars.append(ch)
cleaned = "".join(cleaned_chars)
if max_len <= 0:
return ""
if len(cleaned) > max_len:
# Reserve room for the "..." truncation marker.
keep = max(1, max_len - 3)
cleaned = cleaned[:keep] + "..."
return cleaned
def safe_pack_id(value: object) -> str:
"""Sanitise a pack-id-shaped fragment for error messages.
Pack ids are a narrower display category than free text: callers
typically only want to see ASCII letters, digits, hyphens, and
underscores. Anything outside that set is replaced with ``"?"`` and
the result is truncated to a conservative 48 characters.
This helper does NOT validate the pack id for filesystem use — that is
the job of the loader's own ``_find_pack`` / ``_safe_pack_id`` guard.
"""
if value is None:
return _EMPTY_MARK
text = value if isinstance(value, str) else repr(value)
if text == "":
return _EMPTY_MARK
cleaned = "".join(
ch if (ch.isascii() and (ch.isalnum() or ch in {"-", "_", "."})) else "?"
for ch in text
)
if len(cleaned) > 48:
cleaned = cleaned[:45] + "..."
return cleaned
__all__ = ("safe_display", "safe_pack_id")