Skip to main content

tower_http/services/fs/serve_dir/
open_file.rs

1use super::{
2    backend::{Backend, File as _, Metadata as _},
3    headers::{ETag, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince, LastModified},
4    ServeVariant,
5};
6use crate::content_encoding::{Encoding, QValue};
7use bytes::Bytes;
8use http::{header, HeaderValue, Method, Request, Uri};
9use http_body_util::Empty;
10use http_range_header::RangeUnsatisfiableError;
11use std::{
12    ffi::OsStr,
13    io::{self, ErrorKind, SeekFrom},
14    ops::RangeInclusive,
15    path::{Path, PathBuf},
16};
17use tokio::io::AsyncSeekExt;
18
19pub(super) enum OpenFileOutput {
20    FileOpened(Box<FileOpened>),
21    Redirect {
22        location: HeaderValue,
23    },
24    FileNotFound,
25    PreconditionFailed,
26    NotModified {
27        etag: Option<ETag>,
28        last_modified: Option<LastModified>,
29    },
30    InvalidRedirectUri,
31    InvalidFilename,
32}
33
34pub(super) struct FileOpened {
35    pub(super) extent: FileRequestExtent,
36    pub(super) chunk_size: usize,
37    pub(super) mime_header_value: HeaderValue,
38    pub(super) maybe_encoding: Option<Encoding>,
39    pub(super) maybe_range: Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>>,
40    pub(super) last_modified: Option<LastModified>,
41    pub(super) precompression_configured: bool,
42    pub(super) etag: Option<ETag>,
43}
44
45pub(super) enum FileRequestExtent {
46    Full(Box<dyn tokio::io::AsyncRead + Unpin + Send>, u64),
47    Head(u64),
48}
49
50pub(super) struct OpenFileRequest<B> {
51    pub(super) variant: ServeVariant,
52    pub(super) redirect_path_prefix: String,
53    pub(super) path_to_file: PathBuf,
54    pub(super) req: Request<Empty<Bytes>>,
55    pub(super) negotiated_encodings: Vec<(Encoding, QValue)>,
56    pub(super) range_header: Option<String>,
57    pub(super) buf_chunk_size: usize,
58    pub(super) precompression_configured: bool,
59    pub(super) backend: B,
60}
61
62pub(super) async fn open_file<B: Backend>(
63    request: OpenFileRequest<B>,
64) -> io::Result<OpenFileOutput> {
65    let OpenFileRequest {
66        variant,
67        redirect_path_prefix,
68        mut path_to_file,
69        req,
70        negotiated_encodings,
71        range_header,
72        buf_chunk_size,
73        precompression_configured,
74        backend,
75    } = request;
76    let preconditions = Preconditions {
77        if_match: req
78            .headers()
79            .get(header::IF_MATCH)
80            .and_then(IfMatch::from_header_value),
81        if_unmodified_since: req
82            .headers()
83            .get(header::IF_UNMODIFIED_SINCE)
84            .and_then(IfUnmodifiedSince::from_header_value),
85        if_none_match: req
86            .headers()
87            .get(header::IF_NONE_MATCH)
88            .and_then(IfNoneMatch::from_header_value),
89        if_modified_since: req
90            .headers()
91            .get(header::IF_MODIFIED_SINCE)
92            .and_then(IfModifiedSince::from_header_value),
93    };
94
95    let mime = match variant {
96        ServeVariant::Directory {
97            append_index_html_on_directories,
98            html_as_default_extension,
99        } => {
100            // Might already at this point know a redirect or not found result should be
101            // returned which corresponds to a Some(output). Otherwise the path might be
102            // modified and proceed to the open file/metadata future.
103            if let Some(output) = maybe_redirect_or_append_path(
104                &redirect_path_prefix,
105                &mut path_to_file,
106                req.uri(),
107                append_index_html_on_directories,
108                html_as_default_extension,
109                &backend,
110            )
111            .await
112            {
113                return Ok(output);
114            }
115
116            mime_guess::from_path(&path_to_file)
117                .first_raw()
118                .map(HeaderValue::from_static)
119                .unwrap_or_else(|| {
120                    HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
121                })
122        }
123
124        ServeVariant::SingleFile { mime } => mime,
125    };
126
127    if req.method() == Method::HEAD {
128        #[cfg(feature = "tracing")]
129        let _path_str = path_to_file.display().to_string();
130        let (meta, maybe_encoding) =
131            file_metadata_with_fallback(&backend, path_to_file, negotiated_encodings).await?;
132
133        let last_modified = meta.modified().ok().map(LastModified::from);
134        let etag = meta
135            .modified()
136            .ok()
137            .and_then(|mtime| ETag::from_metadata(meta.len(), mtime));
138
139        #[cfg(feature = "tracing")]
140        if etag.is_none() {
141            rate_limited!(
142                std::time::Duration::from_secs(60),
143                tracing::warn!(path = %_path_str, "ETag generation failed (mtime unavailable or pre-epoch)")
144            );
145        }
146
147        if let Some(output) = preconditions.check(etag.as_ref(), last_modified.as_ref()) {
148            return Ok(output);
149        }
150
151        let maybe_range = try_parse_range(range_header.as_deref(), meta.len());
152
153        Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
154            extent: FileRequestExtent::Head(meta.len()),
155            chunk_size: buf_chunk_size,
156            mime_header_value: mime,
157            maybe_encoding,
158            maybe_range,
159            last_modified,
160            precompression_configured,
161            etag,
162        })))
163    } else {
164        #[cfg(feature = "tracing")]
165        let _path_str = path_to_file.display().to_string();
166        let (mut file, maybe_encoding) =
167            match open_file_with_fallback(&backend, path_to_file, negotiated_encodings).await {
168                Ok(result) => result,
169
170                Err(err) if is_invalid_filename_error(&err) => {
171                    return Ok(OpenFileOutput::InvalidFilename)
172                }
173                Err(err) => return Err(err),
174            };
175
176        let meta = file.metadata().await?;
177
178        let last_modified = meta.modified().ok().map(LastModified::from);
179        let etag = meta
180            .modified()
181            .ok()
182            .and_then(|mtime| ETag::from_metadata(meta.len(), mtime));
183
184        #[cfg(feature = "tracing")]
185        if etag.is_none() {
186            rate_limited!(
187                std::time::Duration::from_secs(60),
188                tracing::warn!(path = %_path_str, "ETag generation failed (mtime unavailable or pre-epoch)")
189            );
190        }
191
192        if let Some(output) = preconditions.check(etag.as_ref(), last_modified.as_ref()) {
193            return Ok(output);
194        }
195
196        let size = meta.len();
197        let maybe_range = try_parse_range(range_header.as_deref(), size);
198        if let Some(Ok(ranges)) = maybe_range.as_ref() {
199            // if there is any other amount of ranges than 1 we'll return an
200            // unsatisfiable later as there isn't yet support for multipart ranges
201            if ranges.len() == 1 {
202                file.seek(SeekFrom::Start(*ranges[0].start())).await?;
203            }
204        }
205
206        Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
207            extent: FileRequestExtent::Full(Box::new(file), size),
208            chunk_size: buf_chunk_size,
209            mime_header_value: mime,
210            maybe_encoding,
211            maybe_range,
212            last_modified,
213            precompression_configured,
214            etag,
215        })))
216    }
217}
218
219fn is_invalid_filename_error(err: &io::Error) -> bool {
220    // Only applies to NULL bytes
221    if err.kind() == ErrorKind::InvalidInput {
222        return true;
223    }
224
225    // FIXME: Remove when MSRV >= 1.87.
226    // `io::ErrorKind::InvalidFilename` is stabilized in v1.87
227    #[cfg(windows)]
228    if let Some(raw_err) = err.raw_os_error() {
229        // https://github.com/rust-lang/rust/blob/70e2b4a4d197f154bed0eb3dcb5cac6a948ff3a3/library/std/src/sys/pal/windows/mod.rs
230        // Lines 81 and 115
231        if (raw_err == 123) || (raw_err == 161) || (raw_err == 206) {
232            return true;
233        }
234    }
235
236    false
237}
238
239/// Precondition headers parsed from the request.
240struct Preconditions {
241    if_match: Option<IfMatch>,
242    if_unmodified_since: Option<IfUnmodifiedSince>,
243    if_none_match: Option<IfNoneMatch>,
244    if_modified_since: Option<IfModifiedSince>,
245}
246
247impl Preconditions {
248    /// Evaluate preconditions per [RFC 9110 §13.2.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.2.2).
249    ///
250    /// Precedence order:
251    /// 1. If-Match (strong comparison) → 412 on failure
252    /// 2. If-Unmodified-Since (only if If-Match absent) → 412 on failure
253    /// 3. If-None-Match (weak comparison) → 304 on failure (for GET/HEAD)
254    /// 4. If-Modified-Since (only if If-None-Match absent) → 304 on failure
255    fn check(
256        self,
257        etag: Option<&ETag>,
258        last_modified: Option<&LastModified>,
259    ) -> Option<OpenFileOutput> {
260        // Step 1: If-Match
261        if let Some(if_match) = self.if_match {
262            // RFC 9110 §13.1.1: "If the field value is '*', the condition is FALSE
263            // if the origin server does not have a current representation."
264            // No ETag means no current representation → fail.
265            let passes = etag
266                .map(|etag| if_match.precondition_passes(etag))
267                .unwrap_or(false);
268            if !passes {
269                return Some(OpenFileOutput::PreconditionFailed);
270            }
271        } else {
272            // Step 2: If-Unmodified-Since (only when If-Match is absent)
273            // RFC 9110 §13.1.4: "MUST ignore if the resource does not have a
274            // modification date available."
275            if let Some(since) = self.if_unmodified_since {
276                let passes = last_modified
277                    .map(|lm| since.precondition_passes(lm))
278                    .unwrap_or(true);
279                if !passes {
280                    return Some(OpenFileOutput::PreconditionFailed);
281                }
282            }
283        }
284
285        // Step 3: If-None-Match
286        if let Some(if_none_match) = self.if_none_match {
287            // No ETag available → condition is vacuously true (passes), serve normally.
288            let passes = etag
289                .map(|etag| if_none_match.precondition_passes(etag))
290                .unwrap_or(true);
291            if !passes {
292                return Some(OpenFileOutput::NotModified {
293                    etag: etag.cloned(),
294                    last_modified: last_modified.map(|lm| LastModified(lm.0)),
295                });
296            }
297        } else {
298            // Step 4: If-Modified-Since (only when If-None-Match is absent)
299            // No Last-Modified → treat as modified (serve normally).
300            if let Some(since) = self.if_modified_since {
301                let unmodified = last_modified
302                    .map(|lm| !since.is_modified(lm))
303                    .unwrap_or(false);
304                if unmodified {
305                    return Some(OpenFileOutput::NotModified {
306                        etag: etag.cloned(),
307                        last_modified: last_modified.map(|lm| LastModified(lm.0)),
308                    });
309                }
310            }
311        }
312
313        None
314    }
315}
316
317// Returns the preferred_encoding encoding and modifies the path extension
318// to the corresponding file extension for the encoding.
319fn preferred_encoding(
320    path: &mut PathBuf,
321    negotiated_encoding: &[(Encoding, QValue)],
322) -> Option<Encoding> {
323    let preferred_encoding = Encoding::preferred_encoding(negotiated_encoding.iter().copied());
324
325    if let Some(file_extension) =
326        preferred_encoding.and_then(|encoding| encoding.to_file_extension())
327    {
328        let new_file_name = path
329            .file_name()
330            .map(|file_name| {
331                let mut os_string = file_name.to_os_string();
332                os_string.push(file_extension);
333                os_string
334            })
335            .unwrap_or_else(|| file_extension.to_os_string());
336
337        path.set_file_name(new_file_name);
338    }
339
340    preferred_encoding
341}
342
343// Attempts to open the file with any of the possible negotiated_encodings in the
344// preferred order. If none of the negotiated_encodings have a corresponding precompressed
345// file the uncompressed file is used as a fallback.
346async fn open_file_with_fallback<B: Backend>(
347    backend: &B,
348    mut path: PathBuf,
349    mut negotiated_encoding: Vec<(Encoding, QValue)>,
350) -> io::Result<(B::File, Option<Encoding>)> {
351    let (file, encoding) = loop {
352        // Get the preferred encoding among the negotiated ones.
353        let encoding = preferred_encoding(&mut path, &negotiated_encoding);
354        match (backend.open(path.clone()).await, encoding) {
355            (Ok(file), maybe_encoding) => break (file, maybe_encoding),
356            (Err(err), Some(encoding))
357                if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
358            {
359                // Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
360                // to reset the path before the next iteration.
361                path.set_extension(OsStr::new(""));
362                // Remove the encoding from the negotiated_encodings since the file doesn't exist
363                negotiated_encoding
364                    .retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
365            }
366            (Err(err), _) => return Err(err),
367        }
368    };
369    Ok((file, encoding))
370}
371
372// Attempts to get the file metadata with any of the possible negotiated_encodings in the
373// preferred order. If none of the negotiated_encodings have a corresponding precompressed
374// file the uncompressed file is used as a fallback.
375async fn file_metadata_with_fallback<B: Backend>(
376    backend: &B,
377    mut path: PathBuf,
378    mut negotiated_encoding: Vec<(Encoding, QValue)>,
379) -> io::Result<(B::Metadata, Option<Encoding>)> {
380    let (meta, encoding) = loop {
381        // Get the preferred encoding among the negotiated ones.
382        let encoding = preferred_encoding(&mut path, &negotiated_encoding);
383        match (backend.metadata(path.clone()).await, encoding) {
384            (Ok(meta), maybe_encoding) => break (meta, maybe_encoding),
385            (Err(err), Some(encoding))
386                if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
387            {
388                // Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
389                // to reset the path before the next iteration.
390                path.set_extension(OsStr::new(""));
391                // Remove the encoding from the negotiated_encodings since the file doesn't exist
392                negotiated_encoding
393                    .retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
394            }
395            (Err(err), _) => return Err(err),
396        }
397    };
398    Ok((meta, encoding))
399}
400
401async fn maybe_redirect_or_append_path<B: Backend>(
402    redirect_path_prefix: &str,
403    path_to_file: &mut PathBuf,
404    uri: &Uri,
405    append_index_html_on_directories: bool,
406    html_as_default_extension: bool,
407    backend: &B,
408) -> Option<OpenFileOutput> {
409    let uri_path = uri.path();
410
411    let is_directory = is_dir(path_to_file, backend).await;
412
413    if uri_path.ends_with('/') && uri_path != "/" && is_directory != Some(true) {
414        return Some(OpenFileOutput::FileNotFound);
415    }
416
417    // If the path has no extension and doesn't exist as a file, try appending .html
418    if html_as_default_extension && is_directory.is_none() && path_to_file.extension().is_none() {
419        path_to_file.set_extension("html");
420        return None;
421    }
422
423    if is_directory != Some(true) {
424        return None;
425    }
426
427    if !append_index_html_on_directories {
428        return Some(OpenFileOutput::FileNotFound);
429    }
430
431    if uri_path.ends_with('/') {
432        path_to_file.push("index.html");
433        None
434    } else {
435        let uri = match append_slash_on_path(uri.clone(), redirect_path_prefix) {
436            Ok(uri) => uri,
437            Err(err) => return Some(err),
438        };
439        let location = HeaderValue::from_str(&uri.to_string()).unwrap();
440        Some(OpenFileOutput::Redirect { location })
441    }
442}
443
444fn try_parse_range(
445    maybe_range_ref: Option<&str>,
446    file_size: u64,
447) -> Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>> {
448    maybe_range_ref.map(|header_value| {
449        http_range_header::parse_range_header(header_value)
450            .and_then(|first_pass| first_pass.validate(file_size))
451    })
452}
453
454async fn is_dir<B: Backend>(path_to_file: &Path, backend: &B) -> Option<bool> {
455    backend
456        .metadata(path_to_file.to_owned())
457        .await
458        .ok()
459        .map(|meta_data| meta_data.is_dir())
460}
461
462fn append_slash_on_path(uri: Uri, redirect_path_prefix: &str) -> Result<Uri, OpenFileOutput> {
463    let http::uri::Parts {
464        scheme,
465        authority,
466        path_and_query,
467        ..
468    } = uri.into_parts();
469
470    let mut uri_builder = Uri::builder();
471
472    if let Some(scheme) = scheme {
473        uri_builder = uri_builder.scheme(scheme);
474    }
475
476    if let Some(authority) = authority {
477        uri_builder = uri_builder.authority(authority);
478    }
479
480    let uri_builder = if let Some(path_and_query) = path_and_query {
481        if let Some(query) = path_and_query.query() {
482            uri_builder.path_and_query(format!(
483                "{redirect_path_prefix}{}/?{}",
484                path_and_query.path(),
485                query
486            ))
487        } else {
488            uri_builder.path_and_query(format!("{redirect_path_prefix}{}/", path_and_query.path()))
489        }
490    } else {
491        uri_builder.path_and_query(format!("{redirect_path_prefix}/"))
492    };
493
494    uri_builder.build().map_err(|_err| {
495        #[cfg(feature = "tracing")]
496        tracing::error!(err = ?_err, "redirect uri failed to build");
497
498        OpenFileOutput::InvalidRedirectUri
499    })
500}
501
502#[test]
503fn preferred_encoding_with_extension() {
504    let mut path = PathBuf::from("hello.txt");
505    preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
506    assert_eq!(path, PathBuf::from("hello.txt.gz"));
507}
508
509#[test]
510fn preferred_encoding_without_extension() {
511    let mut path = PathBuf::from("hello");
512    preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
513    assert_eq!(path, PathBuf::from("hello.gz"));
514}