termsafe

package
v0.13.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package termsafe holds the one canonical sanitiser for a string built from untrusted content (commit subjects, refs, file paths, repo prose, error text echoing a malformed file) before it is written to a terminal or a human report. It is the single primitive every render path shares — the terminal analogue of fsutil's guarded read: a hostile or archived repository controls this text, and left raw it can spoof, corrupt, or visually rewrite the report.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BlockText added in v0.12.0

func BlockText(s string) string

BlockText escapes an already-cleaned value that starts a line of its own — a bare paragraph, or the content of a list item or a block quote, which begins a block of its own too, where its first character could open a heading, a list, a quote, a table row or a code fence. A backslash is markdown's own escape for exactly these, and CommonMark renders a backslash-escaped ASCII punctuation character as the character itself, so the reader sees the value's text unchanged.

The cleaner's code-span exemption rests on the field being parsed as the exact string it was cleaned as (the invariant note above). Escaping a leading backtick unconditionally broke that: it kills the span the cleaner relied on and republishes its sheltered content as live markup — a quoted `<details>` became a real disclosure widget, concealing every later section of the record. A leading run that opens a BALANCED span opens no block (a backtick fence's info string may not contain backticks, so a run with a matching closer on the same line is an inline span by construction), so only an unbalanced run is escaped — and the cleaner no longer emits one.

'[' is here for a subtler reason than the rest: a paragraph shaped like a link reference definition (`[x]: https://…`) is CONSUMED by CommonMark and renders as nothing at all — so a value in that shape would erase itself while the fields around it read normally.

It is for a ONE-LINE value (a cleaned one: CleanProse folds line breaks), and it is the LAST step, like CodeSpan.

func CleanProse

func CleanProse(s string, capBytes int) string

CleanProse neutralises one untrusted prose field and caps it at capBytes, preserving interior whitespace runs. The cap is applied last and the result is re-trimmed, so a cut landing mid-word leaves no dangling space; a cut landing mid-rune drops the partial rune rather than emitting replacement bytes.

func CleanProseLine

func CleanProseLine(s string, capBytes int) string

CleanProseLine is CleanProse for a field that must occupy exactly one line: every whitespace run collapses to a single space before the cap is applied. Use it wherever the prose lands in a file whose line structure is machine-read.

func CodeSpan added in v0.11.1

func CodeSpan(s string) string

CodeSpan wraps one already-cleaned value in a CommonMark code span whose delimiters the value cannot break, WITHOUT altering the value's bytes.

A cleaned field is parsed as CommonMark as the exact string it was cleaned as, and the shelter the cleaner grants what sits inside the FIELD's own code spans is only real if the rendered line draws the same boundaries. A renderer that wraps the field in its own single backticks moves them: with a page name of a`<script>`b, the render's opening backtick pairs with the field's first one, the field's own span dissolves, and the <script> the cleaner deliberately left alone is live prose in a committed record (iss-2609020539188868).

The rule is CommonMark's own: a run of N backticks opens a span closed by the next run of exactly N, so a fence one longer than the value's longest run cannot be closed early by anything the value carries. A leading or trailing backtick — or a value padded with spaces on both sides — takes the spec's one-space padding, which the reader strips again, so the value reads back verbatim.

It is for a ONE-LINE value, and it is the LAST step: cleaning the result would re-judge delimiters this function chose. An empty value returns an empty string, because CommonMark has no empty code span; a caller wanting a visible marker for an absent value supplies its own placeholder.

func CodeSpanText added in v0.11.1

func CodeSpanText(raw string) string

CodeSpanText is the text a span's raw content renders as, by CommonMark's two content rules: every line ending becomes a space, and then ONE leading and ONE trailing space are stripped, only when both are present and the content is not all spaces. So a span of a lone space keeps it, a span padded on one side keeps its padding, and a span padded to hold a backtick at its edge gives up exactly the padding the writer added.

func DescribeRefused added in v0.12.0

func DescribeRefused(value string) string

