Skip to main content

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