use gtk::prelude::*;
use gtk::{gio, glib};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use vte::prelude::*;
const LARGE_PASTE_BYTES: usize = 4096;
const CANCEL_BUTTON: i32 = 0;
const PASTE_BUTTON: i32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PasteMetadata {
line_break_count: usize,
line_count: usize,
character_count: usize,
byte_count: usize,
disallowed_control_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PasteAssessment {
metadata: PasteMetadata,
unusually_large: bool,
}
impl PasteAssessment {
fn requires_confirmation(self) -> bool {
self.metadata.line_break_count > 0
|| self.metadata.disallowed_control_count > 0
|| self.unusually_large
}
}
#[derive(Clone)]
pub(crate) struct PasteController {
inner: Rc<PasteControllerInner>,
}
struct PasteControllerInner {
window: glib::WeakRef<gtk::ApplicationWindow>,
terminal: glib::WeakRef<vte::Terminal>,
pending_request: Cell<Option<u64>>,
next_request: Cell<u64>,
cancellable: RefCell<Option<(u64, gio::Cancellable)>>,
terminal_focused: Cell<bool>,
}
impl PasteController {
pub(crate) fn new(window: >k::ApplicationWindow, terminal: &vte::Terminal) -> Self {
let inner = Rc::new(PasteControllerInner {
window: window.downgrade(),
terminal: terminal.downgrade(),
pending_request: Cell::new(None),
next_request: Cell::new(0),
cancellable: RefCell::new(None),
terminal_focused: Cell::new(false),
});
let terminal_focus = gtk::EventControllerFocus::new();
terminal_focus.connect_contains_focus_notify({
let inner = Rc::downgrade(&inner);
move |focus| {
if let Some(inner) = inner.upgrade() {
inner.terminal_focused.set(focus.contains_focus());
}
}
});
terminal.add_controller(terminal_focus);
window.connect_destroy({
let inner = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = inner.upgrade() {
inner.cancel_pending();
}
}
});
Self { inner }
}
pub(crate) fn request(&self) {
if self.inner.pending_request.get().is_some() {
return;
}
let Some((window, _terminal)) = self.inner.active_target() else {
self.inner.cancel_pending();
return;
};
let request = self.inner.next_request.get().wrapping_add(1);
self.inner.next_request.set(request);
self.inner.pending_request.set(Some(request));
let cancellable = gio::Cancellable::new();
self.inner
.cancellable
.replace(Some((request, cancellable.clone())));
let inner = self.inner.clone();
window
.clipboard()
.read_text_async(Some(&cancellable), move |result| {
inner.clipboard_read(request, result);
});
}
}
impl PasteControllerInner {
fn clipboard_read(
self: &Rc<Self>,
request: u64,
result: Result<Option<glib::GString>, glib::Error>,
) {
if !self.is_current(request) {
return;
}
let Some((window, terminal)) = self.active_target() else {
self.finish(request);
return;
};
let Ok(Some(text)) = result else {
self.finish(request);
return;
};
if text.is_empty() {
self.finish(request);
return;
}
let assessment = classify(text.as_str());
if !assessment.requires_confirmation() {
terminal.paste_text(text.as_str());
self.finish(request);
return;
}
let dialog = gtk::AlertDialog::builder()
.message("Confirm risky terminal paste")
.detail(confirmation_detail(assessment))
.buttons(["Cancel", "Paste"])
.cancel_button(CANCEL_BUTTON)
.default_button(CANCEL_BUTTON)
.modal(true)
.build();
let cancellable = self
.cancellable
.borrow()
.as_ref()
.filter(|(id, _)| *id == request)
.map(|(_, cancellable)| cancellable.clone());
let inner = self.clone();
dialog.choose(Some(&window), cancellable.as_ref(), move |result| {
if !inner.is_current(request) {
return;
}
let approved = matches!(result, Ok(PASTE_BUTTON));
let window_active = inner
.window
.upgrade()
.is_some_and(|window| window.is_active());
let terminal = inner.terminal.upgrade();
inner.finish(request);
if approved && window_active {
if let Some(terminal) = terminal {
terminal.paste_text(text.as_str());
}
}
});
}
fn is_current(&self, request: u64) -> bool {
self.pending_request.get() == Some(request)
}
fn active_target(&self) -> Option<(gtk::ApplicationWindow, vte::Terminal)> {
let window = self.window.upgrade()?;
let terminal = self.terminal.upgrade()?;
(window.is_active() && self.terminal_focused.get()).then_some((window, terminal))
}
fn finish(&self, request: u64) {
if self.is_current(request) {
self.pending_request.set(None);
self.cancellable.borrow_mut().take();
}
}
fn cancel_pending(&self) {
self.pending_request.set(None);
if let Some((_, cancellable)) = self.cancellable.borrow_mut().take() {
cancellable.cancel();
}
}
}
fn classify(text: &str) -> PasteAssessment {
let mut character_count = 0;
let mut line_break_count = 0;
let mut disallowed_control_count = 0;
let mut characters = text.chars().peekable();
while let Some(character) = characters.next() {
character_count += 1;
match character {
'\r' => {
line_break_count += 1;
if characters.peek() == Some(&'\n') {
characters.next();
character_count += 1;
}
}
'\n' => line_break_count += 1,
character if is_disallowed_control(character) => disallowed_control_count += 1,
_ => {}
}
}
let byte_count = text.len();
PasteAssessment {
metadata: PasteMetadata {
line_break_count,
line_count: if text.is_empty() {
0
} else {
line_break_count + 1
},
character_count,
byte_count,
disallowed_control_count,
},
unusually_large: byte_count > LARGE_PASTE_BYTES,
}
}
fn is_disallowed_control(character: char) -> bool {
matches!(character, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}')
&& !matches!(character, '\t' | '\n' | '\r')
}
fn confirmation_detail(assessment: PasteAssessment) -> String {
let metadata = assessment.metadata;
let mut reasons = Vec::new();
if metadata.line_break_count > 0 {
reasons.push(format!(
"{} line break{}",
metadata.line_break_count,
plural(metadata.line_break_count)
));
}
if metadata.disallowed_control_count > 0 {
reasons.push(format!(
"{} disallowed control character{}",
metadata.disallowed_control_count,
plural(metadata.disallowed_control_count)
));
}
if assessment.unusually_large {
reasons.push(format!(
"unusually large (over {LARGE_PASTE_BYTES} UTF-8 bytes)"
));
}
format!(
"Reasons: {}.\nCounts: {} line{}, {} character{}, {} UTF-8 byte{}.",
reasons.join(", "),
metadata.line_count,
plural(metadata.line_count),
metadata.character_count,
plural(metadata.character_count),
metadata.byte_count,
plural(metadata.byte_count),
)
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normal_tab_and_empty_text_are_safe() {
for text in ["printf safe", "one\ttwo", ""] {
assert!(!classify(text).requires_confirmation(), "{text:?}");
}
assert_eq!(classify("").metadata.line_count, 0);
}
#[test]
fn lf_cr_and_crlf_have_correct_line_counts() {
for text in ["one\ntwo", "one\rtwo", "one\r\ntwo"] {
let metadata = classify(text).metadata;
assert_eq!(metadata.line_break_count, 1, "{text:?}");
assert_eq!(metadata.line_count, 2, "{text:?}");
}
let metadata = classify("one\r\ntwo\rthree\nfour").metadata;
assert_eq!(metadata.line_break_count, 3);
assert_eq!(metadata.line_count, 4);
}
#[test]
fn c0_and_c1_controls_are_risky_but_tab_and_line_breaks_are_separate() {
let assessment = classify("a\u{0000}\u{001b}\u{007f}\u{0080}\u{009f}\t\r\nb");
assert!(assessment.requires_confirmation());
assert_eq!(assessment.metadata.disallowed_control_count, 5);
assert_eq!(assessment.metadata.line_break_count, 1);
}
#[test]
fn unicode_character_and_utf8_byte_counts_are_distinct() {
let metadata = classify("é🙂").metadata;
assert_eq!(metadata.character_count, 2);
assert_eq!(metadata.byte_count, 6);
assert_eq!(metadata.line_count, 1);
}
#[test]
fn unusually_large_threshold_is_strictly_greater_than_4096_bytes() {
let boundary = "a".repeat(LARGE_PASTE_BYTES);
let over_boundary = "a".repeat(LARGE_PASTE_BYTES + 1);
assert!(!classify(&boundary).requires_confirmation());
assert!(classify(&over_boundary).requires_confirmation());
}
#[test]
fn confirmation_metadata_does_not_include_payload_text() {
let payload = "private-marker\nsecond-private-marker";
let detail = confirmation_detail(classify(payload));
assert!(!detail.contains("private-marker"));
assert!(!detail.contains("second-private-marker"));
assert!(detail.contains("Reasons: 1 line break"));
assert!(detail.contains("Counts: 2 lines, 36 characters, 36 UTF-8 bytes"));
}
}