1use std::path::{Path, PathBuf};
5
6use super::{PathRef, Uri};
7use crate::address::{AuthorityRef, Domain, Host};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FileUriError {
12 NotAFileUri,
14 MissingPath,
16 RelativePath,
18 SeparatorInSegment,
21 NulInSegment,
23 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
45pub 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
104fn 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
116fn to_unc_separators(path: &str) -> String {
118 path.replace('/', "\\")
119}
120
121fn 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.to_str().is_empty() || host == Host::Name(Domain::tld_localhost()).view()
131}
132
133fn 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 "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 "file://fileserver.corp/etc/passwd",
264 "file://backup-host/share/pac.js",
265 "file://127.0.0.1/etc/passwd",
267 "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 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 ("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}