use gtk::prelude::*;
use gtk::{gio, glib};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use vte::prelude::*;
const LARGE_PASTE_BYTES: usize = 4096;
const BACKGROUND_ASSESSMENT_BYTES: usize = 64 * 1024;
const MAX_PASTE_BYTES: usize = 64 * 1024 * 1024;
const CANCELLATION_CHECK_CHARACTERS: usize = 4096;
const CLIPBOARD_READ_CHUNK_BYTES: usize = 64 * 1024;
const CLIPBOARD_READ_TIMEOUT: Duration = Duration::from_secs(15);
const TEXT_MIME_TYPES: &[&str] = &["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"];
#[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(Debug, PartialEq, Eq)]
struct AssessedPaste {
text: String,
assessment: PasteAssessment,
}
fn should_assess_in_background(byte_count: usize) -> bool {
byte_count >= BACKGROUND_ASSESSMENT_BYTES
}
fn paste_size_is_allowed(byte_count: usize) -> bool {
byte_count <= MAX_PASTE_BYTES
}
fn assess_owned(
text: String,
cancellation_generation: &AtomicU64,
expected_generation: u64,
) -> Option<AssessedPaste> {
let assessment = classify_cancellable(&text, || {
cancellation_generation.load(Ordering::Acquire) != expected_generation
})?;
Some(AssessedPaste { text, assessment })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PasteRequestSource {
FocusedTerminal,
ContextMenu,
}
fn paste_target_is_authorized(
source: PasteRequestSource,
window_active: bool,
terminal_focused: bool,
) -> bool {
window_active && (terminal_focused || matches!(source, PasteRequestSource::ContextMenu))
}
#[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)>>,
timeout: RefCell<Option<glib::SourceId>>,
terminal_focused: Cell<bool>,
assessment_generation: Arc<AtomicU64>,
}
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),
timeout: RefCell::new(None),
terminal_focused: Cell::new(false),
assessment_generation: Arc::new(AtomicU64::new(0)),
});
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) {
self.request_from(PasteRequestSource::FocusedTerminal);
}
pub(crate) fn request_from_context_menu(&self) {
self.request_from(PasteRequestSource::ContextMenu);
}
fn request_from(&self, source: PasteRequestSource) {
if self.inner.pending_request.get().is_some() {
return;
}
let Some((window, _terminal)) = self.inner.active_target(source) 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 timeout = glib::timeout_add_local_once(CLIPBOARD_READ_TIMEOUT, {
let inner = Rc::downgrade(&self.inner);
move || {
if let Some(inner) = inner.upgrade() {
inner.timeout.borrow_mut().take();
inner.cancel_pending();
}
}
});
self.inner.timeout.replace(Some(timeout));
let inner = self.inner.clone();
window.clipboard().read_async(
TEXT_MIME_TYPES,
glib::Priority::DEFAULT,
Some(&cancellable),
move |result| inner.clipboard_stream_ready(request, source, result),
);
}
}
impl PasteControllerInner {
fn clipboard_stream_ready(
self: &Rc<Self>,
request: u64,
source: PasteRequestSource,
result: Result<(gio::InputStream, glib::GString), glib::Error>,
) {
if !self.is_current(request) {
return;
}
if self.active_target(source).is_none() {
self.finish(request);
return;
}
let Ok((stream, _mime_type)) = result else {
self.finish(request);
return;
};
self.read_clipboard_chunk(request, source, stream, Vec::new());
}
fn read_clipboard_chunk(
self: &Rc<Self>,
request: u64,
source: PasteRequestSource,
stream: gio::InputStream,
bytes: Vec<u8>,
) {
if !self.is_current(request) {
return;
}
let Some(cancellable) = self
.cancellable
.borrow()
.as_ref()
.filter(|(current, _)| *current == request)
.map(|(_, cancellable)| cancellable.clone())
else {
self.finish(request);
return;
};
let remaining = (MAX_PASTE_BYTES + 1).saturating_sub(bytes.len());
let read_size = remaining.clamp(1, CLIPBOARD_READ_CHUNK_BYTES);
let inner = self.clone();
let stream_for_next = stream.clone();
stream.read_bytes_async(
read_size,
glib::Priority::DEFAULT,
Some(&cancellable),
move |result| {
if !inner.is_current(request) {
return;
}
let Ok(chunk) = result else {
inner.finish(request);
return;
};
if chunk.is_empty() {
inner.finish_clipboard_read(request);
match String::from_utf8(bytes) {
Ok(text) => inner.clipboard_text_ready(request, source, text),
Err(_) => inner.finish(request),
}
return;
}
let mut bytes = bytes;
bytes.extend_from_slice(chunk.as_ref());
if !paste_size_is_allowed(bytes.len()) {
inner.reject_oversized(request, source, bytes.len());
} else {
inner.read_clipboard_chunk(request, source, stream_for_next, bytes);
}
},
);
}
fn clipboard_text_ready(
self: &Rc<Self>,
request: u64,
source: PasteRequestSource,
text: String,
) {
if !self.is_current(request) || self.active_target(source).is_none() {
self.finish(request);
return;
}
if text.is_empty() {
self.finish(request);
return;
}
if should_assess_in_background(text.len()) {
let cancellation_generation = self.assessment_generation.clone();
let generation = cancellation_generation.fetch_add(1, Ordering::AcqRel) + 1;
let assessment_task = gio::spawn_blocking(move || {
assess_owned(text, &cancellation_generation, generation)
});
let inner = Rc::downgrade(self);
glib::spawn_future_local(async move {
match assessment_task.await {
Ok(Some(AssessedPaste { text, assessment })) => {
if let Some(inner) = inner.upgrade() {
inner.assessment_ready(request, source, text, assessment);
}
}
Ok(None) | Err(_) => {
if let Some(inner) = inner.upgrade() {
inner.finish(request);
}
}
}
});
return;
}
let assessment = classify(&text);
self.assessment_ready(request, source, text, assessment);
}
fn reject_oversized(
self: &Rc<Self>,
request: u64,
source: PasteRequestSource,
byte_count: usize,
) {
let Some((window, _terminal)) = self.active_target(source) else {
self.finish(request);
return;
};
self.finish(request);
let dialog = gtk::MessageDialog::builder()
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::Close)
.text("Paste is too large")
.secondary_text(format!(
"The clipboard contains {:.1} MiB of text. Lios limits one paste to {} MiB to keep the terminal responsive.",
byte_count as f64 / (1024.0 * 1024.0),
MAX_PASTE_BYTES / (1024 * 1024)
))
.build();
dialog.connect_response(|dialog, _| dialog.destroy());
dialog.present();
}
fn assessment_ready<T: AsRef<str> + 'static>(
self: &Rc<Self>,
request: u64,
source: PasteRequestSource,
text: T,
assessment: PasteAssessment,
) {
if !self.is_current(request) {
return;
}
let Some((window, terminal)) = self.active_target(source) else {
self.finish(request);
return;
};
if !assessment.requires_confirmation() {
terminal.paste_text(text.as_ref());
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_ref());
}
}
});
dialog.present();
}
fn is_current(&self, request: u64) -> bool {
self.pending_request.get() == Some(request)
}
fn active_target(
&self,
source: PasteRequestSource,
) -> Option<(gtk::ApplicationWindow, vte::Terminal)> {
let window = self.window.upgrade()?;
let terminal = self.terminal.upgrade()?;
paste_target_is_authorized(source, 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();
if let Some(timeout) = self.timeout.borrow_mut().take() {
timeout.remove();
}
self.assessment_generation.fetch_add(1, Ordering::AcqRel);
}
}
fn finish_clipboard_read(&self, request: u64) {
if self.is_current(request) {
self.cancellable.borrow_mut().take();
if let Some(timeout) = self.timeout.borrow_mut().take() {
timeout.remove();
}
}
}
fn cancel_pending(&self) {
self.pending_request.set(None);
self.assessment_generation.fetch_add(1, Ordering::AcqRel);
if let Some(timeout) = self.timeout.borrow_mut().take() {
timeout.remove();
}
if let Some((_, cancellable)) = self.cancellable.borrow_mut().take() {
cancellable.cancel();
}
}
}
fn classify(text: &str) -> PasteAssessment {
classify_cancellable(text, || false).expect("an uncancelled classification always completes")
}
fn classify_cancellable(
text: &str,
mut cancelled: impl FnMut() -> bool,
) -> Option<PasteAssessment> {
let mut character_count = 0;
let mut line_break_count = 0;
let mut disallowed_control_count = 0;
let mut characters_since_check = 0;
let mut characters = text.chars().peekable();
if cancelled() {
return None;
}
while let Some(character) = characters.next() {
character_count += 1;
characters_since_check += 1;
match character {
'\r' => {
line_break_count += 1;
if characters.peek() == Some(&'\n') {
characters.next();
character_count += 1;
characters_since_check += 1;
}
}
'\n' => line_break_count += 1,
character if is_disallowed_control(character) => disallowed_control_count += 1,
_ => {}
}
if characters_since_check >= CANCELLATION_CHECK_CHARACTERS {
if cancelled() {
return None;
}
characters_since_check = 0;
}
}
let byte_count = text.len();
Some(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::*;
fn assert_send<T: Send>() {}
#[test]
fn assessment_dispatch_keeps_normal_pastes_inline() {
assert!(!should_assess_in_background(0));
assert!(!should_assess_in_background(
BACKGROUND_ASSESSMENT_BYTES - 1
));
assert!(should_assess_in_background(BACKGROUND_ASSESSMENT_BYTES));
assert!(should_assess_in_background(BACKGROUND_ASSESSMENT_BYTES + 1));
}
#[test]
fn paste_size_limit_has_an_inclusive_boundary() {
assert!(paste_size_is_allowed(MAX_PASTE_BYTES));
assert!(!paste_size_is_allowed(MAX_PASTE_BYTES + 1));
}
#[test]
fn background_assessment_payload_is_owned_send_data_with_exact_semantics() {
assert_send::<AssessedPaste>();
let text = "one\r\ntwo\u{001b}é🙂".repeat(BACKGROUND_ASSESSMENT_BYTES / 8);
let expected = classify(&text);
let cancellation_generation = AtomicU64::new(7);
let assessed = assess_owned(text.clone(), &cancellation_generation, 7).unwrap();
assert_eq!(assessed.text, text);
assert_eq!(assessed.assessment, expected);
}
#[test]
fn background_assessment_stops_when_its_generation_is_cancelled() {
let text = "terminal text ".repeat(CANCELLATION_CHECK_CHARACTERS * 3);
let cancellation_generation = AtomicU64::new(2);
assert_eq!(assess_owned(text, &cancellation_generation, 1), None);
}
#[test]
fn cancellable_classification_checks_during_long_scans() {
let text = "x".repeat(CANCELLATION_CHECK_CHARACTERS * 3);
let checks = Cell::new(0);
let result = classify_cancellable(&text, || {
let next = checks.get() + 1;
checks.set(next);
next >= 3
});
assert_eq!(result, None);
assert_eq!(checks.get(), 3);
}
#[test]
fn focused_terminal_paste_requires_active_window_and_terminal_focus() {
let source = PasteRequestSource::FocusedTerminal;
assert!(paste_target_is_authorized(source, true, true));
assert!(!paste_target_is_authorized(source, true, false));
assert!(!paste_target_is_authorized(source, false, true));
assert!(!paste_target_is_authorized(source, false, false));
}
#[test]
fn context_menu_paste_allows_popover_focus_only_in_active_window() {
let source = PasteRequestSource::ContextMenu;
assert!(paste_target_is_authorized(source, true, true));
assert!(paste_target_is_authorized(source, true, false));
assert!(!paste_target_is_authorized(source, false, true));
assert!(!paste_target_is_authorized(source, false, false));
}
#[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"));
}
}