Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182//! 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("日本語"), "日本語"); }}