use core::fmt;
use std::path::PathBuf;
#[cfg(any(target_os = "windows", test))]
pub(crate) mod windows_uri;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MimeType {
TextUtf8,
CrcblRon,
UriList,
Other(&'static str),
}
impl MimeType {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::TextUtf8 => "text/plain;charset=utf-8",
Self::CrcblRon => "application/x-crcbl+ron",
Self::UriList => "text/uri-list",
Self::Other(mime) => mime,
}
}
#[must_use]
pub fn recognize(mime: &str) -> Option<Self> {
match mime {
"text/plain;charset=utf-8" | "text/plain" | "UTF8_STRING" | "STRING" => {
Some(Self::TextUtf8)
}
"application/x-crcbl+ron" => Some(Self::CrcblRon),
"text/uri-list" => Some(Self::UriList),
_ => None,
}
}
}
impl fmt::Display for MimeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ReceivedMime(std::borrow::Cow<'static, str>);
impl ReceivedMime {
#[must_use]
pub fn new(mime: &str) -> Self {
for known in [MimeType::TextUtf8, MimeType::CrcblRon, MimeType::UriList] {
if known.as_str() == mime {
return Self(std::borrow::Cow::Borrowed(known.as_str()));
}
}
Self(std::borrow::Cow::Owned(mime.to_string()))
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn recognized(&self) -> Option<MimeType> {
MimeType::recognize(&self.0)
}
#[must_use]
pub fn matches(&self, mime: MimeType) -> bool {
self.recognized() == Some(mime) || self.0 == mime.as_str()
}
}
impl From<MimeType> for ReceivedMime {
fn from(mime: MimeType) -> Self {
Self(std::borrow::Cow::Borrowed(mime.as_str()))
}
}
impl fmt::Display for ReceivedMime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ClipboardOffer<'a> {
pub mime: MimeType,
pub bytes: &'a [u8],
}
impl<'a> ClipboardOffer<'a> {
#[inline]
#[must_use]
pub const fn text(text: &'a str) -> Self {
Self {
mime: MimeType::TextUtf8,
bytes: text.as_bytes(),
}
}
#[inline]
#[must_use]
pub const fn ron(ron: &'a str) -> Self {
Self {
mime: MimeType::CrcblRon,
bytes: ron.as_bytes(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClipboardContent {
Bytes(Vec<u8>),
Empty,
Unavailable,
}
impl ClipboardContent {
#[inline]
#[must_use]
pub fn bytes(&self) -> Option<&[u8]> {
match self {
Self::Bytes(bytes) => Some(bytes),
Self::Empty | Self::Unavailable => None,
}
}
#[inline]
#[must_use]
pub fn into_bytes(self) -> Option<Vec<u8>> {
match self {
Self::Bytes(bytes) => Some(bytes),
Self::Empty | Self::Unavailable => None,
}
}
#[must_use]
pub fn text(&self) -> Option<&str> {
core::str::from_utf8(self.bytes()?).ok()
}
#[inline]
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Bytes(_) => "Bytes",
Self::Empty => "Empty",
Self::Unavailable => "Unavailable",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClipboardRequestId(pub u32);
impl fmt::Display for ClipboardRequestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "clipboard request {}", self.0)
}
}
#[must_use]
pub fn parse_uri_list(bytes: &[u8]) -> Vec<PathBuf> {
uri_list_lines(bytes).filter_map(file_uri_to_path).collect()
}
pub(crate) fn uri_list_lines(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
bytes
.split(|byte| *byte == b'\n')
.map(trim_ascii)
.filter(|line| !line.is_empty() && line[0] != b'#')
}
#[cfg(not(target_os = "windows"))]
fn file_uri_to_path(uri: &[u8]) -> Option<PathBuf> {
file_uri_to_posix_path(uri)
}
#[cfg(target_os = "windows")]
fn file_uri_to_path(uri: &[u8]) -> Option<PathBuf> {
windows_uri::file_uri_to_windows_path(uri).map(PathBuf::from)
}
#[cfg(any(not(target_os = "windows"), test))]
pub(crate) fn file_uri_to_posix_path(uri: &[u8]) -> Option<PathBuf> {
const SCHEME: &[u8] = b"file:";
let (prefix, rest) = uri.split_at_checked(SCHEME.len())?;
if !prefix.eq_ignore_ascii_case(SCHEME) {
return None;
}
let path = if let Some(authority) = rest.strip_prefix(b"//") {
let split = authority.iter().position(|byte| *byte == b'/')?;
let (host, path) = authority.split_at(split);
if !(host.is_empty() || host.eq_ignore_ascii_case(b"localhost")) {
return None;
}
path
} else if rest.starts_with(b"/") {
rest
} else {
return None;
};
let decoded = percent_decode(path);
if decoded.is_empty() {
return None;
}
bytes_to_path(decoded)
}
fn percent_decode(bytes: &[u8]) -> Vec<u8> {
const fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& let (Some(high), Some(low)) = (
bytes.get(index + 1).copied().and_then(hex),
bytes.get(index + 2).copied().and_then(hex),
)
{
out.push((high << 4) | low);
index += 3;
continue;
}
out.push(bytes[index]);
index += 1;
}
out
}
#[cfg(unix)]
fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
}
#[cfg(all(not(unix), any(not(target_os = "windows"), test)))]
fn bytes_to_path(bytes: Vec<u8>) -> Option<PathBuf> {
String::from_utf8(bytes).ok().map(PathBuf::from)
}
const fn trim_ascii(mut line: &[u8]) -> &[u8] {
while let [first, rest @ ..] = line {
if first.is_ascii_whitespace() {
line = rest;
} else {
break;
}
}
while let [rest @ .., last] = line {
if last.is_ascii_whitespace() {
line = rest;
} else {
break;
}
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_two_engine_mimes_have_the_specified_spellings() {
assert_eq!(MimeType::TextUtf8.as_str(), "text/plain;charset=utf-8");
assert_eq!(MimeType::CrcblRon.as_str(), "application/x-crcbl+ron");
assert_eq!(MimeType::UriList.as_str(), "text/uri-list");
assert_eq!(MimeType::Other("image/png").to_string(), "image/png");
}
#[test]
fn the_common_plain_text_spellings_are_all_accepted() {
for spelling in [
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
] {
assert_eq!(
MimeType::recognize(spelling),
Some(MimeType::TextUtf8),
"{spelling}"
);
}
assert_eq!(MimeType::recognize("text/html"), None);
assert_eq!(
MimeType::recognize("application/x-crcbl+ron"),
Some(MimeType::CrcblRon)
);
assert_eq!(
MimeType::recognize("text/uri-list"),
Some(MimeType::UriList)
);
}
#[test]
fn a_received_mime_holds_what_the_other_application_actually_said() {
let foreign = ReceivedMime::new("application/vnd.some-editor.scene+json");
assert_eq!(foreign.recognized(), None);
assert_eq!(
foreign.as_str(),
"application/vnd.some-editor.scene+json",
"the literal spelling survives"
);
assert_eq!(foreign.to_string(), foreign.as_str());
assert!(!foreign.matches(MimeType::TextUtf8));
let plain = ReceivedMime::new("text/plain");
assert_eq!(plain.as_str(), "text/plain", "no canonicalization");
assert_eq!(plain.recognized(), Some(MimeType::TextUtf8));
assert!(
plain.matches(MimeType::TextUtf8),
"a paste handler asking for text must accept this"
);
assert_ne!(plain, ReceivedMime::from(MimeType::TextUtf8));
for known in [MimeType::TextUtf8, MimeType::CrcblRon, MimeType::UriList] {
let received = ReceivedMime::new(known.as_str());
assert_eq!(received, ReceivedMime::from(known));
assert_eq!(received.recognized(), Some(known));
assert!(received.matches(known));
}
}
fn posix_uri_list(bytes: &[u8]) -> Vec<PathBuf> {
uri_list_lines(bytes)
.filter_map(file_uri_to_posix_path)
.collect()
}
#[cfg(target_os = "windows")]
#[test]
fn on_windows_a_uri_list_names_windows_paths() {
assert_eq!(
parse_uri_list(
b"# a CF_HDROP, rendered\r\n\
file:///C:/My%20Scene.ron\r\n\
file://server/share/caf%C3%A9.png\r\n\
file:///tmp/posix-only\r\n\
https://example.com/x\r\n"
),
vec![
PathBuf::from(r"C:\My Scene.ron"),
PathBuf::from(r"\\server\share\café.png"),
],
"a POSIX path has no drive to resolve against and is skipped, like the URL"
);
}
#[test]
fn a_uri_list_is_crlf_separated_percent_encoded_and_may_carry_comments() {
let dropped = b"#comment\r\n\
file:///home/dev/My%20Scene.ron\r\n\
file:///tmp/a%2Bb%2Fc.png\r\n";
assert_eq!(
posix_uri_list(dropped),
vec![
PathBuf::from("/home/dev/My Scene.ron"),
PathBuf::from("/tmp/a+b/c.png"),
]
);
assert_eq!(
posix_uri_list(b" file:///tmp/one\nfile:///tmp/two"),
vec![PathBuf::from("/tmp/one"), PathBuf::from("/tmp/two")]
);
assert!(posix_uri_list(b"").is_empty());
assert!(posix_uri_list(b"\r\n\r\n# only comments\r\n").is_empty());
}
#[test]
fn a_uri_that_is_not_a_local_file_is_not_turned_into_a_path() {
for hostile in [
&b"https://example.com/x"[..],
b"trash:///deleted",
b"smb://server/share/file",
b"file://remotehost/etc/passwd",
b"file:relative/path",
b"file://",
b"not a uri at all",
] {
assert!(
posix_uri_list(hostile).is_empty(),
"{}",
String::from_utf8_lossy(hostile)
);
}
assert_eq!(
posix_uri_list(b"FILE:///tmp/x\nfile://localhost/tmp/y\nfile:/tmp/z"),
vec![
PathBuf::from("/tmp/x"),
PathBuf::from("/tmp/y"),
PathBuf::from("/tmp/z"),
],
"the scheme is case-insensitive and the authority may be omitted"
);
assert_eq!(
posix_uri_list(b"file:///"),
vec![PathBuf::from("/")],
"the root directory is a real path, degenerate as a drop is"
);
assert_eq!(
posix_uri_list(b"file:///tmp/100%%20done"),
vec![PathBuf::from("/tmp/100% done")]
);
assert_eq!(
posix_uri_list(b"file:///tmp/trailing%"),
vec![PathBuf::from("/tmp/trailing%")]
);
}
#[cfg(unix)]
#[test]
fn a_path_that_is_not_utf8_survives_the_round_trip() {
use std::os::unix::ffi::OsStrExt;
let paths = parse_uri_list(b"file:///tmp/%FF%FE.bin");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].as_os_str().as_bytes(), b"/tmp/\xff\xfe.bin");
}
#[test]
fn offers_carry_bytes_without_copying() {
let offer = ClipboardOffer::text("hello");
assert_eq!(offer.mime, MimeType::TextUtf8);
assert_eq!(offer.bytes, b"hello");
assert_eq!(ClipboardOffer::ron("(x:1)").mime, MimeType::CrcblRon);
assert_eq!(ClipboardRequestId(2).to_string(), "clipboard request 2");
}
}