Skip to main content

ashlar_native/
clipboard.rs

1//! The system clipboard: text, RGBA images, rich content (text with HTML, a PNG, file lists),
2//! secrets that clear themselves, and, on macOS, custom pasteboard types so an app can copy its
3//! own data alongside a plain-text fallback.
4//!
5//! On Linux (X11 and Wayland) the copying process serves the clipboard: one long-lived arboard
6//! clipboard lives on a worker thread for the life of the process, so copied content stays
7//! pasteable after the call returns. Elsewhere each call opens the clipboard briefly.
8//!
9//! Content read with [`rich`] is bounded ([`MAX_TEXT_BYTES`], [`MAX_HTML_BYTES`],
10//! [`MAX_PNG_BYTES`], [`MAX_FILES`]) and its HTML goes through [`sanitize_html`].
11
12use std::hash::{Hash, Hasher};
13use std::path::PathBuf;
14use std::time::Duration;
15
16use crate::{NativeError, Result};
17
18/// Largest image accepted, in pixels per side.
19pub const MAX_IMAGE_SIDE: u32 = 16_384;
20/// Largest custom payload, in bytes.
21pub const MAX_CUSTOM_BYTES: usize = 256 * 1024 * 1024;
22/// Largest text copied or read, in bytes.
23pub const MAX_TEXT_BYTES: usize = 64 * 1024 * 1024;
24/// Largest HTML copied or read, in bytes.
25pub const MAX_HTML_BYTES: usize = 16 * 1024 * 1024;
26/// Largest PNG copied or read, in bytes.
27pub const MAX_PNG_BYTES: usize = 64 * 1024 * 1024;
28/// Most files in one copy.
29pub const MAX_FILES: usize = 4096;
30/// Longest a secret may stay before it is cleared.
31pub const MAX_SECRET_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
32
33const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
34
35/// An image as RGBA8, row-major, no padding.
36#[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    /// Check the size against the pixel data; refuse empty and oversized images.
45    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/// One piece of content in several forms. Copying writes as many forms as the platform takes in
58/// one go: macOS writes text, HTML and PNG together (files alone); elsewhere arboard writes one
59/// form per copy, chosen in the order files, HTML (with the text as its plain fallback), PNG,
60/// text.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Rich {
63    pub text: String,
64    pub html: Option<String>,
65    /// PNG-encoded image bytes.
66    pub png: Option<Vec<u8>>,
67    /// Absolute paths.
68    pub files: Vec<PathBuf>,
69}
70
71impl Rich {
72    /// Plain text only.
73    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    /// Refuse empty and oversized content before the OS sees it.
82    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    /// Bound and clean content read from the clipboard: oversized forms are dropped, HTML is
110    /// sanitized.
111    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
123/// A pasteboard type name for custom data: a uniform type identifier on macOS
124/// (`com.example.app.clip`) or a MIME type (`application/x-example-clip`). Bounded and printable.
125pub 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
129// ------------------------------------------------------------------ backends
130
131/// Where clipboard content lives: [`System`], or [`MemoryClipboard`] for tests.
132pub trait Backend {
133    /// Replace the content. `secret`: ask clipboard history and monitoring tools to skip it.
134    fn set_rich(&mut self, content: &Rich, secret: bool) -> Result<()>;
135    /// The content as stored, before [`read`] bounds and sanitizes it.
136    fn rich(&mut self) -> Result<Rich>;
137    fn clear(&mut self) -> Result<()>;
138    /// A counter that changes with every write by anyone, where the OS has one (macOS).
139    fn change_count(&mut self) -> Option<i64>;
140}
141
142/// Copy `content` to `backend` after checking it.
143pub fn write(backend: &mut dyn Backend, content: &Rich) -> Result<()> {
144    content.validate()?;
145    backend.set_rich(content, false)
146}
147
148/// Read `backend`'s content, bounded and with its HTML sanitized.
149pub fn read(backend: &mut dyn Backend) -> Result<Rich> {
150    backend.rich().map(Rich::inbound)
151}
152
153/// An in-memory clipboard with a change counter, for tests.
154#[derive(Debug, Default)]
155pub struct MemoryClipboard {
156    pub content: Rich,
157    /// The last write asked to be kept out of history.
158    pub secret: bool,
159    changes: i64,
160    /// Report a change counter, as macOS does (`false`: as Windows and Linux).
161    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// ------------------------------------------------------------------ secrets
186
187/// What a secret copy left behind, to tell later whether the clipboard still holds it. Keeps a
188/// hash of the text, not the text.
189#[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
201/// Copy `text` kept out of clipboard history where the platform allows.
202pub 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
209/// Clear the clipboard if it still holds the secret `stamp` describes: the change counter is
210/// unchanged where there is one, otherwise the text is the same. `Ok(true)` when cleared.
211pub 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
225/// Copy a password or token: kept out of clipboard history and monitoring where supported
226/// (macOS `org.nspasteboard.ConcealedType`, Windows history/cloud/monitoring flags, Linux
227/// `x-kde-passwordManagerHint`), and cleared after `clear_after` unless something else was
228/// copied since.
229pub 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
246// ------------------------------------------------------------------ HTML
247
248/// Elements dropped with everything inside them.
249const DROP_WITH_CONTENT: &[&str] = &[
250    "script", "style", "iframe", "object", "embed", "noscript", "noembed", "noframes", "template", "frame", "frameset", "applet", "svg", "math",
251    "xml",
252];
253/// Elements dropped, their content kept.
254const DROP_TAG: &[&str] = &[
255    "base", "link", "meta", "form", "input", "button", "textarea", "select", "option", "param", "source", "track", "portal", "html", "head", "body",
256];
257/// Attributes holding a URL.
258const URL_ATTRS: &[&str] =
259    &["href", "src", "xlink:href", "action", "formaction", "background", "poster", "cite", "longdesc", "lowsrc", "dynsrc", "data", "ping"];
260
261/// Make pasted HTML safe to show or keep: drops comments, scripts, styles, frames and embedded
262/// objects (with their content), form controls, event attributes (`on…`), `srcdoc`/`srcset`,
263/// risky inline styles, and URLs other than http(s), mailto, tel, relative ones and (for images)
264/// `data:image/…` other than SVG. What is left is re-emitted with quoted attributes.
265pub 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("&lt;");
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('"', "&quot;"));
324                    out.push('"');
325                }
326            }
327            if tag.self_closing {
328                out.push_str(" /");
329            }
330        }
331        out.push('>');
332    }
333    out
334}
335
336/// One parsed tag.
337struct Tag {
338    name: String,
339    closing: bool,
340    self_closing: bool,
341    attrs: Vec<(String, Option<String>)>,
342    /// Bytes of input it spans.
343    len: usize,
344    /// Ended by `>` (an unfinished tag at the end of the input is dropped).
345    complete: bool,
346}
347
348impl Tag {
349    /// `s` starts with `<`. `None` when no tag name follows (a literal `<`).
350    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            // `=` right at the start of an attribute: skip it as junk
387            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
425/// Where the closing tag `</name…>` ends in `s`, matched without regard to case.
426fn 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
458/// Relative, http(s), mailto, tel, and (with `image`) non-SVG `data:image/…` URLs.
459fn 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    // an entity or escape before the path could spell a scheme (`javascript&colon;`)
463    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// ------------------------------------------------------------------ the system clipboard
475
476/// The system clipboard as a [`Backend`].
477#[derive(Debug, Default, Clone, Copy)]
478pub struct System;
479
480/// Copy several forms of one piece of content (see [`Rich`] for what each platform keeps).
481pub fn set_rich(content: &Rich) -> Result<()> {
482    write(&mut System, content)
483}
484
485/// The clipboard's content in every form it has, bounded and with the HTML sanitized. Forms the
486/// clipboard doesn't hold are empty.
487pub 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            // `Unknown` carries a description from the OS; never the clipboard content.
504            arboard::Error::Unknown { description } => NativeError::Os(crate::os_text(description)),
505            _ => NativeError::Os("the clipboard failed".into()),
506        }
507    }
508
509    /// Run `f` with a clipboard: the long-lived one on the worker thread (Linux and other X11 /
510    /// Wayland systems), or one opened for this call.
511    #[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    /// One clipboard for the life of the process, on its own thread: on X11 and Wayland the
525    /// copying process serves the content, so dropping the clipboard would take it away.
526    #[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        /// How long a caller waits for the worker (another app may hold the clipboard).
536        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                // the worker ended: start another for this job
583                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    /// The clipboard's text, `None` when it holds no text.
601    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    /// The clipboard's image as RGBA8, `None` when it holds no image.
623    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    /// Mark a write as secret with arboard's per-platform flags.
673    #[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        /// The nspasteboard.org marker clipboard managers skip.
754        const CONCEALED: &str = "org.nspasteboard.ConcealedType";
755
756        /// Text, HTML and PNG as representations of one item.
757        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    /// Replace the clipboard with these representations of one piece of content, for example
834    /// the app's own format plus `public.utf8-plain-text` for other apps.
835    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    /// The clipboard's data of type `ty`, `None` when it holds none.
849    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    /// The type names the clipboard currently holds.
863    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
885/// Custom types: `set_custom(&[("com.example.clip", bytes), ("public.utf8-plain-text", text)])`,
886/// `custom("com.example.clip")`, `types()`. macOS only for now; elsewhere `Unsupported`.
887pub 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, &not_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        // what another app put there is bounded on read
956        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        // with a change counter, even the same text copied again is not ours
977        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="jav&#x61;script:x">x</a>"#, "<a>x</a>"),
994            (r#"<a href="javascript&colon;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 &quot;hi&quot;">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 &lt; 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    /// Writes to the real clipboard: `cargo test -p ashlar-native clipboard -- --ignored`.
1035    #[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}