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#[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 pub fn inferred(body: impl Into<Bytes>) -> Self {
30 Self::new(body, "")
31 }
32}
33
34#[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#[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 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 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 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 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}