Skip to main content

tako_rs_streams/static/
dir.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use http::header;
5
6/// Static directory server with configurable fallback handling.
7#[doc(alias = "static")]
8#[doc(alias = "serve_dir")]
9pub struct ServeDir {
10  pub(crate) base_dir: PathBuf,
11  pub(crate) fallback: Option<PathBuf>,
12  pub(crate) index_files: Vec<String>,
13  pub(crate) precompressed: PrecompressedPolicy,
14  pub(crate) sanitized_base: Option<PathBuf>,
15}
16
17/// Which precompressed sidecar files (if any) `ServeDir` should prefer when
18/// the client advertises support via `Accept-Encoding`.
19#[derive(Debug, Clone, Copy, Default)]
20pub struct PrecompressedPolicy {
21  /// Serve `<file>.br` when the client accepts `br`.
22  pub brotli: bool,
23  /// Serve `<file>.gz` when the client accepts `gzip`.
24  pub gzip: bool,
25}
26
27impl PrecompressedPolicy {
28  /// Both `br` and `gzip` enabled.
29  pub const fn both() -> Self {
30    Self {
31      brotli: true,
32      gzip: true,
33    }
34  }
35
36  /// `br` only.
37  pub const fn brotli_only() -> Self {
38    Self {
39      brotli: true,
40      gzip: false,
41    }
42  }
43
44  /// `gzip` only.
45  pub const fn gzip_only() -> Self {
46    Self {
47      brotli: false,
48      gzip: true,
49    }
50  }
51}
52
53/// Builder for configuring a `ServeDir` instance.
54#[must_use]
55pub struct ServeDirBuilder {
56  base_dir: PathBuf,
57  fallback: Option<PathBuf>,
58  index_files: Vec<String>,
59  precompressed: PrecompressedPolicy,
60}
61
62impl ServeDirBuilder {
63  /// Creates a new builder with the specified base directory.
64  #[inline]
65  pub fn new<P: Into<PathBuf>>(base_dir: P) -> Self {
66    Self {
67      base_dir: base_dir.into(),
68      fallback: None,
69      index_files: vec!["index.html".into(), "index.htm".into()],
70      precompressed: PrecompressedPolicy::default(),
71    }
72  }
73
74  /// Sets a fallback file to serve when requested files are not found.
75  #[inline]
76  pub fn fallback<P: Into<PathBuf>>(mut self, fallback: P) -> Self {
77    self.fallback = Some(fallback.into());
78    self
79  }
80
81  /// Replace the index resolution priority list (defaults to
82  /// `["index.html", "index.htm"]`).
83  #[inline]
84  pub fn index_files<I, S>(mut self, names: I) -> Self
85  where
86    I: IntoIterator<Item = S>,
87    S: Into<String>,
88  {
89    self.index_files = names.into_iter().map(Into::into).collect();
90    self
91  }
92
93  /// Configure preference for precompressed sidecar files.
94  #[inline]
95  pub fn precompressed(mut self, policy: PrecompressedPolicy) -> Self {
96    self.precompressed = policy;
97    self
98  }
99
100  /// Builds and returns the configured `ServeDir` instance.
101  #[inline]
102  pub fn build(self) -> ServeDir {
103    let sanitized_base = self.base_dir.canonicalize().ok();
104    ServeDir {
105      base_dir: self.base_dir,
106      fallback: self.fallback,
107      index_files: self.index_files,
108      precompressed: self.precompressed,
109      sanitized_base,
110    }
111  }
112}
113
114impl ServeDir {
115  /// Creates a new builder for configuring a `ServeDir`.
116  pub fn builder<P: Into<PathBuf>>(base_dir: P) -> ServeDirBuilder {
117    ServeDirBuilder::new(base_dir)
118  }
119
120  /// Sanitizes the requested path to prevent directory traversal attacks.
121  pub(crate) fn sanitize_path(&self, req_path: &str) -> Option<PathBuf> {
122    let rel_path = req_path.trim_start_matches('/');
123    // Refuse explicit `..` traversal segments before touching the FS.
124    if rel_path
125      .split(['/', '\\'])
126      .any(|seg| seg == ".." || seg == ".")
127    {
128      return None;
129    }
130    let joined = self.base_dir.join(rel_path);
131    let canonical = joined.canonicalize().ok()?;
132    let base = self
133      .sanitized_base
134      .clone()
135      .or_else(|| self.base_dir.canonicalize().ok())?;
136    if canonical.starts_with(&base) {
137      Some(canonical)
138    } else {
139      None
140    }
141  }
142
143  fn accepts(headers: &http::HeaderMap, encoding: &str) -> bool {
144    let Some(v) = headers
145      .get(header::ACCEPT_ENCODING)
146      .and_then(|v| v.to_str().ok())
147    else {
148      return false;
149    };
150    for part in v.split(',') {
151      let part = part.trim();
152      // Strip any q-value parameter; reject q=0 explicitly.
153      let mut name_q = part.split(';');
154      let name = name_q.next().unwrap_or("").trim();
155      let q_zero = name_q.any(|p| p.trim().strip_prefix("q=").is_some_and(|q| q.trim() == "0"));
156      if q_zero {
157        continue;
158      }
159      if name.eq_ignore_ascii_case(encoding) || name == "*" {
160        return true;
161      }
162    }
163    false
164  }
165
166  /// Verifies a sidecar path (`<file>.br` / `<file>.gz`) canonicalizes to
167  /// somewhere inside the base directory before we hand it to the open
168  /// pipeline. The original base-prefix check only covered `file_path`; a
169  /// symlinked sidecar could otherwise escape outside the base.
170  pub(crate) fn canonical_within_base(&self, p: &Path) -> Option<PathBuf> {
171    let canonical = p.canonicalize().ok()?;
172    let base = self
173      .sanitized_base
174      .clone()
175      .or_else(|| self.base_dir.canonicalize().ok())?;
176    if canonical.starts_with(&base) {
177      Some(canonical)
178    } else {
179      None
180    }
181  }
182
183  pub(crate) fn precompressed_variant(
184    &self,
185    file_path: &Path,
186    headers: &http::HeaderMap,
187  ) -> Option<(PathBuf, &'static str)> {
188    if self.precompressed.brotli && Self::accepts(headers, "br") {
189      let mut p = file_path.as_os_str().to_owned();
190      p.push(".br");
191      let p = PathBuf::from(p);
192      if let Some(canonical) = self.canonical_within_base(&p) {
193        return Some((canonical, "br"));
194      }
195    }
196    if self.precompressed.gzip && Self::accepts(headers, "gzip") {
197      let mut p = file_path.as_os_str().to_owned();
198      p.push(".gz");
199      let p = PathBuf::from(p);
200      if let Some(canonical) = self.canonical_within_base(&p) {
201        return Some((canonical, "gzip"));
202      }
203    }
204    None
205  }
206}