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};
11use crate::PrivOwnedStr;
12
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
29#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
30pub struct ContentDisposition {
31 pub disposition_type: ContentDispositionType,
33
34 pub filename: Option<String>,
36}
37
38impl ContentDisposition {
39 pub fn new(disposition_type: ContentDispositionType) -> Self {
41 Self { disposition_type, filename: None }
42 }
43
44 pub fn with_filename(mut self, filename: Option<String>) -> Self {
46 self.filename = filename;
47 self
48 }
49}
50
51impl fmt::Display for ContentDisposition {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "{}", self.disposition_type)?;
54
55 if let Some(filename) = &self.filename {
56 if filename.is_ascii() {
57 let filename = sanitize_for_ascii_quoted_string(filename);
59
60 write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
62 } else {
63 write!(f, "; filename*={}", rfc8187::encode(filename))?;
65 }
66 }
67
68 Ok(())
69 }
70}
71
72impl TryFrom<&[u8]> for ContentDisposition {
73 type Error = ContentDispositionParseError;
74
75 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
76 let mut pos = 0;
77
78 skip_ascii_whitespaces(value, &mut pos);
79
80 if pos == value.len() {
81 return Err(ContentDispositionParseError::MissingDispositionType);
82 }
83
84 let disposition_type_start = pos;
85
86 while let Some(byte) = value.get(pos) {
88 if byte.is_ascii_whitespace() || *byte == b';' {
89 break;
90 }
91
92 pos += 1;
93 }
94
95 let disposition_type =
96 ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
97
98 let mut filename_ext = None;
102 let mut filename = None;
103
104 while pos != value.len() {
106 if let Some(param) = RawParam::parse_next(value, &mut pos) {
107 if param.name.eq_ignore_ascii_case(b"filename*")
108 && let Some(value) = param.decode_value()
109 {
110 filename_ext = Some(value);
111 break;
113 } else if param.name.eq_ignore_ascii_case(b"filename")
114 && let Some(value) = param.decode_value()
115 {
116 filename = Some(value);
117 }
118 }
119 }
120
121 Ok(Self { disposition_type, filename: filename_ext.or(filename) })
122 }
123}
124
125impl FromStr for ContentDisposition {
126 type Err = ContentDispositionParseError;
127
128 fn from_str(s: &str) -> Result<Self, Self::Err> {
129 s.as_bytes().try_into()
130 }
131}
132
133struct RawParam<'a> {
135 name: &'a [u8],
136 value: &'a [u8],
137 is_quoted_string: bool,
138}
139
140impl<'a> RawParam<'a> {
141 fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
148 let name = parse_param_name(bytes, pos)?;
149
150 skip_ascii_whitespaces(bytes, pos);
151
152 if *pos == bytes.len() {
153 return None;
155 }
156 if bytes[*pos] != b'=' {
157 *pos = bytes.len();
161 return None;
162 }
163
164 *pos += 1;
166
167 skip_ascii_whitespaces(bytes, pos);
168
169 let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
170
171 Some(Self { name, value, is_quoted_string })
172 }
173
174 fn decode_value(&self) -> Option<String> {
178 if self.name.ends_with(b"*") {
179 rfc8187::decode(self.value).ok().map(|s| s.into_owned())
180 } else {
181 let s = String::from_utf8_lossy(self.value);
182
183 if self.is_quoted_string { Some(unescape_string(&s)) } else { Some(s.into_owned()) }
184 }
185 }
186}
187
188fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
192 while let Some(byte) = bytes.get(*pos) {
193 if !byte.is_ascii_whitespace() {
194 break;
195 }
196
197 *pos += 1;
198 }
199}
200
201fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
207 skip_ascii_whitespaces(bytes, pos);
208
209 if *pos == bytes.len() {
210 return None;
212 }
213
214 let name_start = *pos;
215
216 while let Some(byte) = bytes.get(*pos) {
218 if !is_tchar(*byte) {
219 break;
220 }
221
222 *pos += 1;
223 }
224
225 if *pos == bytes.len() {
226 return None;
228 }
229 if bytes[*pos] == b';' {
230 *pos += 1;
233 return None;
234 }
235
236 let name = &bytes[name_start..*pos];
237
238 if name.is_empty() {
239 *pos = bytes.len();
241 return None;
242 }
243
244 Some(name)
245}
246
247fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
254 skip_ascii_whitespaces(bytes, pos);
255
256 if *pos == bytes.len() {
257 return None;
259 }
260
261 let is_quoted_string = bytes[*pos] == b'"';
262 if is_quoted_string {
263 *pos += 1;
265 }
266
267 let value_start = *pos;
268
269 let mut escape_next = false;
271
272 while let Some(byte) = bytes.get(*pos) {
275 if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
276 break;
277 }
278
279 if is_quoted_string && *byte == b'"' && !escape_next {
280 break;
281 }
282
283 escape_next = *byte == b'\\' && !escape_next;
284
285 *pos += 1;
286 }
287
288 let value = &bytes[value_start..*pos];
289
290 if is_quoted_string && *pos != bytes.len() {
291 *pos += 1;
293 }
294
295 skip_ascii_whitespaces(bytes, pos);
296
297 if *pos != bytes.len() {
299 if bytes[*pos] == b';' {
300 *pos += 1;
302 } else {
303 *pos = bytes.len();
307 return None;
308 }
309 }
310
311 Some((value, is_quoted_string))
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
316#[non_exhaustive]
317pub enum ContentDispositionParseError {
318 #[error("disposition type is missing")]
320 MissingDispositionType,
321
322 #[error("invalid disposition type: {0}")]
324 InvalidDispositionType(#[from] TokenStringParseError),
325}
326
327#[derive(Clone, Default, AsRefStr, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
339#[ruma_enum(rename_all = "lowercase")]
340#[non_exhaustive]
341pub enum ContentDispositionType {
342 #[default]
346 Inline,
347
348 Attachment,
350
351 #[doc(hidden)]
352 _Custom(PrivOwnedStr),
353}
354
355impl ContentDispositionType {
356 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
358 Self::from_str(s)
359 }
360}
361
362impl From<TokenString> for ContentDispositionType {
363 fn from(value: TokenString) -> Self {
364 if value.eq_ignore_ascii_case("inline") {
365 Self::Inline
366 } else if value.eq_ignore_ascii_case("attachment") {
367 Self::Attachment
368 } else {
369 Self::_Custom(PrivOwnedStr(value.0))
370 }
371 }
372}
373
374impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
375 type Error = TokenStringParseError;
376
377 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
378 if value.eq_ignore_ascii_case(b"inline") {
379 Ok(Self::Inline)
380 } else if value.eq_ignore_ascii_case(b"attachment") {
381 Ok(Self::Attachment)
382 } else {
383 TokenString::try_from(value).map(Into::into)
384 }
385 }
386}
387
388impl FromStr for ContentDispositionType {
389 type Err = TokenStringParseError;
390
391 fn from_str(s: &str) -> Result<Self, Self::Err> {
392 s.as_bytes().try_into()
393 }
394}
395
396impl PartialEq<ContentDispositionType> for ContentDispositionType {
397 fn eq(&self, other: &ContentDispositionType) -> bool {
398 self.as_str().eq_ignore_ascii_case(other.as_str())
399 }
400}
401
402impl Eq for ContentDispositionType {}
403
404impl PartialEq<TokenString> for ContentDispositionType {
405 fn eq(&self, other: &TokenString) -> bool {
406 self.as_str().eq_ignore_ascii_case(other.as_str())
407 }
408}
409
410impl<'a> PartialEq<&'a str> for ContentDispositionType {
411 fn eq(&self, other: &&'a str) -> bool {
412 self.as_str().eq_ignore_ascii_case(other)
413 }
414}
415
416#[derive(Clone, PartialEq, Eq, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, OrdAsRefStr)]
422pub struct TokenString(Box<str>);
423
424impl TokenString {
425 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
427 Self::from_str(s)
428 }
429}
430
431impl Deref for TokenString {
432 type Target = str;
433
434 fn deref(&self) -> &Self::Target {
435 self.as_ref()
436 }
437}
438
439impl AsRef<str> for TokenString {
440 fn as_ref(&self) -> &str {
441 &self.0
442 }
443}
444
445impl<'a> PartialEq<&'a str> for TokenString {
446 fn eq(&self, other: &&'a str) -> bool {
447 self.as_str().eq(*other)
448 }
449}
450
451impl<'a> TryFrom<&'a [u8]> for TokenString {
452 type Error = TokenStringParseError;
453
454 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
455 if value.is_empty() {
456 Err(TokenStringParseError::Empty)
457 } else if is_token(value) {
458 let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
459 Ok(Self(s.into()))
460 } else {
461 Err(TokenStringParseError::InvalidCharacter)
462 }
463 }
464}
465
466impl FromStr for TokenString {
467 type Err = TokenStringParseError;
468
469 fn from_str(s: &str) -> Result<Self, Self::Err> {
470 s.as_bytes().try_into()
471 }
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
476#[non_exhaustive]
477pub enum TokenStringParseError {
478 #[error("string is empty")]
480 Empty,
481
482 #[error("string contains invalid character")]
484 InvalidCharacter,
485}
486
487#[cfg(test)]
488mod tests {
489 use std::str::FromStr;
490
491 use super::{ContentDisposition, ContentDispositionType};
492
493 #[test]
494 fn parse_content_disposition_valid() {
495 let content_disposition = ContentDisposition::from_str("inline").unwrap();
497 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
498 assert_eq!(content_disposition.filename, None);
499
500 let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
502 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
503 assert_eq!(content_disposition.filename, None);
504
505 let content_disposition =
507 ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
508 assert_eq!(content_disposition.disposition_type.as_str(), "custom");
509 assert_eq!(content_disposition.filename, None);
510
511 let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
513 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
514 assert_eq!(content_disposition.filename.unwrap(), "my_file");
515
516 let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
518 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
519 assert_eq!(content_disposition.filename.unwrap(), "my_file");
520
521 let content_disposition =
523 ContentDisposition::from_str(" INLINE ;FILENAME = my_file ").unwrap();
524 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
525 assert_eq!(content_disposition.filename.unwrap(), "my_file");
526
527 let content_disposition = ContentDisposition::from_str(
529 r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#,
530 )
531 .unwrap();
532 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
533 assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
534
535 let content_disposition =
537 ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#)
538 .unwrap();
539 assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
540 assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
541 }
542
543 #[test]
544 fn parse_content_disposition_invalid_type() {
545 ContentDisposition::from_str("").unwrap_err();
547
548 ContentDisposition::from_str("; foo=bar").unwrap_err();
550 }
551
552 #[test]
553 fn parse_content_disposition_invalid_parameters() {
554 let content_disposition =
556 ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
557 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
558 assert_eq!(content_disposition.filename, None);
559
560 let content_disposition =
562 ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
563 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
564 assert_eq!(content_disposition.filename.unwrap(), "my_file");
565
566 let content_disposition =
568 ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
569 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
570 assert_eq!(content_disposition.filename, None);
571 }
572
573 #[test]
574 fn content_disposition_serialize() {
575 let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
577 let serialized = content_disposition.to_string();
578 assert_eq!(serialized, "inline");
579
580 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
582 .with_filename(Some("my_file".to_owned()));
583 let serialized = content_disposition.to_string();
584 assert_eq!(serialized, "attachment; filename=my_file");
585
586 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
588 .with_filename(Some("my file".to_owned()));
589 let serialized = content_disposition.to_string();
590 assert_eq!(serialized, r#"attachment; filename="my file""#);
591
592 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
594 .with_filename(Some(r#""my"\file"#.to_owned()));
595 let serialized = content_disposition.to_string();
596 assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
597
598 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
600 .with_filename(Some("Mi Corazón".to_owned()));
601 let serialized = content_disposition.to_string();
602 assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
603
604 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
606 .with_filename(Some("my\r\nfile".to_owned()));
607 let serialized = content_disposition.to_string();
608 assert_eq!(serialized, "attachment; filename=myfile");
609 }
610
611 #[test]
612 fn rfc6266_examples() {
613 let unquoted = "Attachment; filename=example.html";
615 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
616
617 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
618 assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
619
620 let reserialized = content_disposition.to_string();
621 assert_eq!(reserialized, "attachment; filename=example.html");
622
623 let quoted = r#"INLINE; FILENAME= "an example.html""#;
625 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
626
627 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
628 assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
629
630 let reserialized = content_disposition.to_string();
631 assert_eq!(reserialized, r#"inline; filename="an example.html""#);
632
633 let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
635 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
636
637 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
638 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
639
640 let reserialized = content_disposition.to_string();
641 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
642
643 let rfc8187_with_fallback =
645 r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
646 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
647
648 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
649 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
650 }
651
652 #[test]
653 fn rfc8187_examples() {
654 let unquoted = "attachment; foo= bar; filename=Economy";
661 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
662
663 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
664 assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
665
666 let reserialized = content_disposition.to_string();
667 assert_eq!(reserialized, "attachment; filename=Economy");
668
669 let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
671 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
672
673 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
674 assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
675
676 let reserialized = content_disposition.to_string();
677 assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
678
679 let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
681 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
682
683 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
684 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
685
686 let reserialized = content_disposition.to_string();
687 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
688
689 let rfc8187_other =
691 r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
692 let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
693
694 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
695 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
696
697 let reserialized = content_disposition.to_string();
698 assert_eq!(
699 reserialized,
700 r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
701 );
702
703 let rfc8187_with_fallback = r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
705 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
706
707 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
708 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
709
710 let reserialized = content_disposition.to_string();
711 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#);
712 }
713}