tower_http/services/fs/serve_dir/
open_file.rs

1use super::{
2    headers::{IfModifiedSince, IfUnmodifiedSince, LastModified},
3    ServeVariant,
4};
5use crate::content_encoding::{Encoding, QValue};
6use bytes::Bytes;
7use http::{header, HeaderValue, Method, Request, Uri};
8use http_body_util::Empty;
9use http_range_header::RangeUnsatisfiableError;
10use std::{
11    ffi::OsStr,
12    fs::Metadata,
13    io::{self, SeekFrom},
14    ops::RangeInclusive,
15    path::{Path, PathBuf},
16};
17use tokio::{fs::File, io::AsyncSeekExt};
18
19pub(super) enum OpenFileOutput {
20    FileOpened(Box<FileOpened>),
21    Redirect { location: HeaderValue },
22    FileNotFound,
23    PreconditionFailed,
24    NotModified,
25    InvalidRedirectUri,
26}
27
28pub(super) struct FileOpened {
29    pub(super) extent: FileRequestExtent,
30    pub(super) chunk_size: usize,
31    pub(super) mime_header_value: HeaderValue,
32    pub(super) maybe_encoding: Option<Encoding>,
33    pub(super) maybe_range: Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>>,
34    pub(super) last_modified: Option<LastModified>,
35}
36
37pub(super) enum FileRequestExtent {
38    Full(File, Metadata),
39    Head(Metadata),
40}
41
42pub(super) async fn open_file(
43    variant: ServeVariant,
44    mut path_to_file: PathBuf,
45    req: Request<Empty<Bytes>>,
46    negotiated_encodings: Vec<(Encoding, QValue)>,
47    range_header: Option<String>,
48    buf_chunk_size: usize,
49) -> io::Result<OpenFileOutput> {
50    let if_unmodified_since = req
51        .headers()
52        .get(header::IF_UNMODIFIED_SINCE)
53        .and_then(IfUnmodifiedSince::from_header_value);
54
55    let if_modified_since = req
56        .headers()
57        .get(header::IF_MODIFIED_SINCE)
58        .and_then(IfModifiedSince::from_header_value);
59
60    let mime = match variant {
61        ServeVariant::Directory {
62            append_index_html_on_directories,
63        } => {
64            // Might already at this point know a redirect or not found result should be
65            // returned which corresponds to a Some(output). Otherwise the path might be
66            // modified and proceed to the open file/metadata future.
67            if let Some(output) = maybe_redirect_or_append_path(
68                &mut path_to_file,
69                req.uri(),
70                append_index_html_on_directories,
71            )
72            .await
73            {
74                return Ok(output);
75            }
76
77            mime_guess::from_path(&path_to_file)
78                .first_raw()
79                .map(HeaderValue::from_static)
80                .unwrap_or_else(|| {
81                    HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
82                })
83        }
84
85        ServeVariant::SingleFile { mime } => mime,
86    };
87
88    if req.method() == Method::HEAD {
89        let (meta, maybe_encoding) =
90            file_metadata_with_fallback(path_to_file, negotiated_encodings).await?;
91
92        let last_modified = meta.modified().ok().map(LastModified::from);
93        if let Some(output) = check_modified_headers(
94            last_modified.as_ref(),
95            if_unmodified_since,
96            if_modified_since,
97        ) {
98            return Ok(output);
99        }
100
101        let maybe_range = try_parse_range(range_header.as_deref(), meta.len());
102
103        Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
104            extent: FileRequestExtent::Head(meta),
105            chunk_size: buf_chunk_size,
106            mime_header_value: mime,
107            maybe_encoding,
108            maybe_range,
109            last_modified,
110        })))
111    } else {
112        let (mut file, maybe_encoding) =
113            open_file_with_fallback(path_to_file, negotiated_encodings).await?;
114        let meta = file.metadata().await?;
115        let last_modified = meta.modified().ok().map(LastModified::from);
116        if let Some(output) = check_modified_headers(
117            last_modified.as_ref(),
118            if_unmodified_since,
119            if_modified_since,
120        ) {
121            return Ok(output);
122        }
123
124        let maybe_range = try_parse_range(range_header.as_deref(), meta.len());
125        if let Some(Ok(ranges)) = maybe_range.as_ref() {
126            // if there is any other amount of ranges than 1 we'll return an
127            // unsatisfiable later as there isn't yet support for multipart ranges
128            if ranges.len() == 1 {
129                file.seek(SeekFrom::Start(*ranges[0].start())).await?;
130            }
131        }
132
133        Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
134            extent: FileRequestExtent::Full(file, meta),
135            chunk_size: buf_chunk_size,
136            mime_header_value: mime,
137            maybe_encoding,
138            maybe_range,
139            last_modified,
140        })))
141    }
142}
143
144fn check_modified_headers(
145    modified: Option<&LastModified>,
146    if_unmodified_since: Option<IfUnmodifiedSince>,
147    if_modified_since: Option<IfModifiedSince>,
148) -> Option<OpenFileOutput> {
149    if let Some(since) = if_unmodified_since {
150        let precondition = modified
151            .as_ref()
152            .map(|time| since.precondition_passes(time))
153            .unwrap_or(false);
154
155        if !precondition {
156            return Some(OpenFileOutput::PreconditionFailed);
157        }
158    }
159
160    if let Some(since) = if_modified_since {
161        let unmodified = modified
162            .as_ref()
163            .map(|time| !since.is_modified(time))
164            // no last_modified means its always modified
165            .unwrap_or(false);
166        if unmodified {
167            return Some(OpenFileOutput::NotModified);
168        }
169    }
170
171    None
172}
173
174// Returns the preferred_encoding encoding and modifies the path extension
175// to the corresponding file extension for the encoding.
176fn preferred_encoding(
177    path: &mut PathBuf,
178    negotiated_encoding: &[(Encoding, QValue)],
179) -> Option<Encoding> {
180    let preferred_encoding = Encoding::preferred_encoding(negotiated_encoding.iter().copied());
181
182    if let Some(file_extension) =
183        preferred_encoding.and_then(|encoding| encoding.to_file_extension())
184    {
185        let new_file_name = path
186            .file_name()
187            .map(|file_name| {
188                let mut os_string = file_name.to_os_string();
189                os_string.push(file_extension);
190                os_string
191            })
192            .unwrap_or_else(|| file_extension.to_os_string());
193
194        path.set_file_name(new_file_name);
195    }
196
197    preferred_encoding
198}
199
200// Attempts to open the file with any of the possible negotiated_encodings in the
201// preferred order. If none of the negotiated_encodings have a corresponding precompressed
202// file the uncompressed file is used as a fallback.
203async fn open_file_with_fallback(
204    mut path: PathBuf,
205    mut negotiated_encoding: Vec<(Encoding, QValue)>,
206) -> io::Result<(File, Option<Encoding>)> {
207    let (file, encoding) = loop {
208        // Get the preferred encoding among the negotiated ones.
209        let encoding = preferred_encoding(&mut path, &negotiated_encoding);
210        match (File::open(&path).await, encoding) {
211            (Ok(file), maybe_encoding) => break (file, maybe_encoding),
212            (Err(err), Some(encoding)) if err.kind() == io::ErrorKind::NotFound => {
213                // Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
214                // to reset the path before the next iteration.
215                path.set_extension(OsStr::new(""));
216                // Remove the encoding from the negotiated_encodings since the file doesn't exist
217                negotiated_encoding
218                    .retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
219            }
220            (Err(err), _) => return Err(err),
221        }
222    };
223    Ok((file, encoding))
224}
225
226// Attempts to get the file metadata with any of the possible negotiated_encodings in the
227// preferred order. If none of the negotiated_encodings have a corresponding precompressed
228// file the uncompressed file is used as a fallback.
229async fn file_metadata_with_fallback(
230    mut path: PathBuf,
231    mut negotiated_encoding: Vec<(Encoding, QValue)>,
232) -> io::Result<(Metadata, Option<Encoding>)> {
233    let (file, encoding) = loop {
234        // Get the preferred encoding among the negotiated ones.
235        let encoding = preferred_encoding(&mut path, &negotiated_encoding);
236        match (tokio::fs::metadata(&path).await, encoding) {
237            (Ok(file), maybe_encoding) => break (file, maybe_encoding),
238            (Err(err), Some(encoding)) if err.kind() == io::ErrorKind::NotFound => {
239                // Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
240                // to reset the path before the next iteration.
241                path.set_extension(OsStr::new(""));
242                // Remove the encoding from the negotiated_encodings since the file doesn't exist
243                negotiated_encoding
244                    .retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
245            }
246            (Err(err), _) => return Err(err),
247        }
248    };
249    Ok((file, encoding))
250}
251
252async fn maybe_redirect_or_append_path(
253    path_to_file: &mut PathBuf,
254    uri: &Uri,
255    append_index_html_on_directories: bool,
256) -> Option<OpenFileOutput> {
257    if !is_dir(path_to_file).await {
258        return None;
259    }
260
261    if !append_index_html_on_directories {
262        return Some(OpenFileOutput::FileNotFound);
263    }
264
265    if uri.path().ends_with('/') {
266        path_to_file.push("index.html");
267        None
268    } else {
269        let uri = match append_slash_on_path(uri.clone()) {
270            Ok(uri) => uri,
271            Err(err) => return Some(err),
272        };
273        let location = HeaderValue::from_str(&uri.to_string()).unwrap();
274        Some(OpenFileOutput::Redirect { location })
275    }
276}
277
278fn try_parse_range(
279    maybe_range_ref: Option<&str>,
280    file_size: u64,
281) -> Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>> {
282    maybe_range_ref.map(|header_value| {
283        http_range_header::parse_range_header(header_value)
284            .and_then(|first_pass| first_pass.validate(file_size))
285    })
286}
287
288async fn is_dir(path_to_file: &Path) -> bool {
289    tokio::fs::metadata(path_to_file)
290        .await
291        .map_or(false, |meta_data| meta_data.is_dir())
292}
293
294fn append_slash_on_path(uri: Uri) -> Result<Uri, OpenFileOutput> {
295    let http::uri::Parts {
296        scheme,
297        authority,
298        path_and_query,
299        ..
300    } = uri.into_parts();
301
302    let mut uri_builder = Uri::builder();
303
304    if let Some(scheme) = scheme {
305        uri_builder = uri_builder.scheme(scheme);
306    }
307
308    if let Some(authority) = authority {
309        uri_builder = uri_builder.authority(authority);
310    }
311
312    let uri_builder = if let Some(path_and_query) = path_and_query {
313        if let Some(query) = path_and_query.query() {
314            uri_builder.path_and_query(format!("{}/?{}", path_and_query.path(), query))
315        } else {
316            uri_builder.path_and_query(format!("{}/", path_and_query.path()))
317        }
318    } else {
319        uri_builder.path_and_query("/")
320    };
321
322    uri_builder.build().map_err(|err| {
323        tracing::error!(?err, "redirect uri failed to build");
324        OpenFileOutput::InvalidRedirectUri
325    })
326}
327
328#[test]
329fn preferred_encoding_with_extension() {
330    let mut path = PathBuf::from("hello.txt");
331    preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
332    assert_eq!(path, PathBuf::from("hello.txt.gz"));
333}
334
335#[test]
336fn preferred_encoding_without_extension() {
337    let mut path = PathBuf::from("hello");
338    preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
339    assert_eq!(path, PathBuf::from("hello.gz"));
340}