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