use std::path::PathBuf;
pub(crate) const PASTE_START: &[u8] = b"\x1b[200~";
pub(crate) const PASTE_END: &[u8] = b"\x1b[201~";
const PASTE_BUFFER_CAP: usize = 64 * 1024;
const MAX_CARRY: usize = 16;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InputEvent {
Bytes(Vec<u8>),
Paste(Vec<u8>),
PasteChord(Vec<u8>),
}
enum State {
Normal,
InPaste(Vec<u8>),
PassthroughPaste,
}
pub(crate) struct InputScanner {
state: State,
carry: Vec<u8>,
}
impl InputScanner {
pub(crate) fn new() -> InputScanner {
InputScanner {
state: State::Normal,
carry: Vec::new(),
}
}
pub(crate) fn has_idle_carry(&self) -> bool {
matches!(self.state, State::Normal) && !self.carry.is_empty()
}
pub(crate) fn take_carry(&mut self) -> Vec<u8> {
std::mem::take(&mut self.carry)
}
pub(crate) fn flush_all(&mut self) -> Vec<u8> {
let mut out = Vec::new();
if let State::InPaste(body) = std::mem::replace(&mut self.state, State::Normal) {
out.extend_from_slice(PASTE_START);
out.extend_from_slice(&body);
}
out.append(&mut self.carry);
out
}
pub(crate) fn scan(&mut self, input: &[u8]) -> Vec<InputEvent> {
let mut buf = std::mem::take(&mut self.carry);
buf.extend_from_slice(input);
let mut events = Vec::new();
let mut plain = Vec::new(); let mut i = 0;
let flush_plain = |plain: &mut Vec<u8>, events: &mut Vec<InputEvent>| {
if !plain.is_empty() {
events.push(InputEvent::Bytes(std::mem::take(plain)));
}
};
while i < buf.len() {
match &mut self.state {
State::Normal => match classify_at(&buf[i..]) {
Classified::Plain(n) => {
plain.extend_from_slice(&buf[i..i + n]);
i += n;
}
Classified::Chord(n) => {
flush_plain(&mut plain, &mut events);
events.push(InputEvent::PasteChord(buf[i..i + n].to_vec()));
i += n;
}
Classified::PasteStart => {
flush_plain(&mut plain, &mut events);
self.state = State::InPaste(Vec::new());
i += PASTE_START.len();
}
Classified::NeedMore => {
if buf.len() - i > MAX_CARRY {
plain.push(buf[i]);
i += 1;
} else {
self.carry = buf[i..].to_vec();
i = buf.len();
}
}
},
State::InPaste(body) => {
if let Some(end) = find(&buf[i..], PASTE_END) {
body.extend_from_slice(&buf[i..i + end]);
let body = std::mem::take(body);
events.push(InputEvent::Paste(body));
self.state = State::Normal;
i += end + PASTE_END.len();
continue;
}
let keep = partial_suffix_len(&buf[i..], PASTE_END);
body.extend_from_slice(&buf[i..buf.len() - keep]);
if keep > 0 {
self.carry = buf[buf.len() - keep..].to_vec();
}
if body.len() > PASTE_BUFFER_CAP {
let mut raw = PASTE_START.to_vec();
raw.append(body);
flush_plain(&mut plain, &mut events);
events.push(InputEvent::Bytes(raw));
self.state = State::PassthroughPaste;
}
i = buf.len();
}
State::PassthroughPaste => {
if let Some(end) = find(&buf[i..], PASTE_END) {
let through = i + end + PASTE_END.len();
plain.extend_from_slice(&buf[i..through]);
self.state = State::Normal;
i = through;
continue;
}
let keep = partial_suffix_len(&buf[i..], PASTE_END);
plain.extend_from_slice(&buf[i..buf.len() - keep]);
if keep > 0 {
self.carry = buf[buf.len() - keep..].to_vec();
}
i = buf.len();
}
}
}
flush_plain(&mut plain, &mut events);
events
}
}
enum Classified {
Plain(usize),
Chord(usize),
PasteStart,
NeedMore,
}
fn classify_at(buf: &[u8]) -> Classified {
match buf[0] {
0x16 => Classified::Chord(1),
0x1b => classify_escape(buf),
_ => {
let n = buf
.iter()
.position(|&b| b == 0x1b || b == 0x16)
.unwrap_or(buf.len());
Classified::Plain(n)
}
}
}
fn classify_escape(buf: &[u8]) -> Classified {
if buf.len() < 2 {
return Classified::NeedMore;
}
if buf[1] != b'[' {
return Classified::Plain(1);
}
let mut i = 2;
while i < buf.len() && (0x30..=0x3f).contains(&buf[i]) {
i += 1;
}
while i < buf.len() && (0x20..=0x2f).contains(&buf[i]) {
i += 1;
}
let Some(&fin) = buf.get(i) else {
return Classified::NeedMore;
};
if !(0x40..=0x7e).contains(&fin) {
return Classified::Plain(1);
}
let params = &buf[2..i];
let len = i + 1;
if fin == b'~' && params == b"200" {
return Classified::PasteStart;
}
if is_ctrl_v_csi(params, fin) {
return Classified::Chord(len);
}
Classified::Plain(len)
}
fn is_ctrl_v_csi(params: &[u8], fin: u8) -> bool {
match fin {
b'u' => matches!(params, b"118;5" | b"118;5:1" | b"118;5:2"),
b'~' => params == b"27;5;118",
_ => false,
}
}
pub(crate) fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
pub(crate) fn partial_suffix_len(buf: &[u8], pattern: &[u8]) -> usize {
let max = pattern.len().min(buf.len() + 1) - 1;
for take in (1..=max).rev() {
if buf[buf.len() - take..] == pattern[..take] {
return take;
}
}
0
}
pub(crate) fn dropped_local_files(paste: &[u8]) -> Option<Vec<PathBuf>> {
let text = std::str::from_utf8(paste).ok()?;
let tokens = shell_words::split(text.trim()).ok()?;
if tokens.is_empty() {
return None;
}
let dragged = text.ends_with(' ') || tokens.iter().all(|token| token.starts_with("file://"));
if !dragged {
return None;
}
let mut paths = Vec::with_capacity(tokens.len());
for token in tokens {
let path = if token.starts_with("file://") {
url::Url::parse(&token).ok()?.to_file_path().ok()?
} else {
PathBuf::from(token)
};
if !path.is_absolute() {
return None;
}
if !std::fs::metadata(&path).is_ok_and(|meta| meta.is_file()) {
return None;
}
paths.push(path);
}
Some(paths)
}
pub(crate) fn sanitize_drop_name(path: &std::path::Path) -> String {
let name: String = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.take(128)
.collect();
if name.trim_matches(|c| c == '.' || c == '-').is_empty() {
"file".to_string()
} else {
name
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scan_all(scanner: &mut InputScanner, chunks: &[&[u8]]) -> Vec<InputEvent> {
let mut events = Vec::new();
for chunk in chunks {
events.extend(scanner.scan(chunk));
}
events
}
#[test]
fn plain_bytes_pass_through() {
let mut s = InputScanner::new();
assert_eq!(s.scan(b"hello"), vec![InputEvent::Bytes(b"hello".to_vec())]);
assert!(!s.has_idle_carry());
}
#[test]
fn ctrl_v_chord_in_every_encoding() {
let chords: [&[u8]; 5] = [
b"\x16",
b"\x1b[118;5u",
b"\x1b[118;5:1u",
b"\x1b[118;5:2u",
b"\x1b[27;5;118~",
];
for chord in chords {
let mut s = InputScanner::new();
assert_eq!(
s.scan(chord),
vec![InputEvent::PasteChord(chord.to_vec())],
"chord {chord:?}"
);
}
}
#[test]
fn ctrl_v_release_passes_through() {
let mut s = InputScanner::new();
assert_eq!(
s.scan(b"\x1b[118;5:3u"),
vec![InputEvent::Bytes(b"\x1b[118;5:3u".to_vec())]
);
}
#[test]
fn paste_collected_across_split_reads() {
let mut s = InputScanner::new();
let events = scan_all(&mut s, &[b"ab\x1b[20", b"0~/tmp/x ", b"\x1b[2", b"01~cd"]);
assert_eq!(
events,
vec![
InputEvent::Bytes(b"ab".to_vec()),
InputEvent::Paste(b"/tmp/x ".to_vec()),
InputEvent::Bytes(b"cd".to_vec()),
]
);
}
#[test]
fn chord_split_across_reads() {
let mut s = InputScanner::new();
let events = scan_all(&mut s, &[b"\x1b", b"[118;5u"]);
assert_eq!(
events,
vec![InputEvent::PasteChord(b"\x1b[118;5u".to_vec())]
);
}
#[test]
fn lone_escape_is_carried_then_flushed() {
let mut s = InputScanner::new();
assert_eq!(s.scan(b"\x1b"), vec![]);
assert!(s.has_idle_carry());
assert_eq!(s.take_carry(), b"\x1b".to_vec());
}
#[test]
fn partial_end_marker_is_held_through_idle_not_flushed() {
let mut s = InputScanner::new();
assert_eq!(s.scan(b"\x1b[200~body\x1b"), vec![]);
assert!(!s.has_idle_carry());
assert_eq!(s.scan(b"[201~"), vec![InputEvent::Paste(b"body".to_vec())]);
}
#[test]
fn eof_flushes_an_unterminated_paste_verbatim() {
let mut s = InputScanner::new();
assert_eq!(s.scan(b"\x1b[200~body\x1b"), vec![]);
assert_eq!(s.flush_all(), b"\x1b[200~body\x1b".to_vec());
assert!(!s.has_idle_carry());
}
#[test]
fn arrow_key_passes_through_unbroken() {
let mut s = InputScanner::new();
assert_eq!(
s.scan(b"\x1b[A"),
vec![InputEvent::Bytes(b"\x1b[A".to_vec())]
);
}
#[test]
fn chord_inside_paste_is_literal() {
let mut s = InputScanner::new();
let events = s.scan(b"\x1b[200~a\x16b\x1b[201~");
assert_eq!(events, vec![InputEvent::Paste(b"a\x16b".to_vec())]);
}
#[test]
fn oversized_paste_streams_through() {
let mut s = InputScanner::new();
let big = vec![b'x'; PASTE_BUFFER_CAP + 10];
let mut first = PASTE_START.to_vec();
first.extend_from_slice(&big);
let mut events = s.scan(&first);
events.extend(s.scan(b"tail\x1b[201~after"));
let forwarded: Vec<u8> = events
.iter()
.flat_map(|e| match e {
InputEvent::Bytes(b) => b.clone(),
_ => panic!("unexpected event {e:?}"),
})
.collect();
let mut expected = first.clone();
expected.extend_from_slice(b"tail\x1b[201~after");
assert_eq!(forwarded, expected);
}
#[test]
fn dropped_paths_parse_escaped_and_quoted_forms() {
let dir = tempfile::tempdir().unwrap();
let spaced = dir.path().join("my file.png");
std::fs::write(&spaced, b"x").unwrap();
let plain = dir.path().join("b.txt");
std::fs::write(&plain, b"y").unwrap();
let escaped = format!(
"{}/my\\ file.png '{}' ",
dir.path().display(),
plain.display()
);
let paths = dropped_local_files(escaped.as_bytes()).unwrap();
assert_eq!(paths, vec![spaced.clone(), plain.clone()]);
let uri = format!("file://{}/my%20file.png", dir.path().display());
assert_eq!(dropped_local_files(uri.as_bytes()).unwrap(), vec![spaced]);
}
#[test]
fn non_paths_and_directories_do_not_qualify() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(dropped_local_files(b"hello world"), None);
assert_eq!(dropped_local_files(b""), None);
let secret = dir.path().join("id_ed25519");
std::fs::write(&secret, b"key").unwrap();
assert_eq!(
dropped_local_files(secret.to_string_lossy().as_bytes()),
None
);
let copied_line = format!("{}\n", secret.display());
assert_eq!(dropped_local_files(copied_line.as_bytes()), None);
let dragged = format!("{} ", secret.display());
assert_eq!(
dropped_local_files(dragged.as_bytes()).unwrap(),
vec![secret.clone()]
);
let second = dir.path().join("also-real");
std::fs::write(&second, b"x").unwrap();
let two_no_space = format!("{} {}", secret.display(), second.display());
assert_eq!(dropped_local_files(two_no_space.as_bytes()), None);
let mixed = format!("file://{} {}", second.display(), secret.display());
assert_eq!(dropped_local_files(mixed.as_bytes()), None);
assert_eq!(
dropped_local_files(dir.path().to_string_lossy().as_bytes()),
None
);
let real = dir.path().join("a");
std::fs::write(&real, b"x").unwrap();
let mixed = format!("{} /definitely/not/here", real.display());
assert_eq!(dropped_local_files(mixed.as_bytes()), None);
}
#[test]
fn sanitize_drop_name_reduces_to_safe_chars() {
assert_eq!(
sanitize_drop_name(std::path::Path::new("/a/Screen Shot (1).png")),
"Screen-Shot--1-.png"
);
assert_eq!(
sanitize_drop_name(std::path::Path::new("/a/ΓΌ~!.png")),
"---.png"
);
assert_eq!(sanitize_drop_name(std::path::Path::new("/")), "file");
}
}