Skip to main content

mini_static/
resolve.rs

1use std::fs::{File, Metadata};
2use std::path::{Path, PathBuf};
3
4use crate::error::StaticError;
5
6/// A request path resolved all the way to an opened file.
7///
8/// Holding the open handle is the point: containment was verified on this exact fd (see
9/// [`real_path_of`]), so serving from it — rather than re-opening by path — leaves no
10/// gap between the check and the bytes.
11pub(crate) struct ResolvedFile {
12    pub(crate) file: File,
13    pub(crate) metadata: Metadata,
14    pub(crate) path: PathBuf,
15}
16
17/// The real, symlink-resolved path of an already-open file, from the kernel.
18///
19/// This is `canonicalize()` inverted: instead of resolving a path and hoping the later
20/// `open` lands on the same file, open first and ask what was opened. macOS answers via
21/// `fcntl(F_GETPATH)`; Linux via the fd's `/proc` symlink. Measured at ~38% cheaper than
22/// the canonicalize-then-open sequence it replaces — and immune to the path being
23/// swapped between check and use, because there is no "between".
24#[cfg(any(target_os = "macos", target_os = "ios"))]
25fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
26    use std::os::fd::AsRawFd;
27    use std::os::unix::ffi::OsStrExt;
28
29    let mut buf = [0u8; libc::PATH_MAX as usize];
30    // SAFETY: `buf` is PATH_MAX bytes and F_GETPATH writes at most PATH_MAX including
31    // the NUL terminator; the fd is valid for the lifetime of `file`.
32    let rc = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr()) };
33    if rc != 0 {
34        return Err(std::io::Error::last_os_error());
35    }
36    let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
37    Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
38}
39
40#[cfg(target_os = "linux")]
41fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
42    use std::os::fd::AsRawFd;
43    std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))
44}
45
46/// Check that a canonicalized path stays within the server root.
47///
48/// Calls `canonicalize()` on the joined path and verifies the result starts with
49/// `root_canon`, catching symlink escapes and traversal attempts that sneak through
50/// segment-based checks.
51///
52/// # Arguments
53///
54/// * `root_canon` - The server root in canonical form.
55/// * `joined` - A path that may need canonicalizing (e.g., the result of `root.join(...)`).
56///
57/// # Returns
58///
59/// - `Ok(PathBuf)` if the canonicalized path stays within root.
60/// - `Err(StaticError::NotFound)` if the path doesn't exist or can't be canonicalized.
61/// - `Err(StaticError::Traversal)` if the canonicalized path escapes root.
62// On fd-verified platforms (macOS/iOS/Linux) only the portability fallback calls this,
63// so it reads as dead there — it is the other platforms' security boundary, not debris.
64#[cfg_attr(
65    any(target_os = "macos", target_os = "ios", target_os = "linux"),
66    allow(dead_code)
67)]
68pub(crate) fn canonicalize_within_root(
69    root_canon: &Path,
70    joined: &Path,
71) -> Result<PathBuf, StaticError> {
72    let canon = joined
73        .canonicalize()
74        .map_err(|_| StaticError::NotFound(joined.display().to_string()))?;
75
76    if canon.starts_with(root_canon) {
77        Ok(canon)
78    } else {
79        Err(StaticError::Traversal(joined.display().to_string()))
80    }
81}
82
83/// Resolve a request path under a pre-canonicalized root.
84///
85/// This function assumes `root_canon` is already in canonical form — `root_canon` should be
86/// the output of `root.canonicalize()` called once at server startup. Per-request resolution
87/// only canonicalizes the joined path, not the root.
88///
89/// # Path Traversal Protection
90///
91/// Segment-based traversal check rejects only path segments exactly equal to `..`.
92/// This allows filenames containing `..` as a substring (e.g., `jquery..min.js`) while
93/// blocking traversal attempts like `../../etc/passwd`.
94///
95/// # Directory Handling
96///
97/// If the resolved path is a directory, automatically serves `index.html` from that directory
98/// if it exists and doesn't escape the root.
99///
100/// # Symlinks
101///
102/// Symlinks are followed during canonicalization. After following symlinks, the final
103/// canonical path must stay within the server root.
104///
105/// # Arguments
106///
107/// * `root_canon` - The server root in canonical form (should be output of `canonicalize()`).
108/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
109///
110/// # Returns
111///
112/// - `Ok(PathBuf)` if the path resolves to a file within root.
113/// - `Err(StaticError::NotFound)` if the path doesn't exist.
114/// - `Err(StaticError::Traversal)` if the path attempts to escape the root.
115pub fn resolve_with_canonical_root(
116    root_canon: &Path,
117    request_path: &str,
118) -> Result<PathBuf, StaticError> {
119    resolve_with_policy(root_canon, request_path, HiddenFiles::Deny)
120}
121
122/// Whether dot-prefixed request-path segments may be served.
123///
124/// The default is [`HiddenFiles::Deny`]: a served root is frequently a build output
125/// directory, a repository working copy, or a folder someone dropped a `.env` into, and
126/// serving `.git/config` or `.env` to anyone who guesses the name is a credential leak
127/// that no traversal check catches — the files are legitimately *inside* the root.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum HiddenFiles {
130    /// Dot-prefixed segments answer as a miss.
131    Deny,
132    /// Dot-prefixed segments resolve like any other name.
133    Serve,
134}
135
136/// The one dot-prefixed prefix served under [`HiddenFiles::Deny`]: `/.well-known/` is
137/// where the web puts things that are *meant* to be fetched — ACME challenges for
138/// certificate issuance, `security.txt`, app-site association files. Denying it would
139/// break certificate renewal on any site served by this crate.
140const WELL_KNOWN: &str = ".well-known";
141
142/// Whether any segment of `decoded` is a hidden name, honoring the `.well-known`
143/// exception.
144///
145/// Scope is deliberately the *request path* only, never the served root's own
146/// filesystem path — a root that itself lives under a dot-directory
147/// (`~/.config/site/public`) must keep working, since the operator chose that location
148/// and no request can address it.
149///
150/// A segment of exactly `.` is a same-directory reference (`/./index.html`), not a
151/// hidden name, so it is exempt. `..` never reaches here — the traversal check rejects
152/// it first.
153fn has_hidden_segment(decoded: &str) -> bool {
154    decoded
155        .trim_start_matches('/')
156        .split('/')
157        .enumerate()
158        .any(|(index, segment)| {
159            let is_well_known_root = index == 0 && segment == WELL_KNOWN;
160            segment.starts_with('.') && segment != "." && !is_well_known_root
161        })
162}
163
164/// [`resolve_with_canonical_root`] with an explicit hidden-file policy.
165pub(crate) fn resolve_with_policy(
166    root_canon: &Path,
167    request_path: &str,
168    hidden: HiddenFiles,
169) -> Result<PathBuf, StaticError> {
170    if request_path.contains('\0') {
171        return Err(StaticError::Traversal(request_path.to_string()));
172    }
173
174    let decoded = decode_request_path(request_path);
175
176    // Segment-based traversal check: reject only path segments exactly equal to ".."
177    for segment in decoded.split('/') {
178        if segment == ".." {
179            return Err(StaticError::Traversal(request_path.to_string()));
180        }
181    }
182
183    // `NotFound`, not a distinct error: a hidden file that exists and one that doesn't
184    // must be indistinguishable, or the response becomes an oracle for what the root
185    // contains — the same reasoning that collapses traversal into the miss message.
186    if hidden == HiddenFiles::Deny && has_hidden_segment(&decoded) {
187        return Err(StaticError::NotFound(request_path.to_string()));
188    }
189
190    let stripped = decoded.trim_start_matches('/');
191    let joined = root_canon.join(stripped);
192
193    open_verified(root_canon, &joined, request_path).map(|resolved| resolved.path)
194}
195
196/// Open `joined` and prove, on the opened fd, that it lies under `root_canon`.
197///
198/// A directory retries with `index.html` appended — verified on its *own* fd, never
199/// trusted transitively from the directory's.
200///
201/// Every open failure collapses to `NotFound`: differentiating errno (permission vs
202/// absent vs vanished) would hand back the existence oracle the 404 path works to deny.
203#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
204fn open_verified(
205    root_canon: &Path,
206    joined: &Path,
207    request_path: &str,
208) -> Result<ResolvedFile, StaticError> {
209    let file = File::open(joined).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
210    let real = real_path_of(&file).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
211    if !real.starts_with(root_canon) {
212        return Err(StaticError::Traversal(request_path.to_string()));
213    }
214    let metadata = file
215        .metadata()
216        .map_err(|_| StaticError::NotFound(request_path.to_string()))?;
217
218    if metadata.is_dir() {
219        return open_verified(root_canon, &real.join("index.html"), request_path);
220    }
221
222    Ok(ResolvedFile {
223        file,
224        metadata,
225        path: real,
226    })
227}
228
229/// Portability fallback: the canonicalize-then-open sequence this crate used through
230/// 0.30.x, for platforms without a way to read an fd's real path. Slower and it
231/// re-admits the check-to-open window; the property tests exercise whichever variant
232/// the platform compiles.
233#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
234fn open_verified(
235    root_canon: &Path,
236    joined: &Path,
237    request_path: &str,
238) -> Result<ResolvedFile, StaticError> {
239    let canon = canonicalize_within_root(root_canon, joined)?;
240    let target = if canon.is_dir() {
241        let index = canon.join("index.html");
242        canonicalize_within_root(root_canon, &index)?;
243        index
244    } else {
245        canon
246    };
247    let file = File::open(&target).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
248    let metadata = file
249        .metadata()
250        .map_err(|_| StaticError::NotFound(request_path.to_string()))?;
251    Ok(ResolvedFile {
252        file,
253        metadata,
254        path: target,
255    })
256}
257
258/// [`resolve_with_policy`], but yielding the opened, containment-verified file rather
259/// than a path to reopen. `Server::handle_request` serves from this handle directly.
260pub(crate) fn open_with_policy(
261    root_canon: &Path,
262    request_path: &str,
263    hidden: HiddenFiles,
264) -> Result<ResolvedFile, StaticError> {
265    if request_path.contains('\0') {
266        return Err(StaticError::Traversal(request_path.to_string()));
267    }
268    let decoded = decode_request_path(request_path);
269    for segment in decoded.split('/') {
270        if segment == ".." {
271            return Err(StaticError::Traversal(request_path.to_string()));
272        }
273    }
274    if hidden == HiddenFiles::Deny && has_hidden_segment(&decoded) {
275        return Err(StaticError::NotFound(request_path.to_string()));
276    }
277    let stripped = decoded.trim_start_matches('/');
278    open_verified(root_canon, &root_canon.join(stripped), request_path)
279}
280
281/// Percent-decode a request path to UTF-8, falling back to the raw string if decoding
282/// produces invalid UTF-8.
283///
284/// This function decodes percent-encoded characters in the path (e.g., `%2F` → `/`),
285/// allowing clients to request files with non-ASCII characters in their names.
286/// If the decoded bytes are not valid UTF-8, the original path is returned unchanged.
287pub(crate) fn decode_request_path(request_path: &str) -> String {
288    percent_encoding::percent_decode_str(request_path)
289        .decode_utf8()
290        .map(|s| s.to_string())
291        .unwrap_or_else(|_| request_path.to_string())
292}
293
294/// Resolve a request path under a root directory, canonicalizing the root first.
295///
296/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
297/// the root on every call. For production use where the root is fixed at startup, prefer
298/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
299///
300/// # Arguments
301///
302/// * `root` - The server root directory (need not be pre-canonicalized).
303/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
304///
305/// # Returns
306///
307/// - `Ok(PathBuf)` if the path resolves to a file within root.
308/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
309/// - `Err(StaticError::Io)` if canonicalizing the root fails.
310pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
311    let root_canon = root.canonicalize().map_err(StaticError::Io)?;
312    resolve_with_canonical_root(&root_canon, request_path)
313}