From 54c06d86a1671c062b70c6305b0f64f74c71e29d Mon Sep 17 00:00:00 2001 From: Dan Dumont Date: Wed, 27 Mar 2024 17:36:33 -0400 Subject: [PATCH] serve pre-compressed files --- core/http/src/header/accept_encoding.rs | 369 ++++++++++++++++++ core/http/src/header/content_coding.rs | 309 +++++++++++++++ core/http/src/header/content_encoding.rs | 187 +++++++++ core/http/src/header/known_content_codings.rs | 10 + core/http/src/header/media_type.rs | 2 +- core/http/src/header/mod.rs | 8 + core/http/src/parse/accept_encoding.rs | 75 ++++ core/http/src/parse/content_coding.rs | 119 ++++++ core/http/src/parse/mod.rs | 4 + core/lib/src/fs/maybe_compressed_file.rs | 114 ++++++ core/lib/src/fs/mod.rs | 4 + core/lib/src/fs/server.rs | 65 ++- core/lib/src/fs/server_file.rs | 60 +++ core/lib/tests/file_server.rs | 44 ++- core/lib/tests/static/other/hello.txt.gz | Bin 0 -> 34 bytes 15 files changed, 1342 insertions(+), 28 deletions(-) create mode 100644 core/http/src/header/accept_encoding.rs create mode 100644 core/http/src/header/content_coding.rs create mode 100644 core/http/src/header/content_encoding.rs create mode 100644 core/http/src/header/known_content_codings.rs create mode 100644 core/http/src/parse/accept_encoding.rs create mode 100644 core/http/src/parse/content_coding.rs create mode 100644 core/lib/src/fs/maybe_compressed_file.rs create mode 100644 core/lib/src/fs/server_file.rs create mode 100644 core/lib/tests/static/other/hello.txt.gz diff --git a/core/http/src/header/accept_encoding.rs b/core/http/src/header/accept_encoding.rs new file mode 100644 index 0000000000..ef3eb9c32e --- /dev/null +++ b/core/http/src/header/accept_encoding.rs @@ -0,0 +1,369 @@ +use std::borrow::Cow; +use std::ops::Deref; +use std::str::FromStr; +use std::fmt; + +use crate::{ContentCoding, Header}; +use crate::parse::parse_accept_encoding; + +/// The HTTP Accept-Encoding header. +/// +/// An `AcceptEncoding` header is composed of zero or more content codings, each of which +/// may have an optional weight value (a [`QContentCoding`]). The header is sent by +/// an HTTP client to describe the formats it accepts as well as the order in +/// which it prefers different formats. +/// +/// # Usage +/// +/// The Accept-Encoding header of an incoming request can be retrieved via the +/// [`Request::accept_encoding()`] method. The [`preferred()`] method can be used to +/// retrieve the client's preferred content coding. +/// +/// [`Request::accept_encoding()`]: rocket::Request::accept_encoding() +/// [`preferred()`]: AcceptEncoding::preferred() +/// +/// An `AcceptEncoding` type with a single, common content coding can be easily constructed +/// via provided associated constants. +/// +/// ## Example +/// +/// Construct an `AcceptEncoding` header with a single `gzip` content coding: +/// +/// ```rust +/// # extern crate rocket; +/// use rocket::http::AcceptEncoding; +/// +/// # #[allow(unused_variables)] +/// let accept_gzip = AcceptEncoding::GZIP; +/// ``` +/// +/// # Header +/// +/// `AcceptEncoding` implements `Into
`. As such, it can be used in any context +/// where an `Into
` is expected: +/// +/// ```rust +/// # extern crate rocket; +/// use rocket::http::AcceptEncoding; +/// use rocket::response::Response; +/// +/// let response = Response::build().header(AcceptEncoding::GZIP).finalize(); +/// ``` +#[derive(Debug, Clone)] +pub struct AcceptEncoding(pub(crate) Cow<'static, [QContentCoding]>); + +/// A `ContentCoding` with an associated quality value. +#[derive(Debug, Clone, PartialEq)] +pub struct QContentCoding(pub ContentCoding, pub Option); + +macro_rules! accept_encoding_constructor { + ($($name:ident ($check:ident): $str:expr, $c:expr,)+) => { + $( + #[doc="An `AcceptEncoding` header with the single content coding for"] + #[doc=concat!("**", $str, "**: ", "_", $c, "_")] + #[allow(non_upper_case_globals)] + pub const $name: AcceptEncoding = AcceptEncoding({ + const INNER: &[QContentCoding] = &[QContentCoding(ContentCoding::$name, None)]; + Cow::Borrowed(INNER) + }); + )+ + }; +} + +impl AcceptEncoding { + /// Constructs a new `AcceptEncoding` header from one or more media types. + /// + /// The `items` parameter may be of type `QContentCoding`, `[QContentCoding]`, + /// `&[QContentCoding]` or `Vec`. To prevent additional allocations, + /// prefer to provide inputs of type `QContentCoding`, `[QContentCoding]`, or + /// `Vec`. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{QContentCoding, ContentCoding, AcceptEncoding}; + /// + /// // Construct an `Accept` via a `Vec`. + /// let gzip_then_deflate = vec![ContentCoding::GZIP, ContentCoding::DEFLATE]; + /// let accept = AcceptEncoding::new(gzip_then_deflate); + /// assert_eq!(accept.preferred().media_type(), &ContentCoding::GZIP); + /// + /// // Construct an `Accept` via an `[QMediaType]`. + /// let accept = Accept::new([MediaType::JSON.into(), MediaType::HTML.into()]); + /// assert_eq!(accept.preferred().media_type(), &MediaType::JSON); + /// + /// // Construct an `Accept` via a `QMediaType`. + /// let accept = Accept::new(QMediaType(MediaType::JSON, None)); + /// assert_eq!(accept.preferred().media_type(), &MediaType::JSON); + /// ``` + #[inline(always)] + pub fn new, M: Into>(items: T) -> AcceptEncoding { + AcceptEncoding(items.into_iter().map(|v| v.into()).collect()) + } + + // TODO: Implement this. + // #[inline(always)] + // pub fn add>(&mut self, content_coding: M) { + // self.0.push(content_coding.into()); + // } + + /// Retrieve the client's preferred content coding. This method follows [RFC + /// 7231 5.3.4]. If the list of content codings is empty, this method returns a + /// content coding of any with no quality value: (`*`). + /// + /// [RFC 7231 5.3.4]: https://tools.ietf.org/html/rfc7231#section-5.3.4 + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{QContentCoding, ContentCoding, AcceptEncoding}; + /// + /// let qcontent_codings = vec![ + /// QContentCoding(MediaType::DEFLATE, Some(0.3)), + /// QContentCoding(MediaType::GZIP, Some(0.9)), + /// ]; + /// + /// let accept = AcceptEncoding::new(qcontent_codings); + /// assert_eq!(accept.preferred().content_coding(), &MediaType::GZIP); + /// ``` + pub fn preferred(&self) -> &QContentCoding { + static ANY: QContentCoding = QContentCoding(ContentCoding::Any, None); + + // See https://tools.ietf.org/html/rfc7231#section-5.3.4. + let mut all = self.iter(); + let mut preferred = all.next().unwrap_or(&ANY); + for content_coding in all { + if content_coding.weight().is_none() && preferred.weight().is_some() { + // Content coding without a `q` parameter are preferred. + preferred = content_coding; + } else if content_coding.weight_or(0.0) > preferred.weight_or(1.0) { + // Prefer content coding with a greater weight, but if one doesn't + // have a weight, prefer the one we already have. + preferred = content_coding; + } + } + + preferred + } + + /// Retrieve the first media type in `self`, if any. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{QContentCoding, ContentCoding, AcceptEncoding}; + /// + /// let accept_encoding = AcceptEncoding::new(QContentCoding(ContentCoding::GZIP, None)); + /// assert_eq!(accept_encoding.first(), Some(&ContentCoding::GZIP.into())); + /// ``` + #[inline(always)] + pub fn first(&self) -> Option<&QContentCoding> { + self.iter().next() + } + + /// Returns an iterator over all of the (quality) media types in `self`. + /// Media types are returned in the order in which they appear in the + /// header. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{QContentCoding, ContentCoding, AcceptEncoding}; + /// + /// let qcontent_codings = vec![ + /// QContentCoding(MediaType::DEFLATE, Some(0.3)) + /// QContentCoding(MediaType::GZIP, Some(0.9)), + /// ]; + /// + /// let accept_encoding = AcceptEncoding::new(qcontent_codings.clone()); + /// + /// let mut iter = accept.iter(); + /// assert_eq!(iter.next(), Some(&qcontent_codings[0])); + /// assert_eq!(iter.next(), Some(&qcontent_codings[1])); + /// assert_eq!(iter.next(), None); + /// ``` + #[inline(always)] + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter() + } + + /// Returns an iterator over all of the (bare) media types in `self`. Media + /// types are returned in the order in which they appear in the header. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{QMediaType, MediaType, Accept}; + /// + /// let qmedia_types = vec![ + /// QMediaType(MediaType::JSON, Some(0.3)), + /// QMediaType(MediaType::HTML, Some(0.9)) + /// ]; + /// + /// let accept = Accept::new(qmedia_types.clone()); + /// + /// let mut iter = accept.media_types(); + /// assert_eq!(iter.next(), Some(qmedia_types[0].media_type())); + /// assert_eq!(iter.next(), Some(qmedia_types[1].media_type())); + /// assert_eq!(iter.next(), None); + /// ``` + #[inline(always)] + pub fn content_codings(&self) -> impl Iterator + '_ { + self.iter().map(|weighted_cc| weighted_cc.content_coding()) + } + + known_content_codings!(accept_encoding_constructor); +} + +impl> From for AcceptEncoding { + #[inline(always)] + fn from(items: T) -> AcceptEncoding { + AcceptEncoding::new(items.into_iter().map(QContentCoding::from)) + } +} + +impl PartialEq for AcceptEncoding { + fn eq(&self, other: &AcceptEncoding) -> bool { + self.iter().eq(other.iter()) + } +} + +impl fmt::Display for AcceptEncoding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, content_coding) in self.iter().enumerate() { + if i >= 1 { + write!(f, ", {}", content_coding.0)?; + } else { + write!(f, "{}", content_coding.0)?; + } + } + + Ok(()) + } +} + +impl FromStr for AcceptEncoding { + // Ideally we'd return a `ParseError`, but that requires a lifetime. + type Err = String; + + #[inline] + fn from_str(raw: &str) -> Result { + parse_accept_encoding(raw).map_err(|e| e.to_string()) + } +} + +/// Creates a new `Header` with name `Accept-Encoding` and the value set to the HTTP +/// rendering of this `Accept` header. +impl From for Header<'static> { + #[inline(always)] + fn from(val: AcceptEncoding) -> Self { + Header::new("Accept-Encoding", val.to_string()) + } +} + +impl QContentCoding { + /// Retrieve the weight of the media type, if there is any. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{ContentCoding, QContentCoding}; + /// + /// let q_coding = QContentCoding(ContentCoding::GZIP, Some(0.3)); + /// assert_eq!(q_coding.weight(), Some(0.3)); + /// ``` + #[inline(always)] + pub fn weight(&self) -> Option { + self.1 + } + + /// Retrieve the weight of the media type or a given default value. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{ContentCoding, QContentCoding}; + /// + /// let q_coding = QContentCoding(ContentCoding::GZIP, Some(0.3)); + /// assert_eq!(q_coding.weight_or(0.9), 0.3); + /// + /// let q_coding = QContentCoding(ContentCoding::GZIP, None); + /// assert_eq!(q_coding.weight_or(0.9), 0.9); + /// ``` + #[inline(always)] + pub fn weight_or(&self, default: f32) -> f32 { + self.1.unwrap_or(default) + } + + /// Borrow the internal `MediaType`. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{ContentCoding, QContentCoding}; + /// + /// let q_coding = QContentCoding(ContentCoding::GZIP, Some(0.3)); + /// assert_eq!(q_coding.content_coding(), &ContentCoding::GZIP); + /// ``` + #[inline(always)] + pub fn content_coding(&self) -> &ContentCoding { + &self.0 + } +} + +impl From for QContentCoding { + #[inline(always)] + fn from(content_coding: ContentCoding) -> QContentCoding { + QContentCoding(content_coding, None) + } +} + +impl Deref for QContentCoding { + type Target = ContentCoding; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod test { + use crate::{AcceptEncoding, ContentCoding}; + + #[track_caller] + fn assert_preference(string: &str, expect: &str) { + let ae: AcceptEncoding = string.parse().expect("accept_encoding string parse"); + let expected: ContentCoding = expect.parse().expect("content coding parse"); + let preferred = ae.preferred(); + let actual = preferred.content_coding(); + if *actual != expected { + panic!("mismatch for {}: expected {}, got {}", string, expected, actual) + } + } + + #[test] + fn test_preferred() { + assert_preference("deflate", "deflate"); + assert_preference("gzip, deflate", "gzip"); + assert_preference("deflate; q=0.1, gzip", "gzip"); + assert_preference("gzip; q=1, gzip", "gzip"); + + assert_preference("gzip, deflate; q=1", "gzip"); + assert_preference("deflate; q=1, gzip", "gzip"); + + assert_preference("gzip; q=0.1, gzip; q=0.2", "gzip; q=0.2"); + assert_preference("rar; q=0.1, compress; q=0.2", "compress; q=0.2"); + assert_preference("rar; q=0.5, compress; q=0.2", "rar; q=0.5"); + + assert_preference("rar; q=0.5, compress; q=0.2, nonsense", "nonsense"); + } +} diff --git a/core/http/src/header/content_coding.rs b/core/http/src/header/content_coding.rs new file mode 100644 index 0000000000..135da33385 --- /dev/null +++ b/core/http/src/header/content_coding.rs @@ -0,0 +1,309 @@ +use core::f32; +use std::borrow::Cow; +use std::str::FromStr; +use std::fmt; +use std::hash::{Hash, Hasher}; + +use crate::uncased::UncasedStr; +use crate::parse::{Indexed, IndexedStr, parse_content_coding}; +use crate::Source; + +/// An HTTP content coding. +/// +/// # Usage +/// +/// A `ContentCoding` should rarely be used directly. Instead, one is typically used +/// indirectly via types like [`Accept-Encoding`](crate::Accept-Encoding) and +/// [`ContentEncoding`](crate::ContentEncoding), which internally contain `ContentCoding`s. +/// Nonetheless, a `ContentCoding` can be created via the [`ContentCoding::new()`] +/// and [`ContentCoding::with_weight()`]. +/// The preferred method, however, is to create a `ContentCoding` via an associated +/// constant. +/// +/// ## Example +/// +/// A content coding of `gzip` can be instantiated via the +/// [`ContentCoding::GZIP`] constant: +/// +/// ```rust +/// # extern crate rocket; +/// use rocket::http::ContentCoding; +/// +/// let gzip = ContentCoding::GZIP; +/// assert_eq!(gzip.coding(), "gzip"); +/// +/// let gzip = ContentCoding::new("gzip"); +/// assert_eq!(ContentCoding::GZIP, gzip); +/// ``` +/// +/// # Comparison and Hashing +/// +/// The `PartialEq` and `Hash` implementations for `ContentCoding` _do not_ take +/// into account parameters. This means that a content coding of `gzip` is +/// equal to a content coding of `gzip; q=1`, for instance. This is +/// typically the comparison that is desired. +/// +/// If an exact comparison is desired that takes into account parameters, the +/// [`exact_eq()`](ContentCoding::exact_eq()) method can be used. +#[derive(Debug, Clone)] +pub struct ContentCoding { + /// InitCell for the entire content codding string. + pub(crate) source: Source, + /// The top-level type. + pub(crate) coding: IndexedStr<'static>, + /// The parameters, if any. + pub(crate) weight: Option, +} + +macro_rules! content_codings { + // ($($name:ident ($check:ident): $str:expr, $t:expr, $s:expr $(; $k:expr => $v:expr)*,)+) + ($($name:ident ($check:ident): $str:expr, $c:expr,)+) => { + $( + /// Content Coding for + #[doc = concat!("**", $str, "**: ")] + #[doc = concat!("`", $c, "`")] + #[allow(non_upper_case_globals)] + pub const $name: ContentCoding = ContentCoding::new_known( + $c, + $c, + None, + ); + )+ + + /// Returns `true` if this ContentCoding is known to Rocket. In other words, + /// returns `true` if there is an associated constant for `self`. + pub fn is_known(&self) -> bool { + if let Source::Known(_) = self.source { + return true; + } + + $(if self.$check() { return true })+ + false + } + + $( + /// Returns `true` if the top-level and sublevel types of + /// `self` are the same as those of + #[doc = concat!("`ContentCoding::", stringify!($name), "`, ")] + /// i.e + #[doc = concat!("`", $c, "`.")] + #[inline(always)] + pub fn $check(&self) -> bool { + *self == ContentCoding::$name + } + )+ + } +} + +impl ContentCoding { + /// Creates a new `ContentCoding` for `coding`. + /// This should _only_ be used to construct uncommon or custom content codings. + /// Use an associated constant for everything else. + /// + /// # Example + /// + /// Create a custom `rar` content coding: + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentCoding; + /// + /// let custom = ContentCoding::new("rar"); + /// assert_eq!(custom.coding(), "rar"); + /// ``` + #[inline] + pub fn new(coding: C) -> ContentCoding + where C: Into> + { + ContentCoding { + source: Source::None, + coding: Indexed::Concrete(coding.into()), + weight: None, + } + } + + /// Sets the weight `weight` on `self`. + /// + /// # Example + /// + /// Create a custom `rar; q=1` content coding: + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentCoding; + /// + /// let id = ContentCoding::new("rar").with_weight(1); + /// assert_eq!(id.to_string(), "rar; q=1".to_string()); + /// ``` + pub fn with_weight(mut self, p: f32) -> ContentCoding + { + self.weight = Some(p); + self + } + + /// A `const` variant of [`ContentCoding::with_params()`]. Creates a new + /// `ContentCoding` with coding `coding`, and weight + /// `weight`, which may be empty. + /// + /// # Example + /// + /// Create a custom `rar` content coding: + /// + /// ```rust + /// use rocket::http::ContentCoding; + /// + /// let custom = ContentCoding::const_new("rar", None); + /// assert_eq!(custom.coding(), "rar"); + /// assert_eq!(custom.weight(), None); + /// ``` + #[inline] + pub const fn const_new( + coding: &'static str, + weight: Option, + ) -> ContentCoding { + ContentCoding { + source: Source::None, + coding: Indexed::Concrete(Cow::Borrowed(coding)), + weight: weight, + } + } + + #[inline] + pub(crate) const fn new_known( + source: &'static str, + coding: &'static str, + weight: Option, + ) -> ContentCoding { + ContentCoding { + source: Source::Known(source), + coding: Indexed::Concrete(Cow::Borrowed(coding)), + weight: weight, + } + } + + pub(crate) fn known_source(&self) -> Option<&'static str> { + match self.source { + Source::Known(string) => Some(string), + Source::Custom(Cow::Borrowed(string)) => Some(string), + _ => None + } + } + + /// Returns the coding for this ContentCoding. The return type, + /// `UncasedStr`, has caseless equality comparison and hashing. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentCoding; + /// + /// let gzip = ContentCoding::GZIP; + /// assert_eq!(gzip.coding(), "gzip"); + /// assert_eq!(gzip.top(), "GZIP"); + /// assert_eq!(gzip.top(), "Gzip"); + /// ``` + #[inline] + pub fn coding(&self) -> &UncasedStr { + self.coding.from_source(self.source.as_str()).into() + } + + /// Compares `self` with `other` and returns `true` if `self` and `other` + /// are exactly equal to each other, including with respect to their + /// weight. + /// + /// This is different from the `PartialEq` implementation in that it + /// considers parameters. In particular, `Eq` implies `PartialEq` but + /// `PartialEq` does not imply `Eq`. That is, if `PartialEq` returns false, + /// this function is guaranteed to return false. Similarly, if `exact_eq` + /// returns `true`, `PartialEq` is guaranteed to return true. However, if + /// `PartialEq` returns `true`, `exact_eq` function may or may not return + /// `true`. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentCoding; + /// + /// let gzip = ContentCoding::GZIP; + /// let gzip2 = ContentCoding::new("gzip").with_weight(1); + /// let just_plain = ContentCoding::new("gzip"); + /// + /// // The `PartialEq` implementation doesn't consider parameters. + /// assert!(plain == just_plain); + /// assert!(just_plain == plain2); + /// assert!(plain == plain2); + /// + /// // While `exact_eq` does. + /// assert!(plain.exact_eq(&just_plain)); + /// assert!(!plain2.exact_eq(&just_plain)); + /// assert!(!plain.exact_eq(&plain2)); + /// ``` + pub fn exact_eq(&self, other: &ContentCoding) -> bool { + self == other && self.weight().eq(other.weight()) + } + + /// Returns the weight content coding. + /// + /// # Example + /// + /// The `ContentCoding::GZIP` type has no specified weight: + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentCoding; + /// + /// let gzip = ContentCoding::GZIP; + /// let weight = gzip.weight(); + /// assert_eq!(weight, None); + /// ``` + #[inline] + pub fn weight(&self) -> &Option { + &self.weight + } + + known_content_codings!(content_codings); +} + +impl FromStr for ContentCoding { + // Ideally we'd return a `ParseError`, but that requires a lifetime. + type Err = String; + + #[inline] + fn from_str(raw: &str) -> Result { + parse_content_coding(raw).map_err(|e| e.to_string()) + } +} + +impl PartialEq for ContentCoding { + #[inline(always)] + fn eq(&self, other: &ContentCoding) -> bool { + self.coding() == other.coding() + } +} + +impl Eq for ContentCoding { } + +impl Hash for ContentCoding { + #[inline] + fn hash(&self, state: &mut H) { + self.coding().hash(state); + } +} + +impl fmt::Display for ContentCoding { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(src) = self.known_source() { + src.fmt(f) + } else { + write!(f, "{}", self.coding())?; + if let Some(weight) = self.weight() { + write!(f, "; q={}", weight)?; + } + + Ok(()) + } + } +} diff --git a/core/http/src/header/content_encoding.rs b/core/http/src/header/content_encoding.rs new file mode 100644 index 0000000000..13521f4d47 --- /dev/null +++ b/core/http/src/header/content_encoding.rs @@ -0,0 +1,187 @@ +use std::borrow::Cow; +use std::ops::Deref; +use std::str::FromStr; +use std::fmt; + +use crate::header::Header; +use crate::ContentCoding; + +/// Representation of HTTP Content-Encoding. +/// +/// # Usage +/// +/// `ContentEncoding`s should rarely be created directly. Instead, an associated +/// constant should be used; one is declared for most commonly used content +/// types. +/// +/// ## Example +/// +/// A Content-Encoding of `gzip` can be instantiated via the +/// `GZIP` constant: +/// +/// ```rust +/// # extern crate rocket; +/// use rocket::http::ContentEncoding; +/// +/// # #[allow(unused_variables)] +/// let html = ContentEncoding::GZIP; +/// ``` +/// +/// # Header +/// +/// `ContentEncoding` implements `Into
`. As such, it can be used in any +/// context where an `Into
` is expected: +/// +/// ```rust +/// # extern crate rocket; +/// use rocket::http::ContentEncoding; +/// use rocket::response::Response; +/// +/// let response = Response::build().header(ContentEncoding::GZIP).finalize(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ContentEncoding(pub ContentCoding); + +macro_rules! content_encodings { + ($($name:ident ($check:ident): $str:expr, $c:expr,)+) => { + $( + + /// Content Encoding for + #[doc = concat!("**", $str, "**: ")] + #[doc = concat!("`", $c, "`")] + + #[allow(non_upper_case_globals)] + pub const $name: ContentEncoding = ContentEncoding(ContentCoding::$name); + )+ +}} + +impl ContentEncoding { + /// Creates a new `ContentEncoding` with `coding`. + /// This should _only_ be used to construct uncommon or custom content + /// types. Use an associated constant for everything else. + /// + /// # Example + /// + /// Create a custom `foo` content encoding: + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentEncoding; + /// + /// let custom = ContentEncoding::new("foo"); + /// assert_eq!(custom.content_coding(), "foo"); + /// ``` + #[inline(always)] + pub fn new(coding: S) -> ContentEncoding + where S: Into> + { + ContentEncoding(ContentCoding::new(coding)) + } + + /// Borrows the inner `ContentCoding` of `self`. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::{ContentEncoding, ContentCoding}; + /// + /// let http = ContentEncoding::GZIP; + /// let content_coding = http.content_coding(); + /// ``` + #[inline(always)] + pub fn content_coding(&self) -> &ContentCoding { + &self.0 + } + + known_content_codings!(content_encodings); +} + +impl Default for ContentEncoding { + /// Returns a ContentEncoding of `Any`, or `*`. + #[inline(always)] + fn default() -> ContentEncoding { + ContentEncoding::Any + } +} + +impl Deref for ContentEncoding { + type Target = ContentCoding; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromStr for ContentEncoding { + type Err = String; + + /// Parses a `ContentEncoding` from a given Content-Encoding header value. + /// + /// # Examples + /// + /// Parsing a `gzip`: + /// + /// ```rust + /// # extern crate rocket; + /// use std::str::FromStr; + /// use rocket::http::ContentEncoding; + /// + /// let gzip = ContentEncoding::from_str("gzip").unwrap(); + /// assert!(gzip.is_known()); + /// assert_eq!(gzip, ContentEncoding::GZIP); + /// ``` + /// + /// Parsing an invalid Content-Encoding value: + /// + /// ```rust + /// # extern crate rocket; + /// use std::str::FromStr; + /// use rocket::http::ContentEncoding; + /// + /// let custom = ContentEncoding::from_str("12ec/.322r"); + /// assert!(custom.is_err()); + /// ``` + #[inline(always)] + fn from_str(raw: &str) -> Result { + ContentCoding::from_str(raw).map(ContentEncoding) + } +} + +impl From for ContentEncoding { + fn from(content_coding: ContentCoding) -> Self { + ContentEncoding(content_coding) + } +} + +impl fmt::Display for ContentEncoding { + /// Formats the ContentEncoding as an HTTP Content-Encoding value. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// use rocket::http::ContentEncoding; + /// + /// let cc = format!("{}", ContentEncoding::GZIP); + /// assert_eq!(cc, "gzip"); + /// ``` + #[inline(always)] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Creates a new `Header` with name `Content-Encoding` and the value set to the +/// HTTP rendering of this Content-Encoding. +impl From for Header<'static> { + #[inline(always)] + fn from(content_encoding: ContentEncoding) -> Self { + if let Some(src) = content_encoding.known_source() { + Header::new("Content-Encoding", src) + } else { + Header::new("Content-Encoding", content_encoding.to_string()) + } + } +} diff --git a/core/http/src/header/known_content_codings.rs b/core/http/src/header/known_content_codings.rs new file mode 100644 index 0000000000..f8ee6b2f20 --- /dev/null +++ b/core/http/src/header/known_content_codings.rs @@ -0,0 +1,10 @@ +macro_rules! known_content_codings { + ($cont:ident) => ($cont! { + Any (is_any): "any content coding", "*", + // BR (is_br): "Brotli Compressed Data Format", "br", + // COMPRESS (is_compress): "UNIX \"compress\" data format", "compress", + // DEFLATE (is_deflate): "\"deflate\" compressed data inside the \"zlib\" data format", "deflate", + GZIP (is_gzip): "GZIP file format", "gzip", + IDENTITY (is_identity): "Reserved", "identity", + }) +} diff --git a/core/http/src/header/media_type.rs b/core/http/src/header/media_type.rs index 691ac24db7..b6a3ca5a15 100644 --- a/core/http/src/header/media_type.rs +++ b/core/http/src/header/media_type.rs @@ -621,7 +621,7 @@ impl Extend<(IndexedStr<'static>, IndexedStr<'static>)> for MediaParams { impl Source { #[inline] - fn as_str(&self) -> Option<&str> { + pub(crate) fn as_str(&self) -> Option<&str> { match *self { Source::Known(s) => Some(s), Source::Custom(ref s) => Some(s.borrow()), diff --git a/core/http/src/header/mod.rs b/core/http/src/header/mod.rs index 653b786348..3e37d5bed8 100644 --- a/core/http/src/header/mod.rs +++ b/core/http/src/header/mod.rs @@ -1,13 +1,21 @@ #[macro_use] mod known_media_types; +#[macro_use] +mod known_content_codings; mod media_type; +mod content_coding; mod content_type; mod accept; +mod accept_encoding; +mod content_encoding; mod header; mod proxy_proto; pub use self::content_type::ContentType; +pub use self::content_encoding::ContentEncoding; pub use self::accept::{Accept, QMediaType}; +pub use self::accept_encoding::{AcceptEncoding, QContentCoding}; +pub use self::content_coding::ContentCoding; pub use self::media_type::MediaType; pub use self::header::{Header, HeaderMap}; pub use self::proxy_proto::ProxyProto; diff --git a/core/http/src/parse/accept_encoding.rs b/core/http/src/parse/accept_encoding.rs new file mode 100644 index 0000000000..3698e1f4a0 --- /dev/null +++ b/core/http/src/parse/accept_encoding.rs @@ -0,0 +1,75 @@ +use pear::macros::parser; +use pear::combinators::{series, surrounded}; + +use crate::{AcceptEncoding, QContentCoding}; +use crate::parse::checkers::is_whitespace; +use crate::parse::content_coding::content_coding; + +type Input<'a> = pear::input::Pear>; +type Result<'a, T> = pear::input::Result>; + +#[parser] +fn weighted_content_coding<'a>(input: &mut Input<'a>) -> Result<'a, QContentCoding> { + let content_coding = content_coding()?; + let weight = match content_coding.weight() { + Some(v) => Some(*v), + _ => None + }; + + QContentCoding(content_coding, weight) +} + +#[parser] +fn accept_encoding<'a>(input: &mut Input<'a>) -> Result<'a, AcceptEncoding> { + let vec = series(|i| surrounded(i, weighted_content_coding, is_whitespace), ',')?; + AcceptEncoding(std::borrow::Cow::Owned(vec)) +} + +pub fn parse_accept_encoding(input: &str) -> Result<'_, AcceptEncoding> { + parse!(accept_encoding: Input::new(input)) +} + +#[cfg(test)] +mod test { + use crate::ContentCoding; + use super::parse_accept_encoding; + + macro_rules! assert_parse { + ($string:expr) => ({ + match parse_accept_encoding($string) { + Ok(ae) => ae, + Err(e) => panic!("{:?} failed to parse: {}", $string, e) + } + }); + } + + macro_rules! assert_parse_eq { + ($string:expr, [$($cc:expr),*]) => ({ + let expected = vec![$($cc),*]; + let result = assert_parse!($string); + for (i, wcc) in result.iter().enumerate() { + assert_eq!(wcc.content_coding(), &expected[i]); + } + }); + } + + #[test] + fn check_does_parse() { + assert_parse!("gzip"); + assert_parse!("gzip; q=1"); + assert_parse!("*, gzip; q=1.0, rar, deflate"); + assert_parse!("rar, deflate"); + assert_parse!("deflate;q=0.3, gzip;q=0.7, rar;q=0.4, *;q=0.5"); + } + + #[test] + fn check_parse_eq() { + assert_parse_eq!("gzip", [ContentCoding::GZIP]); + assert_parse_eq!("gzip, deflate", + [ContentCoding::GZIP, ContentCoding::new("deflate")]); + assert_parse_eq!("gzip; q=1, deflate", + [ContentCoding::GZIP, ContentCoding::new("deflate")]); + assert_parse_eq!("gzip, gzip; q=0.1, gzip; q=0.2", + [ContentCoding::GZIP, ContentCoding::GZIP, ContentCoding::GZIP]); + } +} diff --git a/core/http/src/parse/content_coding.rs b/core/http/src/parse/content_coding.rs new file mode 100644 index 0000000000..b96c85821f --- /dev/null +++ b/core/http/src/parse/content_coding.rs @@ -0,0 +1,119 @@ +use std::borrow::Cow; + +use pear::input::Extent; +use pear::macros::{parser, parse}; +use pear::parsers::*; +use pear::combinators::surrounded; + +use crate::header::{ContentCoding, Source}; +use crate::parse::checkers::{is_valid_token, is_whitespace}; + +type Input<'a> = pear::input::Pear>; +type Result<'a, T> = pear::input::Result>; + +#[parser] +fn coding_param<'a>(input: &mut Input<'a>) -> Result<'a, Extent<&'a str>> { + let _ = (take_some_while_until(|c| matches!(c, 'Q' | 'q'), '=')?, eat('=')?).0; + let value = take_some_while_until(|c| matches!(c, '0'..='9' | '.'), ';')?; + + value +} + +#[parser] +pub fn content_coding<'a>(input: &mut Input<'a>) -> Result<'a, ContentCoding> { + let (coding, weight) = { + let coding = take_some_while_until(is_valid_token, ';')?; + let weight = match eat(input, ';') { + Ok(_) => surrounded(coding_param, is_whitespace)?, + Err(_) => Extent {start: 0, end: 0, values: ""}, + }; + + (coding, weight) + }; + + let weight = match weight.len() { + len if len > 0 && len <= 5 => match weight.parse::().ok() { + Some(q) if q > 1. => parse_error!("q value must be <= 1")?, + Some(q) if q < 0. => parse_error!("q value must be > 0")?, + Some(q) => Some(q), + None => parse_error!("invalid content coding weight")? + }, + _ => None, + }; + + ContentCoding { + weight: weight, + source: Source::Custom(Cow::Owned(input.start.to_string())), + coding: coding.into(), + } +} + +pub fn parse_content_coding(input: &str) -> Result<'_, ContentCoding> { + parse!(content_coding: Input::new(input)) +} + +#[cfg(test)] +mod test { + use crate::ContentCoding; + use super::parse_content_coding; + + macro_rules! assert_no_parse { + ($string:expr) => ({ + let result: Result<_, _> = parse_content_coding($string).into(); + if result.is_ok() { + panic!("{:?} parsed unexpectedly.", $string) + } + }); + } + + macro_rules! assert_parse { + ($string:expr) => ({ + match parse_content_coding($string) { + Ok(content_coding) => content_coding, + Err(e) => panic!("{:?} failed to parse: {}", $string, e) + } + }); + } + + macro_rules! assert_parse_eq { + (@full $string:expr, $result:expr, $weight:expr) => ({ + let result = assert_parse!($string); + assert_eq!(result, $result); + + assert_eq!(*result.weight(), $weight); + }); + + (from: $string:expr, into: $result:expr) + => (assert_parse_eq!(@full $string, $result, None)); + (from: $string:expr, into: $result:expr, weight: $weight:literal) + => (assert_parse_eq!(@full $string, $result, Some($weight))); + } + + #[test] + fn check_does_parse() { + assert_parse!("*"); + assert_parse!("rar"); + assert_parse!("gzip"); + assert_parse!("identity"); + } + + #[test] + fn check_parse_eq() { + assert_parse_eq!(from: "gzip", into: ContentCoding::GZIP); + assert_parse_eq!(from: "gzip; q=1", into: ContentCoding::GZIP, weight: 1f32); + + assert_parse_eq!(from: "*", into: ContentCoding::Any); + assert_parse_eq!(from: "rar", into: ContentCoding::new("rar")); + } + + #[test] + fn check_params_do_parse() { + assert_parse!("*; q=1"); + } + + #[test] + fn test_bad_parses() { + assert_no_parse!("*; q=1;"); + assert_no_parse!("*; q=1; q=2"); + } +} diff --git a/core/http/src/parse/mod.rs b/core/http/src/parse/mod.rs index 3c0a1f8705..66aba3d94b 100644 --- a/core/http/src/parse/mod.rs +++ b/core/http/src/parse/mod.rs @@ -1,10 +1,14 @@ mod media_type; mod accept; +mod accept_encoding; +mod content_coding; mod checkers; mod indexed; pub use self::media_type::*; pub use self::accept::*; +pub use self::accept_encoding::*; +pub use self::content_coding::*; pub mod uri; diff --git a/core/lib/src/fs/maybe_compressed_file.rs b/core/lib/src/fs/maybe_compressed_file.rs new file mode 100644 index 0000000000..6b975719af --- /dev/null +++ b/core/lib/src/fs/maybe_compressed_file.rs @@ -0,0 +1,114 @@ +use std::ffi::OsString; +use std::path::Path; +use std::io; + +use rocket_http::{ContentCoding, ContentEncoding, ContentType}; + +use crate::fs::NamedFile; +use crate::response::{self, Responder}; +use crate::Request; + +/// A [`Responder`] that looks for pre-zipped files on the filesystem (by an +/// extra `.gz' file extension) to serve in place of the given file. +/// +/// # Example +/// +/// A simple static file server mimicking [`FileServer`]: +/// +/// ```rust +/// # use rocket::get; +/// use std::path::{PathBuf, Path}; +/// +/// use rocket::fs::{NamedFile, relative}; +/// +/// #[get("/file/")] +/// pub async fn second(path: PathBuf) -> Option { +/// let mut path = Path::new(relative!("static")).join(path); +/// if path.is_dir() { +/// path.push("index.html"); +/// } +/// +/// MaybeCompressedFile::open(path).await.ok() +/// } +/// ``` +/// +/// Always prefer to use [`FileServer`] which has more functionality and a +/// pithier API. +/// +/// [`FileServer`]: crate::fs::FileServer +#[derive(Debug)] +pub(crate) struct MaybeCompressedFile { + encoding: ContentCoding, + ct_ext: Option, + file: NamedFile, +} + +impl MaybeCompressedFile { + /// Attempts to open files in read-only mode. + /// + /// # Errors + /// + /// This function will return an error if the selected path does not already + /// exist. Other errors may also be returned according to + /// [`OpenOptions::open()`](std::fs::OpenOptions::open()). + pub async fn open>(encoding: ContentCoding, path: P) -> io::Result { + let o_path = path.as_ref().to_path_buf(); + + let (ct_ext, encoding, file) = match o_path.extension() { + // A compressed file is being requested, no need to compress again. + Some(e) if e == "gz" => + (Some(e.to_owned()), ContentCoding::IDENTITY, NamedFile::open(path).await?), + + ct_ext if encoding.is_gzip() => { + // construct path to the compressed file + let ct_ext = ct_ext.map(|e| e.to_owned()); + let zip_ext = ct_ext.as_ref().map(|e| { + let mut z = e.to_owned(); + z.push(".gz"); + z + }).unwrap_or(OsString::from("gz")); + + let zipped = o_path.with_extension(zip_ext); + match zipped.exists() { + true => (ct_ext, encoding, NamedFile::open(zipped).await?), + false => (ct_ext, ContentCoding::IDENTITY, NamedFile::open(path).await?), + } + } + + // gzip not supported, fall back to IDENTITY + ct_ext => + (ct_ext.map(|e| e.to_owned()), ContentCoding::IDENTITY, NamedFile::open(o_path).await?), + + }; + + Ok(MaybeCompressedFile { ct_ext, encoding, file }) + } + + pub fn file(&self) -> &NamedFile { + &self.file + } +} + +/// Streams the *appropriate* named file to the client. Sets or overrides the +/// Content-Type in the response according to the file's non-zipped extension +/// if appropriate and if the extension is recognized. See +/// [`ContentType::from_extension()`] for more information. If you would like to +/// stream a file with a different Content-Type than that implied by its +/// extension, use a [`File`] directly. +impl<'r> Responder<'r, 'static> for MaybeCompressedFile { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let mut response = self.file.respond_to(request)?; + + if !self.encoding.is_identity() && !self.encoding.is_any() { + response.set_header(ContentEncoding::from(self.encoding)); + } + + if let Some(ext) = self.ct_ext { + if let Some(ct) = ContentType::from_extension(&ext.to_string_lossy()) { + response.set_header(ct); + } + } + + Ok(response) + } +} \ No newline at end of file diff --git a/core/lib/src/fs/mod.rs b/core/lib/src/fs/mod.rs index dd00f1798a..b4dfe5de85 100644 --- a/core/lib/src/fs/mod.rs +++ b/core/lib/src/fs/mod.rs @@ -2,11 +2,15 @@ mod server; mod named_file; +mod maybe_compressed_file; +mod server_file; mod temp_file; mod file_name; pub use server::*; pub use named_file::*; +pub(crate) use maybe_compressed_file::*; +pub(crate) use server_file::*; pub use temp_file::*; pub use file_name::*; pub use server::relative; diff --git a/core/lib/src/fs/server.rs b/core/lib/src/fs/server.rs index 45300db592..d12092ea8a 100644 --- a/core/lib/src/fs/server.rs +++ b/core/lib/src/fs/server.rs @@ -1,11 +1,13 @@ use std::path::{PathBuf, Path}; +use rocket_http::{AcceptEncoding, ContentCoding}; + use crate::{Request, Data}; use crate::http::{Method, Status, uri::Segments, ext::IntoOwned}; use crate::route::{Route, Handler, Outcome}; use crate::response::{Redirect, Responder}; use crate::outcome::IntoOutcome; -use crate::fs::NamedFile; +use crate::fs::{MaybeCompressedFile, NamedFile, ServerFile}; /// Custom handler for serving static files. /// @@ -218,28 +220,46 @@ impl Handler for FileServer { .map(|path| self.root.join(path)); match path { - Some(p) if p.is_dir() => { - // Normalize '/a/b/foo' to '/a/b/foo/'. - if options.contains(Options::NormalizeDirs) && !req.uri().path().ends_with('/') { - let normal = req.uri().map_path(|p| format!("{}/", p)) - .expect("adding a trailing slash to a known good path => valid path") - .into_owned(); - - return Redirect::permanent(normal) - .respond_to(req) - .or_forward((data, Status::InternalServerError)); - } + Some(mut p) => { + if p.is_dir() { + // Normalize '/a/b/foo' to '/a/b/foo/'. + if options.contains(Options::NormalizeDirs) && !req.uri().path().ends_with('/') { + let normal = req.uri().map_path(|p| format!("{}/", p)) + .expect("adding a trailing slash to a known good path => valid path") + .into_owned(); + + return Redirect::permanent(normal) + .respond_to(req) + .or_forward((data, Status::InternalServerError)); + } - if !options.contains(Options::Index) { - return Outcome::forward(data, Status::NotFound); + if !options.contains(Options::Index) { + return Outcome::forward(data, Status::NotFound); + } + + p = p.join("index.html"); } - let index = NamedFile::open(p.join("index.html")).await; - index.respond_to(req).or_forward((data, Status::NotFound)) - }, - Some(p) => { - let file = NamedFile::open(p).await; - file.respond_to(req).or_forward((data, Status::NotFound)) + let encoding = match options.contains(Options::PreZipped) { + true => req.headers() + .get_one("Accept-Encoding") + .and_then(|v| v.parse().ok()) + .map(|ae: AcceptEncoding| { + ae.content_codings() + .filter(|cc| cc.is_known()) + .filter(|cc| cc.weight().unwrap_or(1f32) > 0f32) + // Our current impl will prefer gzip if the client accepts it. + .map(|cc| { + if cc.is_any() { ContentCoding::GZIP } else { cc.to_owned() } + }) + .collect::>() + }) + .and_then(|ccs| ccs.into_iter().reduce(|acc, cc| if acc.is_gzip() { acc } else { cc })), + false => None, + }.unwrap_or(ContentCoding::IDENTITY); + + ServerFile::new(MaybeCompressedFile::open(encoding, p).await).await + .respond_to(req).or_forward((data, Status::NotFound)) } None => Outcome::forward(data, Status::NotFound), } @@ -257,6 +277,7 @@ impl Handler for FileServer { /// * [`Options::Missing`] - Don't fail if the path to serve is missing. /// * [`Options::NormalizeDirs`] - Redirect directories without a trailing /// slash to ones with a trailing slash. +/// * [`Options::PreZipped`] - Serve pre-compressed files if they exist. /// /// `Options` structures can be `or`d together to select two or more options. /// For instance, to request that both dot files and index pages be returned, @@ -366,6 +387,10 @@ impl Options { /// prevent inevitable 404 errors. This option overrides that. pub const Missing: Options = Options(1 << 4); + /// Check for and serve pre-zipped files on the filesystem based on + /// a similarly named file with a `.gz` extension. + pub const PreZipped: Options = Options(1 << 5); + /// Returns `true` if `self` is a superset of `other`. In other words, /// returns `true` if all of the options in `other` are also in `self`. /// diff --git a/core/lib/src/fs/server_file.rs b/core/lib/src/fs/server_file.rs new file mode 100644 index 0000000000..863a799bc2 --- /dev/null +++ b/core/lib/src/fs/server_file.rs @@ -0,0 +1,60 @@ +use std::io; + +use http::header::IF_MODIFIED_SINCE; +use rocket_http::Status; +use time::OffsetDateTime; + +use crate::fs::MaybeCompressedFile; +use crate::response::{self, Responder}; +use crate::Request; + +/// A [`Responder`] that wraps the [`MaybeCompressedFile`] and sets the +/// `Last-Modified` header. +/// +/// [`FileServer`]: crate::fs::FileServer +#[derive(Debug)] +pub(crate) struct ServerFile { + last_modified: Option, + file: MaybeCompressedFile, +} + +impl ServerFile { + /// Attempts to read file metadata. + /// + /// # Errors + /// + /// This function will return an error if the file's metadata cannot be read. + /// [`OpenOptions::open()`](std::fs::OpenOptions::open()). + pub async fn new(file: io::Result) -> io::Result { + let file = file?; + let metadata = file.file().metadata().await?; + let last_modified = metadata.modified()?.duration_since(std::time::UNIX_EPOCH).ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .and_then(|sec| OffsetDateTime::from_unix_timestamp(sec).ok()); + // .and_then(|odt| odt.format(&time::format_description::well_known::Rfc2822).ok()); + + Ok(Self { last_modified, file }) + } +} + +/// Sets the last-modified data for the file response +impl<'r> Responder<'r, 'static> for ServerFile { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let if_modified_since = request.headers().get_one(IF_MODIFIED_SINCE.as_str()) + .and_then(|v| time::OffsetDateTime::parse(v, &time::format_description::well_known::Rfc2822).ok()); + + match (self.last_modified, if_modified_since) { + (Some(lm), Some(ims)) if lm <= ims => + return crate::Response::build().status(Status::NotModified).ok(), + _ => {} + } + + let mut response = self.file.respond_to(request)?; + + self.last_modified + .and_then(|odt| odt.format(&time::format_description::well_known::Rfc2822).ok()) + .map(|lm| response.set_raw_header("last-modified", lm)); + + Ok(response) + } +} diff --git a/core/lib/tests/file_server.rs b/core/lib/tests/file_server.rs index c0b33e53a8..d0ea46adb1 100644 --- a/core/lib/tests/file_server.rs +++ b/core/lib/tests/file_server.rs @@ -5,6 +5,7 @@ use rocket::{Rocket, Route, Build}; use rocket::http::Status; use rocket::local::blocking::Client; use rocket::fs::{FileServer, Options, relative}; +use rocket_http::Header; fn static_root() -> &'static Path { Path::new(relative!("/tests/static")) @@ -20,6 +21,7 @@ fn rocket() -> Rocket { .mount("/both", FileServer::new(&root, Options::DotFiles | Options::Index)) .mount("/redir", FileServer::new(&root, Options::NormalizeDirs)) .mount("/redir_index", FileServer::new(&root, Options::NormalizeDirs | Options::Index)) + .mount("/compressed", FileServer::new(&root, Options::PreZipped)) } static REGULAR_FILES: &[&str] = &[ @@ -27,6 +29,11 @@ static REGULAR_FILES: &[&str] = &[ "inner/goodbye", "inner/index.html", "other/hello.txt", + "other/hello.txt.gz", +]; + +static COMPRESSED_FILES: &[&str] = &[ + "other/hello.txt", ]; static HIDDEN_FILES: &[&str] = &[ @@ -39,21 +46,36 @@ static INDEXED_DIRECTORIES: &[&str] = &[ "inner/", ]; -fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { +fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool, compressed: bool) { let full_path = format!("/{}/{}", prefix, path); - let response = client.get(full_path).dispatch(); + let mut request = client.get(full_path); + request.add_header(Header::new("Accept-Encoding", "gzip")); + let mut response = request.dispatch(); if exists { assert_eq!(response.status(), Status::Ok); - let mut path = static_root().join(path); + let mut path = match compressed { + true => static_root().join(format!("{path}.gz")), + false => static_root().join(path), + }; if path.is_dir() { path = path.join("index.html"); } let mut file = File::open(path).expect("open file"); - let mut expected_contents = String::new(); - file.read_to_string(&mut expected_contents).expect("read file"); - assert_eq!(response.into_string(), Some(expected_contents)); + let mut expected_contents = vec![]; + file.read_to_end(&mut expected_contents).expect("read file"); + + let mut actual = vec![]; + response.read_to_end(&mut actual).expect("read response"); + + let ce: Vec<&str> = response.headers().get("Content-Encoding").collect(); + if compressed { + assert_eq!(vec!["gzip"], ce); + } else { + assert_eq!(Vec::<&str>::new(), ce); + } + assert_eq!(actual, expected_contents); } else { assert_eq!(response.status(), Status::NotFound); } @@ -61,7 +83,7 @@ fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { fn assert_all(client: &Client, prefix: &str, paths: &[&str], exist: bool) { for path in paths.iter() { - assert_file(client, prefix, path, exist); + assert_file(client, prefix, path, exist, false); } } @@ -190,3 +212,11 @@ fn test_redirection() { assert_eq!(response.status(), Status::PermanentRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir_index/other/")); } + +#[test] +fn test_compression() { + let client = Client::debug(rocket()).expect("valid rocket"); + for path in COMPRESSED_FILES { + assert_file(&client, "compressed", path, true, true) + } +} \ No newline at end of file diff --git a/core/lib/tests/static/other/hello.txt.gz b/core/lib/tests/static/other/hello.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..2a9f277843f555ee5381abc6d2bb26946accac54 GIT binary patch literal 34 qcmb2|=HTFS+?mS2oRON7ldo4&QNr-~M932+hB=E@gs?C$FaQ9&`U?R7 literal 0 HcmV?d00001