ruma_common/http_headers/
content_disposition.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
28#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
29pub struct ContentDisposition {
30 pub disposition_type: ContentDispositionType,
32
33 pub filename: Option<String>,
35}
36
37impl ContentDisposition {
38 pub fn new(disposition_type: ContentDispositionType) -> Self {
40 Self { disposition_type, filename: None }
41 }
42
43 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 let filename = sanitize_for_ascii_quoted_string(filename);
58
59 write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
61 } else {
62 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 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 let mut filename_ext = None;
101 let mut filename = None;
102
103 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 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
132struct RawParam<'a> {
134 name: &'a [u8],
135 value: &'a [u8],
136 is_quoted_string: bool,
137}
138
139impl<'a> RawParam<'a> {
140 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 return None;
154 }
155 if bytes[*pos] != b'=' {
156 *pos = bytes.len();
160 return None;
161 }
162
163 *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 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
191fn 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
204fn 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 return None;
215 }
216
217 let name_start = *pos;
218
219 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 return None;
231 }
232 if bytes[*pos] == b';' {
233 *pos += 1;
236 return None;
237 }
238
239 let name = &bytes[name_start..*pos];
240
241 if name.is_empty() {
242 *pos = bytes.len();
244 return None;
245 }
246
247 Some(name)
248}
249
250fn 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 return None;
262 }
263
264 let is_quoted_string = bytes[*pos] == b'"';
265 if is_quoted_string {
266 *pos += 1;
268 }
269
270 let value_start = *pos;
271
272 let mut escape_next = false;
274
275 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 *pos += 1;
296 }
297
298 skip_ascii_whitespaces(bytes, pos);
299
300 if *pos != bytes.len() {
302 if bytes[*pos] == b';' {
303 *pos += 1;
305 } else {
306 *pos = bytes.len();
310 return None;
311 }
312 }
313
314 Some((value, is_quoted_string))
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
319#[non_exhaustive]
320pub enum ContentDispositionParseError {
321 #[error("disposition type is missing")]
323 MissingDispositionType,
324
325 #[error("invalid disposition type: {0}")]
327 InvalidDispositionType(#[from] TokenStringParseError),
328}
329
330#[derive(Clone, Default, AsRefStr, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
342#[ruma_enum(rename_all = "lowercase")]
343#[non_exhaustive]
344pub enum ContentDispositionType {
345 #[default]
349 Inline,
350
351 Attachment,
353
354 #[doc(hidden)]
355 _Custom(TokenString),
356}
357
358impl ContentDispositionType {
359 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#[derive(Clone, PartialEq, Eq, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
425pub struct TokenString(Box<str>);
426
427impl TokenString {
428 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
479#[non_exhaustive]
480pub enum TokenStringParseError {
481 #[error("string is empty")]
483 Empty,
484
485 #[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 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 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 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 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 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 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 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 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 ContentDisposition::from_str("").unwrap_err();
550
551 ContentDisposition::from_str("; foo=bar").unwrap_err();
553 }
554
555 #[test]
556 fn parse_content_disposition_invalid_parameters() {
557 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 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 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 let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
580 let serialized = content_disposition.to_string();
581 assert_eq!(serialized, "inline");
582
583 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 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 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 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 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 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 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 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 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 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 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 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 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 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}