use gtk::gdk;
use gtk::prelude::*;
use gtk::{gio, glib};
use std::cell::Cell;
use std::rc::Rc;
use vte::prelude::*;
use crate::terminal::shell_quote;
const MAX_DROPPED_FILES: usize = 256;
const MAX_DROPPED_TEXT_BYTES: usize = 1024 * 1024;
pub(crate) fn install_file_drop(terminal: &vte::Terminal) {
let formats = gdk::ContentFormats::new(&["text/uri-list"]);
let target = gtk::DropTargetAsync::new(Some(formats), gdk::DragAction::COPY);
let terminal_ref = terminal.downgrade();
let drop_generation = Rc::new(Cell::new(0_u64));
target.connect_drag_enter({
let terminal = terminal_ref.clone();
move |_, _, _, _| {
if let Some(terminal) = terminal.upgrade() {
terminal.add_css_class("lios-drop-ready");
}
gdk::DragAction::COPY
}
});
target.connect_drag_leave({
let terminal = terminal_ref.clone();
move |_, _| {
if let Some(terminal) = terminal.upgrade() {
terminal.remove_css_class("lios-drop-ready");
}
}
});
target.connect_drop(move |_, drop, _, _| {
if terminal_ref.upgrade().is_none() {
return false;
}
let generation = drop_generation.get().wrapping_add(1);
drop_generation.set(generation);
let drop = drop.clone();
let terminal = terminal_ref.clone();
let drop_generation = drop_generation.clone();
glib::spawn_future_local(async move {
let accepted = read_uri_list(&drop).await.and_then(|uri_list| {
let text = dropped_uri_list_text(&uri_list)?;
let terminal = terminal.upgrade()?;
if !drop_request_is_authorized(
generation,
drop_generation.get(),
terminal.is_mapped(),
) {
return None;
}
terminal.paste_text(&text);
Some(())
});
drop.finish(if accepted.is_some() {
gdk::DragAction::COPY
} else {
gdk::DragAction::empty()
});
});
true
});
terminal.add_controller(target);
}
fn drop_request_is_authorized(
request_generation: u64,
current_generation: u64,
terminal_mapped: bool,
) -> bool {
request_generation == current_generation && terminal_mapped
}
async fn read_uri_list(drop: &gdk::Drop) -> Option<String> {
let (stream, _) = drop
.read_future(&["text/uri-list"], glib::Priority::DEFAULT)
.await
.ok()?;
let mut bytes = Vec::new();
loop {
let remaining = MAX_DROPPED_TEXT_BYTES
.checked_add(1)?
.checked_sub(bytes.len())?;
if remaining == 0 {
return None;
}
let chunk = stream
.read_bytes_future(remaining.min(64 * 1024), glib::Priority::DEFAULT)
.await
.ok()?;
if chunk.is_empty() {
break;
}
bytes.extend_from_slice(&chunk);
}
String::from_utf8(bytes).ok()
}
fn dropped_uri_list_text(uri_list: &str) -> Option<String> {
let mut files = Vec::new();
for line in uri_list.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
if files.len() == MAX_DROPPED_FILES
|| line.contains('\0')
|| glib::Uri::is_valid(line, glib::UriFlags::NONE).is_err()
{
return None;
}
files.push(gio::File::for_uri(line));
}
dropped_file_text(files.iter())
}
fn dropped_file_text<'a>(files: impl IntoIterator<Item = &'a gio::File>) -> Option<String> {
let mut text = String::new();
let mut count = 0;
for file in files {
count += 1;
if count > MAX_DROPPED_FILES {
return None;
}
let value = match file.path() {
Some(path) => path.into_os_string().into_string().ok()?,
None => file.uri().to_string(),
};
if value.chars().any(char::is_control) {
return None;
}
let quoted = shell_quote(&value);
let separator_bytes = usize::from(!text.is_empty());
if text
.len()
.saturating_add(separator_bytes)
.saturating_add(quoted.len())
> MAX_DROPPED_TEXT_BYTES
{
return None;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str("ed);
}
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dropped_files_are_shell_quoted_and_space_separated() {
let files = [
gio::File::for_path("/tmp/ordinary.txt"),
gio::File::for_path("/tmp/a file's name; $(touch nope)"),
];
assert_eq!(
dropped_file_text(files.iter()).as_deref(),
Some("'/tmp/ordinary.txt' '/tmp/a file'\\''s name; $(touch nope)'")
);
}
#[test]
fn remote_files_fall_back_to_quoted_uris() {
let files = [gio::File::for_uri("sftp://example.test/home/a%20file.txt")];
assert_eq!(
dropped_file_text(files.iter()).as_deref(),
Some("'sftp://example.test/home/a%20file.txt'")
);
}
#[test]
fn uri_lists_ignore_comments_and_decode_local_paths() {
assert_eq!(
dropped_uri_list_text(
"# generated by file manager\r\nfile:///tmp/one%20file\r\n\r\nfile:///tmp/two\n"
)
.as_deref(),
Some("'/tmp/one file' '/tmp/two'")
);
}
#[test]
fn malformed_uri_lists_are_rejected_as_a_unit() {
assert_eq!(dropped_uri_list_text("file:///tmp/good\nnot a URI\n"), None);
assert_eq!(dropped_uri_list_text("file:///tmp/good\0bad\n"), None);
}
#[test]
fn terminal_control_characters_in_local_paths_are_rejected() {
for path in [
"/tmp/line\nbreak",
"/tmp/readline\u{15}erase",
"/tmp/bracket\u{1b}[201~escape",
"/tmp/tab\tname",
] {
let files = [gio::File::for_path(path)];
assert_eq!(dropped_file_text(files.iter()), None, "path: {path:?}");
}
assert_eq!(
dropped_uri_list_text("file:///tmp/bracket%1B%5B201~%15echo%20nope%0A"),
None
);
}
#[test]
fn non_utf8_local_paths_are_rejected_instead_of_changed_to_uris() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let path = OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec());
let files = [gio::File::for_path(path)];
assert_eq!(dropped_file_text(files.iter()), None);
}
#[test]
fn asynchronous_drop_requires_the_current_mapped_target() {
assert!(drop_request_is_authorized(4, 4, true));
assert!(!drop_request_is_authorized(3, 4, true));
assert!(!drop_request_is_authorized(4, 4, false));
}
#[test]
fn empty_and_excessive_drops_are_rejected() {
let empty: [gio::File; 0] = [];
assert_eq!(dropped_file_text(empty.iter()), None);
let files = (0..=MAX_DROPPED_FILES)
.map(|index| gio::File::for_path(format!("/tmp/{index}")))
.collect::<Vec<_>>();
assert_eq!(dropped_file_text(files.iter()), None);
}
#[test]
fn oversized_drop_text_is_rejected_before_pasting() {
let oversized = format!("/tmp/{}", "x".repeat(MAX_DROPPED_TEXT_BYTES));
let files = [gio::File::for_path(oversized)];
assert_eq!(dropped_file_text(files.iter()), None);
}
}