1
0
Fork 0
mirror of https://github.com/actix/actix-web.git synced 2024-11-22 09:31:10 +00:00

fix(multipart): optional content-disposition for non-form-data requests (#3416)

This commit is contained in:
Rob Ede 2024-07-01 03:55:08 +01:00 committed by GitHub
parent 668b8e5745
commit 71cd3a31f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 479 additions and 248 deletions

View file

@ -138,7 +138,7 @@ struct ParsedField<'t> {
/// `#[multipart(duplicate_field = "<behavior>")]` attribute: /// `#[multipart(duplicate_field = "<behavior>")]` attribute:
/// ///
/// - "ignore": (default) Extra fields are ignored. I.e., the first one is persisted. /// - "ignore": (default) Extra fields are ignored. I.e., the first one is persisted.
/// - "deny": A `MultipartError::UnsupportedField` error response is returned. /// - "deny": A `MultipartError::UnknownField` error response is returned.
/// - "replace": Each field is processed, but only the last one is persisted. /// - "replace": Each field is processed, but only the last one is persisted.
/// ///
/// Note that `Vec` fields will ignore this option. /// Note that `Vec` fields will ignore this option.
@ -229,7 +229,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS
// Return value when a field name is not supported by the form // Return value when a field name is not supported by the form
let unknown_field_result = if attrs.deny_unknown_fields { let unknown_field_result = if attrs.deny_unknown_fields {
quote!(::std::result::Result::Err( quote!(::std::result::Result::Err(
::actix_multipart::MultipartError::UnsupportedField(field.name().to_string()) ::actix_multipart::MultipartError::UnknownField(field.name().unwrap().to_string())
)) ))
} else { } else {
quote!(::std::result::Result::Ok(())) quote!(::std::result::Result::Ok(()))
@ -292,7 +292,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS
limits: &'t mut ::actix_multipart::form::Limits, limits: &'t mut ::actix_multipart::form::Limits,
state: &'t mut ::actix_multipart::form::State, state: &'t mut ::actix_multipart::form::State,
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::actix_multipart::MultipartError>> + 't>> { ) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::actix_multipart::MultipartError>> + 't>> {
match field.name() { match field.name().unwrap() {
#handle_field_impl #handle_field_impl
_ => return ::std::boxed::Box::pin(::std::future::ready(#unknown_field_result)), _ => return ::std::boxed::Box::pin(::std::future::ready(#unknown_field_result)),
} }

View file

@ -2,6 +2,15 @@
## Unreleased ## Unreleased
- Add `MultipartError::ContentTypeIncompatible` variant.
- Add `MultipartError::ContentDispositionNameMissing` variant.
- Rename `MultipartError::{NoContentDisposition => ContentDispositionMissing}` variant.
- Rename `MultipartError::{NoContentType => ContentTypeMissing}` variant.
- Rename `MultipartError::{ParseContentType => ContentTypeParse}` variant.
- Rename `MultipartError::{Boundary => BoundaryMissing}` variant.
- Rename `MultipartError::{UnsupportedField => UnknownField}` variant.
- Remove top-level re-exports of `test` utilities.
## 0.6.2 ## 0.6.2
- Add testing utilities under new module `test`. - Add testing utilities under new module `test`.

View file

@ -63,7 +63,9 @@ actix-multipart-rfc7578 = "0.10"
actix-rt = "2.2" actix-rt = "2.2"
actix-test = "0.1" actix-test = "0.1"
actix-web = "4" actix-web = "4"
assert_matches = "1"
awc = "3" awc = "3"
env_logger = "0.11"
futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] } futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] }
multer = "3" multer = "3"
tokio = { version = "1.24.2", features = ["sync"] } tokio = { version = "1.24.2", features = ["sync"] }

View file

@ -54,15 +54,15 @@ async fn main() -> std::io::Result<()> {
} }
``` ```
<!-- cargo-rdme end --> cURL request:
[More available in the examples repo &rarr;](https://github.com/actix/examples/tree/master/forms/multipart) ```sh
Curl request :
```bash
curl -v --request POST \ curl -v --request POST \
--url http://localhost:8080/videos \ --url http://localhost:8080/videos \
-F 'json={"name": "Cargo.lock"};type=application/json' \ -F 'json={"name": "Cargo.lock"};type=application/json' \
-F file=@./Cargo.lock -F file=@./Cargo.lock
``` ```
<!-- cargo-rdme end -->
[More available in the examples repo &rarr;](https://github.com/actix/examples/tree/master/forms/multipart)

View file

@ -0,0 +1,36 @@
use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
use actix_web::{middleware::Logger, post, App, HttpServer, Responder};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Metadata {
name: String,
}
#[derive(Debug, MultipartForm)]
struct UploadForm {
#[multipart(limit = "100MB")]
file: TempFile,
json: MpJson<Metadata>,
}
#[post("/videos")]
async fn post_video(MultipartForm(form): MultipartForm<UploadForm>) -> impl Responder {
format!(
"Uploaded file {}, with size: {}\ntemporary file ({}) was deleted\n",
form.json.name,
form.file.size,
form.file.file.path().display(),
)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
HttpServer::new(move || App::new().service(post_video).wrap(Logger::default()))
.workers(2)
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View file

@ -11,77 +11,95 @@ use derive_more::{Display, Error, From};
#[derive(Debug, Display, From, Error)] #[derive(Debug, Display, From, Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum MultipartError { pub enum MultipartError {
/// Content-Disposition header is not found or is not equal to "form-data". /// Could not find Content-Type header.
#[display(fmt = "Could not find Content-Type header")]
ContentTypeMissing,
/// Could not parse Content-Type header.
#[display(fmt = "Could not parse Content-Type header")]
ContentTypeParse,
/// Parsed Content-Type did not have "multipart" top-level media type.
/// ///
/// According to [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2) a /// Also raised when extracting a [`MultipartForm`] from a request that does not have the
/// Content-Disposition header must always be present and equal to "form-data". /// "multipart/form-data" media type.
#[display(fmt = "No Content-Disposition `form-data` header")] ///
NoContentDisposition, /// [`MultipartForm`]: struct@crate::form::MultipartForm
#[display(fmt = "Parsed Content-Type did not have "multipart" top-level media type")]
ContentTypeIncompatible,
/// Content-Type header is not found /// Multipart boundary is not found.
#[display(fmt = "No Content-Type header found")]
NoContentType,
/// Can not parse Content-Type header
#[display(fmt = "Can not parse Content-Type header")]
ParseContentType,
/// Multipart boundary is not found
#[display(fmt = "Multipart boundary is not found")] #[display(fmt = "Multipart boundary is not found")]
Boundary, BoundaryMissing,
/// Nested multipart is not supported /// Content-Disposition header was not found or not of disposition type "form-data" when parsing
/// a "form-data" field.
///
/// As per [RFC 7578 §4.2], a "multipart/form-data" field's Content-Disposition header must
/// always be present and have a disposition type of "form-data".
///
/// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
#[display(fmt = "Content-Disposition header was not found when parsing a \"form-data\" field")]
ContentDispositionMissing,
/// Content-Disposition name parameter was not found when parsing a "form-data" field.
///
/// As per [RFC 7578 §4.2], a "multipart/form-data" field's Content-Disposition header must
/// always include a "name" parameter.
///
/// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
#[display(fmt = "Content-Disposition header was not found when parsing a \"form-data\" field")]
ContentDispositionNameMissing,
/// Nested multipart is not supported.
#[display(fmt = "Nested multipart is not supported")] #[display(fmt = "Nested multipart is not supported")]
Nested, Nested,
/// Multipart stream is incomplete /// Multipart stream is incomplete.
#[display(fmt = "Multipart stream is incomplete")] #[display(fmt = "Multipart stream is incomplete")]
Incomplete, Incomplete,
/// Error during field parsing /// Field parsing failed.
#[display(fmt = "{}", _0)] #[display(fmt = "Error during field parsing")]
Parse(ParseError), Parse(ParseError),
/// Payload error /// HTTP payload error.
#[display(fmt = "{}", _0)] #[display(fmt = "Payload error")]
Payload(PayloadError), Payload(PayloadError),
/// Not consumed /// Stream is not consumed.
#[display(fmt = "Multipart stream is not consumed")] #[display(fmt = "Stream is not consumed")]
NotConsumed, NotConsumed,
/// An error from a field handler in a form /// Form field handler raised error.
#[display( #[display(fmt = "An error occurred processing field: {name}")]
fmt = "An error occurred processing field `{}`: {}",
field_name,
source
)]
Field { Field {
field_name: String, name: String,
source: actix_web::Error, source: actix_web::Error,
}, },
/// Duplicate field /// Duplicate field found (for structure that opted-in to denying duplicate fields).
#[display(fmt = "Duplicate field found for: `{}`", _0)] #[display(fmt = "Duplicate field found: {_0}")]
#[from(ignore)] #[from(ignore)]
DuplicateField(#[error(not(source))] String), DuplicateField(#[error(not(source))] String),
/// Missing field /// Required field is missing.
#[display(fmt = "Field with name `{}` is required", _0)] #[display(fmt = "Required field is missing: {_0}")]
#[from(ignore)] #[from(ignore)]
MissingField(#[error(not(source))] String), MissingField(#[error(not(source))] String),
/// Unknown field /// Unknown field (for structure that opted-in to denying unknown fields).
#[display(fmt = "Unsupported field `{}`", _0)] #[display(fmt = "Unknown field: {_0}")]
#[from(ignore)] #[from(ignore)]
UnsupportedField(#[error(not(source))] String), UnknownField(#[error(not(source))] String),
} }
/// Return `BadRequest` for `MultipartError` /// Return `BadRequest` for `MultipartError`.
impl ResponseError for MultipartError { impl ResponseError for MultipartError {
fn status_code(&self) -> StatusCode { fn status_code(&self) -> StatusCode {
match &self { match &self {
MultipartError::Field { source, .. } => source.as_response_error().status_code(), MultipartError::Field { source, .. } => source.as_response_error().status_code(),
MultipartError::ContentTypeIncompatible => StatusCode::UNSUPPORTED_MEDIA_TYPE,
_ => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_REQUEST,
} }
} }
@ -93,7 +111,7 @@ mod tests {
#[test] #[test]
fn test_multipart_error() { fn test_multipart_error() {
let resp = MultipartError::Boundary.error_response(); let resp = MultipartError::BoundaryMissing.error_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
} }
} }

View file

@ -10,6 +10,7 @@ use crate::server::Multipart;
/// Content-type: multipart/form-data; /// Content-type: multipart/form-data;
/// ///
/// # Examples /// # Examples
///
/// ``` /// ```
/// use actix_web::{web, HttpResponse, Error}; /// use actix_web::{web, HttpResponse, Error};
/// use actix_multipart::Multipart; /// use actix_multipart::Multipart;
@ -35,9 +36,6 @@ impl FromRequest for Multipart {
#[inline] #[inline]
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
ready(Ok(match Multipart::boundary(req.headers()) { ready(Ok(Multipart::from_req(req, payload)))
Ok(boundary) => Multipart::from_boundary(boundary, payload.take()),
Err(err) => Multipart::from_error(err),
}))
} }
} }

