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}