use std::path::{Path, PathBuf};
use super::{PathRef, Uri};
use crate::address::{AuthorityRef, Domain, Host};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileUriError {
NotAFileUri,
MissingPath,
RelativePath,
SeparatorInSegment,
NulInSegment,
NonLocalAuthority,
}
impl std::fmt::Display for FileUriError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotAFileUri => f.write_str("not a file: uri"),
Self::MissingPath => f.write_str("file: uri has no path"),
Self::RelativePath => f.write_str("file: uri path is not absolute"),
Self::SeparatorInSegment => {
f.write_str("file: uri path segment decodes to a path separator")
}
Self::NulInSegment => f.write_str("file: uri path segment contains a NUL byte"),
Self::NonLocalAuthority => f.write_str("file: uri authority names a non-local host"),
}
}
}
impl std::error::Error for FileUriError {}
pub fn file_uri_path(uri: &Uri) -> Result<PathBuf, FileUriError> {
if uri.scheme() != Some(&crate::Protocol::FILE) {
return Err(FileUriError::NotAFileUri);
}
let remote_host = match uri.authority() {
Some(authority) if !is_local_authority(authority) => Some(unc_host(authority)?),
_ => None,
};
let decoded = decode_path(uri.path().ok_or(FileUriError::MissingPath)?)?;
if decoded.is_empty() {
return Err(FileUriError::MissingPath);
}
if remote_host.is_none() && !is_absolute_local_path(&decoded) {
return Err(FileUriError::RelativePath);
}
match remote_host {
Some(host) => Ok(PathBuf::from(format!(
"\\\\{host}{}",
to_unc_separators(&decoded)
))),
None => Ok(Path::new(trim_windows_drive_prefix(&decoded)).to_path_buf()),
}
}
fn is_absolute_local_path(path: &str) -> bool {
#[cfg(not(windows))]
{
path.starts_with('/')
}
#[cfg(windows)]
{
let bytes = path.as_bytes();
path.starts_with('/')
|| bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}
}
fn unc_host(authority: AuthorityRef<'_>) -> Result<String, FileUriError> {
if cfg!(not(windows)) || authority.userinfo().is_some() || !authority.port().is_unset() {
return Err(FileUriError::NonLocalAuthority);
}
Ok(authority.host().to_string())
}
fn to_unc_separators(path: &str) -> String {
path.replace('/', "\\")
}
fn is_local_authority(authority: AuthorityRef<'_>) -> bool {
if authority.userinfo().is_some() || !authority.port().is_unset() {
return false;
}
let host = authority.host();
host.to_str().is_empty() || host == Host::Name(Domain::tld_localhost()).view()
}
fn trim_windows_drive_prefix(path: &str) -> &str {
#[cfg(windows)]
{
let bytes = path.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'/'
&& bytes[2] == b':'
&& bytes[1].is_ascii_alphabetic()
{
return &path[1..];
}
path
}
#[cfg(not(windows))]
path
}
fn decode_path(path: PathRef<'_>) -> Result<String, FileUriError> {
let rooted = path.as_encoded_str().as_ref().starts_with('/');
let mut decoded = String::new();
if rooted {
decoded.push('/');
}
for (index, segment) in path.segments().enumerate() {
let segment = segment.as_decoded_str();
if segment.contains('/') || cfg!(windows) && segment.contains('\\') {
return Err(FileUriError::SeparatorInSegment);
}
if segment.contains('\0') {
return Err(FileUriError::NulInSegment);
}
if index > 0 {
decoded.push('/');
}
decoded.push_str(&segment);
}
Ok(decoded)
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(raw: &str) -> Uri {
raw.parse().unwrap()
}
#[test]
fn decodes_each_segment() {
assert_eq!(
file_uri_path(&uri("file:///tmp/a%20b/report.txt")).unwrap(),
PathBuf::from("/tmp/a b/report.txt"),
);
}
#[test]
fn rejects_unopenable_bytes_inside_segment() {
assert_eq!(
file_uri_path(&uri("file:///tmp/a%2Fb/report.txt")),
Err(FileUriError::SeparatorInSegment),
);
assert_eq!(
file_uri_path(&uri("file:///tmp/a%00b/report.txt")),
Err(FileUriError::NulInSegment),
);
}
#[test]
fn rejects_other_schemes_and_empty_paths() {
assert_eq!(
file_uri_path(&uri("http://example.com/x")),
Err(FileUriError::NotAFileUri),
);
assert_eq!(
file_uri_path(&uri("file://")),
Err(FileUriError::MissingPath)
);
for raw in ["file:relative/path", "file:./pac.js", "file:../pac.js"] {
assert_eq!(
file_uri_path(&uri(raw)),
Err(FileUriError::RelativePath),
"{raw}",
);
}
}
#[test]
fn local_authority_forms_are_accepted() {
for raw in [
"file:///etc/hosts",
"file:/etc/hosts",
"file://localhost/etc/hosts",
"file://LOCALHOST/etc/hosts",
] {
assert_eq!(
file_uri_path(&uri(raw)),
Ok(PathBuf::from("/etc/hosts")),
"{raw}"
);
}
}
#[test]
fn rejects_an_authority_that_names_no_openable_path() {
for raw in [
"file://user@localhost/etc/passwd",
"file://localhost:80/etc/passwd",
"file://user@fileserver.corp/share/x",
"file://fileserver.corp:445/share/x",
] {
assert_eq!(
file_uri_path(&uri(raw)),
Err(FileUriError::NonLocalAuthority),
"{raw}"
);
}
}
#[test]
#[cfg(not(windows))]
fn a_remote_authority_is_refused_where_unc_paths_do_not_exist() {
for raw in [
"file://fileserver.corp/etc/passwd",
"file://backup-host/share/pac.js",
"file://127.0.0.1/etc/passwd",
"file://evil.localhost/etc/passwd",
] {
assert_eq!(
file_uri_path(&uri(raw)),
Err(FileUriError::NonLocalAuthority),
"{raw}"
);
}
}
#[test]
#[cfg(windows)]
fn a_remote_authority_is_the_unc_path_it_spells() {
for (raw, expected) in [
(
"file://fileserver.corp/share/pac.js",
r"\\fileserver.corp\share\pac.js",
),
("file://server/share", r"\\server\share"),
("file://server/a%20b/c", r"\\server\a b\c"),
] {
assert_eq!(
file_uri_path(&uri(raw)),
Ok(std::path::PathBuf::from(expected)),
"{raw}"
);
}
assert_eq!(
file_uri_path(&uri("file://server/a%2Fb")),
Err(FileUriError::SeparatorInSegment),
);
}
#[test]
fn dot_segments_are_resolved_by_canonicalize() {
let path = file_uri_path(&uri("file:///tmp/sub/../pac.js").canonicalize()).unwrap();
assert_eq!(path, PathBuf::from("/tmp/pac.js"));
}
#[cfg(windows)]
#[test]
fn windows_drive_letter_loses_its_leading_slash() {
assert_eq!(
file_uri_path(&uri("file:///C:/Users/x")).unwrap(),
PathBuf::from("C:/Users/x"),
);
}
}