1use 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
33pub const DECRQM_5522_QUERY: &str = "\x1b[?5522$p";
35
36pub const DECRPM_5522_PREFIX: &str = "\x1b[?5522;";
38
39pub const MAX_PACKET_BYTES: usize = 16 * 1024;
45
46pub const MAX_READ_BYTES: usize = 16 * 1024 * 1024;
51
52const MIME_LIST_PROBE_B64: &str = "Lg==";
54
55fn is_request_id_byte(byte: u8) -> bool {
57 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'.')
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct ReadOptions<'a> {
63 pub id: &'a str,
65 pub password: Option<&'a str>,
68 pub name: Option<&'a str>,
70 pub primary: bool,
72}
73
74#[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#[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 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#[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#[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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum ReadErrorCode {
161 NoSys,
164 Perm,
166 Busy,
168 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 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#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum ReadStatus {
197 Ok,
199 Data,
201 Done,
203 Error(ReadErrorCode),
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct ReadPacket {
210 pub status: ReadStatus,
212 pub mime: Option<CompactString>,
214 pub data_b64: String,
217 pub id: Option<CompactString>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Error)]
223pub enum PacketError {
224 #[error("not a 5522 packet")]
226 Not5522,
227 #[error("5522 packet without type")]
229 MissingType,
230 #[error("unexpected 5522 type: {0}")]
232 UnexpectedType(CompactString),
233 #[error("5522 packet without status")]
235 MissingStatus,
236 #[error("5522 data packet without mime")]
238 MissingMime,
239 #[error("5522 data packet with undecodable mime")]
241 InvalidMime,
242 #[error("5522 packet exceeds size limit")]
244 TooLarge,
245}
246
247fn 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
258pub fn parse_read_packet(packet: &str) -> Result<ReadPacket, PacketError> {
270 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 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#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct MimeData {
343 pub mime: CompactString,
345 pub decoded_bytes: Vec<u8>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum AssembleOutcome {
352 NeedMore,
354 Complete(Vec<MimeData>),
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Error)]
360pub enum AssembleError {
361 #[error("clipboard read failed: {0}")]
363 Terminal(CompactString),
364 #[error("clipboard reply id mismatch")]
366 IdMismatch,
367 #[error("clipboard reply out of order")]
370 OutOfOrder,
371 #[error("clipboard data exceeds size limit")]
373 TooLarge,
374 #[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#[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 #[must_use]
408 pub fn new(expected_id: &str) -> Self {
409 Self::with_max_bytes(expected_id, MAX_READ_BYTES)
410 }
411
412 #[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 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 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
510pub 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#[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#[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 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 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 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 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 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}