Skip to main content

rama_net/uri/
file.rs

1//! `file:` URI paths, as defined by
2//! [RFC 8089](https://datatracker.ietf.org/doc/html/rfc8089).
3
4use std::path::{Path, PathBuf};
5
6use super::{PathRef, Uri};
7use crate::address::{AuthorityRef, Domain, Host};
8
9/// Why a `file:` URI does not name a path.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FileUriError {
12    /// The uri is not a `file:` uri.
13    NotAFileUri,
14    /// The uri carries no path at all.
15    MissingPath,
16    /// A local file URI path must be absolute.
17    RelativePath,
18    /// A percent-escape in a segment decodes to a path separator, which
19    /// would traverse out of the segment it was written in.
20    SeparatorInSegment,
21    /// A segment contains a NUL byte, which filesystem APIs cannot open.
22    NulInSegment,
23    /// The authority names a host other than this machine, so the path
24    /// lives on that host and not in the local filesystem.
25    NonLocalAuthority,
26}
27
28impl std::fmt::Display for FileUriError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::NotAFileUri => f.write_str("not a file: uri"),
32            Self::MissingPath => f.write_str("file: uri has no path"),
33            Self::RelativePath => f.write_str("file: uri path is not absolute"),
34            Self::SeparatorInSegment => {
35                f.write_str("file: uri path segment decodes to a path separator")
36            }
37            Self::NulInSegment => f.write_str("file: uri path segment contains a NUL byte"),
38            Self::NonLocalAuthority => f.write_str("file: uri authority names a non-local host"),
39        }
40    }
41}
42
43impl std::error::Error for FileUriError {}
44
45/// The filesystem path a `file:` [`Uri`] refers to.
46///
47/// - `file:///etc/hosts` → `/etc/hosts`
48/// - `file://localhost/etc/hosts` → `/etc/hosts`
49/// - `file:///C:/Users/x` (windows) → `C:/Users/x`
50/// - `file://server/share/x` (windows) → `\\server\share\x`
51///
52/// Per RFC 8089 §2 only an empty authority or `localhost` names this
53/// machine. Any other authority names a *different* host: on windows that
54/// is the UNC form of Appendix E.3.2 and maps to `\\host\path`, and
55/// everywhere else there is no such path, so it is refused rather than
56/// read from the local filesystem.
57///
58/// Percent-escapes are decoded per segment; a segment that decodes to a
59/// path separator is rejected rather than silently traversing. Callers
60/// should [`Uri::canonicalize`] first so `.`/`..` are resolved, and open
61/// the result through [`rama_utils::fs`] rather than [`std::fs`].
62pub fn file_uri_path(uri: &Uri) -> Result<PathBuf, FileUriError> {
63    if uri.scheme() != Some(&crate::Protocol::FILE) {
64        return Err(FileUriError::NotAFileUri);
65    }
66    let remote_host = match uri.authority() {
67        Some(authority) if !is_local_authority(authority) => Some(unc_host(authority)?),
68        _ => None,
69    };
70
71    let decoded = decode_path(uri.path().ok_or(FileUriError::MissingPath)?)?;
72    if decoded.is_empty() {
73        return Err(FileUriError::MissingPath);
74    }
75    if remote_host.is_none() && !is_absolute_local_path(&decoded) {
76        return Err(FileUriError::RelativePath);
77    }
78
79    match remote_host {
80        Some(host) => Ok(PathBuf::from(format!(
81            "\\\\{host}{}",
82            to_unc_separators(&decoded)
83        ))),
84        None => Ok(Path::new(trim_windows_drive_prefix(&decoded)).to_path_buf()),
85    }
86}
87
88fn is_absolute_local_path(path: &str) -> bool {
89    #[cfg(not(windows))]
90    {
91        path.starts_with('/')
92    }
93    #[cfg(windows)]
94    {
95        let bytes = path.as_bytes();
96        path.starts_with('/')
97            || bytes.len() >= 3
98                && bytes[0].is_ascii_alphabetic()
99                && bytes[1] == b':'
100                && matches!(bytes[2], b'/' | b'\\')
101    }
102}
103
104/// The host of a UNC path, or a refusal where UNC paths do not exist.
105///
106/// Only windows has a filesystem path that names another host; elsewhere
107/// reading the local path instead would serve a different file than the one
108/// asked for.
109fn unc_host(authority: AuthorityRef<'_>) -> Result<String, FileUriError> {
110    if cfg!(not(windows)) || authority.userinfo().is_some() || !authority.port().is_unset() {
111        return Err(FileUriError::NonLocalAuthority);
112    }
113    Ok(authority.host().to_string())
114}
115
116/// UNC paths are `\\`-separated.
117fn to_unc_separators(path: &str) -> String {
118    path.replace('/', "\\")
119}
120
121/// RFC 8089 §2: the local host is written as an empty authority or as
122/// `localhost`. Userinfo and a port have no meaning for a local file, so
123/// their presence means the uri was meant for something else.
124fn is_local_authority(authority: AuthorityRef<'_>) -> bool {
125    if authority.userinfo().is_some() || !authority.port().is_unset() {
126        return false;
127    }
128    let host = authority.host();
129    // host equality is canonical, so `LOCALHOST` compares equal too
130    host.to_str().is_empty() || host == Host::Name(Domain::tld_localhost()).view()
131}
132
133/// On windows `file:///C:/x` parses with path `/C:/x`; the leading slash
134/// is dropped to get `C:/x`. On unix it IS the absolute-path indicator.
135fn trim_windows_drive_prefix(path: &str) -> &str {
136    #[cfg(windows)]
137    {
138        let bytes = path.as_bytes();
139        if bytes.len() >= 3
140            && bytes[0] == b'/'
141            && bytes[2] == b':'
142            && bytes[1].is_ascii_alphabetic()
143        {
144            return &path[1..];
145        }
146        path
147    }
148    #[cfg(not(windows))]
149    path
150}
151
152fn decode_path(path: PathRef<'_>) -> Result<String, FileUriError> {
153    let rooted = path.as_encoded_str().as_ref().starts_with('/');
154    let mut decoded = String::new();
155    if rooted {
156        decoded.push('/');
157    }
158
159    for (index, segment) in path.segments().enumerate() {
160        let segment = segment.as_decoded_str();
161        if segment.contains('/') || cfg!(windows) && segment.contains('\\') {
162            return Err(FileUriError::SeparatorInSegment);
163        }
164        if segment.contains('\0') {
165            return Err(FileUriError::NulInSegment);
166        }
167        if index > 0 {
168            decoded.push('/');
169        }
170        decoded.push_str(&segment);
171    }
172
173    Ok(decoded)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn uri(raw: &str) -> Uri {
181        raw.parse().unwrap()
182    }
183
184    #[test]
185    fn decodes_each_segment() {
186        assert_eq!(
187            file_uri_path(&uri("file:///tmp/a%20b/report.txt")).unwrap(),
188            PathBuf::from("/tmp/a b/report.txt"),
189        );
190    }
191
192    #[test]
193    fn rejects_unopenable_bytes_inside_segment() {
194        assert_eq!(
195            file_uri_path(&uri("file:///tmp/a%2Fb/report.txt")),
196            Err(FileUriError::SeparatorInSegment),
197        );
198        assert_eq!(
199            file_uri_path(&uri("file:///tmp/a%00b/report.txt")),
200            Err(FileUriError::NulInSegment),
201        );
202    }
203
204    #[test]
205    fn rejects_other_schemes_and_empty_paths() {
206        assert_eq!(
207            file_uri_path(&uri("http://example.com/x")),
208            Err(FileUriError::NotAFileUri),
209        );
210        assert_eq!(
211            file_uri_path(&uri("file://")),
212            Err(FileUriError::MissingPath)
213        );
214        for raw in ["file:relative/path", "file:./pac.js", "file:../pac.js"] {
215            assert_eq!(
216                file_uri_path(&uri(raw)),
217                Err(FileUriError::RelativePath),
218                "{raw}",
219            );
220        }
221    }
222
223    #[test]
224    fn local_authority_forms_are_accepted() {
225        for raw in [
226            "file:///etc/hosts",
227            "file:/etc/hosts",
228            "file://localhost/etc/hosts",
229            "file://LOCALHOST/etc/hosts",
230        ] {
231            assert_eq!(
232                file_uri_path(&uri(raw)),
233                Ok(PathBuf::from("/etc/hosts")),
234                "{raw}"
235            );
236        }
237    }
238
239    #[test]
240    fn rejects_an_authority_that_names_no_openable_path() {
241        for raw in [
242            // neither userinfo nor a port mean anything for a file path,
243            // on any platform
244            "file://user@localhost/etc/passwd",
245            "file://localhost:80/etc/passwd",
246            "file://user@fileserver.corp/share/x",
247            "file://fileserver.corp:445/share/x",
248        ] {
249            assert_eq!(
250                file_uri_path(&uri(raw)),
251                Err(FileUriError::NonLocalAuthority),
252                "{raw}"
253            );
254        }
255    }
256
257    #[test]
258    #[cfg(not(windows))]
259    fn a_remote_authority_is_refused_where_unc_paths_do_not_exist() {
260        for raw in [
261            // a remote host owns this path, and reading the local one
262            // instead would serve a different file than the one asked for
263            "file://fileserver.corp/etc/passwd",
264            "file://backup-host/share/pac.js",
265            // loopback by ip is not one of the two RFC 8089 spellings
266            "file://127.0.0.1/etc/passwd",
267            // only the `localhost` name itself, not a subdomain of it
268            "file://evil.localhost/etc/passwd",
269        ] {
270            assert_eq!(
271                file_uri_path(&uri(raw)),
272                Err(FileUriError::NonLocalAuthority),
273                "{raw}"
274            );
275        }
276    }
277
278    #[test]
279    #[cfg(windows)]
280    fn a_remote_authority_is_the_unc_path_it_spells() {
281        // RFC 8089 appendix E.3.2: `file://host/share/x` is `\\host\share\x`
282        for (raw, expected) in [
283            (
284                "file://fileserver.corp/share/pac.js",
285                r"\\fileserver.corp\share\pac.js",
286            ),
287            ("file://server/share", r"\\server\share"),
288            // a percent-escape still decodes, and still may not smuggle a
289            // separator into a segment
290            ("file://server/a%20b/c", r"\\server\a b\c"),
291        ] {
292            assert_eq!(
293                file_uri_path(&uri(raw)),
294                Ok(std::path::PathBuf::from(expected)),
295                "{raw}"
296            );
297        }
298
299        assert_eq!(
300            file_uri_path(&uri("file://server/a%2Fb")),
301            Err(FileUriError::SeparatorInSegment),
302        );
303    }
304
305    #[test]
306    fn dot_segments_are_resolved_by_canonicalize() {
307        let path = file_uri_path(&uri("file:///tmp/sub/../pac.js").canonicalize()).unwrap();
308        assert_eq!(path, PathBuf::from("/tmp/pac.js"));
309    }
310
311    #[cfg(windows)]
312    #[test]
313    fn windows_drive_letter_loses_its_leading_slash() {
314        assert_eq!(
315            file_uri_path(&uri("file:///C:/Users/x")).unwrap(),
316            PathBuf::from("C:/Users/x"),
317        );
318    }
319}