ntex_files/header/content_disposition.rs
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/rfc2388.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
9use super::error;
10use super::parsing::{self, ExtendedValue};
11use super::{Header, RawLike};
12use crate::standard_header;
13use regex::Regex;
14use std::fmt;
15use std::sync::LazyLock;
16
17/// The implied disposition of the content of the HTTP body.
18#[derive(Clone, Debug, PartialEq)]
19pub enum DispositionType {
20 /// Inline implies default processing
21 Inline,
22
23 /// Attachment implies that the recipient should prompt the user to save the response locally,
24 /// rather than process it normally (as per its media type).
25 Attachment,
26
27 /// Used in *multipart/form-data* as defined in
28 /// [RFC 7578](https://datatracker.ietf.org/doc/html/rfc7578) to carry the field name and
29 /// optional filename.
30 FormData,
31
32 /// Extension type. Should be handled by recipients the same way as Attachment
33 Ext(String),
34}
35
36impl<'a> From<&'a str> for DispositionType {
37 fn from(origin: &'a str) -> DispositionType {
38 if unicase::eq_ascii(origin, "inline") {
39 DispositionType::Inline
40 } else if unicase::eq_ascii(origin, "attachment") {
41 DispositionType::Attachment
42 } else if unicase::eq_ascii(origin, "form-data") {
43 DispositionType::FormData
44 } else {
45 DispositionType::Ext(origin.to_owned())
46 }
47 }
48}
49
50/// A parameter to the disposition type.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum DispositionParam {
53 /// For [`DispositionType::FormData`] (i.e. *multipart/form-data*), the name of an field from
54 /// the form.
55 Name(String),
56
57 /// A plain file name.
58 ///
59 /// It is [not supposed](https://datatracker.ietf.org/doc/html/rfc6266#appendix-D) to contain
60 /// any non-ASCII characters when used in a *Content-Disposition* HTTP response header, where
61 /// [`FilenameExt`](DispositionParam::FilenameExt) with charset UTF-8 may be used instead
62 /// in case there are Unicode characters in file names.
63 Filename(String),
64
65 /// An extended file name. It must not exist for `ContentType::Formdata` according to
66 /// [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2).
67 FilenameExt(ExtendedValue),
68
69 /// An unrecognized regular parameter as defined in
70 /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
71 /// `reg-parameter`, in
72 /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
73 /// `token "=" value`. Recipients should ignore unrecognizable parameters.
74 Unknown(String, String),
75
76 /// An unrecognized extended parameter as defined in
77 /// [RFC 5987 §3.2.1](https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1) as
78 /// `ext-parameter`, in
79 /// [RFC 6266 §4.1](https://datatracker.ietf.org/doc/html/rfc6266#section-4.1) as
80 /// `ext-token "=" ext-value`. The single trailing asterisk is not included. Recipients should
81 /// ignore unrecognizable parameters.
82 UnknownExt(String, ExtendedValue),
83}
84
85impl DispositionParam {
86 /// Returns `true` if the parameter is [`Name`](DispositionParam::Name).
87 #[inline]
88 pub fn is_name(&self) -> bool {
89 self.as_name().is_some()
90 }
91
92 /// Returns `true` if the parameter is [`Filename`](DispositionParam::Filename).
93 #[inline]
94 pub fn is_filename(&self) -> bool {
95 self.as_filename().is_some()
96 }
97
98 /// Returns `true` if the parameter is [`FilenameExt`](DispositionParam::FilenameExt).
99 #[inline]
100 pub fn is_filename_ext(&self) -> bool {
101 self.as_filename_ext().is_some()
102 }
103
104 /// Returns `true` if the parameter is [`Unknown`](DispositionParam::Unknown) and the `name`
105 #[inline]
106 /// matches.
107 pub fn is_unknown<T: AsRef<str>>(&self, name: T) -> bool {
108 self.as_unknown(name).is_some()
109 }
110
111 /// Returns `true` if the parameter is [`UnknownExt`](DispositionParam::UnknownExt) and the
112 /// `name` matches.
113 #[inline]
114 pub fn is_unknown_ext<T: AsRef<str>>(&self, name: T) -> bool {
115 self.as_unknown_ext(name).is_some()
116 }
117
118 /// Returns the name if applicable.
119 #[inline]
120 pub fn as_name(&self) -> Option<&str> {
121 match self {
122 DispositionParam::Name(name) => Some(name.as_str()),
123 _ => None,
124 }
125 }
126
127 /// Returns the filename if applicable.
128 #[inline]
129 pub fn as_filename(&self) -> Option<&str> {
130 match self {
131 DispositionParam::Filename(filename) => Some(filename.as_str()),
132 _ => None,
133 }
134 }
135
136 /// Returns the filename* if applicable.
137 #[inline]
138 pub fn as_filename_ext(&self) -> Option<&ExtendedValue> {
139 match self {
140 DispositionParam::FilenameExt(value) => Some(value),
141 _ => None,
142 }
143 }
144
145 /// Returns the value of the unrecognized regular parameter if it is
146 /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
147 #[inline]
148 pub fn as_unknown<T: AsRef<str>>(&self, name: T) -> Option<&str> {
149 match self {
150 DispositionParam::Unknown(ext_name, value)
151 if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
152 {
153 Some(value.as_str())
154 }
155 _ => None,
156 }
157 }
158
159 /// Returns the value of the unrecognized extended parameter if it is
160 /// [`Unknown`](DispositionParam::Unknown) and the `name` matches.
161 #[inline]
162 pub fn as_unknown_ext<T: AsRef<str>>(&self, name: T) -> Option<&ExtendedValue> {
163 match self {
164 DispositionParam::UnknownExt(ext_name, value)
165 if ext_name.eq_ignore_ascii_case(name.as_ref()) =>
166 {
167 Some(value)
168 }
169 _ => None,
170 }
171 }
172}
173
174/// A `Content-Disposition` header, (re)defined in [RFC6266](https://tools.ietf.org/html/rfc6266).
175///
176/// The Content-Disposition response header field is used to convey
177/// additional information about how to process the response payload, and
178/// also can be used to attach additional metadata, such as the filename
179/// to use when saving the response payload locally.
180///
181/// # ABNF
182///
183/// ```text
184/// content-disposition = "Content-Disposition" ":"
185/// disposition-type *( ";" disposition-parm )
186///
187/// disposition-type = "inline" | "attachment" | disp-ext-type
188/// ; case-insensitive
189///
190/// disp-ext-type = token
191///
192/// disposition-parm = filename-parm | disp-ext-parm
193///
194/// filename-parm = "filename" "=" value
195/// | "filename*" "=" ext-value
196///
197/// disp-ext-parm = token "=" value
198/// | ext-token "=" ext-value
199///
200/// ext-token = <the characters in token, followed by "*">
201/// ```
202///
203#[derive(Clone, Debug, PartialEq)]
204pub struct ContentDisposition {
205 /// The disposition
206 pub disposition: DispositionType,
207 /// Disposition parameters
208 pub parameters: Vec<DispositionParam>,
209}
210
211impl ContentDisposition {
212 /// Returns `true` if type is [`Inline`](DispositionType::Inline).
213 pub fn is_inline(&self) -> bool {
214 matches!(self.disposition, DispositionType::Inline)
215 }
216
217 /// Returns `true` if type is [`Attachment`](DispositionType::Attachment).
218 pub fn is_attachment(&self) -> bool {
219 matches!(self.disposition, DispositionType::Attachment)
220 }
221
222 /// Returns `true` if type is [`FormData`](DispositionType::FormData).
223 pub fn is_form_data(&self) -> bool {
224 matches!(self.disposition, DispositionType::FormData)
225 }
226
227 /// Returns `true` if type is [`Ext`](DispositionType::Ext) and the `disp_type` matches.
228 pub fn is_ext(&self, disp_type: impl AsRef<str>) -> bool {
229 matches!(
230 self.disposition,
231 DispositionType::Ext(ref t) if t.eq_ignore_ascii_case(disp_type.as_ref())
232 )
233 }
234
235 /// Return the value of *name* if exists.
236 pub fn get_name(&self) -> Option<&str> {
237 self.parameters.iter().find_map(DispositionParam::as_name)
238 }
239
240 /// Return the value of *filename* if exists.
241 pub fn get_filename(&self) -> Option<&str> {
242 self.parameters.iter().find_map(DispositionParam::as_filename)
243 }
244
245 /// Return the value of *filename\** if exists.
246 pub fn get_filename_ext(&self) -> Option<&ExtendedValue> {
247 self.parameters.iter().find_map(DispositionParam::as_filename_ext)
248 }
249
250 /// Return the value of the parameter which the `name` matches.
251 pub fn get_unknown(&self, name: impl AsRef<str>) -> Option<&str> {
252 let name = name.as_ref();
253 self.parameters.iter().find_map(|p| p.as_unknown(name))
254 }
255
256 /// Return the value of the extended parameter which the `name` matches.
257 pub fn get_unknown_ext(&self, name: impl AsRef<str>) -> Option<&ExtendedValue> {
258 let name = name.as_ref();
259 self.parameters.iter().find_map(|p| p.as_unknown_ext(name))
260 }
261}
262
263impl Header for ContentDisposition {
264 fn header_name() -> &'static str {
265 static NAME: &str = "Content-Disposition";
266 NAME
267 }
268
269 fn parse_header<'a, T>(raw: &'a T) -> error::Result<ContentDisposition>
270 where
271 T: RawLike<'a>,
272 {
273 parsing::from_one_raw_str(raw).and_then(|s: String| {
274 let mut sections = s.split(';');
275 let disposition = match sections.next() {
276 Some(s) => s.trim(),
277 None => return Err(error::Error::Header),
278 };
279
280 let mut cd =
281 ContentDisposition { disposition: disposition.into(), parameters: Vec::new() };
282
283 for section in sections {
284 let mut parts = section.splitn(2, '=');
285
286 let key = if let Some(key) = parts.next() {
287 let key_trimmed = key.trim();
288
289 if key_trimmed.is_empty() || key_trimmed == "*" {
290 return Err(error::Error::Header);
291 }
292
293 key_trimmed
294 } else {
295 return Err(error::Error::Header);
296 };
297
298 let val = if let Some(val) = parts.next() {
299 val.trim()
300 } else {
301 return Err(error::Error::Header);
302 };
303
304 if let Some(key) = key.strip_suffix('*') {
305 let ext_val = parsing::parse_extended_value(val)?;
306
307 cd.parameters.push(if unicase::eq_ascii(key, "filename") {
308 DispositionParam::FilenameExt(ext_val)
309 } else {
310 DispositionParam::UnknownExt(key.to_owned(), ext_val)
311 });
312 } else {
313 let val = if val.starts_with('\"') {
314 // quoted-string: defined in RFC 6266 -> RFC 2616 Section 3.6
315 let mut escaping = false;
316 let mut quoted_string = vec![];
317
318 // search for closing quote
319 for &c in val.as_bytes().iter().skip(1) {
320 if escaping {
321 escaping = false;
322 quoted_string.push(c);
323 } else if c == 0x5c {
324 // backslash
325 escaping = true;
326 } else if c == 0x22 {
327 // double quote
328 break;
329 } else {
330 quoted_string.push(c);
331 }
332 }
333
334 // In fact, it should not be Err if the above code is correct.
335 String::from_utf8(quoted_string).map_err(|_| error::Error::Header)?
336 } else {
337 if val.is_empty() {
338 // quoted-string can be empty, but token cannot be empty
339 return Err(error::Error::Header);
340 }
341
342 val.to_owned()
343 };
344
345 cd.parameters.push(if unicase::eq_ascii(key, "name") {
346 DispositionParam::Name(val)
347 } else if unicase::eq_ascii(key, "filename") {
348 // See also comments in test_from_raw_unnecessary_percent_decode.
349 DispositionParam::Filename(val)
350 } else {
351 DispositionParam::Unknown(key.to_owned(), val)
352 });
353 }
354 }
355
356 Ok(cd)
357 })
358 }
359
360 #[inline]
361 fn fmt_header(&self, f: &mut super::Formatter) -> fmt::Result {
362 f.fmt_line(self)
363 }
364}
365
366impl fmt::Display for DispositionType {
367 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
368 match self {
369 DispositionType::Inline => write!(f, "inline"),
370 DispositionType::Attachment => write!(f, "attachment"),
371 DispositionType::FormData => write!(f, "form-data"),
372 DispositionType::Ext(s) => write!(f, "{}", s),
373 }
374 }
375}
376
377impl fmt::Display for DispositionParam {
378 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
379 // All ASCII control characters (0-30, 127) including horizontal tab, double quote, and
380 // backslash should be escaped in quoted-string (i.e. "foobar").
381 //
382 // Ref: RFC 6266 §4.1 -> RFC 2616 §3.6
383 //
384 // filename-parm = "filename" "=" value
385 // value = token | quoted-string
386 // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
387 // qdtext = <any TEXT except <">>
388 // quoted-pair = "\" CHAR
389 // TEXT = <any OCTET except CTLs,
390 // but including LWS>
391 // LWS = [CRLF] 1*( SP | HT )
392 // OCTET = <any 8-bit sequence of data>
393 // CHAR = <any US-ASCII character (octets 0 - 127)>
394 // CTL = <any US-ASCII control character
395 // (octets 0 - 31) and DEL (127)>
396 //
397 // Ref: RFC 7578 S4.2 -> RFC 2183 S2 -> RFC 2045 S5.1
398 // parameter := attribute "=" value
399 // attribute := token
400 // ; Matching of attributes
401 // ; is ALWAYS case-insensitive.
402 // value := token / quoted-string
403 // token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
404 // or tspecials>
405 // tspecials := "(" / ")" / "<" / ">" / "@" /
406 // "," / ";" / ":" / "\" / <">
407 // "/" / "[" / "]" / "?" / "="
408 // ; Must be in quoted-string,
409 // ; to use within parameter values
410 //
411 //
412 // See also comments in test_from_raw_unnecessary_percent_decode.
413
414 static RE: LazyLock<Regex> =
415 LazyLock::new(|| Regex::new("[\x00-\x08\x10-\x1F\x7F\"\\\\]").unwrap());
416
417 match self {
418 DispositionParam::Name(value) => write!(f, "name={}", value),
419
420 DispositionParam::Filename(value) => {
421 write!(f, "filename=\"{}\"", RE.replace_all(value, "\\$0").as_ref())
422 }
423
424 DispositionParam::Unknown(name, value) => {
425 write!(f, "{}=\"{}\"", name, &RE.replace_all(value, "\\$0").as_ref())
426 }
427
428 DispositionParam::FilenameExt(ext_value) => {
429 write!(f, "filename*={}", ext_value)
430 }
431
432 DispositionParam::UnknownExt(name, ext_value) => {
433 write!(f, "{}*={}", name, ext_value)
434 }
435 }
436 }
437}
438
439impl fmt::Display for ContentDisposition {
440 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
441 write!(f, "{}", self.disposition)?;
442
443 for param in &self.parameters {
444 write!(f, "; {}", param)?;
445 }
446
447 Ok(())
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{ContentDisposition, DispositionParam, DispositionType, Header};
454 use crate::header::parsing::ExtendedValue;
455 use crate::header::{Charset, Raw};
456
457 #[test]
458 fn test_parse_header() {
459 let a: Raw = "".into();
460 assert!(ContentDisposition::parse_header(&a).is_err());
461
462 let a: Raw = "form-data; dummy=3; name=upload;\r\n filename=\"sample.png\"".into();
463 let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
464 let b = ContentDisposition {
465 disposition: DispositionType::FormData,
466 parameters: vec![
467 DispositionParam::Unknown("dummy".to_owned(), "3".to_owned()),
468 DispositionParam::Name("upload".to_owned()),
469 DispositionParam::Filename("sample.png".to_owned()),
470 ],
471 };
472 assert_eq!(a, b);
473
474 let a: Raw = "attachment; filename=\"image.jpg\"".into();
475 let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
476 let b = ContentDisposition {
477 disposition: DispositionType::Attachment,
478 parameters: vec![DispositionParam::Filename("image.jpg".to_owned())],
479 };
480 assert_eq!(a, b);
481
482 let a: Raw = "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates".into();
483 let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
484 let b = ContentDisposition {
485 disposition: DispositionType::Attachment,
486 parameters: vec![DispositionParam::FilenameExt(ExtendedValue {
487 charset: Charset::Ext(String::from("UTF-8")),
488 language_tag: None,
489 value: vec![
490 0xc2, 0xa3, 0x20, b'a', b'n', b'd', 0x20, 0xe2, 0x82, 0xac, 0x20, b'r',
491 b'a', b't', b'e', b's',
492 ],
493 })],
494 };
495 assert_eq!(a, b);
496 }
497
498 #[test]
499 fn test_display() {
500 let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates";
501 let a: Raw = as_string.into();
502 let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
503 let display_rendered = format!("{}", a);
504 assert_eq!(as_string, display_rendered);
505
506 // TODO Fix this test
507 // let a: Raw = "attachment; filename*=UTF-8''black%20and%20white.csv".into();
508 // let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
509 // let display_rendered = format!("{}", a);
510 // assert_eq!("attachment; filename=\"black and white.csv\"".to_owned(), display_rendered);
511
512 let a: Raw = "attachment; filename=colourful.csv".into();
513 let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap();
514 let display_rendered = format!("{}", a);
515 assert_eq!("attachment; filename=\"colourful.csv\"".to_owned(), display_rendered);
516 }
517}
518
519standard_header!(ContentDisposition, CONTENT_DISPOSITION);