Skip to main content

rama_http/layer/uri/
file.rs

1//! Serve `file://` request URIs from the local filesystem.
2//!
3//! A client stack layered with [`FileUriLayer`] answers `file://` requests
4//! itself and passes every other scheme to the inner service, so one client
5//! serves both local and remote URIs.
6//!
7//! Place this layer *outside* any
8//! [`FollowRedirectLayer`][crate::layer::follow_redirect::FollowRedirectLayer]:
9//! redirects are followed by the inner service, so a remote response can never
10//! redirect into the local filesystem.
11//!
12//! This is a **client-side** layer: the request target is your own. Mounting
13//! it in a server stack, where the request target is attacker-controlled,
14//! turns any absolute-form `file://` target into an arbitrary local-file read.
15//! If you must, always confine it with [`FileUriLayer::with_jail`]; the
16//! default (`None`) serves any readable absolute path.
17
18use 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/// Serve `file://` request URIs from the local filesystem.
34///
35/// See the [module docs](crate::layer::uri::file) for an example.
36#[derive(Debug, Clone, Default)]
37pub struct FileUriLayer {
38    jail: Option<PathBuf>,
39}
40
41impl FileUriLayer {
42    /// Create a new [`FileUriLayer`].
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    generate_set_and_with! {
49        /// Confine served files to within this root directory,
50        /// rejecting any path resolving outside it.
51        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/// Serve `file://` request URIs from the local filesystem.
77///
78/// See the [module docs](crate::layer::uri::file) for an example.
79#[derive(Debug, Clone)]
80pub struct FileUriService<S> {
81    inner: S,
82    jail: Option<PathBuf>,
83}
84
85impl<S> FileUriService<S> {
86    /// Create a new [`FileUriService`].
87    pub const fn new(inner: S) -> Self {
88        Self { inner, jail: None }
89    }
90
91    generate_set_and_with! {
92        /// Confine served files to within this root directory,
93        /// rejecting any path resolving outside it.
94        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        // canonicalize resolves `.`/`..` (incl. percent-encoded) and clamps to
123        // root before touching the fs; safe_open is the traversal backstop
124        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        // stat the open handle rather than the path: a directory opens fine on
135        // unix and would otherwise only fail once the body is read, long after
136        // the 200 OK and its headers were committed
137        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
162/// Guess the media type from the file extension, mirroring
163/// [`ServeFile`][crate::service::fs::ServeFile].
164fn 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    /// `file://` uri for a local path. Windows paths are neither rooted nor
187    /// `/`-separated, so they need rewriting to be uri syntax at all.
188    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    /// A directory is refused either by the metadata check (unix opens a
198    /// directory fine) or by the open itself (windows does not).
199    #[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        // the path rides along as a structured field, never formatted into
318        // the error's own message
319        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        // a jail root is a directory too, and must not be served as its own file
339        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        // ... while a regular file in the same directory still is
345        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    /// RFC 8089 no-authority form; the path shape is unix-specific.
381    #[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        // layered outside the redirect follower: the redirect is resolved by
415        // the inner service, so its file:// location never reaches this layer
416        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        // `..` segments are resolved before the fs is touched
442        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}