Skip to main content

palpo_core/http_headers/
content_disposition.rs

1//! Types to (de)serialize the `Content-Disposition` HTTP header.
2
3use std::{fmt, ops::Deref, str::FromStr};
4
5use salvo::oapi::ToSchema;
6use serde::Serialize;
7
8use crate::macros::{AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr};
9
10use super::{
11    is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string, unescape_string,
12};
13
14/// The value of a `Content-Disposition` HTTP header.
15///
16/// This implementation supports the `Content-Disposition` header format as defined for HTTP in [RFC
17/// 6266].
18///
19/// The only supported parameter is `filename`. It is encoded or decoded as needed, using a quoted
20/// string or the `ext-token = ext-value` format, with the encoding defined in [RFC 8187].
21///
22/// This implementation does not support serializing to the format defined for the
23/// `multipart/form-data` content type in [RFC 7578]. It should however manage to parse the
24/// disposition type and filename parameter of the body parts.
25///
26/// [RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266
27/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
28/// [RFC 7578]: https://datatracker.ietf.org/doc/html/rfc7578
29#[derive(ToSchema, Serialize, Debug, Clone, PartialEq, Eq, Default)]
30#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
31pub struct ContentDisposition {
32    /// The disposition type.
33    pub disposition_type: ContentDispositionType,
34
35    /// The filename of the content.
36    pub filename: Option<String>,
37}
38
39impl ContentDisposition {
40    /// Creates a new `ContentDisposition` with the given disposition type.
41    pub fn new(disposition_type: ContentDispositionType) -> Self {
42        Self {
43            disposition_type,
44            filename: None,
45        }
46    }
47
48    /// Add the given filename to this `ContentDisposition`.
49    pub fn with_filename(mut self, filename: Option<String>) -> Self {
50        self.filename = filename;
51        self
52    }
53}
54
55impl fmt::Display for ContentDisposition {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}", self.disposition_type)?;
58
59        if let Some(filename) = &self.filename {
60            if filename.is_ascii() {
61                // First, remove all non-quotable characters, that is control characters.
62                let filename = sanitize_for_ascii_quoted_string(filename);
63
64                // We can use the filename parameter.
65                write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
66            } else {
67                // We need to use RFC 8187 encoding.
68                write!(f, "; filename*={}", rfc8187::encode(filename))?;
69            }
70        }
71
72        Ok(())
73    }
74}
75
76impl TryFrom<&[u8]> for ContentDisposition {
77    type Error = ContentDispositionParseError;
78
79    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
80        let mut pos = 0;
81
82        skip_ascii_whitespaces(value, &mut pos);
83
84        if pos == value.len() {
85            return Err(ContentDispositionParseError::MissingDispositionType);
86        }
87
88        let disposition_type_start = pos;
89
90        // Find the next whitespace or `;`.
91        while let Some(byte) = value.get(pos) {
92            if byte.is_ascii_whitespace() || *byte == b';' {
93                break;
94            }
95
96            pos += 1;
97        }
98
99        let disposition_type = ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
100
101        // The `filename*` parameter (`filename_ext` here) using UTF-8 encoding should be used, but
102        // it is likely to be after the `filename` parameter containing only ASCII
103        // characters if both are present.
104        let mut filename_ext = None;
105        let mut filename = None;
106
107        // Parse the parameters. We ignore parameters that fail to parse for maximum compatibility.
108        while pos != value.len() {
109            if let Some(param) = RawParam::parse_next(value, &mut pos) {
110                if param.name.eq_ignore_ascii_case(b"filename*") {
111                    if let Some(value) = param.decode_value() {
112                        filename_ext = Some(value);
113                        // We can stop parsing, this is the only parameter that we need.
114                        break;
115                    }
116                } else if param.name.eq_ignore_ascii_case(b"filename") {
117                    if let Some(value) = param.decode_value() {
118                        filename = Some(value);
119                    }
120                }
121            }
122        }
123
124        Ok(Self {
125            disposition_type,
126            filename: filename_ext.or(filename),
127        })
128    }
129}
130
131impl FromStr for ContentDisposition {
132    type Err = ContentDispositionParseError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        s.as_bytes().try_into()
136    }
137}
138
139/// A raw parameter in a `Content-Disposition` HTTP header.
140struct RawParam<'a> {
141    name: &'a [u8],
142    value: &'a [u8],
143    is_quoted_string: bool,
144}
145
146impl<'a> RawParam<'a> {
147    /// Parse the next `RawParam` in the given bytes, starting at the given position.
148    ///
149    /// The position is updated during the parsing.
150    ///
151    /// Returns `None` if no parameter was found or if an error occurred when parsing the
152    /// parameter.
153    fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
154        let name = parse_param_name(bytes, pos)?;
155
156        skip_ascii_whitespaces(bytes, pos);
157
158        if *pos == bytes.len() {
159            // We are at the end of the bytes and only have the parameter name.
160            return None;
161        }
162        if bytes[*pos] != b'=' {
163            // We should have an equal sign, there is a problem with the bytes and we can't recover
164            // from it.
165            // Skip to the end to stop the parsing.
166            *pos = bytes.len();
167            return None;
168        }
169
170        // Skip the equal sign.
171        *pos += 1;
172
173        skip_ascii_whitespaces(bytes, pos);
174
175        let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
176
177        Some(Self {
178            name,
179            value,
180            is_quoted_string,
181        })
182    }
183
184    /// Decode the value of this `RawParam`.
185    ///
186    /// Returns `None` if decoding the param failed.
187    fn decode_value(&self) -> Option<String> {
188        if self.name.ends_with(b"*") {
189            rfc8187::decode(self.value).ok().map(|s| s.into_owned())
190        } else {
191            let s = String::from_utf8_lossy(self.value);
192
193            if self.is_quoted_string {
194                Some(unescape_string(&s))
195            } else {
196                Some(s.into_owned())
197            }
198        }
199    }
200}
201
202/// Skip ASCII whitespaces in the given bytes, starting at the given position.
203///
204/// The position is updated to after the whitespaces.
205fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
206    while let Some(byte) = bytes.get(*pos) {
207        if !byte.is_ascii_whitespace() {
208            break;
209        }
210
211        *pos += 1;
212    }
213}
214
215/// Parse a parameter name in the given bytes, starting at the given position.
216///
217/// The position is updated while parsing.
218///
219/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
220fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
221    skip_ascii_whitespaces(bytes, pos);
222
223    if *pos == bytes.len() {
224        // We are at the end of the bytes and didn't find anything.
225        return None;
226    }
227
228    let name_start = *pos;
229
230    // Find the end of the parameter name. The name can only contain token chars.
231    while let Some(byte) = bytes.get(*pos) {
232        if !is_tchar(*byte) {
233            break;
234        }
235
236        *pos += 1;
237    }
238
239    if *pos == bytes.len() {
240        // We are at the end of the bytes and only have the parameter name.
241        return None;
242    }
243    if bytes[*pos] == b';' {
244        // We are at the end of the parameter and only have the parameter name, skip the `;` and
245        // parse the next parameter.
246        *pos += 1;
247        return None;
248    }
249
250    let name = &bytes[name_start..*pos];
251
252    if name.is_empty() {
253        // It's probably a syntax error, we cannot recover from it.
254        *pos = bytes.len();
255        return None;
256    }
257
258    Some(name)
259}
260
261/// Parse a parameter value in the given bytes, starting at the given position.
262///
263/// The position is updated while parsing.
264///
265/// Returns a `(value, is_quoted_string)` tuple if parsing succeeded.
266/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
267fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
268    skip_ascii_whitespaces(bytes, pos);
269
270    if *pos == bytes.len() {
271        // We are at the end of the bytes and didn't find anything.
272        return None;
273    }
274
275    let is_quoted_string = bytes[*pos] == b'"';
276    if is_quoted_string {
277        // Skip the start double quote.
278        *pos += 1;
279    }
280
281    let value_start = *pos;
282
283    // Keep track of whether the next byte is escaped with a backslash.
284    let mut escape_next = false;
285
286    // Find the end of the value, it's a whitespace or a semi-colon, or a double quote if the string
287    // is quoted.
288    while let Some(byte) = bytes.get(*pos) {
289        if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
290            break;
291        }
292
293        if is_quoted_string && *byte == b'"' && !escape_next {
294            break;
295        }
296
297        escape_next = *byte == b'\\' && !escape_next;
298
299        *pos += 1;
300    }
301
302    let value = &bytes[value_start..*pos];
303
304    if is_quoted_string && *pos != bytes.len() {
305        // Skip the end double quote.
306        *pos += 1;
307    }
308
309    skip_ascii_whitespaces(bytes, pos);
310
311    // Check for parameters separator if we are not at the end of the string.
312    if *pos != bytes.len() {
313        if bytes[*pos] == b';' {
314            // Skip the `;` at the end of the parameter.
315            *pos += 1;
316        } else {
317            // We should have a `;`, there is a problem with the bytes and we can't recover
318            // from it.
319            // Skip to the end to stop the parsing.
320            *pos = bytes.len();
321            return None;
322        }
323    }
324
325    Some((value, is_quoted_string))
326}
327
328/// An error encountered when trying to parse an invalid [`ContentDisposition`].
329#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
330#[non_exhaustive]
331pub enum ContentDispositionParseError {
332    /// The disposition type is missing.
333    #[error("disposition type is missing")]
334    MissingDispositionType,
335
336    /// The disposition type is invalid.
337    #[error("invalid disposition type: {0}")]
338    InvalidDispositionType(#[from] TokenStringParseError),
339}
340
341/// A disposition type in the `Content-Disposition` HTTP header as defined in [Section 4.2 of RFC
342/// 6266].
343///
344/// This type can hold an arbitrary [`TokenString`]. To build this with a custom value, convert it
345/// from a `TokenString` with `::from()` / `.into()`. To check for values that are not available as
346/// a documented variant here, use its string representation, obtained through
347/// [`.as_str()`](Self::as_str()).
348///
349/// Comparisons with other string types are done case-insensitively.
350///
351/// [Section 4.2 of RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266#section-4.2
352#[derive(
353    ToSchema,
354    Serialize,
355    Clone,
356    Default,
357    AsRefStr,
358    DebugAsRefStr,
359    AsStrAsRefStr,
360    DisplayAsRefStr,
361    PartialOrdAsRefStr,
362    OrdAsRefStr,
363)]
364#[palpo_enum(rename_all = "lowercase")]
365#[non_exhaustive]
366pub enum ContentDispositionType {
367    /// The content can be displayed.
368    ///
369    /// This is the default.
370    #[default]
371    Inline,
372
373    /// The content should be downloaded instead of displayed.
374    Attachment,
375
376    #[doc(hidden)]
377    #[salvo(schema(value_type = String))]
378    _Custom(TokenString),
379}
380
381impl ContentDispositionType {
382    /// Try parsing a `&str` into a `ContentDispositionType`.
383    pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
384        Self::from_str(s)
385    }
386}
387
388impl From<TokenString> for ContentDispositionType {
389    fn from(value: TokenString) -> Self {
390        if value.eq_ignore_ascii_case("inline") {
391            Self::Inline
392        } else if value.eq_ignore_ascii_case("attachment") {
393            Self::Attachment
394        } else {
395            Self::_Custom(value)
396        }
397    }
398}
399
400impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
401    type Error = TokenStringParseError;
402
403    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
404        if value.eq_ignore_ascii_case(b"inline") {
405            Ok(Self::Inline)
406        } else if value.eq_ignore_ascii_case(b"attachment") {
407            Ok(Self::Attachment)
408        } else {
409            TokenString::try_from(value).map(Self::_Custom)
410        }
411    }
412}
413
414impl FromStr for ContentDispositionType {
415    type Err = TokenStringParseError;
416
417    fn from_str(s: &str) -> Result<Self, Self::Err> {
418        s.as_bytes().try_into()
419    }
420}
421
422impl PartialEq<ContentDispositionType> for ContentDispositionType {
423    fn eq(&self, other: &ContentDispositionType) -> bool {
424        self.as_str().eq_ignore_ascii_case(other.as_str())
425    }
426}
427
428impl Eq for ContentDispositionType {}
429
430impl PartialEq<TokenString> for ContentDispositionType {
431    fn eq(&self, other: &TokenString) -> bool {
432        self.as_str().eq_ignore_ascii_case(other.as_str())
433    }
434}
435
436impl<'a> PartialEq<&'a str> for ContentDispositionType {
437    fn eq(&self, other: &&'a str) -> bool {
438        self.as_str().eq_ignore_ascii_case(other)
439    }
440}
441
442/// A non-empty string consisting only of `token`s as defined in [RFC 9110 Section 3.2.6].
443///
444/// This is a string that can only contain a limited character set.
445///
446/// [RFC 7230 Section 3.2.6]: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
447#[derive(
448    Clone, Serialize, PartialEq, Eq, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, PartialOrdAsRefStr, OrdAsRefStr,
449)]
450pub struct TokenString(Box<str>);
451
452impl TokenString {
453    /// Try parsing a `&str` into a `TokenString`.
454    pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
455        Self::from_str(s)
456    }
457}
458
459impl Deref for TokenString {
460    type Target = str;
461
462    fn deref(&self) -> &Self::Target {
463        self.as_ref()
464    }
465}
466
467impl AsRef<str> for TokenString {
468    fn as_ref(&self) -> &str {
469        &self.0
470    }
471}
472
473impl<'a> PartialEq<&'a str> for TokenString {
474    fn eq(&self, other: &&'a str) -> bool {
475        self.as_str().eq(*other)
476    }
477}
478
479impl<'a> TryFrom<&'a [u8]> for TokenString {
480    type Error = TokenStringParseError;
481
482    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
483        if value.is_empty() {
484            Err(TokenStringParseError::Empty)
485        } else if is_token(value) {
486            let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
487            Ok(Self(s.into()))
488        } else {
489            Err(TokenStringParseError::InvalidCharacter)
490        }
491    }
492}
493
494impl FromStr for TokenString {
495    type Err = TokenStringParseError;
496
497    fn from_str(s: &str) -> Result<Self, Self::Err> {
498        s.as_bytes().try_into()
499    }
500}
501
502/// The parsed string contains a character not allowed for a [`TokenString`].
503#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
504#[non_exhaustive]
505pub enum TokenStringParseError {
506    /// The string is empty.
507    #[error("string is empty")]
508    Empty,
509
510    /// The string contains an invalid character for a token string.
511    #[error("string contains invalid character")]
512    InvalidCharacter,
513}
514
515#[cfg(test)]
516mod tests {
517    use std::str::FromStr;
518
519    use super::{ContentDisposition, ContentDispositionType};
520
521    #[test]
522    fn parse_content_disposition_valid() {
523        // Only disposition type.
524        let content_disposition = ContentDisposition::from_str("inline").unwrap();
525        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
526        assert_eq!(content_disposition.filename, None);
527
528        // Only disposition type with separator.
529        let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
530        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
531        assert_eq!(content_disposition.filename, None);
532
533        // Unknown disposition type and parameters.
534        let content_disposition = ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
535        assert_eq!(content_disposition.disposition_type.as_str(), "custom");
536        assert_eq!(content_disposition.filename, None);
537
538        // Disposition type and filename.
539        let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
540        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
541        assert_eq!(content_disposition.filename.unwrap(), "my_file");
542
543        // Case insensitive.
544        let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
545        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
546        assert_eq!(content_disposition.filename.unwrap(), "my_file");
547
548        // Extra spaces.
549        let content_disposition = ContentDisposition::from_str("  INLINE   ;FILENAME =   my_file   ").unwrap();
550        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
551        assert_eq!(content_disposition.filename.unwrap(), "my_file");
552
553        // Unsupported filename* is skipped and falls back to ASCII filename.
554        let content_disposition =
555            ContentDisposition::from_str(r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#)
556                .unwrap();
557        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
558        assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
559
560        // filename could be UTF-8 for extra compatibility (with `form-data` for example).
561        let content_disposition =
562            ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#).unwrap();
563        assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
564        assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
565    }
566
567    #[test]
568    fn parse_content_disposition_invalid_type() {
569        // Empty.
570        ContentDisposition::from_str("").unwrap_err();
571
572        // Missing disposition type.
573        ContentDisposition::from_str("; foo=bar").unwrap_err();
574    }
575
576    #[test]
577    fn parse_content_disposition_invalid_parameters() {
578        // Unexpected `:` after parameter name, filename parameter is not reached.
579        let content_disposition = ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
580        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
581        assert_eq!(content_disposition.filename, None);
582
583        // Same error, but after filename, so filename was parser.
584        let content_disposition = ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
585        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
586        assert_eq!(content_disposition.filename.unwrap(), "my_file");
587
588        // Missing `;` between parameters, filename parameter is not parsed successfully.
589        let content_disposition = ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
590        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
591        assert_eq!(content_disposition.filename, None);
592    }
593
594    #[test]
595    fn content_disposition_serialize() {
596        // Only disposition type.
597        let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
598        let serialized = content_disposition.to_string();
599        assert_eq!(serialized, "inline");
600
601        // Disposition type and ASCII filename without space.
602        let content_disposition =
603            ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my_file".to_owned()));
604        let serialized = content_disposition.to_string();
605        assert_eq!(serialized, "attachment; filename=my_file");
606
607        // Disposition type and ASCII filename with space.
608        let content_disposition =
609            ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my file".to_owned()));
610        let serialized = content_disposition.to_string();
611        assert_eq!(serialized, r#"attachment; filename="my file""#);
612
613        // Disposition type and ASCII filename with double quote and backslash.
614        let content_disposition =
615            ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some(r#""my"\file"#.to_owned()));
616        let serialized = content_disposition.to_string();
617        assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
618
619        // Disposition type and UTF-8 filename.
620        let content_disposition =
621            ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("Mi Corazón".to_owned()));
622        let serialized = content_disposition.to_string();
623        assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
624
625        // Sanitized filename.
626        let content_disposition =
627            ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my\r\nfile".to_owned()));
628        let serialized = content_disposition.to_string();
629        assert_eq!(serialized, "attachment; filename=myfile");
630    }
631
632    #[test]
633    fn rfc6266_examples() {
634        // Basic syntax with unquoted filename.
635        let unquoted = "Attachment; filename=example.html";
636        let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
637
638        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
639        assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
640
641        let reserialized = content_disposition.to_string();
642        assert_eq!(reserialized, "attachment; filename=example.html");
643
644        // With quoted filename, case insensitivity and extra whitespaces.
645        let quoted = r#"INLINE; FILENAME= "an example.html""#;
646        let content_disposition = ContentDisposition::from_str(quoted).unwrap();
647
648        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
649        assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
650
651        let reserialized = content_disposition.to_string();
652        assert_eq!(reserialized, r#"inline; filename="an example.html""#);
653
654        // With RFC 8187-encoded UTF-8 filename.
655        let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
656        let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
657
658        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
659        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
660
661        let reserialized = content_disposition.to_string();
662        assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
663
664        // With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
665        let rfc8187_with_fallback = r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
666        let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
667
668        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
669        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
670    }
671
672    #[test]
673    fn rfc8187_examples() {
674        // Those examples originate from RFC 8187, but are changed to fit the expectations here:
675        //
676        // - A disposition type is added
677        // - The title parameter is renamed to filename
678
679        // Basic syntax with unquoted filename.
680        let unquoted = "attachment; foo= bar; filename=Economy";
681        let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
682
683        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
684        assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
685
686        let reserialized = content_disposition.to_string();
687        assert_eq!(reserialized, "attachment; filename=Economy");
688
689        // With quoted filename.
690        let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
691        let content_disposition = ContentDisposition::from_str(quoted).unwrap();
692
693        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
694        assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
695
696        let reserialized = content_disposition.to_string();
697        assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
698
699        // With RFC 8187-encoded UTF-8 filename.
700        let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
701        let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
702
703        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
704        assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
705
706        let reserialized = content_disposition.to_string();
707        assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
708
709        // With RFC 8187-encoded UTF-8 filename again.
710        let rfc8187_other = r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
711        let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
712
713        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
714        assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
715
716        let reserialized = content_disposition.to_string();
717        assert_eq!(
718            reserialized,
719            r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
720        );
721
722        // With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
723        let rfc8187_with_fallback =
724            r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
725        let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
726
727        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
728        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
729
730        let reserialized = content_disposition.to_string();
731        assert_eq!(
732            reserialized,
733            r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#
734        );
735    }
736}