actix_web/http/header/
content_disposition.rs

1//! The `Content-Disposition` header and associated types.
2//!
3//! # References
4//! - "The Content-Disposition Header Field":
5//!   <https://datatracker.ietf.org/doc/html/rfc2183>
6//! - "The Content-Disposition Header Field in the Hypertext Transfer Protocol (HTTP)":
7//!   <https://datatracker.ietf.org/doc/html/rfc6266>
8//! - "Returning Values from Forms: multipart/form-data":
9//!   <https://datatracker.ietf.org/doc/html/rfc7578>
10//! - Browser conformance tests at: <http://greenbytes.de/tech/tc2231/>
11//! - IANA assignment: <http://www.iana.org/assignments/cont-disp/cont-disp.xhtml>
12
13use std::fmt::{self, Write};
14
15use once_cell::sync::Lazy;
16#[cfg(feature = "unicode")]
17use regex::Regex;
18#[cfg(not(feature = "unicode"))]
19use regex_lite::Regex;
20
21use super::{ExtendedValue, Header, TryIntoHeaderValue, Writer};
22use crate::http::header;
23
24/// Split at the index of the first `needle` if it exists or at the end.
25fn split_once(haystack: &str, needle: char) -> (&str, &str) {
26    haystack.find(needle).map_or_else(
27        || (haystack, ""),
28        |sc| {
29            let (first, last) = haystack.split_at(sc);
30            (first, last.split_at(1).1)
31        },
32    )
33}
34
35/// Split at the index of the first `needle` if it exists or at the end, trim the right of the
36/// first part and the left of the last part.
37fn split_once_and_trim(haystack: &str, needle: char) -> (&str, &str) {
38    let (first, last) = split_once(haystack, needle);
39    (first.trim_end(), last.trim_start())
40}
41
42/// The implied disposition of the content of the HTTP body.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DispositionType {
45    /// Inline implies default processing.
46    Inline,
47
48    /// Attachment implies that the recipient should prompt the user to save the response locally,
49    /// rather than process it normally (as per its media type).
50    Attachment,
51
52    /// Used in *multipart/form-data* as defined in
53    /// [RFC 7578](https://datatracker.ietf.org/doc/html/rfc7578) to carry the field name and
54    /// optional filename.
55    FormData,
56
57    /// Extension type. Should be handled by recipients the same way as Attachment.
58    Ext(String),
59}
60
61impl<'a> From<&'a str> for DispositionType {
62    fn from(origin: &'a str) -> DispositionType {
63        if origin.eq_ignore_ascii_case("inline") {
64            DispositionType::Inline
65        } else if origin.eq_ignore_ascii_case("attachment") {
66            DispositionType::Attachment
67        } else if origin.eq_ignore_ascii_case("form-data") {
68            DispositionType::FormData
69        } else {
70            DispositionType::Ext(origin.to_owned())
71        }
72    }
73}
74
75/// Parameter in [`ContentDisposition`].
76///
77/// # Examples
78/// ```
79/// use actix_web::http::header::DispositionParam;
80///
81/// let param = DispositionParam::Filename(String::from("sample.txt"));
82/// assert!(param.is_filename());
83/// assert_eq!(param.as_filename().unwrap(), "sample.txt");
84/// ```
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[allow(clippy::large_enum_variant)]
87pub enum DispositionParam {
88    /// For [`DispositionType::FormData`] (i.e. *multipart/form-data*), the name of an field from
89    /// the form.
90    Name(String),
91
92    /// A plain file name.
93    ///
94    /// It is [not supposed](https://datatracker.ietf.org/doc/html/rfc6266#appendix-D) to contain
95    /// any non-ASCII characters when used in a *Content-Disposition* HTTP response header, where
96    /// [`FilenameExt`](DispositionParam::FilenameExt) with charset UTF-8 may be used instead
97    /// in case there are Unicode characters in file names.
98    Filename(String),
99
100    /// An extended file name. It must not exist for `ContentType::Formdata` according to
101    /// [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2).
102    FilenameExt(ExtendedValue),
103
104    /// An unrecognized regular parameter as defined in
105    /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
106    /// `reg-parameter`, in
107    /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
108    /// `token "=" value`. Recipients should ignore unrecognizable parameters.
109    Unknown(String, String),
110
111    /// An unrecognized extended parameter as defined in
112    /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
113    /// `ext-parameter`, in
114    /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
115    /// `ext-token "=" ext-value`. The single trailing asterisk is not included. Recipients should
116    /// ignore unrecognizable parameters.
117    UnknownExt(String, ExtendedValue),
118}
119
120impl DispositionParam {
121    /// Returns `true` if the parameter is [`Name`](DispositionParam::Name).
122    #[inline]
123    pub fn is_name(&self) -> bool {
124        self.as_name().is_some()
125    }
126
127    /// Returns `true` if the parameter is [`Filename`](DispositionParam::Filename).
128    #[inline]
129    pub fn is_filename(&self) -> bool {
130        self.as_filename().is_some()
131    }
132
133    /// Returns `true` if the parameter is [`FilenameExt`](DispositionParam::FilenameExt).
134    #[inline]
135    pub fn is_filename_ext(&self) -> bool {
136        self.as_filename_ext().is_some()
137    }
138
139    /// Returns `true` if the parameter is [`Unknown`](DispositionParam::Unknown) and the `name`
140    #[inline]
141    /// matches.
142    pub fn is_unknown<T: AsRef<str>>(&self, name: T) -> bool {
143        self.as_unknown(name).is_some()
144    }
145
146    /// Returns `true` if the parameter is [`UnknownExt`](DispositionParam::UnknownExt) and the
147    /// `name` matches.
148    #[inline]
149    pub fn is_unknown_ext<T: AsRef<str>>(&self, name: T) -> bool {
150        self.as_unknown_ext(name).is_some()
151    }
152
153    /// Returns the name if applicable.
154    #[inline]
155    pub fn as_name(&self) -> Option<&str> {
156        match self {
157            DispositionParam::Name(name) => Some(name.as_str()),
158            _ => None,
159        }
160    }
161
162    /// Returns the filename if applicable.
163    #[inline]
164    pub fn as_filename(&self) -> Option<&str> {
165        match self {
166            DispositionParam::Filename(filename) => Some(filename.as_str()),
167            _ => None,
168        }
169    }
170
171    /// Returns the filename* if applicable.
172    #[inline]
173    pub fn as_filename_ext(&self) -> Option<&ExtendedValue> {
174        match self {
175            DispositionParam::FilenameExt(value) => Some(value),
176            _ => None,
177        }
178    }
179
180    /// Returns the value of the unrecognized regular parameter if it is
181    /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
182    #[inline]
183    pub fn as_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str> {
184        match self {
185            DispositionParam::Unknown(ref ext_name, ref value)
186                if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
187            {
188                Some(value.as_str())
189            }
190            _ => None,
191        }
192    }
193
194    /// Returns the value of the unrecognized extended parameter if it is
195    /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
196    #[inline]
197    pub fn as_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue> {
198        match self {
199            DispositionParam::UnknownExt(ref ext_name, ref value)
200                if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
201            {
202                Some(value)
203            }
204            _ => None,
205        }
206    }
207}
208
209/// `Content-Disposition` header.
210///
211/// It is compatible to be used either as [a response header for the main body][use_main_body]
212/// as (re)defined in [RFC 6266], or as [a header for a multipart body][use_multipart] as
213/// (re)defined in [RFC 7587].
214///
215/// In a regular HTTP response, the *Content-Disposition* response header is a header indicating if
216/// the content is expected to be displayed *inline* in the browser, that is, as a Web page or as
217/// part of a Web page, or as an attachment, that is downloaded and saved locally, and also can be
218/// used to attach additional metadata, such as the filename to use when saving the response payload
219/// locally.
220///
221/// In a *multipart/form-data* body, the HTTP *Content-Disposition* general header is a header that
222/// can be used on the subpart of a multipart body to give information about the field it applies to.
223/// The subpart is delimited by the boundary defined in the *Content-Type* header. Used on the body
224/// itself, *Content-Disposition* has no effect.
225///
226/// # ABNF
227/// ```plain
228/// content-disposition = "Content-Disposition" ":"
229///                       disposition-type *( ";" disposition-parm )
230///
231/// disposition-type    = "inline" | "attachment" | disp-ext-type
232///                       ; case-insensitive
233///
234/// disp-ext-type       = token
235///
236/// disposition-parm    = filename-parm | disp-ext-parm
237///
238/// filename-parm       = "filename" "=" value
239///                     | "filename*" "=" ext-value
240///
241/// disp-ext-parm       = token "=" value
242///                     | ext-token "=" ext-value
243///
244/// ext-token           = <the characters in token, followed by "*">
245/// ```
246///
247/// # Note
248/// *filename* is [not supposed](https://datatracker.ietf.org/doc/html/rfc6266#appendix-D) to
249/// contain any non-ASCII characters when used in a *Content-Disposition* HTTP response header,
250/// where filename* with charset UTF-8 may be used instead in case there are Unicode characters in
251/// file names. Filename is [acceptable](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2)
252/// to be UTF-8 encoded directly in a *Content-Disposition* header for
253/// *multipart/form-data*, though.
254///
255/// *filename* [must not](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2) be used within
256/// *multipart/form-data*.
257///
258/// # Examples
259/// ```
260/// use actix_web::http::header::{
261///     Charset, ContentDisposition, DispositionParam, DispositionType,
262///     ExtendedValue,
263/// };
264///
265/// let cd1 = ContentDisposition {
266///     disposition: DispositionType::Attachment,
267///     parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
268///         charset: Charset::Iso_8859_1, // The character set for the bytes of the filename
269///         language_tag: None, // The optional language tag (see `language-tag` crate)
270///         value: b"\xA9 Ferris 2011.txt".to_vec(), // the actual bytes of the filename
271///     })],
272/// };
273/// assert!(cd1.is_attachment());
274/// assert!(cd1.get_filename_ext().is_some());
275///
276/// let cd2 = ContentDisposition {
277///     disposition: DispositionType::FormData,
278///     parameters: vec![
279///         DispositionParam::Name(String::from("file")),
280///         DispositionParam::Filename(String::from("bill.odt")),
281///     ],
282/// };
283/// assert_eq!(cd2.get_name(), Some("file")); // field name
284/// assert_eq!(cd2.get_filename(), Some("bill.odt"));
285///
286/// // HTTP response header with Unicode characters in file names
287/// let cd3 = ContentDisposition {
288///     disposition: DispositionType::Attachment,
289///     parameters: vec![
290///         DispositionParam::FilenameExt(ExtendedValue {
291///             charset: Charset::Ext(String::from("UTF-8")),
292///             language_tag: None,
293///             value: String::from("\u{1f600}.svg").into_bytes(),
294///         }),
295///         // fallback for better compatibility
296///         DispositionParam::Filename(String::from("Grinning-Face-Emoji.svg"))
297///     ],
298/// };
299/// assert_eq!(cd3.get_filename_ext().map(|ev| ev.value.as_ref()),
300///            Some("\u{1f600}.svg".as_bytes()));
301/// ```
302///
303/// # Security Note
304/// If "filename" parameter is supplied, do not use the file name blindly, check and possibly
305/// change to match local file system conventions if applicable, and do not use directory path
306/// information that may be present.
307/// See [RFC 2183 §2.3](https://datatracker.ietf.org/doc/html/rfc2183#section-2.3).
308///
309/// [use_main_body]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_response_header_for_the_main_body
310/// [RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266
311/// [use_multipart]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_header_for_a_multipart_body
312/// [RFC 7587]: https://datatracker.ietf.org/doc/html/rfc7578
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct ContentDisposition {
315    /// The disposition type
316    pub disposition: DispositionType,
317
318    /// Disposition parameters
319    pub parameters: Vec<DispositionParam>,
320}
321
322impl ContentDisposition {
323    /// Constructs a Content-Disposition header suitable for downloads.
324    ///
325    /// # Examples
326    /// ```
327    /// use actix_web::http::header::{ContentDisposition, TryIntoHeaderValue as _};
328    ///
329    /// let cd = ContentDisposition::attachment("files.zip");
330    ///
331    /// let cd_val = cd.try_into_value().unwrap();
332    /// assert_eq!(cd_val, "attachment; filename=\"files.zip\"");
333    /// ```
334    pub fn attachment(filename: impl Into<String>) -> Self {
335        Self {
336            disposition: DispositionType::Attachment,
337            parameters: vec![DispositionParam::Filename(filename.into())],
338        }
339    }
340
341    /// Parse a raw Content-Disposition header value.
342    pub fn from_raw(hv: &header::HeaderValue) -> Result<Self, crate::error::ParseError> {
343        // `header::from_one_raw_str` invokes `hv.to_str` which assumes `hv` contains only visible
344        //  ASCII characters. So `hv.as_bytes` is necessary here.
345        let hv = String::from_utf8(hv.as_bytes().to_vec())
346            .map_err(|_| crate::error::ParseError::Header)?;
347
348        let (disp_type, mut left) = split_once_and_trim(hv.as_str().trim(), ';');
349        if disp_type.is_empty() {
350            return Err(crate::error::ParseError::Header);
351        }
352
353        let mut cd = ContentDisposition {
354            disposition: disp_type.into(),
355            parameters: Vec::new(),
356        };
357
358        while !left.is_empty() {
359            let (param_name, new_left) = split_once_and_trim(left, '=');
360            if param_name.is_empty() || param_name == "*" || new_left.is_empty() {
361                return Err(crate::error::ParseError::Header);
362            }
363            left = new_left;
364            if let Some(param_name) = param_name.strip_suffix('*') {
365                // extended parameters
366                let (ext_value, new_left) = split_once_and_trim(left, ';');
367                left = new_left;
368                let ext_value = header::parse_extended_value(ext_value)?;
369
370                let param = if param_name.eq_ignore_ascii_case("filename") {
371                    DispositionParam::FilenameExt(ext_value)
372                } else {
373                    DispositionParam::UnknownExt(param_name.to_owned(), ext_value)
374                };
375                cd.parameters.push(param);
376            } else {
377                // regular parameters
378                let value = if left.starts_with('\"') {
379                    // quoted-string: defined in RFC 6266 -> RFC 2616 Section 3.6
380                    let mut escaping = false;
381                    let mut quoted_string = vec![];
382                    let mut end = None;
383                    // search for closing quote
384                    for (i, &c) in left.as_bytes().iter().skip(1).enumerate() {
385                        if escaping {
386                            escaping = false;
387                            quoted_string.push(c);
388                        } else if c == 0x5c {
389                            // backslash
390                            escaping = true;
391                        } else if c == 0x22 {
392                            // double quote
393                            end = Some(i + 1); // cuz skipped 1 for the leading quote
394                            break;
395                        } else {
396                            quoted_string.push(c);
397                        }
398                    }
399                    left = &left[end.ok_or(crate::error::ParseError::Header)? + 1..];
400                    left = split_once(left, ';').1.trim_start();
401                    // In fact, it should not be Err if the above code is correct.
402                    String::from_utf8(quoted_string)
403                        .map_err(|_| crate::error::ParseError::Header)?
404                } else {
405                    // token: won't contains semicolon according to RFC 2616 Section 2.2
406                    let (token, new_left) = split_once_and_trim(left, ';');
407                    left = new_left;
408                    if token.is_empty() {
409                        // quoted-string can be empty, but token cannot be empty
410                        return Err(crate::error::ParseError::Header);
411                    }
412                    token.to_owned()
413                };
414
415                let param = if param_name.eq_ignore_ascii_case("name") {
416                    DispositionParam::Name(value)
417                } else if param_name.eq_ignore_ascii_case("filename") {
418                    // See also comments in test_from_raw_unnecessary_percent_decode.
419                    DispositionParam::Filename(value)
420                } else {
421                    DispositionParam::Unknown(param_name.to_owned(), value)
422                };
423                cd.parameters.push(param);
424            }
425        }
426
427        Ok(cd)
428    }
429
430    /// Returns `true` if type is [`Inline`](DispositionType::Inline).
431    pub fn is_inline(&self) -> bool {
432        matches!(self.disposition, DispositionType::Inline)
433    }
434
435    /// Returns `true` if type is [`Attachment`](DispositionType::Attachment).
436    pub fn is_attachment(&self) -> bool {
437        matches!(self.disposition, DispositionType::Attachment)
438    }
439
440    /// Returns `true` if type is [`FormData`](DispositionType::FormData).
441    pub fn is_form_data(&self) -> bool {
442        matches!(self.disposition, DispositionType::FormData)
443    }
444
445    /// Returns `true` if type is [`Ext`](DispositionType::Ext) and the `disp_type` matches.
446    pub fn is_ext(&self, disp_type: impl AsRef<str>) -> bool {
447        matches!(
448            self.disposition,
449            DispositionType::Ext(ref t) if t.eq_ignore_ascii_case(disp_type.as_ref())
450        )
451    }
452
453    /// Return the value of *name* if exists.
454    pub fn get_name(&self) -> Option<&str> {
455        self.parameters.iter().find_map(DispositionParam::as_name)
456    }
457
458    /// Return the value of *filename* if exists.
459    pub fn get_filename(&self) -> Option<&str> {
460        self.parameters
461            .iter()
462            .find_map(DispositionParam::as_filename)
463    }
464
465    /// Return the value of *filename\** if exists.
466    pub fn get_filename_ext(&self) -> Option<&ExtendedValue> {
467        self.parameters
468            .iter()
469            .find_map(DispositionParam::as_filename_ext)
470    }
471
472    /// Return the value of the parameter which the `name` matches.
473    pub fn get_unknown(&self, name: impl AsRef<str>) -> Option<&str> {
474        let name = name.as_ref();
475        self.parameters.iter().find_map(|p| p.as_unknown(name))
476    }
477
478    /// Return the value of the extended parameter which the `name` matches.
479    pub fn get_unknown_ext(&self, name: impl AsRef<str>) -> Option<&ExtendedValue> {
480        let name = name.as_ref();
481        self.parameters.iter().find_map(|p| p.as_unknown_ext(name))
482    }
483}
484
485impl TryIntoHeaderValue for ContentDisposition {
486    type Error = header::InvalidHeaderValue;
487
488    fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
489        let mut writer = Writer::new();
490        let _ = write!(&mut writer, "{}", self);
491        header::HeaderValue::from_maybe_shared(writer.take())
492    }
493}
494
495impl Header for ContentDisposition {
496    fn name() -> header::HeaderName {
497        header::CONTENT_DISPOSITION
498    }
499
500    fn parse<T: crate::HttpMessage>(msg: &T) -> Result<Self, crate::error::ParseError> {
501        if let Some(h) = msg.headers().get(Self::name()) {
502            Self::from_raw(h)
503        } else {
504            Err(crate::error::ParseError::Header)
505        }
506    }
507}
508
509impl fmt::Display for DispositionType {
510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511        match self {
512            DispositionType::Inline => write!(f, "inline"),
513            DispositionType::Attachment => write!(f, "attachment"),
514            DispositionType::FormData => write!(f, "form-data"),
515            DispositionType::Ext(ref s) => write!(f, "{}", s),
516        }
517    }
518}
519
520impl fmt::Display for DispositionParam {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        // All ASCII control characters (0-30, 127) including horizontal tab, double quote, and
523        // backslash should be escaped in quoted-string (i.e. "foobar").
524        //
525        // Ref: RFC 6266 §4.1 -> RFC 2616 §3.6
526        //
527        // filename-parm  = "filename" "=" value
528        // value          = token | quoted-string
529        // quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
530        // qdtext         = <any TEXT except <">>
531        // quoted-pair    = "\" CHAR
532        // TEXT           = <any OCTET except CTLs,
533        //                  but including LWS>
534        // LWS            = [CRLF] 1*( SP | HT )
535        // OCTET          = <any 8-bit sequence of data>
536        // CHAR           = <any US-ASCII character (octets 0 - 127)>
537        // CTL            = <any US-ASCII control character
538        //                  (octets 0 - 31) and DEL (127)>
539        //
540        // Ref: RFC 7578 S4.2 -> RFC 2183 S2 -> RFC 2045 S5.1
541        // parameter := attribute "=" value
542        // attribute := token
543        //              ; Matching of attributes
544        //              ; is ALWAYS case-insensitive.
545        // value := token / quoted-string
546        // token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
547        //             or tspecials>
548        // tspecials :=  "(" / ")" / "<" / ">" / "@" /
549        //               "," / ";" / ":" / "\" / <">
550        //               "/" / "[" / "]" / "?" / "="
551        //               ; Must be in quoted-string,
552        //               ; to use within parameter values
553        //
554        //
555        // See also comments in test_from_raw_unnecessary_percent_decode.
556
557        static RE: Lazy<Regex> =
558            Lazy::new(|| Regex::new("[\x00-\x08\x10-\x1F\x7F\"\\\\]").unwrap());
559
560        match self {
561            DispositionParam::Name(ref value) => write!(f, "name={}", value),
562
563            DispositionParam::Filename(ref value) => {
564                write!(f, "filename=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
565            }
566
567            DispositionParam::Unknown(ref name, ref value) => write!(
568                f,
569                "{}=\"{}\"",
570                name,
571                &RE.replace_all(value, "\\$0").as_ref()
572            ),
573
574            DispositionParam::FilenameExt(ref ext_value) => {
575                write!(f, "filename*={}", ext_value)
576            }
577
578            DispositionParam::UnknownExt(ref name, ref ext_value) => {
579                write!(f, "{}*={}", name, ext_value)
580            }
581        }
582    }
583}
584
585impl fmt::Display for ContentDisposition {
586    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587        write!(f, "{}", self.disposition)?;
588        self.parameters
589            .iter()
590            .try_for_each(|param| write!(f, "; {}", param))
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::{ContentDisposition, DispositionParam, DispositionType};
597    use crate::http::header::{Charset, ExtendedValue, HeaderValue};
598
599    #[test]
600    fn test_from_raw_basic() {
601        assert!(ContentDisposition::from_raw(&HeaderValue::from_static("")).is_err());
602
603        let a =
604            HeaderValue::from_static("form-data; dummy=3; name=upload; filename=\"sample.png\"");
605        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
606        let b = ContentDisposition {
607            disposition: DispositionType::FormData,
608            parameters: vec![
609                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
610                DispositionParam::Name("upload".to_owned()),
611                DispositionParam::Filename("sample.png".to_owned()),
612            ],
613        };
614        assert_eq!(a, b);
615
616        let a = HeaderValue::from_static("attachment; filename=\"image.jpg\"");
617        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
618        let b = ContentDisposition {
619            disposition: DispositionType::Attachment,
620            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
621        };
622        assert_eq!(a, b);
623
624        let a = HeaderValue::from_static("inline; filename=image.jpg");
625        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
626        let b = ContentDisposition {
627            disposition: DispositionType::Inline,
628            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
629        };
630        assert_eq!(a, b);
631
632        let a = HeaderValue::from_static(
633            "attachment; creation-date=\"Wed, 12 Feb 1997 16:29:51 -0500\"",
634        );
635        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
636        let b = ContentDisposition {
637            disposition: DispositionType::Attachment,
638            parameters: vec![DispositionParam::Unknown(
639                String::from("creation-date"),
640                "Wed, 12 Feb 1997 16:29:51 -0500".to_owned(),
641            )],
642        };
643        assert_eq!(a, b);
644    }
645
646    #[test]
647    fn test_from_raw_extended() {
648        let a = HeaderValue::from_static(
649            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
650        );
651        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
652        let b = ContentDisposition {
653            disposition: DispositionType::Attachment,
654            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
655                charset: Charset::Ext(String::from("UTF-8")),
656                language_tag: None,
657                value: vec![
658                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
659                    b't', b'e', b's',
660                ],
661            })],
662        };
663        assert_eq!(a, b);
664
665        let a = HeaderValue::from_static(
666            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
667        );
668        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
669        let b = ContentDisposition {
670            disposition: DispositionType::Attachment,
671            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
672                charset: Charset::Ext(String::from("UTF-8")),
673                language_tag: None,
674                value: vec![
675                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
676                    b't', b'e', b's',
677                ],
678            })],
679        };
680        assert_eq!(a, b);
681    }
682
683    #[test]
684    fn test_from_raw_extra_whitespace() {
685        let a = HeaderValue::from_static(
686            "form-data  ; du-mmy= 3  ; name =upload ; filename =  \"sample.png\"  ; ",
687        );
688        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
689        let b = ContentDisposition {
690            disposition: DispositionType::FormData,
691            parameters: vec![
692                DispositionParam::Unknown("du-mmy".to_owned(), "3".to_owned()),
693                DispositionParam::Name("upload".to_owned()),
694                DispositionParam::Filename("sample.png".to_owned()),
695            ],
696        };
697        assert_eq!(a, b);
698    }
699
700    #[test]
701    fn test_from_raw_unordered() {
702        let a = HeaderValue::from_static(
703            "form-data; dummy=3; filename=\"sample.png\" ; name=upload;",
704            // Actually, a trailing semicolon is not compliant. But it is fine to accept.
705        );
706        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
707        let b = ContentDisposition {
708            disposition: DispositionType::FormData,
709            parameters: vec![
710                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
711                DispositionParam::Filename("sample.png".to_owned()),
712                DispositionParam::Name("upload".to_owned()),
713            ],
714        };
715        assert_eq!(a, b);
716
717        let a = HeaderValue::from_str(
718            "attachment; filename*=iso-8859-1''foo-%E4.html; filename=\"foo-ä.html\"",
719        )
720        .unwrap();
721        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
722        let b = ContentDisposition {
723            disposition: DispositionType::Attachment,
724            parameters: vec![
725                DispositionParam::FilenameExt(ExtendedValue {
726                    charset: Charset::Iso_8859_1,
727                    language_tag: None,
728                    value: b"foo-\xe4.html".to_vec(),
729                }),
730                DispositionParam::Filename("foo-ä.html".to_owned()),
731            ],
732        };
733        assert_eq!(a, b);
734    }
735
736    #[test]
737    fn test_from_raw_only_disp() {
738        let a = ContentDisposition::from_raw(&HeaderValue::from_static("attachment")).unwrap();
739        let b = ContentDisposition {
740            disposition: DispositionType::Attachment,
741            parameters: vec![],
742        };
743        assert_eq!(a, b);
744
745        let a = ContentDisposition::from_raw(&HeaderValue::from_static("inline ;")).unwrap();
746        let b = ContentDisposition {
747            disposition: DispositionType::Inline,
748            parameters: vec![],
749        };
750        assert_eq!(a, b);
751
752        let a =
753            ContentDisposition::from_raw(&HeaderValue::from_static("unknown-disp-param")).unwrap();
754        let b = ContentDisposition {
755            disposition: DispositionType::Ext(String::from("unknown-disp-param")),
756            parameters: vec![],
757        };
758        assert_eq!(a, b);
759    }
760
761    #[test]
762    fn from_raw_with_mixed_case() {
763        let a = HeaderValue::from_str(
764            "InLInE; fIlenAME*=iso-8859-1''foo-%E4.html; filEName=\"foo-ä.html\"",
765        )
766        .unwrap();
767        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
768        let b = ContentDisposition {
769            disposition: DispositionType::Inline,
770            parameters: vec![
771                DispositionParam::FilenameExt(ExtendedValue {
772                    charset: Charset::Iso_8859_1,
773                    language_tag: None,
774                    value: b"foo-\xe4.html".to_vec(),
775                }),
776                DispositionParam::Filename("foo-ä.html".to_owned()),
777            ],
778        };
779        assert_eq!(a, b);
780    }
781
782    #[test]
783    fn from_raw_with_unicode() {
784        /* RFC 7578 Section 4.2:
785        Some commonly deployed systems use multipart/form-data with file names directly encoded
786        including octets outside the US-ASCII range. The encoding used for the file names is
787        typically UTF-8, although HTML forms will use the charset associated with the form.
788
789        Mainstream browsers like Firefox (gecko) and Chrome use UTF-8 directly as above.
790        (And now, only UTF-8 is handled by this implementation.)
791        */
792        let a = HeaderValue::from_str("form-data; name=upload; filename=\"文件.webp\"").unwrap();
793        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
794        let b = ContentDisposition {
795            disposition: DispositionType::FormData,
796            parameters: vec![
797                DispositionParam::Name(String::from("upload")),
798                DispositionParam::Filename(String::from("文件.webp")),
799            ],
800        };
801        assert_eq!(a, b);
802
803        let a = HeaderValue::from_str(
804            "form-data; name=upload; filename=\"余固知謇謇之為患兮,忍而不能舍也.pptx\"",
805        )
806        .unwrap();
807        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
808        let b = ContentDisposition {
809            disposition: DispositionType::FormData,
810            parameters: vec![
811                DispositionParam::Name(String::from("upload")),
812                DispositionParam::Filename(String::from("余固知謇謇之為患兮,忍而不能舍也.pptx")),
813            ],
814        };
815        assert_eq!(a, b);
816    }
817
818    #[test]
819    fn test_from_raw_escape() {
820        let a = HeaderValue::from_static(
821            "form-data; dummy=3; name=upload; filename=\"s\\amp\\\"le.png\"",
822        );
823        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
824        let b = ContentDisposition {
825            disposition: DispositionType::FormData,
826            parameters: vec![
827                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
828                DispositionParam::Name("upload".to_owned()),
829                DispositionParam::Filename(
830                    ['s', 'a', 'm', 'p', '\"', 'l', 'e', '.', 'p', 'n', 'g']
831                        .iter()
832                        .collect(),
833                ),
834            ],
835        };
836        assert_eq!(a, b);
837    }
838
839    #[test]
840    fn test_from_raw_semicolon() {
841        let a = HeaderValue::from_static("form-data; filename=\"A semicolon here;.pdf\"");
842        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
843        let b = ContentDisposition {
844            disposition: DispositionType::FormData,
845            parameters: vec![DispositionParam::Filename(String::from(
846                "A semicolon here;.pdf",
847            ))],
848        };
849        assert_eq!(a, b);
850    }
851
852    #[test]
853    fn test_from_raw_unnecessary_percent_decode() {
854        // In fact, RFC 7578 (multipart/form-data) Section 2 and 4.2 suggests that filename with
855        // non-ASCII characters MAY be percent-encoded.
856        // On the contrary, RFC 6266 or other RFCs related to Content-Disposition response header
857        // do not mention such percent-encoding.
858        // So, it appears to be undecidable whether to percent-decode or not without
859        // knowing the usage scenario (multipart/form-data v.s. HTTP response header) and
860        // inevitable to unnecessarily percent-decode filename with %XX in the former scenario.
861        // Fortunately, it seems that almost all mainstream browsers just send UTF-8 encoded file
862        // names in quoted-string format (tested on Edge, IE11, Chrome and Firefox) without
863        // percent-encoding. So we do not bother to attempt to percent-decode.
864        let a = HeaderValue::from_static(
865            "form-data; name=photo; filename=\"%74%65%73%74%2e%70%6e%67\"",
866        );
867        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
868        let b = ContentDisposition {
869            disposition: DispositionType::FormData,
870            parameters: vec![
871                DispositionParam::Name("photo".to_owned()),
872                DispositionParam::Filename(String::from("%74%65%73%74%2e%70%6e%67")),
873            ],
874        };
875        assert_eq!(a, b);
876
877        let a = HeaderValue::from_static("form-data; name=photo; filename=\"%74%65%73%74.png\"");
878        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
879        let b = ContentDisposition {
880            disposition: DispositionType::FormData,
881            parameters: vec![
882                DispositionParam::Name("photo".to_owned()),
883                DispositionParam::Filename(String::from("%74%65%73%74.png")),
884            ],
885        };
886        assert_eq!(a, b);
887    }
888
889    #[test]
890    fn test_from_raw_param_value_missing() {
891        let a = HeaderValue::from_static("form-data; name=upload ; filename=");
892        assert!(ContentDisposition::from_raw(&a).is_err());
893
894        let a = HeaderValue::from_static("attachment; dummy=; filename=invoice.pdf");
895        assert!(ContentDisposition::from_raw(&a).is_err());
896
897        let a = HeaderValue::from_static("inline; filename=  ");
898        assert!(ContentDisposition::from_raw(&a).is_err());
899
900        let a = HeaderValue::from_static("inline; filename=\"\"");
901        assert!(ContentDisposition::from_raw(&a)
902            .expect("parse cd")
903            .get_filename()
904            .expect("filename")
905            .is_empty());
906    }
907
908    #[test]
909    fn test_from_raw_param_name_missing() {
910        let a = HeaderValue::from_static("inline; =\"test.txt\"");
911        assert!(ContentDisposition::from_raw(&a).is_err());
912
913        let a = HeaderValue::from_static("inline; =diary.odt");
914        assert!(ContentDisposition::from_raw(&a).is_err());
915
916        let a = HeaderValue::from_static("inline; =");
917        assert!(ContentDisposition::from_raw(&a).is_err());
918    }
919
920    #[test]
921    fn test_display_extended() {
922        let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
923        let a = HeaderValue::from_static(as_string);
924        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
925        let display_rendered = format!("{}", a);
926        assert_eq!(as_string, display_rendered);
927
928        let a = HeaderValue::from_static("attachment; filename=colourful.csv");
929        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
930        let display_rendered = format!("{}", a);
931        assert_eq!(
932            "attachment; filename=\"colourful.csv\"".to_owned(),
933            display_rendered
934        );
935    }
936
937    #[test]
938    fn test_display_quote() {
939        let as_string = "form-data; name=upload; filename=\"Quote\\\"here.png\"";
940        as_string
941            .find(['\\', '\"'].iter().collect::<String>().as_str())
942            .unwrap(); // ensure `\"` is there
943        let a = HeaderValue::from_static(as_string);
944        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
945        let display_rendered = format!("{}", a);
946        assert_eq!(as_string, display_rendered);
947    }
948
949    #[test]
950    fn test_display_space_tab() {
951        let as_string = "form-data; name=upload; filename=\"Space here.png\"";
952        let a = HeaderValue::from_static(as_string);
953        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
954        let display_rendered = format!("{}", a);
955        assert_eq!(as_string, display_rendered);
956
957        let a: ContentDisposition = ContentDisposition {
958            disposition: DispositionType::Inline,
959            parameters: vec![DispositionParam::Filename(String::from("Tab\there.png"))],
960        };
961        let display_rendered = format!("{}", a);
962        assert_eq!("inline; filename=\"Tab\x09here.png\"", display_rendered);
963    }
964
965    #[test]
966    fn test_display_control_characters() {
967        /* let a = "attachment; filename=\"carriage\rreturn.png\"";
968        let a = HeaderValue::from_static(a);
969        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
970        let display_rendered = format!("{}", a);
971        assert_eq!(
972            "attachment; filename=\"carriage\\\rreturn.png\"",
973            display_rendered
974        );*/
975        // No way to create a HeaderValue containing a carriage return.
976
977        let a: ContentDisposition = ContentDisposition {
978            disposition: DispositionType::Inline,
979            parameters: vec![DispositionParam::Filename(String::from("bell\x07.png"))],
980        };
981        let display_rendered = format!("{}", a);
982        assert_eq!("inline; filename=\"bell\\\x07.png\"", display_rendered);
983    }
984
985    #[test]
986    fn test_param_methods() {
987        let param = DispositionParam::Filename(String::from("sample.txt"));
988        assert!(param.is_filename());
989        assert_eq!(param.as_filename().unwrap(), "sample.txt");
990
991        let param = DispositionParam::Unknown(String::from("foo"), String::from("bar"));
992        assert!(param.is_unknown("foo"));
993        assert_eq!(param.as_unknown("fOo"), Some("bar"));
994    }
995
996    #[test]
997    fn test_disposition_methods() {
998        let cd = ContentDisposition {
999            disposition: DispositionType::FormData,
1000            parameters: vec![
1001                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
1002                DispositionParam::Name("upload".to_owned()),
1003                DispositionParam::Filename("sample.png".to_owned()),
1004            ],
1005        };
1006        assert_eq!(cd.get_name(), Some("upload"));
1007        assert_eq!(cd.get_unknown("dummy"), Some("3"));
1008        assert_eq!(cd.get_filename(), Some("sample.png"));
1009        assert_eq!(cd.get_unknown_ext("dummy"), None);
1010        assert_eq!(cd.get_unknown("duMMy"), Some("3"));
1011    }
1012}