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    // The path ends at a `?` or `#`, which RFC 3986 section 3.3 makes a query
506    // or fragment delimiter rather than part of any name — an encoder that
507    // meant the character escapes it. `windows_uri` ends it the same way.
508    let end = rest
509        .iter()
510        .position(|byte| matches!(byte, b'?' | b'#'))
511        .unwrap_or(rest.len());
512    let rest = &rest[..end];
513
514    let path = if let Some(authority) = rest.strip_prefix(b"//") {
515        // `file://<authority>/<path>`. The authority ends at the first `/`,
516        // which is also the first byte of the path.
517        let split = authority.iter().position(|byte| *byte == b'/')?;
518        let (host, path) = authority.split_at(split);
519        if !(host.is_empty() || host.eq_ignore_ascii_case(b"localhost")) {
520            return None;
521        }
522        path
523    } else if rest.starts_with(b"/") {
524        // `file:/path` — RFC 8089 permits the authority to be omitted entirely.
525        rest
526    } else {
527        // `file:relative` is not a thing this can resolve against anything.
528        return None;
529    };
530
531    let decoded = percent_decode(path);
532    if decoded.is_empty() {
533        return None;
534    }
535    bytes_to_path(decoded)
536}
537
538/// RFC 3986 percent-decoding, byte-wise.
539///
540/// A `%` that is not followed by two hex digits is a literal `%`: senders do
541/// emit them, and refusing the whole line over one would lose a real file.
542fn percent_decode(bytes: &[u8]) -> Vec<u8> {
543    const fn hex(byte: u8) -> Option<u8> {
544        match byte {
545            b'0'..=b'9' => Some(byte - b'0'),
546            b'a'..=b'f' => Some(byte - b'a' + 10),
547            b'A'..=b'F' => Some(byte - b'A' + 10),
548            _ => None,
549        }
550    }
551    let mut out = Vec::with_capacity(bytes.len());
552    let mut index = 0;
553    while index < bytes.len() {
554        if bytes[index] == b'%'
555            && let (Some(high), Some(low)) = (
556                bytes.get(index + 1).copied().and_then(hex),
557                bytes.get(index + 2).copied().and_then(hex),
558            )
559        {
560            out.push((high << 4) | low);
561            index += 3;
562            continue;
563        }
564        out.push(bytes[index]);
565        index += 1;
566    }
567    out
568}
569
570/// Decoded URI bytes as a path.
571///
572/// Unix paths are arbitrary bytes, and a file whose name is not valid UTF-8 is
573/// still a file that can be dropped on a window, so the bytes are taken as-is.
574#[cfg(unix)]
575fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
576    use std::os::unix::ffi::OsStringExt;
577    Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
578}
579
580/// As above. Everywhere else a path is not a byte string, and `text/uri-list`
581/// is defined as UTF-8, so anything else is rejected rather than mangled.
582#[cfg(all(not(unix), any(not(target_os = "windows"), test)))]
583fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
584    String::from_utf8(bytes).ok().map(PathBuf::from)
585}
586
587/// `[u8]::trim_ascii`, plus the CR of a CRLF.
588const fn trim_ascii(mut line: &[u8]) -> &[u8] {
589    while let [first, rest @ ..] = line {
590        if first.is_ascii_whitespace() {
591            line = rest;
592        } else {
593            break;
594        }
595    }
596    while let [rest @ .., last] = line {
597        if last.is_ascii_whitespace() {
598            line = rest;
599        } else {
600            break;
601        }
602    }
603    line
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn the_two_engine_mimes_have_the_specified_spellings() {
612        // These strings are a compatibility contract with every other
613        // application on the desktop and with older builds of this engine.
614        assert_eq!(MimeType::TextUtf8.as_str(), "text/plain;charset=utf-8");
615        assert_eq!(MimeType::CrcblRon.as_str(), "application/x-crcbl+ron");
616        assert_eq!(MimeType::UriList.as_str(), "text/uri-list");
617        assert_eq!(MimeType::Other("image/png").to_string(), "image/png");
618    }
619
620    #[test]
621    fn the_common_plain_text_spellings_are_all_accepted() {
622        for spelling in [
623            "text/plain;charset=utf-8",
624            "text/plain",
625            "UTF8_STRING",
626            "STRING",
627        ] {
628            assert_eq!(
629                MimeType::recognize(spelling),
630                Some(MimeType::TextUtf8),
631                "{spelling}"
632            );
633        }
634        assert_eq!(MimeType::recognize("text/html"), None);
635        assert_eq!(
636            MimeType::recognize("application/x-crcbl+ron"),
637            Some(MimeType::CrcblRon)
638        );
639        assert_eq!(
640            MimeType::recognize("text/uri-list"),
641            Some(MimeType::UriList)
642        );
643    }
644
645    #[test]
646    fn a_received_mime_holds_what_the_other_application_actually_said() {
647        // The hole this type closes: `MimeType::Other` is `&'static str` and
648        // cannot hold a string that arrived at runtime.
649        let foreign = ReceivedMime::new("application/vnd.some-editor.scene+json");
650        assert_eq!(foreign.recognized(), None);
651        assert_eq!(
652            foreign.as_str(),
653            "application/vnd.some-editor.scene+json",
654            "the literal spelling survives"
655        );
656        assert_eq!(foreign.to_string(), foreign.as_str());
657        assert!(!foreign.matches(MimeType::TextUtf8));
658
659        // An alternative spelling is preserved verbatim *and* recognized.
660        let plain = ReceivedMime::new("text/plain");
661        assert_eq!(plain.as_str(), "text/plain", "no canonicalization");
662        assert_eq!(plain.recognized(), Some(MimeType::TextUtf8));
663        assert!(
664            plain.matches(MimeType::TextUtf8),
665            "a paste handler asking for text must accept this"
666        );
667        assert_ne!(plain, ReceivedMime::from(MimeType::TextUtf8));
668
669        // The engine's own formats round-trip without allocating a new string.
670        for known in [MimeType::TextUtf8, MimeType::CrcblRon, MimeType::UriList] {
671            let received = ReceivedMime::new(known.as_str());
672            assert_eq!(received, ReceivedMime::from(known));
673            assert_eq!(received.recognized(), Some(known));
674            assert!(received.matches(known));
675        }
676    }
677
678    /// [`parse_uri_list`] through the POSIX decoder whatever the host, so the
679    /// rules every Unix backend relies on are checked on Windows too, where
680    /// `parse_uri_list` itself decodes Windows paths instead.
681    fn posix_uri_list(bytes: &[u8]) -> Vec<PathBuf> {
682        uri_list_lines(bytes)
683            .filter_map(file_uri_to_posix_path)
684            .collect()
685    }
686
687    #[cfg(target_os = "windows")]
688    #[test]
689    fn on_windows_a_uri_list_names_windows_paths() {
690        // The gap this closes: through the POSIX decoder `file:///C:/a` came
691        // back as `/C:/a`, which no Windows API opens.
692        assert_eq!(
693            parse_uri_list(
694                b"# a CF_HDROP, rendered\r\n\
695                  file:///C:/My%20Scene.ron\r\n\
696                  file://server/share/caf%C3%A9.png\r\n\
697                  file:///tmp/posix-only\r\n\
698                  https://example.com/x\r\n"
699            ),
700            vec![
701                PathBuf::from(r"C:\My Scene.ron"),
702                PathBuf::from(r"\\server\share\café.png"),
703            ],
704            "a POSIX path has no drive to resolve against and is skipped, like the URL"
705        );
706    }
707
708    #[test]
709    fn a_uri_list_is_crlf_separated_percent_encoded_and_may_carry_comments() {
710        // What a file manager actually sends for a two-file drop, byte for
711        // byte: a comment line the format permits, CRLF endings, and a space
712        // encoded as `%20`.
713        let dropped = b"#comment\r\n\
714                        file:///home/dev/My%20Scene.ron\r\n\
715                        file:///tmp/a%2Bb%2Fc.png\r\n";
716        assert_eq!(
717            posix_uri_list(dropped),
718            vec![
719                PathBuf::from("/home/dev/My Scene.ron"),
720                // `%2F` is a slash *in the name*, decoded after the URI has
721                // been split — decoding first would invent a directory.
722                PathBuf::from("/tmp/a+b/c.png"),
723            ]
724        );
725
726        // Bare LF, no trailing newline, and leading whitespace: senders do all
727        // three, and insisting on the letter of the spec would return nothing.
728        assert_eq!(
729            posix_uri_list(b"  file:///tmp/one\nfile:///tmp/two"),
730            vec![PathBuf::from("/tmp/one"), PathBuf::from("/tmp/two")]
731        );
732        assert!(posix_uri_list(b"").is_empty());
733        assert!(posix_uri_list(b"\r\n\r\n# only comments\r\n").is_empty());
734    }
735
736    #[test]
737    fn a_uri_that_is_not_a_local_file_is_not_turned_into_a_path() {
738        // The decision this function is most likely to get wrong: a URL is not
739        // a path, and producing `example.com/x` from one would look like it
740        // worked until something opened it.
741        for hostile in [
742            &b"https://example.com/x"[..],
743            b"trash:///deleted",
744            b"smb://server/share/file",
745            b"file://remotehost/etc/passwd",
746            b"file:relative/path",
747            b"file://",
748            b"not a uri at all",
749        ] {
750            assert!(
751                posix_uri_list(hostile).is_empty(),
752                "{}",
753                String::from_utf8_lossy(hostile)
754            );
755        }
756
757        // An empty authority and an explicit `localhost` are both this machine,
758        // and `file:/path` is RFC 8089's authority-less form.
759        assert_eq!(
760            posix_uri_list(b"FILE:///tmp/x\nfile://localhost/tmp/y\nfile:/tmp/z"),
761            vec![
762                PathBuf::from("/tmp/x"),
763                PathBuf::from("/tmp/y"),
764                PathBuf::from("/tmp/z"),
765            ],
766            "the scheme is case-insensitive and the authority may be omitted"
767        );
768        assert_eq!(
769            posix_uri_list(b"file:///"),
770            vec![PathBuf::from("/")],
771            "the root directory is a real path, degenerate as a drop is"
772        );
773
774        // A stray `%` is a literal `%`, not a reason to lose the file.
775        assert_eq!(
776            posix_uri_list(b"file:///tmp/100%%20done"),
777            vec![PathBuf::from("/tmp/100% done")]
778        );
779        assert_eq!(
780            posix_uri_list(b"file:///tmp/trailing%"),
781            vec![PathBuf::from("/tmp/trailing%")]
782        );
783
784        // A query or fragment is not part of the name; an escaped `#` or `?`
785        // is, which is how an encoder spells a name holding one.
786        assert_eq!(
787            posix_uri_list(
788                b"file:///tmp/a%23b#fragment\nfile:///tmp/c?query\nfile:///tmp/d%3Fe\n\
789                  file://localhost/tmp/f#g"
790            ),
791            vec![
792                PathBuf::from("/tmp/a#b"),
793                PathBuf::from("/tmp/c"),
794                PathBuf::from("/tmp/d?e"),
795                PathBuf::from("/tmp/f"),
796            ]
797        );
798        assert!(
799            posix_uri_list(b"file://#/tmp/x").is_empty(),
800            "a fragment that swallows the whole path leaves no file"
801        );
802    }
803
804    #[cfg(unix)]
805    #[test]
806    fn a_path_that_is_not_utf8_survives_the_round_trip() {
807        // Unix filenames are bytes. A backend that went through `String` would
808        // replace this one with U+FFFD and hand back a path that does not
809        // exist.
810        use std::os::unix::ffi::OsStrExt;
811        let paths = parse_uri_list(b"file:///tmp/%FF%FE.bin");
812        assert_eq!(paths.len(), 1);
813        assert_eq!(paths[0].as_os_str().as_bytes(), b"/tmp/\xff\xfe.bin");
814    }
815
816    #[test]
817    fn offers_carry_bytes_without_copying() {
818        let offer = ClipboardOffer::text("hello");
819        assert_eq!(offer.mime, MimeType::TextUtf8);
820        assert_eq!(offer.bytes, b"hello");
821        assert_eq!(ClipboardOffer::ron("(x:1)").mime, MimeType::CrcblRon);
822        assert_eq!(ClipboardRequestId(2).to_string(), "clipboard request 2");
823    }
824}