1use std::{fmt, ops::Deref, str::FromStr};
4
5use salvo::oapi::ToSchema;
6use serde::Serialize;
7
8use crate::macros::{AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr};
9
10use super::{
11 is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string, unescape_string,
12};
13
14#[derive(ToSchema, Serialize, Debug, Clone, PartialEq, Eq, Default)]
30#[cfg_attr(not(feature = "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 {
43 disposition_type,
44 filename: None,
45 }
46 }
47
48 pub fn with_filename(mut self, filename: Option<String>) -> Self {
50 self.filename = filename;
51 self
52 }
53}
54
55impl fmt::Display for ContentDisposition {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 write!(f, "{}", self.disposition_type)?;
58
59 if let Some(filename) = &self.filename {
60 if filename.is_ascii() {
61 let filename = sanitize_for_ascii_quoted_string(filename);
63
64 write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
66 } else {
67 write!(f, "; filename*={}", rfc8187::encode(filename))?;
69 }
70 }
71
72 Ok(())
73 }
74}
75
76impl TryFrom<&[u8]> for ContentDisposition {
77 type Error = ContentDispositionParseError;
78
79 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
80 let mut pos = 0;
81
82 skip_ascii_whitespaces(value, &mut pos);
83
84 if pos == value.len() {
85 return Err(ContentDispositionParseError::MissingDispositionType);
86 }
87
88 let disposition_type_start = pos;
89
90 while let Some(byte) = value.get(pos) {
92 if byte.is_ascii_whitespace() || *byte == b';' {
93 break;
94 }
95
96 pos += 1;
97 }
98
99 let disposition_type = ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
100
101 let mut filename_ext = None;
105 let mut filename = None;
106
107 while pos != value.len() {
109 if let Some(param) = RawParam::parse_next(value, &mut pos) {
110 if param.name.eq_ignore_ascii_case(b"filename*") {
111 if let Some(value) = param.decode_value() {
112 filename_ext = Some(value);
113 break;
115 }
116 } else if param.name.eq_ignore_ascii_case(b"filename") {
117 if let Some(value) = param.decode_value() {
118 filename = Some(value);
119 }
120 }
121 }
122 }
123
124 Ok(Self {
125 disposition_type,
126 filename: filename_ext.or(filename),
127 })
128 }
129}
130
131impl FromStr for ContentDisposition {
132 type Err = ContentDispositionParseError;
133
134 fn from_str(s: &str) -> Result<Self, Self::Err> {
135 s.as_bytes().try_into()
136 }
137}
138
139struct RawParam<'a> {
141 name: &'a [u8],
142 value: &'a [u8],
143 is_quoted_string: bool,
144}
145
146impl<'a> RawParam<'a> {
147 fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
154 let name = parse_param_name(bytes, pos)?;
155
156 skip_ascii_whitespaces(bytes, pos);
157
158 if *pos == bytes.len() {
159 return None;
161 }
162 if bytes[*pos] != b'=' {
163 *pos = bytes.len();
167 return None;
168 }
169
170 *pos += 1;
172
173 skip_ascii_whitespaces(bytes, pos);
174
175 let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
176
177 Some(Self {
178 name,
179 value,
180 is_quoted_string,
181 })
182 }
183
184 fn decode_value(&self) -> Option<String> {
188 if self.name.ends_with(b"*") {
189 rfc8187::decode(self.value).ok().map(|s| s.into_owned())
190 } else {
191 let s = String::from_utf8_lossy(self.value);
192
193 if self.is_quoted_string {
194 Some(unescape_string(&s))
195 } else {
196 Some(s.into_owned())
197 }
198 }
199 }
200}
201
202fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
206 while let Some(byte) = bytes.get(*pos) {
207 if !byte.is_ascii_whitespace() {
208 break;
209 }
210
211 *pos += 1;
212 }
213}
214
215fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
221 skip_ascii_whitespaces(bytes, pos);
222
223 if *pos == bytes.len() {
224 return None;
226 }
227
228 let name_start = *pos;
229
230 while let Some(byte) = bytes.get(*pos) {
232 if !is_tchar(*byte) {
233 break;
234 }
235
236 *pos += 1;
237 }
238
239 if *pos == bytes.len() {
240 return None;
242 }
243 if bytes[*pos] == b';' {
244 *pos += 1;
247 return None;
248 }
249
250 let name = &bytes[name_start..*pos];
251
252 if name.is_empty() {
253 *pos = bytes.len();
255 return None;
256 }
257
258 Some(name)
259}
260
261fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
268 skip_ascii_whitespaces(bytes, pos);
269
270 if *pos == bytes.len() {
271 return None;
273 }
274
275 let is_quoted_string = bytes[*pos] == b'"';
276 if is_quoted_string {
277 *pos += 1;
279 }
280
281 let value_start = *pos;
282
283 let mut escape_next = false;
285
286 while let Some(byte) = bytes.get(*pos) {
289 if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
290 break;
291 }
292
293 if is_quoted_string && *byte == b'"' && !escape_next {
294 break;
295 }
296
297 escape_next = *byte == b'\\' && !escape_next;
298
299 *pos += 1;
300 }
301
302 let value = &bytes[value_start..*pos];
303
304 if is_quoted_string && *pos != bytes.len() {
305 *pos += 1;
307 }
308
309 skip_ascii_whitespaces(bytes, pos);
310
311 if *pos != bytes.len() {
313 if bytes[*pos] == b';' {
314 *pos += 1;
316 } else {
317 *pos = bytes.len();
321 return None;
322 }
323 }
324
325 Some((value, is_quoted_string))
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
330#[non_exhaustive]
331pub enum ContentDispositionParseError {
332 #[error("disposition type is missing")]
334 MissingDispositionType,
335
336 #[error("invalid disposition type: {0}")]
338 InvalidDispositionType(#[from] TokenStringParseError),
339}
340
341#[derive(
353 ToSchema,
354 Serialize,
355 Clone,
356 Default,
357 AsRefStr,
358 DebugAsRefStr,
359 AsStrAsRefStr,
360 DisplayAsRefStr,
361 PartialOrdAsRefStr,
362 OrdAsRefStr,
363)]
364#[palpo_enum(rename_all = "lowercase")]
365#[non_exhaustive]
366pub enum ContentDispositionType {
367 #[default]
371 Inline,
372
373 Attachment,
375
376 #[doc(hidden)]
377 #[salvo(schema(value_type = String))]
378 _Custom(TokenString),
379}
380
381impl ContentDispositionType {
382 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
384 Self::from_str(s)
385 }
386}
387
388impl From<TokenString> for ContentDispositionType {
389 fn from(value: TokenString) -> Self {
390 if value.eq_ignore_ascii_case("inline") {
391 Self::Inline
392 } else if value.eq_ignore_ascii_case("attachment") {
393 Self::Attachment
394 } else {
395 Self::_Custom(value)
396 }
397 }
398}
399
400impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
401 type Error = TokenStringParseError;
402
403 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
404 if value.eq_ignore_ascii_case(b"inline") {
405 Ok(Self::Inline)
406 } else if value.eq_ignore_ascii_case(b"attachment") {
407 Ok(Self::Attachment)
408 } else {
409 TokenString::try_from(value).map(Self::_Custom)
410 }
411 }
412}
413
414impl FromStr for ContentDispositionType {
415 type Err = TokenStringParseError;
416
417 fn from_str(s: &str) -> Result<Self, Self::Err> {
418 s.as_bytes().try_into()
419 }
420}
421
422impl PartialEq<ContentDispositionType> for ContentDispositionType {
423 fn eq(&self, other: &ContentDispositionType) -> bool {
424 self.as_str().eq_ignore_ascii_case(other.as_str())
425 }
426}
427
428impl Eq for ContentDispositionType {}
429
430impl PartialEq<TokenString> for ContentDispositionType {
431 fn eq(&self, other: &TokenString) -> bool {
432 self.as_str().eq_ignore_ascii_case(other.as_str())
433 }
434}
435
436impl<'a> PartialEq<&'a str> for ContentDispositionType {
437 fn eq(&self, other: &&'a str) -> bool {
438 self.as_str().eq_ignore_ascii_case(other)
439 }
440}
441
442#[derive(
448 Clone, Serialize, PartialEq, Eq, DebugAsRefStr, AsStrAsRefStr, DisplayAsRefStr, PartialOrdAsRefStr, OrdAsRefStr,
449)]
450pub struct TokenString(Box<str>);
451
452impl TokenString {
453 pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
455 Self::from_str(s)
456 }
457}
458
459impl Deref for TokenString {
460 type Target = str;
461
462 fn deref(&self) -> &Self::Target {
463 self.as_ref()
464 }
465}
466
467impl AsRef<str> for TokenString {
468 fn as_ref(&self) -> &str {
469 &self.0
470 }
471}
472
473impl<'a> PartialEq<&'a str> for TokenString {
474 fn eq(&self, other: &&'a str) -> bool {
475 self.as_str().eq(*other)
476 }
477}
478
479impl<'a> TryFrom<&'a [u8]> for TokenString {
480 type Error = TokenStringParseError;
481
482 fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
483 if value.is_empty() {
484 Err(TokenStringParseError::Empty)
485 } else if is_token(value) {
486 let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
487 Ok(Self(s.into()))
488 } else {
489 Err(TokenStringParseError::InvalidCharacter)
490 }
491 }
492}
493
494impl FromStr for TokenString {
495 type Err = TokenStringParseError;
496
497 fn from_str(s: &str) -> Result<Self, Self::Err> {
498 s.as_bytes().try_into()
499 }
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
504#[non_exhaustive]
505pub enum TokenStringParseError {
506 #[error("string is empty")]
508 Empty,
509
510 #[error("string contains invalid character")]
512 InvalidCharacter,
513}
514
515#[cfg(test)]
516mod tests {
517 use std::str::FromStr;
518
519 use super::{ContentDisposition, ContentDispositionType};
520
521 #[test]
522 fn parse_content_disposition_valid() {
523 let content_disposition = ContentDisposition::from_str("inline").unwrap();
525 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
526 assert_eq!(content_disposition.filename, None);
527
528 let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
530 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
531 assert_eq!(content_disposition.filename, None);
532
533 let content_disposition = ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
535 assert_eq!(content_disposition.disposition_type.as_str(), "custom");
536 assert_eq!(content_disposition.filename, None);
537
538 let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
540 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
541 assert_eq!(content_disposition.filename.unwrap(), "my_file");
542
543 let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
545 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
546 assert_eq!(content_disposition.filename.unwrap(), "my_file");
547
548 let content_disposition = ContentDisposition::from_str(" INLINE ;FILENAME = my_file ").unwrap();
550 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
551 assert_eq!(content_disposition.filename.unwrap(), "my_file");
552
553 let content_disposition =
555 ContentDisposition::from_str(r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#)
556 .unwrap();
557 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
558 assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
559
560 let content_disposition =
562 ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#).unwrap();
563 assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
564 assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
565 }
566
567 #[test]
568 fn parse_content_disposition_invalid_type() {
569 ContentDisposition::from_str("").unwrap_err();
571
572 ContentDisposition::from_str("; foo=bar").unwrap_err();
574 }
575
576 #[test]
577 fn parse_content_disposition_invalid_parameters() {
578 let content_disposition = 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 = ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
585 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
586 assert_eq!(content_disposition.filename.unwrap(), "my_file");
587
588 let content_disposition = ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
590 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
591 assert_eq!(content_disposition.filename, None);
592 }
593
594 #[test]
595 fn content_disposition_serialize() {
596 let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
598 let serialized = content_disposition.to_string();
599 assert_eq!(serialized, "inline");
600
601 let content_disposition =
603 ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my_file".to_owned()));
604 let serialized = content_disposition.to_string();
605 assert_eq!(serialized, "attachment; filename=my_file");
606
607 let content_disposition =
609 ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my file".to_owned()));
610 let serialized = content_disposition.to_string();
611 assert_eq!(serialized, r#"attachment; filename="my file""#);
612
613 let content_disposition =
615 ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some(r#""my"\file"#.to_owned()));
616 let serialized = content_disposition.to_string();
617 assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
618
619 let content_disposition =
621 ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("Mi Corazón".to_owned()));
622 let serialized = content_disposition.to_string();
623 assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
624
625 let content_disposition =
627 ContentDisposition::new(ContentDispositionType::Attachment).with_filename(Some("my\r\nfile".to_owned()));
628 let serialized = content_disposition.to_string();
629 assert_eq!(serialized, "attachment; filename=myfile");
630 }
631
632 #[test]
633 fn rfc6266_examples() {
634 let unquoted = "Attachment; filename=example.html";
636 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
637
638 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
639 assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
640
641 let reserialized = content_disposition.to_string();
642 assert_eq!(reserialized, "attachment; filename=example.html");
643
644 let quoted = r#"INLINE; FILENAME= "an example.html""#;
646 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
647
648 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
649 assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
650
651 let reserialized = content_disposition.to_string();
652 assert_eq!(reserialized, r#"inline; filename="an example.html""#);
653
654 let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
656 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
657
658 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
659 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
660
661 let reserialized = content_disposition.to_string();
662 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
663
664 let rfc8187_with_fallback = r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
666 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
667
668 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
669 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
670 }
671
672 #[test]
673 fn rfc8187_examples() {
674 let unquoted = "attachment; foo= bar; filename=Economy";
681 let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
682
683 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
684 assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
685
686 let reserialized = content_disposition.to_string();
687 assert_eq!(reserialized, "attachment; filename=Economy");
688
689 let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
691 let content_disposition = ContentDisposition::from_str(quoted).unwrap();
692
693 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
694 assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
695
696 let reserialized = content_disposition.to_string();
697 assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
698
699 let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
701 let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
702
703 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
704 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
705
706 let reserialized = content_disposition.to_string();
707 assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
708
709 let rfc8187_other = r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
711 let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
712
713 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
714 assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
715
716 let reserialized = content_disposition.to_string();
717 assert_eq!(
718 reserialized,
719 r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
720 );
721
722 let rfc8187_with_fallback =
724 r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
725 let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
726
727 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
728 assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
729
730 let reserialized = content_disposition.to_string();
731 assert_eq!(
732 reserialized,
733 r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#
734 );
735 }
736}