DescribeRefused says what a refused closed-set value looks like without quoting it: its length, or that it is empty. It is for a refusal of a value that arrived in a host-composed payload and was not redacted on the way in — an enum member, a version string, a mode — where quoting it would carry whatever was pasted there (a token, a home path) into the terminal, a log and the session transcript. Sanitize is no substitute: it strips control sequences and redacts nothing. The length and the listed set beside it are enough to find a typo.

func EncodeHiddenRunes added in v0.6.8

func EncodeHiddenRunes(s string) string

EncodeHiddenRunes percent-encodes every rune the terminal sanitizer would mask — C0/DEL, the 2-byte-encoded C1 range, bidi overrides and zero-width runes — so a value built from an untrusted source (a redirect-supplied final address, say) is recorded losslessly but can no longer smuggle terminal escapes or Trojan-Source reordering into a JSON surface or a committed baseline. This is the canonical encoder for the JSON/record boundary that Sanitize's doc note points at (iss-359); a value the terminal render path masks with '?' is encoded here instead, so the byte is preserved rather than substituted.

It is lossless in both directions a mask is not: a hidden rune is percent-encoded as its UTF-8 bytes, and a byte that is not valid UTF-8 — which strings.Map would silently rewrite to U+FFFD — is percent-encoded raw. A canonical address never carries these runes (net/url rejects C0 outright and percent-encodes the path itself), so encoding them cannot break a legitimate final URL.

func EncodeHiddenRunesBlock added in v0.11.1

func EncodeHiddenRunesBlock(s string) string

EncodeHiddenRunesBlock is EncodeHiddenRunes for multi-line prose bound for a committed record — a capture body, a resolution note, a press release. It encodes every rune EncodeHiddenRunes encodes EXCEPT the three that are the prose's own structure: the line feed, the tab, and a carriage return that is half of a CRLF pair. A bare carriage return is encoded, because it is the terminal overwrite a record must not carry (iss-2608301206073609).

It is SanitizeBlock's counterpart at the record boundary: the render path masks, the record path encodes, and both keep the line structure the prose was written with.

func IsHidden added in v0.10.0

func IsHidden(r rune) bool

IsHidden reports whether r is a bidirectional control or a zero-width rune: a rune that makes rendered text read differently from its bytes, or hides text altogether. It is the predicate Sanitize masks these with, exported so a parser that refuses such runes outright judges them by the same set.

func OpensBalancedCodeSpan added in v0.7.1

func OpensBalancedCodeSpan(s string) bool

OpensBalancedCodeSpan reports whether s BEGINS with a backtick run that a later run of exactly the same length closes — that is, whether s starts with a code span rather than with literal backticks. It asks PairCodeSpan, the pairer the site renderer draws spans by, so a run judged balanced here is the span the renderer renders.

It exists so a caller that escapes leading block markers can ask the cleaner's own grammar instead of guessing. A leading run that opens a balanced span opens no block: a backtick fence's info string may not contain backticks, so a run with a matching closer on the same line is an inline span by construction, and escaping it would kill the shelter the cleaner's exemption relies on. Only an unbalanced leading run is a fence, and the cleaner no longer emits one.

func Sanitize

func Sanitize(s string) string

