requiem_http/header/common/
content_disposition.rs

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