use std::path::PathBuf; use std::time::Duration; const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024; const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardMode { System, Separate, } struct ClipboardCommand { program: PathBuf, args: &'static [&'static str], } struct Commands { reader: Option, writer: Option, } pub struct SystemClipboard { commands: Option>, timeout: Duration, } impl Default for SystemClipboard { fn default() -> Self { Self::new() } } impl SystemClipboard { pub fn new() -> Self { Self { commands: None, timeout: COMMAND_TIMEOUT, } } #[cfg(test)] pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self { Self { commands: Some(Ok(Commands { reader: Some(ClipboardCommand { program: reader, args: &[], }), writer: Some(ClipboardCommand { program: writer, args: &[], }), })), timeout: COMMAND_TIMEOUT, } } pub fn read(&mut self) -> Result { let timeout = self.timeout; let commands = self.commands.get_or_insert_with(select_commands); match &commands.as_ref().map_err(Clone::clone)?.reader { Some(command) => { let bytes = run(command, None, timeout)?; String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into()) } #[cfg(target_os = "macos")] None => macos::read(), #[cfg(not(target_os = "macos"))] None => Err("System clipboard is unsupported on this platform".into()), } } pub fn write(&mut self, text: &str) -> Result<(), String> { if text.len() > MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } let timeout = self.timeout; let commands = self.commands.get_or_insert_with(select_commands); match &commands.as_ref().map_err(Clone::clone)?.writer { Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()), #[cfg(target_os = "macos")] None => macos::write(text), #[cfg(not(target_os = "macos"))] None => Err("System clipboard is unsupported on this platform".into()), } } } #[cfg(target_os = "linux")] fn executable(name: &str) -> Option { use std::os::unix::fs::PermissionsExt; let paths = std::env::var_os("PATH")?; std::env::split_paths(&paths) .map(|directory| directory.join(name)) .find(|path| { path.metadata().is_ok_and(|metadata| { metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 }) }) } fn select_commands() -> Result { #[cfg(target_os = "macos")] { Ok(Commands { reader: None, writer: None, }) } #[cfg(target_os = "linux")] { if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) { if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) { return Ok(Commands { reader: Some(ClipboardCommand { program: reader, args: &["--no-newline", "--type", "text"], }), writer: Some(ClipboardCommand { program: writer, args: &["--type", "text/plain;charset=utf-8"], }), }); } } if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) { if let Some(program) = executable("xclip") { return Ok(Commands { reader: Some(ClipboardCommand { program: program.clone(), args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"], }), writer: Some(ClipboardCommand { program, args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"], }), }); } } Err("System clipboard requires Wayland with wl-paste/wl-copy or X11 with xclip".into()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { Err("System clipboard is unsupported on this platform".into()) } } #[cfg(target_os = "macos")] mod macos { use std::ffi::{c_char, c_void}; use std::ptr; type Object = *const c_void; #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { fn CFStringCreateWithCString( allocator: Object, bytes: *const c_char, encoding: u32, ) -> Object; fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object; fn CFRelease(object: Object); } #[link(name = "CoreServices", kind = "framework")] unsafe extern "C" { static kUTTypeUTF8PlainText: Object; } #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32; fn PasteboardClear(pasteboard: Object) -> i32; fn PasteboardPutItemFlavor( pasteboard: Object, item: usize, flavor: Object, data: Object, flags: u32, ) -> i32; } struct Owned(Object); impl Owned { fn new(object: Object) -> Result { if object.is_null() { Err("Cannot allocate system clipboard data".into()) } else { Ok(Self(object)) } } } impl Drop for Owned { fn drop(&mut self) { // SAFETY: each handle owns one Core Foundation reference. unsafe { CFRelease(self.0) }; } } fn check(status: i32) -> Result<(), String> { if status == 0 { Ok(()) } else { Err(format!("System clipboard publication failed ({status})")) } } fn write_to(name: Object, text: &str) -> Result<(), String> { // SAFETY: framework constants and byte slices live throughout the calls. unsafe { let data = Owned::new(CFDataCreate( ptr::null(), text.as_ptr(), text.len() as isize, ))?; let mut board = ptr::null(); check(PasteboardCreate(name, &mut board))?; let board = Owned::new(board)?; // Prepare all allocations before changing clipboard contents. check(PasteboardClear(board.0))?; check(PasteboardPutItemFlavor( board.0, 1, kUTTypeUTF8PlainText, data.0, 0, )) } } pub(super) fn write(text: &str) -> Result<(), String> { // SAFETY: the clipboard name is a process-lifetime, null-terminated literal. let name = unsafe { Owned::new(CFStringCreateWithCString( ptr::null(), c"com.apple.pasteboard.clipboard".as_ptr(), 0x0800_0100, ))? }; write_to(name.0, text) } #[link(name = "AppKit", kind = "framework")] unsafe extern "C" { static NSPasteboardTypeString: Object; } #[link(name = "objc")] unsafe extern "C" { fn objc_getClass(name: *const c_char) -> Object; fn sel_registerName(name: *const c_char) -> Object; fn objc_msgSend(); fn objc_autoreleasePoolPush() -> Object; fn objc_autoreleasePoolPop(pool: Object); } struct AutoreleasePool(Object); impl Drop for AutoreleasePool { fn drop(&mut self) { // SAFETY: this pool was pushed on the same thread. unsafe { objc_autoreleasePoolPop(self.0) }; } } fn read_from(board: Object) -> Result { // SAFETY: receivers are live AppKit objects; each cast matches the // selected Objective-C method's ABI. The caller owns an autorelease pool. unsafe { let string: unsafe extern "C" fn(Object, Object, Object) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let text = string( board, sel_registerName(c"stringForType:".as_ptr()), NSPasteboardTypeString, ); if text.is_null() { return Ok(String::new()); } let encode: unsafe extern "C" fn(Object, Object, usize) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let data = encode(text, sel_registerName(c"dataUsingEncoding:".as_ptr()), 4); if data.is_null() { return Err("System clipboard is not valid UTF-8".into()); } let length: unsafe extern "C" fn(Object, Object) -> usize = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let length = length(data, sel_registerName(c"length".as_ptr())); if length > super::MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } if length == 0 { return Ok(String::new()); } let bytes: unsafe extern "C" fn(Object, Object) -> *const u8 = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let bytes = std::slice::from_raw_parts( bytes(data, sel_registerName(c"bytes".as_ptr())), length, ); String::from_utf8(bytes.to_vec()) .map_err(|_| "System clipboard is not valid UTF-8".into()) } } pub(super) fn read() -> Result { // SAFETY: AppKit class and selector names are static C strings. unsafe { let _pool = AutoreleasePool(objc_autoreleasePoolPush()); let send: unsafe extern "C" fn(Object, Object) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let board = send( objc_getClass(c"NSPasteboard".as_ptr()), sel_registerName(c"generalPasteboard".as_ptr()), ); if board.is_null() { return Err("Cannot open system clipboard".into()); } read_from(board) } } #[cfg(test)] mod tests { use super::*; unsafe extern "C" { fn PasteboardSynchronize(pasteboard: Object) -> u32; fn PasteboardGetItemIdentifier( pasteboard: Object, index: isize, item: *mut usize, ) -> i32; fn PasteboardCopyItemFlavorData( pasteboard: Object, item: usize, flavor: Object, data: *mut Object, ) -> i32; fn CFDataGetBytePtr(data: Object) -> *const u8; fn CFDataGetLength(data: Object) -> isize; } #[test] fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() { // A private pasteboard leaves the user's clipboard untouched. let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) }; let name = unsafe { Owned::new(CFStringCreateWithCString( ptr::null(), c"vamp-native-clipboard-regression".as_ptr(), 0x0800_0100, )) .unwrap() }; let board = unsafe { let mut board = ptr::null(); check(PasteboardCreate(name.0, &mut board)).unwrap(); Owned::new(board).unwrap() }; let reader = unsafe { let send: unsafe extern "C" fn(Object, Object, Object) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); send( objc_getClass(c"NSPasteboard".as_ptr()), sel_registerName(c"pasteboardWithName:".as_ptr()), name.0, ) }; for text in [ "", "é e\u{301} 👨‍💻\n\0tail", "{\\rtf1 plain text}", "%!PS-Adobe-3.0\nplain", ] { write_to(name.0, text).unwrap(); assert_eq!(read_from(reader).unwrap(), text); unsafe { PasteboardSynchronize(board.0); let mut item = 0; check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap(); let flavor = Owned::new(CFStringCreateWithCString( ptr::null(), c"public.utf8-plain-text".as_ptr(), 0x0800_0100, )) .unwrap(); let mut data = ptr::null(); check(PasteboardCopyItemFlavorData( board.0, item, flavor.0, &mut data, )) .unwrap(); let data = Owned::new(data).unwrap(); let length = CFDataGetLength(data.0) as usize; let bytes = if length == 0 { &[] } else { std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length) }; assert_eq!(bytes, text.as_bytes()); } } let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES); write_to(name.0, &maximum).unwrap(); assert_eq!(read_from(reader).unwrap(), maximum); write_to(name.0, &(maximum + "x")).unwrap(); assert_eq!( read_from(reader).unwrap_err(), "System clipboard text exceeds 4 MiB" ); } } } #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result, String> { Err("System clipboard is unsupported on this platform".into()) } #[cfg(any(target_os = "macos", target_os = "linux"))] fn run( command: &ClipboardCommand, input: Option<&[u8]>, timeout: Duration, ) -> Result, String> { use std::io::{self, Read, Write}; use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; use std::time::Instant; let mut command_process = Command::new(&command.program); command_process .args(command.args) .process_group(0) .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(if input.is_none() { Stdio::piped() } else { Stdio::null() }) // Native tools may echo input in diagnostics. Never capture or surface stderr. .stderr(Stdio::null()); let child = command_process .spawn() .map_err(|error| format!("Cannot start system clipboard command: {error}"))?; let mut process = ClipboardProcess(Some(child)); let child = process.0.as_mut().expect("clipboard child exists"); let mut stdin = child.stdin.take(); let mut stdout = child.stdout.take(); if let Some(pipe) = &stdin { nonblocking(pipe).map_err(|error| format!("Cannot open clipboard input: {error}"))?; } if let Some(pipe) = &stdout { nonblocking(pipe).map_err(|error| format!("Cannot open clipboard output: {error}"))?; } let started = Instant::now(); let mut written = 0; let mut output = Vec::new(); let mut status = None; let mut chunk = [0_u8; 8192]; loop { if started.elapsed() >= timeout { return Err("System clipboard command timed out".into()); } let mut progressed = false; if let Some(pipe) = stdin.as_mut() { let bytes = input.expect("input pipe has bytes"); if written == bytes.len() { stdin.take(); progressed = true; } else { let end = bytes.len().min(written + chunk.len()); match pipe.write(&bytes[written..end]) { Ok(0) => return Err("System clipboard command closed its input".into()), Ok(count) => { written += count; progressed = true; } Err(error) if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) => {} Err(error) => return Err(format!("System clipboard write failed: {error}")), } } } if let Some(pipe) = stdout.as_mut() { let limit = chunk.len().min(MAX_CLIPBOARD_BYTES + 1 - output.len()); match pipe.read(&mut chunk[..limit]) { Ok(0) => { stdout.take(); progressed = true; } Ok(count) => { if output.len() + count > MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } output.extend_from_slice(&chunk[..count]); progressed = true; } Err(error) if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) => {} Err(error) => return Err(format!("System clipboard read failed: {error}")), } } if status.is_none() { status = process .0 .as_mut() .expect("clipboard child exists") .try_wait() .map_err(|error| format!("Cannot wait for system clipboard command: {error}"))?; } if let Some(exit) = status { if !exit.success() { return Err(format!("System clipboard command failed ({exit})")); } if stdin.is_none() && stdout.is_none() { // Clipboard owners (wl-copy/xclip) intentionally leave a serving child alive. process.0.take(); return Ok(output); } } if !progressed { let remaining = timeout.saturating_sub(started.elapsed()); wait_for_pipes(stdin.as_ref(), stdout.as_ref(), remaining)?; } } } #[cfg(any(target_os = "macos", target_os = "linux"))] struct ClipboardProcess(Option); #[cfg(any(target_os = "macos", target_os = "linux"))] impl Drop for ClipboardProcess { fn drop(&mut self) { if let Some(mut child) = self.0.take() { // Also close inherited pipes held by descendants, as in the Ruby host runner. // SAFETY: spawn placed this child in a process group with its own PID. unsafe { kill(-(child.id() as i32), 9); } let _ = child.kill(); let _ = child.wait(); } } } #[cfg(any(target_os = "macos", target_os = "linux"))] fn nonblocking(pipe: &impl std::os::fd::AsRawFd) -> std::io::Result<()> { #[cfg(target_os = "macos")] const O_NONBLOCK: i32 = 0x0004; #[cfg(target_os = "linux")] const O_NONBLOCK: i32 = 0x0800; // SAFETY: the descriptor is borrowed from a live child pipe; F_GETFL/F_SETFL // inspect/change flags only and neither consume the descriptor nor access pointers. unsafe { let flags = fcntl(pipe.as_raw_fd(), 3); if flags == -1 || fcntl(pipe.as_raw_fd(), 4, flags | O_NONBLOCK) == -1 { Err(std::io::Error::last_os_error()) } else { Ok(()) } } } #[cfg(any(target_os = "macos", target_os = "linux"))] #[repr(C)] struct PollFd { fd: i32, events: i16, revents: i16, } #[cfg(target_os = "macos")] type PollCount = u32; #[cfg(target_os = "linux")] type PollCount = usize; #[cfg(any(target_os = "macos", target_os = "linux"))] fn wait_for_pipes( stdin: Option<&std::process::ChildStdin>, stdout: Option<&std::process::ChildStdout>, remaining: Duration, ) -> Result<(), String> { use std::os::fd::AsRawFd; let mut pipes = [ PollFd { fd: stdin.map_or(-1, AsRawFd::as_raw_fd), events: 4, revents: 0, }, PollFd { fd: stdout.map_or(-1, AsRawFd::as_raw_fd), events: 1, revents: 0, }, ]; // Recheck process completion at least every 5 ms, waking immediately on pipe readiness. let milliseconds = remaining.as_millis().min(5) as i32; // SAFETY: the C-compatible array is live for the call; negative descriptors are ignored. if unsafe { poll(pipes.as_mut_ptr(), pipes.len() as PollCount, milliseconds) } == -1 { let error = std::io::Error::last_os_error(); if error.kind() != std::io::ErrorKind::Interrupted { return Err(format!("Cannot poll system clipboard command: {error}")); } } Ok(()) } #[cfg(any(target_os = "macos", target_os = "linux"))] unsafe extern "C" { fn fcntl(fd: i32, command: i32, ...) -> i32; fn kill(pid: i32, signal: i32) -> i32; fn poll(fds: *mut PollFd, count: PollCount, timeout: i32) -> i32; } #[cfg(all(test, any(target_os = "macos", target_os = "linux")))] mod tests { use super::*; use std::fs; use std::os::unix::fs::PermissionsExt; use std::sync::atomic::{AtomicU64, Ordering}; static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = std::env::temp_dir().join(format!( "vamp-clipboard-{}-{}", std::process::id(), FIXTURE_ID.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&path).unwrap(); Self(path) } fn command(&self, name: &str, body: &str) -> PathBuf { let path = self.0.join(name); fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); path } fn clipboard(&self, text: &[u8]) -> SystemClipboard { let state = self.0.join("state"); fs::write(&state, text).unwrap(); let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); SystemClipboard::with_commands( self.command("read", &format!("exec /bin/cat {state}")), self.command("write", &format!("exec /bin/cat > {state}")), ) } } impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.0).unwrap(); } } #[test] fn construction_does_not_probe_or_spawn() { assert!(SystemClipboard::new().commands.is_none()); } #[test] fn round_trips_unicode_whitespace_and_large_pipes_exactly() { let fixture = Fixture::new(); let mut clipboard = fixture.clipboard(b""); for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] { clipboard.write(text).unwrap(); assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes()); assert_eq!(clipboard.read().unwrap(), text); } } #[test] fn rejects_oversize_and_invalid_utf8_without_leaking_contents() { let fixture = Fixture::new(); let mut clipboard = fixture.clipboard(&[0xff, b'\n']); assert_eq!( clipboard.read().unwrap_err(), "System clipboard is not valid UTF-8" ); let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2); clipboard.write(&maximum).unwrap(); assert_eq!(clipboard.read().unwrap(), maximum); let too_large = format!("{maximum}x"); assert_eq!( clipboard.write(&too_large).unwrap_err(), "System clipboard text exceeds 4 MiB" ); assert_eq!( fs::read(fixture.0.join("state")).unwrap(), maximum.as_bytes() ); fs::write(fixture.0.join("state"), &too_large).unwrap(); assert_eq!( clipboard.read().unwrap_err(), "System clipboard text exceeds 4 MiB" ); let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1]; incomplete.push(0xc3); fs::write(fixture.0.join("state"), incomplete).unwrap(); assert_eq!( clipboard.read().unwrap_err(), "System clipboard is not valid UTF-8" ); } #[test] fn command_failures_and_stderr_cannot_leak_clipboard_text() { let fixture = Fixture::new(); let failed = fixture.command( "failed", "printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23", ); let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed); let error = clipboard.read().unwrap_err(); assert!(error.contains("failed")); assert!(!error.contains("private clipboard payload")); let error = clipboard.write("private clipboard payload").unwrap_err(); assert!(!error.contains("private clipboard payload")); let missing = fixture.0.join("not-an-executable"); let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing); assert!(clipboard.read().unwrap_err().contains("Cannot start")); assert!( clipboard .write("secret") .unwrap_err() .contains("Cannot start") ); } #[test] fn ignored_stderr_does_not_block_large_writes() { let fixture = Fixture::new(); let mut clipboard = fixture.clipboard(b""); let state = fixture.0.join("state"); let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); clipboard .commands .as_mut() .unwrap() .as_mut() .unwrap() .writer .as_mut() .unwrap() .program = fixture.command( "noisy", &format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"), ); let text = "λ\n".repeat(300_000); clipboard.write(&text).unwrap(); assert_eq!(clipboard.read().unwrap(), text); } #[test] fn failed_process_cleanup_kills_and_reaps_a_running_child() { use std::io::Read; use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; unsafe extern "C" { fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; } let fixture = Fixture::new(); let hung = fixture.command("running", "printf R\nwhile :; do :; done"); let mut command = Command::new(hung); command .process_group(0) .stdout(Stdio::piped()) .stderr(Stdio::null()); let mut child = command.spawn().unwrap(); let pid = child.id() as i32; let mut ready = [0]; child .stdout .as_mut() .unwrap() .read_exact(&mut ready) .unwrap(); assert_eq!(ready, [b'R']); drop(ClipboardProcess(Some(child))); let mut status = 0; // SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check. assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1); assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10)); } #[test] fn immediate_injected_timeout_terminates_nonterminating_commands() { let fixture = Fixture::new(); let hung = fixture.command("hung", "while :; do :; done"); let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung); // No assertion depends on wall-clock speed or a deadline remaining in the future. clipboard.timeout = Duration::ZERO; assert_eq!( clipboard.read().unwrap_err(), "System clipboard command timed out" ); assert_eq!( clipboard.write("text").unwrap_err(), "System clipboard command timed out" ); } }