Raxol.Core.Boundary.TermText (Raxol Core v2.8.0)

Copy Markdown View Source

Terminal-injection confinement: neutralize ESC/ANSI/control bytes in untrusted text before it reaches the terminal renderer.

One of the three centralized boundary confinements (PR #569 thread 2); the others are Raxol.Core.Boundary.Path (path-traversal) and Raxol.Core.Boundary.Evaluation (code-evaluation exposure). This one has byte-stream semantics — a total function over binaries, no filesystem, never errors, never passes a dangerous byte through. It enforces the repo rule "never embed raw ANSI codes in strings passed to text()" at the untrusted boundary instead of trusting upstream.

What sanitize/2 strips

In a single left-to-right pass:

  • ESC (0x1B) and every sequence it introduces — CSI (ESC [ … final 0x40..0x7E), OSC (ESC ] … BEL/ST; kills title-set and OSC-8 hyperlink smuggling), DCS/APC/PM/SOS (ESC P/ESC _/ESC ^/ESC X … ST), two-byte ESC <c> forms, and a truncated trailing ESC.
  • C0 controls (0x00..0x1F) except the :allow list (default [?\n]).
  • DEL (0x7F) and raw C1 controls (0x80..0x9F).
  • Format characters that misrepresent a line — the bidi embedding/override block and isolates (U+202A..U+202E, U+2066..U+2069), which reorder rendered text (Trojan Source, CWE-451) so "disabled in a hosted session" can be made to read as its opposite with nothing an ESC filter would catch; U+200B, U+200E, U+200F, U+00AD, U+2028, U+2029 and U+FEFF, which hide or re-break the content a reader is asked to trust; and the tag characters U+E0000..U+E007F, which carry an invisible ASCII payload inside ordinary-looking text. ZWJ (U+200D) and ZWNJ (U+200C) are deliberately KEPT: they join emoji sequences, carry meaning in Indic and Perso-Arabic scripts, and reorder nothing.
  • Invalid UTF-8 bytes → replaced with U+FFFD (never emit a broken sequence downstream). Raw C1 bytes are stripped (not replaced); other invalid bytes become U+FFFD.

Valid printable text (including CJK, emoji, and other multi-byte UTF-8) passes through unchanged. The function never raises and its output contains no ESC and no disallowed control byte for ANY input binary.

Where confinement happens (authoritative)

Untrusted content is confined at the OUTPUT SINKS, not by walking and rebuilding a component tree on every frame. Component modules keep their view data ordinary and do no terminal confinement of their own; they point here instead of restating this list. The sinks are:

  • Cell writes — Raxol.Core.Runtime.Rendering.Backends.sanitize_char/1 delegates to sanitize_cell/1 as a cell enters the screen buffer.
  • Terminal emitters — Raxol.Terminal.Renderer and Raxol.Core.Renderer pass every cell through sanitize_cell/1 and every OSC 8 URL through sanitize_url/1, immediately before the bytes are assembled.
  • Layout link propagation — Raxol.UI.Layout.Engine confines a :link attribute with sanitize_url/1 as it propagates.
  • Append/paint authority — Raxol.Harness.Surface.ViewText sanitizes per line while flattening the view tree, over this module's strip_codepoint?/2 deny set.

A cell sink and a text sink differ in exactly ONE respect: a cell owns a column, so a disallowed cell is BLANKED (sanitize_cell/1), while disallowed text is DELETED (sanitize/2). Every sink shares one deny set, strip_codepoint?/2.

Summary

Types

A C0/allowed control byte value, e.g. ?\t or ?\n.

Functions

Sanitize binary for safe delivery to a terminal renderer.

Confine one screen CELL's text.

Confine a URL for OSC 8 emission: sanitize/2 with allow: [], then a scheme allowlist (http, https, mailto).

The deny set, as a predicate over one codepoint.

Types

control_byte()

@type control_byte() :: 0..31

A C0/allowed control byte value, e.g. ?\t or ?\n.

Functions

sanitize(binary, opts \\ [])

@spec sanitize(binary(), keyword()) :: String.t()

Sanitize binary for safe delivery to a terminal renderer.

Total: always returns a String.t(), never raises.

Options

  • :allow — a list of C0 control byte values (0..31) to pass through. Defaults to [?\n]. ESC (0x1B) is never allowed regardless of this list.

sanitize_cell(char)

@spec sanitize_cell(term()) :: String.t()

Confine one screen CELL's text.

A cell is POSITIONAL: it owns a column. Deleting it (what sanitize/2 does to text) pulls every cell to its right one column left, so box borders, table columns and the approval line come apart around a single hostile byte. A disallowed cell is therefore BLANKED to " ", never deleted, and an invalid UTF-8 byte becomes the one-column U+FFFD the text path already substitutes.

A non-binary cell fails closed as "", and an empty cell stays empty: neither occupies a column to preserve.

sanitize_url(url)

@spec sanitize_url(term()) :: String.t()

Confine a URL for OSC 8 emission: sanitize/2 with allow: [], then a scheme allowlist (http, https, mailto).

Returns "" for a non-binary, for a URL whose bytes all sanitize away, and for any other scheme — including a URL with no scheme at all, which a terminal resolves against a base this process does not control. Callers read "" as "no link": the label text still renders, it is simply not clickable.

strip_codepoint?(codepoint, allow \\ ~c"\n")

@spec strip_codepoint?(integer(), [control_byte()]) :: boolean()

The deny set, as a predicate over one codepoint.

true when codepoint must never reach a terminal: a C0 control outside allow, DEL, a C1 control, or one of the format characters listed in the moduledoc. Public so that every sink — text, cell, and the Raxol.Harness.Surface.ViewText line scanner — decides with the SAME set rather than keeping a second list that drifts from this one.