Sanitize replaces every terminal-display attack rune with a visible '?' (tab becomes a space). It neutralises:

  • C0 controls (<0x20) and DEL (0x7f) — these carry ESC, so a raw ANSI escape in a commit subject could recolour, move the cursor, or corrupt the report; a newline is masked too, so an injected line break cannot forge extra lines;
  • the C1 range (0x80–0x9F) — U+009B (CSI) acts like ESC[ on an 8-bit terminal, so masking ESC (a C0 control) alone would leave an equivalent path open;
  • bidirectional override/isolate controls (the "Trojan Source" class) and zero-width characters, which reorder or hide text so the rendered line differs from the bytes — the reader sees something the file does not say.

JSON output is NOT covered by encoding/json's own escaping: it escapes only C0 (below 0x20) and U+2028/9 — DEL, the C1 range, bidi overrides and zero-width runes pass through raw (pinned by TestJSONLeavesC1AndBidiRaw). A JSON surface whose strings reach a terminal or a committed record needs its values shape-checked or encoded at the boundary that produced them (iss-359); this function is the mask for the human/terminal render path.

func SanitizeAll

func SanitizeAll(in []string) []string

SanitizeAll sanitises every member of a slice, returning a new slice.

func SanitizeBlock

func SanitizeBlock(s string) string

SanitizeBlock sanitises multi-line text while keeping its line structure: each line is sanitised on its own and the line terminators are preserved. Sanitize masks a newline (an injected line break must not forge extra report lines), which is right for a value interpolated into one line and wrong for output that IS lines — a rendered diff or a quoted file excerpt, where flattening the terminators destroys the artefact the reader needs. Use this only where the line breaks are the render's own, never for a single untrusted value.

The carriage return of a CRLF pair survives; every other carriage return is masked. That distinction is load-bearing in both directions: a BARE CR moves the cursor to the column zero of the line being written, so it can overprint what the reader already saw — the attack Sanitize masks — whereas the CR of a CRLF is immediately committed by its newline and can overprint nothing. And a rendered patch against a CRLF file must carry those pairs or no patch tool will apply it, so dropping them would silently void the artefact.

func TableCell added in v0.12.0

func TableCell(s string) string

TableCell escapes an already-cleaned value for a markdown table cell. The backslash is escaped with the pipe: GFM reads `\|` as an escaped delimiter, so escaping the pipe alone turns a prose `\|` into `\\|`, a live delimiter that splits the row and pushes the renderer's last column off its end.

Types

type PairedSpan added in v0.11.1

type PairedSpan struct {
	Start, ContentStart, ContentEnd, End int
}

PairedSpan is where one CommonMark code span sits in the string it was paired in: s[Start:End] is the whole span, delimiters included, and s[ContentStart:ContentEnd] is the raw text between the two backtick runs.

func PairCodeSpan added in v0.11.1

func PairCodeSpan(s string, i int) (span PairedSpan, ok bool)

PairCodeSpan is the tree's one code-span pairer. It reads the backtick run that begins at s[i] and reports the span that run opens, or ok=false when it opens none and is literal backticks.

The rule is CommonMark's: a backtick string is a maximal run, so the closer is the first later run of EXACTLY the opening length, and a run of any other length is span content skipped whole — a longer run never closes by its prefix and a shorter one never closes at all. Line endings are content, so a span paired over a paragraph may cross lines.

i is the first backtick of the run: callers walk runs whole and never hand it the middle of one. Whether the run may open a span at all — a backtick behind a backslash is escaped, outside a span only — is the caller's walk to decide, because only the caller knows whether it reads escapes. Asking this function is how every reader and every escaper draws the SAME boundaries: the site renderer, the prose cleaner, the record's comment and span readers and the block escapers once each paired runs by their own walk, and the renderer's walk closed a span where the opening run's TEXT first recurred, so it refused a span a two-backtick run opened around a three-backtick run while the escapers had judged it balanced and left it unescaped (iss-2609262322244502).

The walk visits each byte once per call, so a line whose runs all have DISTINCT lengths, asked once per run, costs time superlinear in its length (iss-2608301803425790). That shape is left in place on a measurement rather than a shrug: a line of 120 distinct-length runs costs ~200us, an ordinary record line ~76ns, and both candidate fixes cost more than they save. Precomputing the runs into a slice takes the bad line to ~7us and the ordinary line to ~115ns with one allocation — every line paying for a shape no record body has; stepping between runs with strings.IndexByte leaves the ordinary line alone and takes the bad line to ~243us, the gaps being too short to repay the call. Re-run mdrecord's BenchmarkOpensCommentDistinctRuns and BenchmarkOpensCommentTypicalLine before revisiting this.

func (PairedSpan) Raw added in v0.11.1

func (c PairedSpan) Raw(s string) string

Raw is the span's text as written between its delimiters, before CodeSpanText normalises it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL