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;
#[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::MessageDialog::builder()
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::None)
.text("Confirm risky terminal paste")
.secondary_text(confirmation_detail(assessment))
.build();
dialog.add_button("Cancel", gtk::ResponseType::Cancel);
dialog.add_button("Paste", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
let inner = self.clone();
dialog.connect_response(move |dialog, response| {
if !inner.is_current(request) {
dialog.destroy();
return;
}
let approved = response == gtk::ResponseType::Accept;
let target_active = dialog.is_active()
|| inner
.window
.upgrade()
.is_some_and(|window| window.is_active());
let terminal = inner.terminal.upgrade();
inner.finish(request);
dialog.destroy();
if approved && target_active {
if let Some(terminal) = terminal {
terminal.paste_text(text.as_str());
}
}
});
dialog.present();
}
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"));
}
}