1use std::path::{Path, PathBuf};
19
20use rama_core::{
21 Layer, Service,
22 error::{BoxError, BoxErrorExt, ErrorContext, ErrorExt},
23};
24use rama_net::{Protocol, uri::file_uri_path};
25use rama_utils::macros::{define_inner_service_accessors, generate_set_and_with};
26
27use crate::{
28 Body, Method, Request, Response, StatusCode,
29 headers::{ContentType, HttpResponseBuilderExt as _},
30 mime::{Mime, guess as mime_guess},
31};
32
33#[derive(Debug, Clone, Default)]
37pub struct FileUriLayer {
38 jail: Option<PathBuf>,
39}
40
41impl FileUriLayer {
42 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 generate_set_and_with! {
49 pub fn jail(mut self, jail: Option<PathBuf>) -> Self {
52 self.jail = jail;
53 self
54 }
55 }
56}
57
58impl<S> Layer<S> for FileUriLayer {
59 type Service = FileUriService<S>;
60
61 fn layer(&self, inner: S) -> Self::Service {
62 FileUriService {
63 inner,
64 jail: self.jail.clone(),
65 }
66 }
67
68 fn into_layer(self, inner: S) -> Self::Service {
69 FileUriService {
70 inner,
71 jail: self.jail,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
80pub struct FileUriService<S> {
81 inner: S,
82 jail: Option<PathBuf>,
83}
84
85impl<S> FileUriService<S> {
86 pub const fn new(inner: S) -> Self {
88 Self { inner, jail: None }
89 }
90
91 generate_set_and_with! {
92 pub fn jail(mut self, jail: Option<PathBuf>) -> Self {
95 self.jail = jail;
96 self
97 }
98 }
99
100 define_inner_service_accessors!();
101}
102
103impl<S, ReqBody> Service<Request<ReqBody>> for FileUriService<S>
104where
105 S: Service<Request<ReqBody>, Output = Response, Error: Into<BoxError>>,
106 ReqBody: Send + 'static,
107{
108 type Output = Response;
109 type Error = BoxError;
110
111 async fn serve(&self, req: Request<ReqBody>) -> Result<Self::Output, Self::Error> {
112 if req.uri().scheme() != Some(&Protocol::FILE) {
113 return self.inner.serve(req).await.map_err(Into::into);
114 }
115
116 if !matches!(req.method(), &Method::GET | &Method::HEAD) {
117 return Err(BoxError::from_static_str(
118 "file:// URIs support GET and HEAD only",
119 ));
120 }
121
122 let uri = req.uri().clone().canonicalize();
125 let path = file_uri_path(&uri).context("resolve file: uri path")?;
126
127 let file = match &self.jail {
128 Some(root) => rama_utils::fs::safe_open_under(root, &path).await,
129 None => rama_utils::fs::safe_open(&path).await,
130 }
131 .context("open file")
132 .with_context_str_field("path", || path.to_string_lossy())?;
133
134 let metadata = file
138 .metadata()
139 .await
140 .context("stat file")
141 .with_context_str_field("path", || path.to_string_lossy())?;
142 if !metadata.is_file() {
143 return Err(
144 BoxError::from_static_str("file: uri does not point to a regular file")
145 .context_str_field("path", path.to_string_lossy()),
146 );
147 }
148
149 let body = if req.method() == Method::HEAD {
150 Body::empty()
151 } else {
152 Body::from_stream(rama_core::stream::io::ReaderStream::new(file))
153 };
154 Response::builder()
155 .status(StatusCode::OK)
156 .typed_header(ContentType::new(guess_mime(&path)))
157 .body(body)
158 .context("build file:// response")
159 }
160}
161
162fn guess_mime(path: &Path) -> Mime {
165 mime_guess::from_path(path)
166 .first()
167 .unwrap_or(crate::mime::APPLICATION_OCTET_STREAM)
168}
169
170#[cfg(test)]
171mod tests {
172 use std::convert::Infallible;
173
174 use rama_core::service::service_fn;
175
176 use super::*;
177 use crate::{BodyExtractExt as _, headers::HeaderMapExt as _};
178 use rama_net::uri::Uri;
179
180 fn service() -> FileUriService<impl Service<Request, Output = Response, Error = Infallible>> {
181 FileUriService::new(service_fn(async |_: Request| {
182 Ok::<_, Infallible>(Response::new(Body::from("remote")))
183 }))
184 }
185
186 fn file_uri(path: &Path) -> String {
189 let path = path.display().to_string().replace('\\', "/");
190 if path.starts_with('/') {
191 format!("file://{path}")
192 } else {
193 format!("file:///{path}")
194 }
195 }
196
197 #[track_caller]
200 fn assert_not_a_regular_file(err: &BoxError, ctx: &str) {
201 let err = err.to_string();
202 let refused = if cfg!(unix) {
203 err.contains("regular file")
204 } else {
205 err.contains("regular file") || err.contains("open file")
206 };
207 assert!(refused, "{ctx}: unexpected error: {err}");
208 }
209
210 async fn get(
211 svc: &impl Service<Request, Output = Response, Error = BoxError>,
212 uri: &str,
213 ) -> Result<Response, BoxError> {
214 let uri: Uri = uri.parse().unwrap();
215 svc.serve(Request::get(uri).body(Body::empty()).unwrap())
216 .await
217 }
218
219 #[test]
220 fn file_uri_of_a_windows_shaped_path_is_parseable() {
221 let raw = file_uri(Path::new(r"C:\Users\rama\Temp\pac.js"));
222 assert_eq!(raw, "file:///C:/Users/rama/Temp/pac.js");
223 let uri: Uri = raw.parse().unwrap();
224 assert_eq!(uri.scheme(), Some(&Protocol::FILE));
225
226 assert_eq!(file_uri(Path::new("/tmp/pac.js")), "file:///tmp/pac.js");
227 }
228
229 #[tokio::test]
230 async fn non_file_scheme_passes_through() {
231 let resp = get(&service(), "http://example.com/x").await.unwrap();
232 assert_eq!(resp.try_into_string().await.unwrap(), "remote");
233 }
234
235 #[tokio::test]
236 async fn serves_local_file() {
237 let dir = tempfile::tempdir().unwrap();
238 std::fs::write(dir.path().join("pac.js"), "DIRECT").unwrap();
239 let uri = format!("{}/pac.js", file_uri(dir.path()));
240
241 let resp = get(&service(), &uri).await.unwrap();
242 assert_eq!(resp.status(), StatusCode::OK);
243 assert_eq!(
244 resp.headers()
245 .typed_get::<ContentType>()
246 .unwrap()
247 .into_mime(),
248 crate::mime::TEXT_JAVASCRIPT,
249 );
250 assert_eq!(resp.try_into_string().await.unwrap(), "DIRECT");
251 }
252
253 #[tokio::test]
254 async fn unknown_extension_falls_back_to_octet_stream() {
255 let dir = tempfile::tempdir().unwrap();
256 std::fs::write(dir.path().join("pac.unknownext"), "DIRECT").unwrap();
257 let uri = format!("{}/pac.unknownext", file_uri(dir.path()));
258
259 let resp = get(&service(), &uri).await.unwrap();
260 assert_eq!(
261 resp.headers()
262 .typed_get::<ContentType>()
263 .unwrap()
264 .into_mime(),
265 crate::mime::APPLICATION_OCTET_STREAM,
266 );
267 }
268
269 #[tokio::test]
270 async fn head_has_no_body() {
271 let dir = tempfile::tempdir().unwrap();
272 std::fs::write(dir.path().join("pac.js"), "DIRECT").unwrap();
273 let uri = format!("{}/pac.js", file_uri(dir.path()));
274
275 let resp = service()
276 .serve(
277 Request::builder()
278 .method(Method::HEAD)
279 .uri(uri)
280 .body(Body::empty())
281 .unwrap(),
282 )
283 .await
284 .unwrap();
285 assert_eq!(resp.try_into_string().await.unwrap(), "");
286 }
287
288 #[tokio::test]
289 async fn rejects_non_get_methods() {
290 let err = service()
291 .serve(
292 Request::builder()
293 .method(Method::POST)
294 .uri("file:///tmp/x")
295 .body(Body::empty())
296 .unwrap(),
297 )
298 .await
299 .unwrap_err();
300 assert!(err.to_string().contains("GET and HEAD"), "{err}");
301 }
302
303 #[tokio::test]
304 async fn rejects_relative_file_uri_paths() {
305 for uri in ["file:Cargo.toml", "file:./Cargo.toml", "file:../Cargo.toml"] {
306 let err = get(&service(), uri).await.unwrap_err();
307 assert!(err.to_string().contains("not absolute"), "{uri}: {err}");
308 }
309 }
310
311 #[tokio::test]
312 async fn missing_file_errors() {
313 let dir = tempfile::tempdir().unwrap();
314 let uri = format!("{}/nope.js", file_uri(dir.path()));
315 let err = get(&service(), &uri).await.unwrap_err().to_string();
316
317 assert!(err.contains("open file"), "{err}");
320 assert!(err.contains("path="), "{err}");
321 assert!(err.contains("nope.js"), "{err}");
322 }
323
324 #[tokio::test]
325 async fn directory_is_not_served() {
326 let dir = tempfile::tempdir().unwrap();
327 std::fs::create_dir(dir.path().join("sub")).unwrap();
328
329 for uri in [
330 file_uri(dir.path()),
331 format!("{}/", file_uri(dir.path())),
332 format!("{}/sub", file_uri(dir.path())),
333 ] {
334 let err = get(&service(), &uri).await.unwrap_err();
335 assert_not_a_regular_file(&err, &uri);
336 }
337
338 let svc = service().with_jail(dir.path().to_path_buf());
340 let uri = file_uri(dir.path());
341 let err = get(&svc, &uri).await.unwrap_err();
342 assert_not_a_regular_file(&err, &uri);
343
344 std::fs::write(dir.path().join("pac.js"), "DIRECT").unwrap();
346 let uri = format!("{}/pac.js", file_uri(dir.path()));
347 let resp = get(&service(), &uri).await.unwrap();
348 assert_eq!(resp.try_into_string().await.unwrap(), "DIRECT");
349 }
350
351 #[tokio::test]
352 async fn head_of_a_directory_is_not_a_success() {
353 let dir = tempfile::tempdir().unwrap();
354 let uri = format!("{}/", file_uri(dir.path()));
355 let err = service()
356 .serve(
357 Request::builder()
358 .method(Method::HEAD)
359 .uri(uri.as_str())
360 .body(Body::empty())
361 .unwrap(),
362 )
363 .await
364 .unwrap_err();
365 assert_not_a_regular_file(&err, &uri);
366 }
367
368 #[cfg(unix)]
369 #[tokio::test]
370 async fn symlink_to_directory_is_not_served() {
371 let dir = tempfile::tempdir().unwrap();
372 std::fs::create_dir(dir.path().join("sub")).unwrap();
373 std::os::unix::fs::symlink(dir.path().join("sub"), dir.path().join("link")).unwrap();
374
375 let uri = format!("{}/link", file_uri(dir.path()));
376 let err = get(&service(), &uri).await.unwrap_err();
377 assert_not_a_regular_file(&err, &uri);
378 }
379
380 #[cfg(unix)]
382 #[tokio::test]
383 async fn no_authority_form_with_dot_segments_is_served() {
384 let dir = tempfile::tempdir().unwrap();
385 std::fs::write(dir.path().join("pac.js"), "DIRECT").unwrap();
386
387 let uri = format!("file:{}/sub/../pac.js", dir.path().display());
388 let resp = get(&service(), &uri).await.unwrap();
389 assert_eq!(resp.try_into_string().await.unwrap(), "DIRECT");
390 }
391
392 #[tokio::test]
393 async fn jail_rejects_paths_outside_root() {
394 let root = tempfile::tempdir().unwrap();
395 let outside = tempfile::tempdir().unwrap();
396 std::fs::write(outside.path().join("secret"), "leak").unwrap();
397 std::fs::write(root.path().join("ok"), "fine").unwrap();
398
399 let svc = service().with_jail(root.path().to_path_buf());
400 let uri = format!("{}/secret", file_uri(outside.path()));
401 let _err = get(&svc, &uri).await.unwrap_err();
402
403 let uri = format!("{}/ok", file_uri(root.path()));
404 let resp = get(&svc, &uri).await.unwrap();
405 assert_eq!(resp.try_into_string().await.unwrap(), "fine");
406 }
407
408 #[tokio::test]
409 async fn redirect_into_file_scheme_is_not_served() {
410 use crate::layer::follow_redirect::FollowRedirectLayer;
411 use crate::{StatusCode, header::LOCATION};
412 use rama_core::Layer as _;
413
414 let svc = (FileUriLayer::new(), FollowRedirectLayer::new()).into_layer(service_fn(
417 async |req: Request| {
418 Ok::<_, Infallible>(if req.uri().scheme() == Some(&Protocol::FILE) {
419 Response::new(Body::from("inner saw file scheme"))
420 } else {
421 Response::builder()
422 .status(StatusCode::FOUND)
423 .header(LOCATION, "file:///etc/hosts")
424 .body(Body::empty())
425 .unwrap()
426 })
427 },
428 ));
429
430 let resp = get(&svc, "http://example.com/redirect").await.unwrap();
431 assert_eq!(
432 resp.try_into_string().await.unwrap(),
433 "inner saw file scheme"
434 );
435 }
436
437 #[tokio::test]
438 async fn traversal_is_clamped_to_root() {
439 let dir = tempfile::tempdir().unwrap();
440 std::fs::write(dir.path().join("pac.js"), "DIRECT").unwrap();
441 let uri = format!("{}/sub/../pac.js", file_uri(dir.path()));
443
444 let resp = get(&service(), &uri).await.unwrap();
445 assert_eq!(resp.try_into_string().await.unwrap(), "DIRECT");
446 }
447}