Skip to main content

mini_static/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::StaticError;
4
5/// Resolve a request path under a pre-canonicalized root.
6///
7/// This function assumes `root_canon` is already in canonical form — `root_canon` should be
8/// the output of `root.canonicalize()` called once at server startup. Per-request resolution
9/// only canonicalizes the joined path, not the root.
10///
11/// # Path Traversal Protection
12///
13/// Segment-based traversal check rejects only path segments exactly equal to `..`.
14/// This allows filenames containing `..` as a substring (e.g., `jquery..min.js`) while
15/// blocking traversal attempts like `../../etc/passwd`.
16///
17/// # Directory Handling
18///
19/// If the resolved path is a directory, automatically serves `index.html` from that directory
20/// if it exists and doesn't escape the root.
21///
22/// # Symlinks
23///
24/// Symlinks are followed during canonicalization. After following symlinks, the final
25/// canonical path must stay within the server root.
26///
27/// # Arguments
28///
29/// * `root_canon` - The server root in canonical form (should be output of `canonicalize()`).
30/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
31///
32/// # Returns
33///
34/// - `Ok(PathBuf)` if the path resolves to a file within root.
35/// - `Err(StaticError::NotFound)` if the path doesn't exist.
36/// - `Err(StaticError::Traversal)` if the path attempts to escape the root.
37pub fn resolve_with_canonical_root(root_canon: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
38    if request_path.contains('\0') {
39        return Err(StaticError::Traversal(request_path.to_string()));
40    }
41
42    let decoded = decode_request_path(request_path);
43
44    // Segment-based traversal check: reject only path segments exactly equal to ".."
45    for segment in decoded.split('/') {
46        if segment == ".." {
47            return Err(StaticError::Traversal(request_path.to_string()));
48        }
49    }
50
51    let stripped = decoded.trim_start_matches('/');
52    let joined = root_canon.join(stripped);
53
54    let canon = joined.canonicalize().map_err(|_| {
55        StaticError::NotFound(request_path.to_string())
56    })?;
57
58    if !canon.starts_with(root_canon) {
59        return Err(StaticError::Traversal(request_path.to_string()));
60    }
61
62    if canon.is_dir() {
63        let index = canon.join("index.html");
64        // Canonicalizing (not just `.exists()`) both confirms the file is present and
65        // resolves any symlink so the boundary check below also covers a symlinked
66        // index.html pointing outside root — `.exists()` alone would miss that.
67        let index_canon = index.canonicalize().map_err(|_| {
68            StaticError::NotFound(request_path.to_string())
69        })?;
70        if !index_canon.starts_with(root_canon) {
71            return Err(StaticError::Traversal(request_path.to_string()));
72        }
73        Ok(index)
74    } else {
75        Ok(canon)
76    }
77}
78
79/// Percent-decode a request path to UTF-8, falling back to the raw string if decoding
80/// produces invalid UTF-8.
81///
82/// This function decodes percent-encoded characters in the path (e.g., `%2F` → `/`),
83/// allowing clients to request files with non-ASCII characters in their names.
84/// If the decoded bytes are not valid UTF-8, the original path is returned unchanged.
85pub(crate) fn decode_request_path(request_path: &str) -> String {
86    percent_encoding::percent_decode_str(request_path)
87        .decode_utf8()
88        .map(|s| s.to_string())
89        .unwrap_or_else(|_| request_path.to_string())
90}
91
92/// Resolve a request path under a root directory, canonicalizing the root first.
93///
94/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
95/// the root on every call. For production use where the root is fixed at startup, prefer
96/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
97///
98/// # Arguments
99///
100/// * `root` - The server root directory (need not be pre-canonicalized).
101/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
102///
103/// # Returns
104///
105/// - `Ok(PathBuf)` if the path resolves to a file within root.
106/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
107/// - `Err(StaticError::Io)` if canonicalizing the root fails.
108pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
109    let root_canon = root.canonicalize().map_err(StaticError::Io)?;
110    resolve_with_canonical_root(&root_canon, request_path)
111}