//! Somebody else's words, on your terminal. //! //! Every title, body, comment and excerpt atgc prints came out of a record //! written by an account it has never met. Opening a pull or an issue against //! a repo needs no permission on it (see [Architecture](crate::docs::architecture)), //! so the set of people who can put a string in front of a maintainer running //! `atgc pr list` is *everyone*, and a terminal does not draw a string — it //! interprets one. `\x1b[2J\x1b[H` clears the screen and starts the output //! again somewhere the reader is not looking; `\r` returns the cursor to //! column zero so the rest of the title overwrites what the row already said; //! `\x1b]8;;` opens a hyperlink whose text and destination need not agree, and //! `\x1b]52;` writes the clipboard on terminals that honour it. //! //! The half of this that was already noticed points the wrong way: //! [`crate::term::column::width`] recognises CSI and OSC sequences so that a //! cell carrying one is padded to the width it *draws* in. That is correct //! for atgc's own escapes and it means a hostile title lines up perfectly. //! //! # The rule //! //! [`clean`] drops the characters a terminal acts on rather than draws: //! C0 (`U+0000`–`U+001F`) except `\n` and `\t`, `U+007F`, and C1 //! (`U+0080`–`U+009F`). Everything else survives byte for byte, so prose in //! any script arrives as it was written. //! //! Dropped, not escaped and not replaced. Removing the `ESC` from //! `\x1b[2J` leaves `[2J`, which prints as the four characters it is: the //! reader sees that something was there, and nothing acts on it. `gh` strips //! the same way in `pr view` and `issue view`, for the same reason. //! //! `\r` is not one of the two survivors. It is the escape-free version of the //! same trick — a line that ends in `\rsomething else` shows only the second //! half — and a `\r\n` body loses nothing a terminal wanted, since the `\n` //! it is paired with is what actually ends the line. //! //! `U+2028` and `U+2029` are deliberately *not* dropped. They break lines for //! a text engine and for a JavaScript parser; a terminal draws them as a //! glyph or as nothing, and no terminal acts on them. A rule about them would //! be a rule about somebody else's renderer. //! //! # Where it is applied, and where it must not be //! //! At the boundary where record text becomes a line for a person: the //! `ellipsize` call sites in the listings, the body printers in `pr view` and //! `issue view`, the comment thread in [`crate::cmd::print_thread`], and //! [`crate::term::hyperlink::handle`], which builds a URL out of a string an //! index handed back. //! //! Two places are exempt on purpose: //! //! - **`--json`.** serde escapes control characters on the way out, and the //! value is the contract: something reading the document back wants what //! the record holds, not a rendering of it. Same rule as the ellipsis and //! the padding — see [`crate::term::jsonout`], rule 3. //! - **`pr diff`.** A patch is bytes for `git am`, not a line for a person. //! Filtering one would corrupt it, and git's own pager already decides what //! to do with what is in there. //! //! Both exemptions are in [Output contracts](crate::docs::output), beside the //! other four rules every command holds to. /// Drop what a terminal would act on, keep what it would draw. /// /// C0 except `\n` and `\t`, `U+007F`, and C1. The module documentation above /// has the reasoning and the two exemptions; this is the whole rule. /// /// Allocates unconditionally rather than returning a `Cow`, because every /// caller is about to `format!` the result into a line anyway and the borrow /// would only be threaded through to be cloned at the end of it. pub(crate) fn clean(text: &str) -> String { text.chars().filter(|c| !acts_on_the_terminal(*c)).collect() } /// [`clean`] for a value going into a fixed-width column. /// /// The two characters [`clean`] keeps are the two a *row* cannot have. A /// title holding `\n` prints as two lines, and the second one is a row the /// reader did not fetch, in a listing whose whole shape says one record per /// line — forging a row takes no escape sequence at all. A `\t` jumps to the /// next tab stop, which moves every column after it. /// /// Both become one space, rather than being dropped, so that the words either /// side of them stay separate words. pub(crate) fn one_line(text: &str) -> String { text.chars() .filter_map(|c| match c { c if acts_on_the_terminal(c) => None, '\n' | '\t' => Some(' '), c => Some(c), }) .collect() } /// The one predicate both functions filter on: a character a terminal reads /// as an instruction rather than as something to draw. fn acts_on_the_terminal(c: char) -> bool { match c { '\n' | '\t' => false, '\u{0}'..='\u{1f}' | '\u{7f}' | '\u{80}'..='\u{9f}' => true, _ => false, } } #[cfg(test)] mod tests { use super::{clean, one_line}; /// The whole C0 range and the whole C1 range, one character at a time, /// rather than the handful anybody thinks to write down. #[test] fn every_control_character_is_dropped_except_the_two_kept() { for c in ('\u{0}'..='\u{1f}').chain(std::iter::once('\u{7f}')) { let printed = clean(&format!("a{c}b")); match c { '\n' | '\t' => assert_eq!(printed, format!("a{c}b"), "{:#04x} kept", c as u32), _ => assert_eq!(printed, "ab", "{:#04x} dropped", c as u32), } } for c in '\u{80}'..='\u{9f}' { assert_eq!(clean(&format!("a{c}b")), "ab", "{:#04x} dropped", c as u32); } } /// `\r` is not a survivor: on its own it returns the cursor to column /// zero, so what follows overwrites the line that was already there. #[test] fn a_carriage_return_goes_and_the_newline_beside_it_stays() { assert_eq!(clean("first\rsecond"), "firstsecond"); assert_eq!(clean("line one\r\nline two"), "line one\nline two"); } /// Ordinary prose is not a security question and must survive exactly. /// Multibyte text is where a filter written over bytes goes wrong. #[test] fn text_a_terminal_would_draw_is_untouched() { assert_eq!(clean(""), ""); assert_eq!(clean("a plain title"), "a plain title"); assert_eq!(clean("日本語のタイトル"), "日本語のタイトル"); assert_eq!(clean("🧬 émoji and áccents"), "🧬 émoji and áccents"); // U+2028 and U+2029 are line breaks for a text engine, not for a // terminal, and this module's rule is about terminals. assert_eq!(clean("a\u{2028}b\u{2029}c"), "a\u{2028}b\u{2029}c"); // A body keeps its shape: paragraphs and indentation both. assert_eq!(clean("one\n\n\ttwo\n"), "one\n\n\ttwo\n"); } /// The sequence from the bug report: an OSC 8 hyperlink whose text and /// destination disagree. What is left is the letters, and no `ESC`. #[test] fn an_osc_8_link_comes_apart_into_its_letters() { let hostile = "\x1b]8;;https://evil.example\x1b\\ok\x1b]8;;\x1b\\"; let printed = clean(hostile); assert!(!printed.contains('\x1b'), "{printed:?}"); assert_eq!(printed, "]8;;https://evil.example\\ok]8;;\\"); } /// A CSI that would rewrite the screen. Dropping the introducer leaves /// the parameters as visible text, which is the intended failure mode: /// the reader sees that something was there. #[test] fn a_clear_screen_is_left_as_printable_text() { let printed = clean("\x1b[2J\x1b[Hgotcha"); assert!(!printed.contains('\x1b'), "{printed:?}"); assert_eq!(printed, "[2J[Hgotcha"); // And the C1 spelling of CSI, which is one character rather than two // and so slips past anything looking only for `ESC`. assert_eq!(clean("\u{9b}2Jgotcha"), "2Jgotcha"); } /// A column's version keeps the row a row. `\n` in a title would print a /// second line that looks like a second record. #[test] fn a_column_value_cannot_carry_a_line_break_or_a_tab() { assert_eq!(one_line("real title\nforged row"), "real title forged row"); assert_eq!(one_line("a\tb"), "a b"); assert_eq!(one_line("\x1b[31mred\x1b[0m"), "[31mred[0m"); // Everything `clean` drops, `one_line` drops too. assert_eq!(one_line("first\rsecond"), "firstsecond"); assert_eq!(one_line("日本語"), "日本語"); } }