Skip to main content

ntex_files/header/
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/rfc2388.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 super::error;
10use super::parsing::{self, ExtendedValue};
11use super::{Header, RawLike};
12use crate::standard_header;
13use regex::Regex;
14use std::fmt;
15use std::sync::LazyLock;
16
17/// The implied disposition of the content of the HTTP body.
18#[derive(Clone, Debug, PartialEq)]
19pub enum DispositionType {
20    /// Inline implies default processing
21    Inline,
22
23    /// Attachment implies that the recipient should prompt the user to save the response locally,
24    /// rather than process it normally (as per its media type).
25    Attachment,
26
27    /// Used in *multipart/form-data* as defined in
28    /// [RFC 7578](https://datatracker.ietf.org/doc/html/rfc7578) to carry the field name and
29    /// optional filename.
30    FormData,
31
32    /// Extension type.  Should be handled by recipients the same way as Attachment
33    Ext(String),
34}
35
36impl<'a> From<&'a str> for DispositionType {
37    fn from(origin: &'a str) -> DispositionType {
38        if unicase::eq_ascii(origin, "inline") {
39            DispositionType::Inline
40        } else if unicase::eq_ascii(origin, "attachment") {
41            DispositionType::Attachment
42        } else if unicase::eq_ascii(origin, "form-data") {
43            DispositionType::FormData
44        } else {
45            DispositionType::Ext(origin.to_owned())
46        }
47    }
48}
49
50/// A parameter to the disposition type.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum DispositionParam {
53    /// For [`DispositionType::FormData`] (i.e. *multipart/form-data*), the name of an field from
54    /// the form.
55    Name(String),
56
57    /// A plain file name.
58    ///
59    /// It is [not supposed](https://datatracker.ietf.org/doc/html/rfc6266#appendix-D) to contain
60    /// any non-ASCII characters when used in a *Content-Disposition* HTTP response header, where
61    /// [`FilenameExt`](DispositionParam::FilenameExt) with charset UTF-8 may be used instead
62    /// in case there are Unicode characters in file names.
63    Filename(String),
64
65    /// An extended file name. It must not exist for `ContentType::Formdata` according to
66    /// [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2).
67    FilenameExt(ExtendedValue),
68
69    /// An unrecognized regular parameter as defined in
70    /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
71    /// `reg-parameter`, in
72    /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
73    /// `token "=" value`. Recipients should ignore unrecognizable parameters.
74    Unknown(String, String),
75
76    /// An unrecognized extended parameter as defined in
77    /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
78    /// `ext-parameter`, in
79    /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
80    /// `ext-token "=" ext-value`. The single trailing asterisk is not included. Recipients should
81    /// ignore unrecognizable parameters.
82    UnknownExt(String, ExtendedValue),
83}
84
85impl DispositionParam {
86    /// Returns `true` if the parameter is [`Name`](DispositionParam::Name).
87    #[inline]
88    pub fn is_name(&self) -> bool {
89        self.as_name().is_some()
90    }
91
92    /// Returns `true` if the parameter is [`Filename`](DispositionParam::Filename).
93    #[inline]
94    pub fn is_filename(&self) -> bool {
95        self.as_filename().is_some()
96    }
97
98    /// Returns `true` if the parameter is [`FilenameExt`](DispositionParam::FilenameExt).
99    #[inline]
100    pub fn is_filename_ext(&self) -> bool {
101        self.as_filename_ext().is_some()
102    }
103
104    /// Returns `true` if the parameter is [`Unknown`](DispositionParam::Unknown) and the `name`
105    #[inline]
106    /// matches.
107    pub fn is_unknown<T: AsRef<str>>(&self, name: T) -> bool {
108        self.as_unknown(name).is_some()
109    }
110
111    /// Returns `true` if the parameter is [`UnknownExt`](DispositionParam::UnknownExt) and the
112    /// `name` matches.
113    #[inline]
114    pub fn is_unknown_ext<T: AsRef<str>>(&self, name: T) -> bool {
115        self.as_unknown_ext(name).is_some()
116    }
117
118    /// Returns the name if applicable.
119    #[inline]
120    pub fn as_name(&self) -> Option<&str> {
121        match self {
122            DispositionParam::Name(name) => Some(name.as_str()),
123            _ => None,
124        }
125    }
126
127    /// Returns the filename if applicable.
128    #[inline]
129    pub fn as_filename(&self) -> Option<&str> {
130        match self {
131            DispositionParam::Filename(filename) => Some(filename.as_str()),
132            _ => None,
133        }
134    }
135
136    /// Returns the filename* if applicable.
137    #[inline]
138    pub fn as_filename_ext(&self) -> Option<&ExtendedValue> {
139        match self {
140            DispositionParam::FilenameExt(value) => Some(value),
141            _ => None,
142        }
143    }
144
145    /// Returns the value of the unrecognized regular parameter if it is
146    /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
147    #[inline]
148    pub fn as_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str> {
149        match self {
150            DispositionParam::Unknown(ext_name, value)
151                if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
152            {
153                Some(value.as_str())
154            }
155            _ => None,
156        }
157    }
158
159    /// Returns the value of the unrecognized extended parameter if it is
160    /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
161    #[inline]
162    pub fn as_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue> {
163        match self {
164            DispositionParam::UnknownExt(ext_name, value)
165                if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
166            {
167                Some(value)
168            }
169            _ => None,
170        }
171    }
172}
173
174/// A `Content-Disposition` header, (re)defined in [RFC6266](https://tools.ietf.org/html/rfc6266).
175///
176/// The Content-Disposition response header field is used to convey
177/// additional information about how to process the response payload, and
178/// also can be used to attach additional metadata, such as the filename
179/// to use when saving the response payload locally.
180///
181/// # ABNF
182///
183/// ```text
184/// content-disposition = "Content-Disposition" ":"
185///                       disposition-type *( ";" disposition-parm )
186///
187/// disposition-type    = "inline" | "attachment" | disp-ext-type
188///                       ; case-insensitive
189///
190/// disp-ext-type       = token
191///
192/// disposition-parm    = filename-parm | disp-ext-parm
193///
194/// filename-parm       = "filename" "=" value
195///                     | "filename*" "=" ext-value
196///
197/// disp-ext-parm       = token "=" value
198///                     | ext-token "=" ext-value
199///
200/// ext-token           = <the characters in token, followed by "*">
201/// ```
202///
203#[derive(Clone, Debug, PartialEq)]
204pub struct ContentDisposition {
205    /// The disposition
206    pub disposition: DispositionType,
207    /// Disposition parameters
208    pub parameters: Vec<DispositionParam>,
209}
210
211impl ContentDisposition {
212    /// Returns `true` if type is [`Inline`](DispositionType::Inline).
213    pub fn is_inline(&self) -> bool {
214        matches!(self.disposition, DispositionType::Inline)
215    }
216
217    /// Returns `true` if type is [`Attachment`](DispositionType::Attachment).
218    pub fn is_attachment(&self) -> bool {
219        matches!(self.disposition, DispositionType::Attachment)
220    }
221
222    /// Returns `true` if type is [`FormData`](DispositionType::FormData).
223    pub fn is_form_data(&self) -> bool {
224        matches!(self.disposition, DispositionType::FormData)
225    }
226
227    /// Returns `true` if type is [`Ext`](DispositionType::Ext) and the `disp_type` matches.
228    pub fn is_ext(&self, disp_type: impl AsRef<str>) -> bool {
229        matches!(
230            self.disposition,
231            DispositionType::Ext(ref t) if t.eq_ignore_ascii_case(disp_type.as_ref())
232        )
233    }
234
235    /// Return the value of *name* if exists.
236    pub fn get_name(&self) -> Option<&str> {
237        self.parameters.iter().find_map(DispositionParam::as_name)
238    }
239
240    /// Return the value of *filename* if exists.
241    pub fn get_filename(&self) -> Option<&str> {
242        self.parameters.iter().find_map(DispositionParam::as_filename)
243    }
244
245    /// Return the value of *filename\** if exists.
246    pub fn get_filename_ext(&self) -> Option<&ExtendedValue> {
247        self.parameters.iter().find_map(DispositionParam::as_filename_ext)
248    }
249
250    /// Return the value of the parameter which the `name` matches.
251    pub fn get_unknown(&self, name: impl AsRef<str>) -> Option<&str> {
252        let name = name.as_ref();
253        self.parameters.iter().find_map(|p| p.as_unknown(name))
254    }
255
256    /// Return the value of the extended parameter which the `name` matches.
257    pub fn get_unknown_ext(&self, name: impl AsRef<str>) -> Option<&ExtendedValue> {
258        let name = name.as_ref();
259        self.parameters.iter().find_map(|p| p.as_unknown_ext(name))
260    }
261}
262
263impl Header for ContentDisposition {
264    fn header_name() -> &'static str {
265        static NAME: &str = "Content-Disposition";
266        NAME
267    }
268
269    fn parse_header<'a, T>(raw: &'a T) -> error::Result<ContentDisposition>
270    where
271        T: RawLike<'a>,
272    {
273        parsing::from_one_raw_str(raw).and_then(|s: String| {
274            let mut sections = s.split(';');
275            let disposition = match sections.next() {
276                Some(s) => s.trim(),
277                None => return Err(error::Error::Header),
278            };
279
280            let mut cd =
281                ContentDisposition { disposition: disposition.into(), parameters: Vec::new() };
282
283            for section in sections {
284                let mut parts = section.splitn(2, '=');
285
286                let key = if let Some(key) = parts.next() {
287                    let key_trimmed = key.trim();
288
289                    if key_trimmed.is_empty() || key_trimmed == "*" {
290                        return Err(error::Error::Header);
291                    }
292
293                    key_trimmed
294                } else {
295                    return Err(error::Error::Header);
296                };
297
298                let val = if let Some(val) = parts.next() {
299                    val.trim()
300                } else {
301                    return Err(error::Error::Header);
302                };
303
304                if let Some(key) = key.strip_suffix('*') {
305                    let ext_val = parsing::parse_extended_value(val)?;
306
307                    cd.parameters.push(if unicase::eq_ascii(key, "filename") {
308                        DispositionParam::FilenameExt(ext_val)
309                    } else {
310                        DispositionParam::UnknownExt(key.to_owned(), ext_val)
311                    });
312                } else {
313                    let val = if val.starts_with('\"') {
314                        // quoted-string: defined in RFC 6266 -> RFC 2616 Section 3.6
315                        let mut escaping = false;
316                        let mut quoted_string = vec![];
317
318                        // search for closing quote
319                        for &c in val.as_bytes().iter().skip(1) {
320                            if escaping {
321                                escaping = false;
322                                quoted_string.push(c);
323                            } else if c == 0x5c {
324                                // backslash
325                                escaping = true;
326                            } else if c == 0x22 {
327                                // double quote
328                                break;
329                            } else {
330                                quoted_string.push(c);
331                            }
332                        }
333
334                        // In fact, it should not be Err if the above code is correct.
335                        String::from_utf8(quoted_string).map_err(|_| error::Error::Header)?
336                    } else {
337                        if val.is_empty() {
338                            // quoted-string can be empty, but token cannot be empty
339                            return Err(error::Error::Header);
340                        }
341
342                        val.to_owned()
343                    };
344
345                    cd.parameters.push(if unicase::eq_ascii(key, "name") {
346                        DispositionParam::Name(val)
347                    } else if unicase::eq_ascii(key, "filename") {
348                        // See also comments in test_from_raw_unnecessary_percent_decode.
349                        DispositionParam::Filename(val)
350                    } else {
351                        DispositionParam::Unknown(key.to_owned(), val)
352                    });
353                }
354            }
355
356            Ok(cd)
357        })
358    }
359
360    #[inline]
361    fn fmt_header(&self, f: &mut super::Formatter) -> fmt::Result {
362        f.fmt_line(self)
363    }
364}
365
366impl fmt::Display for DispositionType {
367    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
368        match self {
369            DispositionType::Inline => write!(f, "inline"),
370            DispositionType::Attachment => write!(f, "attachment"),
371            DispositionType::FormData => write!(f, "form-data"),
372            DispositionType::Ext(s) => write!(f, "{}", s),
373        }
374    }
375}
376
377impl fmt::Display for DispositionParam {
378    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
379        // All ASCII control characters (0-30, 127) including horizontal tab, double quote, and
380        // backslash should be escaped in quoted-string (i.e. "foobar").
381        //
382        // Ref: RFC 6266 §4.1 -> RFC 2616 §3.6
383        //
384        // filename-parm  = "filename" "=" value
385        // value          = token | quoted-string
386        // quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
387        // qdtext         = <any TEXT except <">>
388        // quoted-pair    = "\" CHAR
389        // TEXT           = <any OCTET except CTLs,
390        //                  but including LWS>
391        // LWS            = [CRLF] 1*( SP | HT )
392        // OCTET          = <any 8-bit sequence of data>
393        // CHAR           = <any US-ASCII character (octets 0 - 127)>
394        // CTL            = <any US-ASCII control character
395        //                  (octets 0 - 31) and DEL (127)>
396        //
397        // Ref: RFC 7578 S4.2 -> RFC 2183 S2 -> RFC 2045 S5.1
398        // parameter := attribute "=" value
399        // attribute := token
400        //              ; Matching of attributes
401        //              ; is ALWAYS case-insensitive.
402        // value := token / quoted-string
403        // token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
404        //             or tspecials>
405        // tspecials :=  "(" / ")" / "<" / ">" / "@" /
406        //               "," / ";" / ":" / "\" / <">
407        //               "/" / "[" / "]" / "?" / "="
408        //               ; Must be in quoted-string,
409        //               ; to use within parameter values
410        //
411        //
412        // See also comments in test_from_raw_unnecessary_percent_decode.
413
414        static RE: LazyLock<Regex> =
415            LazyLock::new(|| Regex::new("[\x00-\x08\x10-\x1F\x7F\"\\\\]").unwrap());
416
417        match self {
418            DispositionParam::Name(value) => write!(f, "name={}", value),
419
420            DispositionParam::Filename(value) => {
421                write!(f, "filename=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
422            }
423
424            DispositionParam::Unknown(name, value) => {
425                write!(f, "{}=\"{}\"", name, &RE.replace_all(value, "\\$0").as_ref())
426            }
427
428            DispositionParam::FilenameExt(ext_value) => {
429                write!(f, "filename*={}", ext_value)
430            }
431
432            DispositionParam::UnknownExt(name, ext_value) => {
433                write!(f, "{}*={}", name, ext_value)
434            }
435        }
436    }
437}
438
439impl fmt::Display for ContentDisposition {
440    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
441        write!(f, "{}", self.disposition)?;
442
443        for param in &self.parameters {
444            write!(f, "; {}", param)?;
445        }
446
447        Ok(())
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::{ContentDisposition, DispositionParam, DispositionType, Header};
454    use crate::header::parsing::ExtendedValue;
455    use crate::header::{Charset, Raw};
456
457    #[test]
458    fn test_parse_header() {
459        let a: Raw = "".into();
460        assert!(ContentDisposition::parse_header(&a).is_err());
461
462        let a: Raw = "form-data; dummy=3; name=upload;\r\n filename=\"sample.png\"".into();
463        let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
464        let b = ContentDisposition {
465            disposition: DispositionType::FormData,
466            parameters: vec![
467                DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
468                DispositionParam::Name("upload".to_owned()),
469                DispositionParam::Filename("sample.png".to_owned()),
470            ],
471        };
472        assert_eq!(a, b);
473
474        let a: Raw = "attachment; filename=\"image.jpg\"".into();
475        let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
476        let b = ContentDisposition {
477            disposition: DispositionType::Attachment,
478            parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
479        };
480        assert_eq!(a, b);
481
482        let a: Raw = "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates".into();
483        let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
484        let b = ContentDisposition {
485            disposition: DispositionType::Attachment,
486            parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
487                charset: Charset::Ext(String::from("UTF-8")),
488                language_tag: None,
489                value: vec![
490                    0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r',
491                    b'a', b't', b'e', b's',
492                ],
493            })],
494        };
495        assert_eq!(a, b);
496    }
497
498    #[test]
499    fn test_display() {
500        let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
501        let a: Raw = as_string.into();
502        let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
503        let display_rendered = format!("{}", a);
504        assert_eq!(as_string, display_rendered);
505
506        // TODO Fix this test
507        // let a: Raw = "attachment; filename*=UTF-8''black%20and%20white.csv".into();
508        // let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
509        // let display_rendered = format!("{}", a);
510        // assert_eq!("attachment; filename=\"black and white.csv\"".to_owned(), display_rendered);
511
512        let a: Raw = "attachment; filename=colourful.csv".into();
513        let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
514        let display_rendered = format!("{}", a);
515        assert_eq!("attachment; filename=\"colourful.csv\"".to_owned(), display_rendered);
516    }
517}
518
519standard_header!(ContentDisposition, CONTENT_DISPOSITION);