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