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 resolve_with_policy(root_canon, request_path, HiddenFiles::Deny)
73}
74
75/// Whether dot-prefixed request-path segments may be served.
76///
77/// The default is [`HiddenFiles::Deny`]: a served root is frequently a build output
78/// directory, a repository working copy, or a folder someone dropped a `.env` into, and
79/// serving `.git/config` or `.env` to anyone who guesses the name is a credential leak
80/// that no traversal check catches — the files are legitimately *inside* the root.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum HiddenFiles {
83 /// Dot-prefixed segments answer as a miss.
84 Deny,
85 /// Dot-prefixed segments resolve like any other name.
86 Serve,
87}
88
89/// The one dot-prefixed prefix served under [`HiddenFiles::Deny`]: `/.well-known/` is
90/// where the web puts things that are *meant* to be fetched — ACME challenges for
91/// certificate issuance, `security.txt`, app-site association files. Denying it would
92/// break certificate renewal on any site served by this crate.
93const WELL_KNOWN: &str = ".well-known";
94
95/// Whether any segment of `decoded` is a hidden name, honoring the `.well-known`
96/// exception.
97///
98/// Scope is deliberately the *request path* only, never the served root's own
99/// filesystem path — a root that itself lives under a dot-directory
100/// (`~/.config/site/public`) must keep working, since the operator chose that location
101/// and no request can address it.
102///
103/// A segment of exactly `.` is a same-directory reference (`/./index.html`), not a
104/// hidden name, so it is exempt. `..` never reaches here — the traversal check rejects
105/// it first.
106fn has_hidden_segment(decoded: &str) -> bool {
107 decoded
108 .trim_start_matches('/')
109 .split('/')
110 .enumerate()
111 .any(|(index, segment)| {
112 let is_well_known_root = index == 0 && segment == WELL_KNOWN;
113 segment.starts_with('.') && segment != "." && !is_well_known_root
114 })
115}
116
117/// [`resolve_with_canonical_root`] with an explicit hidden-file policy.
118pub(crate) fn resolve_with_policy(
119 root_canon: &Path,
120 request_path: &str,
121 hidden: HiddenFiles,
122) -> Result<PathBuf, StaticError> {
123 if request_path.contains('\0') {
124 return Err(StaticError::Traversal(request_path.to_string()));
125 }
126
127 let decoded = decode_request_path(request_path);
128
129 // Segment-based traversal check: reject only path segments exactly equal to ".."
130 for segment in decoded.split('/') {
131 if segment == ".." {
132 return Err(StaticError::Traversal(request_path.to_string()));
133 }
134 }
135
136 // `NotFound`, not a distinct error: a hidden file that exists and one that doesn't
137 // must be indistinguishable, or the response becomes an oracle for what the root
138 // contains — the same reasoning that collapses traversal into the miss message.
139 if hidden == HiddenFiles::Deny && has_hidden_segment(&decoded) {
140 return Err(StaticError::NotFound(request_path.to_string()));
141 }
142
143 let stripped = decoded.trim_start_matches('/');
144 let joined = root_canon.join(stripped);
145
146 let canon = canonicalize_within_root(root_canon, &joined)?;
147
148 if canon.is_dir() {
149 let index = canon.join("index.html");
150 let _index_canon = canonicalize_within_root(root_canon, &index)?;
151 Ok(index)
152 } else {
153 Ok(canon)
154 }
155}
156
157/// Percent-decode a request path to UTF-8, falling back to the raw string if decoding
158/// produces invalid UTF-8.
159///
160/// This function decodes percent-encoded characters in the path (e.g., `%2F` → `/`),
161/// allowing clients to request files with non-ASCII characters in their names.
162/// If the decoded bytes are not valid UTF-8, the original path is returned unchanged.
163pub(crate) fn decode_request_path(request_path: &str) -> String {
164 percent_encoding::percent_decode_str(request_path)
165 .decode_utf8()
166 .map(|s| s.to_string())
167 .unwrap_or_else(|_| request_path.to_string())
168}
169
170/// Resolve a request path under a root directory, canonicalizing the root first.
171///
172/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
173/// the root on every call. For production use where the root is fixed at startup, prefer
174/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
175///
176/// # Arguments
177///
178/// * `root` - The server root directory (need not be pre-canonicalized).
179/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
180///
181/// # Returns
182///
183/// - `Ok(PathBuf)` if the path resolves to a file within root.
184/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
185/// - `Err(StaticError::Io)` if canonicalizing the root fails.
186pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
187 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
188 resolve_with_canonical_root(&root_canon, request_path)
189}