Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ pkarr = { version = "8.0.0", default-features = false, features = ["dht"] }
# against it. See docs/plans/interactive-terminal-ui-plan-2026-09-10.md.
ratatui = "0.30.2"
crossterm = "0.29.0"
# Display width of a name a peer chose, so eliding it counts a CJK character as
# the two columns it takes. Pure Rust, and already in the tree through ratatui,
# so this adds no crate to the build.
unicode-width = "0.2"

[dev-dependencies]
api = { path = ".." }
Expand Down
3 changes: 2 additions & 1 deletion cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ pub fn create_session(origin: &str, ciphertext_size: u64) -> Result<String, Clie
.and_then(|body| body.message)
.unwrap_or_else(|| format!("the relay rejected the request with status {status}"));

Err(ClientError::Server(message))
// The relay is untrusted, and this is printed as it arrives.
Err(ClientError::Server(crate::display::peer_message(&message)))
}
Err(error) => Err(ClientError::Transport(format!(
"could not reach {origin}: {error}"
Expand Down
262 changes: 262 additions & 0 deletions cli/src/display.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
//! Putting text somebody else chose on this terminal.
//!
//! A filename is chosen by the sender, an error message by the peer or the
//! relay, and none of them is trusted. Printed as-is, a name can carry escape
//! sequences that move the cursor, clear the line, retitle the window, or plant
//! a hyperlink. It can also carry bidirectional controls that make `exe.pdf`
//! read as `fdp.exe`. That matters most exactly where Drop asks a person to
//! decide something about the name they are looking at, and it matters already
//! for the "Receiving" line, which is printed before any decision exists.
//!
//! Everything here replaces rather than removes. A `\u{FFFD}` where a control
//! character was tells the reader the name was odd; silently deleting it would
//! show them a cleaner name than the one that exists.
//!
//! Legitimate names in any script pass through unchanged: only control and
//! formatting characters are touched, never letters, marks or emoji.

use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

/// What stands in for anything that should not reach a terminal.
const REPLACEMENT: char = '\u{FFFD}';

/// Columns a name may take before its middle is elided.
///
/// Wide enough for ordinary names, narrow enough that a hostile one cannot push
/// a question onto a line of its own and off the screen.
pub const NAME_COLUMNS: usize = 80;

/// Columns kept from the end of an elided name, so the extension — the part
/// that says what kind of file this is — always survives.
const TAIL_COLUMNS: usize = 24;

/// Columns a message from a peer or a relay may take.
const MESSAGE_COLUMNS: usize = 200;

/// Characters that are not controls but still change how a terminal lays text
/// out: bidirectional overrides, embeddings, isolates and marks; line and
/// paragraph separators; and invisible characters that let two different names
/// look identical.
///
/// Zero-width joiner and non-joiner (U+200D, U+200C) are deliberately absent.
/// Emoji sequences and several scripts need them, and they cannot reorder or
/// hide text.
fn is_layout_control(character: char) -> bool {
matches!(
character,
'\u{061C}' // arabic letter mark
| '\u{200B}' // zero width space
| '\u{200E}' | '\u{200F}' // left-to-right and right-to-left marks
| '\u{2028}' | '\u{2029}' // line and paragraph separators
| '\u{202A}'..='\u{202E}' // embeddings and overrides
| '\u{2060}' // word joiner
| '\u{2066}'..='\u{2069}' // isolates
| '\u{FEFF}' // byte order mark
)
}

/// Makes untrusted text safe to print, without shortening it.
///
/// Control characters (C0, DEL, C1, which includes the bytes that start every
/// escape sequence) and layout controls become `\u{FFFD}`. Runs of whitespace
/// collapse to one space, so padding cannot push the end of a name out of
/// sight.
pub fn for_terminal(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_whitespace = false;

for character in text.chars() {
if character.is_control() || is_layout_control(character) {
out.push(REPLACEMENT);
in_whitespace = false;
} else if character.is_whitespace() {
if !in_whitespace {
out.push(' ');
}
in_whitespace = true;
} else {
out.push(character);
in_whitespace = false;
}
}

out
}

/// A name a peer chose, ready to print on one line.
///
/// Sanitised by [`for_terminal`], then elided in the middle if it is wider than
/// [`NAME_COLUMNS`]. The middle rather than the end, because the end holds the
/// extension and the extension is what says whether this is a document or a
/// program.
pub fn name(text: &str) -> String {
elide_middle(&for_terminal(text), NAME_COLUMNS)
}

/// A message a peer or a relay sent, ready to print after `error: `.
///
/// Neither is trusted, so this is sanitised too, and capped so a hostile one
/// cannot fill the screen.
pub fn peer_message(text: &str) -> String {
elide_middle(&for_terminal(text), MESSAGE_COLUMNS)
}

/// Shortens `text` to at most `columns` display columns by replacing its middle
/// with an ellipsis, measuring wide characters as two columns.
fn elide_middle(text: &str, columns: usize) -> String {
if text.width() <= columns {
return text.to_string();
}

let tail_budget = TAIL_COLUMNS.min(columns / 2);
// One column for the ellipsis itself.
let head_budget = columns - tail_budget - 1;

let mut head = String::new();
let mut used = 0;
for character in text.chars() {
let width = character.width().unwrap_or(0);
if used + width > head_budget {
break;
}
head.push(character);
used += width;
}

let mut tail: Vec<char> = Vec::new();
let mut used = 0;
for character in text.chars().rev() {
let width = character.width().unwrap_or(0);
if used + width > tail_budget {
break;
}
tail.push(character);
used += width;
}
tail.reverse();

format!("{head}\u{2026}{}", tail.into_iter().collect::<String>())
}

#[cfg(test)]
mod tests {
use super::{NAME_COLUMNS, for_terminal, name, peer_message};
use unicode_width::UnicodeWidthStr;

#[test]
fn a_csi_sequence_cannot_reach_the_terminal() {
// Clear the line, move up, and print something else.
let hostile = "report.pdf\x1b[2K\x1b[1Ainvoice.pdf";
let shown = for_terminal(hostile);

assert!(!shown.contains('\x1b'), "escape survived: {shown:?}");
assert!(
shown.contains('\u{FFFD}'),
"the tampering should be visible"
);
assert!(shown.contains("report.pdf") && shown.contains("invoice.pdf"));
}

#[test]
fn an_osc_hyperlink_and_a_window_title_are_neutralised() {
let hyperlink = "\x1b]8;;https://example.invalid\x07click.txt\x1b]8;;\x07";
let title = "\x1b]0;owned\x07name.txt";

for hostile in [hyperlink, title] {
let shown = for_terminal(hostile);
assert!(
!shown.contains('\x1b') && !shown.contains('\x07'),
"{shown:?}"
);
}
}

#[test]
fn c1_controls_are_replaced_too() {
// U+009B is a single-character CSI on terminals that honour C1.
let shown = for_terminal("a\u{009B}2Kb");
assert_eq!(shown, "a\u{FFFD}2Kb");
}

#[test]
fn newlines_and_tabs_cannot_break_a_line() {
let shown = for_terminal("first\nsecond\rthird\tfourth");
assert!(!shown.contains(['\n', '\r', '\t']), "{shown:?}");
}

#[test]
fn every_bidirectional_control_is_replaced() {
let controls = [
'\u{061C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
];

for control in controls {
let shown = for_terminal(&format!("invoice{control}fdp.exe"));
assert!(
!shown.contains(control),
"U+{:04X} survived: {shown:?}",
control as u32
);
}
}

#[test]
fn the_classic_right_to_left_override_shows_its_real_extension() {
// Rendered naively this reads "invoice_exe.pdf".
let shown = name("invoice_\u{202E}fdp.exe");
assert!(shown.ends_with("fdp.exe"), "{shown:?}");
}

#[test]
fn whitespace_padding_collapses() {
let shown = for_terminal("report.pdf .exe");
assert_eq!(shown, "report.pdf .exe");
}

#[test]
fn a_long_name_keeps_its_extension() {
let long = format!("{}.exe", "a".repeat(300));
let shown = name(&long);

assert!(shown.width() <= NAME_COLUMNS, "{} columns", shown.width());
assert!(shown.ends_with(".exe"), "{shown:?}");
assert!(shown.contains('\u{2026}'));
}

#[test]
fn wide_characters_count_as_two_columns() {
let long = format!("{}.txt", "東".repeat(100));
let shown = name(&long);

assert!(shown.width() <= NAME_COLUMNS, "{} columns", shown.width());
assert!(shown.ends_with(".txt"));
}

#[test]
fn honest_names_in_any_script_are_untouched() {
for honest in [
"Łódź 東京 🎉.txt",
"résumé-final (2).pdf",
"مرحبا.docx",
"👩‍👩‍👧 family.jpg", // zero-width joiners inside the emoji
"10:30 standup.md",
] {
assert_eq!(name(honest), honest);
}
}

#[test]
fn a_short_name_is_not_elided() {
assert_eq!(name("report.pdf"), "report.pdf");
}

#[test]
fn a_peer_message_is_sanitised_and_capped() {
let hostile = format!("\x1b[2J{}", "x".repeat(10_000));
let shown = peer_message(&hostile);

assert!(!shown.contains('\x1b'));
assert!(shown.width() <= 200);
}
}
1 change: 1 addition & 0 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod direct;
// the browser client can compile it to WebAssembly. Keeping the name `crypto`
// means every call site below reads the same as before the split.
pub use drop_crypto as crypto;
pub mod display;
pub mod payload;
pub mod progress;
pub mod recv;
Expand Down
Loading