1 // # References
2 //
3 // "The Content-Disposition Header Field" https://www.ietf.org/rfc/rfc2183.txt
4 // "The Content-Disposition Header Field in the Hypertext Transfer Protocol (HTTP)" https://www.ietf.org/rfc/rfc6266.txt
5 // "Returning Values from Forms: multipart/form-data" https://www.ietf.org/rfc/rfc7578.txt
6 // Browser conformance tests at: http://greenbytes.de/tech/tc2231/
7 // IANA assignment: http://www.iana.org/assignments/cont-disp/cont-disp.xhtml
8
9 use lazy_static::lazy_static;
10 use regex::Regex;
11 use std::fmt::{self, Write};
12
13 use crate::header::{self, ExtendedValue, Header, IntoHeaderValue, Writer};
14
15 /// Split at the index of the first `needle` if it exists or at the end.
split_once(haystack: &str, needle: char) -> (&str, &str)16 fn split_once(haystack: &str, needle: char) -> (&str, &str) {
17 haystack.find(needle).map_or_else(
18 || (haystack, ""),
19 |sc| {
20 let (first, last) = haystack.split_at(sc);
21 (first, last.split_at(1).1)
22 },
23 )
24 }
25
26 /// Split at the index of the first `needle` if it exists or at the end, trim the right of the
27 /// first part and the left of the last part.
split_once_and_trim(haystack: &str, needle: char) -> (&str, &str)28 fn split_once_and_trim(haystack: &str, needle: char) -> (&str, &str) {
29 let (first, last) = split_once(haystack, needle);
30 (first.trim_end(), last.trim_start())
31 }
32
33 /// The implied disposition of the content of the HTTP body.
34 #[derive(Clone, Debug, PartialEq)]
35 pub enum DispositionType {
36 /// Inline implies default processing
37 Inline,
38 /// Attachment implies that the recipient should prompt the user to save the response locally,
39 /// rather than process it normally (as per its media type).
40 Attachment,
41 /// Used in *multipart/form-data* as defined in
42 /// [RFC7578](https://tools.ietf.org/html/rfc7578) to carry the field name and the file name.
43 FormData,
44 /// Extension type. Should be handled by recipients the same way as Attachment
45 Ext(String),
46 }
47
48 impl<'a> From<&'a str> for DispositionType {
from(origin: &'a str) -> DispositionType49 fn from(origin: &'a str) -> DispositionType {
50 if origin.eq_ignore_ascii_case("inline") {
51 DispositionType::Inline
52 } else if origin.eq_ignore_ascii_case("attachment") {
53 DispositionType::Attachment
54 } else if origin.eq_ignore_ascii_case("form-data") {
55 DispositionType::FormData
56 } else {
57 DispositionType::Ext(origin.to_owned())
58 }
59 }
60 }
61
62 /// Parameter in [`ContentDisposition`].
63 ///
64 /// # Examples
65 /// ```
66 /// use actix_http::http::header::DispositionParam;
67 ///
68 /// let param = DispositionParam::Filename(String::from("sample.txt"));
69 /// assert!(param.is_filename());
70 /// assert_eq!(param.as_filename().unwrap(), "sample.txt");
71 /// ```
72 #[derive(Clone, Debug, PartialEq)]
73 #[allow(clippy::large_enum_variant)]
74 pub enum DispositionParam {
75 /// For [`DispositionType::FormData`] (i.e. *multipart/form-data*), the name of an field from
76 /// the form.
77 Name(String),
78 /// A plain file name.
79 ///
80 /// It is [not supposed](https://tools.ietf.org/html/rfc6266#appendix-D) to contain any
81 /// non-ASCII characters when used in a *Content-Disposition* HTTP response header, where
82 /// [`FilenameExt`](DispositionParam::FilenameExt) with charset UTF-8 may be used instead
83 /// in case there are Unicode characters in file names.
84 Filename(String),
85 /// An extended file name. It must not exist for `ContentType::Formdata` according to
86 /// [RFC7578 Section 4.2](https://tools.ietf.org/html/rfc7578#section-4.2).
87 FilenameExt(ExtendedValue),
88 /// An unrecognized regular parameter as defined in
89 /// [RFC5987](https://tools.ietf.org/html/rfc5987) as *reg-parameter*, in
90 /// [RFC6266](https://tools.ietf.org/html/rfc6266) as *token "=" value*. Recipients should
91 /// ignore unrecognizable parameters.
92 Unknown(String, String),
93 /// An unrecognized extended paramater as defined in
94 /// [RFC5987](https://tools.ietf.org/html/rfc5987) as *ext-parameter*, in
95 /// [RFC6266](https://tools.ietf.org/html/rfc6266) as *ext-token "=" ext-value*. The single
96 /// trailling asterisk is not included. Recipients should ignore unrecognizable parameters.
97 UnknownExt(String, ExtendedValue),
98 }
99
100 impl DispositionParam {
101 /// Returns `true` if the paramater is [`Name`](DispositionParam::Name).
102 #[inline]
is_name(&self) -> bool103 pub fn is_name(&self) -> bool {
104 self.as_name().is_some()
105 }
106
107 /// Returns `true` if the paramater is [`Filename`](DispositionParam::Filename).
108 #[inline]
is_filename(&self) -> bool109 pub fn is_filename(&self) -> bool {
110 self.as_filename().is_some()
111 }
112
113 /// Returns `true` if the paramater is [`FilenameExt`](DispositionParam::FilenameExt).
114 #[inline]
is_filename_ext(&self) -> bool115 pub fn is_filename_ext(&self) -> bool {
116 self.as_filename_ext().is_some()
117 }
118
119 /// Returns `true` if the paramater is [`Unknown`](DispositionParam::Unknown) and the `name`
120 #[inline]
121 /// matches.
is_unknown<T: AsRef<str>>(&self, name: T) -> bool122 pub fn is_unknown<T: AsRef<str>>(&self, name: T) -> bool {
123 self.as_unknown(name).is_some()
124 }
125
126 /// Returns `true` if the paramater is [`UnknownExt`](DispositionParam::UnknownExt) and the
127 /// `name` matches.
128 #[inline]
is_unknown_ext<T: AsRef<str>>(&self, name: T) -> bool129 pub fn is_unknown_ext<T: AsRef<str>>(&self, name: T) -> bool {
130 self.as_unknown_ext(name).is_some()
131 }
132
133 /// Returns the name if applicable.
134 #[inline]
as_name(&self) -> Option<&str>135 pub fn as_name(&self) -> Option<&str> {
136 match self {
137 DispositionParam::Name(ref name) => Some(name.as_str()),
138 _ => None,
139 }
140 }
141
142 /// Returns the filename if applicable.
143 #[inline]
as_filename(&self) -> Option<&str>144 pub fn as_filename(&self) -> Option<&str> {
145 match self {
146 DispositionParam::Filename(ref filename) => Some(filename.as_str()),
147 _ => None,
148 }
149 }
150
151 /// Returns the filename* if applicable.
152 #[inline]
as_filename_ext(&self) -> Option<&ExtendedValue>153 pub fn as_filename_ext(&self) -> Option<&ExtendedValue> {
154 match self {
155 DispositionParam::FilenameExt(ref value) => Some(value),
156 _ => None,
157 }
158 }
159
160 /// Returns the value of the unrecognized regular parameter if it is
161 /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
162 #[inline]
as_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str>163 pub fn as_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str> {
164 match self {
165 DispositionParam::Unknown(ref ext_name, ref value)
166 if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
167 {
168 Some(value.as_str())
169 }
170 _ => None,
171 }
172 }
173
174 /// Returns the value of the unrecognized extended parameter if it is
175 /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
176 #[inline]
as_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue>177 pub fn as_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue> {
178 match self {
179 DispositionParam::UnknownExt(ref ext_name, ref value)
180 if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
181 {
182 Some(value)
183 }
184 _ => None,
185 }
186 }
187 }
188
189 /// A *Content-Disposition* header. It is compatible to be used either as
190 /// [a response header for the main body](https://mdn.io/Content-Disposition#As_a_response_header_for_the_main_body)
191 /// as (re)defined in [RFC6266](https://tools.ietf.org/html/rfc6266), or as
192 /// [a header for a multipart body](https://mdn.io/Content-Disposition#As_a_header_for_a_multipart_body)
193 /// as (re)defined in [RFC7587](https://tools.ietf.org/html/rfc7578).
194 ///
195 /// In a regular HTTP response, the *Content-Disposition* response header is a header indicating if
196 /// the content is expected to be displayed *inline* in the browser, that is, as a Web page or as
197 /// part of a Web page, or as an attachment, that is downloaded and saved locally, and also can be
198 /// used to attach additional metadata, such as the filename to use when saving the response payload
199 /// locally.
200 ///
201 /// In a *multipart/form-data* body, the HTTP *Content-Disposition* general header is a header that
202 /// can be used on the subpart of a multipart body to give information about the field it applies to.
203 /// The subpart is delimited by the boundary defined in the *Content-Type* header. Used on the body
204 /// itself, *Content-Disposition* has no effect.
205 ///
206 /// # ABNF
207
208 /// ```text
209 /// content-disposition = "Content-Disposition" ":"
210 /// disposition-type *( ";" disposition-parm )
211 ///
212 /// disposition-type = "inline" | "attachment" | disp-ext-type
213 /// ; case-insensitive
214 ///
215 /// disp-ext-type = token
216 ///
217 /// disposition-parm = filename-parm | disp-ext-parm
218 ///
219 /// filename-parm = "filename" "=" value
220 /// | "filename*" "=" ext-value
221 ///
222 /// disp-ext-parm = token "=" value
223 /// | ext-token "=" ext-value
224 ///
225 /// ext-token = <the characters in token, followed by "*">
226 /// ```
227 ///
228 /// # Note
229 ///
230 /// filename is [not supposed](https://tools.ietf.org/html/rfc6266#appendix-D) to contain any
231 /// non-ASCII characters when used in a *Content-Disposition* HTTP response header, where
232 /// filename* with charset UTF-8 may be used instead in case there are Unicode characters in file
233 /// names.
234 /// filename is [acceptable](https://tools.ietf.org/html/rfc7578#section-4.2) to be UTF-8 encoded
235 /// directly in a *Content-Disposition* header for *multipart/form-data*, though.
236 ///
237 /// filename* [must not](https://tools.ietf.org/html/rfc7578#section-4.2) be used within
238 /// *multipart/form-data*.
239 ///
240 /// # Example
241 ///
242 /// ```
243 /// use actix_http::http::header::{
244 /// Charset, ContentDisposition, DispositionParam, DispositionType,
245 /// ExtendedValue,
246 /// };
247 ///
248 /// let cd1 = ContentDisposition {
249 /// disposition: DispositionType::Attachment,
250 /// parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
251 /// charset: Charset::Iso_8859_1, // The character set for the bytes of the filename
252 /// language_tag: None, // The optional language tag (see `language-tag` crate)
253 /// value: b"\xa9 Copyright 1989.txt".to_vec(), // the actual bytes of the filename
254 /// })],
255 /// };
256 /// assert!(cd1.is_attachment());
257 /// assert!(cd1.get_filename_ext().is_some());
258 ///
259 /// let cd2 = ContentDisposition {
260 /// disposition: DispositionType::FormData,
261 /// parameters: vec![
262 /// DispositionParam::Name(String::from("file")),
263 /// DispositionParam::Filename(String::from("bill.odt")),
264 /// ],
265 /// };
266 /// assert_eq!(cd2.get_name(), Some("file")); // field name
267 /// assert_eq!(cd2.get_filename(), Some("bill.odt"));
268 ///
269 /// // HTTP response header with Unicode characters in file names
270 /// let cd3 = ContentDisposition {
271 /// disposition: DispositionType::Attachment,
272 /// parameters: vec![
273 /// DispositionParam::FilenameExt(ExtendedValue {
274 /// charset: Charset::Ext(String::from("UTF-8")),
275 /// language_tag: None,
276 /// value: String::from("\u{1f600}.svg").into_bytes(),
277 /// }),
278 /// // fallback for better compatibility
279 /// DispositionParam::Filename(String::from("Grinning-Face-Emoji.svg"))
280 /// ],
281 /// };
282 /// assert_eq!(cd3.get_filename_ext().map(|ev| ev.value.as_ref()),
283 /// Some("\u{1f600}.svg".as_bytes()));
284 /// ```
285 ///
286 /// # WARN
287 /// If "filename" parameter is supplied, do not use the file name blindly, check and possibly
288 /// change to match local file system conventions if applicable, and do not use directory path
289 /// information that may be present. See [RFC2183](https://tools.ietf.org/html/rfc2183#section-2.3)
290 /// .
291 #[derive(Clone, Debug, PartialEq)]
292 pub struct ContentDisposition {
293 /// The disposition type
294 pub disposition: DispositionType,
295 /// Disposition parameters
296 pub parameters: Vec<DispositionParam>,
297 }
298
299 impl ContentDisposition {
300 /// Parse a raw Content-Disposition header value.
from_raw(hv: &header::HeaderValue) -> Result<Self, crate::error::ParseError>301 pub fn from_raw(hv: &header::HeaderValue) -> Result<Self, crate::error::ParseError> {
302 // `header::from_one_raw_str` invokes `hv.to_str` which assumes `hv` contains only visible
303 // ASCII characters. So `hv.as_bytes` is necessary here.
304 let hv = String::from_utf8(hv.as_bytes().to_vec())
305 .map_err(|_| crate::error::ParseError::Header)?;
306 let (disp_type, mut left) = split_once_and_trim(hv.as_str().trim(), ';');
307 if disp_type.is_empty() {
308 return Err(crate::error::ParseError::Header);
309 }
310 let mut cd = ContentDisposition {
311 disposition: disp_type.into(),
312 parameters: Vec::new(),
313 };
314
315 while !left.is_empty() {
316 let (param_name, new_left) = split_once_and_trim(left, '=');
317 if param_name.is_empty() || param_name == "*" || new_left.is_empty() {
318 return Err(crate::error::ParseError::Header);
319 }
320 left = new_left;
321 if param_name.ends_with('*') {
322 // extended parameters
323 let param_name = ¶m_name[..param_name.len() - 1]; // trim asterisk
324 let (ext_value, new_left) = split_once_and_trim(left, ';');
325 left = new_left;
326 let ext_value = header::parse_extended_value(ext_value)?;
327
328 let param = if param_name.eq_ignore_ascii_case("filename") {
329 DispositionParam::FilenameExt(ext_value)
330 } else {
331 DispositionParam::UnknownExt(param_name.to_owned(), ext_value)
332 };
333 cd.parameters.push(param);
334 } else {
335 // regular parameters
336 let value = if left.starts_with('\"') {
337 // quoted-string: defined in RFC6266 -> RFC2616 Section 3.6
338 let mut escaping = false;
339 let mut quoted_string = vec![];
340 let mut end = None;
341 // search for closing quote
342 for (i, &c) in left.as_bytes().iter().skip(1).enumerate() {
343 if escaping {
344 escaping = false;
345 quoted_string.push(c);
346 } else if c == 0x5c {
347 // backslash
348 escaping = true;
349 } else if c == 0x22 {
350 // double quote
351 end = Some(i + 1); // cuz skipped 1 for the leading quote
352 break;
353 } else {
354 quoted_string.push(c);
355 }
356 }
357 left = &left[end.ok_or(crate::error::ParseError::Header)? + 1..];
358 left = split_once(left, ';').1.trim_start();
359 // In fact, it should not be Err if the above code is correct.
360 String::from_utf8(quoted_string)
361 .map_err(|_| crate::error::ParseError::Header)?
362 } else {
363 // token: won't contains semicolon according to RFC 2616 Section 2.2
364 let (token, new_left) = split_once_and_trim(left, ';');
365 left = new_left;
366 if token.is_empty() {
367 // quoted-string can be empty, but token cannot be empty
368 return Err(crate::error::ParseError::Header);
369 }
370 token.to_owned()
371 };
372
373 let param = if param_name.eq_ignore_ascii_case("name") {
374 DispositionParam::Name(value)
375 } else if param_name.eq_ignore_ascii_case("filename") {
376 // See also comments in test_from_raw_uncessary_percent_decode.
377 DispositionParam::Filename(value)
378 } else {
379 DispositionParam::Unknown(param_name.to_owned(), value)
380 };
381 cd.parameters.push(param);
382 }
383 }
384
385 Ok(cd)
386 }
387
388 /// Returns `true` if it is [`Inline`](DispositionType::Inline).
is_inline(&self) -> bool389 pub fn is_inline(&self) -> bool {
390 match self.disposition {
391 DispositionType::Inline => true,
392 _ => false,
393 }
394 }
395
396 /// Returns `true` if it is [`Attachment`](DispositionType::Attachment).
is_attachment(&self) -> bool397 pub fn is_attachment(&self) -> bool {
398 match self.disposition {
399 DispositionType::Attachment => true,
400 _ => false,
401 }
402 }
403
404 /// Returns `true` if it is [`FormData`](DispositionType::FormData).
is_form_data(&self) -> bool405 pub fn is_form_data(&self) -> bool {
406 match self.disposition {
407 DispositionType::FormData => true,
408 _ => false,
409 }
410 }
411
412 /// Returns `true` if it is [`Ext`](DispositionType::Ext) and the `disp_type` matches.
is_ext<T: AsRef<str>>(&self, disp_type: T) -> bool413 pub fn is_ext<T: AsRef<str>>(&self, disp_type: T) -> bool {
414 match self.disposition {
415 DispositionType::Ext(ref t)
416 if t.eq_ignore_ascii_case(disp_type.as_ref()) =>
417 {
418 true
419 }
420 _ => false,
421 }
422 }
423
424 /// Return the value of *name* if exists.
get_name(&self) -> Option<&str>425 pub fn get_name(&self) -> Option<&str> {
426 self.parameters.iter().filter_map(|p| p.as_name()).nth(0)
427 }
428
429 /// Return the value of *filename* if exists.
get_filename(&self) -> Option<&str>430 pub fn get_filename(&self) -> Option<&str> {
431 self.parameters
432 .iter()
433 .filter_map(|p| p.as_filename())
434 .nth(0)
435 }
436
437 /// Return the value of *filename\** if exists.
get_filename_ext(&self) -> Option<&ExtendedValue>438 pub fn get_filename_ext(&self) -> Option<&ExtendedValue> {
439 self.parameters
440 .iter()
441 .filter_map(|p| p.as_filename_ext())
442 .nth(0)
443 }
444
445 /// Return the value of the parameter which the `name` matches.
get_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str>446 pub fn get_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str> {
447 let name = name.as_ref();
448 self.parameters
449 .iter()
450 .filter_map(|p| p.as_unknown(name))
451 .nth(0)
452 }
453
454 /// Return the value of the extended parameter which the `name` matches.
get_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue>455 pub fn get_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue> {
456 let name = name.as_ref();
457 self.parameters
458 .iter()
459 .filter_map(|p| p.as_unknown_ext(name))
460 .nth(0)
461 }
462 }
463
464 impl IntoHeaderValue for ContentDisposition {
465 type Error = header::InvalidHeaderValue;
466
try_into(self) -> Result<header::HeaderValue, Self::Error>467 fn try_into(self) -> Result<header::HeaderValue, Self::Error> {
468 let mut writer = Writer::new();
469 let _ = write!(&mut writer, "{}", self);
470 header::HeaderValue::from_maybe_shared(writer.take())
471 }
472 }
473
474 impl Header for ContentDisposition {
name() -> header::HeaderName475 fn name() -> header::HeaderName {
476 header::CONTENT_DISPOSITION
477 }
478
parse<T: crate::HttpMessage>(msg: &T) -> Result<Self, crate::error::ParseError>479 fn parse<T: crate::HttpMessage>(msg: &T) -> Result<Self, crate::error::ParseError> {
480 if let Some(h) = msg.headers().get(&Self::name()) {
481 Self::from_raw(&h)
482 } else {
483 Err(crate::error::ParseError::Header)
484 }
485 }
486 }
487
488 impl fmt::Display for DispositionType {
fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result489 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490 match self {
491 DispositionType::Inline => write!(f, "inline"),
492 DispositionType::Attachment => write!(f, "attachment"),
493 DispositionType::FormData => write!(f, "form-data"),
494 DispositionType::Ext(ref s) => write!(f, "{}", s),
495 }
496 }
497 }
498
499 impl fmt::Display for DispositionParam {
fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 // All ASCII control characters (0-30, 127) including horizontal tab, double quote, and
502 // backslash should be escaped in quoted-string (i.e. "foobar").
503 // Ref: RFC6266 S4.1 -> RFC2616 S3.6
504 // filename-parm = "filename" "=" value
505 // value = token | quoted-string
506 // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
507 // qdtext = <any TEXT except <">>
508 // quoted-pair = "\" CHAR
509 // TEXT = <any OCTET except CTLs,
510 // but including LWS>
511 // LWS = [CRLF] 1*( SP | HT )
512 // OCTET = <any 8-bit sequence of data>
513 // CHAR = <any US-ASCII character (octets 0 - 127)>
514 // CTL = <any US-ASCII control character
515 // (octets 0 - 31) and DEL (127)>
516 //
517 // Ref: RFC7578 S4.2 -> RFC2183 S2 -> RFC2045 S5.1
518 // parameter := attribute "=" value
519 // attribute := token
520 // ; Matching of attributes
521 // ; is ALWAYS case-insensitive.
522 // value := token / quoted-string
523 // token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
524 // or tspecials>
525 // tspecials := "(" / ")" / "<" / ">" / "@" /
526 // "," / ";" / ":" / "\" / <">
527 // "/" / "[" / "]" / "?" / "="
528 // ; Must be in quoted-string,
529 // ; to use within parameter values
530 //
531 //
532 // See also comments in test_from_raw_uncessary_percent_decode.
533 lazy_static! {
534 static ref RE: Regex = Regex::new("[\x00-\x08\x10-\x1F\x7F\"\\\\]").unwrap();
535 }
536 match self {
537 DispositionParam::Name(ref value) => write!(f, "name={}", value),
538 DispositionParam::Filename(ref value) => {
539 write!(f, "filename=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
540 }
541 DispositionParam::Unknown(ref name, ref value) => write!(
542 f,
543 "{}=\"{}\"",
544 name,
545 &RE.replace_all(value, "\\$0").as_ref()
546 ),
547 DispositionParam::FilenameExt(ref ext_value) => {
548 write!(f, "filename*={}", ext_value)
549 }
550 DispositionParam::UnknownExt(ref name, ref ext_value) => {
551 write!(f, "{}*={}", name, ext_value)
552 }
553 }
554 }
555 }
556
557 impl fmt::Display for ContentDisposition {
fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result558 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
559 write!(f, "{}", self.disposition)?;
560 self.parameters
561 .iter()
562 .map(|param| write!(f, "; {}", param))
563 .collect()
564 }
565 }
566
567 #[cfg(test)]
568 mod tests {
569 use super::{ContentDisposition, DispositionParam, DispositionType};
570 use crate::header::shared::Charset;
571 use crate::header::{ExtendedValue, HeaderValue};
572
573 #[test]
test_from_raw_basic()574 fn test_from_raw_basic() {
575 assert!(ContentDisposition::from_raw(&HeaderValue::from_static("")).is_err());
576
577 let a = HeaderValue::from_static(
578 "form-data; dummy=3; name=upload; filename=\"sample.png\"",
579 );
580 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
581 let b = ContentDisposition {
582 disposition: DispositionType::FormData,
583 parameters: vec![
584 DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
585 DispositionParam::Name("upload".to_owned()),
586 DispositionParam::Filename("sample.png".to_owned()),
587 ],
588 };
589 assert_eq!(a, b);
590
591 let a = HeaderValue::from_static("attachment; filename=\"image.jpg\"");
592 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
593 let b = ContentDisposition {
594 disposition: DispositionType::Attachment,
595 parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
596 };
597 assert_eq!(a, b);
598
599 let a = HeaderValue::from_static("inline; filename=image.jpg");
600 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
601 let b = ContentDisposition {
602 disposition: DispositionType::Inline,
603 parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
604 };
605 assert_eq!(a, b);
606
607 let a = HeaderValue::from_static(
608 "attachment; creation-date=\"Wed, 12 Feb 1997 16:29:51 -0500\"",
609 );
610 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
611 let b = ContentDisposition {
612 disposition: DispositionType::Attachment,
613 parameters: vec![DispositionParam::Unknown(
614 String::from("creation-date"),
615 "Wed, 12 Feb 1997 16:29:51 -0500".to_owned(),
616 )],
617 };
618 assert_eq!(a, b);
619 }
620
621 #[test]
test_from_raw_extended()622 fn test_from_raw_extended() {
623 let a = HeaderValue::from_static(
624 "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
625 );
626 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
627 let b = ContentDisposition {
628 disposition: DispositionType::Attachment,
629 parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
630 charset: Charset::Ext(String::from("UTF-8")),
631 language_tag: None,
632 value: vec![
633 0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20,
634 b'r', b'a', b't', b'e', b's',
635 ],
636 })],
637 };
638 assert_eq!(a, b);
639
640 let a = HeaderValue::from_static(
641 "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates",
642 );
643 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
644 let b = ContentDisposition {
645 disposition: DispositionType::Attachment,
646 parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
647 charset: Charset::Ext(String::from("UTF-8")),
648 language_tag: None,
649 value: vec![
650 0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20,
651 b'r', b'a', b't', b'e', b's',
652 ],
653 })],
654 };
655 assert_eq!(a, b);
656 }
657
658 #[test]
test_from_raw_extra_whitespace()659 fn test_from_raw_extra_whitespace() {
660 let a = HeaderValue::from_static(
661 "form-data ; du-mmy= 3 ; name =upload ; filename = \"sample.png\" ; ",
662 );
663 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
664 let b = ContentDisposition {
665 disposition: DispositionType::FormData,
666 parameters: vec![
667 DispositionParam::Unknown("du-mmy".to_owned(), "3".to_owned()),
668 DispositionParam::Name("upload".to_owned()),
669 DispositionParam::Filename("sample.png".to_owned()),
670 ],
671 };
672 assert_eq!(a, b);
673 }
674
675 #[test]
test_from_raw_unordered()676 fn test_from_raw_unordered() {
677 let a = HeaderValue::from_static(
678 "form-data; dummy=3; filename=\"sample.png\" ; name=upload;",
679 // Actually, a trailling semolocon is not compliant. But it is fine to accept.
680 );
681 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
682 let b = ContentDisposition {
683 disposition: DispositionType::FormData,
684 parameters: vec![
685 DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
686 DispositionParam::Filename("sample.png".to_owned()),
687 DispositionParam::Name("upload".to_owned()),
688 ],
689 };
690 assert_eq!(a, b);
691
692 let a = HeaderValue::from_str(
693 "attachment; filename*=iso-8859-1''foo-%E4.html; filename=\"foo-ä.html\"",
694 )
695 .unwrap();
696 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
697 let b = ContentDisposition {
698 disposition: DispositionType::Attachment,
699 parameters: vec![
700 DispositionParam::FilenameExt(ExtendedValue {
701 charset: Charset::Iso_8859_1,
702 language_tag: None,
703 value: b"foo-\xe4.html".to_vec(),
704 }),
705 DispositionParam::Filename("foo-ä.html".to_owned()),
706 ],
707 };
708 assert_eq!(a, b);
709 }
710
711 #[test]
test_from_raw_only_disp()712 fn test_from_raw_only_disp() {
713 let a = ContentDisposition::from_raw(&HeaderValue::from_static("attachment"))
714 .unwrap();
715 let b = ContentDisposition {
716 disposition: DispositionType::Attachment,
717 parameters: vec![],
718 };
719 assert_eq!(a, b);
720
721 let a =
722 ContentDisposition::from_raw(&HeaderValue::from_static("inline ;")).unwrap();
723 let b = ContentDisposition {
724 disposition: DispositionType::Inline,
725 parameters: vec![],
726 };
727 assert_eq!(a, b);
728
729 let a = ContentDisposition::from_raw(&HeaderValue::from_static(
730 "unknown-disp-param",
731 ))
732 .unwrap();
733 let b = ContentDisposition {
734 disposition: DispositionType::Ext(String::from("unknown-disp-param")),
735 parameters: vec![],
736 };
737 assert_eq!(a, b);
738 }
739
740 #[test]
from_raw_with_mixed_case()741 fn from_raw_with_mixed_case() {
742 let a = HeaderValue::from_str(
743 "InLInE; fIlenAME*=iso-8859-1''foo-%E4.html; filEName=\"foo-ä.html\"",
744 )
745 .unwrap();
746 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
747 let b = ContentDisposition {
748 disposition: DispositionType::Inline,
749 parameters: vec![
750 DispositionParam::FilenameExt(ExtendedValue {
751 charset: Charset::Iso_8859_1,
752 language_tag: None,
753 value: b"foo-\xe4.html".to_vec(),
754 }),
755 DispositionParam::Filename("foo-ä.html".to_owned()),
756 ],
757 };
758 assert_eq!(a, b);
759 }
760
761 #[test]
from_raw_with_unicode()762 fn from_raw_with_unicode() {
763 /* RFC7578 Section 4.2:
764 Some commonly deployed systems use multipart/form-data with file names directly encoded
765 including octets outside the US-ASCII range. The encoding used for the file names is
766 typically UTF-8, although HTML forms will use the charset associated with the form.
767
768 Mainstream browsers like Firefox (gecko) and Chrome use UTF-8 directly as above.
769 (And now, only UTF-8 is handled by this implementation.)
770 */
771 let a = HeaderValue::from_str("form-data; name=upload; filename=\"文件.webp\"")
772 .unwrap();
773 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
774 let b = ContentDisposition {
775 disposition: DispositionType::FormData,
776 parameters: vec![
777 DispositionParam::Name(String::from("upload")),
778 DispositionParam::Filename(String::from("文件.webp")),
779 ],
780 };
781 assert_eq!(a, b);
782
783 let a = HeaderValue::from_str(
784 "form-data; name=upload; filename=\"余固知謇謇之為患兮,忍而不能舍也.pptx\"",
785 )
786 .unwrap();
787 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
788 let b = ContentDisposition {
789 disposition: DispositionType::FormData,
790 parameters: vec![
791 DispositionParam::Name(String::from("upload")),
792 DispositionParam::Filename(String::from(
793 "余固知謇謇之為患兮,忍而不能舍也.pptx",
794 )),
795 ],
796 };
797 assert_eq!(a, b);
798 }
799
800 #[test]
test_from_raw_escape()801 fn test_from_raw_escape() {
802 let a = HeaderValue::from_static(
803 "form-data; dummy=3; name=upload; filename=\"s\\amp\\\"le.png\"",
804 );
805 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
806 let b = ContentDisposition {
807 disposition: DispositionType::FormData,
808 parameters: vec![
809 DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
810 DispositionParam::Name("upload".to_owned()),
811 DispositionParam::Filename(
812 ['s', 'a', 'm', 'p', '\"', 'l', 'e', '.', 'p', 'n', 'g']
813 .iter()
814 .collect(),
815 ),
816 ],
817 };
818 assert_eq!(a, b);
819 }
820
821 #[test]
test_from_raw_semicolon()822 fn test_from_raw_semicolon() {
823 let a =
824 HeaderValue::from_static("form-data; filename=\"A semicolon here;.pdf\"");
825 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
826 let b = ContentDisposition {
827 disposition: DispositionType::FormData,
828 parameters: vec![DispositionParam::Filename(String::from(
829 "A semicolon here;.pdf",
830 ))],
831 };
832 assert_eq!(a, b);
833 }
834
835 #[test]
test_from_raw_uncessary_percent_decode()836 fn test_from_raw_uncessary_percent_decode() {
837 // In fact, RFC7578 (multipart/form-data) Section 2 and 4.2 suggests that filename with
838 // non-ASCII characters MAY be percent-encoded.
839 // On the contrary, RFC6266 or other RFCs related to Content-Disposition response header
840 // do not mention such percent-encoding.
841 // So, it appears to be undecidable whether to percent-decode or not without
842 // knowing the usage scenario (multipart/form-data v.s. HTTP response header) and
843 // inevitable to unnecessarily percent-decode filename with %XX in the former scenario.
844 // Fortunately, it seems that almost all mainstream browsers just send UTF-8 encoded file
845 // names in quoted-string format (tested on Edge, IE11, Chrome and Firefox) without
846 // percent-encoding. So we do not bother to attempt to percent-decode.
847 let a = HeaderValue::from_static(
848 "form-data; name=photo; filename=\"%74%65%73%74%2e%70%6e%67\"",
849 );
850 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
851 let b = ContentDisposition {
852 disposition: DispositionType::FormData,
853 parameters: vec![
854 DispositionParam::Name("photo".to_owned()),
855 DispositionParam::Filename(String::from("%74%65%73%74%2e%70%6e%67")),
856 ],
857 };
858 assert_eq!(a, b);
859
860 let a = HeaderValue::from_static(
861 "form-data; name=photo; filename=\"%74%65%73%74.png\"",
862 );
863 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
864 let b = ContentDisposition {
865 disposition: DispositionType::FormData,
866 parameters: vec![
867 DispositionParam::Name("photo".to_owned()),
868 DispositionParam::Filename(String::from("%74%65%73%74.png")),
869 ],
870 };
871 assert_eq!(a, b);
872 }
873
874 #[test]
test_from_raw_param_value_missing()875 fn test_from_raw_param_value_missing() {
876 let a = HeaderValue::from_static("form-data; name=upload ; filename=");
877 assert!(ContentDisposition::from_raw(&a).is_err());
878
879 let a = HeaderValue::from_static("attachment; dummy=; filename=invoice.pdf");
880 assert!(ContentDisposition::from_raw(&a).is_err());
881
882 let a = HeaderValue::from_static("inline; filename= ");
883 assert!(ContentDisposition::from_raw(&a).is_err());
884
885 let a = HeaderValue::from_static("inline; filename=\"\"");
886 assert!(ContentDisposition::from_raw(&a)
887 .expect("parse cd")
888 .get_filename()
889 .expect("filename")
890 .is_empty());
891 }
892
893 #[test]
test_from_raw_param_name_missing()894 fn test_from_raw_param_name_missing() {
895 let a = HeaderValue::from_static("inline; =\"test.txt\"");
896 assert!(ContentDisposition::from_raw(&a).is_err());
897
898 let a = HeaderValue::from_static("inline; =diary.odt");
899 assert!(ContentDisposition::from_raw(&a).is_err());
900
901 let a = HeaderValue::from_static("inline; =");
902 assert!(ContentDisposition::from_raw(&a).is_err());
903 }
904
905 #[test]
test_display_extended()906 fn test_display_extended() {
907 let as_string =
908 "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
909 let a = HeaderValue::from_static(as_string);
910 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
911 let display_rendered = format!("{}", a);
912 assert_eq!(as_string, display_rendered);
913
914 let a = HeaderValue::from_static("attachment; filename=colourful.csv");
915 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
916 let display_rendered = format!("{}", a);
917 assert_eq!(
918 "attachment; filename=\"colourful.csv\"".to_owned(),
919 display_rendered
920 );
921 }
922
923 #[test]
test_display_quote()924 fn test_display_quote() {
925 let as_string = "form-data; name=upload; filename=\"Quote\\\"here.png\"";
926 as_string
927 .find(['\\', '\"'].iter().collect::<String>().as_str())
928 .unwrap(); // ensure `\"` is there
929 let a = HeaderValue::from_static(as_string);
930 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
931 let display_rendered = format!("{}", a);
932 assert_eq!(as_string, display_rendered);
933 }
934
935 #[test]
test_display_space_tab()936 fn test_display_space_tab() {
937 let as_string = "form-data; name=upload; filename=\"Space here.png\"";
938 let a = HeaderValue::from_static(as_string);
939 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
940 let display_rendered = format!("{}", a);
941 assert_eq!(as_string, display_rendered);
942
943 let a: ContentDisposition = ContentDisposition {
944 disposition: DispositionType::Inline,
945 parameters: vec![DispositionParam::Filename(String::from("Tab\there.png"))],
946 };
947 let display_rendered = format!("{}", a);
948 assert_eq!("inline; filename=\"Tab\x09here.png\"", display_rendered);
949 }
950
951 #[test]
test_display_control_characters()952 fn test_display_control_characters() {
953 /* let a = "attachment; filename=\"carriage\rreturn.png\"";
954 let a = HeaderValue::from_static(a);
955 let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap();
956 let display_rendered = format!("{}", a);
957 assert_eq!(
958 "attachment; filename=\"carriage\\\rreturn.png\"",
959 display_rendered
960 );*/
961 // No way to create a HeaderValue containing a carriage return.
962
963 let a: ContentDisposition = ContentDisposition {
964 disposition: DispositionType::Inline,
965 parameters: vec![DispositionParam::Filename(String::from("bell\x07.png"))],
966 };
967 let display_rendered = format!("{}", a);
968 assert_eq!("inline; filename=\"bell\\\x07.png\"", display_rendered);
969 }
970
971 #[test]
test_param_methods()972 fn test_param_methods() {
973 let param = DispositionParam::Filename(String::from("sample.txt"));
974 assert!(param.is_filename());
975 assert_eq!(param.as_filename().unwrap(), "sample.txt");
976
977 let param = DispositionParam::Unknown(String::from("foo"), String::from("bar"));
978 assert!(param.is_unknown("foo"));
979 assert_eq!(param.as_unknown("fOo"), Some("bar"));
980 }
981
982 #[test]
test_disposition_methods()983 fn test_disposition_methods() {
984 let cd = ContentDisposition {
985 disposition: DispositionType::FormData,
986 parameters: vec![
987 DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
988 DispositionParam::Name("upload".to_owned()),
989 DispositionParam::Filename("sample.png".to_owned()),
990 ],
991 };
992 assert_eq!(cd.get_name(), Some("upload"));
993 assert_eq!(cd.get_unknown("dummy"), Some("3"));
994 assert_eq!(cd.get_filename(), Some("sample.png"));
995 assert_eq!(cd.get_unknown_ext("dummy"), None);
996 assert_eq!(cd.get_unknown("duMMy"), Some("3"));
997 }
998 }
999