Skip to main content

crcbl_shell/
clipboard.rs

1//! Clipboard and drag-and-drop payloads.
2//!
3//! # Decision: the trait surface shipped a slice before the backends
4//!
5//! P0.4 implemented no platform clipboard — there was no platform code in that
6//! slice at all — and P0.5c implemented the Wayland one against this shape
7//! unchanged, which is the outcome the trade below was made for. The *shape*
8//! was here first, and that was deliberate:
9//! adding a method to a trait that three backends already implement means
10//! touching three backends, whereas leaving an unimplemented method on the
11//! trait costs nothing but the `Unsupported` arm each of them writes once.
12//! The windowing rules in `docs/notes/backends.md` also settle the design
13//! questions that would
14//! otherwise make this speculative — the mime set is specified, and clipboard
15//! and drag-and-drop are stated to share one implementation on Wayland and X11
16//! ("one implementation, two triggers"), so the payload types below serve both.
17//!
18//! [`HeadlessShell`](crate::HeadlessShell) implements the surface fully, in
19//! process, which means the shape is exercised by tests rather than merely
20//! asserted to be right.
21//!
22//! # Decision: reads are asynchronous, like `crcbl-hal`'s readback
23//!
24//! There is no `fn clipboard_text(&self) -> Option<String>`. A read is
25//! [`clipboard_request`](crate::Shell::clipboard_request), which returns a
26//! [`ClipboardRequestId`], and the answer arrives later as
27//! [`ShellEvent::ClipboardData`](crate::ShellEvent::ClipboardData).
28//!
29//! This is the same argument `crcbl-hal` makes for polled readback, and it has
30//! the same force. A synchronous getter is unimplementable on:
31//!
32//! * **X11**, where reading a selection is a round trip to the *owning client*
33//!   — `ConvertSelection`, wait for `SelectionNotify`, and for anything large
34//!   an entire `INCR` chunked transfer driven by property-change events. A
35//!   blocking implementation deadlocks the moment the owner is the same
36//!   process, which in an editor with two windows it is.
37//! * **Wayland**, where a `data_offer` hands over a file descriptor to read.
38//! * **The browser**, where `navigator.clipboard.read()` returns a Promise and
39//!   is permission-gated behind a user gesture.
40//!
41//! Three of five backends cannot implement the synchronous shape, so it is not
42//! the shape. Writes, by contrast, are synchronous: every platform's write is
43//! "advertise that we own the selection", which completes immediately — the
44//! *transfer* happens later, on demand, and the shell owns the bytes until it
45//! does.
46//!
47//! # Decision: a read that cannot be answered yet is *held*, not answered
48//!
49//! P0.5c's revision, and the reason for it is a finding rather than a
50//! preference. On Wayland a client is told what is on the clipboard by
51//! `wl_data_device.selection`, which arrives **asynchronously** and **only
52//! while one of its windows has keyboard focus**. A read issued a millisecond
53//! too early — during the focus change a Ctrl+V *itself* may have caused — has
54//! nothing to answer from.
55//!
56//! Answering "empty" there is a lie a consumer cannot detect, and the end-to-end
57//! suite proved it: it needed a retry loop that no editor's paste command would
58//! ever contain. So the shape is that a backend **holds** the request until it
59//! is answerable and only then emits
60//! [`ShellEvent::ClipboardData`](crate::ShellEvent::ClipboardData). There is
61//! deliberately no "try again later" outcome; see [`ClipboardContent`].
62//!
63//! This costs X11 nothing, which is the test every seam change here has to
64//! pass: an X11 backend can answer immediately (`XGetSelectionOwner` says
65//! whether anyone owns the selection, with no focus involved), so "hold until
66//! answerable" collapses to "answer". A backend where the wait is real bounds
67//! it and reports [`ClipboardContent::Unavailable`] rather than waiting
68//! forever, and [`Shell::clipboard_readable`](crate::Shell::clipboard_readable)
69//! lets a UI ask in advance instead of discovering it by waiting.
70//!
71//! # Two mime types, always offered together
72//!
73//! The windowing rules specify `text/plain` plus the custom
74//! `application/x-crcbl+ron` so that engine↔engine copies are lossless while
75//! outside applications still receive readable text. Offering both is the
76//! caller's job, and [`ClipboardOffer`] is a slice for exactly that reason.
77//!
78//! # `text/uri-list` is parsed here, not in a backend
79//!
80//! A file drop and a "copy file" paste both arrive as a `text/uri-list` blob,
81//! on Wayland (`wl_data_offer`), on X11 (`XdndSelection`) and in a browser
82//! (`DataTransfer`). The format is RFC 2483's, the same on all three, and
83//! getting it wrong — percent-encoding, `file://` authorities, CRLF, comment
84//! lines — is wrong in the same way on all three. So [`parse_uri_list`] lives
85//! next to the mime types rather than being written once per backend, which is
86//! how the second and third copies drift.
87//!
88//! Windows is the one place a URI's path is not the file's path: `file:///C:/a`
89//! names `C:\a`, and `file://server/share` a UNC share. That mapping, in both
90//! directions, is the `windows_uri` module — the Win32 backend encodes a
91//! `CF_HDROP` with it, and [`parse_uri_list`] decodes with it when built for
92//! Windows.
93
94use core::fmt;
95use std::path::PathBuf;
96
97#[cfg(any(target_os = "windows", test))]
98pub(crate) mod windows_uri;
99
100/// The format of a clipboard or drag-and-drop payload.
101///
102/// A small enum with an escape hatch rather than a bare string, so the two
103/// formats the engine cares about cannot be misspelled, and so a `match` on the
104/// interesting cases reads as one.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub enum MimeType {
107    /// `text/plain;charset=utf-8` — what every other application understands.
108    ///
109    /// Backends must accept the `text/plain` spelling without the charset
110    /// parameter as equivalent when *receiving*, and offer the explicit
111    /// charset when sending: X11 clients in particular offer both `STRING` and
112    /// `UTF8_STRING` and mean this.
113    TextUtf8,
114
115    /// `application/x-crcbl+ron` — the engine's own lossless format.
116    ///
117    /// Copying a scene node between two editor windows goes through this;
118    /// pasting into a text editor goes through [`TextUtf8`](Self::TextUtf8),
119    /// which is why both are offered at once.
120    CrcblRon,
121
122    /// `text/uri-list` — file paths, the format both a file-manager drop and a
123    /// "copy file" paste arrive in.
124    UriList,
125
126    /// Anything else, by literal mime string.
127    ///
128    /// `&'static str` rather than `String` keeps [`MimeType`] `Copy` and keeps
129    /// the whole payload type free of allocation on the send path. A backend
130    /// receiving a mime it has no variant for reports it here.
131    Other(&'static str),
132}
133
134impl MimeType {
135    /// The wire form of this mime type.
136    #[must_use]
137    pub const fn as_str(self) -> &'static str {
138        match self {
139            Self::TextUtf8 => "text/plain;charset=utf-8",
140            Self::CrcblRon => "application/x-crcbl+ron",
141            Self::UriList => "text/uri-list",
142            Self::Other(mime) => mime,
143        }
144    }
145
146    /// Recognizes a mime string the window system offered.
147    ///
148    /// Accepts the `text/plain` spelling without a charset as
149    /// [`TextUtf8`](Self::TextUtf8): X11 and the browser both produce it, and
150    /// treating it as an unknown format would mean refusing the single most
151    /// common paste there is.
152    #[must_use]
153    pub fn recognize(mime: &str) -> Option<Self> {
154        match mime {
155            "text/plain;charset=utf-8" | "text/plain" | "UTF8_STRING" | "STRING" => {
156                Some(Self::TextUtf8)
157            }
158            "application/x-crcbl+ron" => Some(Self::CrcblRon),
159            "text/uri-list" => Some(Self::UriList),
160            _ => None,
161        }
162    }
163}
164
165impl fmt::Display for MimeType {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171/// A mime type as some *other* application spelled it.
172///
173/// # Why this is a second type
174///
175/// [`MimeType`] is `Copy`, which means its escape hatch is
176/// [`Other(&'static str)`](MimeType::Other) — fine for something this engine
177/// names in its own source, and structurally unable to hold a string that
178/// arrived at runtime from another process. Every incoming mime is exactly
179/// that: an X11 `TARGETS` reply, a Wayland `data_offer.offer`, or a browser
180/// `DataTransfer.types` entry, all of them arbitrary and none of them
181/// `'static`.
182///
183/// So the outgoing side stays `Copy` and the incoming side owns its string. The
184/// asymmetry is the point: a clipboard write names a format the engine chose,
185/// and a clipboard read reports a format someone else chose.
186///
187/// # Fidelity
188///
189/// The literal spelling is preserved rather than canonicalized.
190/// [`recognized`](Self::recognized) maps it onto a [`MimeType`] where one fits,
191/// but `ReceivedMime::new("text/plain")` keeps `"text/plain"` — because a
192/// backend answering an X11 `SelectionRequest` has to echo back the *target
193/// atom that was asked for*, and rewriting it to
194/// `text/plain;charset=utf-8` would produce a reply the peer does not
195/// recognize.
196#[derive(Clone, Debug, PartialEq, Eq, Hash)]
197pub struct ReceivedMime(std::borrow::Cow<'static, str>);
198
199impl ReceivedMime {
200    /// Wraps a mime string that arrived from another application.
201    ///
202    /// Borrows rather than allocating when the string is byte-identical to one
203    /// of the engine's own — which covers the overwhelmingly common case of a
204    /// crcbl-to-crcbl copy — and allocates otherwise.
205    #[must_use]
206    pub fn new(mime: &str) -> Self {
207        for known in [MimeType::TextUtf8, MimeType::CrcblRon, MimeType::UriList] {
208            if known.as_str() == mime {
209                return Self(std::borrow::Cow::Borrowed(known.as_str()));
210            }
211        }
212        Self(std::borrow::Cow::Owned(mime.to_string()))
213    }
214
215    /// The mime string exactly as it was received.
216    #[inline]
217    #[must_use]
218    pub fn as_str(&self) -> &str {
219        &self.0
220    }
221
222    /// The [`MimeType`] this corresponds to, if the engine knows the format.
223    ///
224    /// `None` means "some other application's format" — loggable, forwardable,
225    /// and not something the engine can interpret.
226    #[must_use]
227    pub fn recognized(&self) -> Option<MimeType> {
228        MimeType::recognize(&self.0)
229    }
230
231    /// Whether this is the format `mime` names, allowing for the alternative
232    /// spellings [`MimeType::recognize`] accepts.
233    ///
234    /// This is the comparison a paste handler wants: asking for
235    /// [`MimeType::TextUtf8`] and receiving `"text/plain"` is a match, and
236    /// comparing the strings directly would say it is not.
237    #[must_use]
238    pub fn matches(&self, mime: MimeType) -> bool {
239        self.recognized() == Some(mime) || self.0 == mime.as_str()
240    }
241}
242
243impl From<MimeType> for ReceivedMime {
244    fn from(mime: MimeType) -> Self {
245        Self(std::borrow::Cow::Borrowed(mime.as_str()))
246    }
247}
248
249impl fmt::Display for ReceivedMime {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        f.write_str(&self.0)
252    }
253}
254
255/// One format of a payload being offered to the window system.
256///
257/// A clipboard write offers a slice of these — the same bytes in several
258/// formats, or genuinely different renderings of one thing.
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub struct ClipboardOffer<'a> {
261    /// What format `bytes` is in.
262    pub mime: MimeType,
263    /// The payload. Not required to be valid UTF-8 even for
264    /// [`MimeType::TextUtf8`] — validation belongs to whoever built it, and a
265    /// shell that re-validated every write would be doing it twice.
266    pub bytes: &'a [u8],
267}
268
269impl<'a> ClipboardOffer<'a> {
270    /// A `text/plain` offer.
271    #[inline]
272    #[must_use]
273    pub const fn text(text: &'a str) -> Self {
274        Self {
275            mime: MimeType::TextUtf8,
276            bytes: text.as_bytes(),
277        }
278    }
279
280    /// An `application/x-crcbl+ron` offer.
281    #[inline]
282    #[must_use]
283    pub const fn ron(ron: &'a str) -> Self {
284        Self {
285            mime: MimeType::CrcblRon,
286            bytes: ron.as_bytes(),
287        }
288    }
289}
290
291/// What a clipboard read produced.
292///
293/// # Decision: three outcomes, because two of them were being conflated
294///
295/// This started life as `Option<Vec<u8>>`, on the argument that "the clipboard
296/// was empty", "it held no compatible format" and "the transfer failed" all
297/// mean *there is nothing to paste* and a caller does the same thing for each.
298/// P0.5c found that wrong against a real compositor, in two ways that a caller
299/// genuinely must tell apart:
300///
301/// * **A successful transfer of zero bytes is not a failure.** A peer may
302///   publish an empty selection, and [`Bytes(vec![])`](Self::Bytes) says so.
303///   Reporting `None` there tells an editor its paste failed when it did not.
304/// * **"Could not be read" is not "there is nothing there".** A transfer whose
305///   peer went away mid-write, or which never became readable at all, is a
306///   failure a UI may want to surface ("the clipboard could not be read") and
307///   which an automation script must not silently treat as an empty clipboard.
308///
309/// [`Empty`](Self::Empty) still merges "the clipboard holds nothing" with "it
310/// holds nothing in *that* format", because those two really are one case: the
311/// caller named the format, and the answer is that it is not on offer. A caller
312/// that wants a different format asks for it, which is one more request either
313/// way.
314///
315/// What is deliberately **not** here is "not yet". A read that cannot be
316/// answered yet is *held* until it can be — see
317/// [`clipboard_request`](crate::Shell::clipboard_request) — because a variant
318/// meaning "ask again later" is a retry loop in every consumer, and the
319/// consumer has no idea how long to wait.
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub enum ClipboardContent {
322    /// The payload, in the format [`ClipboardData::mime`](crate::ShellEvent::ClipboardData)
323    /// names. May be empty: that is a successful transfer of nothing.
324    Bytes(Vec<u8>),
325
326    /// The clipboard holds nothing, or nothing in the requested format.
327    Empty,
328
329    /// The read could not be completed.
330    ///
331    /// The window system refused it, the peer holding the data went away or
332    /// never delivered, or the window never became able to read the clipboard
333    /// within the backend's deadline. Distinct from [`Empty`](Self::Empty):
334    /// there may well be something on the clipboard.
335    Unavailable,
336}
337
338impl ClipboardContent {
339    /// The payload, if the read succeeded.
340    ///
341    /// `None` for both [`Empty`](Self::Empty) and
342    /// [`Unavailable`](Self::Unavailable) — the "just give me the bytes"
343    /// accessor, for a caller that treats every non-answer the same.
344    #[inline]
345    #[must_use]
346    pub fn bytes(&self) -> Option<&[u8]> {
347        match self {
348            Self::Bytes(bytes) => Some(bytes),
349            Self::Empty | Self::Unavailable => None,
350        }
351    }
352
353    /// The payload, consumed. See [`bytes`](Self::bytes).
354    #[inline]
355    #[must_use]
356    pub fn into_bytes(self) -> Option<Vec<u8>> {
357        match self {
358            Self::Bytes(bytes) => Some(bytes),
359            Self::Empty | Self::Unavailable => None,
360        }
361    }
362
363    /// The payload as text, when it is valid UTF-8.
364    ///
365    /// The overwhelmingly common paste, and the one place it is worth saving
366    /// every caller the same three lines. Invalid UTF-8 answers `None` rather
367    /// than being lossily replaced: a paste that silently corrupts what was
368    /// copied is worse than one that refuses.
369    #[must_use]
370    pub fn text(&self) -> Option<&str> {
371        core::str::from_utf8(self.bytes()?).ok()
372    }
373
374    /// A short, stable name for logs and test assertions.
375    #[inline]
376    #[must_use]
377    pub const fn name(&self) -> &'static str {
378        match self {
379            Self::Bytes(_) => "Bytes",
380            Self::Empty => "Empty",
381            Self::Unavailable => "Unavailable",
382        }
383    }
384}
385
386/// Identifies an outstanding [`clipboard_request`](crate::Shell::clipboard_request).
387///
388/// Matched against
389/// [`ShellEvent::ClipboardData::request`](crate::ShellEvent::ClipboardData) when
390/// the answer arrives. Ids are unique within a shell for the session; a caller
391/// with several reads in flight — an editor paste racing an asset-browser drop
392/// — tells them apart by this and nothing else.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
394pub struct ClipboardRequestId(pub u32);
395
396impl fmt::Display for ClipboardRequestId {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        write!(f, "clipboard request {}", self.0)
399    }
400}
401
402/// The local paths named by a [`MimeType::UriList`] payload.
403///
404/// A dropped file does not arrive as a path. It arrives as RFC 2483's
405/// `text/uri-list`: CRLF-separated URIs, percent-encoded, with `#` comment
406/// lines permitted. This turns that into the paths
407/// [`ShellEvent::DroppedFile`](crate::ShellEvent::DroppedFile) carries.
408///
409/// # What is dropped, and why
410///
411/// * **Comment lines and blank lines.** The format defines both.
412/// * **Non-`file:` URIs.** A file manager can put `https://…`, `trash:///…` or
413///   `smb://host/share` in a drop, and none of them is a path — turning
414///   `https://example.com/a` into the relative path `example.com/a` would be a
415///   plausible-looking lie that only fails once something tries to open it.
416///   They are skipped, so a drop of three URLs and one file produces one
417///   [`DroppedFile`](crate::ShellEvent::DroppedFile).
418/// * **`file://` with a remote authority, except on Windows.**
419///   `file://host/path` names a file on *that* host. `file:///path` (empty
420///   authority) and `file://localhost/path` are this machine and are kept;
421///   anything else is not reachable through a `PathBuf` — except on Windows,
422///   where it is the UNC path `\\host\path` and is kept as one.
423///
424/// Percent-decoding happens **after** the scheme and authority are stripped, so
425/// a `%2F` inside a filename decodes to a `/` in the name rather than to a path
426/// separator that changes the URI's structure — the ordering RFC 3986 requires
427/// and the one a naive "decode the whole line first" gets wrong.
428///
429/// Paths are bytes on Unix, so a name that is not valid UTF-8 survives intact.
430///
431/// # On Windows
432///
433/// A URI's path is RFC 8089's Windows mapping rather than the path verbatim:
434/// `file:///C:/a%20b` is `C:\a b`, `file://server/share/x` is
435/// `\\server\share\x`, and a URI with no drive or share — `file:///tmp/x` — is
436/// skipped, because there is nothing to resolve it against. A name that
437/// decodes to `\` or `/` is skipped rather than turned into a separator. The
438/// same module encodes the Win32 backend's `CF_HDROP` answers, so what that
439/// backend hands out, this reads back.
440///
441/// ```
442/// use crcbl_shell::parse_uri_list;
443/// use std::path::PathBuf;
444///
445/// if cfg!(target_os = "windows") {
446///     let dropped = b"# comment\r\nfile:///C:/my%20scene.ron\r\nhttps://example.com/x\r\n";
447///     assert_eq!(
448///         parse_uri_list(dropped),
449///         vec![PathBuf::from(r"C:\my scene.ron")],
450///         "the URL is not a path, the space is decoded and the drive is found",
451///     );
452/// } else {
453///     let dropped = b"# comment\r\nfile:///tmp/my%20scene.ron\r\nhttps://example.com/x\r\n";
454///     assert_eq!(
455///         parse_uri_list(dropped),
456///         vec![PathBuf::from("/tmp/my scene.ron")],
457///         "the URL is not a path and the space is decoded",
458///     );
459/// }
460/// ```
461#[must_use]
462pub fn parse_uri_list(bytes: &[u8]) -> Vec<PathBuf> {
463    uri_list_lines(bytes).filter_map(file_uri_to_path).collect()
464}
465
466/// The URI lines of a `text/uri-list`, without comments, blanks or line ends.
467pub(crate) fn uri_list_lines(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
468    // Split on LF and trim a trailing CR, rather than splitting on CRLF: the
469    // format says CRLF, real senders send both, and a parser that insisted on
470    // CRLF would silently return nothing for half of them.
471    bytes
472        .split(|byte| *byte == b'\n')
473        .map(trim_ascii)
474        // `#` is a comment *line*; a `#` inside a URI is a fragment and is not
475        // this case.
476        .filter(|line| !line.is_empty() && line[0] != b'#')
477}
478
479/// One `file:` URI as a path, or `None` for anything that is not a local file.
480#[cfg(not(target_os = "windows"))]
481fn file_uri_to_path(uri: &[u8]) -> Option<PathBuf> {
482    file_uri_to_posix_path(uri)
483}
484
485/// As above, through RFC 8089's Windows mapping; see [`windows_uri`].
486#[cfg(target_os = "windows")]
487fn file_uri_to_path(uri: &[u8]) -> Option<PathBuf> {
488    windows_uri::file_uri_to_windows_path(uri).map(PathBuf::from)
489}
490
491/// One `file:` URI as a POSIX path, whatever the host, or `None` for anything
492/// that is not a local file.
493///
494/// [`parse_uri_list`]'s decoder everywhere but Windows, and what the AppKit
495/// backend reads a pasteboard file URL with — a macOS path is a POSIX path.
496/// Compiled into a Windows build only for tests, so that both of those keep
497/// being checked on every host.
498#[cfg(any(not(target_os = "windows"), test))]
499pub(crate) fn file_uri_to_posix_path(uri: &[u8]) -> Option<PathBuf> {
500    const SCHEME: &[u8] = b"file:";
501    let (prefix, rest) = uri.split_at_checked(SCHEME.len())?;
502    if !prefix.eq_ignore_ascii_case(SCHEME) {
503        return None;
504    }
505
506    let path = if let Some(authority) = rest.strip_prefix(b"//") {
507        // `file://<authority>/<path>`. The authority ends at the first `/`,
508        // which is also the first byte of the path.
509        let split = authority.iter().position(|byte| *byte == b'/')?;
510        let (host, path) = authority.split_at(split);
511        if !(host.is_empty() || host.eq_ignore_ascii_case(b"localhost")) {
512            return None;
513        }
514        path
515    } else if rest.starts_with(b"/") {
516        // `file:/path` — RFC 8089 permits the authority to be omitted entirely.
517        rest
518    } else {
519        // `file:relative` is not a thing this can resolve against anything.
520        return None;
521    };
522
523    let decoded = percent_decode(path);
524    if decoded.is_empty() {
525        return None;
526    }
527    bytes_to_path(decoded)
528}
529
530/// RFC 3986 percent-decoding, byte-wise.
531///
532/// A `%` that is not followed by two hex digits is a literal `%`: senders do
533/// emit them, and refusing the whole line over one would lose a real file.
534fn percent_decode(bytes: &[u8]) -> Vec<u8> {
535    const fn hex(byte: u8) -> Option<u8> {
536        match byte {
537            b'0'..=b'9' => Some(byte - b'0'),
538            b'a'..=b'f' => Some(byte - b'a' + 10),
539            b'A'..=b'F' => Some(byte - b'A' + 10),
540            _ => None,
541        }
542    }
543    let mut out = Vec::with_capacity(bytes.len());
544    let mut index = 0;
545    while index < bytes.len() {
546        if bytes[index] == b'%'
547            && let (Some(high), Some(low)) = (
548                bytes.get(index + 1).copied().and_then(hex),
549                bytes.get(index + 2).copied().and_then(hex),
550            )
551        {
552            out.push((high << 4) | low);
553            index += 3;
554            continue;
555        }
556        out.push(bytes[index]);
557        index += 1;
558    }
559    out
560}
561
562/// Decoded URI bytes as a path.
563///
564/// Unix paths are arbitrary bytes, and a file whose name is not valid UTF-8 is
565/// still a file that can be dropped on a window, so the bytes are taken as-is.
566#[cfg(unix)]
567fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
568    use std::os::unix::ffi::OsStringExt;
569    Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
570}
571
572/// As above. Everywhere else a path is not a byte string, and `text/uri-list`
573/// is defined as UTF-8, so anything else is rejected rather than mangled.
574#[cfg(all(not(unix), any(not(target_os = "windows"), test)))]
575fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
576    String::from_utf8(bytes).ok().map(PathBuf::from)
577}
578
579/// `[u8]::trim_ascii`, plus the CR of a CRLF.
580const fn trim_ascii(mut line: &[u8]) -> &[u8] {
581    while let [first, rest @ ..] = line {
582        if first.is_ascii_whitespace() {
583            line = rest;
584        } else {
585            break;
586        }
587    }
588    while let [rest @ .., last] = line {
589        if last.is_ascii_whitespace() {
590            line = rest;
591        } else {
592            break;
593        }
594    }
595    line
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn the_two_engine_mimes_have_the_specified_spellings() {
604        // These strings are a compatibility contract with every other
605        // application on the desktop and with older builds of this engine.
606        assert_eq!(MimeType::TextUtf8.as_str(), "text/plain;charset=utf-8");
607        assert_eq!(MimeType::CrcblRon.as_str(), "application/x-crcbl+ron");
608        assert_eq!(MimeType::UriList.as_str(), "text/uri-list");
609        assert_eq!(MimeType::Other("image/png").to_string(), "image/png");
610    }
611
612    #[test]
613    fn the_common_plain_text_spellings_are_all_accepted() {
614        for spelling in [
615            "text/plain;charset=utf-8",
616            "text/plain",
617            "UTF8_STRING",
618            "STRING",
619        ] {
620            assert_eq!(
621                MimeType::recognize(spelling),
622                Some(MimeType::TextUtf8),
623                "{spelling}"
624            );
625        }
626        assert_eq!(MimeType::recognize("text/html"), None);
627        assert_eq!(
628            MimeType::recognize("application/x-crcbl+ron"),
629            Some(MimeType::CrcblRon)
630        );
631        assert_eq!(
632            MimeType::recognize("text/uri-list"),
633            Some(MimeType::UriList)
634        );
635    }
636
637    #[test]
638    fn a_received_mime_holds_what_the_other_application_actually_said() {
639        // The hole this type closes: `MimeType::Other` is `&'static str` and
640        // cannot hold a string that arrived at runtime.
641        let foreign = ReceivedMime::new("application/vnd.some-editor.scene+json");
642        assert_eq!(foreign.recognized(), None);
643        assert_eq!(
644            foreign.as_str(),
645            "application/vnd.some-editor.scene+json",
646            "the literal spelling survives"
647        );
648        assert_eq!(foreign.to_string(), foreign.as_str());
649        assert!(!foreign.matches(MimeType::TextUtf8));
650
651        // An alternative spelling is preserved verbatim *and* recognized.
652        let plain = ReceivedMime::new("text/plain");
653        assert_eq!(plain.as_str(), "text/plain", "no canonicalization");
654        assert_eq!(plain.recognized(), Some(MimeType::TextUtf8));
655        assert!(
656            plain.matches(MimeType::TextUtf8),
657            "a paste handler asking for text must accept this"
658        );
659        assert_ne!(plain, ReceivedMime::from(MimeType::TextUtf8));
660
661        // The engine's own formats round-trip without allocating a new string.
662        for known in [MimeType::TextUtf8, MimeType::CrcblRon, MimeType::UriList] {
663            let received = ReceivedMime::new(known.as_str());
664            assert_eq!(received, ReceivedMime::from(known));
665            assert_eq!(received.recognized(), Some(known));
666            assert!(received.matches(known));
667        }
668    }
669
670    /// [`parse_uri_list`] through the POSIX decoder whatever the host, so the
671    /// rules every Unix backend relies on are checked on Windows too, where
672    /// `parse_uri_list` itself decodes Windows paths instead.
673    fn posix_uri_list(bytes: &[u8]) -> Vec<PathBuf> {
674        uri_list_lines(bytes)
675            .filter_map(file_uri_to_posix_path)
676            .collect()
677    }
678
679    #[cfg(target_os = "windows")]
680    #[test]
681    fn on_windows_a_uri_list_names_windows_paths() {
682        // The gap this closes: through the POSIX decoder `file:///C:/a` came
683        // back as `/C:/a`, which no Windows API opens.
684        assert_eq!(
685            parse_uri_list(
686                b"# a CF_HDROP, rendered\r\n\
687                  file:///C:/My%20Scene.ron\r\n\
688                  file://server/share/caf%C3%A9.png\r\n\
689                  file:///tmp/posix-only\r\n\
690                  https://example.com/x\r\n"
691            ),
692            vec![
693                PathBuf::from(r"C:\My Scene.ron"),
694                PathBuf::from(r"\\server\share\café.png"),
695            ],
696            "a POSIX path has no drive to resolve against and is skipped, like the URL"
697        );
698    }
699
700    #[test]
701    fn a_uri_list_is_crlf_separated_percent_encoded_and_may_carry_comments() {
702        // What a file manager actually sends for a two-file drop, byte for
703        // byte: a comment line the format permits, CRLF endings, and a space
704        // encoded as `%20`.
705        let dropped = b"#comment\r\n\
706                        file:///home/dev/My%20Scene.ron\r\n\
707                        file:///tmp/a%2Bb%2Fc.png\r\n";
708        assert_eq!(
709            posix_uri_list(dropped),
710            vec![
711                PathBuf::from("/home/dev/My Scene.ron"),
712                // `%2F` is a slash *in the name*, decoded after the URI has
713                // been split — decoding first would invent a directory.
714                PathBuf::from("/tmp/a+b/c.png"),
715            ]
716        );
717
718        // Bare LF, no trailing newline, and leading whitespace: senders do all
719        // three, and insisting on the letter of the spec would return nothing.
720        assert_eq!(
721            posix_uri_list(b"  file:///tmp/one\nfile:///tmp/two"),
722            vec![PathBuf::from("/tmp/one"), PathBuf::from("/tmp/two")]
723        );
724        assert!(posix_uri_list(b"").is_empty());
725        assert!(posix_uri_list(b"\r\n\r\n# only comments\r\n").is_empty());
726    }
727
728    #[test]
729    fn a_uri_that_is_not_a_local_file_is_not_turned_into_a_path() {
730        // The decision this function is most likely to get wrong: a URL is not
731        // a path, and producing `example.com/x` from one would look like it
732        // worked until something opened it.
733        for hostile in [
734            &b"https://example.com/x"[..],
735            b"trash:///deleted",
736            b"smb://server/share/file",
737            b"file://remotehost/etc/passwd",
738            b"file:relative/path",
739            b"file://",
740            b"not a uri at all",
741        ] {
742            assert!(
743                posix_uri_list(hostile).is_empty(),
744                "{}",
745                String::from_utf8_lossy(hostile)
746            );
747        }
748
749        // An empty authority and an explicit `localhost` are both this machine,
750        // and `file:/path` is RFC 8089's authority-less form.
751        assert_eq!(
752            posix_uri_list(b"FILE:///tmp/x\nfile://localhost/tmp/y\nfile:/tmp/z"),
753            vec![
754                PathBuf::from("/tmp/x"),
755                PathBuf::from("/tmp/y"),
756                PathBuf::from("/tmp/z"),
757            ],
758            "the scheme is case-insensitive and the authority may be omitted"
759        );
760        assert_eq!(
761            posix_uri_list(b"file:///"),
762            vec![PathBuf::from("/")],
763            "the root directory is a real path, degenerate as a drop is"
764        );
765
766        // A stray `%` is a literal `%`, not a reason to lose the file.
767        assert_eq!(
768            posix_uri_list(b"file:///tmp/100%%20done"),
769            vec![PathBuf::from("/tmp/100% done")]
770        );
771        assert_eq!(
772            posix_uri_list(b"file:///tmp/trailing%"),
773            vec![PathBuf::from("/tmp/trailing%")]
774        );
775    }
776
777    #[cfg(unix)]
778    #[test]
779    fn a_path_that_is_not_utf8_survives_the_round_trip() {
780        // Unix filenames are bytes. A backend that went through `String` would
781        // replace this one with U+FFFD and hand back a path that does not
782        // exist.
783        use std::os::unix::ffi::OsStrExt;
784        let paths = parse_uri_list(b"file:///tmp/%FF%FE.bin");
785        assert_eq!(paths.len(), 1);
786        assert_eq!(paths[0].as_os_str().as_bytes(), b"/tmp/\xff\xfe.bin");
787    }
788
789    #[test]
790    fn offers_carry_bytes_without_copying() {
791        let offer = ClipboardOffer::text("hello");
792        assert_eq!(offer.mime, MimeType::TextUtf8);
793        assert_eq!(offer.bytes, b"hello");
794        assert_eq!(ClipboardOffer::ron("(x:1)").mime, MimeType::CrcblRon);
795        assert_eq!(ClipboardRequestId(2).to_string(), "clipboard request 2");
796    }
797}