Skip to main content

actix_files/
named.rs

1use std::{
2    borrow::Cow,
3    fs::Metadata,
4    io,
5    path::{Path, PathBuf},
6    time::{SystemTime, UNIX_EPOCH},
7};
8
9use actix_web::{
10    body::{self, BoxBody, SizedStream},
11    dev::{
12        self, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
13        ServiceResponse,
14    },
15    http::{
16        header::{
17            self, Charset, ContentDisposition, ContentEncoding, DispositionParam, DispositionType,
18            ExtendedValue,
19        },
20        Method, StatusCode,
21    },
22    Error, HttpMessage, HttpRequest, HttpResponse, Responder,
23};
24use bitflags::bitflags;
25use derive_more::{Deref, DerefMut};
26use futures_core::future::LocalBoxFuture;
27use mime::Mime;
28
29use crate::{encoding::equiv_utf8_text, range::HttpRange};
30
31bitflags! {
32    #[derive(Debug, Clone, Copy)]
33    pub(crate) struct Flags: u8 {
34        const ETAG =                0b0000_0001;
35        const LAST_MD =             0b0000_0010;
36        const CONTENT_DISPOSITION = 0b0000_0100;
37        const PREFER_UTF8 =         0b0000_1000;
38    }
39}
40
41impl Default for Flags {
42    fn default() -> Self {
43        Flags::from_bits_truncate(0b0000_1111)
44    }
45}
46
47/// A file with an associated name.
48///
49/// `NamedFile` can be registered as services:
50/// ```
51/// use actix_web::App;
52/// use actix_files::NamedFile;
53///
54/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
55/// let file = NamedFile::open("./static/index.html")?;
56/// let app = App::new().service(file);
57/// # Ok(())
58/// # }
59/// ```
60///
61/// They can also be returned from handlers:
62/// ```
63/// use actix_web::{Responder, get};
64/// use actix_files::NamedFile;
65///
66/// #[get("/")]
67/// async fn index() -> impl Responder {
68///     NamedFile::open("./static/index.html")
69/// }
70/// ```
71#[derive(Debug, Deref, DerefMut)]
72pub struct NamedFile {
73    #[deref]
74    #[deref_mut]
75    file: File,
76    path: PathBuf,
77    modified: Option<SystemTime>,
78    pub(crate) md: Metadata,
79    pub(crate) flags: Flags,
80    pub(crate) status_code: StatusCode,
81    pub(crate) content_type: Mime,
82    pub(crate) content_disposition: ContentDisposition,
83    pub(crate) encoding: Option<ContentEncoding>,
84    pub(crate) read_mode_threshold: u64,
85}
86
87pub(crate) use std::fs::File;
88
89use super::chunked;
90
91pub(crate) fn get_content_type_and_disposition(
92    path: &Path,
93) -> Result<(mime::Mime, ContentDisposition), io::Error> {
94    let filename = match path.file_name() {
95        Some(name) => name.to_string_lossy(),
96        None => {
97            return Err(io::Error::new(
98                io::ErrorKind::InvalidInput,
99                "Provided path has no filename",
100            ));
101        }
102    };
103
104    let ct = mime_guess::from_path(path).first_or_octet_stream();
105
106    let disposition = match ct.type_() {
107        mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline,
108        mime::APPLICATION => match ct.subtype() {
109            mime::JAVASCRIPT | mime::JSON => DispositionType::Inline,
110            name if name == "wasm" || name == "xhtml" => DispositionType::Inline,
111            _ => DispositionType::Attachment,
112        },
113        _ => DispositionType::Attachment,
114    };
115
116    // replace special characters in filenames which could occur on some filesystems
117    let mut escaped_len = filename.len();
118    for byte in filename.bytes() {
119        if matches!(byte, b'\n' | b'\x0B' | b'\x0C' | b'\r') {
120            escaped_len += 2;
121        }
122    }
123
124    let filename_s = if escaped_len == filename.len() {
125        filename.to_string()
126    } else {
127        let mut escaped = String::with_capacity(escaped_len);
128        for ch in filename.chars() {
129            match ch {
130                '\n' => escaped.push_str("%0A"),   // \n line break
131                '\x0B' => escaped.push_str("%0B"), // \v vertical tab
132                '\x0C' => escaped.push_str("%0C"), // \f form feed
133                '\r' => escaped.push_str("%0D"),   // \r carriage return
134                ch => escaped.push(ch),
135            }
136        }
137        escaped
138    };
139
140    let is_ascii = filename.is_ascii();
141
142    let mut parameters = Vec::with_capacity(if is_ascii { 1 } else { 2 });
143    parameters.push(DispositionParam::Filename(filename_s));
144
145    if !is_ascii {
146        parameters.push(DispositionParam::FilenameExt(ExtendedValue {
147            charset: Charset::Ext(String::from("UTF-8")),
148            language_tag: None,
149            value: filename.into_owned().into_bytes(),
150        }))
151    }
152
153    let cd = ContentDisposition {
154        disposition,
155        parameters,
156    };
157
158    Ok((ct, cd))
159}
160
161impl NamedFile {
162    /// Creates an instance from a previously opened file.
163    ///
164    /// The given `path` need not exist and is only used to determine the `ContentType` and
165    /// `ContentDisposition` headers.
166    ///
167    /// # Examples
168    /// ```ignore
169    /// use std::{
170    ///     io::{self, Write as _},
171    ///     env,
172    ///     fs::File
173    /// };
174    /// use actix_files::NamedFile;
175    ///
176    /// let mut file = File::create("foo.txt")?;
177    /// file.write_all(b"Hello, world!")?;
178    /// let named_file = NamedFile::from_file(file, "bar.txt")?;
179    /// # std::fs::remove_file("foo.txt");
180    /// Ok(())
181    /// ```
182    pub fn from_file<P: AsRef<Path>>(file: File, path: P) -> io::Result<NamedFile> {
183        let path = path.as_ref().to_path_buf();
184
185        // Get the name of the file and use it to construct default Content-Type
186        // and Content-Disposition values
187        let (content_type, content_disposition) = get_content_type_and_disposition(&path)?;
188
189        let md = file.metadata()?;
190
191        let modified = md.modified().ok();
192        let encoding = None;
193
194        Ok(NamedFile {
195            path,
196            file,
197            content_type,
198            content_disposition,
199            md,
200            modified,
201            encoding,
202            status_code: StatusCode::OK,
203            flags: Flags::default(),
204            read_mode_threshold: 0,
205        })
206    }
207
208    /// Attempts to open a file in read-only mode.
209    ///
210    /// # Examples
211    /// ```
212    /// use actix_files::NamedFile;
213    /// let file = NamedFile::open("foo.txt");
214    /// ```
215    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
216        let file = File::open(&path)?;
217        Self::from_file(file, path)
218    }
219
220    /// Returns reference to the underlying file object.
221    #[inline]
222    pub fn file(&self) -> &File {
223        &self.file
224    }
225
226    /// Returns the filesystem path to this file.
227    ///
228    /// # Examples
229    /// ```
230    /// # use std::io;
231    /// use actix_files::NamedFile;
232    ///
233    /// # fn path() -> io::Result<()> {
234    /// let file = NamedFile::open("test.txt")?;
235    /// assert_eq!(file.path().as_os_str(), "foo.txt");
236    /// # Ok(())
237    /// # }
238    /// ```
239    #[inline]
240    pub fn path(&self) -> &Path {
241        self.path.as_path()
242    }
243
244    /// Returns the time the file was last modified.
245    ///
246    /// Returns `None` only on unsupported platforms; see [`std::fs::Metadata::modified()`].
247    /// Therefore, it is usually safe to unwrap this.
248    #[inline]
249    pub fn modified(&self) -> Option<SystemTime> {
250        self.modified
251    }
252
253    /// Returns the filesystem metadata associated with this file.
254    #[inline]
255    pub fn metadata(&self) -> &Metadata {
256        &self.md
257    }
258
259    /// Returns the `Content-Type` header that will be used when serving this file.
260    #[inline]
261    pub fn content_type(&self) -> &Mime {
262        &self.content_type
263    }
264
265    /// Returns the `Content-Disposition` that will be used when serving this file.
266    #[inline]
267    pub fn content_disposition(&self) -> &ContentDisposition {
268        &self.content_disposition
269    }
270
271    /// Returns the `Content-Encoding` that will be used when serving this file.
272    ///
273    /// A return value of `None` indicates that the content is not already using a compressed
274    /// representation and may be subject to compression downstream.
275    #[inline]
276    pub fn content_encoding(&self) -> Option<ContentEncoding> {
277        self.encoding
278    }
279
280    /// Set response status code.
281    #[deprecated(since = "0.7.0", note = "Prefer `Responder::customize()`.")]
282    pub fn set_status_code(mut self, status: StatusCode) -> Self {
283        self.status_code = status;
284        self
285    }
286
287    /// Sets the `Content-Type` header that will be used when serving this file. By default the
288    /// `Content-Type` is inferred from the filename extension.
289    #[inline]
290    pub fn set_content_type(mut self, mime_type: Mime) -> Self {
291        self.content_type = mime_type;
292        self
293    }
294
295    /// Set the Content-Disposition for serving this file. This allows changing the
296    /// `inline/attachment` disposition as well as the filename sent to the peer.
297    ///
298    /// By default the disposition is `inline` for `text/*`, `image/*`, `video/*` and
299    /// `application/{javascript, json, wasm}` mime types, and `attachment` otherwise, and the
300    /// filename is taken from the path provided in the `open` method after converting it to UTF-8
301    /// (using `to_string_lossy`).
302    #[inline]
303    pub fn set_content_disposition(mut self, cd: ContentDisposition) -> Self {
304        self.content_disposition = cd;
305        self.flags.insert(Flags::CONTENT_DISPOSITION);
306        self
307    }
308
309    /// Disables `Content-Disposition` header.
310    ///
311    /// By default, the `Content-Disposition` header is sent.
312    #[inline]
313    pub fn disable_content_disposition(mut self) -> Self {
314        self.flags.remove(Flags::CONTENT_DISPOSITION);
315        self
316    }
317
318    /// Sets content encoding for this file.
319    ///
320    /// This prevents the `Compress` middleware from modifying the file contents and signals to
321    /// browsers/clients how to decode it. For example, if serving a compressed HTML file (e.g.,
322    /// `index.html.gz`) then use `.set_content_encoding(ContentEncoding::Gzip)`.
323    #[inline]
324    pub fn set_content_encoding(mut self, enc: ContentEncoding) -> Self {
325        self.encoding = Some(enc);
326        self
327    }
328
329    /// Sets the size threshold that determines file read mode (sync/async).
330    ///
331    /// When a file is smaller than the threshold (bytes), the reader will use synchronous
332    /// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the
333    /// main thread.
334    ///
335    /// Tweaking this value according to your expected usage may lead to significant performance
336    /// gains (or losses in other handlers, if `size` is too high).
337    ///
338    /// Default is 0, meaning all files are read asynchronously.
339    pub fn read_mode_threshold(mut self, size: u64) -> Self {
340        self.read_mode_threshold = size;
341        self
342    }
343
344    /// Specifies whether to return `ETag` header in response.
345    ///
346    /// Default is true.
347    #[inline]
348    pub fn use_etag(mut self, value: bool) -> Self {
349        self.flags.set(Flags::ETAG, value);
350        self
351    }
352
353    /// Specifies whether to return `Last-Modified` header in response.
354    ///
355    /// Default is true.
356    #[inline]
357    pub fn use_last_modified(mut self, value: bool) -> Self {
358        self.flags.set(Flags::LAST_MD, value);
359        self
360    }
361
362    /// Specifies whether text responses should signal a UTF-8 encoding.
363    ///
364    /// Default is false (but will default to true in a future version).
365    #[inline]
366    pub fn prefer_utf8(mut self, value: bool) -> Self {
367        self.flags.set(Flags::PREFER_UTF8, value);
368        self
369    }
370
371    /// Creates a strong ETag from the file's metadata.
372    ///
373    /// Returns `None` if the platform does not provide the file's modification time. (See
374    /// [`File::modified()`] docs for more.)
375    ///
376    /// [`File::modified()`]: https://doc.rust-lang.org/std/fs/struct.Metadata.html#method.modified
377    pub(crate) fn etag(&self) -> Option<header::EntityTag> {
378        let mtime = self.modified?;
379
380        Some({
381            let ino = {
382                #[cfg(unix)]
383                {
384                    use std::os::unix::fs::MetadataExt as _;
385
386                    self.md.ino()
387                }
388
389                #[cfg(windows)]
390                {
391                    // MetadataExt::file_index is not yet stable.
392                    winapi_util::file::information(&self.file).map_or(0, |info| info.file_index())
393                }
394
395                #[cfg(not(any(unix, windows)))]
396                {
397                    0
398                }
399            };
400
401            // Don't panic for pre-epoch modification times. Encode the timestamp as seconds and
402            // sub-second nanoseconds relative to the UNIX epoch, allowing negative values.
403            let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) {
404                Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
405                Err(err) => {
406                    let dur = err.duration();
407
408                    // For timestamps before the epoch, represent the time as a negative seconds
409                    // offset with positive nanoseconds (like POSIX timespec).
410                    if dur.subsec_nanos() == 0 {
411                        (-(dur.as_secs() as i64), 0)
412                    } else {
413                        (
414                            -(dur.as_secs() as i64) - 1,
415                            1_000_000_000 - dur.subsec_nanos(),
416                        )
417                    }
418                }
419            };
420
421            header::EntityTag::new_strong(format!(
422                "{:x}:{:x}:{:x}:{:x}",
423                ino,
424                self.md.len(),
425                secs as u64,
426                nanos
427            ))
428        })
429    }
430
431    pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
432        let mtime = self.modified?;
433
434        // avoid panic in `httpdate` crate when formatting as an HTTP date
435        // see: https://github.com/actix/actix-web/issues/2748
436        //
437        // httpdate supports dates in range [1970, 9999); see:
438        // https://github.com/seanmonstar/httpdate/blob/v1.0.3/src/date.rs
439        let dur = mtime.duration_since(UNIX_EPOCH).ok()?;
440        if dur.as_secs() >= 253_402_300_800 {
441            return None;
442        }
443
444        Some(mtime.into())
445    }
446
447    /// Creates an `HttpResponse` with file as a streaming body.
448    pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> {
449        if self.status_code != StatusCode::OK {
450            let mut res = HttpResponse::build(self.status_code);
451
452            let ct = if self.flags.contains(Flags::PREFER_UTF8) {
453                equiv_utf8_text(self.content_type.clone())
454            } else {
455                self.content_type
456            };
457
458            res.insert_header((header::CONTENT_TYPE, ct.to_string()));
459
460            if self.flags.contains(Flags::CONTENT_DISPOSITION) {
461                res.insert_header((
462                    header::CONTENT_DISPOSITION,
463                    self.content_disposition.to_string(),
464                ));
465            }
466
467            if let Some(current_encoding) = self.encoding {
468                res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
469            }
470
471            let reader =
472                chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold);
473
474            return res.streaming(reader);
475        }
476
477        let etag = if self.flags.contains(Flags::ETAG) {
478            self.etag()
479        } else {
480            None
481        };
482
483        let last_modified = if self.flags.contains(Flags::LAST_MD) {
484            self.last_modified()
485        } else {
486            None
487        };
488
489        // check preconditions
490        let precondition_failed = if !any_match(etag.as_ref(), req) {
491            true
492        } else if req.headers().contains_key(header::IF_MATCH) {
493            false
494        } else if let (Some(ref m), Some(header::IfUnmodifiedSince(ref since))) =
495            (last_modified, req.get_header())
496        {
497            let t1: SystemTime = (*m).into();
498            let t2: SystemTime = (*since).into();
499
500            match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
501                (Ok(t1), Ok(t2)) => t1.as_secs() > t2.as_secs(),
502                _ => false,
503            }
504        } else {
505            false
506        };
507
508        let is_get_or_head = matches!(*req.method(), Method::GET | Method::HEAD);
509
510        // check last modified
511        let not_modified = if !none_match(etag.as_ref(), req) {
512            true
513        } else if !is_get_or_head || req.headers().contains_key(header::IF_NONE_MATCH) {
514            false
515        } else if let (Some(ref m), Some(header::IfModifiedSince(ref since))) =
516            (last_modified, req.get_header())
517        {
518            let t1: SystemTime = (*m).into();
519            let t2: SystemTime = (*since).into();
520
521            match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
522                (Ok(t1), Ok(t2)) => t1.as_secs() <= t2.as_secs(),
523                _ => false,
524            }
525        } else {
526            false
527        };
528
529        let mut res = HttpResponse::build(self.status_code);
530
531        let ct = if self.flags.contains(Flags::PREFER_UTF8) {
532            equiv_utf8_text(self.content_type.clone())
533        } else {
534            self.content_type
535        };
536
537        res.insert_header((header::CONTENT_TYPE, ct.to_string()));
538
539        if self.flags.contains(Flags::CONTENT_DISPOSITION) {
540            res.insert_header((
541                header::CONTENT_DISPOSITION,
542                self.content_disposition.to_string(),
543            ));
544        }
545
546        if let Some(current_encoding) = self.encoding {
547            res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
548        }
549
550        if let Some(lm) = last_modified {
551            res.insert_header((header::LAST_MODIFIED, lm.to_string()));
552        }
553
554        if let Some(etag) = &etag {
555            res.insert_header((header::ETAG, etag.to_string()));
556        }
557
558        res.insert_header((header::ACCEPT_RANGES, "bytes"));
559
560        if precondition_failed || (not_modified && !is_get_or_head) {
561            return res.status(StatusCode::PRECONDITION_FAILED).finish();
562        } else if not_modified {
563            return res
564                .status(StatusCode::NOT_MODIFIED)
565                .body(body::None::new())
566                .map_into_boxed_body();
567        }
568
569        let mut length = self.md.len();
570        let mut offset = 0;
571        let mut ranged_req = false;
572
573        // RFC 9110 Section 14.2 defines range handling only for GET.
574        if let Some(ranges) = req
575            .headers()
576            .get(header::RANGE)
577            .filter(|_| req.method() == Method::GET)
578            .filter(|_| if_range_matches(etag.as_ref(), last_modified, req))
579            // RFC 9110 section 14.2 requires unknown range units to be ignored.
580            .filter(|ranges| {
581                ranges
582                    .to_str()
583                    .ok()
584                    .and_then(|value| value.split_once('='))
585                    .is_none_or(|(unit, _)| unit.eq_ignore_ascii_case("bytes"))
586            })
587        {
588            if let Ok(ranges_header) = ranges.to_str() {
589                // Range units are case-insensitive, but http-range requires literal `bytes=`.
590                let ranges_header = match ranges_header.split_once('=') {
591                    Some((unit, set)) if unit.eq_ignore_ascii_case("bytes") && unit != "bytes" => {
592                        Cow::Owned(format!("bytes={set}"))
593                    }
594                    _ => Cow::Borrowed(ranges_header),
595                };
596
597                if let Some(range) = HttpRange::parse(&ranges_header, length)
598                    .ok()
599                    .and_then(|ranges| ranges.first().copied())
600                {
601                    ranged_req = true;
602                    length = range.length;
603                    offset = range.start;
604
605                    res.insert_header((
606                        header::CONTENT_RANGE,
607                        format!("bytes {}-{}/{}", offset, offset + length - 1, self.md.len()),
608                    ));
609                } else {
610                    res.insert_header((header::CONTENT_RANGE, format!("bytes */{}", length)));
611                    return res.status(StatusCode::RANGE_NOT_SATISFIABLE).finish();
612                };
613            } else {
614                return res.status(StatusCode::BAD_REQUEST).finish();
615            };
616        };
617
618        let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
619
620        if ranged_req {
621            res.status(StatusCode::PARTIAL_CONTENT);
622        }
623
624        res.body(SizedStream::new(length, reader))
625    }
626}
627
628/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
629fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
630    match req.get_header::<header::IfMatch>() {
631        None | Some(header::IfMatch::Any) => true,
632
633        Some(header::IfMatch::Items(ref items)) => {
634            if let Some(some_etag) = etag {
635                for item in items {
636                    if item.strong_eq(some_etag) {
637                        return true;
638                    }
639                }
640            }
641
642            false
643        }
644    }
645}
646
647/// Returns true if `req` has no `If-Range` header or one whose validator matches the file.
648fn if_range_matches(
649    etag: Option<&header::EntityTag>,
650    last_modified: Option<header::HttpDate>,
651    req: &HttpRequest,
652) -> bool {
653    if !req.headers().contains_key(header::IF_RANGE) {
654        return true;
655    }
656
657    match req.get_header::<header::IfRange>() {
658        Some(header::IfRange::EntityTag(ref tag)) => etag.is_some_and(|etag| etag.strong_eq(tag)),
659        Some(header::IfRange::Date(date)) => last_modified.is_some_and(|m| {
660            let t1: SystemTime = m.into();
661            let t2: SystemTime = date.into();
662
663            match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
664                (Ok(t1), Ok(t2)) => t1.as_secs() == t2.as_secs(),
665                _ => false,
666            }
667        }),
668        None => false,
669    }
670}
671
672/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
673fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
674    match req.get_header::<header::IfNoneMatch>() {
675        Some(header::IfNoneMatch::Any) => false,
676
677        Some(header::IfNoneMatch::Items(ref items)) => {
678            if let Some(some_etag) = etag {
679                for item in items {
680                    if item.weak_eq(some_etag) {
681                        return false;
682                    }
683                }
684            }
685
686            true
687        }
688
689        None => true,
690    }
691}
692
693impl Responder for NamedFile {
694    type Body = BoxBody;
695
696    fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
697        self.into_response(req)
698    }
699}
700
701impl ServiceFactory<ServiceRequest> for NamedFile {
702    type Response = ServiceResponse;
703    type Error = Error;
704    type Config = ();
705    type Service = NamedFileService;
706    type InitError = ();
707    type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
708
709    fn new_service(&self, _: ()) -> Self::Future {
710        let service = NamedFileService {
711            path: self.path.clone(),
712        };
713
714        Box::pin(async move { Ok(service) })
715    }
716}
717
718#[doc(hidden)]
719#[derive(Debug)]
720pub struct NamedFileService {
721    path: PathBuf,
722}
723
724impl Service<ServiceRequest> for NamedFileService {
725    type Response = ServiceResponse;
726    type Error = Error;
727    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
728
729    dev::always_ready!();
730
731    fn call(&self, req: ServiceRequest) -> Self::Future {
732        let (req, _) = req.into_parts();
733
734        let path = self.path.clone();
735        Box::pin(async move {
736            let file = NamedFile::open(path)?;
737            let res = file.into_response(&req);
738            Ok(ServiceResponse::new(req, res))
739        })
740    }
741}
742
743impl HttpServiceFactory for NamedFile {
744    fn register(self, config: &mut AppService) {
745        config.register_service(
746            ResourceDef::root_prefix(self.path.to_string_lossy().as_ref()),
747            None,
748            self,
749            None,
750        )
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    #[test]
759    fn audio_files_use_inline_content_disposition() {
760        let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
761        assert_eq!(cd.disposition, DispositionType::Inline);
762    }
763
764    #[test]
765    fn special_chars_are_escaped_in_content_disposition_filename() {
766        let (_ct, cd) =
767            get_content_type_and_disposition(Path::new("test\n\x0B\x0C\rnewline.text")).unwrap();
768
769        assert_eq!(
770            cd.to_string(),
771            "inline; filename=\"test%0A%0B%0C%0Dnewline.text\"",
772        );
773    }
774
775    #[cfg(any(unix, windows))]
776    #[actix_web::test]
777    async fn if_none_match_does_not_match_a_different_file_with_same_size_and_mtime() {
778        use std::fs;
779
780        use actix_web::{body::to_bytes, test::TestRequest};
781        use filetime::{set_file_mtime, FileTime};
782
783        let dir = tempfile::tempdir().unwrap();
784        let first_path = dir.path().join("first.txt");
785        let second_path = dir.path().join("second.txt");
786
787        fs::write(&first_path, b"first").unwrap();
788        fs::write(&second_path, b"other").unwrap();
789
790        let mtime = FileTime::from_unix_time(1_600_000_000, 0);
791
792        set_file_mtime(&first_path, mtime).unwrap();
793        set_file_mtime(&second_path, mtime).unwrap();
794
795        let first_md = fs::metadata(&first_path).unwrap();
796        let second_md = fs::metadata(&second_path).unwrap();
797
798        assert_eq!(first_md.len(), second_md.len());
799        assert_eq!(first_md.modified().unwrap(), second_md.modified().unwrap());
800
801        let req = TestRequest::default().to_http_request();
802        let first = NamedFile::from_file(File::open(&first_path).unwrap(), "file.txt").unwrap();
803        let res = first.into_response(&req);
804        let etag = res.headers().get(header::ETAG).unwrap().clone();
805
806        let req = TestRequest::default()
807            .insert_header((header::IF_NONE_MATCH, etag.clone()))
808            .to_http_request();
809        let second = NamedFile::from_file(File::open(&second_path).unwrap(), "file.txt").unwrap();
810        let res = second.into_response(&req);
811
812        assert_eq!(res.status(), StatusCode::OK);
813        assert_ne!(res.headers().get(header::ETAG).unwrap(), &etag);
814        assert_eq!(
815            to_bytes(res.into_body()).await.unwrap(),
816            b"other".as_slice()
817        );
818    }
819}