Skip to main content

mini_static/
resolve.rs

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