diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 037c411..816d2d7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1800,6 +1800,7 @@ dependencies = [ "serde", "smithay-clipboard", "web-time", + "webbrowser", "winit", ] @@ -7074,6 +7075,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "1.0.9" diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml index b960403..ef7d62d 100644 --- a/rust/app/Cargo.toml +++ b/rust/app/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] nexus-core = { path = "../core" } -eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] } +eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence", "links"] } egui = "0.36" parking_lot = "0.12" zeroize = "1" diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 8598252..908136a 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -791,10 +791,9 @@ impl App { /// A remote host (OSC 52 or VNC) wants to set the clipboard: allowed per preference, and always /// announced so a silent swap can't set up a paste into another shell. pub fn remote_clipboard(&mut self, text: &str) { - let first: String = text.lines().next().unwrap_or("").chars().take(60).collect(); if self.s.prefs.allow_remote_clipboard { self.s.ctx.copy_text(text.into()); - self.s.info(format!("Remote host set the clipboard: {first}")); + self.s.info(format!("Remote host set the clipboard: {}", clipboard_summary(text))); } else { self.s.info("Blocked a remote clipboard write (Settings → General to allow)"); } @@ -1077,3 +1076,22 @@ impl eframe::App for App { let _ = ToastKind::Info; } } + +/// How a remote clipboard write is announced: control characters made visible and the line +/// count stated, so `ls\ncurl … | sh` can't pass for a harmless `ls`. +fn clipboard_summary(text: &str) -> String { + let lines = text.lines().count(); + let shown: String = text.chars().take(60).flat_map(|c| if c.is_control() { c.escape_debug().collect::>() } else { vec![c] }).collect(); + let more = if text.chars().count() > 60 { "…" } else { "" }; + if lines > 1 { format!("{shown}{more} ({lines} lines)") } else { format!("{shown}{more}") } +} + +#[cfg(test)] +mod clipboard_tests { + #[test] + fn remote_clipboard_toast_shows_hidden_lines() { + assert_eq!(super::clipboard_summary("ls"), "ls"); + assert_eq!(super::clipboard_summary("ls\ncurl x|sh\n"), "ls\\ncurl x|sh\\n (2 lines)"); + assert!(super::clipboard_summary("a\x0fb").contains("\\u{f}")); + } +} diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index caf7d05..e319bce 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -931,7 +931,13 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { } Dialog::ConfirmPaste { session, text } => { let lines = text.lines().count(); - let preview: String = text.lines().take(12).collect::>().join("\n"); + // Control characters other than newline/tab act on their own; show them. + let preview: String = text + .lines() + .take(12) + .map(|l| l.chars().flat_map(|c| if c.is_control() && c != '\t' { c.escape_debug().collect::>() } else { vec![c] }).collect::()) + .collect::>() + .join("\n"); modal(ctx, "confirm-paste", 480.0, |ui| { ui.label(RichText::new("Paste multiple lines?").font(theme::semibold(15.0))); widgets::hint(ui, &format!("{lines} line(s) will run as commands as soon as they are pasted:")); diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs index 06f43fa..704ee0e 100644 --- a/rust/app/src/state.rs +++ b/rust/app/src/state.rs @@ -45,6 +45,8 @@ pub enum SettingsTab { About, } +const MAX_TOASTS: usize = 8; + pub struct Toast { pub text: String, pub kind: ToastKind, @@ -129,8 +131,17 @@ impl Shared { } fn push(&mut self, text: impl Into, kind: ToastKind) { - let secs = if kind == ToastKind::Error { 6.0 } else { 4.0 }; - self.toasts.push(Toast { text: text.into(), kind, until: self.now + secs }); + let (text, secs) = (text.into(), if kind == ToastKind::Error { 6.0 } else { 4.0 }); + // Remote output can raise toasts (triggers, clipboard writes) at line rate: repeats only + // extend the visible one, and at most a handful are kept so a flood can't stall frames. + if let Some(t) = self.toasts.iter_mut().find(|t| t.text == text && t.kind == kind) { + t.until = self.now + secs; + } else { + self.toasts.push(Toast { text, kind, until: self.now + secs }); + if self.toasts.len() > MAX_TOASTS { + self.toasts.remove(0); + } + } self.ctx.request_repaint(); } diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs index 23ab03f..bafb167 100644 --- a/rust/app/src/terminal.rs +++ b/rust/app/src/terminal.rs @@ -280,11 +280,18 @@ impl TermView { return; } let decoded = (|| { - let mut r = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?; + let reader = || image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok(); + // Read the header first: refuse anything larger than the per-terminal image budget + // before decoding, since the decoder's own limits don't bound every internal buffer. + let (w, h) = reader()?.into_dimensions().ok()?; + if w as usize * h as usize > MAX_IMAGE_PIXELS { + return None; + } + let mut r = reader()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(8192); limits.max_image_height = Some(8192); - limits.max_alloc = Some(256 << 20); + limits.max_alloc = Some((MAX_IMAGE_PIXELS * 4) as u64); r.limits(limits); Some(r.decode().ok()?.to_rgba8()) })(); @@ -707,9 +714,11 @@ impl TermView { } /// Pastes `text`, or hands it back in `out.confirm_paste` when `confirm_paste` is set and the - /// newlines would run each line immediately (no bracketed paste). + /// paste could act by itself without bracketed paste: newlines run lines, and other control + /// characters do too (readline's C-o runs the line, C-d exits, ESC drives key macros). pub fn paste(&mut self, text: &str, out: &mut TermResponse) { - if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && text.contains(['\n', '\r']) { + let acts = text.chars().any(|c| c.is_control() && c != '\t'); + if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && acts { out.confirm_paste = Some(text.into()); return; } @@ -871,8 +880,8 @@ impl TermView { return Some(link); } let (text, cols) = self.line_text(point.line); - let re = regex::Regex::new(URL_RE).ok()?; - re.find_iter(&text).find_map(|m| { + static RE: std::sync::LazyLock = std::sync::LazyLock::new(|| regex::Regex::new(URL_RE).expect("URL_RE is valid")); + RE.find_iter(&text).find_map(|m| { let start = text[..m.start()].chars().count(); let end = start + m.as_str().chars().count() - 1; (cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string()) @@ -1226,12 +1235,39 @@ mod tests { let mut out = TermResponse::default(); t.paste("one line", &mut out); assert_eq!(out.input, b"one line"); + // Control characters that act without a newline need the prompt too (C-o runs the line). + let mut out = TermResponse::default(); + t.paste("curl x|sh\x0f", &mut out); + assert!(out.input.is_empty() && out.confirm_paste.is_some()); + let mut out = TermResponse::default(); + t.paste("a\tb", &mut out); + assert_eq!(out.input, b"a\tb", "a tab is fine"); t.feed(b"\x1b[?2004h"); let mut out = TermResponse::default(); t.paste("a\nb", &mut out); assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt"); } + #[test] + fn oversized_inline_images_are_refused_before_decoding() { + use std::io::Cursor; + let png = |w: u32, h: u32| { + let mut v = Vec::new(); + image::RgbaImage::new(w, h).write_to(&mut Cursor::new(&mut v), image::ImageFormat::Png).unwrap(); + v + }; + let b64 = |d: &[u8]| { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(d) + }; + let mut t = TermView::new(1000, 14.0, CursorStyle::Block); + // 8192x8192 is within the decoder's dimension limits but over the 32 Mpx budget. + t.feed(format!("\x1b]1337;File=inline=1:{}\x07", b64(&png(8192, 8192))).as_bytes()); + assert!(t.images.is_empty()); + t.feed(format!("\x1b]1337;File=inline=1:{}\x07", b64(&png(64, 64))).as_bytes()); + assert_eq!(t.images.len(), 1); + } + #[test] fn shell_integration_marks_survive_a_full_scrollback() { // Scrollback of 30 lines, so the history fills and rotates during the test. diff --git a/rust/app/src/widgets.rs b/rust/app/src/widgets.rs index 63a7e1b..4d70129 100644 --- a/rust/app/src/widgets.rs +++ b/rust/app/src/widgets.rs @@ -129,8 +129,11 @@ pub fn group(n: u64) -> String { /// GitHub API responses) are only handed to the system opener when they are http(s), so a /// hostile or spoofed server can't launch `file:`, `ssh:` or custom-scheme handlers. pub fn is_web_url(url: &str) -> bool { - let u = url.trim(); - (u.len() > 8 && u[..8].eq_ignore_ascii_case("https://")) || (u.len() > 7 && u[..7].eq_ignore_ascii_case("http://")) + // Compare bytes: slicing the `str` at 7/8 panics when that offset falls inside a multi-byte + // character, and these strings come from the network (hovering a hostile OSC 8 link). + let u = url.trim().as_bytes(); + let starts = |p: &[u8]| u.len() > p.len() && u[..p.len()].eq_ignore_ascii_case(p); + starts(b"https://") || starts(b"http://") } /// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise. @@ -166,7 +169,20 @@ mod tests { for ok in ["https://dixon.cx/downloads/nexus/", "http://localhost:3000/x", " HTTPS://Example.com "] { assert!(super::is_web_url(ok), "{ok}"); } - for bad in ["", "https://", "file:///etc/passwd", "ssh://evil", "javascript:alert(1)", "ms-msdt:/id x", "ftp://host/", "//host/path"] { + assert!(super::is_web_url("http://über.example/")); + for bad in [ + "", + "https://", + "file:///etc/passwd", + "ssh://evil", + "javascript:alert(1)", + "ms-msdt:/id x", + "ftp://host/", + "//host/path", + "mailto:ü@x", + "http:/üüü", + "ftp://é", + ] { assert!(!super::is_web_url(bad), "{bad}"); } }