use super::percent_decode;
#[must_use]
pub(crate) fn windows_path_to_file_uri(path: &str) -> Option<String> {
let path = path.strip_prefix(r"\\?\").map_or_else(
|| path.to_string(),
|verbatim| {
verbatim
.strip_prefix(r"UNC\")
.map_or_else(|| verbatim.to_string(), |unc| format!(r"\\{unc}"))
},
);
let is_separator = |c: char| c == '\\' || c == '/';
let mut uri = String::from("file://");
let rest = if let Some(unc) = path
.strip_prefix(['\\', '/'])
.and_then(|p| p.strip_prefix(['\\', '/']))
{
let split = unc.find(is_separator)?;
let (server, rest) = unc.split_at(split);
let share = rest[1..].split(is_separator).next().unwrap_or("");
if server.is_empty() || server == "." || share.is_empty() {
return None;
}
percent_encode_into(&mut uri, server);
rest
} else {
let bytes = path.as_bytes();
let has_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& is_separator(char::from(bytes[2]));
if !has_drive {
return None;
}
uri.push('/');
uri.push_str(&path[..2]);
&path[2..]
};
for (index, segment) in rest.split(is_separator).enumerate() {
if index > 0 {
uri.push('/');
}
percent_encode_into(&mut uri, segment);
}
Some(uri)
}
#[must_use]
pub(crate) fn file_uri_to_windows_path(uri: &[u8]) -> Option<String> {
const SCHEME: &[u8] = b"file:";
let (prefix, rest) = uri.split_at_checked(SCHEME.len())?;
if !prefix.eq_ignore_ascii_case(SCHEME) {
return None;
}
let end = rest
.iter()
.position(|byte| matches!(byte, b'?' | b'#'))
.unwrap_or(rest.len());
let rest = &rest[..end];
let (host, path) = match rest.strip_prefix(b"//") {
Some(authority) => split_authority(authority),
None => (&b""[..], rest),
};
let (host, path) = if host.is_empty() || host.eq_ignore_ascii_case(b"localhost") {
match path.strip_prefix(b"//") {
Some(unc) => split_authority(unc),
None => (&b""[..], path),
}
} else {
(host, path)
};
if host.is_empty() {
let mut segments = path
.strip_prefix(b"/")
.unwrap_or(path)
.split(|b| *b == b'/');
let drive = decode_segment(segments.next()?)?;
let letter = match drive.as_bytes() {
[letter, b':' | b'|'] if letter.is_ascii_alphabetic() => char::from(*letter),
_ => return None,
};
let mut windows = format!("{letter}:");
let mut any = false;
for segment in segments {
windows.push('\\');
windows.push_str(&decode_segment(segment)?);
any = true;
}
if !any {
windows.push('\\');
}
return Some(windows);
}
let server = decode_segment(host)?;
if server.contains(':') {
return None;
}
let mut segments = path.split(|b| *b == b'/').skip(1);
let share = decode_segment(segments.next()?)?;
if share.is_empty() {
return None;
}
let mut windows = format!(r"\\{server}\{share}");
for segment in segments {
windows.push('\\');
windows.push_str(&decode_segment(segment)?);
}
Some(windows)
}
#[must_use]
pub(crate) fn uri_list_from_windows_paths<'a>(paths: impl IntoIterator<Item = &'a str>) -> String {
let mut list = String::new();
for path in paths {
match windows_path_to_file_uri(path) {
Some(uri) => {
list.push_str(&uri);
list.push_str("\r\n");
}
None => crcbl_core::log::warn!(
"{path:?} has no file: URI, so it is left out of the text/uri-list"
),
}
}
list
}
fn split_authority(authority: &[u8]) -> (&[u8], &[u8]) {
let split = authority
.iter()
.position(|byte| *byte == b'/')
.unwrap_or(authority.len());
authority.split_at(split)
}
fn decode_segment(segment: &[u8]) -> Option<String> {
let decoded = String::from_utf8(percent_decode(segment)).ok()?;
(!decoded.contains(['\\', '/', '\0'])).then_some(decoded)
}
fn percent_encode_into(out: &mut String, text: &str) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(byte));
} else {
out.push('%');
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0F)]));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const KNOWN: &[(&str, &str)] = &[
(r"C:\a b\c", "file:///C:/a%20b/c"),
(r"C:\", "file:///C:/"),
(r"d:\x.txt", "file:///d:/x.txt"),
(r"\\server\share\x", "file://server/share/x"),
(r"\\server\share", "file://server/share"),
(r"\\my-nas.local\p q\r", "file://my-nas.local/p%20q/r"),
(r"C:\100% #1\a&b", "file:///C:/100%25%20%231/a%26b"),
(
r"C:\café\プ\🎮.png",
"file:///C:/caf%C3%A9/%E3%83%97/%F0%9F%8E%AE.png",
),
(r"C:\a~b_c-d.e", "file:///C:/a~b_c-d.e"),
];
#[test]
fn known_paths_encode_to_the_uris_the_rfcs_give() {
for (path, uri) in KNOWN {
assert_eq!(
windows_path_to_file_uri(path).as_deref(),
Some(*uri),
"{path}"
);
}
}
#[test]
fn known_uris_decode_to_the_paths_they_name() {
for (path, uri) in KNOWN {
assert_eq!(
file_uri_to_windows_path(uri.as_bytes()).as_deref(),
Some(*path),
"{uri}"
);
}
}
#[test]
fn every_known_path_round_trips() {
for (path, _) in KNOWN {
let uri = windows_path_to_file_uri(path).expect("a known path encodes");
assert_eq!(
file_uri_to_windows_path(uri.as_bytes()).as_deref(),
Some(*path),
"through {uri}"
);
}
}
#[test]
fn the_verbatim_and_forward_slash_spellings_name_the_same_files() {
for (spelling, uri) in [
(r"\\?\C:\long\x", "file:///C:/long/x"),
(r"\\?\UNC\server\share\x", "file://server/share/x"),
("C:/a b/c", "file:///C:/a%20b/c"),
("//server/share/x", "file://server/share/x"),
] {
assert_eq!(
windows_path_to_file_uri(spelling).as_deref(),
Some(uri),
"{spelling}"
);
}
}
#[test]
fn a_path_that_names_no_resolvable_file_has_no_uri() {
for path in [
"",
"relative\\x",
r"C:relative",
"C:",
r"\rooted\without\drive",
r"\\.\pipe\name",
r"\\server",
r"\\server\",
r"\\\share",
] {
assert_eq!(windows_path_to_file_uri(path), None, "{path:?}");
}
}
#[test]
fn the_forms_other_applications_write_decode_too() {
for (uri, path) in [
("FILE:///C:/x", r"C:\x"),
("file://localhost/C:/x", r"C:\x"),
("file://LOCALHOST/C:/x", r"C:\x"),
("file:/C:/x", r"C:\x"),
("file:C:/x", r"C:\x"),
("file:///C|/x", r"C:\x"),
("file:///C%3A/x", r"C:\x"),
("file:///C:", r"C:\"),
("file:////server/share/x", r"\\server\share\x"),
("file:///C:/dir/", r"C:\dir\"),
("file:///C:/caf%c3%a9", r"C:\café"),
("file:///C:/a%23b#fragment", r"C:\a#b"),
("file:///C:/a?query", r"C:\a"),
] {
assert_eq!(
file_uri_to_windows_path(uri.as_bytes()).as_deref(),
Some(path),
"{uri}"
);
}
}
#[test]
fn a_uri_that_names_no_windows_file_decodes_to_nothing() {
for uri in [
"file:///tmp/x",
"file:///",
"file://",
"https://example.com/C:/x",
"file:relative/x",
"file:///1:/x",
"file:///CD:/x",
"file:///C:/a%5Cb",
"file:///C:/a%2Fb",
"file:///C:/a%00b",
"file://server%2Fevil/share",
"file:///C:/%FF",
"file://server",
"file://server/",
"file://server//x",
"file://server:445/share",
] {
assert_eq!(file_uri_to_windows_path(uri.as_bytes()), None, "{uri}");
}
}
#[test]
fn a_uri_list_is_one_crlf_terminated_line_per_nameable_path() {
assert_eq!(
uri_list_from_windows_paths([r"C:\a b", "relative", r"\\s\h\ü"]),
"file:///C:/a%20b\r\nfile://s/h/%C3%BC\r\n",
"the relative path is left out, and every line ends in CRLF"
);
assert_eq!(uri_list_from_windows_paths([]), "");
}
}