Skip to main content

mini_static/
resolve.rs

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