diff --git a/CHANGES.md b/CHANGES.md index 403e3296a..2fee070c4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,9 @@ ### Added +* Add `.content_disposition()` method to parse Content-Disposition of + multipart fields + * Re-export `actix::prelude::*` as `actix_web::actix` module. * `HttpRequest::url_for_static()` for a named route with no variables segments diff --git a/src/header/common/content_disposition.rs b/src/header/common/content_disposition.rs index 0fcd6ee09..93102d464 100644 --- a/src/header/common/content_disposition.rs +++ b/src/header/common/content_disposition.rs @@ -7,13 +7,12 @@ // IANA assignment: http://www.iana.org/assignments/cont-disp/cont-disp.xhtml use language_tags::LanguageTag; -use std::fmt; -use unicase; - -use header::{Header, Raw, parsing}; -use header::parsing::{parse_extended_value, http_percent_encode}; +use header; +use header::{Header, IntoHeaderValue, Writer}; use header::shared::Charset; +use std::fmt::{self, Write}; + /// The implied disposition of the content of the HTTP body. #[derive(Clone, Debug, PartialEq)] pub enum DispositionType { @@ -69,17 +68,16 @@ pub enum DispositionParam { /// # Example /// /// ``` -/// use hyper::header::{Headers, ContentDisposition, DispositionType, DispositionParam, Charset}; +/// use actix_web::http::header::{ContentDisposition, DispositionType, DispositionParam, Charset}; /// -/// let mut headers = Headers::new(); -/// headers.set(ContentDisposition { +/// let cd = ContentDisposition { /// disposition: DispositionType::Attachment, /// parameters: vec![DispositionParam::Filename( /// Charset::Iso_8859_1, // The character set for the bytes of the filename /// None, // The optional language tag (see `language-tag` crate) /// b"\xa9 Copyright 1989.txt".to_vec() // the actual bytes of the filename /// )] -/// }); +/// }; /// ``` #[derive(Clone, Debug, PartialEq)] pub struct ContentDisposition { @@ -88,25 +86,20 @@ pub struct ContentDisposition { /// Disposition parameters pub parameters: Vec, } - -impl Header for ContentDisposition { - fn header_name() -> &'static str { - static NAME: &'static str = "Content-Disposition"; - NAME - } - - fn parse_header(raw: &Raw) -> ::Result { - parsing::from_one_raw_str(raw).and_then(|s: String| { +impl ContentDisposition { + /// Parse a raw Content-Disposition header value + pub fn from_raw(hv: &header::HeaderValue) -> Result { + header::from_one_raw_str(Some(hv)).and_then(|s: String| { let mut sections = s.split(';'); let disposition = match sections.next() { Some(s) => s.trim(), - None => return Err(::Error::Header), + None => return Err(::error::ParseError::Header), }; let mut cd = ContentDisposition { - disposition: if unicase::eq_ascii(&*disposition, "inline") { + disposition: if disposition.eq_ignore_ascii_case("inline") { DispositionType::Inline - } else if unicase::eq_ascii(&*disposition, "attachment") { + } else if disposition.eq_ignore_ascii_case("attachment") { DispositionType::Attachment } else { DispositionType::Ext(disposition.to_owned()) @@ -120,22 +113,22 @@ impl Header for ContentDisposition { let key = if let Some(key) = parts.next() { key.trim() } else { - return Err(::Error::Header); + return Err(::error::ParseError::Header); }; let val = if let Some(val) = parts.next() { val.trim() } else { - return Err(::Error::Header); + return Err(::error::ParseError::Header); }; cd.parameters.push( - if unicase::eq_ascii(&*key, "filename") { + if key.eq_ignore_ascii_case("filename") { DispositionParam::Filename( Charset::Ext("UTF-8".to_owned()), None, val.trim_matches('"').as_bytes().to_owned()) - } else if unicase::eq_ascii(&*key, "filename*") { - let extended_value = try!(parse_extended_value(val)); + } else if key.eq_ignore_ascii_case("filename*") { + let extended_value = try!(header::parse_extended_value(val)); DispositionParam::Filename(extended_value.charset, extended_value.language_tag, extended_value.value) } else { DispositionParam::Ext(key.to_owned(), val.trim_matches('"').to_owned()) @@ -146,10 +139,29 @@ impl Header for ContentDisposition { Ok(cd) }) } +} - #[inline] - fn fmt_header(&self, f: &mut ::header::Formatter) -> fmt::Result { - f.fmt_line(self) +impl IntoHeaderValue for ContentDisposition { + type Error = header::InvalidHeaderValueBytes; + + fn try_into(self) -> Result { + let mut writer = Writer::new(); + let _ = write!(&mut writer, "{}", self); + header::HeaderValue::from_shared(writer.take()) + } +} + +impl Header for ContentDisposition { + fn name() -> header::HeaderName { + header::CONTENT_DISPOSITION + } + + fn parse(msg: &T) -> Result { + if let Some(h) = msg.headers().get(Self::name()) { + Self::from_raw(&h) + } else { + Err(::error::ParseError::Header) + } } } @@ -166,7 +178,7 @@ impl fmt::Display for ContentDisposition { let mut use_simple_format: bool = false; if opt_lang.is_none() { if let Charset::Ext(ref ext) = *charset { - if unicase::eq_ascii(&**ext, "utf-8") { + if ext.eq_ignore_ascii_case("utf-8") { use_simple_format = true; } } @@ -183,7 +195,7 @@ impl fmt::Display for ContentDisposition { try!(write!(f, "{}", lang)); }; try!(write!(f, "'")); - try!(http_percent_encode(f, bytes)) + try!(header::http_percent_encode(f, bytes)) } }, DispositionParam::Ext(ref k, ref v) => try!(write!(f, "; {}=\"{}\"", k, v)), @@ -196,15 +208,14 @@ impl fmt::Display for ContentDisposition { #[cfg(test)] mod tests { use super::{ContentDisposition,DispositionType,DispositionParam}; - use ::header::Header; - use ::header::shared::Charset; - + use header::HeaderValue; + use header::shared::Charset; #[test] - fn test_parse_header() { - assert!(ContentDisposition::parse_header(&"".into()).is_err()); + fn test_from_raw() { + assert!(ContentDisposition::from_raw(&HeaderValue::from_static("")).is_err()); - let a = "form-data; dummy=3; name=upload;\r\n filename=\"sample.png\"".into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static("form-data; dummy=3; name=upload; filename=\"sample.png\""); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let b = ContentDisposition { disposition: DispositionType::Ext("form-data".to_owned()), parameters: vec![ @@ -217,8 +228,8 @@ mod tests { }; assert_eq!(a, b); - let a = "attachment; filename=\"image.jpg\"".into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static("attachment; filename=\"image.jpg\""); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let b = ContentDisposition { disposition: DispositionType::Attachment, parameters: vec![ @@ -229,8 +240,8 @@ mod tests { }; assert_eq!(a, b); - let a = "attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates".into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static("attachment; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let b = ContentDisposition { disposition: DispositionType::Attachment, parameters: vec![ @@ -246,18 +257,18 @@ mod tests { #[test] fn test_display() { let as_string = "attachment; filename*=UTF-8'en'%C2%A3%20and%20%E2%82%AC%20rates"; - let a = as_string.into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static(as_string); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let display_rendered = format!("{}",a); assert_eq!(as_string, display_rendered); - let a = "attachment; filename*=UTF-8''black%20and%20white.csv".into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static("attachment; filename*=UTF-8''black%20and%20white.csv"); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let display_rendered = format!("{}",a); assert_eq!("attachment; filename=\"black and white.csv\"".to_owned(), display_rendered); - let a = "attachment; filename=colourful.csv".into(); - let a: ContentDisposition = ContentDisposition::parse_header(&a).unwrap(); + let a = HeaderValue::from_static("attachment; filename=colourful.csv"); + let a: ContentDisposition = ContentDisposition::from_raw(&a).unwrap(); let display_rendered = format!("{}",a); assert_eq!("attachment; filename=\"colourful.csv\"".to_owned(), display_rendered); } diff --git a/src/header/common/mod.rs b/src/header/common/mod.rs index 08f8e0cc4..e6185b5a7 100644 --- a/src/header/common/mod.rs +++ b/src/header/common/mod.rs @@ -13,7 +13,7 @@ pub use self::accept_language::AcceptLanguage; pub use self::accept::Accept; pub use self::allow::Allow; pub use self::cache_control::{CacheControl, CacheDirective}; -//pub use self::content_disposition::{ContentDisposition, DispositionType, DispositionParam}; +pub use self::content_disposition::{ContentDisposition, DispositionType, DispositionParam}; pub use self::content_language::ContentLanguage; pub use self::content_range::{ContentRange, ContentRangeSpec}; pub use self::content_type::ContentType; @@ -334,7 +334,7 @@ mod accept_language; mod accept; mod allow; mod cache_control; -//mod content_disposition; +mod content_disposition; mod content_language; mod content_range; mod content_type; diff --git a/src/header/mod.rs b/src/header/mod.rs index a9c42e29c..8a7dd5bde 100644 --- a/src/header/mod.rs +++ b/src/header/mod.rs @@ -8,6 +8,7 @@ use bytes::{Bytes, BytesMut}; use mime::Mime; use modhttp::header::GetAll; use modhttp::Error as HttpError; +use percent_encoding; pub use modhttp::header::*; @@ -259,3 +260,197 @@ where } Ok(()) } + +// From hyper v0.11.27 src/header/parsing.rs + +/// An extended header parameter value (i.e., tagged with a character set and optionally, +/// a language), as defined in [RFC 5987](https://tools.ietf.org/html/rfc5987#section-3.2). +#[derive(Clone, Debug, PartialEq)] +pub struct ExtendedValue { + /// The character set that is used to encode the `value` to a string. + pub charset: Charset, + /// The human language details of the `value`, if available. + pub language_tag: Option, + /// The parameter value, as expressed in octets. + pub value: Vec, +} + +/// Parses extended header parameter values (`ext-value`), as defined in +/// [RFC 5987](https://tools.ietf.org/html/rfc5987#section-3.2). +/// +/// Extended values are denoted by parameter names that end with `*`. +/// +/// ## ABNF +/// +/// ```text +/// ext-value = charset "'" [ language ] "'" value-chars +/// ; like RFC 2231's +/// ; (see [RFC2231], Section 7) +/// +/// charset = "UTF-8" / "ISO-8859-1" / mime-charset +/// +/// mime-charset = 1*mime-charsetc +/// mime-charsetc = ALPHA / DIGIT +/// / "!" / "#" / "$" / "%" / "&" +/// / "+" / "-" / "^" / "_" / "`" +/// / "{" / "}" / "~" +/// ; as in Section 2.3 of [RFC2978] +/// ; except that the single quote is not included +/// ; SHOULD be registered in the IANA charset registry +/// +/// language = +/// +/// value-chars = *( pct-encoded / attr-char ) +/// +/// pct-encoded = "%" HEXDIG HEXDIG +/// ; see [RFC3986], Section 2.1 +/// +/// attr-char = ALPHA / DIGIT +/// / "!" / "#" / "$" / "&" / "+" / "-" / "." +/// / "^" / "_" / "`" / "|" / "~" +/// ; token except ( "*" / "'" / "%" ) +/// ``` +pub fn parse_extended_value(val: &str) -> Result { + + // Break into three pieces separated by the single-quote character + let mut parts = val.splitn(3,'\''); + + // Interpret the first piece as a Charset + let charset: Charset = match parts.next() { + None => return Err(::error::ParseError::Header), + Some(n) => FromStr::from_str(n).map_err(|_| ::error::ParseError::Header)?, + }; + + // Interpret the second piece as a language tag + let lang: Option = match parts.next() { + None => return Err(::error::ParseError::Header), + Some("") => None, + Some(s) => match s.parse() { + Ok(lt) => Some(lt), + Err(_) => return Err(::error::ParseError::Header), + } + }; + + // Interpret the third piece as a sequence of value characters + let value: Vec = match parts.next() { + None => return Err(::error::ParseError::Header), + Some(v) => percent_encoding::percent_decode(v.as_bytes()).collect(), + }; + + Ok(ExtendedValue { + charset: charset, + language_tag: lang, + value: value, + }) +} + + +impl fmt::Display for ExtendedValue { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let encoded_value = + percent_encoding::percent_encode(&self.value[..], self::percent_encoding_http::HTTP_VALUE); + if let Some(ref lang) = self.language_tag { + write!(f, "{}'{}'{}", self.charset, lang, encoded_value) + } else { + write!(f, "{}''{}", self.charset, encoded_value) + } + } +} + +/// Percent encode a sequence of bytes with a character set defined in +/// [https://tools.ietf.org/html/rfc5987#section-3.2][url] +/// +/// [url]: https://tools.ietf.org/html/rfc5987#section-3.2 +pub fn http_percent_encode(f: &mut fmt::Formatter, bytes: &[u8]) -> fmt::Result { + let encoded = percent_encoding::percent_encode(bytes, self::percent_encoding_http::HTTP_VALUE); + fmt::Display::fmt(&encoded, f) +} +mod percent_encoding_http { + use percent_encoding; + + // internal module because macro is hard-coded to make a public item + // but we don't want to public export this item + define_encode_set! { + // This encode set is used for HTTP header values and is defined at + // https://tools.ietf.org/html/rfc5987#section-3.2 + pub HTTP_VALUE = [percent_encoding::SIMPLE_ENCODE_SET] | { + ' ', '"', '%', '\'', '(', ')', '*', ',', '/', ':', ';', '<', '-', '>', '?', + '[', '\\', ']', '{', '}' + } + } +} + +#[cfg(test)] +mod tests { + use header::shared::Charset; + use super::{ExtendedValue, parse_extended_value}; + use language_tags::LanguageTag; + + #[test] + fn test_parse_extended_value_with_encoding_and_language_tag() { + let expected_language_tag = "en".parse::().unwrap(); + // RFC 5987, Section 3.2.2 + // Extended notation, using the Unicode character U+00A3 (POUND SIGN) + let result = parse_extended_value("iso-8859-1'en'%A3%20rates"); + assert!(result.is_ok()); + let extended_value = result.unwrap(); + assert_eq!(Charset::Iso_8859_1, extended_value.charset); + assert!(extended_value.language_tag.is_some()); + assert_eq!(expected_language_tag, extended_value.language_tag.unwrap()); + assert_eq!(vec![163, b' ', b'r', b'a', b't', b'e', b's'], extended_value.value); + } + + #[test] + fn test_parse_extended_value_with_encoding() { + // RFC 5987, Section 3.2.2 + // Extended notation, using the Unicode characters U+00A3 (POUND SIGN) + // and U+20AC (EURO SIGN) + let result = parse_extended_value("UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"); + assert!(result.is_ok()); + let extended_value = result.unwrap(); + assert_eq!(Charset::Ext("UTF-8".to_string()), extended_value.charset); + assert!(extended_value.language_tag.is_none()); + assert_eq!(vec![194, 163, b' ', b'a', b'n', b'd', b' ', 226, 130, 172, b' ', b'r', b'a', b't', b'e', b's'], extended_value.value); + } + + #[test] + fn test_parse_extended_value_missing_language_tag_and_encoding() { + // From: https://greenbytes.de/tech/tc2231/#attwithfn2231quot2 + let result = parse_extended_value("foo%20bar.html"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_extended_value_partially_formatted() { + let result = parse_extended_value("UTF-8'missing third part"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_extended_value_partially_formatted_blank() { + let result = parse_extended_value("blank second part'"); + assert!(result.is_err()); + } + + #[test] + fn test_fmt_extended_value_with_encoding_and_language_tag() { + let extended_value = ExtendedValue { + charset: Charset::Iso_8859_1, + language_tag: Some("en".parse().expect("Could not parse language tag")), + value: vec![163, b' ', b'r', b'a', b't', b'e', b's'], + }; + assert_eq!("ISO-8859-1'en'%A3%20rates", format!("{}", extended_value)); + } + + #[test] + fn test_fmt_extended_value_with_encoding() { + let extended_value = ExtendedValue { + charset: Charset::Ext("UTF-8".to_string()), + language_tag: None, + value: vec![194, 163, b' ', b'a', b'n', b'd', b' ', 226, 130, 172, b' ', b'r', b'a', + b't', b'e', b's'], + }; + assert_eq!("UTF-8''%C2%A3%20and%20%E2%82%AC%20rates", + format!("{}", extended_value)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5d3767a29..5b1420305 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,6 +128,7 @@ extern crate encoding; extern crate flate2; extern crate h2 as http2; extern crate num_cpus; +#[macro_use] extern crate percent_encoding; extern crate serde_json; extern crate serde_urlencoded; diff --git a/src/multipart.rs b/src/multipart.rs index f310327f4..9c5c0380c 100644 --- a/src/multipart.rs +++ b/src/multipart.rs @@ -7,7 +7,7 @@ use std::{cmp, fmt}; use bytes::Bytes; use futures::task::{current as current_task, Task}; use futures::{Async, Poll, Stream}; -use http::header::{self, HeaderMap, HeaderName, HeaderValue}; +use http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue}; use http::HttpTryFrom; use httparse; use mime; @@ -408,6 +408,17 @@ where pub fn content_type(&self) -> &mime::Mime { &self.ct } + + /// Get the content disposition of the field, if it exists + pub fn content_disposition(&self) -> Option { + // RFC 7578: 'Each part MUST contain a Content-Disposition header field + // where the disposition type is "form-data".' + if let Some(content_disposition) = self.headers.get(::http::header::CONTENT_DISPOSITION) { + ContentDisposition::from_raw(content_disposition).ok() + } else { + None + } + } } impl Stream for Field @@ -723,6 +734,7 @@ mod tests { let bytes = Bytes::from( "testasdadsad\r\n\ --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ test\r\n\ --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ @@ -738,6 +750,12 @@ mod tests { match multipart.poll() { Ok(Async::Ready(Some(item))) => match item { MultipartItem::Field(mut field) => { + { + use http::header::{DispositionType, DispositionParam}; + let cd = field.content_disposition().unwrap(); + assert_eq!(cd.disposition, DispositionType::Ext("form-data".into())); + assert_eq!(cd.parameters[0], DispositionParam::Ext("name".into(), "file".into())); + } assert_eq!(field.content_type().type_(), mime::TEXT); assert_eq!(field.content_type().subtype(), mime::PLAIN);