822 lines
28 KiB
Rust
822 lines
28 KiB
Rust
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<ClipboardCommand>,
|
|
writer: Option<ClipboardCommand>,
|
|
}
|
|
|
|
pub struct SystemClipboard {
|
|
commands: Option<Result<Commands, String>>,
|
|
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<String, String> {
|
|
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<PathBuf> {
|
|
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<Commands, String> {
|
|
#[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<Self, String> {
|
|
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<String, String> {
|
|
// 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<String, String> {
|
|
// 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<Vec<u8>, 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<Vec<u8>, 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<std::process::Child>);
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|