dhttp/util/
path.rs

1//! Path utilities
2
3use std::path::{Path, PathBuf};
4use std::error::Error;
5use std::fmt;
6#[cfg(unix)]
7use std::ffi::OsStr;
8#[cfg(unix)]
9use std::os::unix::ffi::OsStrExt;
10
11use percent_encoding_lite::Bitmask;
12
13use crate::core::{HttpError, HttpErrorType};
14use crate::reqres::StatusCode;
15
16/// URL-decodes and converts request route into a relative path and checks its safety.
17///
18/// See [`DangerousPathError`] for details of these checks
19pub fn sanitize(route: &str) -> Result<PathBuf, DangerousPathError> {
20    let decoded = percent_encoding_lite::decode(route);
21    // Why from_utf8? Long story short, we can't SAFELY construct an OsStr from WTF-8 bytes
22    // We for sure can roundtrip through OsString::from_wide... only to encode it into WTF-8 again
23    // WTF, std?
24    #[cfg(windows)]
25    return sanitize_win(str::from_utf8(&decoded).map_err(|_| DangerousPathError::InvalidCharacters)?);
26    #[cfg(unix)]
27    return sanitize_unix(&decoded);
28    #[cfg(target_os = "cygwin")] {
29        not_implemented("This function assumes unix paths on unix targets. Cygwin uses windows paths and so this code has to be refactored to support it");
30    }
31    #[cfg(not(any(windows, unix)))]
32    not_implemented
33}
34
35// unfortunately we need separate functions for windows/unix because windows one uses &str
36#[cfg(any(unix, test))]
37fn sanitize_unix(route: &[u8]) -> Result<PathBuf, DangerousPathError> {
38    if route.contains(&0) { return Err(DangerousPathError::InvalidCharacters); }
39
40    let mut out = PathBuf::new();
41    for segment in route.split(|&c| c == b'/') {
42        if segment.is_empty() || segment == b"." { continue; }
43        if segment == b".." { return Err(DangerousPathError::DangerousPath); }
44        out.push(OsStr::from_bytes(segment));
45    }
46
47    if out.as_os_str().is_empty() { out.push("."); }
48    Ok(out)
49}
50
51#[cfg(any(windows, test))]
52fn sanitize_win(route: &str) -> Result<PathBuf, DangerousPathError> {
53    if route.contains(|c| c < ' ') { return Err(DangerousPathError::InvalidCharacters); }
54    // windows invalid characters except path separators '/' '\\'
55    // out of all these ':' is most important because it rejects drive letters
56    const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
57    if route.contains(INVALID_CHARS) { return Err(DangerousPathError::InvalidCharacters); }
58
59    let mut out = PathBuf::new();
60    for segment in route.split(['/', '\\']) {
61        if segment.is_empty() || segment == "." { continue; }
62        if segment == ".." { return Err(DangerousPathError::DangerousPath); }
63        out.push(segment);
64    }
65
66    if out.as_os_str().is_empty() { out.push("."); }
67    Ok(out)
68}
69
70#[derive(Debug, Clone, PartialEq)]
71#[non_exhaustive]
72pub enum DangerousPathError {
73    /// Path contains dangerous segments (`..` and drive letters on Windows)
74    DangerousPath,
75    /// Path was either invalid UTF-8 (only on Windows), file name or contained forbidden characters:
76    /// - `\0` on unix
77    /// - 0-31 and `<>:"/\|?*` on Windows
78    InvalidCharacters,
79}
80
81impl fmt::Display for DangerousPathError {
82    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
83        match self {
84            DangerousPathError::DangerousPath => f.write_str("path contains `..` or drive letters"),
85            DangerousPathError::InvalidCharacters => f.write_str("path contains forbidden characters"),
86        }
87    }
88}
89
90impl Error for DangerousPathError {}
91impl HttpError for DangerousPathError {
92    fn error_type(&self) -> HttpErrorType { HttpErrorType::Hidden }
93    fn status_code(&self) -> StatusCode { StatusCode::BAD_REQUEST }
94}
95
96/// Performs URL encoding for a given [`Path`] (lossy on Windows)
97pub fn encode(path: &Path) -> String {
98    #[cfg(windows)]
99    return percent_encoding_lite::encode(&path.to_string_lossy(), Bitmask::PATH);
100    #[cfg(unix)]
101    return percent_encoding_lite::encode(path.as_os_str().as_bytes(), Bitmask::PATH);
102    #[cfg(not(any(windows, unix)))]
103    not_implemented
104}
105
106// TODO: test C:file on actix and " .." on dhttp
107#[cfg(test)]
108mod tests {
109    use super::{sanitize_win, sanitize_unix, DangerousPathError};
110    use DangerousPathError::*;
111    #[test]
112    fn win() {
113        assert_eq!(sanitize_win("/.."), Err(DangerousPath));
114        assert_eq!(sanitize_win("/\\..\\"), Err(DangerousPath));
115        assert_eq!(sanitize_win("Dir\\.."), Err(DangerousPath));
116        assert_eq!(sanitize_win("/C:/Windows"), Err(InvalidCharacters));
117        assert_eq!(sanitize_win("/C:\\Windows"), Err(InvalidCharacters));
118        assert_eq!(sanitize_win("C:file.txt"), Err(InvalidCharacters));
119        assert_eq!(sanitize_win("/\\\\?\\"), Err(InvalidCharacters));
120        assert_eq!(sanitize_win("/\0"), Err(InvalidCharacters));
121        assert!(sanitize_win("/status.json").is_ok());
122        assert!(sanitize_win("/files/examples/fileserver.rs").is_ok());
123        assert!(sanitize_win("/F1SHMSOaYAA1M2G.jpeg").is_ok());
124    }
125    #[test]
126    fn unix() {
127        assert_eq!(sanitize_unix(b"/.."), Err(DangerousPath));
128        assert_eq!(sanitize_unix(b"../"), Err(DangerousPath));
129        assert_eq!(sanitize_unix(b".."), Err(DangerousPath));
130        assert_eq!(sanitize_unix(b"/dir/.."), Err(DangerousPath));
131        assert_eq!(sanitize_unix(b"/\0"), Err(InvalidCharacters));
132        assert_eq!(sanitize_unix(b"/nulls\0instead\0of\0spaces"), Err(InvalidCharacters));
133        assert!(sanitize_unix(b"<>:\"/\\|?*").is_ok());
134        assert!(sanitize_unix(b"/just/a/path").is_ok());
135        assert!(sanitize_unix(b"/dev/sda").is_ok());
136        assert!(sanitize_unix(b"\\..\\This is a filename").is_ok());
137        assert!(sanitize_unix(b"/C:/Windows").is_ok());
138    }
139}