View file

@ -41,8 +41,9 @@ impl<'t> FieldReader<'t> for Bytes {
content_type: field.content_type().map(ToOwned::to_owned), content_type: field.content_type().map(ToOwned::to_owned),
file_name: field file_name: field
.content_disposition() .content_disposition()
.expect("multipart form fields should have a content-disposition header")
.get_filename() .get_filename()
.map(str::to_owned), .map(ToOwned::to_owned),
}) })
}) })
} }

View file

@ -32,7 +32,6 @@ where
fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future { fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future {
Box::pin(async move { Box::pin(async move {
let config = JsonConfig::from_req(req); let config = JsonConfig::from_req(req);
let field_name = field.name().to_owned();
if config.validate_content_type { if config.validate_content_type {
let valid = if let Some(mime) = field.content_type() { let valid = if let Some(mime) = field.content_type() {
@ -43,17 +42,19 @@ where
if !valid { if !valid {
return Err(MultipartError::Field { return Err(MultipartError::Field {
field_name, name: field.form_field_name,
source: config.map_error(req, JsonFieldError::ContentType), source: config.map_error(req, JsonFieldError::ContentType),
}); });
} }
} }
let form_field_name = field.form_field_name.clone();
let bytes = Bytes::read_field(req, field, limits).await?; let bytes = Bytes::read_field(req, field, limits).await?;
Ok(Json(serde_json::from_slice(bytes.data.as_ref()).map_err( Ok(Json(serde_json::from_slice(bytes.data.as_ref()).map_err(
|err| MultipartError::Field { |err| MultipartError::Field {
field_name, name: form_field_name,
source: config.map_error(req, JsonFieldError::Deserialize(err)), source: config.map_error(req, JsonFieldError::Deserialize(err)),
}, },
)?)) )?))

View file

@ -80,13 +80,13 @@ where
state: &'t mut State, state: &'t mut State,
duplicate_field: DuplicateField, duplicate_field: DuplicateField,
) -> Self::Future { ) -> Self::Future {
if state.contains_key(field.name()) { if state.contains_key(&field.form_field_name) {
match duplicate_field { match duplicate_field {
DuplicateField::Ignore => return Box::pin(ready(Ok(()))), DuplicateField::Ignore => return Box::pin(ready(Ok(()))),
DuplicateField::Deny => { DuplicateField::Deny => {
return Box::pin(ready(Err(MultipartError::DuplicateField( return Box::pin(ready(Err(MultipartError::DuplicateField(
field.name().to_owned(), field.form_field_name,
)))) ))))
} }
@ -95,7 +95,7 @@ where
} }
Box::pin(async move { Box::pin(async move {
let field_name = field.name().to_owned(); let field_name = field.form_field_name.clone();
let t = T::read_field(req, field, limits).await?; let t = T::read_field(req, field, limits).await?;
state.insert(field_name, Box::new(t)); state.insert(field_name, Box::new(t));
Ok(()) Ok(())
@ -123,10 +123,8 @@ where
Box::pin(async move { Box::pin(async move {
// Note: Vec GroupReader always allows duplicates // Note: Vec GroupReader always allows duplicates
let field_name = field.name().to_owned();
let vec = state let vec = state
.entry(field_name) .entry(field.form_field_name.clone())
.or_insert_with(|| Box::<Vec<T>>::default()) .or_insert_with(|| Box::<Vec<T>>::default())
.downcast_mut::<Vec<T>>() .downcast_mut::<Vec<T>>()
.unwrap(); .unwrap();
@ -159,13 +157,13 @@ where
state: &'t mut State, state: &'t mut State,
duplicate_field: DuplicateField, duplicate_field: DuplicateField,
) -> Self::Future { ) -> Self::Future {
if state.contains_key(field.name()) { if state.contains_key(&field.form_field_name) {
match duplicate_field { match duplicate_field {
DuplicateField::Ignore => return Box::pin(ready(Ok(()))), DuplicateField::Ignore => return Box::pin(ready(Ok(()))),
DuplicateField::Deny => { DuplicateField::Deny => {
return Box::pin(ready(Err(MultipartError::DuplicateField( return Box::pin(ready(Err(MultipartError::DuplicateField(
field.name().to_owned(), field.form_field_name,
)))) ))))
} }
@ -174,7 +172,7 @@ where
} }
Box::pin(async move { Box::pin(async move {
let field_name = field.name().to_owned(); let field_name = field.form_field_name.clone();
let t = T::read_field(req, field, limits).await?; let t = T::read_field(req, field, limits).await?;
state.insert(field_name, Box::new(t)); state.insert(field_name, Box::new(t));
Ok(()) Ok(())
@ -281,6 +279,9 @@ impl Limits {
/// [`MultipartCollect`] trait. You should use the [`macro@MultipartForm`] macro to derive this /// [`MultipartCollect`] trait. You should use the [`macro@MultipartForm`] macro to derive this
/// for your struct. /// for your struct.
/// ///
/// Note that this extractor rejects requests with any other Content-Type such as `multipart/mixed`,
/// `multipart/related`, or non-multipart media types.
///
/// Add a [`MultipartFormConfig`] to your app data to configure extraction. /// Add a [`MultipartFormConfig`] to your app data to configure extraction.
#[derive(Deref, DerefMut)] #[derive(Deref, DerefMut)]
pub struct MultipartForm<T: MultipartCollect>(pub T); pub struct MultipartForm<T: MultipartCollect>(pub T);
@ -294,14 +295,24 @@ impl<T: MultipartCollect> MultipartForm<T> {
impl<T> FromRequest for MultipartForm<T> impl<T> FromRequest for MultipartForm<T>
where where
T: MultipartCollect, T: MultipartCollect + 'static,
{ {
type Error = Error; type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>; type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
#[inline] #[inline]
fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future { fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future {
let mut payload = Multipart::new(req.headers(), payload.take()); let mut multipart = Multipart::from_req(req, payload);
let content_type = match multipart.content_type_or_bail() {
Ok(content_type) => content_type,
Err(err) => return Box::pin(ready(Err(err.into()))),
};
if content_type.subtype() != mime::FORM_DATA {
// this extractor only supports multipart/form-data
return Box::pin(ready(Err(MultipartError::ContentTypeIncompatible.into())));
};
let config = MultipartFormConfig::from_req(req); let config = MultipartFormConfig::from_req(req);
let mut limits = Limits::new(config.total_limit, config.memory_limit); let mut limits = Limits::new(config.total_limit, config.memory_limit);
@ -313,14 +324,20 @@ where
Box::pin( Box::pin(
async move { async move {
let mut state = State::default(); let mut state = State::default();
// We need to ensure field limits are shared for all instances of this field name
// ensure limits are shared for all fields with this name
let mut field_limits = HashMap::<String, Option<usize>>::new(); let mut field_limits = HashMap::<String, Option<usize>>::new();
while let Some(field) = payload.try_next().await? { while let Some(field) = multipart.try_next().await? {
debug_assert!(
!field.form_field_name.is_empty(),
"multipart form fields should have names",
);
// Retrieve the limit for this field // Retrieve the limit for this field
let entry = field_limits let entry = field_limits
.entry(field.name().to_owned()) .entry(field.form_field_name.clone())
.or_insert_with(|| T::limit(field.name())); .or_insert_with(|| T::limit(&field.form_field_name));
limits.field_limit_remaining.clone_from(entry); limits.field_limit_remaining.clone_from(entry);
@ -329,6 +346,7 @@ where
// Update the stored limit // Update the stored limit
*entry = limits.field_limit_remaining; *entry = limits.field_limit_remaining;
} }
let inner = T::from_state(state)?; let inner = T::from_state(state)?;
Ok(MultipartForm(inner)) Ok(MultipartForm(inner))
} }
@ -752,6 +770,41 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
} }
#[actix_rt::test]
async fn non_multipart_form_data() {
#[derive(MultipartForm)]
struct TestNonMultipartFormData {
#[allow(unused)]
#[multipart(limit = "30B")]
foo: Text<String>,
}
async fn non_multipart_form_data_route(
_form: MultipartForm<TestNonMultipartFormData>,
) -> String {
unreachable!("request is sent with multipart/mixed");
}
let srv = actix_test::start(|| {
App::new().route("/", web::post().to(non_multipart_form_data_route))
});
let mut form = multipart::Form::default();
form.add_text("foo", "foo");
// mangle content-type, keeping the boundary
let ct = form.content_type().replacen("/form-data", "/mixed", 1);
let res = Client::default()
.post(srv.url("/"))
.content_type(ct)
.send_body(multipart::Body::from(form))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[should_panic(expected = "called `Result::unwrap()` on an `Err` value: Connect(Disconnected)")] #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: Connect(Disconnected)")]
#[actix_web::test] #[actix_web::test]
async fn field_try_next_panic() { async fn field_try_next_panic() {

View file

@ -42,38 +42,36 @@ impl<'t> FieldReader<'t> for TempFile {
fn read_field(req: &'t HttpRequest, mut field: Field, limits: &'t mut Limits) -> Self::Future { fn read_field(req: &'t HttpRequest, mut field: Field, limits: &'t mut Limits) -> Self::Future {
Box::pin(async move { Box::pin(async move {
let config = TempFileConfig::from_req(req); let config = TempFileConfig::from_req(req);
let field_name = field.name().to_owned();
let mut size = 0; let mut size = 0;
let file = config let file = config.create_tempfile().map_err(|err| {
.create_tempfile() config.map_error(req, &field.form_field_name, TempFileError::FileIo(err))
.map_err(|err| config.map_error(req, &field_name, TempFileError::FileIo(err)))?; })?;
let mut file_async = let mut file_async = tokio::fs::File::from_std(file.reopen().map_err(|err| {
tokio::fs::File::from_std(file.reopen().map_err(|err| { config.map_error(req, &field.form_field_name, TempFileError::FileIo(err))
config.map_error(req, &field_name, TempFileError::FileIo(err)) })?);
})?);
while let Some(chunk) = field.try_next().await? { while let Some(chunk) = field.try_next().await? {
limits.try_consume_limits(chunk.len(), false)?; limits.try_consume_limits(chunk.len(), false)?;
size += chunk.len(); size += chunk.len();
file_async.write_all(chunk.as_ref()).await.map_err(|err| { file_async.write_all(chunk.as_ref()).await.map_err(|err| {
config.map_error(req, &field_name, TempFileError::FileIo(err)) config.map_error(req, &field.form_field_name, TempFileError::FileIo(err))
})?; })?;
} }
file_async file_async.flush().await.map_err(|err| {
.flush() config.map_error(req, &field.form_field_name, TempFileError::FileIo(err))
.await })?;
.map_err(|err| config.map_error(req, &field_name, TempFileError::FileIo(err)))?;
Ok(TempFile { Ok(TempFile {
file, file,
content_type: field.content_type().map(ToOwned::to_owned), content_type: field.content_type().map(ToOwned::to_owned),
file_name: field file_name: field
.content_disposition() .content_disposition()
.expect("multipart form fields should have a content-disposition header")
.get_filename() .get_filename()
.map(str::to_owned), .map(ToOwned::to_owned),
size, size,
}) })
}) })
@ -137,7 +135,7 @@ impl TempFileConfig {
}; };
MultipartError::Field { MultipartError::Field {
field_name: field_name.to_owned(), name: field_name.to_owned(),
source, source,
} }
} }

View file

@ -36,7 +36,6 @@ where
fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future { fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future {
Box::pin(async move { Box::pin(async move {
let config = TextConfig::from_req(req); let config = TextConfig::from_req(req);
let field_name = field.name().to_owned();
if config.validate_content_type { if config.validate_content_type {
let valid = if let Some(mime) = field.content_type() { let valid = if let Some(mime) = field.content_type() {
@ -49,22 +48,24 @@ where
if !valid { if !valid {
return Err(MultipartError::Field { return Err(MultipartError::Field {
field_name, name: field.form_field_name,
source: config.map_error(req, TextError::ContentType), source: config.map_error(req, TextError::ContentType),
}); });
} }
} }
let form_field_name = field.form_field_name.clone();
let bytes = Bytes::read_field(req, field, limits).await?; let bytes = Bytes::read_field(req, field, limits).await?;
let text = str::from_utf8(&bytes.data).map_err(|err| MultipartError::Field { let text = str::from_utf8(&bytes.data).map_err(|err| MultipartError::Field {
field_name: field_name.clone(), name: form_field_name.clone(),
source: config.map_error(req, TextError::Utf8Error(err)), source: config.map_error(req, TextError::Utf8Error(err)),
})?; })?;
Ok(Text(serde_plain::from_str(text).map_err(|err| { Ok(Text(serde_plain::from_str(text).map_err(|err| {
MultipartError::Field { MultipartError::Field {
field_name, name: form_field_name,
source: config.map_error(req, TextError::Deserialize(err)), source: config.map_error(req, TextError::Deserialize(err)),
} }
})?)) })?))

View file

@ -5,7 +5,7 @@
//! ```no_run //! ```no_run
//! use actix_web::{post, App, HttpServer, Responder}; //! use actix_web::{post, App, HttpServer, Responder};
//! //!
//! use actix_multipart::form::{json::Json as MPJson, tempfile::TempFile, MultipartForm}; //! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
//! use serde::Deserialize; //! use serde::Deserialize;
//! //!
//! #[derive(Debug, Deserialize)] //! #[derive(Debug, Deserialize)]
@ -17,7 +17,7 @@
//! struct UploadForm { //! struct UploadForm {
//! #[multipart(limit = "100MB")] //! #[multipart(limit = "100MB")]
//! file: TempFile, //! file: TempFile,
//! json: MPJson<Metadata>, //! json: MpJson<Metadata>,
//! } //! }
//! //!
//! #[post("/videos")] //! #[post("/videos")]
@ -36,6 +36,15 @@
//! .await //! .await
//! } //! }
//! ``` //! ```
//!
//! cURL request:
//!
//! ```sh
//! curl -v --request POST \
//! --url http://localhost:8080/videos \
//! -F 'json={"name": "Cargo.lock"};type=application/json' \
//! -F file=@./Cargo.lock
//! ```
#![deny(rust_2018_idioms, nonstandard_style)] #![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)] #![warn(future_incompatible)]
@ -57,7 +66,4 @@ pub mod test;
pub use self::{ pub use self::{
error::MultipartError, error::MultipartError,
server::{Field, Multipart}, server::{Field, Multipart},
test::{
create_form_data_payload_and_headers, create_form_data_payload_and_headers_with_boundary,
},
}; };

View file

@ -10,12 +10,15 @@ use std::{
}; };
use actix_web::{ use actix_web::{
dev,
error::{ParseError, PayloadError}, error::{ParseError, PayloadError},
http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue}, http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue},
HttpRequest,
}; };
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
use futures_core::stream::{LocalBoxStream, Stream}; use futures_core::stream::{LocalBoxStream, Stream};
use local_waker::LocalWaker; use local_waker::LocalWaker;
use mime::Mime;
use crate::error::MultipartError; use crate::error::MultipartError;
@ -23,87 +26,79 @@ const MAX_HEADERS: usize = 32;
/// The server-side implementation of `multipart/form-data` requests. /// The server-side implementation of `multipart/form-data` requests.
/// ///
/// This will parse the incoming stream into `MultipartItem` instances via its /// This will parse the incoming stream into `MultipartItem` instances via its `Stream`
/// Stream implementation. /// implementation. `MultipartItem::Field` contains multipart field. `MultipartItem::Multipart` is
/// `MultipartItem::Field` contains multipart field. `MultipartItem::Multipart` /// used for nested multipart streams.
/// is used for nested multipart streams.
pub struct Multipart { pub struct Multipart {
safety: Safety, safety: Safety,
error: Option<MultipartError>,
inner: Option<InnerMultipart>, inner: Option<InnerMultipart>,
} error: Option<MultipartError>,
enum InnerMultipartItem {
None,
Field(Rc<RefCell<InnerField>>),
}
#[derive(PartialEq, Debug)]
enum InnerState {
/// Stream eof
Eof,
/// Skip data until first boundary
FirstBoundary,
/// Reading boundary
Boundary,
/// Reading Headers,
Headers,
}
struct InnerMultipart {
payload: PayloadRef,
boundary: String,
state: InnerState,
item: InnerMultipartItem,
} }
impl Multipart { impl Multipart {
/// Create multipart instance for boundary. /// Creates multipart instance from parts.
pub fn new<S>(headers: &HeaderMap, stream: S) -> Multipart pub fn new<S>(headers: &HeaderMap, stream: S) -> Self
where where
S: Stream<Item = Result<Bytes, PayloadError>> + 'static, S: Stream<Item = Result<Bytes, PayloadError>> + 'static,
{ {
match Self::boundary(headers) { match Self::find_ct_and_boundary(headers) {
Ok(boundary) => Multipart::from_boundary(boundary, stream), Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, stream),
Err(err) => Multipart::from_error(err), Err(err) => Self::from_error(err),
} }
} }
/// Extract boundary info from headers. /// Creates multipart instance from parts.
pub(crate) fn boundary(headers: &HeaderMap) -> Result<String, MultipartError> { pub(crate) fn from_req(req: &HttpRequest, payload: &mut dev::Payload) -> Self {
headers match Self::find_ct_and_boundary(req.headers()) {
.get(&header::CONTENT_TYPE) Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, payload.take()),
.ok_or(MultipartError::NoContentType)? Err(err) => Self::from_error(err),
.to_str() }
.ok()
.and_then(|content_type| content_type.parse::<mime::Mime>().ok())
.ok_or(MultipartError::ParseContentType)?
.get_param(mime::BOUNDARY)
.map(|boundary| boundary.as_str().to_owned())
.ok_or(MultipartError::Boundary)
} }
/// Create multipart instance for given boundary and stream /// Extract Content-Type and boundary info from headers.
pub(crate) fn from_boundary<S>(boundary: String, stream: S) -> Multipart pub(crate) fn find_ct_and_boundary(
headers: &HeaderMap,
) -> Result<(Mime, String), MultipartError> {
let content_type = headers
.get(&header::CONTENT_TYPE)
.ok_or(MultipartError::ContentTypeMissing)?
.to_str()
.ok()
.and_then(|content_type| content_type.parse::<Mime>().ok())
.ok_or(MultipartError::ContentTypeParse)?;
if content_type.type_() != mime::MULTIPART {
return Err(MultipartError::ContentTypeIncompatible);
}
let boundary = content_type
.get_param(mime::BOUNDARY)
.ok_or(MultipartError::BoundaryMissing)?
.as_str()
.to_owned();
Ok((content_type, boundary))
}
/// Constructs a new multipart reader from given Content-Type, boundary, and stream.
pub(crate) fn from_ct_and_boundary<S>(ct: Mime, boundary: String, stream: S) -> Multipart
where where
S: Stream<Item = Result<Bytes, PayloadError>> + 'static, S: Stream<Item = Result<Bytes, PayloadError>> + 'static,
{ {
Multipart { Multipart {
error: None,
safety: Safety::new(), safety: Safety::new(),
inner: Some(InnerMultipart { inner: Some(InnerMultipart {
boundary,
payload: PayloadRef::new(PayloadBuffer::new(stream)), payload: PayloadRef::new(PayloadBuffer::new(stream)),
content_type: ct,
boundary,
state: InnerState::FirstBoundary, state: InnerState::FirstBoundary,
item: InnerMultipartItem::None, item: InnerMultipartItem::None,
}), }),
error: None,
} }
} }
/// Create Multipart instance from MultipartError /// Constructs a new multipart reader from given `MultipartError`.
pub(crate) fn from_error(err: MultipartError) -> Multipart { pub(crate) fn from_error(err: MultipartError) -> Multipart {
Multipart { Multipart {
error: Some(err), error: Some(err),
@ -111,6 +106,21 @@ impl Multipart {
inner: None, inner: None,
} }
} }
/// Return requests parsed Content-Type or raise the stored error.
pub(crate) fn content_type_or_bail(&mut self) -> Result<mime::Mime, MultipartError> {
if let Some(err) = self.error.take() {
return Err(err);
}
Ok(self
.inner
.as_ref()
// TODO: look into using enum instead of two options
.expect("multipart requests should have state")
.content_type
.clone())
}
} }
impl Stream for Multipart { impl Stream for Multipart {
@ -141,8 +151,46 @@ impl Stream for Multipart {
} }
} }
#[derive(PartialEq, Debug)]
enum InnerState {
/// Stream EOF.
Eof,
/// Skip data until first boundary.
FirstBoundary,
/// Reading boundary.
Boundary,
/// Reading Headers.
Headers,
}
enum InnerMultipartItem {
None,
Field(Rc<RefCell<InnerField>>),
}
struct InnerMultipart {
/// Request's payload stream & buffer.
payload: PayloadRef,
/// Request's Content-Type.
///
/// Guaranteed to have "multipart" top-level media type, i.e., `multipart/*`.
content_type: Mime,
/// Field boundary.
boundary: String,
state: InnerState,
item: InnerMultipartItem,
}
impl InnerMultipart { impl InnerMultipart {
fn read_headers(payload: &mut PayloadBuffer) -> Result<Option<HeaderMap>, MultipartError> { fn read_field_headers(
payload: &mut PayloadBuffer,
) -> Result<Option<HeaderMap>, MultipartError> {
match payload.read_until(b"\r\n\r\n")? { match payload.read_until(b"\r\n\r\n")? {
None => { None => {
if payload.eof { if payload.eof {
@ -153,6 +201,7 @@ impl InnerMultipart {
} }
Some(bytes) => { Some(bytes) => {
let mut hdrs = [httparse::EMPTY_HEADER; MAX_HEADERS]; let mut hdrs = [httparse::EMPTY_HEADER; MAX_HEADERS];
match httparse::parse_headers(&bytes, &mut hdrs) { match httparse::parse_headers(&bytes, &mut hdrs) {
Ok(httparse::Status::Complete((_, hdrs))) => { Ok(httparse::Status::Complete((_, hdrs))) => {
// convert headers // convert headers
@ -193,7 +242,7 @@ impl InnerMultipart {
|| &chunk[..2] != b"--" || &chunk[..2] != b"--"
|| &chunk[2..boundary.len() + 2] != boundary.as_bytes() || &chunk[2..boundary.len() + 2] != boundary.as_bytes()
{ {
Err(MultipartError::Boundary) Err(MultipartError::BoundaryMissing)
} else if &chunk[boundary.len() + 2..] == b"\r\n" { } else if &chunk[boundary.len() + 2..] == b"\r\n" {
Ok(Some(false)) Ok(Some(false))
} else if &chunk[boundary.len() + 2..boundary.len() + 4] == b"--" } else if &chunk[boundary.len() + 2..boundary.len() + 4] == b"--"
@ -202,7 +251,7 @@ impl InnerMultipart {
{ {
Ok(Some(true)) Ok(Some(true))
} else { } else {
Err(MultipartError::Boundary) Err(MultipartError::BoundaryMissing)
} }
} }
} }
@ -217,7 +266,7 @@ impl InnerMultipart {
match payload.readline()? { match payload.readline()? {
Some(chunk) => { Some(chunk) => {
if chunk.is_empty() { if chunk.is_empty() {
return Err(MultipartError::Boundary); return Err(MultipartError::BoundaryMissing);
} }
if chunk.len() < boundary.len() { if chunk.len() < boundary.len() {
continue; continue;
@ -282,7 +331,7 @@ impl InnerMultipart {
} }
} }
let headers = if let Some(mut payload) = self.payload.get_mut(safety) { let field_headers = if let Some(mut payload) = self.payload.get_mut(safety) {
match self.state { match self.state {
// read until first boundary // read until first boundary
InnerState::FirstBoundary => { InnerState::FirstBoundary => {
@ -317,7 +366,7 @@ impl InnerMultipart {
// read field headers for next field // read field headers for next field
if self.state == InnerState::Headers { if self.state == InnerState::Headers {
if let Some(headers) = InnerMultipart::read_headers(&mut payload)? { if let Some(headers) = InnerMultipart::read_field_headers(&mut payload)? {
self.state = InnerState::Boundary; self.state = InnerState::Boundary;
headers headers
} else { } else {
@ -331,31 +380,37 @@ impl InnerMultipart {
return Poll::Pending; return Poll::Pending;
}; };
// According to RFC 7578 §4.2, a Content-Disposition header must always be present and let field_content_disposition = field_headers
// set to "form-data".
let content_disposition = headers
.get(&header::CONTENT_DISPOSITION) .get(&header::CONTENT_DISPOSITION)
.and_then(|cd| ContentDisposition::from_raw(cd).ok()) .and_then(|cd| ContentDisposition::from_raw(cd).ok())
.filter(|content_disposition| { .filter(|content_disposition| {
let is_form_data = matches!(
content_disposition.disposition == header::DispositionType::FormData; content_disposition.disposition,
header::DispositionType::FormData,
let has_field_name = content_disposition )
.parameters
.iter()
.any(|param| matches!(param, header::DispositionParam::Name(_)));
is_form_data && has_field_name
}); });
let cd = if let Some(content_disposition) = content_disposition { let form_field_name = if self.content_type.subtype() == mime::FORM_DATA {
content_disposition // According to RFC 7578 §4.2, which relates to "multipart/form-data" requests
// specifically, fields must have a Content-Disposition header, its disposition
// type must be set as "form-data", and it must have a name parameter.
let Some(cd) = &field_content_disposition else {
return Poll::Ready(Some(Err(MultipartError::ContentDispositionMissing)));
};
let Some(field_name) = cd.get_name() else {
return Poll::Ready(Some(Err(MultipartError::ContentDispositionNameMissing)));
};
Some(field_name.to_owned())
} else { } else {
return Poll::Ready(Some(Err(MultipartError::NoContentDisposition))); None
}; };
let ct: Option<mime::Mime> = headers // TODO: check out other multipart/* RFCs for specific requirements
let field_content_type: Option<Mime> = field_headers
.get(&header::CONTENT_TYPE) .get(&header::CONTENT_TYPE)
.and_then(|ct| ct.to_str().ok()) .and_then(|ct| ct.to_str().ok())
.and_then(|ct| ct.parse().ok()); .and_then(|ct| ct.parse().ok());
@ -363,23 +418,24 @@ impl InnerMultipart {
self.state = InnerState::Boundary; self.state = InnerState::Boundary;
// nested multipart stream is not supported // nested multipart stream is not supported
if let Some(mime) = &ct { if let Some(mime) = &field_content_type {
if mime.type_() == mime::MULTIPART { if mime.type_() == mime::MULTIPART {
return Poll::Ready(Some(Err(MultipartError::Nested))); return Poll::Ready(Some(Err(MultipartError::Nested)));
} }
} }
let field = let field_inner =
InnerField::new_in_rc(self.payload.clone(), self.boundary.clone(), &headers)?; InnerField::new_in_rc(self.payload.clone(), self.boundary.clone(), &field_headers)?;
self.item = InnerMultipartItem::Field(Rc::clone(&field)); self.item = InnerMultipartItem::Field(Rc::clone(&field_inner));
Poll::Ready(Some(Ok(Field::new( Poll::Ready(Some(Ok(Field::new(
field_content_type,
field_content_disposition,
form_field_name,
field_headers,
safety.clone(cx), safety.clone(cx),
headers, field_inner,
ct,
cd,
field,
)))) ))))
} }
} }
@ -392,26 +448,42 @@ impl Drop for InnerMultipart {
} }
} }
/// A single field in a multipart stream /// A single field in a multipart stream.
pub struct Field { pub struct Field {
ct: Option<mime::Mime>, /// Field's Content-Type.
cd: ContentDisposition, content_type: Option<Mime>,
/// Field's Content-Disposition.
content_disposition: Option<ContentDisposition>,
/// Form field name.
///
/// A non-optional storage for form field names to avoid unwraps in `form` module. Will be an
/// empty string in non-form contexts.
///
// INVARIANT: always non-empty when request content-type is multipart/form-data.
pub(crate) form_field_name: String,
/// Field's header map.
headers: HeaderMap, headers: HeaderMap,
inner: Rc<RefCell<InnerField>>,
safety: Safety, safety: Safety,
inner: Rc<RefCell<InnerField>>,
} }
impl Field { impl Field {
fn new( fn new(
safety: Safety, content_type: Option<Mime>,
content_disposition: Option<ContentDisposition>,
form_field_name: Option<String>,
headers: HeaderMap, headers: HeaderMap,
ct: Option<mime::Mime>, safety: Safety,
cd: ContentDisposition,
inner: Rc<RefCell<InnerField>>, inner: Rc<RefCell<InnerField>>,
) -> Self { ) -> Self {
Field { Field {
ct, content_type,
cd, content_disposition,
form_field_name: form_field_name.unwrap_or_default(),
headers, headers,
inner, inner,
safety, safety,
@ -428,34 +500,36 @@ impl Field {
/// According to [RFC 7578](https://www.rfc-editor.org/rfc/rfc7578#section-4.4), if it is not /// According to [RFC 7578](https://www.rfc-editor.org/rfc/rfc7578#section-4.4), if it is not
/// present, it should default to "text/plain". Note it is the responsibility of the client to /// present, it should default to "text/plain". Note it is the responsibility of the client to
/// provide the appropriate content type, there is no attempt to validate this by the server. /// provide the appropriate content type, there is no attempt to validate this by the server.
pub fn content_type(&self) -> Option<&mime::Mime> { pub fn content_type(&self) -> Option<&Mime> {
self.ct.as_ref() self.content_type.as_ref()
} }
/// Returns the field's Content-Disposition. /// Returns this field's parsed Content-Disposition header, if set.
/// ///
/// Per [RFC 7578 §4.2]: "Each part MUST contain a Content-Disposition header field where the /// # Validation
/// disposition type is `form-data`. The Content-Disposition header field MUST also contain an
/// additional parameter of `name`; the value of the `name` parameter is the original field name
/// from the form."
/// ///
/// This crate validates that it exists before returning a `Field`. As such, it is safe to /// Per [RFC 7578 §4.2], the parts of a multipart/form-data payload MUST contain a
/// unwrap `.content_disposition().get_name()`. The [name](Self::name) method is provided as /// Content-Disposition header field where the disposition type is `form-data` and MUST also
/// a convenience. /// contain an additional parameter of `name` with its value being the original field name from
/// the form. This requirement is enforced during extraction for multipart/form-data requests,
/// but not other kinds of multipart requests (such as multipart/related).
///
/// As such, it is safe to `.unwrap()` calls `.content_disposition()` if you've verified.
///
/// The [`name()`](Self::name) method is also provided as a convenience for obtaining the
/// aforementioned name parameter.
/// ///
/// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 /// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
pub fn content_disposition(&self) -> &ContentDisposition { pub fn content_disposition(&self) -> Option<&ContentDisposition> {
&self.cd self.content_disposition.as_ref()
} }
/// Returns the field's name. /// Returns the field's name, if set.
/// ///
/// See [content_disposition](Self::content_disposition) regarding guarantees about existence of /// See [`content_disposition()`](Self::content_disposition) regarding guarantees on presence of
/// the name field. /// the "name" field.
pub fn name(&self) -> &str { pub fn name(&self) -> Option<&str> {
self.content_disposition() self.content_disposition()?.get_name()
.get_name()
.expect("field name should be guaranteed to exist in multipart form-data")
} }
} }
@ -465,6 +539,7 @@ impl Stream for Field {
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut(); let this = self.get_mut();
let mut inner = this.inner.borrow_mut(); let mut inner = this.inner.borrow_mut();
if let Some(mut buffer) = inner if let Some(mut buffer) = inner
.payload .payload
.as_ref() .as_ref()
@ -486,7 +561,7 @@ impl Stream for Field {
impl fmt::Debug for Field { impl fmt::Debug for Field {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ct) = &self.ct { if let Some(ct) = &self.content_type {
writeln!(f, "\nField: {}", ct)?; writeln!(f, "\nField: {}", ct)?;
} else { } else {
writeln!(f, "\nField:")?; writeln!(f, "\nField:")?;
@ -570,6 +645,7 @@ impl InnerField {
} }
/// Reads content chunk of body part with unknown length. /// Reads content chunk of body part with unknown length.
///
/// The `Content-Length` header for body part is not necessary. /// The `Content-Length` header for body part is not necessary.
fn read_stream( fn read_stream(
payload: &mut PayloadBuffer, payload: &mut PayloadBuffer,
@ -704,8 +780,8 @@ impl PayloadRef {
} }
} }
fn get_mut(&self, s: &Safety) -> Option<RefMut<'_, PayloadBuffer>> { fn get_mut(&self, safety: &Safety) -> Option<RefMut<'_, PayloadBuffer>> {
if s.current() { if safety.current() {
Some(self.payload.borrow_mut()) Some(self.payload.borrow_mut())
} else { } else {
None None
@ -722,10 +798,11 @@ impl Clone for PayloadRef {
} }
/// Counter. It tracks of number of clones of payloads and give access to payload only to top most. /// Counter. It tracks of number of clones of payloads and give access to payload only to top most.
/// * When dropped, parent task is awakened. This is to support the case where Field is ///
/// dropped in a separate task than Multipart. /// - When dropped, parent task is awakened. This is to support the case where `Field` is dropped in
/// * Assumes that parent owners don't move to different tasks; only the top-most is allowed to. /// a separate task than `Multipart`.
/// * If dropped and is not top most owner, is_clean flag is set to false. /// - Assumes that parent owners don't move to different tasks; only the top-most is allowed to.
/// - If dropped and is not top most owner, is_clean flag is set to false.
#[derive(Debug)] #[derive(Debug)]
struct Safety { struct Safety {
task: LocalWaker, task: LocalWaker,
@ -876,6 +953,7 @@ mod tests {
test::TestRequest, test::TestRequest,
FromRequest, FromRequest,
}; };
use assert_matches::assert_matches;
use bytes::BufMut as _; use bytes::BufMut as _;
use futures_util::{future::lazy, StreamExt as _}; use futures_util::{future::lazy, StreamExt as _};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -888,8 +966,8 @@ mod tests {
#[actix_rt::test] #[actix_rt::test]
async fn test_boundary() { async fn test_boundary() {
let headers = HeaderMap::new(); let headers = HeaderMap::new();
match Multipart::boundary(&headers) { match Multipart::find_ct_and_boundary(&headers) {
Err(MultipartError::NoContentType) => {} Err(MultipartError::ContentTypeMissing) => {}
_ => unreachable!("should not happen"), _ => unreachable!("should not happen"),
} }
@ -899,8 +977,8 @@ mod tests {
header::HeaderValue::from_static("test"), header::HeaderValue::from_static("test"),
); );
match Multipart::boundary(&headers) { match Multipart::find_ct_and_boundary(&headers) {
Err(MultipartError::ParseContentType) => {} Err(MultipartError::ContentTypeParse) => {}
_ => unreachable!("should not happen"), _ => unreachable!("should not happen"),
} }
@ -909,8 +987,8 @@ mod tests {
header::CONTENT_TYPE, header::CONTENT_TYPE,
header::HeaderValue::from_static("multipart/mixed"), header::HeaderValue::from_static("multipart/mixed"),
); );
match Multipart::boundary(&headers) { match Multipart::find_ct_and_boundary(&headers) {
Err(MultipartError::Boundary) => {} Err(MultipartError::BoundaryMissing) => {}
_ => unreachable!("should not happen"), _ => unreachable!("should not happen"),
} }
@ -923,8 +1001,8 @@ mod tests {
); );
assert_eq!( assert_eq!(
Multipart::boundary(&headers).unwrap(), Multipart::find_ct_and_boundary(&headers).unwrap().1,
"5c02368e880e436dab70ed54e1c58209" "5c02368e880e436dab70ed54e1c58209",
); );
} }
@ -1059,7 +1137,7 @@ mod tests {
let mut multipart = Multipart::new(&headers, payload); let mut multipart = Multipart::new(&headers, payload);
match multipart.next().await { match multipart.next().await {
Some(Ok(mut field)) => { Some(Ok(mut field)) => {
let cd = field.content_disposition(); let cd = field.content_disposition().unwrap();
assert_eq!(cd.disposition, DispositionType::FormData); assert_eq!(cd.disposition, DispositionType::FormData);
assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); assert_eq!(cd.parameters[0], DispositionParam::Name("file".into()));
@ -1121,7 +1199,7 @@ mod tests {
let mut multipart = Multipart::new(&headers, payload); let mut multipart = Multipart::new(&headers, payload);
match multipart.next().await.unwrap() { match multipart.next().await.unwrap() {
Ok(mut field) => { Ok(mut field) => {
let cd = field.content_disposition(); let cd = field.content_disposition().unwrap();
assert_eq!(cd.disposition, DispositionType::FormData); assert_eq!(cd.disposition, DispositionType::FormData);
assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); assert_eq!(cd.parameters[0], DispositionParam::Name("file".into()));
@ -1245,7 +1323,7 @@ mod tests {
#[actix_rt::test] #[actix_rt::test]
async fn test_multipart_from_error() { async fn test_multipart_from_error() {
let err = MultipartError::NoContentType; let err = MultipartError::ContentTypeMissing;
let mut multipart = Multipart::from_error(err); let mut multipart = Multipart::from_error(err);
assert!(multipart.next().await.unwrap().is_err()) assert!(multipart.next().await.unwrap().is_err())
} }
@ -1254,9 +1332,8 @@ mod tests {
async fn test_multipart_from_boundary() { async fn test_multipart_from_boundary() {
let (_, payload) = create_stream(); let (_, payload) = create_stream();
let (_, headers) = create_simple_request_with_header(); let (_, headers) = create_simple_request_with_header();
let boundary = Multipart::boundary(&headers); let (ct, boundary) = Multipart::find_ct_and_boundary(&headers).unwrap();
assert!(boundary.is_ok()); let _ = Multipart::from_ct_and_boundary(ct, boundary, payload);
let _ = Multipart::from_boundary(boundary.unwrap(), payload);
} }
#[actix_rt::test] #[actix_rt::test]
@ -1278,11 +1355,43 @@ mod tests {
} }
#[actix_rt::test] #[actix_rt::test]
async fn no_content_disposition() { async fn no_content_disposition_form_data() {
let bytes = Bytes::from( let bytes = Bytes::from(
"testasdadsad\r\n\ "testasdadsad\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\
Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: 4\r\n\
\r\n\
test\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n",
);
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static(
"multipart/form-data; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"",
),
);
let payload = SlowStream::new(bytes);
let mut multipart = Multipart::new(&headers, payload);
let res = multipart.next().await.unwrap();
assert_matches!(
res.expect_err(
"according to RFC 7578, form-data fields require a content-disposition header"
),
MultipartError::ContentDispositionMissing
);
}
#[actix_rt::test]
async fn no_content_disposition_non_form_data() {
let bytes = Bytes::from(
"testasdadsad\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: 4\r\n\
\r\n\
test\r\n\ test\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n", --abbc761f78ff4d7cb7573b5a23f96ef0\r\n",
); );
@ -1297,20 +1406,18 @@ mod tests {
let mut multipart = Multipart::new(&headers, payload); let mut multipart = Multipart::new(&headers, payload);
let res = multipart.next().await.unwrap(); let res = multipart.next().await.unwrap();
assert!(res.is_err()); res.unwrap();
assert!(matches!(
res.unwrap_err(),
MultipartError::NoContentDisposition,
));
} }
#[actix_rt::test] #[actix_rt::test]
async fn no_name_in_content_disposition() { async fn no_name_in_form_data_content_disposition() {
let bytes = Bytes::from( let bytes = Bytes::from(
"testasdadsad\r\n\ "testasdadsad\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\
Content-Disposition: form-data; filename=\"fn.txt\"\r\n\ Content-Disposition: form-data; filename=\"fn.txt\"\r\n\
Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: 4\r\n\
\r\n\
test\r\n\ test\r\n\
--abbc761f78ff4d7cb7573b5a23f96ef0\r\n", --abbc761f78ff4d7cb7573b5a23f96ef0\r\n",
); );
@ -1318,18 +1425,17 @@ mod tests {
headers.insert( headers.insert(
header::CONTENT_TYPE, header::CONTENT_TYPE,
header::HeaderValue::from_static( header::HeaderValue::from_static(
"multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", "multipart/form-data; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"",
), ),
); );
let payload = SlowStream::new(bytes); let payload = SlowStream::new(bytes);
let mut multipart = Multipart::new(&headers, payload); let mut multipart = Multipart::new(&headers, payload);
let res = multipart.next().await.unwrap(); let res = multipart.next().await.unwrap();
assert!(res.is_err()); assert_matches!(
assert!(matches!( res.expect_err("according to RFC 7578, form-data fields require a name attribute"),
res.unwrap_err(), MultipartError::ContentDispositionNameMissing
MultipartError::NoContentDisposition, );
));
} }
#[actix_rt::test] #[actix_rt::test]
@ -1362,7 +1468,7 @@ mod tests {
let mut field = multipart.next().await.unwrap().unwrap(); let mut field = multipart.next().await.unwrap().unwrap();
let task = rt::spawn(async move { let task = rt::spawn(async move {
rt::time::sleep(Duration::from_secs(1)).await; rt::time::sleep(Duration::from_millis(500)).await;
assert_eq!(field.next().await.unwrap().unwrap(), "test"); assert_eq!(field.next().await.unwrap().unwrap(), "test");
drop(field); drop(field);
}); });

View file

@ -1,3 +1,5 @@
//! Multipart testing utilities.
use actix_web::http::header::{self, HeaderMap}; use actix_web::http::header::{self, HeaderMap};
use bytes::{BufMut as _, Bytes, BytesMut}; use bytes::{BufMut as _, Bytes, BytesMut};
use mime::Mime; use mime::Mime;

View file

@ -154,7 +154,7 @@ impl DispositionParam {
#[inline] #[inline]
pub fn as_name(&self) -> Option<&str> { pub fn as_name(&self) -> Option<&str> {
match self { match self {
DispositionParam::Name(ref name) => Some(name.as_str()), DispositionParam::Name(name) => Some(name.as_str()),
_ => None, _ => None,
} }
} }
@ -163,7 +163,7 @@ impl DispositionParam {
#[inline] #[inline]
pub fn as_filename(&self) -> Option<&str> { pub fn as_filename(&self) -> Option<&str> {
match self { match self {
DispositionParam::Filename(ref filename) => Some(filename.as_str()), DispositionParam::Filename(filename) => Some(filename.as_str()),
_ => None, _ => None,
} }
} }
@ -172,7 +172,7 @@ impl DispositionParam {
#[inline] #[inline]
pub fn as_filename_ext(&self) -> Option<&ExtendedValue> { pub fn as_filename_ext(&self) -> Option<&ExtendedValue> {
match self { match self {
DispositionParam::FilenameExt(ref value) => Some(value), DispositionParam::FilenameExt(value) => Some(value),
_ => None, _ => None,
} }
} }