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