Skip to main content

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) => {
568                write!(f, "{name}=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
569            }
570
571            DispositionParam::FilenameExt(ref ext_value) => {
572                write!(f, "filename*={}", ext_value)
573            }
574
575            DispositionParam::UnknownExt(ref name, ref ext_value) => {
576                write!(f, "{}*={}", name, ext_value)
577            }
578        }
579    }
580}
581
582impl fmt::Display for ContentDisposition {
583    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584        write!(f, "{}", self.disposition)?;
585        self.parameters
586            .iter()
587            .try_for_each(|param| write!(f, "; {}", param))
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::{ContentDisposition, DispositionParam, DispositionType};
594    use crate::http::header::{Charset, ExtendedValue, HeaderValue};
595
596    #[test]
597    fn test_from_raw_basic() {
598        assert!(ContentDisposition::from_raw(&HeaderValue::from_static("")).is_err());
599
600        let a =
601            HeaderValue::from_static("form-data; dummy=3; name=upload; filename=\"sample.png\"");
602        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
603        let b = ContentDisposition {
604            disposition: DispositionType::FormData,
605            parameters: vec![
606                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
607                DispositionParam::Name("upload".to_owned()),
608                DispositionParam::Filename("sample.png".to_owned()),
609            ],
610        };
611        assert_eq!(a, b);
612
613        let a = HeaderValue::from_static("attachment; filename=\"image.jpg\"");
614        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
615        let b = ContentDisposition {
616            disposition: DispositionType::Attachment,
617            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
618        };
619        assert_eq!(a, b);
620
621        let a = HeaderValue::from_static("inline; filename=image.jpg");
622        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
623        let b = ContentDisposition {
624            disposition: DispositionType::Inline,
625            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
626        };
627        assert_eq!(a, b);
628
629        let a = HeaderValue::from_static(
630            "attachment; creation-date=\"Wed, 12 Feb 1997 16:29:51 -0500\"",
631        );
632        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
633        let b = ContentDisposition {
634            disposition: DispositionType::Attachment,
635            parameters: vec![DispositionParam::Unknown(
636                String::from("creation-date"),
637                "Wed, 12 Feb 1997 16:29:51 -0500".to_owned(),
638            )],
639        };
640        assert_eq!(a, b);
641    }
642
643    #[test]
644    fn test_from_raw_extended() {
645        let a = HeaderValue::from_static(
646            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
647        );
648        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
649        let b = ContentDisposition {
650            disposition: DispositionType::Attachment,
651            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
652                charset: Charset::Ext(String::from("UTF-8")),
653                language_tag: None,
654                value: vec![
655                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
656                    b't', b'e', b's',
657                ],
658            })],
659        };
660        assert_eq!(a, b);
661
662        let a = HeaderValue::from_static(
663            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
664        );
665        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
666        let b = ContentDisposition {
667            disposition: DispositionType::Attachment,
668            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
669                charset: Charset::Ext(String::from("UTF-8")),
670                language_tag: None,
671                value: vec![
672                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
673                    b't', b'e', b's',
674                ],
675            })],
676        };
677        assert_eq!(a, b);
678    }
679
680    #[test]
681    fn test_from_raw_extra_whitespace() {
682        let a = HeaderValue::from_static(
683            "form-data  ; du-mmy= 3  ; name =upload ; filename =  \"sample.png\"  ; ",
684        );
685        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
686        let b = ContentDisposition {
687            disposition: DispositionType::FormData,
688            parameters: vec![
689                DispositionParam::Unknown("du-mmy".to_owned(), "3".to_owned()),
690                DispositionParam::Name("upload".to_owned()),
691                DispositionParam::Filename("sample.png".to_owned()),
692            ],
693        };
694        assert_eq!(a, b);
695    }
696
697    #[test]
698    fn test_from_raw_unordered() {
699        let a = HeaderValue::from_static(
700            "form-data; dummy=3; filename=\"sample.png\" ; name=upload;",
701            // Actually, a trailing semicolon is not compliant. But it is fine to accept.
702        );
703        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
704        let b = ContentDisposition {
705            disposition: DispositionType::FormData,
706            parameters: vec![
707                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
708                DispositionParam::Filename("sample.png".to_owned()),
709                DispositionParam::Name("upload".to_owned()),
710            ],
711        };
712        assert_eq!(a, b);
713
714        let a = HeaderValue::from_str(
715            "attachment; filename*=iso-8859-1''foo-%E4.html; filename=\"foo-ä.html\"",
716        )
717        .unwrap();
718        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
719        let b = ContentDisposition {
720            disposition: DispositionType::Attachment,
721            parameters: vec![
722                DispositionParam::FilenameExt(ExtendedValue {
723                    charset: Charset::Iso_8859_1,
724                    language_tag: None,
725                    value: b"foo-\xe4.html".to_vec(),
726                }),
727                DispositionParam::Filename("foo-ä.html".to_owned()),
728            ],
729        };
730        assert_eq!(a, b);
731    }
732
733    #[test]
734    fn test_from_raw_only_disp() {
735        let a = ContentDisposition::from_raw(&HeaderValue::from_static("attachment")).unwrap();
736        let b = ContentDisposition {
737            disposition: DispositionType::Attachment,
738            parameters: vec![],
739        };
740        assert_eq!(a, b);
741
742        let a = ContentDisposition::from_raw(&HeaderValue::from_static("inline ;")).unwrap();
743        let b = ContentDisposition {
744            disposition: DispositionType::Inline,
745            parameters: vec![],
746        };
747        assert_eq!(a, b);
748
749        let a =
750            ContentDisposition::from_raw(&HeaderValue::from_static("unknown-disp-param")).unwrap();
751        let b = ContentDisposition {
752            disposition: DispositionType::Ext(String::from("unknown-disp-param")),
753            parameters: vec![],
754        };
755        assert_eq!(a, b);
756    }
757
758    #[test]
759    fn from_raw_with_mixed_case() {
760        let a = HeaderValue::from_str(
761            "InLInE; fIlenAME*=iso-8859-1''foo-%E4.html; filEName=\"foo-ä.html\"",
762        )
763        .unwrap();
764        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
765        let b = ContentDisposition {
766            disposition: DispositionType::Inline,
767            parameters: vec![
768                DispositionParam::FilenameExt(ExtendedValue {
769                    charset: Charset::Iso_8859_1,
770                    language_tag: None,
771                    value: b"foo-\xe4.html".to_vec(),
772                }),
773                DispositionParam::Filename("foo-ä.html".to_owned()),
774            ],
775        };
776        assert_eq!(a, b);
777    }
778
779    #[test]
780    fn from_raw_with_unicode() {
781        /* RFC 7578 Section 4.2:
782        Some commonly deployed systems use multipart/form-data with file names directly encoded
783        including octets outside the US-ASCII range. The encoding used for the file names is
784        typically UTF-8, although HTML forms will use the charset associated with the form.
785
786        Mainstream browsers like Firefox (gecko) and Chrome use UTF-8 directly as above.
787        (And now, only UTF-8 is handled by this implementation.)
788        */
789        let a = HeaderValue::from_str("form-data; name=upload; filename=\"文件.webp\"").unwrap();
790        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
791        let b = ContentDisposition {
792            disposition: DispositionType::FormData,
793            parameters: vec![
794                DispositionParam::Name(String::from("upload")),
795                DispositionParam::Filename(String::from("文件.webp")),
796            ],
797        };
798        assert_eq!(a, b);
799
800        let a = HeaderValue::from_str(
801            "form-data; name=upload; filename=\"余固知謇謇之為患兮,忍而不能舍也.pptx\"",
802        )
803        .unwrap();
804        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
805        let b = ContentDisposition {
806            disposition: DispositionType::FormData,
807            parameters: vec![
808                DispositionParam::Name(String::from("upload")),
809                DispositionParam::Filename(String::from("余固知謇謇之為患兮,忍而不能舍也.pptx")),
810            ],
811        };
812        assert_eq!(a, b);
813    }
814
815    #[test]
816    fn test_from_raw_escape() {
817        let a = HeaderValue::from_static(
818            "form-data; dummy=3; name=upload; filename=\"s\\amp\\\"le.png\"",
819        );
820        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
821        let b = ContentDisposition {
822            disposition: DispositionType::FormData,
823            parameters: vec![
824                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
825                DispositionParam::Name("upload".to_owned()),
826                DispositionParam::Filename(
827                    ['s', 'a', 'm', 'p', '\"', 'l', 'e', '.', 'p', 'n', 'g']
828                        .iter()
829                        .collect(),
830                ),
831            ],
832        };
833        assert_eq!(a, b);
834    }
835
836    #[test]
837    fn test_from_raw_semicolon() {
838        let a = HeaderValue::from_static("form-data; filename=\"A semicolon here;.pdf\"");
839        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
840        let b = ContentDisposition {
841            disposition: DispositionType::FormData,
842            parameters: vec![DispositionParam::Filename(String::from(
843                "A semicolon here;.pdf",
844            ))],
845        };
846        assert_eq!(a, b);
847    }
848
849    #[test]
850    fn test_from_raw_unnecessary_percent_decode() {
851        // In fact, RFC 7578 (multipart/form-data) Section 2 and 4.2 suggests that filename with
852        // non-ASCII characters MAY be percent-encoded.
853        // On the contrary, RFC 6266 or other RFCs related to Content-Disposition response header
854        // do not mention such percent-encoding.
855        // So, it appears to be undecidable whether to percent-decode or not without
856        // knowing the usage scenario (multipart/form-data v.s. HTTP response header) and
857        // inevitable to unnecessarily percent-decode filename with %XX in the former scenario.
858        // Fortunately, it seems that almost all mainstream browsers just send UTF-8 encoded file
859        // names in quoted-string format (tested on Edge, IE11, Chrome and Firefox) without
860        // percent-encoding. So we do not bother to attempt to percent-decode.
861        let a = HeaderValue::from_static(
862            "form-data; name=photo; filename=\"%74%65%73%74%2e%70%6e%67\"",
863        );
864        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
865        let b = ContentDisposition {
866            disposition: DispositionType::FormData,
867            parameters: vec![
868                DispositionParam::Name("photo".to_owned()),
869                DispositionParam::Filename(String::from("%74%65%73%74%2e%70%6e%67")),
870            ],
871        };
872        assert_eq!(a, b);
873
874        let a = HeaderValue::from_static("form-data; name=photo; filename=\"%74%65%73%74.png\"");
875        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
876        let b = ContentDisposition {
877            disposition: DispositionType::FormData,
878            parameters: vec![
879                DispositionParam::Name("photo".to_owned()),
880                DispositionParam::Filename(String::from("%74%65%73%74.png")),
881            ],
882        };
883        assert_eq!(a, b);
884    }
885
886    #[test]
887    fn test_from_raw_param_value_missing() {
888        let a = HeaderValue::from_static("form-data; name=upload ; filename=");
889        assert!(ContentDisposition::from_raw(&a).is_err());
890
891        let a = HeaderValue::from_static("attachment; dummy=; filename=invoice.pdf");
892        assert!(ContentDisposition::from_raw(&a).is_err());
893
894        let a = HeaderValue::from_static("inline; filename=  ");
895        assert!(ContentDisposition::from_raw(&a).is_err());
896
897        let a = HeaderValue::from_static("inline; filename=\"\"");
898        assert!(ContentDisposition::from_raw(&a)
899            .expect("parse cd")
900            .get_filename()
901            .expect("filename")
902            .is_empty());
903    }
904
905    #[test]
906    fn test_from_raw_param_name_missing() {
907        let a = HeaderValue::from_static("inline; =\"test.txt\"");
908        assert!(ContentDisposition::from_raw(&a).is_err());
909
910        let a = HeaderValue::from_static("inline; =diary.odt");
911        assert!(ContentDisposition::from_raw(&a).is_err());
912
913        let a = HeaderValue::from_static("inline; =");
914        assert!(ContentDisposition::from_raw(&a).is_err());
915    }
916
917    #[test]
918    fn test_display_extended() {
919        let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
920        let a = HeaderValue::from_static(as_string);
921        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
922        let display_rendered = format!("{}", a);
923        assert_eq!(as_string, display_rendered);
924
925        let a = HeaderValue::from_static("attachment; filename=colourful.csv");
926        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
927        let display_rendered = format!("{}", a);
928        assert_eq!(
929            "attachment; filename=\"colourful.csv\"".to_owned(),
930            display_rendered
931        );
932    }
933
934    #[test]
935    fn test_display_quote() {
936        let as_string = "form-data; name=upload; filename=\"Quote\\\"here.png\"";
937        as_string
938            .find(['\\', '\"'].iter().collect::<String>().as_str())
939            .unwrap(); // ensure `\"` is there
940        let a = HeaderValue::from_static(as_string);
941        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
942        let display_rendered = format!("{}", a);
943        assert_eq!(as_string, display_rendered);
944    }
945
946    #[test]
947    fn test_display_space_tab() {
948        let as_string = "form-data; name=upload; filename=\"Space here.png\"";
949        let a = HeaderValue::from_static(as_string);
950        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
951        let display_rendered = format!("{}", a);
952        assert_eq!(as_string, display_rendered);
953
954        let a: ContentDisposition = ContentDisposition {
955            disposition: DispositionType::Inline,
956            parameters: vec![DispositionParam::Filename(String::from("Tab\there.png"))],
957        };
958        let display_rendered = format!("{}", a);
959        assert_eq!("inline; filename=\"Tab\x09here.png\"", display_rendered);
960    }
961
962    #[test]
963    fn test_display_control_characters() {
964        /* let a = "attachment; filename=\"carriage\rreturn.png\"";
965        let a = HeaderValue::from_static(a);
966        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
967        let display_rendered = format!("{}", a);
968        assert_eq!(
969            "attachment; filename=\"carriage\\\rreturn.png\"",
970            display_rendered
971        );*/
972        // No way to create a HeaderValue containing a carriage return.
973
974        let a: ContentDisposition = ContentDisposition {
975            disposition: DispositionType::Inline,
976            parameters: vec![DispositionParam::Filename(String::from("bell\x07.png"))],
977        };
978        let display_rendered = format!("{}", a);
979        assert_eq!("inline; filename=\"bell\\\x07.png\"", display_rendered);
980    }
981
982    #[test]
983    fn test_param_methods() {
984        let param = DispositionParam::Filename(String::from("sample.txt"));
985        assert!(param.is_filename());
986        assert_eq!(param.as_filename().unwrap(), "sample.txt");
987
988        let param = DispositionParam::Unknown(String::from("foo"), String::from("bar"));
989        assert!(param.is_unknown("foo"));
990        assert_eq!(param.as_unknown("fOo"), Some("bar"));
991    }
992
993    #[test]
994    fn test_disposition_methods() {
995        let cd = ContentDisposition {
996            disposition: DispositionType::FormData,
997            parameters: vec![
998                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
999                DispositionParam::Name("upload".to_owned()),
1000                DispositionParam::Filename("sample.png".to_owned()),
1001            ],
1002        };
1003        assert_eq!(cd.get_name(), Some("upload"));
1004        assert_eq!(cd.get_unknown("dummy"), Some("3"));
1005        assert_eq!(cd.get_filename(), Some("sample.png"));
1006        assert_eq!(cd.get_unknown_ext("dummy"), None);
1007        assert_eq!(cd.get_unknown("duMMy"), Some("3"));
1008    }
1009}