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 && let Some(value) = param.decode_value()
108 {
109 filename_ext = Some(value);
110 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
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 { Some(unescape_string(&s)) } else { Some(s.into_owned()) }
183 }
184 }
185}
186
187fn 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
200fn 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 return None;
211 }
212
213 let name_start = *pos;
214
215 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 return None;
227 }
228 if bytes[*pos] == b';' {
229 *pos += 1;
232 return None;
233 }
234
235 let name = &bytes[name_start..*pos];
236
237 if name.is_empty() {
238 *pos = bytes.len();
240 return None;
241 }
242
243 Some(name)
244}
245
246fn 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 return None;
258 }
259
260 let is_quoted_string = bytes[*pos] == b'"';
261 if is_quoted_string {
262 *pos += 1;
264 }
265
266 let value_start = *pos;
267
268 let mut escape_next = false;
270
271 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 *pos += 1;
292 }
293
294 skip_ascii_whitespaces(bytes, pos);
295
296 if *pos != bytes.len() {
298 if bytes[*pos] == b';' {
299 *pos += 1;
301 } else {
302 *pos = bytes.len();
306 return None;
307 }
308 }
309
310 Some((value, is_quoted_string))
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
315#[non_exhaustive]
316pub enum ContentDispositionParseError {
317 #[error("disposition type is missing")]
319 MissingDispositionType,
320
321 #[error("invalid disposition type: {0}")]
323 InvalidDispositionType(#[from] TokenStringParseError),
324}
325
326#[derive(Clone, Default, AsRefStr, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
338#[ruma_enum(rename_all = "lowercase")]
339#[non_exhaustive]
340pub enum ContentDispositionType {
341 #[default]
345 Inline,
346
347 Attachment,
349
350 #[doc(hidden)]
351 _Custom(TokenString),
352}
353
354impl ContentDispositionType {
355 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#[derive(Clone, PartialEq, Eq, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
421pub struct TokenString(Box<str>);
422
423impl TokenString {
424 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
475#[non_exhaustive]
476pub enum TokenStringParseError {
477 #[error("string is empty")]
479 Empty,
480
481 #[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 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 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 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 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 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 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 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 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 ContentDisposition::from_str("").unwrap_err();
546
547 ContentDisposition::from_str("; foo=bar").unwrap_err();
549 }
550
551 #[test]
552 fn parse_content_disposition_invalid_parameters() {
553 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 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 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 let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
576 let serialized = content_disposition.to_string();
577 assert_eq!(serialized, "inline");
578
579 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 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 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 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 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 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 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 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 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 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 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 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 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 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}