Skip to main content

vtcode_commons/
clipboard_5522.rs

1//! Kitty OSC 5522 rich-clipboard protocol: request builders, reply parsing, capability hint.
2//!
3//! Spec: <https://sw.kovidgoyal.net/kitty/clipboard/>
4//!
5//! VT Code implements the **read path only**: the TUI falls back to it when a
6//! local OS-clipboard image read fails (SSH, containers, headless sessions).
7//! The write path (`type=write`/`wdata`/`walias`) and paste-event mode
8//! (`CSI ? 5522 h`) are deliberately out of scope — terminal copy stays on
9//! plain OSC 52 and paste stays on bracketed paste plus this read fallback.
10//!
11//! Two consumers share this module:
12//!
13//! - the startup color probe ([`crate::utils`] callers in `vtcode-core`)
14//!   folds a DECRQM 5522 query into its existing OSC batch and caches the
15//!   result with [`set_clipboard_5522_hint`];
16//! - the TUI session (Phase 1) sends [`read_request`] on image-paste
17//!   fallback, receives one reply packet per fork-parser event, and feeds
18//!   them to [`ReadAssembler`].
19//!
20//! All parsing is fail-closed: malformed packets, id mismatches, oversized
21//! payloads, and terminal error statuses abort the read instead of yielding
22//! partial data.
23
24use std::sync::atomic::{AtomicU8, Ordering};
25
26use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_ENGINE};
27use compact_str::CompactString;
28use thiserror::Error;
29
30use crate::ansi_codes::{OSC, ST};
31use crate::utils::parse_bool_env_value;
32
33/// Query for 5522 support: `CSI ? 5522 $ p` (DECRQM).
34pub const DECRQM_5522_QUERY: &str = "\x1b[?5522$p";
35
36/// Prefix of a DECRPM reply to [`DECRQM_5522_QUERY`]: `CSI ? 5522 ; Ps $ y`.
37pub const DECRPM_5522_PREFIX: &str = "\x1b[?5522;";
38
39/// Upper bound for one 5522 reply packet (base64 payload plus metadata).
40///
41/// The spec chunks DATA payloads at 4096 decoded bytes (~5464 base64 chars),
42/// so 16 KiB accepts any conforming packet while bounding a malicious or
43/// buggy terminal's per-packet memory. Packets beyond this are rejected.
44pub const MAX_PACKET_BYTES: usize = 16 * 1024;
45
46/// Upper bound for the total decoded bytes of one clipboard read.
47///
48/// Clipboard images are small; anything beyond 16 MiB aborts the read
49/// ([`AssembleError::TooLarge`]) instead of buffering unbounded input.
50pub const MAX_READ_BYTES: usize = 16 * 1024 * 1024;
51
52/// MIME-list probe payload: a single period, base64-encoded (`Lg==`).
53const MIME_LIST_PROBE_B64: &str = "Lg==";
54
55/// Request id charset from the spec: `[a-zA-Z0-9-_+.]`.
56fn is_request_id_byte(byte: u8) -> bool {
57    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'.')
58}
59
60/// Shared options for 5522 read requests.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct ReadOptions<'a> {
63    /// Caller-chosen correlation id; sanitized by [`sanitize_request_id`].
64    pub id: &'a str,
65    /// Session password that lets the terminal pre-authorize future reads.
66    /// Only sent together with [`ReadOptions::name`].
67    pub password: Option<&'a str>,
68    /// Human-friendly client name shown in terminal permission prompts.
69    pub name: Option<&'a str>,
70    /// Read from the primary selection instead of the clipboard.
71    pub primary: bool,
72}
73
74/// Strip characters outside the spec's request-id charset.
75///
76/// Terminals strip invalid characters before echoing the id back, so
77/// sanitizing client-side keeps our correlation comparison exact.
78#[must_use]
79pub fn sanitize_request_id(id: &str) -> String {
80    id.bytes().filter(|byte| is_request_id_byte(*byte)).map(char::from).collect()
81}
82
83/// Generate a random correlation id inside the spec's charset.
84#[must_use]
85pub fn generate_request_id() -> String {
86    use rand::{RngExt, distr::Alphanumeric};
87
88    rand::rng().sample_iter(&Alphanumeric).take(16).map(char::from).collect()
89}
90
91fn encode_options(options: &ReadOptions, metadata: &mut String) {
92    let id = sanitize_request_id(options.id);
93    if !id.is_empty() {
94        metadata.push_str(":id=");
95        metadata.push_str(&id);
96    }
97    // A password without a human name is equivalent to no password; the
98    // terminal must treat the request as though it had none. Enforce that
99    // here so callers cannot accidentally weaken the request.
100    if let (Some(password), Some(name)) = (options.password, options.name) {
101        metadata.push_str(":pw=");
102        metadata.push_str(&BASE64_ENGINE.encode(password.as_bytes()));
103        metadata.push_str(":name=");
104        metadata.push_str(&BASE64_ENGINE.encode(name.as_bytes()));
105    }
106    if options.primary {
107        metadata.push_str(":loc=primary");
108    }
109}
110
111/// Build a MIME-list request: payload is base64(`.`).
112///
113/// Listing is permission-free, so the session lists first and only reads
114/// when an image MIME type is actually present — no prompt for nothing.
115#[must_use]
116pub fn mime_list_request(options: &ReadOptions) -> String {
117    let mut metadata = String::from("type=read");
118    encode_options(options, &mut metadata);
119    format!("{OSC}5522;{metadata};{MIME_LIST_PROBE_B64}{ST}")
120}
121
122/// Build a typed read request for space-separated `mimes`.
123#[must_use]
124pub fn read_request(mimes: &[&str], options: &ReadOptions) -> String {
125    let mut metadata = String::from("type=read");
126    encode_options(options, &mut metadata);
127    let payload = BASE64_ENGINE.encode(mimes.join(" ").as_bytes());
128    format!("{OSC}5522;{metadata};{payload}{ST}")
129}
130
131/// Parse a DECRPM 5522 reply out of a mixed probe buffer.
132///
133/// `Ps` 0 or 4 means unsupported, 1/2/3 means supported; anything else is
134/// ignored and the last valid report wins. Returns `None` when the terminal
135/// sent no 5522 report (treat as unsupported — fail closed).
136#[must_use]
137pub fn parse_decrpm_5522(response: &[u8]) -> Option<bool> {
138    let decoded = String::from_utf8_lossy(response);
139    let mut supported = None;
140    for (pos, _) in decoded.match_indices(DECRPM_5522_PREFIX) {
141        let Some(after) = decoded.get(pos + DECRPM_5522_PREFIX.len()..) else {
142            continue;
143        };
144        // DECRPM ends with `$ y`; `split_once` fails when the reply is
145        // truncated, which we treat as no report.
146        let Some((payload, _)) = after.split_once("$y") else {
147            continue;
148        };
149        match payload {
150            "1" | "2" | "3" => supported = Some(true),
151            "0" | "4" => supported = Some(false),
152            _ => {}
153        }
154    }
155    supported
156}
157
158/// Terminal error status for a 5522 read.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum ReadErrorCode {
161    /// Requested clipboard location is unavailable (`loc=primary` on a
162    /// system without a primary selection).
163    NoSys,
164    /// Permission denied by the system or the user.
165    Perm,
166    /// Temporary failure (another multiplexer client holds the clipboard).
167    Busy,
168    /// Forward-compatibility: a status value this client does not know.
169    /// Treated as a terminal failure, never ignored.
170    Unknown(CompactString),
171}
172
173impl ReadErrorCode {
174    fn from_status(status: &str) -> Self {
175        match status {
176            "ENOSYS" => Self::NoSys,
177            "EPERM" => Self::Perm,
178            "EBUSY" => Self::Busy,
179            other => Self::Unknown(CompactString::new(other)),
180        }
181    }
182
183    /// Short lowercase name for terminal-error reports.
184    fn name(&self) -> CompactString {
185        match self {
186            Self::NoSys => CompactString::const_new("unavailable"),
187            Self::Perm => CompactString::const_new("denied"),
188            Self::Busy => CompactString::const_new("busy"),
189            Self::Unknown(code) => code.clone(),
190        }
191    }
192}
193
194/// Status of one parsed 5522 read-reply packet.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum ReadStatus {
197    /// `status=OK`: terminal accepted the read; DATA packets follow.
198    Ok,
199    /// `status=DATA`: one chunk of one MIME type's base64 payload.
200    Data,
201    /// `status=DONE`: end of transmission.
202    Done,
203    /// `status=<ERRORCODE>`: terminal aborted the read.
204    Error(ReadErrorCode),
205}
206
207/// One parsed 5522 read-reply packet (OSC framing already stripped).
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct ReadPacket {
210    /// Packet status.
211    pub status: ReadStatus,
212    /// Decoded MIME type (`DATA` packets only).
213    pub mime: Option<CompactString>,
214    /// Raw base64 chunk (`DATA` packets only); decoded at assembly time
215    /// because chunks concatenate before decoding.
216    pub data_b64: String,
217    /// Echoed request id, when the terminal sent one back.
218    pub id: Option<CompactString>,
219}
220
221/// A single packet failed to parse.
222#[derive(Debug, Clone, PartialEq, Eq, Error)]
223pub enum PacketError {
224    /// Packet does not start with `5522;`.
225    #[error("not a 5522 packet")]
226    Not5522,
227    /// Metadata has no `type=` pair.
228    #[error("5522 packet without type")]
229    MissingType,
230    /// `type=` is not `read`.
231    #[error("unexpected 5522 type: {0}")]
232    UnexpectedType(CompactString),
233    /// Metadata has no `status=` pair.
234    #[error("5522 packet without status")]
235    MissingStatus,
236    /// `DATA` packet without a `mime=` pair.
237    #[error("5522 data packet without mime")]
238    MissingMime,
239    /// `mime=` value is not valid base64 or not UTF-8.
240    #[error("5522 data packet with undecodable mime")]
241    InvalidMime,
242    /// Packet exceeds [`MAX_PACKET_BYTES`].
243    #[error("5522 packet exceeds size limit")]
244    TooLarge,
245}
246
247/// Strict base64 predicate shared by parser and tests: standard alphabet,
248/// correct padding shape, nothing silently discarded (spec section on
249/// payload encoding forbids dropping whitespace or bad characters).
250fn is_strict_base64(payload: &str) -> bool {
251    !payload.is_empty()
252        && payload.len().is_multiple_of(4)
253        && payload
254            .bytes()
255            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
256}
257
258/// Parse one 5522 reply packet with OSC framing already stripped.
259///
260/// `packet` is the inner `5522;metadata;payload` content: the fork parser
261/// owns framing (terminator scanning, the 16 KiB packet cap) and hands the
262/// session exactly one complete packet per event. Unknown metadata keys are
263/// ignored for forward compatibility; unknown `status=` values become
264/// [`ReadErrorCode::Unknown`] so the read fails instead of hanging.
265///
266/// # Errors
267///
268/// Returns [`PacketError`] when the packet is malformed (see variants).
269pub fn parse_read_packet(packet: &str) -> Result<ReadPacket, PacketError> {
270    // Defense in depth with the fork's framing cap: never parse an
271    // over-sized packet even if a future event source skips the check.
272    if packet.len() > MAX_PACKET_BYTES {
273        return Err(PacketError::TooLarge);
274    }
275    let Some(inner) = packet.strip_prefix("5522;") else {
276        return Err(PacketError::Not5522);
277    };
278    // Metadata and payload split on the FIRST `;`: base64 never contains it.
279    let (metadata, payload) = inner.split_once(';').unwrap_or((inner, ""));
280
281    let mut type_seen = false;
282    let mut status_raw: Option<&str> = None;
283    let mut mime_raw: Option<&str> = None;
284    let mut id_raw: Option<&str> = None;
285    for pair in metadata.split(':') {
286        let Some((key, value)) = pair.split_once('=') else {
287            continue;
288        };
289        match key {
290            "type" => {
291                if value != "read" {
292                    return Err(PacketError::UnexpectedType(CompactString::new(value)));
293                }
294                type_seen = true;
295            }
296            "status" => status_raw = Some(value),
297            "mime" => mime_raw = Some(value),
298            "id" => id_raw = Some(value),
299            _ => {}
300        }
301    }
302    if !type_seen {
303        return Err(PacketError::MissingType);
304    }
305    let Some(status_raw) = status_raw else {
306        return Err(PacketError::MissingStatus);
307    };
308
309    let status = match status_raw {
310        "OK" => ReadStatus::Ok,
311        "DATA" => ReadStatus::Data,
312        "DONE" => ReadStatus::Done,
313        other => ReadStatus::Error(ReadErrorCode::from_status(other)),
314    };
315
316    let mime = if matches!(status, ReadStatus::Data) {
317        let Some(mime_raw) = mime_raw else {
318            return Err(PacketError::MissingMime);
319        };
320        let mime = BASE64_ENGINE
321            .decode(mime_raw)
322            .ok()
323            .and_then(|bytes| String::from_utf8(bytes).ok())
324            .map(CompactString::new)
325            .ok_or(PacketError::InvalidMime)?;
326        Some(mime)
327    } else {
328        None
329    };
330
331    let is_data = matches!(status, ReadStatus::Data);
332    Ok(ReadPacket {
333        status,
334        mime,
335        data_b64: if is_data { payload.to_string() } else { String::new() },
336        id: id_raw.map(CompactString::new),
337    })
338}
339
340/// One fully assembled MIME payload from a 5522 read.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct MimeData {
343    /// Decoded MIME type, e.g. `image/png`.
344    pub mime: CompactString,
345    /// Concatenated decoded bytes of all DATA chunks for this type.
346    pub decoded_bytes: Vec<u8>,
347}
348
349/// Outcome of feeding one packet to [`ReadAssembler::push`].
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum AssembleOutcome {
352    /// More packets needed.
353    NeedMore,
354    /// `DONE` seen; all MIME payloads in transmission order.
355    Complete(Vec<MimeData>),
356}
357
358/// A 5522 read failed while assembling packets.
359#[derive(Debug, Clone, PartialEq, Eq, Error)]
360pub enum AssembleError {
361    /// Terminal aborted the read with an error status.
362    #[error("clipboard read failed: {0}")]
363    Terminal(CompactString),
364    /// Reply id differs from the request id.
365    #[error("clipboard reply id mismatch")]
366    IdMismatch,
367    /// Packet arrived out of protocol order (DATA before OK, second OK,
368    /// packet after DONE).
369    #[error("clipboard reply out of order")]
370    OutOfOrder,
371    /// Total decoded bytes exceeded the assembler cap.
372    #[error("clipboard data exceeds size limit")]
373    TooLarge,
374    /// A DATA chunk is not strict base64 (standard alphabet, padded shape,
375    /// nothing silently discarded).
376    #[error("clipboard chunk is not valid base64")]
377    InvalidBase64,
378}
379
380impl AssembleError {
381    fn terminal(code: &ReadErrorCode) -> Self {
382        Self::Terminal(code.name())
383    }
384}
385
386/// Incremental assembler for one 5522 read: `OK (DATA*) DONE`.
387///
388/// DATA chunks for one MIME type arrive sequentially, then the next type's
389/// chunks; each chunk is individually padded base64 of at most 4096 decoded
390/// bytes, so chunks decode independently and decoded bytes concatenate.
391/// A single assembler handles exactly one read and is discarded after
392/// [`AssembleOutcome::Complete`] or any error.
393#[derive(Debug)]
394pub struct ReadAssembler {
395    expected_id: String,
396    max_bytes: usize,
397    started: bool,
398    finished: bool,
399    current_mime: Option<CompactString>,
400    current_bytes: Vec<u8>,
401    completed: Vec<MimeData>,
402    total_bytes: usize,
403}
404
405impl ReadAssembler {
406    /// New assembler capped at [`MAX_READ_BYTES`] total decoded bytes.
407    #[must_use]
408    pub fn new(expected_id: &str) -> Self {
409        Self::with_max_bytes(expected_id, MAX_READ_BYTES)
410    }
411
412    /// New assembler with an explicit decoded-bytes cap (tests use a small cap
413    /// instead of allocating megabytes).
414    #[must_use]
415    pub fn with_max_bytes(expected_id: &str, max_bytes: usize) -> Self {
416        Self {
417            expected_id: expected_id.to_string(),
418            max_bytes,
419            started: false,
420            finished: false,
421            current_mime: None,
422            current_bytes: Vec::new(),
423            completed: Vec::new(),
424            total_bytes: 0,
425        }
426    }
427
428    fn check_id(&self, packet: &ReadPacket) -> Result<(), AssembleError> {
429        // Terminals must echo the id, but an absent id is tolerated for
430        // interop (single-flight reads make cross-talk unlikely). A
431        // PRESENT but different id is a hard failure.
432        if let Some(id) = packet.id.as_deref()
433            && id != self.expected_id
434        {
435            return Err(AssembleError::IdMismatch);
436        }
437        Ok(())
438    }
439
440    fn flush_current(&mut self) {
441        if let Some(mime) = self.current_mime.take() {
442            let decoded_bytes = std::mem::take(&mut self.current_bytes);
443            self.completed.push(MimeData { mime, decoded_bytes });
444        }
445    }
446
447    /// Feed one parsed packet; returns the read outcome.
448    ///
449    /// # Errors
450    ///
451    /// Returns [`AssembleError`] on id mismatch, out-of-order packets,
452    /// terminal error statuses, oversized data, or invalid base64.
453    pub fn push(&mut self, packet: &ReadPacket) -> Result<AssembleOutcome, AssembleError> {
454        self.check_id(packet)?;
455        if self.finished {
456            return Err(AssembleError::OutOfOrder);
457        }
458        match &packet.status {
459            ReadStatus::Ok => {
460                if self.started {
461                    return Err(AssembleError::OutOfOrder);
462                }
463                self.started = true;
464                Ok(AssembleOutcome::NeedMore)
465            }
466            ReadStatus::Data => {
467                if !self.started {
468                    return Err(AssembleError::OutOfOrder);
469                }
470                let Some(mime) = packet.mime.as_ref() else {
471                    return Err(AssembleError::OutOfOrder);
472                };
473                if !packet.data_b64.is_empty() && !is_strict_base64(&packet.data_b64) {
474                    return Err(AssembleError::InvalidBase64);
475                }
476                let decoded = BASE64_ENGINE
477                    .decode(packet.data_b64.as_bytes())
478                    .ok()
479                    .ok_or(AssembleError::InvalidBase64)?;
480                self.total_bytes = self.total_bytes.saturating_add(decoded.len());
481                if self.total_bytes > self.max_bytes {
482                    return Err(AssembleError::TooLarge);
483                }
484                if self.current_mime.as_ref() != Some(mime) {
485                    self.flush_current();
486                    self.current_mime = Some(mime.clone());
487                }
488                self.current_bytes.extend_from_slice(&decoded);
489                Ok(AssembleOutcome::NeedMore)
490            }
491            ReadStatus::Done => {
492                if !self.started {
493                    return Err(AssembleError::OutOfOrder);
494                }
495                self.flush_current();
496                self.finished = true;
497                Ok(AssembleOutcome::Complete(std::mem::take(&mut self.completed)))
498            }
499            ReadStatus::Error(code) => Err(AssembleError::terminal(code)),
500        }
501    }
502}
503
504const HINT_FALSE: u8 = 0;
505const HINT_TRUE: u8 = 1;
506const HINT_UNSET: u8 = 2;
507
508static CLIPBOARD_5522_HINT: AtomicU8 = AtomicU8::new(HINT_UNSET);
509
510/// Cache the startup probe's 5522 verdict (`None` clears the hint).
511///
512/// Populated once by the terminal probe in `vtcode-core`, which folds a
513/// DECRQM 5522 query into its existing OSC batch; `None` (no report) leaves
514/// the hint unset and reads as unsupported.
515pub fn set_clipboard_5522_hint(value: Option<bool>) {
516    let encoded = match value {
517        Some(true) => HINT_TRUE,
518        Some(false) => HINT_FALSE,
519        None => HINT_UNSET,
520    };
521    CLIPBOARD_5522_HINT.store(encoded, Ordering::Relaxed);
522}
523
524/// Cached startup-probe verdict, if the probe produced one.
525#[must_use]
526pub fn clipboard_5522_hint() -> Option<bool> {
527    match CLIPBOARD_5522_HINT.load(Ordering::Relaxed) {
528        HINT_TRUE => Some(true),
529        HINT_FALSE => Some(false),
530        _ => None,
531    }
532}
533
534/// Whether the session may attempt 5522 clipboard reads.
535///
536/// Resolution order: `VTCODE_CLIPBOARD_5522` env override (canonical
537/// tri-state values), then the startup-probe hint, defaulting to `false`.
538/// Unknown terminals fail closed — no 5522 traffic without evidence.
539#[must_use]
540pub fn clipboard_5522_available() -> bool {
541    if let Some(override_value) = std::env::var("VTCODE_CLIPBOARD_5522")
542        .ok()
543        .and_then(|value| parse_bool_env_value(&value))
544    {
545        return override_value;
546    }
547    clipboard_5522_hint().unwrap_or(false)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn options(id: &str) -> ReadOptions<'_> {
555        ReadOptions { id, password: None, name: None, primary: false }
556    }
557
558    #[test]
559    fn sanitize_request_id_keeps_spec_charset_only() {
560        assert_eq!(sanitize_request_id("abcXYZ019-_+."), "abcXYZ019-_+.");
561        assert_eq!(sanitize_request_id("a b;c=d:e/f\\g"), "abcdefg");
562        assert_eq!(sanitize_request_id("héllo→"), "hllo");
563        assert_eq!(sanitize_request_id(""), "");
564    }
565
566    #[test]
567    fn generated_request_id_is_charset_clean() {
568        for _ in 0..32 {
569            let id = generate_request_id();
570            assert_eq!(id.len(), 16);
571            assert_eq!(sanitize_request_id(&id), id);
572        }
573    }
574
575    #[test]
576    fn mime_list_request_is_byte_exact() {
577        let request = mime_list_request(&options("test-id_1.2+x"));
578        assert_eq!(request, "\x1b]5522;type=read:id=test-id_1.2+x;Lg==\x1b\\");
579    }
580
581    #[test]
582    fn read_request_is_byte_exact_with_all_options() {
583        let request = read_request(
584            &["text/plain", "image/png"],
585            &ReadOptions {
586                id: "a b",
587                password: Some("p"),
588                name: Some("n"),
589                primary: true,
590            },
591        );
592        assert_eq!(
593            request,
594            "\x1b]5522;type=read:id=ab:pw=cA==:name=bg==:loc=primary;dGV4dC9wbGFpbiBpbWFnZS9wbmc=\x1b\\"
595        );
596    }
597
598    #[test]
599    fn password_without_name_is_dropped() {
600        let with_name = read_request(
601            &["a"],
602            &ReadOptions {
603                id: "i",
604                password: Some("p"),
605                name: Some("n"),
606                primary: false,
607            },
608        );
609        let without_name = read_request(
610            &["a"],
611            &ReadOptions {
612                id: "i",
613                password: Some("p"),
614                name: None,
615                primary: false,
616            },
617        );
618        assert!(with_name.contains(":pw=cA==:name=bg==;"), "{with_name:?}");
619        assert_eq!(without_name, "\x1b]5522;type=read:id=i;YQ==\x1b\\");
620    }
621
622    #[test]
623    fn primary_flag_toggles_loc_pair() {
624        let primary = read_request(&["a"], &ReadOptions { id: "i", password: None, name: None, primary: true });
625        let clipboard = read_request(&["a"], &options("i"));
626        assert!(primary.contains(":loc=primary;"), "{primary:?}");
627        assert!(!clipboard.contains("loc="), "{clipboard:?}");
628    }
629
630    #[test]
631    fn decrpm_reports_supported_for_set_modes() {
632        for payload in ["\x1b[?5522;1$y", "\x1b[?5522;2$y", "\x1b[?5522;3$y"] {
633            assert_eq!(parse_decrpm_5522(payload.as_bytes()), Some(true), "{payload:?}");
634        }
635    }
636
637    #[test]
638    fn decrpm_reports_unsupported_for_unset_modes() {
639        for payload in ["\x1b[?5522;0$y", "\x1b[?5522;4$y"] {
640            assert_eq!(parse_decrpm_5522(payload.as_bytes()), Some(false), "{payload:?}");
641        }
642    }
643
644    #[test]
645    fn decrpm_ignores_absent_and_malformed_reports() {
646        assert_eq!(parse_decrpm_5522(b""), None);
647        assert_eq!(parse_decrpm_5522(b"\x1b[?62;22;52c"), None);
648        assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1$n"), None);
649        assert_eq!(parse_decrpm_5522(b"\x1b[?5522;12$y"), None);
650        assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1$z"), None);
651        assert_eq!(parse_decrpm_5522(b"\x1b[?5522;1"), None);
652    }
653
654    #[test]
655    fn decrpm_parses_inside_mixed_probe_buffer_and_last_wins() {
656        let buffer = "\x1b]10;rgb:dddd/dddd/dddd\x1b\\\x1b[?997;1n\x1b[?5522;0$y\x1b[?62;4c";
657        assert_eq!(parse_decrpm_5522(buffer.as_bytes()), Some(false));
658        let flipped = "\x1b[?5522;0$y\x1b[?62;4c\x1b[?5522;1$y";
659        assert_eq!(parse_decrpm_5522(flipped.as_bytes()), Some(true));
660    }
661
662    #[test]
663    fn packet_parses_ok_done_and_error_shapes() {
664        let ok = parse_read_packet("5522;type=read:status=OK:id=abc").expect("ok packet");
665        assert_eq!(ok.status, ReadStatus::Ok);
666        assert_eq!(ok.id.as_deref(), Some("abc"));
667        assert!(ok.mime.is_none());
668
669        let done = parse_read_packet("5522;type=read:status=DONE").expect("done packet");
670        assert_eq!(done.status, ReadStatus::Done);
671
672        let error = parse_read_packet("5522;type=read:status=EPERM:id=abc").expect("error packet");
673        assert_eq!(error.status, ReadStatus::Error(ReadErrorCode::Perm));
674
675        let unknown = parse_read_packet("5522;type=read:status=EWEIRD").expect("unknown status");
676        assert!(matches!(unknown.status, ReadStatus::Error(ReadErrorCode::Unknown(_))));
677    }
678
679    #[test]
680    fn packet_parses_data_with_decoded_mime() {
681        let packet = parse_read_packet("5522;type=read:status=DATA:mime=aW1hZ2UvcG5n;YWI=").expect("data packet");
682        assert_eq!(packet.status, ReadStatus::Data);
683        assert_eq!(packet.mime.as_deref(), Some("image/png"));
684        assert_eq!(packet.data_b64, "YWI=");
685    }
686
687    #[test]
688    fn packet_rejects_malformed_shapes() {
689        assert_eq!(parse_read_packet("52;c;Zm9v"), Err(PacketError::Not5522));
690        assert_eq!(parse_read_packet("5522;status=OK"), Err(PacketError::MissingType));
691        assert!(matches!(parse_read_packet("5522;type=write:status=DONE"), Err(PacketError::UnexpectedType(_))));
692        assert_eq!(parse_read_packet("5522;type=read"), Err(PacketError::MissingStatus));
693        assert_eq!(parse_read_packet("5522;type=read:status=DATA;YWI="), Err(PacketError::MissingMime));
694        assert_eq!(parse_read_packet("5522;type=read:status=DATA:mime=!!!;YWI="), Err(PacketError::InvalidMime));
695    }
696
697    #[test]
698    fn packet_rejects_oversized_payload_at_cap_boundary() {
699        let oversized = format!("5522;type=read:status=DATA:mime=YQ==;{}", "A".repeat(MAX_PACKET_BYTES));
700        assert_eq!(parse_read_packet(&oversized), Err(PacketError::TooLarge));
701
702        // Exactly MAX_PACKET_BYTES still parses (unknown keys are ignored).
703        let head = "5522;type=read:status=OK:pad=";
704        let exact = format!("{head}{}", "A".repeat(MAX_PACKET_BYTES - head.len()));
705        assert_eq!(exact.len(), MAX_PACKET_BYTES);
706        assert!(matches!(parse_read_packet(&exact), Ok(ReadPacket { status: ReadStatus::Ok, .. })));
707    }
708
709    #[test]
710    fn packet_ignores_unknown_metadata_keys() {
711        let packet = parse_read_packet("5522;type=read:status=OK:future=1:id=z").expect("forward compat");
712        assert_eq!(packet.status, ReadStatus::Ok);
713        assert_eq!(packet.id.as_deref(), Some("z"));
714    }
715
716    #[test]
717    fn strict_base64_predicate_matches_spec() {
718        assert!(is_strict_base64("YWI="));
719        assert!(is_strict_base64("aW1hZ2UvcG5n"));
720        assert!(!is_strict_base64(""));
721        assert!(!is_strict_base64("YWI"));
722        assert!(!is_strict_base64("YWI=="));
723        assert!(!is_strict_base64("YW I="));
724        assert!(!is_strict_base64("YWI=\n"));
725        assert!(!is_strict_base64("YW-I="));
726    }
727
728    fn data_packet(mime_b64: &str, chunk_b64: &str) -> ReadPacket {
729        parse_read_packet(&format!("5522;type=read:status=DATA:mime={mime_b64};{chunk_b64}")).expect("data packet")
730    }
731
732    #[test]
733    fn assembler_concatenates_chunks_per_mime_in_order() {
734        let mut assembler = ReadAssembler::new("id-1");
735        let ok = parse_read_packet("5522;type=read:status=OK:id=id-1").expect("ok");
736        assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
737
738        let png = "aW1hZ2UvcG5n";
739        let text = "dGV4dC9wbGFpbg==";
740        assert_eq!(assembler.push(&data_packet(png, "YWI=")), Ok(AssembleOutcome::NeedMore));
741        assert_eq!(assembler.push(&data_packet(png, "Y2Q=")), Ok(AssembleOutcome::NeedMore));
742        assert_eq!(assembler.push(&data_packet(text, "eA==")), Ok(AssembleOutcome::NeedMore));
743
744        let done = parse_read_packet("5522;type=read:status=DONE:id=id-1").expect("done");
745        let outcome = assembler.push(&done).expect("complete");
746        assert_eq!(
747            outcome,
748            AssembleOutcome::Complete(vec![
749                MimeData {
750                    mime: CompactString::new("image/png"),
751                    decoded_bytes: b"abcd".to_vec()
752                },
753                MimeData {
754                    mime: CompactString::new("text/plain"),
755                    decoded_bytes: b"x".to_vec()
756                },
757            ])
758        );
759    }
760
761    #[test]
762    fn assembler_rejects_out_of_order_packets() {
763        let mut assembler = ReadAssembler::new("id-1");
764        // DATA before OK.
765        assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Err(AssembleError::OutOfOrder));
766
767        let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
768        assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
769        // Second OK.
770        assert_eq!(assembler.push(&ok), Err(AssembleError::OutOfOrder));
771
772        let done = parse_read_packet("5522;type=read:status=DONE").expect("done");
773        assert!(matches!(assembler.push(&done), Ok(AssembleOutcome::Complete(_))));
774        // Packet after DONE.
775        assert_eq!(assembler.push(&done), Err(AssembleError::OutOfOrder));
776    }
777
778    #[test]
779    fn assembler_rejects_id_mismatch_but_tolerates_absent_id() {
780        let mut assembler = ReadAssembler::new("id-1");
781        let foreign = parse_read_packet("5522;type=read:status=OK:id=id-2").expect("foreign ok");
782        assert_eq!(assembler.push(&foreign), Err(AssembleError::IdMismatch));
783
784        let anonymous = parse_read_packet("5522;type=read:status=OK").expect("anonymous ok");
785        assert_eq!(assembler.push(&anonymous), Ok(AssembleOutcome::NeedMore));
786    }
787
788    #[test]
789    fn assembler_maps_terminal_errors_and_invalid_chunks() {
790        let mut assembler = ReadAssembler::new("id-1");
791        let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
792        assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
793
794        let busy = parse_read_packet("5522;type=read:status=EBUSY").expect("busy");
795        assert_eq!(assembler.push(&busy), Err(AssembleError::Terminal(CompactString::const_new("busy"))));
796
797        let mut assembler = ReadAssembler::new("id-1");
798        assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
799        let mut bad = data_packet("YQ==", "YWI=");
800        bad.data_b64 = "YW I=".to_string();
801        assert_eq!(assembler.push(&bad), Err(AssembleError::InvalidBase64));
802    }
803
804    #[test]
805    fn assembler_enforces_decoded_size_cap() {
806        let mut assembler = ReadAssembler::with_max_bytes("id-1", 3);
807        let ok = parse_read_packet("5522;type=read:status=OK").expect("ok");
808        assert_eq!(assembler.push(&ok), Ok(AssembleOutcome::NeedMore));
809        // "YWI=" decodes to 2 bytes: fits; the second chunk overflows.
810        assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Ok(AssembleOutcome::NeedMore));
811        assert_eq!(assembler.push(&data_packet("YQ==", "YWI=")), Err(AssembleError::TooLarge));
812    }
813
814    #[test]
815    fn hint_roundtrips_and_defaults_to_unset() {
816        set_clipboard_5522_hint(None);
817        assert_eq!(clipboard_5522_hint(), None);
818        set_clipboard_5522_hint(Some(true));
819        assert_eq!(clipboard_5522_hint(), Some(true));
820        set_clipboard_5522_hint(Some(false));
821        assert_eq!(clipboard_5522_hint(), Some(false));
822        set_clipboard_5522_hint(None);
823    }
824
825    #[test]
826    fn available_resolves_env_over_hint_with_fail_closed_default() {
827        let guard = crate::env_lock::lock();
828        let previous = std::env::var_os("VTCODE_CLIPBOARD_5522");
829        set_clipboard_5522_hint(None);
830
831        guard.remove_var("VTCODE_CLIPBOARD_5522");
832        assert!(!clipboard_5522_available());
833
834        set_clipboard_5522_hint(Some(true));
835        assert!(clipboard_5522_available());
836        guard.set_var("VTCODE_CLIPBOARD_5522", "off");
837        assert!(!clipboard_5522_available());
838
839        set_clipboard_5522_hint(Some(false));
840        guard.set_var("VTCODE_CLIPBOARD_5522", "yes");
841        assert!(clipboard_5522_available());
842
843        guard.restore_var("VTCODE_CLIPBOARD_5522", previous);
844        set_clipboard_5522_hint(None);
845    }
846}