Skip to main content

rest/
static_assets.rs

1use actix_web::{
2    http::{header, Method},
3    web::Bytes,
4    HttpRequest, HttpResponse,
5};
6use std::{
7    collections::HashMap,
8    fmt, io,
9    path::{Component, Path, PathBuf},
10    sync::Arc,
11};
12
13/// One in-memory asset served by [`StaticAssets`].
14#[derive(Debug, Clone)]
15pub struct EmbeddedAsset {
16    body: Bytes,
17    content_type: Arc<str>,
18}
19
20impl EmbeddedAsset {
21    pub fn new(body: impl Into<Bytes>, content_type: impl Into<Arc<str>>) -> Self {
22        Self {
23            body: body.into(),
24            content_type: content_type.into(),
25        }
26    }
27
28    /// Creates an asset whose content type is inferred from the request path.
29    pub fn inferred(body: impl Into<Bytes>) -> Self {
30        Self::new(body, "")
31    }
32}
33
34/// Errors raised while assembling a static fallback.
35#[derive(Debug)]
36pub enum StaticAssetsError {
37    Directory { path: PathBuf, source: io::Error },
38    InvalidPath(String),
39    DuplicatePath(String),
40    InvalidIndex,
41}
42
43impl fmt::Display for StaticAssetsError {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::Directory { path, source } => {
47                write!(
48                    formatter,
49                    "cannot use static directory {}: {source}",
50                    path.display()
51                )
52            }
53            Self::InvalidPath(path) => write!(formatter, "invalid embedded asset path: {path}"),
54            Self::DuplicatePath(path) => write!(formatter, "duplicate embedded asset path: {path}"),
55            Self::InvalidIndex => {
56                formatter.write_str("static index file must be a single file name")
57            }
58        }
59    }
60}
61
62impl std::error::Error for StaticAssetsError {
63    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64        match self {
65            Self::Directory { source, .. } => Some(source),
66            _ => None,
67        }
68    }
69}
70
71/// Traversal-safe fallback for a static directory, embedded assets, or both.
72///
73/// Embedded assets take precedence over files in the directory. Explicit Actix routes still take
74/// precedence because this handler is installed as the application's default service.
75#[derive(Debug, Clone)]
76pub struct StaticAssets {
77    directory: Option<Arc<PathBuf>>,
78    embedded: Arc<HashMap<String, EmbeddedAsset>>,
79    index_file: Arc<str>,
80}
81
82impl Default for StaticAssets {
83    fn default() -> Self {
84        Self {
85            directory: None,
86            embedded: Arc::new(HashMap::new()),
87            index_file: Arc::from("index.html"),
88        }
89    }
90}
91
92impl StaticAssets {
93    /// Creates a fallback rooted at an existing directory. The root is canonicalized immediately.
94    pub fn directory(root: impl AsRef<Path>) -> Result<Self, StaticAssetsError> {
95        let supplied = root.as_ref();
96        let canonical =
97            std::fs::canonicalize(supplied).map_err(|source| StaticAssetsError::Directory {
98                path: supplied.to_owned(),
99                source,
100            })?;
101        if !canonical.is_dir() {
102            return Err(StaticAssetsError::Directory {
103                path: supplied.to_owned(),
104                source: io::Error::new(io::ErrorKind::InvalidInput, "path is not a directory"),
105            });
106        }
107        Ok(Self {
108            directory: Some(Arc::new(canonical)),
109            ..Self::default()
110        })
111    }
112
113    /// Creates an embedded-only fallback.
114    pub fn embedded<I, P>(assets: I) -> Result<Self, StaticAssetsError>
115    where
116        I: IntoIterator<Item = (P, EmbeddedAsset)>,
117        P: AsRef<str>,
118    {
119        let mut fallback = Self::default();
120        for (path, asset) in assets {
121            fallback.insert(path.as_ref(), asset)?;
122        }
123        Ok(fallback)
124    }
125
126    /// Adds or overlays an embedded asset.
127    pub fn with_embedded(
128        mut self,
129        path: impl AsRef<str>,
130        asset: EmbeddedAsset,
131    ) -> Result<Self, StaticAssetsError> {
132        self.insert(path.as_ref(), asset)?;
133        Ok(self)
134    }
135
136    /// Changes the file served for a directory-style request such as `/` or `/docs/`.
137    pub fn with_index_file(
138        mut self,
139        index_file: impl Into<String>,
140    ) -> Result<Self, StaticAssetsError> {
141        let index_file = index_file.into();
142        if normalize_path(&index_file).as_deref() != Some(index_file.as_str())
143            || index_file.contains('/')
144        {
145            return Err(StaticAssetsError::InvalidIndex);
146        }
147        self.index_file = Arc::from(index_file);
148        Ok(self)
149    }
150
151    fn insert(&mut self, path: &str, asset: EmbeddedAsset) -> Result<(), StaticAssetsError> {
152        let normalized =
153            normalize_path(path).ok_or_else(|| StaticAssetsError::InvalidPath(path.to_owned()))?;
154        if Arc::make_mut(&mut self.embedded)
155            .insert(normalized.clone(), asset)
156            .is_some()
157        {
158            return Err(StaticAssetsError::DuplicatePath(normalized));
159        }
160        Ok(())
161    }
162
163    pub(crate) async fn serve(&self, request: HttpRequest) -> HttpResponse {
164        if request.method() != Method::GET && request.method() != Method::HEAD {
165            return HttpResponse::NotFound().finish();
166        }
167        let Some(mut path) = normalize_path(request.path()) else {
168            return HttpResponse::NotFound().finish();
169        };
170        if request.path().ends_with('/') || path.is_empty() {
171            if !path.is_empty() {
172                path.push('/');
173            }
174            path.push_str(&self.index_file);
175        }
176
177        if let Some(asset) = self.embedded.get(&path) {
178            let content_type = if asset.content_type.is_empty() {
179                content_type(&path)
180            } else {
181                &asset.content_type
182            };
183            return response(request.method(), asset.body.clone(), content_type);
184        }
185
186        let Some(root) = &self.directory else {
187            return HttpResponse::NotFound().finish();
188        };
189        let candidate = root.join(&path);
190        let Ok(canonical) = tokio::fs::canonicalize(candidate).await else {
191            return HttpResponse::NotFound().finish();
192        };
193        if !canonical.starts_with(root.as_ref()) {
194            return HttpResponse::NotFound().finish();
195        }
196        let Ok(metadata) = tokio::fs::metadata(&canonical).await else {
197            return HttpResponse::NotFound().finish();
198        };
199        if !metadata.is_file() {
200            return HttpResponse::NotFound().finish();
201        }
202        match tokio::fs::read(canonical).await {
203            Ok(body) => response(request.method(), Bytes::from(body), content_type(&path)),
204            Err(_) => HttpResponse::NotFound().finish(),
205        }
206    }
207}
208
209fn normalize_path(path: &str) -> Option<String> {
210    if path.contains('\0') || path.contains('\\') {
211        return None;
212    }
213    let path = path.trim_start_matches('/');
214    let mut normalized = Vec::new();
215    for component in Path::new(path).components() {
216        match component {
217            Component::Normal(value) => normalized.push(value.to_str()?.to_owned()),
218            Component::CurDir => {}
219            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
220        }
221    }
222    Some(normalized.join("/"))
223}
224
225fn response(method: &Method, body: Bytes, content_type: &str) -> HttpResponse {
226    let length = body.len();
227    let mut builder = HttpResponse::Ok();
228    builder.insert_header((header::CONTENT_TYPE, content_type));
229    builder.insert_header((header::CONTENT_LENGTH, length));
230    if method == Method::HEAD {
231        builder.finish()
232    } else {
233        builder.body(body)
234    }
235}
236
237fn content_type(path: &str) -> &'static str {
238    match Path::new(path)
239        .extension()
240        .and_then(|extension| extension.to_str())
241        .map(str::to_ascii_lowercase)
242        .as_deref()
243    {
244        Some("html" | "htm") => "text/html; charset=utf-8",
245        Some("css") => "text/css; charset=utf-8",
246        Some("js" | "mjs") => "text/javascript; charset=utf-8",
247        Some("json" | "map") => "application/json",
248        Some("txt") => "text/plain; charset=utf-8",
249        Some("svg") => "image/svg+xml",
250        Some("png") => "image/png",
251        Some("jpg" | "jpeg") => "image/jpeg",
252        Some("gif") => "image/gif",
253        Some("webp") => "image/webp",
254        Some("ico") => "image/x-icon",
255        Some("woff") => "font/woff",
256        Some("woff2") => "font/woff2",
257        Some("wasm") => "application/wasm",
258        Some("xml") => "application/xml",
259        Some("pdf") => "application/pdf",
260        _ => "application/octet-stream",
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use actix_web::{body::to_bytes, http::StatusCode, test};
268    use std::fs;
269
270    #[actix_web::test]
271    async fn serves_embedded_index_assets_and_head_requests() {
272        let assets = StaticAssets::embedded([
273            ("index.html", EmbeddedAsset::inferred("home")),
274            ("/app.js", EmbeddedAsset::inferred("code")),
275        ])
276        .unwrap();
277
278        let index = assets
279            .serve(test::TestRequest::get().uri("/").to_http_request())
280            .await;
281        assert_eq!(index.status(), StatusCode::OK);
282        assert_eq!(
283            index.headers().get(header::CONTENT_TYPE).unwrap(),
284            "text/html; charset=utf-8"
285        );
286        assert_eq!(to_bytes(index.into_body()).await.unwrap(), "home");
287
288        let head = assets
289            .serve(
290                test::TestRequest::default()
291                    .method(Method::HEAD)
292                    .uri("/app.js")
293                    .to_http_request(),
294            )
295            .await;
296        assert_eq!(head.status(), StatusCode::OK);
297        assert_eq!(head.headers().get(header::CONTENT_LENGTH).unwrap(), "4");
298        assert!(to_bytes(head.into_body()).await.unwrap().is_empty());
299    }
300
301    #[actix_web::test]
302    async fn serves_directory_files_and_rejects_traversal() {
303        let directory = tempfile::tempdir().unwrap();
304        fs::write(directory.path().join("index.html"), "directory home").unwrap();
305        fs::write(directory.path().join("data.json"), "{}").unwrap();
306        let assets = StaticAssets::directory(directory.path()).unwrap();
307
308        let file = assets
309            .serve(test::TestRequest::get().uri("/data.json").to_http_request())
310            .await;
311        assert_eq!(file.status(), StatusCode::OK);
312        assert_eq!(
313            file.headers().get(header::CONTENT_TYPE).unwrap(),
314            "application/json"
315        );
316
317        for uri in ["/../Cargo.toml", "/missing", "/data.json/child"] {
318            let response = assets
319                .serve(test::TestRequest::get().uri(uri).to_http_request())
320                .await;
321            assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
322        }
323    }
324
325    #[cfg(unix)]
326    #[actix_web::test]
327    async fn rejects_symlinks_that_escape_the_static_root() {
328        use std::os::unix::fs::symlink;
329
330        let directory = tempfile::tempdir().unwrap();
331        let outside = tempfile::NamedTempFile::new().unwrap();
332        fs::write(outside.path(), "secret").unwrap();
333        symlink(outside.path(), directory.path().join("escape.txt")).unwrap();
334        let assets = StaticAssets::directory(directory.path()).unwrap();
335
336        let response = assets
337            .serve(
338                test::TestRequest::get()
339                    .uri("/escape.txt")
340                    .to_http_request(),
341            )
342            .await;
343        assert_eq!(response.status(), StatusCode::NOT_FOUND);
344    }
345
346    #[actix_web::test]
347    async fn validates_embedded_paths_and_duplicates() {
348        assert!(StaticAssets::embedded([("../secret", EmbeddedAsset::inferred("x"))]).is_err());
349        assert!(StaticAssets::embedded([
350            ("same", EmbeddedAsset::inferred("a")),
351            ("/same", EmbeddedAsset::inferred("b")),
352        ])
353        .is_err());
354    }
355}