1use std::{fmt, ops::Deref, str::FromStr};
4
5use ruma_macros::{
6 AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr,
7};
8
9use super::{
10 is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string,
11 unescape_string,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
30#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
31pub struct ContentDisposition {
32 pub disposition_type: ContentDispositionType,
34
35 pub filename: Option<String>,
37}
38
39impl ContentDisposition {
40 pub fn new(disposition_type: ContentDispositionType) -> Self {
42 Self { disposition_type, filename: None }
43 }
44
45 pub fn with_filename(mut self, filename: Option<String>) -> Self {
47 self.filename = filename;
48 self
49 }
50}
51
52impl fmt::Display for ContentDisposition {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "{}", self.disposition_type)?;
55
56 if let Some(filename) = &self.filename {
57 if filename.is_ascii() {
58 let filename = sanitize_for_ascii_quoted_string(filename);
60
61 write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
63 } else {
64 write!(f, "; filename*={}", rfc8187::encode(filename))?;
66 }
67 }
68
69 Ok(())
70 }
71}
72
73impl TryFrom<&[u8]> for ContentDisposition {
74 type Error = ContentDispositionParseError;
75
76 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
77 let mut pos = 0;
78
79 skip_ascii_whitespaces(value, &mut pos);
80
81 if pos == value.len() {
82 return Err(ContentDispositionParseError::MissingDispositionType);
83 }
84
85 let disposition_type_start = pos;
86
87 while let Some(byte) = value.get(pos) {
89 if byte.is_ascii_whitespace() || *byte == b';' {
90 break;
91 }
92
93 pos += 1;
94 }
95
96 let disposition_type =
97 ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
98
99 let mut filename_ext = None;
103 let mut filename = None;
104
105 while pos != value.len() {
107 if let Some(param) = RawParam::parse_next(value, &mut pos) {
108 if param.name.eq_ignore_ascii_case(b"filename*") {
109 if let Some(value) = param.decode_value() {
110 filename_ext = Some(value);
111 break;
113 }
114 } else if param.name.eq_ignore_ascii_case(b"filename") {
115 if let Some(value) = param.decode_value() {
116 filename = Some(value);
117 }
118 }
119 }
120 }
121
122 Ok(Self { disposition_type, filename: filename_ext.or(filename) })
123 }
124}
125
126impl FromStr for ContentDisposition {
127 type Err = ContentDispositionParseError;
128
129 fn from_str(s: &str) -> Result<Self, Self::Err> {
130 s.as_bytes().try_into()
131 }
132}
133
134struct RawParam<'a> {
136 name: &'a [u8],
137 value: &'a [u8],
138 is_quoted_string: bool,
139}
140
141impl<'a> RawParam<'a> {
142 fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
149 let name = parse_param_name(bytes, pos)?;
150
151 skip_ascii_whitespaces(bytes, pos);
152
153 if *pos == bytes.len() {
154 return None;
156 }
157 if bytes[*pos] != b'=' {
158 *pos = bytes.len();
162 return None;
163 }
164
165 *pos += 1;
167
168 skip_ascii_whitespaces(bytes, pos);
169
170 let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
171
172 Some(Self { name, value, is_quoted_string })
173 }
174
175 fn decode_value(&self) -> Option<String> {
179 if self.name.ends_with(b"*") {
180 rfc8187::decode(self.value).ok().map(|s| s.into_owned())
181 } else {
182 let s = String::from_utf8_lossy(self.value);
183
184 if self.is_quoted_string {
185 Some(unescape_string(&s))
186 } else {
187 Some(s.into_owned())
188 }
189 }
190 }
191}
192
193fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
197 while let Some(byte) = bytes.get(*pos) {
198 if !byte.is_ascii_whitespace() {
199 break;
200 }
201
202 *pos += 1;
203 }
204}
205
206fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
212 skip_ascii_whitespaces(bytes, pos);
213
214 if *pos == bytes.len() {
215 return None;
217 }
218
219 let name_start = *pos;
220
221 while let Some(byte) = bytes.get(*pos) {
223 if !is_tchar(*byte) {
224 break;
225 }
226
227 *pos += 1;
228 }
229
230 if *pos == bytes.len() {
231 return None;
233 }
234 if bytes[*pos] == b';' {
235 *pos += 1;
238 return None;
239 }
240
241 let name = &bytes[name_start..*pos];
242
243 if name.is_empty() {
244 *pos = bytes.len();
246 return None;
247 }
248
249 Some(name)
250}
251
252fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
259 skip_ascii_whitespaces(bytes, pos);
260
261 if *pos == bytes.len() {
262 return None;
264 }
265
266 let is_quoted_string = bytes[*pos] == b'"';
267 if is_quoted_string {
268 *pos += 1;
270 }
271
272 let value_start = *pos;
273
274 let mut escape_next = false;
276
277 while let Some(byte) = bytes.get(*pos) {
280 if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
281 break;
282 }
283
284 if is_quoted_string && *byte == b'"' && !escape_next {
285 break;
286 }
287
288 escape_next = *byte == b'\\' && !escape_next;
289
290 *pos += 1;
291 }
292
293 let value = &bytes[value_start..*pos];
294
295 if is_quoted_string && *pos != bytes.len() {
296 *pos += 1;
298 }
299
300 skip_ascii_whitespaces(bytes, pos);
301
302 if *pos != bytes.len() {
304 if bytes[*pos] == b';' {
305 *pos += 1;
307 } else {
308 *pos = bytes.len();
312 return None;
313 }
314 }
315
316 Some((value, is_quoted_string))
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
321#[non_exhaustive]
322pub enum ContentDispositionParseError {
323 #[error("disposition type is missing")]
325 MissingDispositionType,
326
327 #[error("invalid disposition type: {0}")]
329 InvalidDispositionType(#[from] TokenStringParseError),
330}
331
332#[derive(
344 Clone,
345 Default,
346 AsRefStr,
347 DebugAsRefStr,
348 AsStrAsRefStr,
349 DisplayAsRefStr,
350 PartialOrdAsRefStr,
351 OrdAsRefStr,
352)]
353#[ruma_enum(rename_all = "lowercase")]
354#[non_exhaustive]
355pub enum ContentDispositionType {
356 #[default]
360 Inline,
361
362 Attachment,
364
365 #[doc(hidden)]
366 _Custom(TokenString),
367}
368
369impl ContentDispositionType {
370 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
372 Self::from_str(s)
373 }
374}
375
376impl From<TokenString> for ContentDispositionType {
377 fn from(value: TokenString) -> Self {
378 if value.eq_ignore_ascii_case("inline") {
379 Self::Inline
380 } else if value.eq_ignore_ascii_case("attachment") {
381 Self::Attachment
382 } else {
383 Self::_Custom(value)
384 }
385 }
386}
387
388impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
389 type Error = TokenStringParseError;
390
391 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
392 if value.eq_ignore_ascii_case(b"inline") {
393 Ok(Self::Inline)
394 } else if value.eq_ignore_ascii_case(b"attachment") {
395 Ok(Self::Attachment)
396 } else {
397 TokenString::try_from(value).map(Self::_Custom)
398 }
399 }
400}
401
402impl FromStr for ContentDispositionType {
403 type Err = TokenStringParseError;
404
405 fn from_str(s: &str) -> Result<Self, Self::Err> {
406 s.as_bytes().try_into()
407 }
408}
409
410impl PartialEq<ContentDispositionType> for ContentDispositionType {
411 fn eq(&self, other: &ContentDispositionType) -> bool {
412 self.as_str().eq_ignore_ascii_case(other.as_str())
413 }
414}
415
416impl Eq for ContentDispositionType {}
417
418impl PartialEq<TokenString> for ContentDispositionType {
419 fn eq(&self, other: &TokenString) -> bool {
420 self.as_str().eq_ignore_ascii_case(other.as_str())
421 }
422}
423
424impl<'a> PartialEq<&'a str> for ContentDispositionType {
425 fn eq(&self, other: &&'a str) -> bool {
426 self.as_str().eq_ignore_ascii_case(other)
427 }
428}
429
430#[derive(
436 Clone,
437 PartialEq,
438 Eq,
439 DebugAsRefStr,
440 AsStrAsRefStr,
441 DisplayAsRefStr,
442 PartialOrdAsRefStr,
443 OrdAsRefStr,
444)]
445pub struct TokenString(Box<str>);
446
447impl TokenString {
448 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
450 Self::from_str(s)
451 }
452}
453
454impl Deref for TokenString {
455 type Target = str;
456
457 fn deref(&self) -> &Self::Target {
458 self.as_ref()
459 }
460}
461
462impl AsRef<str> for TokenString {
463 fn as_ref(&self) -> &str {
464 &self.0
465 }
466}
467
468impl<'a> PartialEq<&'a str> for TokenString {
469 fn eq(&self, other: &&'a str) -> bool {
470 self.as_str().eq(*other)
471 }
472}
473
474impl<'a> TryFrom<&'a [u8]> for TokenString {
475 type Error = TokenStringParseError;
476
477 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
478 if value.is_empty() {
479 Err(TokenStringParseError::Empty)
480 } else if is_token(value) {
481 let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
482 Ok(Self(s.into()))
483 } else {
484 Err(TokenStringParseError::InvalidCharacter)
485 }
486 }
487}
488
489impl FromStr for TokenString {
490 type Err = TokenStringParseError;
491
492 fn from_str(s: &str) -> Result<Self, Self::Err> {
493 s.as_bytes().try_into()
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
499#[non_exhaustive]
500pub enum TokenStringParseError {
501 #[error("string is empty")]
503 Empty,
504
505 #[error("string contains invalid character")]
507 InvalidCharacter,
508}
509
510#[cfg(test)]
511mod tests {
512 use std::str::FromStr;
513
514 use super::{ContentDisposition, ContentDispositionType};
515
516 #[test]
517 fn parse_content_disposition_valid() {
518 let content_disposition = ContentDisposition::from_str("inline").unwrap();
520 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
521 assert_eq!(content_disposition.filename, None);
522
523 let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
525 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
526 assert_eq!(content_disposition.filename, None);
527
528 let content_disposition =
530 ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
531 assert_eq!(content_disposition.disposition_type.as_str(), "custom");
532 assert_eq!(content_disposition.filename, None);
533
534 let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
536 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
537 assert_eq!(content_disposition.filename.unwrap(), "my_file");
538
539 let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
541 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
542 assert_eq!(content_disposition.filename.unwrap(), "my_file");
543
544 let content_disposition =
546 ContentDisposition::from_str(" INLINE ;FILENAME = my_file ").unwrap();
547 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
548 assert_eq!(content_disposition.filename.unwrap(), "my_file");
549
550 let content_disposition = ContentDisposition::from_str(
552 r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#,
553 )
554 .unwrap();
555 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
556 assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
557
558 let content_disposition =
560 ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#)
561 .unwrap();
562 assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
563 assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
564 }
565
566 #[test]
567 fn parse_content_disposition_invalid_type() {
568 ContentDisposition::from_str("").unwrap_err();
570
571 ContentDisposition::from_str("; foo=bar").unwrap_err();
573 }
574
575 #[test]
576 fn parse_content_disposition_invalid_parameters() {
577 let content_disposition =
579 ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
580 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
581 assert_eq!(content_disposition.filename, None);
582
583 let content_disposition =
585 ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
586 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
587 assert_eq!(content_disposition.filename.unwrap(), "my_file");
588
589 let content_disposition =
591 ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
592 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
593 assert_eq!(content_disposition.filename, None);
594 }
595
596 #[test]
597 fn content_disposition_serialize() {
598 let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
600 let serialized = content_disposition.to_string();
601 assert_eq!(serialized, "inline");
602
603 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
605 .with_filename(Some("my_file".to_owned()));
606 let serialized = content_disposition.to_string();
607 assert_eq!(serialized, "attachment; filename=my_file");
608
609 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
611 .with_filename(Some("my file".to_owned()));
612 let serialized = content_disposition.to_string();
613 assert_eq!(serialized, r#"attachment; filename="my file""#);
614
615 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
617 .with_filename(Some(r#""my"\file"#.to_owned()));
618 let serialized = content_disposition.to_string();
619 assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
620
621 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
623 .with_filename(Some("Mi Corazón".to_owned()));
624 let serialized = content_disposition.to_string();
625 assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
626
627 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
629 .with_filename(Some("my\r\nfile".to_owned()));
630 let serialized = content_disposition.to_string();
631 assert_eq!(serialized, "attachment; filename=myfile");
632 }
633
634 #[test]
635 fn rfc6266_examples() {
636 let unquoted = "Attachment; filename=example.html";
638 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
639
640 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
641 assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
642
643 let reserialized = content_disposition.to_string();
644 assert_eq!(reserialized, "attachment; filename=example.html");
645
646 let quoted = r#"INLINE; FILENAME= "an example.html""#;
648 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
649
650 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
651 assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
652
653 let reserialized = content_disposition.to_string();
654 assert_eq!(reserialized, r#"inline; filename="an example.html""#);
655
656 let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
658 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
659
660 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
661 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
662
663 let reserialized = content_disposition.to_string();
664 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
665
666 let rfc8187_with_fallback =
668 r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
669 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
670
671 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
672 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
673 }
674
675 #[test]
676 fn rfc8187_examples() {
677 let unquoted = "attachment; foo= bar; filename=Economy";
684 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
685
686 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
687 assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
688
689 let reserialized = content_disposition.to_string();
690 assert_eq!(reserialized, "attachment; filename=Economy");
691
692 let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
694 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
695
696 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
697 assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
698
699 let reserialized = content_disposition.to_string();
700 assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
701
702 let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
704 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
705
706 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
707 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
708
709 let reserialized = content_disposition.to_string();
710 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
711
712 let rfc8187_other =
714 r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
715 let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
716
717 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
718 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
719
720 let reserialized = content_disposition.to_string();
721 assert_eq!(
722 reserialized,
723 r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
724 );
725
726 let rfc8187_with_fallback = r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
728 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
729
730 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
731 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
732
733 let reserialized = content_disposition.to_string();
734 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#);
735 }
736}