❯ go-clikit
v0.10.0 pkg.go.dev ↗

concepts › untrusted text

Untrusted text

Text from another host can move the cursor, retitle the terminal or reverse a line. termtext shows such characters as visible escapes, and measures text in columns.

A command that works on many hosts prints what those hosts said: their output, their errors, the values a service reported. None of that text is trusted. A carriage return can overwrite a line printed before. An escape sequence can retitle the terminal or write the clipboard. A bidirectional control can show a name in another order than it has.

exe07 sentbackup doneESC]0;owned BELCRbackup FAILEDraw: the terminal obeysownedbackup FAILEDtermtext.EscapeLines: the reader sees what was sentbackup done\x1b]0;owned\x07\rbackup FAILED
Escaped with termtext, the reader sees what was sent.

One place to fix#

A program that escapes everything with termtext, and nothing with anything else, has one place to fix when a character turns out to need escaping too. The kit escapes what it writes itself: the names, errors and lines that the displays show, and what cliout writes.

FunctionUse it for
termtext.Escapeone value on one line, such as a name or an error: newline and tab are escaped too
termtext.EscapeLinestext of many lines, such as the output of a command: newline and tab stay
termtext.NewEscapeWritera stream, such as a child’s output, escaped line by line as it arrives
remote := "backup done\x1b]0;owned\x07\rbackup FAILED\n\tsee the log\n"
fmt.Print(termtext.EscapeLines(remote))
backup done\x1b]0;owned\x07\rbackup FAILED
	see the log
ExampleEscapeLines in termtext/example_test.go. CI runs it and checks its output.

The policy#

The policy is a deny-list. A rune is escaped when it can move the cursor, change the state of the terminal, or change the order or the lines that text is shown in:

  • every C0 control and DEL, except newline and tab in EscapeLines;
  • every C1 control, which some terminals read as the start of a control sequence;
  • the bidirectional controls and the line and paragraph separators;
  • every byte that is not valid UTF-8.

The forms of an escape are stable: \r, \n and \t, \x and two digits for another C0 control, DEL or a bad byte, and \u and four digits for any other rune. Escaping is idempotent: escaped text does not change when you escape it again.

CAUTION

The set of runes that is escaped can grow in a minor release (decision 14). Do not compare escaped text in a test with text that holds a rune outside plain ASCII and the escapes above.

NOTE

The CRLF rule (decision 42): in EscapeLines and the writer, a carriage return just before a newline is part of the line end and stays. So the output of ssh -t and of a Windows host keeps its line ends. A carriage return anywhere else is escaped.

Other invisible characters, such as a zero-width space, stay as they are. They change neither the cursor nor the terminal. Do not use termtext to make look-alike names visible.

Columns, not bytes#

A display cuts rows to the width of the terminal, and a table aligns its columns. Both need the columns a terminal gives text, which is not its length in bytes nor in runes.

for _, s := range []string{"exe0001", "Grüße", "ノード"} {
	fmt.Println(s, len(s), termtext.Width(s))
}
exe0001 7 7
Grüße 7 5
ノード 9 6
ExampleWidth in termtext/example_test.go. CI runs it and checks its output.
  • termtext.Width counts wide characters as two columns, and combining marks as none. It is meant never to count fewer columns than a terminal shows, so that a cut row does not wrap.
  • termtext.Truncate cuts text to a number of columns and never splits a wide rune.
  • termtext.Wrap fills a paragraph to a number of columns, with an indent for the first line and the others.

All three expect text that is already escaped.