Skip to main content

mini_static/
resolve.rs

1use std::borrow::Cow;
2use std::fs::{File, Metadata};
3use std::path::{Path, PathBuf};
4
5use crate::error::StaticError;
6
7/// A request path resolved all the way to an opened file.
8///
9/// Holding the open handle is the point: containment was verified on this exact fd (see
10/// [`real_path_of`]), so serving from it — rather than re-opening by path — leaves no
11/// gap between the check and the bytes.
12pub(crate) struct ResolvedFile {
13    pub(crate) file: File,
14    pub(crate) metadata: Metadata,
15    pub(crate) path: PathBuf,
16}
17
18/// The real, symlink-resolved path of an already-open file, from the kernel.
19///
20/// This is `canonicalize()` inverted: instead of resolving a path and hoping the later
21/// `open` lands on the same file, open first and ask what was opened. macOS answers via
22/// `fcntl(F_GETPATH)`; Linux via the fd's `/proc` symlink. Measured at ~38% cheaper than
23/// the canonicalize-then-open sequence it replaces — and immune to the path being
24/// swapped between check and use, because there is no "between".
25#[cfg(any(target_os = "macos", target_os = "ios"))]
26fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
27    use std::os::fd::AsRawFd;
28    use std::os::unix::ffi::OsStrExt;
29
30    let mut buf = [0u8; libc::PATH_MAX as usize];
31    // SAFETY: `buf` is PATH_MAX bytes and F_GETPATH writes at most PATH_MAX including
32    // the NUL terminator; the fd is valid for the lifetime of `file`.
33    let rc = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr()) };
34    if rc != 0 {
35        return Err(std::io::Error::last_os_error());
36    }
37    let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
38    Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
39}
40
41#[cfg(target_os = "linux")]
42fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
43    use std::os::fd::AsRawFd;
44    std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))
45}
46
47/// Check that a canonicalized path stays within the server root.
48///
49/// Calls `canonicalize()` on the joined path and verifies the result starts with
50/// `root_canon`, catching symlink escapes and traversal attempts that sneak through
51/// segment-based checks.
52///
53/// # Arguments
54///
55/// * `root_canon` - The server root in canonical form.
56/// * `joined` - A path that may need canonicalizing (e.g., the result of `root.join(...)`).
57///
58/// # Returns
59///
60/// - `Ok(PathBuf)` if the canonicalized path stays within root.
61/// - `Err(StaticError::NotFound)` if the path doesn't exist or can't be canonicalized.
62/// - `Err(StaticError::Traversal)` if the canonicalized path escapes root.
63// On fd-verified platforms (macOS/iOS/Linux) only the portability fallback calls this,
64// so it reads as dead there — it is the other platforms' security boundary, not debris.
65#[cfg_attr(
66    any(target_os = "macos", target_os = "ios", target_os = "linux"),
67    allow(dead_code)
68)]
69pub(crate) fn canonicalize_within_root(
70    root_canon: &Path,
71    joined: &Path,
72) -> Result<PathBuf, StaticError> {
73    let canon = joined
74        .canonicalize()
75        .map_err(|_| StaticError::NotFound(joined.display().to_string()))?;
76
77    if canon.starts_with(root_canon) {
78        Ok(canon)
79    } else {
80        Err(StaticError::Traversal(joined.display().to_string()))
81    }
82}
83
84/// Resolve a request path under a pre-canonicalized root.
85///
86/// This function assumes `root_canon` is already in canonical form — `root_canon` should be
87/// the output of `root.canonicalize()` called once at server startup. Per-request resolution
88/// only canonicalizes the joined path, not the root.
89///
90/// # Path Traversal Protection
91///
92/// Segment-based traversal check rejects only path segments exactly equal to `..`.
93/// This allows filenames containing `..` as a substring (e.g., `jquery..min.js`) while
94/// blocking traversal attempts like `../../etc/passwd`.
95///
96/// # Directory Handling
97///
98/// If the resolved path is a directory, automatically serves `index.html` from that directory
99/// if it exists and doesn't escape the root.
100///
101/// # Symlinks
102///
103/// Symlinks are followed during canonicalization. After following symlinks, the final
104/// canonical path must stay within the server root.
105///
106/// # Arguments
107///
108/// * `root_canon` - The server root in canonical form (should be output of `canonicalize()`).
109/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
110///
111/// # Returns
112///
113/// - `Ok(PathBuf)` if the path resolves to a file within root.
114/// - `Err(StaticError::NotFound)` if the path doesn't exist.
115/// - `Err(StaticError::Traversal)` if the path attempts to escape the root.
116pub fn resolve_with_canonical_root(
117    root_canon: &Path,
118    request_path: &str,
119) -> Result<PathBuf, StaticError> {
120    resolve_with_policy(root_canon, request_path, HiddenFiles::Deny)
121}
122
123/// Whether dot-prefixed request-path segments may be served.
124///
125/// The default is [`HiddenFiles::Deny`]: a served root is frequently a build output
126/// directory, a repository working copy, or a folder someone dropped a `.env` into, and
127/// serving `.git/config` or `.env` to anyone who guesses the name is a credential leak
128/// that no traversal check catches — the files are legitimately *inside* the root.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum HiddenFiles {
131    /// Dot-prefixed segments answer as a miss.
132    Deny,
133    /// Dot-prefixed segments resolve like any other name.
134    Serve,
135}
136
137/// The one dot-prefixed prefix served under [`HiddenFiles::Deny`]: `/.well-known/` is
138/// where the web puts things that are *meant* to be fetched — ACME challenges for
139/// certificate issuance, `security.txt`, app-site association files. Denying it would
140/// break certificate renewal on any site served by this crate.
141const WELL_KNOWN: &str = ".well-known";
142
143/// The file a directory request resolves to.
144///
145/// Named because five places depended on the same literal — two here, two in the
146/// trailing-slash redirect, and the content cache's directory retry. A sixth would have been
147/// the one that disagreed.
148pub(crate) const INDEX_FILE_NAME: &str = "index.html";
149
150/// Whether any segment of `decoded` is a hidden name, honoring the `.well-known`
151/// exception.
152///
153/// Scope is deliberately the *request path* only, never the served root's own
154/// filesystem path — a root that itself lives under a dot-directory
155/// (`~/.config/site/public`) must keep working, since the operator chose that location
156/// and no request can address it.
157///
158/// A segment of exactly `.` is a same-directory reference (`/./index.html`), not a
159/// hidden name, so it is exempt. `..` never reaches here — the traversal check rejects
160/// it first.
161fn has_hidden_segment(segments: &[Cow<'_, str>]) -> bool {
162    segments.iter().enumerate().any(|(index, segment)| {
163        let is_well_known_root = index == 0 && segment.as_ref() == WELL_KNOWN;
164        segment.starts_with('.') && segment != "." && !is_well_known_root
165    })
166}
167
168/// Decode a request path into its segments — this crate's only interpretation of what a
169/// path *is*.
170///
171/// **Splitting happens before decoding**, per RFC 3986 §3.3: `/` separates segments and a
172/// percent-encoded `%2F` is an ordinary character *inside* one. Decoding the whole path
173/// first — which this crate did through 0.31.x — promotes `%2F` into a separator, so
174/// `/admin%2Fconfig` reaches `admin/config` on disk. Containment still held, but any
175/// router or middleware in front of this server correctly reads that request as a single
176/// segment matching no route, so the file was served while the route guarding it was
177/// never consulted. See `tests/encoded_separator.rs`.
178///
179/// A segment that still contains a separator after decoding is refused rather than
180/// re-split — the same answer Apache gives by default (`AllowEncodedSlashes Off`).
181/// Backslash is refused on every platform, not just Windows where it separates: a server
182/// whose test suite runs on one OS should not behave differently on another, and a file
183/// with a backslash in its name is not worth the divergence.
184pub(crate) fn decode_segments(request_path: &str) -> Result<Vec<Cow<'_, str>>, StaticError> {
185    let mut segments = Vec::new();
186    for raw in request_path.split('/') {
187        if raw.is_empty() {
188            continue;
189        }
190        // Invalid UTF-8 falls back to the raw segment, which then matches only a file
191        // literally named that — the behaviour this crate has always had.
192        // Borrowed when the segment carries no percent-encoding, which is the common
193        // case; `into_owned()` here allocated a `String` per segment per request and cost
194        // 5-6% on the `not_modified_304` and `sidecar_hit_200` benchmarks.
195        let decoded = percent_encoding::percent_decode_str(raw)
196            .decode_utf8()
197            .unwrap_or(Cow::Borrowed(raw));
198
199        check_segment(&decoded, request_path)?;
200        segments.push(decoded);
201    }
202    Ok(segments)
203}
204
205/// Refuse a decoded segment that must not reach the filesystem.
206///
207/// Separate from the decoding above because the two have different owners once a router
208/// sits in front of this crate. **The router decides what the segments are; this decides
209/// whether they are safe to open with.** A router that splits before decoding — as
210/// `mini-serve` correctly does — hands over a segment that may still contain a literal
211/// `/` from a `%2F`, and `Path::push` would read that as a separator, which is the
212/// encoded-separator escape all over again. So this runs over segments from any source,
213/// not only ones this crate decoded itself.
214pub(crate) fn check_segment(decoded: &str, request_path: &str) -> Result<(), StaticError> {
215    let refuse = || StaticError::Traversal(request_path.to_string());
216
217    // Catches a NUL whether it arrived raw or as `%00`. A separate pre-check on the
218    // undecoded path used to sit above the decode loop; mutation testing showed it was
219    // fully subsumed — neither guard could be removed alone and be noticed — so the
220    // redundant one went and this one carries the guarantee.
221    if decoded.contains('/') || decoded.contains('\\') || decoded.contains('\0') {
222        return Err(refuse());
223    }
224    if decoded == ".." {
225        return Err(refuse());
226    }
227    Ok(())
228}
229
230/// Join decoded segments onto the root one at a time.
231///
232/// One at a time because `Path::join` re-reads a separator inside its argument; feeding
233/// it a pre-joined string would undo the work `decode_segments` just did.
234fn join_segments(root_canon: &Path, segments: &[Cow<'_, str>]) -> PathBuf {
235    // `push` rather than `join`: `join` clones the whole path per segment, so folding it
236    // allocated a `PathBuf` for every segment of every request.
237    let mut path = root_canon.to_path_buf();
238    for segment in segments {
239        path.push(segment.as_ref());
240    }
241    path
242}
243
244/// [`resolve_with_canonical_root`] with an explicit hidden-file policy.
245pub(crate) fn resolve_with_policy(
246    root_canon: &Path,
247    request_path: &str,
248    hidden: HiddenFiles,
249) -> Result<PathBuf, StaticError> {
250    open_with_policy(root_canon, request_path, hidden).map(|resolved| resolved.path)
251}
252
253/// Open `joined` and prove, on the opened fd, that it lies under `root_canon`.
254///
255/// A directory retries with `index.html` appended — verified on its *own* fd, never
256/// trusted transitively from the directory's.
257///
258/// Every open failure collapses to `NotFound`: differentiating errno (permission vs
259/// absent vs vanished) would hand back the existence oracle the 404 path works to deny.
260#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
261fn open_verified(
262    root_canon: &Path,
263    joined: &Path,
264    request_path: &str,
265) -> Result<ResolvedFile, StaticError> {
266    let file = File::open(joined).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
267    let real = real_path_of(&file).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
268    if !real.starts_with(root_canon) {
269        return Err(StaticError::Traversal(request_path.to_string()));
270    }
271    let metadata = file
272        .metadata()
273        .map_err(|_| StaticError::NotFound(request_path.to_string()))?;
274
275    if metadata.is_dir() {
276        return open_verified(root_canon, &real.join(INDEX_FILE_NAME), request_path);
277    }
278
279    Ok(ResolvedFile {
280        file,
281        metadata,
282        path: real,
283    })
284}
285
286/// Portability fallback: the canonicalize-then-open sequence this crate used through
287/// 0.30.x, for platforms without a way to read an fd's real path. Slower and it
288/// re-admits the check-to-open window; the property tests exercise whichever variant
289/// the platform compiles.
290#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
291fn open_verified(
292    root_canon: &Path,
293    joined: &Path,
294    request_path: &str,
295) -> Result<ResolvedFile, StaticError> {
296    let canon = canonicalize_within_root(root_canon, joined)?;
297    let target = if canon.is_dir() {
298        let index = canon.join(INDEX_FILE_NAME);
299        canonicalize_within_root(root_canon, &index)?;
300        index
301    } else {
302        canon
303    };
304    let file = File::open(&target).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
305    let metadata = file
306        .metadata()
307        .map_err(|_| StaticError::NotFound(request_path.to_string()))?;
308    Ok(ResolvedFile {
309        file,
310        metadata,
311        path: target,
312    })
313}
314
315/// Open a precompressed sidecar and prove, on the opened fd, that it is a regular file
316/// under `root_canon`. `None` means "decline and serve the original".
317///
318/// # Why this exists rather than calling [`open_verified`]
319///
320/// From 0.9.0 until this function, the sidecar probe opened its file with a bare
321/// `File::open` and served it with **no containment check at all** — a `styles.css.br`
322/// symlinked outside the root was served for `GET /styles.css` with `Accept-Encoding: br`,
323/// while `GET /styles.css.br` on the same file correctly returned 404. Two doors to one
324/// file, one of them unguarded, falsifying the `containment-verified-on-fd` guarantee on a
325/// path its mutation test never reached. See `PLAN-sidecar.md`.
326///
327/// It cannot simply call [`open_verified`], because that **retries a directory** with
328/// `index.html` appended: a directory named `styles.css.br` would resolve to
329/// `styles.css.br/index.html` and be served as a brotli body. A sidecar is a regular file
330/// or it is nothing, and `is_file()` is the one condition that says so — it also excludes
331/// a FIFO, where `File::open` blocks until a writer appears and would hang the request.
332///
333/// Every failure returns `None` rather than an error. A sidecar is an optimisation: if it
334/// cannot be served safely the original is served instead, which is what a caller with no
335/// sidecar at all already does. Distinguishing "absent" from "refused" in the response
336/// would hand back an existence oracle for files outside the root.
337#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
338pub(crate) fn open_sidecar_verified(root_canon: &Path, sidecar: &Path) -> Option<ResolvedFile> {
339    let file = File::open(sidecar).ok()?;
340    let real = real_path_of(&file).ok()?;
341    if !real.starts_with(root_canon) {
342        return None;
343    }
344    let metadata = file.metadata().ok()?;
345    if !metadata.is_file() {
346        return None;
347    }
348    Some(ResolvedFile {
349        file,
350        metadata,
351        path: real,
352    })
353}
354
355/// Portability fallback for [`open_sidecar_verified`], for platforms with no way to read an
356/// fd's real path. Canonicalize-then-open, so it re-admits the check-to-open window that
357/// the fd-based variant closes — the same tradeoff the [`open_verified`] fallback makes.
358#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
359pub(crate) fn open_sidecar_verified(root_canon: &Path, sidecar: &Path) -> Option<ResolvedFile> {
360    let canon = canonicalize_within_root(root_canon, sidecar).ok()?;
361    let file = File::open(&canon).ok()?;
362    let metadata = file.metadata().ok()?;
363    if !metadata.is_file() {
364        return None;
365    }
366    Some(ResolvedFile {
367        file,
368        metadata,
369        path: canon,
370    })
371}
372
373/// [`resolve_with_policy`], but yielding the opened, containment-verified file rather
374/// than a path to reopen. `Server::handle_request` serves from this handle directly.
375/// [`open_with_policy`], against segments a caller already split and decoded.
376///
377/// Every segment is still checked by [`check_segment`]: where they came from is not this
378/// crate's business, but whether they can escape the root is.
379pub(crate) fn open_segments<S: AsRef<str>>(
380    root_canon: &Path,
381    segments: &[S],
382    request_path: &str,
383    hidden: HiddenFiles,
384) -> Result<ResolvedFile, StaticError> {
385    let checked = servable_segments(segments, request_path, hidden)?;
386    open_checked(root_canon, &checked, request_path, hidden)
387}
388
389/// The refusals that stand between a request's segments and any file at all.
390///
391/// Called by both ways of answering a request: the disk path before it opens anything, and the
392/// content cache before it looks anything up. **That sharing is the point.** The content cache
393/// initially skipped these and served `/.env` from memory while the disk path refused it — a
394/// hidden file leaked by a performance feature, found by the differential test within a minute
395/// of the cache first answering a request. One implementation cannot diverge from itself.
396pub(crate) fn servable_segments<'a, S: AsRef<str>>(
397    segments: &'a [S],
398    request_path: &str,
399    hidden: HiddenFiles,
400) -> Result<Vec<Cow<'a, str>>, StaticError> {
401    let checked: Vec<Cow<'a, str>> = segments
402        .iter()
403        .map(|segment| {
404            check_segment(segment.as_ref(), request_path)?;
405            Ok(Cow::Borrowed(segment.as_ref()))
406        })
407        .collect::<Result<_, StaticError>>()?;
408
409    if hidden == HiddenFiles::Deny && has_hidden_segment(&checked) {
410        return Err(StaticError::NotFound(request_path.to_string()));
411    }
412    Ok(checked)
413}
414
415pub(crate) fn open_with_policy(
416    root_canon: &Path,
417    request_path: &str,
418    hidden: HiddenFiles,
419) -> Result<ResolvedFile, StaticError> {
420    let segments = decode_segments(request_path)?;
421    open_checked(root_canon, &segments, request_path, hidden)
422}
423
424/// The shared tail: hidden-file policy, then open and prove containment.
425fn open_checked(
426    root_canon: &Path,
427    segments: &[Cow<'_, str>],
428    request_path: &str,
429    hidden: HiddenFiles,
430) -> Result<ResolvedFile, StaticError> {
431
432    // `NotFound`, not a distinct error: a hidden file that exists and one that doesn't
433    // must be indistinguishable, or the response becomes an oracle for what the root
434    // contains — the same reasoning that collapses traversal into the miss message.
435    if hidden == HiddenFiles::Deny && has_hidden_segment(segments) {
436        return Err(StaticError::NotFound(request_path.to_string()));
437    }
438
439    open_verified(root_canon, &join_segments(root_canon, segments), request_path)
440}
441
442/// Resolve a request path under a root directory, canonicalizing the root first.
443///
444/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
445/// the root on every call. For production use where the root is fixed at startup, prefer
446/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
447///
448/// # Arguments
449///
450/// * `root` - The server root directory (need not be pre-canonicalized).
451/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
452///
453/// # Returns
454///
455/// - `Ok(PathBuf)` if the path resolves to a file within root.
456/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
457/// - `Err(StaticError::Io)` if canonicalizing the root fails.
458pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
459    let root_canon = root.canonicalize().map_err(StaticError::Io)?;
460    resolve_with_canonical_root(&root_canon, request_path)
461}