lios 0.1.73

A gorgeous GTK4/VTE Linux terminal with live themes, glass backgrounds, session prompt profiles, Sixel images, and safe GPU controls.
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(&quoted);
    }

    (!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);
    }
}