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    /// Includes a `filename` parameter and adds a UTF-8 encoded `filename*` parameter if the
326    /// filename contains non-ASCII characters.
327    ///
328    /// # Examples
329    /// ```
330    /// use actix_web::http::header::{ContentDisposition, TryIntoHeaderValue as _};
331    ///
332    /// let cd = ContentDisposition::attachment("files.zip");
333    ///
334    /// let cd_val = cd.try_into_value().unwrap();
335    /// assert_eq!(cd_val, "attachment; filename=\"files.zip\"");
336    /// ```
337    pub fn attachment(filename: impl Into<String>) -> Self {
338        let filename = filename.into();
339        let is_ascii = filename.is_ascii();
340
341        let mut parameters = Vec::new();
342
343        if is_ascii {
344            parameters.push(DispositionParam::Filename(filename));
345        } else {
346            parameters.push(DispositionParam::Filename(filename.clone()));
347            parameters.push(DispositionParam::FilenameExt(ExtendedValue {
348                charset: header::Charset::Ext(String::from("UTF-8")),
349                language_tag: None,
350                value: filename.into_bytes(),
351            }));
352        }
353
354        Self {
355            disposition: DispositionType::Attachment,
356            parameters,
357        }
358    }
359
360    /// Parse a raw Content-Disposition header value.
361    pub fn from_raw(hv: &header::HeaderValue) -> Result<Self, crate::error::ParseError> {
362        // `header::from_one_raw_str` invokes `hv.to_str` which assumes `hv` contains only visible
363        //  ASCII characters. So `hv.as_bytes` is necessary here.
364        let hv = String::from_utf8(hv.as_bytes().to_vec())
365            .map_err(|_| crate::error::ParseError::Header)?;
366
367        let (disp_type, mut left) = split_once_and_trim(hv.as_str().trim(), ';');
368        if disp_type.is_empty() {
369            return Err(crate::error::ParseError::Header);
370        }
371
372        let mut cd = ContentDisposition {
373            disposition: disp_type.into(),
374            parameters: Vec::new(),
375        };
376
377        while !left.is_empty() {
378            let (param_name, new_left) = split_once_and_trim(left, '=');
379            if param_name.is_empty() || param_name == "*" || new_left.is_empty() {
380                return Err(crate::error::ParseError::Header);
381            }
382            left = new_left;
383            if let Some(param_name) = param_name.strip_suffix('*') {
384                // extended parameters
385                let (ext_value, new_left) = split_once_and_trim(left, ';');
386                left = new_left;
387                let ext_value = header::parse_extended_value(ext_value)?;
388
389                let param = if param_name.eq_ignore_ascii_case("filename") {
390                    DispositionParam::FilenameExt(ext_value)
391                } else {
392                    DispositionParam::UnknownExt(param_name.to_owned(), ext_value)
393                };
394                cd.parameters.push(param);
395            } else {
396                // regular parameters
397                let value = if left.starts_with('\"') {
398                    // quoted-string: defined in RFC 6266 -> RFC 2616 Section 3.6
399                    let mut escaping = false;
400                    let mut quoted_string = vec![];
401                    let mut end = None;
402                    // search for closing quote
403                    for (i, &c) in left.as_bytes().iter().skip(1).enumerate() {
404                        if escaping {
405                            escaping = false;
406                            quoted_string.push(c);
407                        } else if c == 0x5c {
408                            // backslash
409                            escaping = true;
410                        } else if c == 0x22 {
411                            // double quote
412                            end = Some(i + 1); // cuz skipped 1 for the leading quote
413                            break;
414                        } else {
415                            quoted_string.push(c);
416                        }
417                    }
418                    left = &left[end.ok_or(crate::error::ParseError::Header)? + 1..];
419                    left = split_once(left, ';').1.trim_start();
420                    // In fact, it should not be Err if the above code is correct.
421                    String::from_utf8(quoted_string)
422                        .map_err(|_| crate::error::ParseError::Header)?
423                } else {
424                    // token: won't contains semicolon according to RFC 2616 Section 2.2
425                    let (token, new_left) = split_once_and_trim(left, ';');
426                    left = new_left;
427                    if token.is_empty() {
428                        // quoted-string can be empty, but token cannot be empty
429                        return Err(crate::error::ParseError::Header);
430                    }
431                    token.to_owned()
432                };
433
434                let param = if param_name.eq_ignore_ascii_case("name") {
435                    DispositionParam::Name(value)
436                } else if param_name.eq_ignore_ascii_case("filename") {
437                    // See also comments in test_from_raw_unnecessary_percent_decode.
438                    DispositionParam::Filename(value)
439                } else {
440                    DispositionParam::Unknown(param_name.to_owned(), value)
441                };
442                cd.parameters.push(param);
443            }
444        }
445
446        Ok(cd)
447    }
448
449    /// Returns `true` if type is [`Inline`](DispositionType::Inline).
450    pub fn is_inline(&self) -> bool {
451        matches!(self.disposition, DispositionType::Inline)
452    }
453
454    /// Returns `true` if type is [`Attachment`](DispositionType::Attachment).
455    pub fn is_attachment(&self) -> bool {
456        matches!(self.disposition, DispositionType::Attachment)
457    }
458
459    /// Returns `true` if type is [`FormData`](DispositionType::FormData).
460    pub fn is_form_data(&self) -> bool {
461        matches!(self.disposition, DispositionType::FormData)
462    }
463
464    /// Returns `true` if type is [`Ext`](DispositionType::Ext) and the `disp_type` matches.
465    pub fn is_ext(&self, disp_type: impl AsRef<str>) -> bool {
466        matches!(
467            self.disposition,
468            DispositionType::Ext(ref t) if t.eq_ignore_ascii_case(disp_type.as_ref())
469        )
470    }
471
472    /// Return the value of *name* if exists.
473    pub fn get_name(&self) -> Option<&str> {
474        self.parameters.iter().find_map(DispositionParam::as_name)
475    }
476
477    /// Return the value of *filename* if exists.
478    pub fn get_filename(&self) -> Option<&str> {
479        self.parameters
480            .iter()
481            .find_map(DispositionParam::as_filename)
482    }
483
484    /// Return the value of *filename\** if exists.
485    pub fn get_filename_ext(&self) -> Option<&ExtendedValue> {
486        self.parameters
487            .iter()
488            .find_map(DispositionParam::as_filename_ext)
489    }
490
491    /// Return the value of the parameter which the `name` matches.
492    pub fn get_unknown(&self, name: impl AsRef<str>) -> Option<&str> {
493        let name = name.as_ref();
494        self.parameters.iter().find_map(|p| p.as_unknown(name))
495    }
496
497    /// Return the value of the extended parameter which the `name` matches.
498    pub fn get_unknown_ext(&self, name: impl AsRef<str>) -> Option<&ExtendedValue> {
499        let name = name.as_ref();
500        self.parameters.iter().find_map(|p| p.as_unknown_ext(name))
501    }
502}
503
504impl TryIntoHeaderValue for ContentDisposition {
505    type Error = header::InvalidHeaderValue;
506
507    fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
508        let mut writer = Writer::new();
509        let _ = write!(&mut writer, "{}", self);
510        header::HeaderValue::from_maybe_shared(writer.take())
511    }
512}
513
514impl Header for ContentDisposition {
515    fn name() -> header::HeaderName {
516        header::CONTENT_DISPOSITION
517    }
518
519    fn parse<T: crate::HttpMessage>(msg: &T) -> Result<Self, crate::error::ParseError> {
520        if let Some(h) = msg.headers().get(Self::name()) {
521            Self::from_raw(h)
522        } else {
523            Err(crate::error::ParseError::Header)
524        }
525    }
526}
527
528impl fmt::Display for DispositionType {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        match self {
531            DispositionType::Inline => write!(f, "inline"),
532            DispositionType::Attachment => write!(f, "attachment"),
533            DispositionType::FormData => write!(f, "form-data"),
534            DispositionType::Ext(ref s) => write!(f, "{}", s),
535        }
536    }
537}
538
539impl fmt::Display for DispositionParam {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        // All ASCII control characters (0-30, 127) including horizontal tab, double quote, and
542        // backslash should be escaped in quoted-string (i.e. "foobar").
543        //
544        // Ref: RFC 6266 §4.1 -> RFC 2616 §3.6
545        //
546        // filename-parm  = "filename" "=" value
547        // value          = token | quoted-string
548        // quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
549        // qdtext         = <any TEXT except <">>
550        // quoted-pair    = "\" CHAR
551        // TEXT           = <any OCTET except CTLs,
552        //                  but including LWS>
553        // LWS            = [CRLF] 1*( SP | HT )
554        // OCTET          = <any 8-bit sequence of data>
555        // CHAR           = <any US-ASCII character (octets 0 - 127)>
556        // CTL            = <any US-ASCII control character
557        //                  (octets 0 - 31) and DEL (127)>
558        //
559        // Ref: RFC 7578 S4.2 -> RFC 2183 S2 -> RFC 2045 S5.1
560        // parameter := attribute "=" value
561        // attribute := token
562        //              ; Matching of attributes
563        //              ; is ALWAYS case-insensitive.
564        // value := token / quoted-string
565        // token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
566        //             or tspecials>
567        // tspecials :=  "(" / ")" / "<" / ">" / "@" /
568        //               "," / ";" / ":" / "\" / <">
569        //               "/" / "[" / "]" / "?" / "="
570        //               ; Must be in quoted-string,
571        //               ; to use within parameter values
572        //
573        //
574        // See also comments in test_from_raw_unnecessary_percent_decode.
575
576        static RE: Lazy<Regex> =
577            Lazy::new(|| Regex::new("[\x00-\x08\x10-\x1F\x7F\"\\\\]").unwrap());
578
579        match self {
580            DispositionParam::Name(ref value) => write!(f, "name={}", value),
581
582            DispositionParam::Filename(ref value) => {
583                write!(f, "filename=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
584            }
585
586            DispositionParam::Unknown(ref name, ref value) => {
587                write!(f, "{name}=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
588            }
589
590            DispositionParam::FilenameExt(ref ext_value) => {
591                write!(f, "filename*={}", ext_value)
592            }
593
594            DispositionParam::UnknownExt(ref name, ref ext_value) => {
595                write!(f, "{}*={}", name, ext_value)
596            }
597        }
598    }
599}
600
601impl fmt::Display for ContentDisposition {
602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603        write!(f, "{}", self.disposition)?;
604        self.parameters
605            .iter()
606            .try_for_each(|param| write!(f, "; {}", param))
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::{ContentDisposition, DispositionParam, DispositionType};
613    use crate::http::header::{Charset, ExtendedValue, HeaderValue, TryIntoHeaderValue as _};
614
615    #[test]
616    fn test_attachment_ascii() {
617        let header = ContentDisposition::attachment("file name.zip")
618            .try_into_value()
619            .unwrap();
620
621        assert_eq!(header, "attachment; filename=\"file name.zip\"");
622    }
623
624    #[test]
625    fn test_attachment_non_ascii() {
626        let header = ContentDisposition::attachment("café.pdf")
627            .try_into_value()
628            .unwrap();
629
630        assert_eq!(
631            header.as_bytes(),
632            "attachment; filename=\"café.pdf\"; filename*=UTF-8''caf%C3%A9.pdf".as_bytes()
633        );
634    }
635
636    #[test]
637    fn test_from_raw_basic() {
638        assert!(ContentDisposition::from_raw(&HeaderValue::from_static("")).is_err());
639
640        let a =
641            HeaderValue::from_static("form-data; dummy=3; name=upload; filename=\"sample.png\"");
642        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
643        let b = ContentDisposition {
644            disposition: DispositionType::FormData,
645            parameters: vec![
646                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
647                DispositionParam::Name("upload".to_owned()),
648                DispositionParam::Filename("sample.png".to_owned()),
649            ],
650        };
651        assert_eq!(a, b);
652
653        let a = HeaderValue::from_static("attachment; filename=\"image.jpg\"");
654        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
655        let b = ContentDisposition {
656            disposition: DispositionType::Attachment,
657            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
658        };
659        assert_eq!(a, b);
660
661        let a = HeaderValue::from_static("inline; filename=image.jpg");
662        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
663        let b = ContentDisposition {
664            disposition: DispositionType::Inline,
665            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
666        };
667        assert_eq!(a, b);
668
669        let a = HeaderValue::from_static(
670            "attachment; creation-date=\"Wed, 12 Feb 1997 16:29:51 -0500\"",
671        );
672        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
673        let b = ContentDisposition {
674            disposition: DispositionType::Attachment,
675            parameters: vec![DispositionParam::Unknown(
676                String::from("creation-date"),
677                "Wed, 12 Feb 1997 16:29:51 -0500".to_owned(),
678            )],
679        };
680        assert_eq!(a, b);
681    }
682
683    #[test]
684    fn test_from_raw_extended() {
685        let a = HeaderValue::from_static(
686            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
687        );
688        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
689        let b = ContentDisposition {
690            disposition: DispositionType::Attachment,
691            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
692                charset: Charset::Ext(String::from("UTF-8")),
693                language_tag: None,
694                value: vec![
695                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
696                    b't', b'e', b's',
697                ],
698            })],
699        };
700        assert_eq!(a, b);
701
702        let a = HeaderValue::from_static(
703            "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
704        );
705        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
706        let b = ContentDisposition {
707            disposition: DispositionType::Attachment,
708            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
709                charset: Charset::Ext(String::from("UTF-8")),
710                language_tag: None,
711                value: vec![
712                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r', b'a',
713                    b't', b'e', b's',
714                ],
715            })],
716        };
717        assert_eq!(a, b);
718    }
719
720    #[test]
721    fn test_from_raw_extra_whitespace() {
722        let a = HeaderValue::from_static(
723            "form-data  ; du-mmy= 3  ; name =upload ; filename =  \"sample.png\"  ; ",
724        );
725        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
726        let b = ContentDisposition {
727            disposition: DispositionType::FormData,
728            parameters: vec![
729                DispositionParam::Unknown("du-mmy".to_owned(), "3".to_owned()),
730                DispositionParam::Name("upload".to_owned()),
731                DispositionParam::Filename("sample.png".to_owned()),
732            ],
733        };
734        assert_eq!(a, b);
735    }
736
737    #[test]
738    fn test_from_raw_unordered() {
739        let a = HeaderValue::from_static(
740            "form-data; dummy=3; filename=\"sample.png\" ; name=upload;",
741            // Actually, a trailing semicolon is not compliant. But it is fine to accept.
742        );
743        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
744        let b = ContentDisposition {
745            disposition: DispositionType::FormData,
746            parameters: vec![
747                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
748                DispositionParam::Filename("sample.png".to_owned()),
749                DispositionParam::Name("upload".to_owned()),
750            ],
751        };
752        assert_eq!(a, b);
753
754        let a = HeaderValue::from_str(
755            "attachment; filename*=iso-8859-1''foo-%E4.html; filename=\"foo-ä.html\"",
756        )
757        .unwrap();
758        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
759        let b = ContentDisposition {
760            disposition: DispositionType::Attachment,
761            parameters: vec![
762                DispositionParam::FilenameExt(ExtendedValue {
763                    charset: Charset::Iso_8859_1,
764                    language_tag: None,
765                    value: b"foo-\xe4.html".to_vec(),
766                }),
767                DispositionParam::Filename("foo-ä.html".to_owned()),
768            ],
769        };
770        assert_eq!(a, b);
771    }
772
773    #[test]
774    fn test_from_raw_only_disp() {
775        let a = ContentDisposition::from_raw(&HeaderValue::from_static("attachment")).unwrap();
776        let b = ContentDisposition {
777            disposition: DispositionType::Attachment,
778            parameters: vec![],
779        };
780        assert_eq!(a, b);
781
782        let a = ContentDisposition::from_raw(&HeaderValue::from_static("inline ;")).unwrap();
783        let b = ContentDisposition {
784            disposition: DispositionType::Inline,
785            parameters: vec![],
786        };
787        assert_eq!(a, b);
788
789        let a =
790            ContentDisposition::from_raw(&HeaderValue::from_static("unknown-disp-param")).unwrap();
791        let b = ContentDisposition {
792            disposition: DispositionType::Ext(String::from("unknown-disp-param")),
793            parameters: vec![],
794        };
795        assert_eq!(a, b);
796    }
797
798    #[test]
799    fn from_raw_with_mixed_case() {
800        let a = HeaderValue::from_str(
801            "InLInE; fIlenAME*=iso-8859-1''foo-%E4.html; filEName=\"foo-ä.html\"",
802        )
803        .unwrap();
804        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
805        let b = ContentDisposition {
806            disposition: DispositionType::Inline,
807            parameters: vec![
808                DispositionParam::FilenameExt(ExtendedValue {
809                    charset: Charset::Iso_8859_1,
810                    language_tag: None,
811                    value: b"foo-\xe4.html".to_vec(),
812                }),
813                DispositionParam::Filename("foo-ä.html".to_owned()),
814            ],
815        };
816        assert_eq!(a, b);
817    }
818
819    #[test]
820    fn from_raw_with_unicode() {
821        /* RFC 7578 Section 4.2:
822        Some commonly deployed systems use multipart/form-data with file names directly encoded
823        including octets outside the US-ASCII range. The encoding used for the file names is
824        typically UTF-8, although HTML forms will use the charset associated with the form.
825
826        Mainstream browsers like Firefox (gecko) and Chrome use UTF-8 directly as above.
827        (And now, only UTF-8 is handled by this implementation.)
828        */
829        let a = HeaderValue::from_str("form-data; name=upload; filename=\"文件.webp\"").unwrap();
830        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
831        let b = ContentDisposition {
832            disposition: DispositionType::FormData,
833            parameters: vec![
834                DispositionParam::Name(String::from("upload")),
835                DispositionParam::Filename(String::from("文件.webp")),
836            ],
837        };
838        assert_eq!(a, b);
839
840        let a = HeaderValue::from_str(
841            "form-data; name=upload; filename=\"余固知謇謇之為患兮,忍而不能舍也.pptx\"",
842        )
843        .unwrap();
844        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
845        let b = ContentDisposition {
846            disposition: DispositionType::FormData,
847            parameters: vec![
848                DispositionParam::Name(String::from("upload")),
849                DispositionParam::Filename(String::from("余固知謇謇之為患兮,忍而不能舍也.pptx")),
850            ],
851        };
852        assert_eq!(a, b);
853    }
854
855    #[test]
856    fn test_from_raw_escape() {
857        let a = HeaderValue::from_static(
858            "form-data; dummy=3; name=upload; filename=\"s\\amp\\\"le.png\"",
859        );
860        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
861        let b = ContentDisposition {
862            disposition: DispositionType::FormData,
863            parameters: vec![
864                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
865                DispositionParam::Name("upload".to_owned()),
866                DispositionParam::Filename(
867                    ['s', 'a', 'm', 'p', '\"', 'l', 'e', '.', 'p', 'n', 'g']
868                        .iter()
869                        .collect(),
870                ),
871            ],
872        };
873        assert_eq!(a, b);
874    }
875
876    #[test]
877    fn test_from_raw_semicolon() {
878        let a = HeaderValue::from_static("form-data; filename=\"A semicolon here;.pdf\"");
879        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
880        let b = ContentDisposition {
881            disposition: DispositionType::FormData,
882            parameters: vec![DispositionParam::Filename(String::from(
883                "A semicolon here;.pdf",
884            ))],
885        };
886        assert_eq!(a, b);
887    }
888
889    #[test]
890    fn test_from_raw_unnecessary_percent_decode() {
891        // In fact, RFC 7578 (multipart/form-data) Section 2 and 4.2 suggests that filename with
892        // non-ASCII characters MAY be percent-encoded.
893        // On the contrary, RFC 6266 or other RFCs related to Content-Disposition response header
894        // do not mention such percent-encoding.
895        // So, it appears to be undecidable whether to percent-decode or not without
896        // knowing the usage scenario (multipart/form-data v.s. HTTP response header) and
897        // inevitable to unnecessarily percent-decode filename with %XX in the former scenario.
898        // Fortunately, it seems that almost all mainstream browsers just send UTF-8 encoded file
899        // names in quoted-string format (tested on Edge, IE11, Chrome and Firefox) without
900        // percent-encoding. So we do not bother to attempt to percent-decode.
901        let a = HeaderValue::from_static(
902            "form-data; name=photo; filename=\"%74%65%73%74%2e%70%6e%67\"",
903        );
904        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
905        let b = ContentDisposition {
906            disposition: DispositionType::FormData,
907            parameters: vec![
908                DispositionParam::Name("photo".to_owned()),
909                DispositionParam::Filename(String::from("%74%65%73%74%2e%70%6e%67")),
910            ],
911        };
912        assert_eq!(a, b);
913
914        let a = HeaderValue::from_static("form-data; name=photo; filename=\"%74%65%73%74.png\"");
915        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
916        let b = ContentDisposition {
917            disposition: DispositionType::FormData,
918            parameters: vec![
919                DispositionParam::Name("photo".to_owned()),
920                DispositionParam::Filename(String::from("%74%65%73%74.png")),
921            ],
922        };
923        assert_eq!(a, b);
924    }
925
926    #[test]
927    fn test_from_raw_param_value_missing() {
928        let a = HeaderValue::from_static("form-data; name=upload ; filename=");
929        assert!(ContentDisposition::from_raw(&a).is_err());
930
931        let a = HeaderValue::from_static("attachment; dummy=; filename=invoice.pdf");
932        assert!(ContentDisposition::from_raw(&a).is_err());
933
934        let a = HeaderValue::from_static("inline; filename=  ");
935        assert!(ContentDisposition::from_raw(&a).is_err());
936
937        let a = HeaderValue::from_static("inline; filename=\"\"");
938        assert!(ContentDisposition::from_raw(&a)
939            .expect("parse cd")
940            .get_filename()
941            .expect("filename")
942            .is_empty());
943    }
944
945    #[test]
946    fn test_from_raw_param_name_missing() {
947        let a = HeaderValue::from_static("inline; =\"test.txt\"");
948        assert!(ContentDisposition::from_raw(&a).is_err());
949
950        let a = HeaderValue::from_static("inline; =diary.odt");
951        assert!(ContentDisposition::from_raw(&a).is_err());
952
953        let a = HeaderValue::from_static("inline; =");
954        assert!(ContentDisposition::from_raw(&a).is_err());
955    }
956
957    #[test]
958    fn test_display_extended() {
959        let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
960        let a = HeaderValue::from_static(as_string);
961        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
962        let display_rendered = format!("{}", a);
963        assert_eq!(as_string, display_rendered);
964
965        let a = HeaderValue::from_static("attachment; filename=colourful.csv");
966        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
967        let display_rendered = format!("{}", a);
968        assert_eq!(
969            "attachment; filename=\"colourful.csv\"".to_owned(),
970            display_rendered
971        );
972    }
973
974    #[test]
975    fn test_display_quote() {
976        let as_string = "form-data; name=upload; filename=\"Quote\\\"here.png\"";
977        as_string
978            .find(['\\', '\"'].iter().collect::<String>().as_str())
979            .unwrap(); // ensure `\"` is there
980        let a = HeaderValue::from_static(as_string);
981        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
982        let display_rendered = format!("{}", a);
983        assert_eq!(as_string, display_rendered);
984    }
985
986    #[test]
987    fn test_display_space_tab() {
988        let as_string = "form-data; name=upload; filename=\"Space here.png\"";
989        let a = HeaderValue::from_static(as_string);
990        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
991        let display_rendered = format!("{}", a);
992        assert_eq!(as_string, display_rendered);
993
994        let a: ContentDisposition = ContentDisposition {
995            disposition: DispositionType::Inline,
996            parameters: vec![DispositionParam::Filename(String::from("Tab\there.png"))],
997        };
998        let display_rendered = format!("{}", a);
999        assert_eq!("inline; filename=\"Tab\x09here.png\"", display_rendered);
1000    }
1001
1002    #[test]
1003    fn test_display_control_characters() {
1004        /* let a = "attachment; filename=\"carriage\rreturn.png\"";
1005        let a = HeaderValue::from_static(a);
1006        let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
1007        let display_rendered = format!("{}", a);
1008        assert_eq!(
1009            "attachment; filename=\"carriage\\\rreturn.png\"",
1010            display_rendered
1011        );*/
1012        // No way to create a HeaderValue containing a carriage return.
1013
1014        let a: ContentDisposition = ContentDisposition {
1015            disposition: DispositionType::Inline,
1016            parameters: vec![DispositionParam::Filename(String::from("bell\x07.png"))],
1017        };
1018        let display_rendered = format!("{}", a);
1019        assert_eq!("inline; filename=\"bell\\\x07.png\"", display_rendered);
1020    }
1021
1022    #[test]
1023    fn test_param_methods() {
1024        let param = DispositionParam::Filename(String::from("sample.txt"));
1025        assert!(param.is_filename());
1026        assert_eq!(param.as_filename().unwrap(), "sample.txt");
1027
1028        let param = DispositionParam::Unknown(String::from("foo"), String::from("bar"));
1029        assert!(param.is_unknown("foo"));
1030        assert_eq!(param.as_unknown("fOo"), Some("bar"));
1031    }
1032
1033    #[test]
1034    fn test_disposition_methods() {
1035        let cd = ContentDisposition {
1036            disposition: DispositionType::FormData,
1037            parameters: vec![
1038                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
1039                DispositionParam::Name("upload".to_owned()),
1040                DispositionParam::Filename("sample.png".to_owned()),
1041            ],
1042        };
1043        assert_eq!(cd.get_name(), Some("upload"));
1044        assert_eq!(cd.get_unknown("dummy"), Some("3"));
1045        assert_eq!(cd.get_filename(), Some("sample.png"));
1046        assert_eq!(cd.get_unknown_ext("dummy"), None);
1047        assert_eq!(cd.get_unknown("duMMy"), Some("3"));
1048    }
1049}