use std::io::Write;
use std::path::{Path, PathBuf};
use base64::Engine;
use thiserror::Error;
use crate::redirect_stdio::StderrSuppressGuard;
pub const DEFAULT_MAX_OSC52_BYTES: usize = 1024 * 1024;
const ENV_XDG_RUNTIME_DIR: &str = "XDG_RUNTIME_DIR";
const CLIPBOARD_CACHE_FILENAME: &str = "term-wm-clipboard.txt";
#[cfg(unix)]
const APP_TEMP_DIR_PREFIX: &str = "term-wm";
const OSC52_HEADER_LEN: usize = 5;
const OSC52_HEADER_LEN_WIN: usize = 7;
const CLIPBOARD_PARAM_LEN: usize = 2;
const MAX_OSC52_BUFFER_BYTES: usize = 4 * 1024 * 1024;
const OSC52_HDR_STD: &[u8] = b"\x1b]52;";
const OSC52_HDR_WIN: &[u8] = "←]52;".as_bytes();
#[derive(Debug, Error)]
pub enum ClipboardError {
#[error("clipboard backend error: {0}")]
Backend(#[from] arboard::Error),
#[error("I/O error writing OSC 52 sequence: {0}")]
Io(#[from] std::io::Error),
#[error("clipboard backend not available (running remotely?)")]
NotAvailable,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ClipboardConfig {
pub cache_path: PathBuf,
pub osc52_limit: usize,
}
impl Default for ClipboardConfig {
fn default() -> Self {
Self {
cache_path: default_temp_path(),
osc52_limit: DEFAULT_MAX_OSC52_BYTES,
}
}
}
pub struct Clipboard {
arboard: Option<arboard::Clipboard>,
temp_store_enabled: bool,
temp_path: PathBuf,
osc52_limit: usize,
#[cfg(test)]
pub osc52_output: Vec<u8>,
}
impl Default for Clipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard {
pub fn new() -> Self {
Self::with_config(ClipboardConfig::default())
}
pub fn with_temp_path(path: PathBuf) -> Self {
Self::with_config(ClipboardConfig {
cache_path: path,
..ClipboardConfig::default()
})
}
pub fn with_config(config: ClipboardConfig) -> Self {
let arboard = arboard::Clipboard::new().ok();
let temp_store_enabled = arboard.is_none();
tracing::debug!(
"clipboard: backend arboard={}, temp store={}, store path={}",
if arboard.is_some() {
"available"
} else {
"unavailable"
},
if temp_store_enabled {
"enabled"
} else {
"disabled"
},
config.cache_path.display()
);
Self {
arboard,
temp_store_enabled,
temp_path: config.cache_path,
osc52_limit: config.osc52_limit,
#[cfg(test)]
osc52_output: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn headless_with_temp_path(path: PathBuf) -> Self {
Self {
arboard: None,
temp_store_enabled: true,
temp_path: path,
osc52_limit: DEFAULT_MAX_OSC52_BYTES,
osc52_output: Vec::new(),
}
}
pub fn get(&mut self) -> Result<String, ClipboardError> {
match read_system_clipboard(&mut self.arboard) {
Ok(Some(text)) => {
return Ok(text);
}
Ok(None) if !self.temp_store_enabled => {
tracing::debug!("clipboard: get no backends available");
return Err(ClipboardError::NotAvailable);
}
Ok(None) => {}
Err(e) if !self.temp_store_enabled => {
tracing::debug!("clipboard: get via arboard failed ({e}); temp store inactive");
return Err(e);
}
Err(_) => {
tracing::debug!("clipboard: get via arboard failed; falling back to temp store");
}
}
read_temp_store(&self.temp_path, self.temp_store_enabled)
.ok_or(ClipboardError::NotAvailable)
}
pub fn set(&mut self, text: &str) {
write_temp_store(&self.temp_path, self.temp_store_enabled, text);
write_system_clipboard(&mut self.arboard, text);
let osc52_text = truncate_for_osc52(text, self.osc52_limit);
#[cfg(not(test))]
if let Err(e) = set_via_osc52_with_writer(osc52_text, &mut std::io::stdout().lock()) {
tracing::debug!("clipboard: set OSC 52 to stdout failed ({e})");
}
#[cfg(test)]
{
let mut buf = Vec::new();
let _ = set_via_osc52_with_writer(osc52_text, &mut buf);
self.osc52_output = buf;
}
}
}
fn write_temp_store(path: &Path, enabled: bool, text: &str) {
if enabled && let Err(e) = write_clipboard_temp(path, text) {
tracing::debug!(
"clipboard: set temp store write failed at {} ({e})",
path.display()
);
}
}
fn write_system_clipboard(clipboard: &mut Option<arboard::Clipboard>, text: &str) {
let Some(cb) = clipboard.as_mut() else {
tracing::debug!("clipboard: set arboard unavailable; temp store + OSC 52 only");
return;
};
let _guard = StderrSuppressGuard::new();
match cb.set_text(text.to_owned()) {
Ok(()) => tracing::debug!("clipboard: set wrote via arboard"),
Err(e) => tracing::debug!("clipboard: set via arboard failed ({e})"),
}
}
fn read_system_clipboard(
clipboard: &mut Option<arboard::Clipboard>,
) -> Result<Option<String>, ClipboardError> {
let Some(cb) = clipboard.as_mut() else {
return Ok(None);
};
match cb.get_text() {
Ok(text) => {
tracing::debug!("clipboard: get read via arboard ({} bytes)", text.len());
Ok(Some(text))
}
Err(e) => {
tracing::debug!("clipboard: get via arboard failed ({e})");
Err(e.into())
}
}
}
fn read_temp_store(path: &Path, enabled: bool) -> Option<String> {
if !enabled {
return None;
}
match read_clipboard_temp(path) {
Ok(text) => {
tracing::debug!(
"clipboard: get read via temp store {} ({} bytes)",
path.display(),
text.len()
);
if let Err(e) = std::fs::remove_file(path) {
tracing::debug!(
"clipboard: get temp store cleanup failed at {} ({e})",
path.display()
);
}
Some(text)
}
Err(_) => {
tracing::debug!(
"clipboard: get temp store unavailable at {}",
path.display()
);
None
}
}
}
fn truncate_for_osc52(text: &str, limit: usize) -> &str {
&text[..text.floor_char_boundary(limit)]
}
fn default_temp_path() -> PathBuf {
if let Some(runtime_dir) = std::env::var_os(ENV_XDG_RUNTIME_DIR) {
let path = PathBuf::from(runtime_dir).join(CLIPBOARD_CACHE_FILENAME);
tracing::debug!(
"clipboard: resolved store path via XDG_RUNTIME_DIR -> {}",
path.display()
);
return path;
}
let base = std::env::temp_dir();
#[cfg(unix)]
let base = base.join(format!("{}-{}", APP_TEMP_DIR_PREFIX, unsafe {
libc::getuid()
}));
let path = base.join(CLIPBOARD_CACHE_FILENAME);
tracing::debug!(
"clipboard: resolved store path via temp_dir{} -> {}",
if cfg!(unix) { " (per-user subdir)" } else { "" },
path.display()
);
path
}
fn ensure_clipboard_store_dir(path: &Path) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
match builder.create(parent) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let meta = std::fs::metadata(parent)?;
if !meta.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"clipboard store: parent path is not a directory",
));
}
if meta.uid() != unsafe { libc::geteuid() } {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"clipboard store: parent directory not owned by current user",
));
}
if meta.mode() & 0o022 != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"clipboard store: parent directory is group/other-writable",
));
}
Ok(())
}
Err(e) => Err(e),
}
}
#[cfg(not(unix))]
std::fs::create_dir_all(parent)
}
fn write_clipboard_temp(path: &Path, text: &str) -> std::io::Result<()> {
ensure_clipboard_store_dir(path)?;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
options.custom_flags(libc::O_NOFOLLOW);
}
let mut f = options.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
use std::os::unix::io::AsRawFd;
let meta = f.metadata()?;
if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } || meta.nlink() != 1 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"clipboard store: unexpected file ownership, type, or link count",
));
}
if unsafe { libc::fchmod(f.as_raw_fd(), 0o600) } != 0 {
return Err(std::io::Error::last_os_error());
}
}
f.set_len(0)?;
f.write_all(text.as_bytes())?;
f.flush()?;
Ok(())
}
fn read_clipboard_temp(path: &Path) -> std::io::Result<String> {
#[cfg(unix)]
{
use std::io::Read;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
let mut options = std::fs::OpenOptions::new();
options.read(true);
options.custom_flags(libc::O_NOFOLLOW);
let mut f = options.open(path)?;
let meta = f.metadata()?;
if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"clipboard store: refusing to read non-owner-only file",
));
}
let mut buf = String::new();
f.read_to_string(&mut buf)?;
Ok(buf)
}
#[cfg(not(unix))]
std::fs::read_to_string(path)
}
pub fn format_osc52_bytes(text: &str) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
format!("\x1b]52;c;{encoded}\x07").into_bytes()
}
pub fn set_via_osc52_with_writer(text: &str, writer: &mut dyn Write) -> Result<(), ClipboardError> {
let seq = format_osc52_bytes(text);
writer.write_all(&seq)?;
writer.flush()?;
Ok(())
}
fn find_osc52_header(data: &[u8]) -> Option<usize> {
if let Some(pos) = data
.windows(OSC52_HEADER_LEN)
.position(|w| w == OSC52_HDR_STD)
{
return Some(pos + OSC52_HEADER_LEN);
}
if let Some(pos) = data
.windows(OSC52_HEADER_LEN_WIN)
.position(|w| w == OSC52_HDR_WIN)
{
return Some(pos + OSC52_HEADER_LEN_WIN);
}
None
}
fn is_base64_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'='
}
pub fn extract_osc52_text(data: &[u8]) -> Option<String> {
let mut i = 0;
while i < data.len() {
let header_end = match find_osc52_header(&data[i..]) {
Some(off) => i + off,
None => {
i += 1;
continue;
}
};
let content_start = header_end;
let payload_start = if data[content_start..].starts_with(b"c;") {
content_start + CLIPBOARD_PARAM_LEN
} else {
content_start
};
let mut end = None;
let mut j = payload_start;
let mut seen_base64 = false;
while j < data.len() {
if data[j] == 0x07 {
end = Some(j);
break;
}
if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' {
end = Some(j);
break;
}
if !is_base64_char(data[j]) {
if seen_base64 {
end = Some(j);
}
break;
}
seen_base64 = true;
j += 1;
}
if let Some(end_pos) = end {
let b64 = &data[payload_start..end_pos];
if let Ok(decoded) =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
&& let Ok(text) = String::from_utf8(decoded)
{
return Some(text);
}
return None;
}
break;
}
None
}
pub struct Osc52Extractor {
buf: Vec<u8>,
}
impl Osc52Extractor {
pub fn new() -> Self {
Self { buf: Vec::new() }
}
pub fn push(&mut self, data: &[u8], prev_tail: &[u8]) -> Option<String> {
if !self.buf.is_empty() {
self.buf.extend_from_slice(data);
return self.try_extract(data, prev_tail);
}
if let Some(header_end) = find_osc52_header(data) {
let header_len = if data.windows(OSC52_HEADER_LEN).any(|w| w == OSC52_HDR_STD) {
OSC52_HEADER_LEN
} else {
OSC52_HEADER_LEN_WIN
};
self.buf.extend_from_slice(&data[header_end - header_len..]);
return self.try_extract(data, prev_tail);
}
if !prev_tail.is_empty() {
let mut combined = prev_tail.to_vec();
combined.extend_from_slice(data);
if let Some(header_end) = find_osc52_header(&combined) {
let header_len = if combined
.windows(OSC52_HEADER_LEN)
.any(|w| w == OSC52_HDR_STD)
{
OSC52_HEADER_LEN
} else {
OSC52_HEADER_LEN_WIN
};
self.buf
.extend_from_slice(&combined[header_end - header_len..]);
return self.try_extract(data, prev_tail);
}
}
None
}
pub fn is_active(&self) -> bool {
!self.buf.is_empty()
}
pub fn clear(&mut self) {
self.buf.clear();
}
fn try_extract(&mut self, _data: &[u8], _prev_tail: &[u8]) -> Option<String> {
if self.buf.len() >= MAX_OSC52_BUFFER_BYTES {
self.buf.clear();
return None;
}
if let Some(result) = extract_osc52_text(&self.buf) {
self.buf.clear();
return Some(result);
}
None
}
}
impl Default for Osc52Extractor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_path(dir: &tempfile::TempDir) -> PathBuf {
let sub = dir.path().join("store");
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&sub).unwrap();
}
#[cfg(not(unix))]
std::fs::create_dir_all(&sub).unwrap();
sub.join(CLIPBOARD_CACHE_FILENAME)
}
#[test]
fn temp_store_roundtrip_via_set_get_when_arboard_absent() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(&dir);
let mut cb = Clipboard::with_temp_path(path.clone());
cb.arboard = None;
cb.temp_store_enabled = true;
cb.set("clipboard text");
assert!(path.exists(), "set() must persist to the temp store");
assert_eq!(cb.get().unwrap(), "clipboard text");
assert!(
!path.exists(),
"get() must consume the temp store so secrets do not persist on disk"
);
}
#[test]
fn temp_store_read_helper_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(&dir);
write_clipboard_temp(&path, "helper text").unwrap();
assert_eq!(read_clipboard_temp(&path).unwrap(), "helper text");
}
#[test]
fn clipboard_error_display_messages() {
assert_eq!(
ClipboardError::NotAvailable.to_string(),
"clipboard backend not available (running remotely?)"
);
let io = ClipboardError::Io(std::io::Error::other("boom"));
assert_eq!(io.to_string(), "I/O error writing OSC 52 sequence: boom");
let backend = ClipboardError::Backend(arboard::Error::ContentNotAvailable);
assert_eq!(
backend.to_string(),
"clipboard backend error: The clipboard contents were not available in the requested format or the clipboard is empty."
);
}
#[test]
fn clipboard_error_from_conversions() {
let io: ClipboardError = std::io::Error::other("x").into();
assert!(matches!(io, ClipboardError::Io(_)));
let arboard_err: ClipboardError = arboard::Error::ContentNotAvailable.into();
assert!(matches!(arboard_err, ClipboardError::Backend(_)));
}
#[test]
fn clipboard_new_uses_default_path_and_default_limit() {
let cb = Clipboard::new();
assert_eq!(cb.temp_path, default_temp_path());
assert_eq!(cb.osc52_limit, DEFAULT_MAX_OSC52_BYTES);
}
#[test]
fn temp_store_read_missing_returns_not_available() {
let dir = tempfile::tempdir().unwrap();
let mut cb = Clipboard::with_temp_path(store_path(&dir));
cb.arboard = None;
cb.temp_store_enabled = true;
assert!(matches!(cb.get(), Err(ClipboardError::NotAvailable)));
}
#[test]
fn temp_store_unicode_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut cb = Clipboard::with_temp_path(store_path(&dir));
cb.arboard = None;
cb.temp_store_enabled = true;
cb.set("héllo 日本語 ✅");
assert_eq!(cb.get().unwrap(), "héllo 日本語 ✅");
}
#[cfg(unix)]
#[test]
fn temp_store_dir_and_file_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("store");
let path = store_dir.join(CLIPBOARD_CACHE_FILENAME);
write_clipboard_temp(&path, "secret").unwrap();
let dir_mode = std::fs::metadata(&store_dir).unwrap().permissions().mode() & 0o777;
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "store dir must be owner-only");
assert_eq!(file_mode, 0o600, "store file must be owner-only");
}
#[test]
fn temp_store_not_written_when_clipboard_present() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(&dir);
let mut cb = Clipboard::with_temp_path(path.clone());
cb.temp_store_enabled = false;
cb.arboard = None;
cb.set("sensitive text");
assert!(
!path.exists(),
"temp store must not be written when a system clipboard exists"
);
assert!(
matches!(cb.get(), Err(ClipboardError::NotAvailable)),
"get() must not fall back to the temp store when it is disabled"
);
}
#[cfg(unix)]
#[test]
fn temp_store_write_rejects_symlink() {
use std::os::unix::fs::{DirBuilderExt, symlink};
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("store");
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&store_dir).unwrap();
let target = dir.path().join("target.txt");
std::fs::write(&target, "precious").unwrap();
let link = store_dir.join(CLIPBOARD_CACHE_FILENAME);
symlink(&target, &link).unwrap();
assert!(write_clipboard_temp(&link, "evil").is_err());
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"precious",
"symlink target must not be truncated"
);
}
#[cfg(unix)]
#[test]
fn temp_store_read_rejects_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("store");
std::fs::create_dir(&store_dir).unwrap();
let target = dir.path().join("target.txt");
std::fs::write(&target, "precious").unwrap();
let link = store_dir.join(CLIPBOARD_CACHE_FILENAME);
symlink(&target, &link).unwrap();
assert!(read_clipboard_temp(&link).is_err());
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"precious",
"symlink target must be left untouched"
);
}
#[cfg(unix)]
#[test]
fn temp_store_rejects_permissive_preexisting_dir() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("store");
std::fs::create_dir(&store_dir).unwrap();
std::fs::set_permissions(&store_dir, std::fs::Permissions::from_mode(0o777)).unwrap();
let path = store_dir.join(CLIPBOARD_CACHE_FILENAME);
assert!(
write_clipboard_temp(&path, "secret").is_err(),
"permissive pre-existing store dir must be rejected"
);
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn temp_store_write_rejects_hardlink_target() {
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("store");
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&store_dir).unwrap();
let attacker_file = dir.path().join("attacker-owned.txt");
std::fs::write(&attacker_file, "precious").unwrap();
let store_path = store_dir.join(CLIPBOARD_CACHE_FILENAME);
std::fs::hard_link(&attacker_file, &store_path).unwrap();
assert_eq!(
std::fs::metadata(&store_path).unwrap().nlink(),
2,
"hard link must be set up for the test"
);
assert!(
write_clipboard_temp(&store_path, "secret").is_err(),
"a hard-linked store file must be rejected before any write"
);
assert_eq!(
std::fs::read_to_string(&attacker_file).unwrap(),
"precious",
"hard-link target must not be truncated or overwritten"
);
}
#[test]
fn clipboard_set_emits_osc52() {
let dir = tempfile::tempdir().unwrap();
let mut cb = Clipboard::with_temp_path(store_path(&dir));
cb.set("hello from test");
assert!(
!cb.osc52_output.is_empty(),
"OSC 52 output must not be empty"
);
let seq = String::from_utf8_lossy(&cb.osc52_output);
assert!(
seq.starts_with("\x1b]52;c;"),
"OSC 52 must start with correct header, got: {seq:?}"
);
assert!(
seq.ends_with('\x07'),
"OSC 52 must end with BEL, got: {seq:?}"
);
assert_eq!(
extract_osc52_text(&cb.osc52_output),
Some("hello from test".to_string()),
"OSC 52 output must survive extract roundtrip"
);
}
#[test]
fn extract_osc52_bel_terminated() {
let data = b"before\x1b]52;c;aGVsbG8=\x07after";
assert_eq!(extract_osc52_text(data), Some("hello".to_string()));
}
#[test]
fn extract_osc52_st_terminated() {
let data = b"\x1b]52;c;d29ybGQ=\x1b\\trailing";
assert_eq!(extract_osc52_text(data), Some("world".to_string()));
}
#[test]
fn extract_osc52_no_pc_param() {
let data = b"\x1b]52;dGVzdA==\x07";
assert_eq!(extract_osc52_text(data), Some("test".to_string()));
}
#[test]
fn extract_osc52_empty_data() {
assert_eq!(extract_osc52_text(b""), None);
assert_eq!(extract_osc52_text(b"no osc here"), None);
}
#[test]
fn extract_osc52_malformed_base64() {
let data = b"\x1b]52;c;!!!\x07";
assert_eq!(extract_osc52_text(data), None);
}
#[test]
fn osc52_roundtrip_ascii() {
let input = "hello world";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_empty() {
let input = "";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_unicode() {
let input = "héllo 日本語 ✅";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_newlines() {
let input = "line1\nline2\r\nline3";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_format_matches_expected_wire_format() {
let bytes = format_osc52_bytes("hello");
let expected = b"\x1b]52;c;aGVsbG8=\x07";
assert_eq!(bytes.as_slice(), expected);
}
#[test]
fn osc52_formatted_embedded_in_larger_buffer_still_extracts() {
let mut buf = b"some normal output\n".to_vec();
buf.extend_from_slice(&format_osc52_bytes("secret"));
buf.extend_from_slice(b"\nmore output");
assert_eq!(extract_osc52_text(&buf), Some("secret".to_string()));
}
#[test]
fn osc52_multiple_sequences_extracts_first() {
let bytes1 = format_osc52_bytes("first");
let bytes2 = format_osc52_bytes("second");
let mut combined = bytes1.clone();
combined.extend_from_slice(&bytes2);
assert_eq!(extract_osc52_text(&combined), Some("first".to_string()));
}
#[test]
fn osc52_set_via_osc52_writer_does_not_panic() {
let mut buf = Vec::new();
let _ = set_via_osc52_with_writer("test", &mut buf);
}
#[test]
fn set_via_osc52_with_writer_writes_correct_bytes() {
let mut buf = Vec::new();
set_via_osc52_with_writer("hello world", &mut buf).unwrap();
let expected = format_osc52_bytes("hello world");
assert_eq!(
buf, expected,
"writer should contain exactly the OSC 52 sequence"
);
}
#[test]
fn set_via_osc52_with_writer_roundtrips_through_extract() {
let mut buf = Vec::new();
set_via_osc52_with_writer("hello 日本語", &mut buf).unwrap();
assert_eq!(
extract_osc52_text(&buf),
Some("hello 日本語".to_string()),
"writer output should survive extract roundtrip"
);
}
#[test]
fn osc52_emission_truncated_over_limit() {
let dir = tempfile::tempdir().unwrap();
let path = store_path(&dir);
let mut cb = Clipboard::with_config(ClipboardConfig {
cache_path: path.clone(),
osc52_limit: 8,
});
cb.arboard = None;
cb.temp_store_enabled = true;
let oversized = "this text is longer than the 8-byte cap";
cb.set(oversized);
let decoded = extract_osc52_text(&cb.osc52_output).unwrap();
assert!(
decoded.len() <= 8,
"OSC 52 emission must be truncated to the cap, got {} bytes",
decoded.len()
);
assert!(
oversized.starts_with(&decoded),
"truncated emission must be a prefix of the original text"
);
assert_eq!(
read_clipboard_temp(&path).unwrap(),
oversized,
"temp store must keep the full text"
);
}
#[test]
fn osc52_truncation_respects_utf8_boundary() {
let dir = tempfile::tempdir().unwrap();
let mut cb = Clipboard::with_config(ClipboardConfig {
cache_path: store_path(&dir),
osc52_limit: 5,
});
cb.arboard = None;
cb.temp_store_enabled = true;
let text = "héllo";
cb.set(text);
let decoded = extract_osc52_text(&cb.osc52_output).unwrap();
assert_eq!(decoded, "héll", "must truncate at a valid UTF-8 boundary");
assert!(decoded.len() <= 5);
assert!(!cb.osc52_output.is_empty(), "OSC 52 must still be emitted");
}
#[test]
fn clipboard_set_triggers_osc52_path() {
let mut buf = Vec::new();
set_via_osc52_with_writer("clip test", &mut buf).unwrap();
let seq = String::from_utf8_lossy(&buf);
assert!(
seq.starts_with("\x1b]52;c;"),
"should start with OSC 52 header"
);
assert!(seq.ends_with('\x07'), "should end with BEL terminator");
assert_eq!(extract_osc52_text(&buf), Some("clip test".to_string()));
}
#[test]
fn extractor_single_chunk_bel() {
let seq = format_osc52_bytes("hello");
let mut ex = Osc52Extractor::new();
let result = ex.push(&seq, &[]);
assert_eq!(result.as_deref(), Some("hello"));
}
#[test]
fn extractor_multi_chunk_bel() {
let seq = format_osc52_bytes("this is a longer test");
let mid = seq.len() / 3;
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..mid], &[]).is_none());
assert!(ex.is_active());
assert!(ex.push(&seq[mid..2 * mid], &[]).is_none());
assert!(ex.is_active());
let result = ex.push(&seq[2 * mid..], &[]);
assert_eq!(result.as_deref(), Some("this is a longer test"));
assert!(!ex.is_active());
}
#[test]
fn extractor_header_cross_boundary() {
let seq = format_osc52_bytes("test");
let split = 3; assert_eq!(&seq[..split], b"\x1b]5");
assert_eq!(&seq[split..split + 3], b"2;c");
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..split], &[]).is_none());
assert!(!ex.is_active());
let result = ex.push(&seq[split..], &seq[..split]);
assert_eq!(result.as_deref(), Some("test"));
}
#[test]
fn extractor_st_terminator_cross_boundary() {
let text = "boundary test";
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
let mut seq = b"\x1b]52;c;".to_vec();
seq.extend_from_slice(encoded.as_bytes());
seq.extend_from_slice(b"\x1b\\");
let split = seq.len() - 2; assert_eq!(seq[split], 0x1b);
assert_eq!(seq[split + 1], b'\\');
let mut ex = Osc52Extractor::new();
let _ = ex.push(&seq[..split], &[]); assert!(ex.is_active());
let result = ex.push(&seq[split..], &seq[split - 2..split]);
assert_eq!(result.as_deref(), Some("boundary test"));
}
#[test]
fn extractor_normal_data_no_false_positive() {
let data = b"hello\nworld\nthis is just normal text\nno osc sequences\n";
let mut ex = Osc52Extractor::new();
assert!(ex.push(data, &[]).is_none());
assert!(!ex.is_active());
}
#[test]
fn extractor_clears_on_4mb_limit() {
let mut ex = Osc52Extractor::new();
ex.buf = vec![0u8; 4 * 1024 * 1024];
assert!(ex.is_active());
assert!(ex.push(b"", &[]).is_none());
assert!(!ex.is_active());
}
#[test]
fn extractor_new_starts_inactive() {
let ex = Osc52Extractor::new();
assert!(!ex.is_active());
}
#[test]
fn extractor_push_empty_data_no_activation() {
let mut ex = Osc52Extractor::new();
assert!(ex.push(b"", &[]).is_none());
assert!(!ex.is_active());
}
#[test]
fn extractor_clear_resets_in_progress() {
let seq = format_osc52_bytes("hello");
let mid = seq.len() / 2;
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..mid], &[]).is_none());
assert!(ex.is_active());
ex.clear();
assert!(!ex.is_active());
let result = ex.push(&seq, &[]);
assert_eq!(result.as_deref(), Some("hello"));
}
}