use std::sync::atomic::{AtomicU8, Ordering};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_ENGINE};
use compact_str::CompactString;
use thiserror::Error;
use crate::ansi_codes::{OSC, ST};
use crate::utils::parse_bool_env_value;
pub const DECRQM_5522_QUERY: &str = "\x1b[?5522$p";
pub const DECRPM_5522_PREFIX: &str = "\x1b[?5522;";
pub const MAX_PACKET_BYTES: usize = 16 * 1024;
pub const MAX_READ_BYTES: usize = 16 * 1024 * 1024;
const MIME_LIST_PROBE_B64: &str = "Lg==";
fn is_request_id_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'.')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadOptions<'a> {
pub id: &'a str,
pub password: Option<&'a str>,
pub name: Option<&'a str>,
pub primary: bool,
}
#[must_use]
pub fn sanitize_request_id(id: &str) -> String {
id.bytes().filter(|byte| is_request_id_byte(*byte)).map(char::from).collect()
}
#[must_use]
pub fn generate_request_id() -> String {
use rand::{RngExt, distr::Alphanumeric};
rand::rng().sample_iter(&Alphanumeric).take(16).map(char::from).collect()
}
fn encode_options(options: &ReadOptions, metadata: &mut String) {
let id = sanitize_request_id(options.id);
if !id.is_empty() {
metadata.push_str(":id=");
metadata.push_str(&id);
}
if let (Some(password), Some(name)) = (options.password, options.name) {
metadata.push_str(":pw=");
metadata.push_str(&BASE64_ENGINE.encode(password.as_bytes()));
metadata.push_str(":name=");
metadata.push_str(&BASE64_ENGINE.encode(name.as_bytes()));
}
if options.primary {
metadata.push_str(":loc=primary");
}
}
#[must_use]
pub fn mime_list_request(options: &ReadOptions) -> String {
let mut metadata = String::from("type=read");
encode_options(options, &mut metadata);
format!("{OSC}5522;{metadata};{MIME_LIST_PROBE_B64}{ST}")
}
#[must_use]
pub fn read_request(mimes: &[&str], options: &ReadOptions) -> String {
let mut metadata = String::from("type=read");
encode_options(options, &mut metadata);
let payload = BASE64_ENGINE.encode(mimes.join(" ").as_bytes());
format!("{OSC}5522;{metadata};{payload}{ST}")
}
#[must_use]
pub fn parse_decrpm_5522(response: &[u8]) -> Option<bool> {
let decoded = String::from_utf8_lossy(response);
let mut supported = None;
for (pos, _) in decoded.match_indices(DECRPM_5522_PREFIX) {
let Some(after) = decoded.get(pos + DECRPM_5522_PREFIX.len()..) else {
continue;
};
let Some((payload, _)) = after.split_once("$y") else {
continue;
};
match payload {
"1" | "2" | "3" => supported = Some(true),
"0" | "4" => supported = Some(false),
_ => {}
}
}
supported
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadErrorCode {
NoSys,
Perm,
Busy,
Unknown(CompactString),
}
impl ReadErrorCode {
fn from_status(status: &str) -> Self {
match status {
"ENOSYS" => Self::NoSys,
"EPERM" => Self::Perm,
"EBUSY" => Self::Busy,
other => Self::Unknown(CompactString::new(other)),
}
}
fn name(&self) -> CompactString {
match self {
Self::NoSys => CompactString::const_new("unavailable"),
Self::Perm => CompactString::const_new("denied"),
Self::Busy => CompactString::const_new("busy"),
Self::Unknown(code) => code.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadStatus {
Ok,
Data,
Done,
Error(ReadErrorCode),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadPacket {
pub status: ReadStatus,
pub mime: Option<CompactString>,
pub data_b64: String,
pub id: Option<CompactString>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PacketError {
#[error("not a 5522 packet")]
Not5522,
#[error("5522 packet without type")]
MissingType,
#[error("unexpected 5522 type: {0}")]
UnexpectedType(CompactString),
#[error("5522 packet without status")]
MissingStatus,
#[error("5522 data packet without mime")]
MissingMime,
#[error("5522 data packet with undecodable mime")]
InvalidMime,
#[error("5522 packet exceeds size limit")]
TooLarge,
}
fn is_strict_base64(payload: &str) -> bool {
!payload.is_empty()
&& payload.len().is_multiple_of(4)
&& payload
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
}
pub fn parse_read_packet(packet: &str) -> Result<ReadPacket, PacketError> {
if packet.len() > MAX_PACKET_BYTES {
return Err(PacketError::TooLarge);
}
let Some(inner) = packet.strip_prefix("5522;") else {
return Err(PacketError::Not5522);
};
let (metadata, payload) = inner.split_once(';').unwrap_or((inner, ""));
let mut type_seen = false;
let mut status_raw: Option<&str> = None;
let mut mime_raw: Option<&str> = None;
let mut id_raw: Option<&str> = None;
for pair in metadata.split(':') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
match key {
"type" => {
if value != "read" {
return Err(PacketError::UnexpectedType(CompactString::new(value)));
}
type_seen = true;
}
"status" => status_raw = Some(value),
"mime" => mime_raw = Some(value),
"id" => id_raw = Some(value),
_ => {}
}
}
if !type_seen {
return Err(PacketError::MissingType);
}
let Some(status_raw) = status_raw else {
return Err(PacketError::MissingStatus);
};
let status = match status_raw {
"OK" => ReadStatus::Ok,
"DATA" => ReadStatus::Data,
"DONE" => ReadStatus::Done,
other => ReadStatus::Error(ReadErrorCode::from_status(other)),
};
let mime = if matches!(status, ReadStatus::Data) {
let Some(mime_raw) = mime_raw else {
return Err(PacketError::MissingMime);
};
let mime = BASE64_ENGINE
.decode(mime_raw)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.map(CompactString::new)
.ok_or(PacketError::InvalidMime)?;
Some(mime)
} else {
None
};
let is_data = matches!(status, ReadStatus::Data);
Ok(ReadPacket {
status,
mime,
data_b64: if is_data { payload.to_string() } else { String::new() },
id: id_raw.map(CompactString::new),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MimeData {
pub mime: CompactString,
pub decoded_bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssembleOutcome {
NeedMore,
Complete(Vec<MimeData>),
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AssembleError {
#[error("clipboard read failed: {0}")]
Terminal(CompactString),
#[error("clipboard reply id mismatch")]
IdMismatch,
#[error("clipboard reply out of order")]
OutOfOrder,
#[error("clipboard data exceeds size limit")]
TooLarge,
#[error("clipboard chunk is not valid base64")]
InvalidBase64,
}
impl AssembleError {
fn terminal(code: &ReadErrorCode) -> Self {
Self::Terminal(code.name())
}
}
#[derive(Debug)]
pub struct ReadAssembler {
expected_id: String,
max_bytes: usize,
started: bool,
finished: bool,
current_mime: Option<CompactString>,
current_bytes: Vec<u8>,
completed: Vec<MimeData>,
total_bytes: usize,
}
impl ReadAssembler {
#[must_use]
pub fn new(expected_id: &str) -> Self {
Self::with_max_bytes(expected_id, MAX_READ_BYTES)
}
#[must_use]
pub fn with_max_bytes(expected_id: &str, max_bytes: usize) -> Self {
Self {
expected_id: expected_id.to_string(),
max_bytes,
started: false,
finished: false,
current_mime: None,
current_bytes: Vec::new(),
completed: Vec::new(),
total_bytes: 0,
}
}
fn check_id(&self, packet: &ReadPacket) -> Result<(), AssembleError> {
if let Some(id) = packet.id.as_deref()
&& id != self.expected_id
{
return Err(AssembleError::IdMismatch);
}
Ok(())
}
fn flush_current(&mut self) {
if let Some(mime) = self.current_mime.take() {
let decoded_bytes = std::mem::take(&mut self.current_bytes);
self.completed.push(MimeData { mime, decoded_bytes });
}
}
pub fn push(&mut self, packet: &ReadPacket) -> Result<AssembleOutcome, AssembleError> {
self.check_id(packet)?;
if self.finished {
return Err(AssembleError::OutOfOrder);
}
match &packet.status {
ReadStatus::Ok => {
if self.started {
return Err(AssembleError::OutOfOrder);
}
self.started = true;
Ok(AssembleOutcome::NeedMore)
}
ReadStatus::Data => {
if !self.started {
return Err(AssembleError::OutOfOrder);
}
let Some(mime) = packet.mime.as_ref() else {
return Err(AssembleError::OutOfOrder);
};
if !packet.data_b64.is_empty() && !is_strict_base64(&packet.data_b64) {
return Err(AssembleError::InvalidBase64);
}
let decoded = BASE64_ENGINE
.decode(packet.data_b64.as_bytes())
.ok()
.ok_or(AssembleError::InvalidBase64)?;
self.total_bytes = self.total_bytes.saturating_add(decoded.len());
if self.total_bytes > self.max_bytes {
return Err(AssembleError::TooLarge);
}
if self.current_mime.as_ref() != Some(mime) {
self.flush_current();
self.current_mime = Some(mime.clone());
}
self.current_bytes.extend_from_slice(&decoded);
Ok(AssembleOutcome::NeedMore)
}
ReadStatus::Done => {
if !self.started {
return Err(AssembleError::OutOfOrder);
}
self.flush_current();
self.finished = true;
Ok(AssembleOutcome::Complete(std::mem::take(&mut self.completed)))
}
ReadStatus::Error(code) => Err(AssembleError::terminal(code)),
}
}
}
const HINT_FALSE: u8 = 0;
const HINT_TRUE: u8 = 1;
const HINT_UNSET: u8 = 2;
static CLIPBOARD_5522_HINT: AtomicU8 = AtomicU8::new(HINT_UNSET);
pub fn set_clipboard_5522_hint(value: Option<bool>) {
let encoded = match value {
Some(true) => HINT_TRUE,
Some(false) => HINT_FALSE,
None => HINT_UNSET,
};
CLIPBOARD_5522_HINT.store(encoded, Ordering::Relaxed);
}
#[must_use]
pub fn clipboard_5522_hint() -> Option<bool> {
match CLIPBOARD_5522_HINT.load(Ordering::Relaxed) {
HINT_TRUE => Some(true),
HINT_FALSE => Some(false),
_ => None,
}
}
#[must_use]
pub fn clipboard_5522_available() -> bool {
if let Some(override_value) = std::env::var("VTCODE_CLIPBOARD_5522")
.ok()
.and_then(|value| parse_bool_env_value(&value))
{
return override_value;
}
clipboard_5522_hint().unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn options(id: &str) -> ReadOptions<'_> {
ReadOptions { id, password: None, name: None, primary: false }
}
#[test]
fn sanitize_request_id_keeps_spec_charset_only() {
assert_eq!(sanitize_request_id("abcXYZ019-_+."), "abcXYZ019-_+.");
assert_eq!(sanitize_request_id("a b;c=d:e/f\\g"), "abcdefg");
assert_eq!(sanitize_request_id("héllo→"), "hllo");
assert_eq!(sanitize_request_id(""), "");
}
#[test]
fn generated_request_id_is_charset_clean() {
for _ in 0..32 {
let id = generate_request_id();
assert_eq!(id.len(), 16);
assert_eq!(sanitize_request_id(&id), id);
}
}
#[test]
fn mime_list_request_is_byte_exact() {
let request = mime_list_request(&options("test-id_1.2+x"));
assert_eq!(request, "\x1b]5522;type=read:id=test-id_1.2+x;Lg==\x1b\\");
}
#[test]
fn read_request_is_byte_exact_with_all_options() {
let request = read_request(
&["text/plain", "image/png"],
&ReadOptions {
id: "a b",
password: Some("p"),
name: Some("n"),
primary: true,
},
);
assert_eq!(
request,
"\x1b]5522;type=read:id=ab:pw=cA==:name=bg==:loc=primary;dGV4dC9wbGFpbiBpbWFnZS9wbmc=\x1b\\"
);
}
#[test]
fn password_without_name_is_dropped() {
let with_name = read_request(
&["a"],
&ReadOptions {
id: "i",
password: Some("p"),
name: Some("n"),
primary: false,
},
);
let without_name = read_request(
&["a"],
&ReadOptions {
id: "i",
password: Some("p"),
name: None,
primary: false,
},
);
assert!(with_name.contains(":pw=cA==:name=bg==;"), "{with_name:?}");
assert_eq!(without_name, "\x1b]5522;type=read:id=i;YQ==\x1b\\");
}
#[test]
fn primary_flag_toggles_loc_pair() {
let primary = read_request(&["a"], &ReadOptions { id: "i", password: None, name: None, primary: true });
let clipboard = read_request(&["a"], &options("i"));
assert!(primary.contains(":loc=primary;"), "{primary:?}");
assert!(!clipboard.contains("loc="), "{clipboard:?}");
}
#[test]
fn decrpm_reports_supported_for_set_modes() {
for payload in ["\x1b[?5522;1$y", "\x1b[?5522;2$y", "\x1b[?5522;3$y"] {
assert_eq!(parse_decrpm_5522(payload.as_bytes()), Some(true), "{payload:?}");
}
}
#[test]
fn decrpm_reports_unsupported_for_unset_modes() {
for payload in ["\x1b[?5522;0$y", "\x1b[?5522;4$y"] {
assert_eq!(parse_decrpm_5522(payload.as_bytes()), Some(false), "{payload:?}");
}
}
#[test]
fn decrpm_ignores_absent_and_malformed_reports() {
assert_eq!(parse_decrpm_5522(b""), None);
assert_eq!(parse_decrpm_5522(b"\x1b[?62;22;52c"), None);
assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1$n"), None);
assert_eq!(parse_decrpm_5522(b"\x1b[?5522;12$y"), None);
assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1$z"), None);
assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1"), None);
}
#[test]
fn decrpm_parses_inside_mixed_probe_buffer_and_last_wins() {
let buffer = "\x1b]10;rgb:dddd/dddd/dddd\x1b\\\x1b[?997;1n\x1b[?5522;0$y\x1b[?62;4c";
assert_eq!(parse_decrpm_5522(buffer.as_bytes()), Some(false));
let flipped = "\x1b[?5522;0$y\x1b[?62;4c\x1b[?5522;1$y";
assert_eq!(parse_decrpm_5522(flipped.as_bytes()), Some(true));
}
#[test]
fn packet_parses_ok_done_and_error_shapes() {
let ok = parse_read_packet("5522;type=read:status=OK:id=abc").expect("ok packet");
assert_eq!(ok.status, ReadStatus::Ok);
assert_eq!(ok.id.as_deref(), Some("abc"));
assert!(ok.mime.is_none());
let done = parse_read_packet("5522;type=read:status=DONE").expect("done packet");
assert_eq!(done.status, ReadStatus::Done);
let error = parse_read_packet("5522;type=read:status=EPERM:id=abc").expect("error packet");
assert_eq!(error.status, ReadStatus::Error(ReadErrorCode::Perm));
let unknown = parse_read_packet("5522;type=read:status=EWEIRD").expect("unknown status");
assert!(matches!(unknown.status, ReadStatus::Error(ReadErrorCode::Unknown(_))));
}
#[test]
fn packet_parses_data_with_decoded_mime() {
let packet = parse_read_packet("5522;type=read:status=DATA:mime=aW1hZ2UvcG5n;YWI=").expect("data packet");
assert_eq!(packet.status, ReadStatus::Data);
assert_eq!(packet.mime.as_deref(), Some("image/png"));
assert_eq!(packet.data_b64, "YWI=");
}
#[test]
fn packet_rejects_malformed_shapes() {
assert_eq!(parse_read_packet("52;c;Zm9v"), Err(PacketError::Not5522));
assert_eq!(parse_read_packet("5522;status=OK"), Err(PacketError::MissingType));
assert!(matches!(parse_read_packet("5522;type=write:status=DONE"), Err(PacketError::UnexpectedType(_))));
assert_eq!(parse_read_packet("5522;type=read"), Err(PacketError::MissingStatus));
assert_eq!(parse_read_packet("5522;type=read:status=DATA;YWI="), Err(PacketError::MissingMime));
assert_eq!(parse_read_packet("5522;type=read:status=DATA:mime=!!!;YWI="), Err(PacketError::InvalidMime));
}
#[test]
fn packet_rejects_oversized_payload_at_cap_boundary() {
let oversized = format!("5522;type=read:status=DATA:mime=YQ==;{}", "A".repeat(MAX_PACKET_BYTES));
assert_eq!(parse_read_packet(&oversized), Err(PacketError::TooLarge));
let head = "5522;type=read:status=OK:pad=";
let exact = format!("{head}{}", "A".repeat(MAX_PACKET_BYTES - head.len()));
assert_eq!(exact.len(), MAX_PACKET_BYTES);
assert!(matches!(parse_read_packet(&exact), Ok(ReadPacket { status: ReadStatus::Ok, .. })));
}
#[test]
fn packet_ignores_unknown_metadata_keys() {
let packet = parse_read_packet("5522;type=read:status=OK:future=1:id=z").expect("forward compat");
assert_eq!(packet.status, ReadStatus::Ok);
assert_eq!(packet.id.as_deref(), Some("z"));
}
#[test]
fn strict_base64_predicate_matches_spec() {
assert!(is_strict_base64("YWI="));
assert!(is_strict_base64("aW1hZ2UvcG5n"));
assert!(!is_strict_base64(""));
assert!(!is_strict_base64("YWI"));
assert!(!is_strict_base64("YWI=="));
assert!(!is_strict_base64("YW I="));
assert!(!is_strict_base64("YWI=\n"));
assert!(!is_strict_base64("YW-I="));
}
fn data_packet(mime_b64: &str, chunk_b64: &str) -> ReadPacket {
parse_read_packet(&format!("5522;type=read:status=DATA:mime={mime_b64};{chunk_b64}")).expect("data packet")
}
#[test]
fn assembler_concatenates_chunks_per_mime_in_order() {
let mut assembler = ReadAssembler::new("id-1");
let ok = parse_read_packet("5522;type=read:status=OK:id=id-1").expect("ok");
assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
let png = "aW1hZ2UvcG5n";
let text = "dGV4dC9wbGFpbg==";
assert_eq!(assembler.push(&data_packet(png, "YWI=")), Ok(AssembleOutcome::NeedMore));
assert_eq!(assembler.push(&data_packet(png, "Y2Q=")), Ok(AssembleOutcome::NeedMore));
assert_eq!(assembler.push(&data_packet(text, "eA==")), Ok(AssembleOutcome::NeedMore));
let done = parse_read_packet("5522;type=read:status=DONE:id=id-1").expect("done");
let outcome = assembler.push(&done).expect("complete");
assert_eq!(
outcome,
AssembleOutcome::Complete(vec![
MimeData {
mime: CompactString::new("image/png"),
decoded_bytes: b"abcd".to_vec()
},
MimeData {
mime: CompactString::new("text/plain"),
decoded_bytes: b"x".to_vec()
},
])
);
}
#[test]
fn assembler_rejects_out_of_order_packets() {
let mut assembler = ReadAssembler::new("id-1");
assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Err(AssembleError::OutOfOrder));
let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
assert_eq!(assembler.push(&ok), Err(AssembleError::OutOfOrder));
let done = parse_read_packet("5522;type=read:status=DONE").expect("done");
assert!(matches!(assembler.push(&done), Ok(AssembleOutcome::Complete(_))));
assert_eq!(assembler.push(&done), Err(AssembleError::OutOfOrder));
}
#[test]
fn assembler_rejects_id_mismatch_but_tolerates_absent_id() {
let mut assembler = ReadAssembler::new("id-1");
let foreign = parse_read_packet("5522;type=read:status=OK:id=id-2").expect("foreign ok");
assert_eq!(assembler.push(&foreign), Err(AssembleError::IdMismatch));
let anonymous = parse_read_packet("5522;type=read:status=OK").expect("anonymous ok");
assert_eq!(assembler.push(&anonymous), Ok(AssembleOutcome::NeedMore));
}
#[test]
fn assembler_maps_terminal_errors_and_invalid_chunks() {
let mut assembler = ReadAssembler::new("id-1");
let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
let busy = parse_read_packet("5522;type=read:status=EBUSY").expect("busy");
assert_eq!(assembler.push(&busy), Err(AssembleError::Terminal(CompactString::const_new("busy"))));
let mut assembler = ReadAssembler::new("id-1");
assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
let mut bad = data_packet("YQ==", "YWI=");
bad.data_b64 = "YW I=".to_string();
assert_eq!(assembler.push(&bad), Err(AssembleError::InvalidBase64));
}
#[test]
fn assembler_enforces_decoded_size_cap() {
let mut assembler = ReadAssembler::with_max_bytes("id-1", 3);
let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Ok(AssembleOutcome::NeedMore));
assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Err(AssembleError::TooLarge));
}
#[test]
fn hint_roundtrips_and_defaults_to_unset() {
set_clipboard_5522_hint(None);
assert_eq!(clipboard_5522_hint(), None);
set_clipboard_5522_hint(Some(true));
assert_eq!(clipboard_5522_hint(), Some(true));
set_clipboard_5522_hint(Some(false));
assert_eq!(clipboard_5522_hint(), Some(false));
set_clipboard_5522_hint(None);
}
#[test]
fn available_resolves_env_over_hint_with_fail_closed_default() {
let guard = crate::env_lock::lock();
let previous = std::env::var_os("VTCODE_CLIPBOARD_5522");
set_clipboard_5522_hint(None);
guard.remove_var("VTCODE_CLIPBOARD_5522");
assert!(!clipboard_5522_available());
set_clipboard_5522_hint(Some(true));
assert!(clipboard_5522_available());
guard.set_var("VTCODE_CLIPBOARD_5522", "off");
assert!(!clipboard_5522_available());
set_clipboard_5522_hint(Some(false));
guard.set_var("VTCODE_CLIPBOARD_5522", "yes");
assert!(clipboard_5522_available());
guard.restore_var("VTCODE_CLIPBOARD_5522", previous);
set_clipboard_5522_hint(None);
}
}