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.canonicalize().map_err(|_| {
26        StaticError::NotFound(joined.display().to_string())
27    })?;
28
29    if !canon.starts_with(root_canon) {
30        return Err(StaticError::Traversal(joined.display().to_string()));
31    }
32
33    Ok(canon)
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(root_canon: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
69    if request_path.contains('\0') {
70        return Err(StaticError::Traversal(request_path.to_string()));
71    }
72
73    let decoded = decode_request_path(request_path);
74
75    // Segment-based traversal check: reject only path segments exactly equal to ".."
76    for segment in decoded.split('/') {
77        if segment == ".." {
78            return Err(StaticError::Traversal(request_path.to_string()));
79        }
80    }
81
82    let stripped = decoded.trim_start_matches('/');
83    let joined = root_canon.join(stripped);
84
85    let canon = canonicalize_within_root(root_canon, &joined)?;
86
87    if canon.is_dir() {
88        let index = canon.join("index.html");
89        let _index_canon = canonicalize_within_root(root_canon, &index)?;
90        Ok(index)
91    } else {
92        Ok(canon)
93    }
94}
95
96/// Percent-decode a request path to UTF-8, falling back to the raw string if decoding
97/// produces invalid UTF-8.
98///
99/// This function decodes percent-encoded characters in the path (e.g., `%2F` → `/`),
100/// allowing clients to request files with non-ASCII characters in their names.
101/// If the decoded bytes are not valid UTF-8, the original path is returned unchanged.
102pub(crate) fn decode_request_path(request_path: &str) -> String {
103    percent_encoding::percent_decode_str(request_path)
104        .decode_utf8()
105        .map(|s| s.to_string())
106        .unwrap_or_else(|_| request_path.to_string())
107}
108
109/// Resolve a request path under a root directory, canonicalizing the root first.
110///
111/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
112/// the root on every call. For production use where the root is fixed at startup, prefer
113/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
114///
115/// # Arguments
116///
117/// * `root` - The server root directory (need not be pre-canonicalized).
118/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
119///
120/// # Returns
121///
122/// - `Ok(PathBuf)` if the path resolves to a file within root.
123/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
124/// - `Err(StaticError::Io)` if canonicalizing the root fails.
125pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
126    let root_canon = root.canonicalize().map_err(StaticError::Io)?;
127    resolve_with_canonical_root(&root_canon, request_path)
128}