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}