1use std::hash::{Hash, Hasher};
13use std::path::PathBuf;
14use std::time::Duration;
15
16use crate::{NativeError, Result};
17
18pub const MAX_IMAGE_SIDE: u32 = 16_384;
20pub const MAX_CUSTOM_BYTES: usize = 256 * 1024 * 1024;
22pub const MAX_TEXT_BYTES: usize = 64 * 1024 * 1024;
24pub const MAX_HTML_BYTES: usize = 16 * 1024 * 1024;
26pub const MAX_PNG_BYTES: usize = 64 * 1024 * 1024;
28pub const MAX_FILES: usize = 4096;
30pub const MAX_SECRET_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
32
33const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Image {
38 pub width: u32,
39 pub height: u32,
40 pub rgba: Vec<u8>,
41}
42
43impl Image {
44 pub fn validate(&self) -> Result<()> {
46 let expected = (self.width as usize).checked_mul(self.height as usize).and_then(|n| n.checked_mul(4));
47 if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE {
48 return Err(NativeError::Invalid(format!("an image of {}×{} pixels can't be copied", self.width, self.height)));
49 }
50 if expected != Some(self.rgba.len()) {
51 return Err(NativeError::Invalid("the pixel data does not match the image size".into()));
52 }
53 Ok(())
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Rich {
63 pub text: String,
64 pub html: Option<String>,
65 pub png: Option<Vec<u8>>,
67 pub files: Vec<PathBuf>,
69}
70
71impl Rich {
72 pub fn text(text: impl Into<String>) -> Self {
74 Rich { text: text.into(), ..Default::default() }
75 }
76
77 pub fn is_empty(&self) -> bool {
78 self.text.is_empty() && self.html.is_none() && self.png.is_none() && self.files.is_empty()
79 }
80
81 pub fn validate(&self) -> Result<()> {
83 if self.is_empty() {
84 return Err(NativeError::Invalid("there is nothing to copy".into()));
85 }
86 if self.text.len() > MAX_TEXT_BYTES {
87 return Err(NativeError::Invalid(format!("the text is larger than {MAX_TEXT_BYTES} bytes")));
88 }
89 if self.html.as_ref().is_some_and(|h| h.len() > MAX_HTML_BYTES) {
90 return Err(NativeError::Invalid(format!("the HTML is larger than {MAX_HTML_BYTES} bytes")));
91 }
92 if let Some(png) = &self.png {
93 if png.len() > MAX_PNG_BYTES {
94 return Err(NativeError::Invalid(format!("the image is larger than {MAX_PNG_BYTES} bytes")));
95 }
96 if !png.starts_with(PNG_SIGNATURE) {
97 return Err(NativeError::Invalid("the image is not a PNG".into()));
98 }
99 }
100 if self.files.len() > MAX_FILES {
101 return Err(NativeError::Invalid(format!("copy at most {MAX_FILES} files")));
102 }
103 if self.files.iter().any(|f| !f.is_absolute()) {
104 return Err(NativeError::Invalid("file paths must be absolute".into()));
105 }
106 Ok(())
107 }
108
109 fn inbound(mut self) -> Self {
112 if self.text.len() > MAX_TEXT_BYTES {
113 self.text.clear();
114 }
115 self.html = self.html.filter(|h| h.len() <= MAX_HTML_BYTES).map(|h| sanitize_html(&h)).filter(|h| !h.trim().is_empty());
116 self.png = self.png.filter(|p| p.len() <= MAX_PNG_BYTES && p.starts_with(PNG_SIGNATURE));
117 self.files.retain(|f| f.is_absolute());
118 self.files.truncate(MAX_FILES);
119 self
120 }
121}
122
123pub fn valid_type(name: &str) -> bool {
126 !name.is_empty() && name.len() <= 128 && name.chars().all(|c| c.is_ascii_alphanumeric() || "._-+/".contains(c))
127}
128
129pub trait Backend {
133 fn set_rich(&mut self, content: &Rich, secret: bool) -> Result<()>;
135 fn rich(&mut self) -> Result<Rich>;
137 fn clear(&mut self) -> Result<()>;
138 fn change_count(&mut self) -> Option<i64>;
140}
141
142pub fn write(backend: &mut dyn Backend, content: &Rich) -> Result<()> {
144 content.validate()?;
145 backend.set_rich(content, false)
146}
147
148pub fn read(backend: &mut dyn Backend) -> Result<Rich> {
150 backend.rich().map(Rich::inbound)
151}
152
153#[derive(Debug, Default)]
155pub struct MemoryClipboard {
156 pub content: Rich,
157 pub secret: bool,
159 changes: i64,
160 pub counts_changes: bool,
162}
163
164impl Backend for MemoryClipboard {
165 fn set_rich(&mut self, content: &Rich, secret: bool) -> Result<()> {
166 self.content = content.clone();
167 self.secret = secret;
168 self.changes += 1;
169 Ok(())
170 }
171 fn rich(&mut self) -> Result<Rich> {
172 Ok(self.content.clone())
173 }
174 fn clear(&mut self) -> Result<()> {
175 self.content = Rich::default();
176 self.secret = false;
177 self.changes += 1;
178 Ok(())
179 }
180 fn change_count(&mut self) -> Option<i64> {
181 self.counts_changes.then_some(self.changes)
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct SecretStamp {
191 change: Option<i64>,
192 hash: u64,
193}
194
195fn hash_text(text: &str) -> u64 {
196 let mut h = std::collections::hash_map::DefaultHasher::new();
197 text.hash(&mut h);
198 h.finish()
199}
200
201pub fn copy_secret(backend: &mut dyn Backend, text: &str) -> Result<SecretStamp> {
203 let content = Rich::text(text);
204 content.validate()?;
205 backend.set_rich(&content, true)?;
206 Ok(SecretStamp { change: backend.change_count(), hash: hash_text(text) })
207}
208
209pub fn clear_if_unchanged(backend: &mut dyn Backend, stamp: &SecretStamp) -> Result<bool> {
212 let ours = match (stamp.change, backend.change_count()) {
213 (Some(then), Some(now)) => then == now,
214 _ => {
215 let now = backend.rich()?;
216 now.html.is_none() && now.png.is_none() && now.files.is_empty() && hash_text(&now.text) == stamp.hash
217 }
218 };
219 if ours {
220 backend.clear()?;
221 }
222 Ok(ours)
223}
224
225pub fn set_secret(text: &str, clear_after: Duration) -> Result<()> {
230 if clear_after > MAX_SECRET_LIFETIME {
231 return Err(NativeError::Invalid("clear secrets within a day".into()));
232 }
233 let stamp = copy_secret(&mut System, text)?;
234 std::thread::Builder::new()
235 .name("ashlar-clipboard-secret".into())
236 .spawn(move || {
237 std::thread::sleep(clear_after);
238 if let Err(e) = clear_if_unchanged(&mut System, &stamp) {
239 log::warn!("the copied secret was not cleared: {e}");
240 }
241 })
242 .map(drop)
243 .map_err(|e| NativeError::Os(crate::os_text(e)))
244}
245
246const DROP_WITH_CONTENT: &[&str] = &[
250 "script", "style", "iframe", "object", "embed", "noscript", "noembed", "noframes", "template", "frame", "frameset", "applet", "svg", "math",
251 "xml",
252];
253const DROP_TAG: &[&str] = &[
255 "base", "link", "meta", "form", "input", "button", "textarea", "select", "option", "param", "source", "track", "portal", "html", "head", "body",
256];
257const URL_ATTRS: &[&str] =
259 &["href", "src", "xlink:href", "action", "formaction", "background", "poster", "cite", "longdesc", "lowsrc", "dynsrc", "data", "ping"];
260
261pub fn sanitize_html(input: &str) -> String {
266 let mut end = input.len().min(MAX_HTML_BYTES);
267 while !input.is_char_boundary(end) {
268 end -= 1;
269 }
270 let input = &input[..end];
271 let mut out = String::with_capacity(input.len());
272 let mut i = 0;
273 while i < input.len() {
274 let rest = &input[i..];
275 let Some(lt) = rest.find('<') else {
276 out.push_str(rest);
277 break;
278 };
279 out.push_str(&rest[..lt]);
280 i += lt;
281 let rest = &input[i..];
282 if rest.starts_with("<!--") {
283 i += rest.find("-->").map_or(rest.len(), |e| e + 3);
284 continue;
285 }
286 if rest.starts_with("<!") || rest.starts_with("<?") {
287 i += rest.find('>').map_or(rest.len(), |e| e + 1);
288 continue;
289 }
290 let Some(tag) = Tag::parse(rest) else {
291 out.push_str("<");
292 i += 1;
293 continue;
294 };
295 i += tag.len;
296 if !tag.complete {
297 break;
298 }
299 let name = tag.name.as_str();
300 if DROP_WITH_CONTENT.contains(&name) {
301 if !tag.closing && !tag.self_closing {
302 i += closing_tag_end(&input[i..], name).unwrap_or(input.len() - i);
303 }
304 continue;
305 }
306 if DROP_TAG.contains(&name) {
307 continue;
308 }
309 out.push('<');
310 if tag.closing {
311 out.push('/');
312 }
313 out.push_str(name);
314 if !tag.closing {
315 for (attr, value) in &tag.attrs {
316 if !keep_attr(attr, value.as_deref()) {
317 continue;
318 }
319 out.push(' ');
320 out.push_str(attr);
321 if let Some(v) = value {
322 out.push_str("=\"");
323 out.push_str(&v.replace('"', """));
324 out.push('"');
325 }
326 }
327 if tag.self_closing {
328 out.push_str(" /");
329 }
330 }
331 out.push('>');
332 }
333 out
334}
335
336struct Tag {
338 name: String,
339 closing: bool,
340 self_closing: bool,
341 attrs: Vec<(String, Option<String>)>,
342 len: usize,
344 complete: bool,
346}
347
348impl Tag {
349 fn parse(s: &str) -> Option<Tag> {
351 let b = s.as_bytes();
352 let mut i = 1;
353 let closing = b.get(i) == Some(&b'/');
354 if closing {
355 i += 1;
356 }
357 if !b.get(i).is_some_and(u8::is_ascii_alphabetic) {
358 return None;
359 }
360 let start = i;
361 while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'-' || *c == b':') {
362 i += 1;
363 }
364 let name = s[start..i].to_ascii_lowercase();
365 let mut attrs = Vec::new();
366 let mut self_closing = false;
367 loop {
368 while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
369 i += 1;
370 }
371 match b.get(i) {
372 None => return Some(Tag { name, closing, self_closing, attrs, len: s.len(), complete: false }),
373 Some(b'>') => return Some(Tag { name, closing, self_closing, attrs, len: i + 1, complete: true }),
374 Some(b'/') => {
375 self_closing = true;
376 i += 1;
377 continue;
378 }
379 Some(_) => {}
380 }
381 self_closing = false;
382 let a = i;
383 while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !b"=>/".contains(c)) {
384 i += 1;
385 }
386 let attr = if i == a {
388 i += 1;
389 continue;
390 } else {
391 s[a..i].to_ascii_lowercase()
392 };
393 while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
394 i += 1;
395 }
396 let mut value = None;
397 if b.get(i) == Some(&b'=') {
398 i += 1;
399 while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
400 i += 1;
401 }
402 match b.get(i) {
403 Some(&q) if q == b'"' || q == b'\'' => {
404 let v = i + 1;
405 let Some(e) = s[v..].find(q as char) else {
406 return Some(Tag { name, closing, self_closing, attrs, len: s.len(), complete: false });
407 };
408 value = Some(s[v..v + e].to_string());
409 i = v + e + 1;
410 }
411 _ => {
412 let v = i;
413 while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
414 i += 1;
415 }
416 value = Some(s[v..i].to_string());
417 }
418 }
419 }
420 attrs.push((attr, value));
421 }
422 }
423}
424
425fn closing_tag_end(s: &str, name: &str) -> Option<usize> {
427 let mut from = 0;
428 while let Some(p) = s[from..].find("</") {
429 let at = from + p + 2;
430 let tail = &s.as_bytes()[at..];
431 let ends = tail.get(name.len()).is_none_or(|c| c.is_ascii_whitespace() || *c == b'>' || *c == b'/');
432 if tail.len() >= name.len() && tail[..name.len()].eq_ignore_ascii_case(name.as_bytes()) && ends {
433 return Some(s[at..].find('>').map_or(s.len(), |e| at + e + 1));
434 }
435 from = at;
436 }
437 None
438}
439
440fn keep_attr(name: &str, value: Option<&str>) -> bool {
441 if name.starts_with("on") || name == "srcdoc" || name == "srcset" || name == "is" {
442 return false;
443 }
444 if !name.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c)) {
445 return false;
446 }
447 let value = value.unwrap_or("");
448 if name == "style" {
449 let v: String = value.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase();
450 return !["expression", "javascript:", "url(", "behavior", "-moz-binding", "@import", "\\"].iter().any(|bad| v.contains(bad));
451 }
452 if URL_ATTRS.contains(&name) {
453 return safe_url(value, name == "src" || name == "poster");
454 }
455 true
456}
457
458fn safe_url(value: &str, image: bool) -> bool {
460 let v: String = value.chars().filter(|c| !c.is_ascii_whitespace() && !c.is_control()).collect::<String>().to_ascii_lowercase();
461 let head = &v[..v.find(['/', '?', '#']).unwrap_or(v.len())];
462 if head.contains('&') || head.contains('\\') || head.contains('%') {
464 return false;
465 }
466 let Some(colon) = head.find(':') else { return true };
467 match &head[..colon] {
468 "http" | "https" | "mailto" | "tel" => true,
469 "data" => image && v.starts_with("data:image/") && !v.starts_with("data:image/svg"),
470 _ => false,
471 }
472}
473
474#[derive(Debug, Default, Clone, Copy)]
478pub struct System;
479
480pub fn set_rich(content: &Rich) -> Result<()> {
482 write(&mut System, content)
483}
484
485pub fn rich() -> Result<Rich> {
488 read(&mut System)
489}
490
491#[cfg(not(target_arch = "wasm32"))]
492mod imp {
493 use std::borrow::Cow;
494
495 use super::*;
496
497 pub(super) fn map(e: arboard::Error) -> NativeError {
498 match e {
499 arboard::Error::ContentNotAvailable => NativeError::Os("the clipboard holds no such content".into()),
500 arboard::Error::ClipboardNotSupported => NativeError::Unsupported,
501 arboard::Error::ClipboardOccupied => NativeError::Os("another application is using the clipboard; try again".into()),
502 arboard::Error::ConversionFailure => NativeError::Os("the clipboard content could not be converted".into()),
503 arboard::Error::Unknown { description } => NativeError::Os(crate::os_text(description)),
505 _ => NativeError::Os("the clipboard failed".into()),
506 }
507 }
508
509 #[cfg(not(all(unix, not(target_os = "macos"))))]
512 fn with<T: Send + 'static>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T> + Send + 'static) -> Result<T> {
513 crate::guarded("the clipboard", || {
514 let mut c = arboard::Clipboard::new().map_err(map)?;
515 f(&mut c)
516 })
517 }
518
519 #[cfg(all(unix, not(target_os = "macos")))]
520 fn with<T: Send + 'static>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T> + Send + 'static) -> Result<T> {
521 worker::run(f)
522 }
523
524 #[cfg(all(unix, not(target_os = "macos")))]
527 mod worker {
528 use std::sync::mpsc::{Sender, channel, sync_channel};
529 use std::sync::{Mutex, PoisonError};
530
531 use super::*;
532
533 type Job = Box<dyn FnOnce(Result<&mut arboard::Clipboard>) + Send>;
534
535 const WAIT: Duration = Duration::from_secs(5);
537
538 static WORKER: Mutex<Option<Sender<Job>>> = Mutex::new(None);
539
540 fn spawn() -> Result<Sender<Job>> {
541 let (tx, rx) = channel::<Job>();
542 std::thread::Builder::new()
543 .name("ashlar-clipboard".into())
544 .spawn(move || {
545 let mut clipboard: Option<arboard::Clipboard> = None;
546 for job in rx {
547 if clipboard.is_none() {
548 match arboard::Clipboard::new() {
549 Ok(c) => clipboard = Some(c),
550 Err(e) => {
551 job(Err(map(e)));
552 continue;
553 }
554 }
555 }
556 match clipboard.as_mut() {
557 Some(c) => job(Ok(c)),
558 None => job(Err(NativeError::Unsupported)),
559 }
560 }
561 })
562 .map_err(|e| NativeError::Os(crate::os_text(e)))?;
563 Ok(tx)
564 }
565
566 pub(super) fn run<T: Send + 'static>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T> + Send + 'static) -> Result<T> {
567 let (rtx, rrx) = sync_channel(1);
568 let job: Job = Box::new(move |c| {
569 let r = c.and_then(|c| crate::guarded("the clipboard", || f(c)));
570 let _ = rtx.send(r);
571 });
572 let mut slot = WORKER.lock().unwrap_or_else(PoisonError::into_inner);
573 let tx = match slot.as_ref() {
574 Some(tx) => tx.clone(),
575 None => {
576 let tx = spawn()?;
577 *slot = Some(tx.clone());
578 tx
579 }
580 };
581 if let Err(e) = tx.send(job) {
582 let tx = spawn()?;
584 *slot = Some(tx.clone());
585 tx.send(e.0).map_err(|_| NativeError::Os("the clipboard worker stopped".into()))?;
586 }
587 drop(slot);
588 rrx.recv_timeout(WAIT).map_err(|_| NativeError::Os("the clipboard did not answer in time".into()))?
589 }
590 }
591
592 fn absent<T>(r: std::result::Result<T, arboard::Error>) -> Result<Option<T>> {
593 match r {
594 Ok(v) => Ok(Some(v)),
595 Err(arboard::Error::ContentNotAvailable | arboard::Error::ConversionFailure) => Ok(None),
596 Err(e) => Err(map(e)),
597 }
598 }
599
600 pub fn text() -> Result<Option<String>> {
602 with(|c| absent(c.get_text()))
603 }
604
605 pub fn set_text(text: &str) -> Result<()> {
606 if text.len() > MAX_TEXT_BYTES {
607 return Err(NativeError::Invalid(format!("the text is larger than {MAX_TEXT_BYTES} bytes")));
608 }
609 let text = text.to_string();
610 with(move |c| c.set_text(text).map_err(map))
611 }
612
613 fn to_image(img: arboard::ImageData<'_>) -> Result<Image> {
614 let (Ok(width), Ok(height)) = (u32::try_from(img.width), u32::try_from(img.height)) else {
615 return Err(NativeError::Os("the clipboard image is too large".into()));
616 };
617 let image = Image { width, height, rgba: img.bytes.into_owned() };
618 image.validate()?;
619 Ok(image)
620 }
621
622 pub fn image() -> Result<Option<Image>> {
624 with(|c| absent(c.get_image())?.map(to_image).transpose())
625 }
626
627 pub fn set_image(image: &Image) -> Result<()> {
628 image.validate()?;
629 let image = image.clone();
630 with(move |c| {
631 let data = arboard::ImageData { width: image.width as usize, height: image.height as usize, bytes: Cow::Borrowed(&image.rgba) };
632 c.set_image(data).map_err(map)
633 })
634 }
635
636 pub fn clear() -> Result<()> {
637 with(|c| c.clear().map_err(map))
638 }
639
640 pub(super) fn png_encode(image: &Image) -> Result<Vec<u8>> {
641 let mut out = Vec::new();
642 let mut enc = png::Encoder::new(&mut out, image.width, image.height);
643 enc.set_color(png::ColorType::Rgba);
644 enc.set_depth(png::BitDepth::Eight);
645 let fail = |e: png::EncodingError| NativeError::Os(crate::os_text(e));
646 let mut w = enc.write_header().map_err(fail)?;
647 w.write_image_data(&image.rgba).map_err(fail)?;
648 w.finish().map_err(fail)?;
649 Ok(out)
650 }
651
652 #[cfg_attr(target_os = "macos", allow(dead_code))]
653 pub(super) fn png_decode(bytes: &[u8]) -> Result<Image> {
654 let bad = |e: png::DecodingError| NativeError::Invalid(format!("the PNG can't be read: {}", crate::os_text(e)));
655 let mut dec = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), png::Limits { bytes: MAX_PNG_BYTES * 4 });
656 dec.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
657 let mut reader = dec.read_info().map_err(bad)?;
658 let size = reader.output_buffer_size().ok_or_else(|| NativeError::Invalid("the PNG is too large".into()))?;
659 let mut buf = vec![0; size];
660 let info = reader.next_frame(&mut buf).map_err(bad)?;
661 buf.truncate(info.buffer_size());
662 let rgba = match info.color_type {
663 png::ColorType::Rgba => buf,
664 png::ColorType::GrayscaleAlpha => buf.as_chunks::<2>().0.iter().flat_map(|&[g, a]| [g, g, g, a]).collect(),
665 _ => return Err(NativeError::Invalid("the PNG has an unsupported color type".into())),
666 };
667 let image = Image { width: info.width, height: info.height, rgba };
668 image.validate()?;
669 Ok(image)
670 }
671
672 #[cfg(not(target_os = "macos"))]
674 fn secret_set(set: arboard::Set<'_>) -> arboard::Set<'_> {
675 #[cfg(target_os = "windows")]
676 {
677 use arboard::SetExtWindows;
678 set.exclude_from_history().exclude_from_cloud().exclude_from_monitoring()
679 }
680 #[cfg(all(unix, not(target_os = "macos")))]
681 {
682 use arboard::SetExtLinux;
683 set.exclude_from_history()
684 }
685 #[cfg(not(any(target_os = "windows", unix)))]
686 set
687 }
688
689 impl Backend for System {
690 #[cfg(target_os = "macos")]
691 fn set_rich(&mut self, content: &Rich, secret: bool) -> Result<()> {
692 if !content.files.is_empty() {
693 let files = content.files.clone();
694 return with(move |c| c.set().file_list(&files).map_err(map));
695 }
696 crate::guarded("the clipboard", || mac::set(content, secret))
697 }
698
699 #[cfg(not(target_os = "macos"))]
700 fn set_rich(&mut self, content: &Rich, secret: bool) -> Result<()> {
701 let content = content.clone();
702 with(move |c| {
703 if !content.files.is_empty() {
704 return c.set().file_list(&content.files).map_err(map);
705 }
706 if let Some(html) = content.html {
707 let alt = (!content.text.is_empty()).then_some(content.text);
708 return c.set().html(html, alt).map_err(map);
709 }
710 if let Some(png) = content.png {
711 let image = png_decode(&png)?;
712 let data = arboard::ImageData { width: image.width as usize, height: image.height as usize, bytes: Cow::Owned(image.rgba) };
713 return c.set().image(data).map_err(map);
714 }
715 let set = c.set();
716 let set = if secret { secret_set(set) } else { set };
717 set.text(content.text).map_err(map)
718 })
719 }
720
721 fn rich(&mut self) -> Result<Rich> {
722 with(|c| {
723 let text = absent(c.get_text())?.unwrap_or_default();
724 let html = absent(c.get().html())?;
725 let png = match absent(c.get_image())? {
726 Some(img) => Some(png_encode(&to_image(img)?)?),
727 None => None,
728 };
729 let files = absent(c.get().file_list())?.unwrap_or_default();
730 Ok(Rich { text, html, png, files })
731 })
732 }
733
734 fn clear(&mut self) -> Result<()> {
735 clear()
736 }
737
738 fn change_count(&mut self) -> Option<i64> {
739 #[cfg(target_os = "macos")]
740 return Some(mac::change_count());
741 #[cfg(not(target_os = "macos"))]
742 None
743 }
744 }
745
746 #[cfg(target_os = "macos")]
747 mod mac {
748 use objc2_app_kit::NSPasteboard;
749 use objc2_foundation::{NSData, NSString};
750
751 use super::*;
752
753 const CONCEALED: &str = "org.nspasteboard.ConcealedType";
755
756 pub fn set(content: &Rich, secret: bool) -> Result<()> {
758 let pb = NSPasteboard::generalPasteboard();
759 pb.clearContents();
760 let put = |ty: &str, data: &[u8]| {
761 if pb.setData_forType(Some(&NSData::with_bytes(data)), &NSString::from_str(ty)) {
762 Ok(())
763 } else {
764 Err(NativeError::Os(format!("the pasteboard refused type `{ty}`")))
765 }
766 };
767 if !content.text.is_empty() {
768 put("public.utf8-plain-text", content.text.as_bytes())?;
769 }
770 if let Some(html) = &content.html {
771 put("public.html", html.as_bytes())?;
772 }
773 if let Some(png) = &content.png {
774 put("public.png", png)?;
775 }
776 if secret {
777 put(CONCEALED, b"")?;
778 }
779 Ok(())
780 }
781
782 pub fn change_count() -> i64 {
783 NSPasteboard::generalPasteboard().changeCount() as i64
784 }
785 }
786}
787
788#[cfg(target_arch = "wasm32")]
789mod imp {
790 use super::*;
791
792 pub fn text() -> Result<Option<String>> {
793 Err(NativeError::Unsupported)
794 }
795 pub fn set_text(_: &str) -> Result<()> {
796 Err(NativeError::Unsupported)
797 }
798 pub fn image() -> Result<Option<Image>> {
799 Err(NativeError::Unsupported)
800 }
801 pub fn set_image(_: &Image) -> Result<()> {
802 Err(NativeError::Unsupported)
803 }
804 pub fn clear() -> Result<()> {
805 Err(NativeError::Unsupported)
806 }
807
808 impl Backend for System {
809 fn set_rich(&mut self, _: &Rich, _: bool) -> Result<()> {
810 Err(NativeError::Unsupported)
811 }
812 fn rich(&mut self) -> Result<Rich> {
813 Err(NativeError::Unsupported)
814 }
815 fn clear(&mut self) -> Result<()> {
816 Err(NativeError::Unsupported)
817 }
818 fn change_count(&mut self) -> Option<i64> {
819 None
820 }
821 }
822}
823
824pub use imp::{clear, image, set_image, set_text, text};
825
826#[cfg(target_os = "macos")]
827mod custom {
828 use objc2_app_kit::NSPasteboard;
829 use objc2_foundation::{NSData, NSString};
830
831 use super::*;
832
833 pub fn set_custom(items: &[(&str, &[u8])]) -> Result<()> {
836 check(items)?;
837 let pb = NSPasteboard::generalPasteboard();
838 pb.clearContents();
839 for (ty, bytes) in items {
840 let data = NSData::with_bytes(bytes);
841 if !pb.setData_forType(Some(&data), &NSString::from_str(ty)) {
842 return Err(NativeError::Os(format!("the pasteboard refused type `{ty}`")));
843 }
844 }
845 Ok(())
846 }
847
848 pub fn custom(ty: &str) -> Result<Option<Vec<u8>>> {
850 if !valid_type(ty) {
851 return Err(NativeError::Invalid(format!("`{}` is not a pasteboard type", ty.chars().take(64).collect::<String>())));
852 }
853 let pb = NSPasteboard::generalPasteboard();
854 let data = pb.dataForType(&NSString::from_str(ty));
855 let Some(data) = data else { return Ok(None) };
856 if data.len() > MAX_CUSTOM_BYTES {
857 return Err(NativeError::Os("the clipboard data is too large".into()));
858 }
859 Ok(Some(data.to_vec()))
860 }
861
862 pub fn types() -> Result<Vec<String>> {
864 let pb = NSPasteboard::generalPasteboard();
865 Ok(pb.types().map(|a| a.iter().take(256).map(|t| t.to_string()).collect()).unwrap_or_default())
866 }
867}
868
869#[cfg(not(target_os = "macos"))]
870mod custom {
871 use super::*;
872
873 pub fn set_custom(items: &[(&str, &[u8])]) -> Result<()> {
874 check(items)?;
875 Err(NativeError::Unsupported)
876 }
877 pub fn custom(_: &str) -> Result<Option<Vec<u8>>> {
878 Err(NativeError::Unsupported)
879 }
880 pub fn types() -> Result<Vec<String>> {
881 Err(NativeError::Unsupported)
882 }
883}
884
885pub use custom::{custom, set_custom, types};
888
889fn check(items: &[(&str, &[u8])]) -> Result<()> {
890 if items.is_empty() || items.len() > 16 {
891 return Err(NativeError::Invalid("copy between 1 and 16 representations".into()));
892 }
893 for (ty, bytes) in items {
894 if !valid_type(ty) {
895 return Err(NativeError::Invalid(format!("`{}` is not a pasteboard type", ty.chars().take(64).collect::<String>())));
896 }
897 if bytes.len() > MAX_CUSTOM_BYTES {
898 return Err(NativeError::Invalid(format!("`{ty}` is larger than {MAX_CUSTOM_BYTES} bytes")));
899 }
900 }
901 Ok(())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn images_are_checked_before_the_os_sees_them() {
910 assert!(Image { width: 2, height: 2, rgba: vec![0; 16] }.validate().is_ok());
911 assert!(Image { width: 2, height: 2, rgba: vec![0; 15] }.validate().is_err());
912 assert!(Image { width: 0, height: 2, rgba: vec![] }.validate().is_err());
913 assert!(Image { width: u32::MAX, height: u32::MAX, rgba: vec![] }.validate().is_err());
914 assert!(set_image(&Image { width: 3, height: 1, rgba: vec![1, 2] }).is_err());
915 }
916
917 #[test]
918 fn custom_types_are_validated() {
919 assert!(valid_type("com.example.app.clip"));
920 assert!(valid_type("application/x-example+json"));
921 for bad in ["", "a b", "a\nb", "ü", &"x".repeat(129)] {
922 assert!(!valid_type(bad), "{bad:?}");
923 }
924 assert!(set_custom(&[]).is_err());
925 assert!(matches!(set_custom(&[("bad type", b"x")]), Err(NativeError::Invalid(_))));
926 assert!(matches!(custom("bad type"), Err(NativeError::Invalid(_)) | Err(NativeError::Unsupported)));
927 }
928
929 #[test]
930 fn rich_content_round_trips_through_the_memory_backend() {
931 let mut m = MemoryClipboard::default();
932 let content = Rich { text: "hi".into(), html: Some(r#"<b onclick="x()">hi</b>"#.into()), png: None, files: vec![] };
933 write(&mut m, &content).unwrap();
934 let back = read(&mut m).unwrap();
935 assert_eq!(back.text, "hi");
936 assert_eq!(back.html.as_deref(), Some("<b>hi</b>"), "HTML is sanitized on read");
937 assert!(!m.secret);
938 }
939
940 #[test]
941 fn size_caps_and_shapes_are_checked_before_writing() {
942 let mut m = MemoryClipboard::default();
943 assert!(matches!(write(&mut m, &Rich::default()), Err(NativeError::Invalid(_))), "empty");
944 let big = Rich::text("x".repeat(MAX_TEXT_BYTES + 1));
945 assert!(matches!(write(&mut m, &big), Err(NativeError::Invalid(_))));
946 let html = Rich { html: Some("x".repeat(MAX_HTML_BYTES + 1)), ..Default::default() };
947 assert!(matches!(write(&mut m, &html), Err(NativeError::Invalid(_))));
948 let not_png = Rich { png: Some(b"GIF89a".to_vec()), ..Default::default() };
949 assert!(matches!(write(&mut m, ¬_png), Err(NativeError::Invalid(_))));
950 let relative = Rich { files: vec!["a.txt".into()], ..Default::default() };
951 assert!(matches!(write(&mut m, &relative), Err(NativeError::Invalid(_))));
952 let many = Rich { files: vec![std::env::temp_dir(); MAX_FILES + 1], ..Default::default() };
953 assert!(matches!(write(&mut m, &many), Err(NativeError::Invalid(_))));
954 assert_eq!(m.content, Rich::default(), "nothing was written");
955 m.content =
957 Rich { text: "ok".into(), png: Some(b"not a png".to_vec()), files: vec!["rel".into(), std::env::temp_dir()], html: Some(" ".into()) };
958 let r = read(&mut m).unwrap();
959 assert_eq!((r.png, r.files, r.html), (None, vec![std::env::temp_dir()], None));
960 }
961
962 #[test]
963 fn a_secret_clears_only_while_the_clipboard_still_holds_it() {
964 for counts_changes in [true, false] {
965 let mut m = MemoryClipboard { counts_changes, ..Default::default() };
966 let stamp = copy_secret(&mut m, "hunter2").unwrap();
967 assert!(m.secret, "kept out of history");
968 assert!(clear_if_unchanged(&mut m, &stamp).unwrap());
969 assert!(m.content.is_empty());
970
971 let stamp = copy_secret(&mut m, "hunter2").unwrap();
972 write(&mut m, &Rich::text("something else")).unwrap();
973 assert!(!clear_if_unchanged(&mut m, &stamp).unwrap(), "the user copied since: {counts_changes}");
974 assert_eq!(m.content.text, "something else");
975 }
976 let mut m = MemoryClipboard { counts_changes: true, ..Default::default() };
978 let stamp = copy_secret(&mut m, "hunter2").unwrap();
979 write(&mut m, &Rich::text("hunter2")).unwrap();
980 assert!(!clear_if_unchanged(&mut m, &stamp).unwrap());
981 assert!(matches!(set_secret("x", MAX_SECRET_LIFETIME * 2), Err(NativeError::Invalid(_))));
982 }
983
984 #[test]
985 fn the_sanitizer_drops_active_content() {
986 let cases = [
987 ("<p>a</p><script>alert(1)</script><p>b</p>", "<p>a</p><p>b</p>"),
988 ("<SCRIPT type=x>bad</ScRiPt >ok", "ok"),
989 ("<style>p{}</style><i>x</i>", "<i>x</i>"),
990 (r#"<b onclick="x()" OnMouseOver='y' class=k>t</b>"#, r#"<b class="k">t</b>"#),
991 (r#"<a href="javascript:alert(1)">x</a>"#, "<a>x</a>"),
992 ("<a href=\" JaVa\tScRiPt:alert(1)\">x</a>", "<a>x</a>"),
993 (r#"<a href="javascript:x">x</a>"#, "<a>x</a>"),
994 (r#"<a href="javascript:x">x</a>"#, "<a>x</a>"),
995 (r#"<a href="https://e.com/?q=a&b" title='say "hi"'>x</a>"#, r#"<a href="https://e.com/?q=a&b" title="say "hi"">x</a>"#),
996 (r#"<a href="docs/a.html">x</a>"#, r#"<a href="docs/a.html">x</a>"#),
997 (
998 r#"<img src="data:image/png;base64,AA"><img src="data:image/svg+xml,x"><img src=data:text/html,x>"#,
999 r#"<img src="data:image/png;base64,AA"><img><img>"#,
1000 ),
1001 (r#"<a href="data:image/png;base64,AA">x</a>"#, "<a>x</a>"),
1002 ("<iframe src=x>inside</iframe><object>o</object>k", "k"),
1003 ("<!-- <script>x</script> -->c", "c"),
1004 ("<!DOCTYPE html><html><body>t</body></html>", "t"),
1005 (
1006 r#"<span style="color:red">r</span><span style="background:url(javascript:x)">s</span>"#,
1007 r#"<span style="color:red">r</span><span>s</span>"#,
1008 ),
1009 ("<div srcdoc=x srcset=y>d</div>", "<div>d</div>"),
1010 ("a < b && c > d", "a < b && c > d"),
1011 ("<br/><br />", "<br /><br />"),
1012 ("x<script>never closed", "x"),
1013 ("x<b class=\"open", "x"),
1014 ("<form action=x><input value=1>t</form>", "t"),
1015 ];
1016 for (input, want) in cases {
1017 assert_eq!(sanitize_html(input), want, "{input}");
1018 }
1019 assert_eq!(sanitize_html("plain ünïcode"), "plain ünïcode");
1020 let nested = sanitize_html("<scr<script>ipt>alert(1)</script>");
1021 assert!(!nested.to_ascii_lowercase().contains("<script"), "{nested}");
1022 }
1023
1024 #[cfg(not(target_arch = "wasm32"))]
1025 #[test]
1026 fn png_bytes_round_trip() {
1027 let img = Image { width: 2, height: 1, rgba: vec![255, 0, 0, 255, 0, 0, 255, 128] };
1028 let png = imp::png_encode(&img).unwrap();
1029 assert!(png.starts_with(PNG_SIGNATURE));
1030 assert_eq!(imp::png_decode(&png).unwrap(), img);
1031 assert!(imp::png_decode(b"\x89PNG\r\n\x1a\njunk").is_err());
1032 }
1033
1034 #[test]
1036 #[ignore = "replaces the user's clipboard"]
1037 fn real_clipboard_round_trip() {
1038 set_text("ashlar clipboard test").unwrap();
1039 assert_eq!(text().unwrap().as_deref(), Some("ashlar clipboard test"));
1040 let img = Image { width: 2, height: 1, rgba: vec![255, 0, 0, 255, 0, 0, 255, 255] };
1041 set_image(&img).unwrap();
1042 assert_eq!(image().unwrap().map(|i| (i.width, i.height)), Some((2, 1)));
1043 if cfg!(target_os = "macos") {
1044 set_custom(&[("com.example.ashlar.test", b"\x00\x01binary"), ("public.utf8-plain-text", b"fallback")]).unwrap();
1045 assert_eq!(custom("com.example.ashlar.test").unwrap().as_deref(), Some(&b"\x00\x01binary"[..]));
1046 assert_eq!(text().unwrap().as_deref(), Some("fallback"));
1047 assert!(types().unwrap().iter().any(|t| t == "com.example.ashlar.test"));
1048 }
1049 let html = Rich { text: "plain".into(), html: Some("<b>bold</b><script>x</script>".into()), png: None, files: vec![] };
1050 set_rich(&html).unwrap();
1051 let back = rich().unwrap();
1052 assert_eq!(back.text, "plain");
1053 assert_eq!(back.html.as_deref(), Some("<b>bold</b>"));
1054 let files = Rich { files: vec![std::env::temp_dir()], ..Default::default() };
1055 set_rich(&files).unwrap();
1056 assert!(!rich().unwrap().files.is_empty());
1057 set_secret("ashlar secret", Duration::from_millis(100)).unwrap();
1058 std::thread::sleep(Duration::from_millis(400));
1059 assert_ne!(text().unwrap().as_deref(), Some("ashlar secret"), "the secret cleared itself");
1060 clear().unwrap();
1061 }
1062}