use std::path::{Path, PathBuf};
const SCHEME: &str = "file://";
const LOCALHOST: &str = "localhost";
const LINE_END: &[u8] = b"\r\n";
const COMMENT: u8 = b'#';
const PERCENT: u8 = b'%';
const HEX_RADIX: u32 = 16;
const GNOME_COPY_VERB: &[u8] = b"copy";
#[must_use]
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
let mut out = Vec::new();
for path in paths {
out.extend_from_slice(&uri_of(path));
out.extend_from_slice(LINE_END);
}
out
}
#[must_use]
pub fn encode_gnome_copied(paths: &[PathBuf]) -> Vec<u8> {
let mut out = GNOME_COPY_VERB.to_vec();
for path in paths {
out.push(b'\n');
out.extend_from_slice(&uri_of(path));
}
out
}
#[must_use]
pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
bytes
.split(|byte| *byte == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| line.first() != Some(&COMMENT))
.filter_map(path_of)
.collect()
}
fn uri_of(path: &Path) -> Vec<u8> {
let mut uri = SCHEME.as_bytes().to_vec();
for byte in path_bytes(path) {
if is_unreserved(byte) || byte == b'/' {
uri.push(byte);
} else {
uri.extend_from_slice(format!("%{byte:02X}").as_bytes());
}
}
uri
}
fn path_of(line: &[u8]) -> Option<PathBuf> {
let rest = line.strip_prefix(SCHEME.as_bytes())?;
let slash = rest.iter().position(|byte| *byte == b'/')?;
let (host, encoded) = rest.split_at(slash);
if !host.is_empty() && !host.eq_ignore_ascii_case(LOCALHOST.as_bytes()) {
return None;
}
Some(path_from_bytes(&percent_decode(encoded)?))
}
fn percent_decode(encoded: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(encoded.len());
let mut bytes = encoded.iter().copied();
while let Some(byte) = bytes.next() {
if byte != PERCENT {
out.push(byte);
continue;
}
let high = char::from(bytes.next()?).to_digit(HEX_RADIX)?;
let low = char::from(bytes.next()?).to_digit(HEX_RADIX)?;
out.push(u8::try_from(high * HEX_RADIX + low).ok()?);
}
Some(out)
}
fn is_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[cfg(unix)]
fn path_bytes(path: &Path) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(unix)]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
fn path_bytes(path: &Path) -> Vec<u8> {
let text = path.to_string_lossy().replace('\\', "/");
if text.starts_with('/') {
text.into_bytes()
} else {
format!("/{text}").into_bytes()
}
}
#[cfg(not(unix))]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
let text = String::from_utf8_lossy(bytes).replace('/', "\\");
let drive = text.as_bytes().get(2) == Some(&b':');
PathBuf::from(if drive { &text[1..] } else { &text })
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn encode_special_characters_percent_encodes_each_path() {
let cases = [
("/tmp/plain.txt", "file:///tmp/plain.txt"),
("/tmp/a b", "file:///tmp/a%20b"),
("/tmp/100%", "file:///tmp/100%25"),
("/tmp/#1", "file:///tmp/%231"),
("/tmp/\u{e9}", "file:///tmp/%C3%A9"),
];
for (path, uri) in cases {
let encoded = encode(&[PathBuf::from(path)]);
assert_eq!(encoded, format!("{uri}\r\n").into_bytes(), "{path}");
}
}
#[test]
fn decode_mixed_lines_keeps_only_local_file_paths() {
let list = "# comment\r\n\
file:///tmp/a%20b\r\n\
file://localhost/tmp/c\n\
file://remote/tmp/d\r\n\
https://example.com/e\r\n\
file:///tmp/bad%zz\r\n\
file:///tmp/%C3%A9\r\n\
copy\n";
let paths = decode(list.as_bytes());
let expected = ["/tmp/a b", "/tmp/c", "/tmp/\u{e9}"].map(PathBuf::from);
assert_eq!(paths, expected);
}
#[test]
fn encode_gnome_copied_roundtrips_through_decode() {
let paths = ["/tmp/a b", "/tmp/c"].map(PathBuf::from);
let encoded = encode_gnome_copied(&paths);
assert_eq!(encoded, b"copy\nfile:///tmp/a%20b\nfile:///tmp/c");
assert_eq!(decode(&encoded), paths);
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
#[test]
fn encode_drive_and_unc_paths_use_file_uri_forms() {
let cases = [
(r"C:\a b\c.txt", "file:///C%3A/a%20b/c.txt"),
(r"\\server\share\d", "file:////server/share/d"),
];
for (path, uri) in cases {
let encoded = encode(&[PathBuf::from(path)]);
assert_eq!(encoded, format!("{uri}\r\n").into_bytes(), "{path}");
}
}
#[test]
fn decode_drive_and_unc_uris_restore_backslash_paths() {
let list = "file:///C:/a%20b/c.txt\r\nfile:///C%3A/d\r\nfile:////server/share/e\r\n";
let paths = decode(list.as_bytes());
let expected = [r"C:\a b\c.txt", r"C:\d", r"\\server\share\e"].map(PathBuf::from);
assert_eq!(paths, expected);
}
}