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:
parent
668b8e5745
commit
71cd3a31f9
16 changed files with 479 additions and 248 deletions
|
@ -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)),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
|
@ -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"] }
|
||||||
|
|
|
@ -54,15 +54,15 @@ async fn main() -> std::io::Result<()> {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- cargo-rdme end -->
|
cURL request:
|
||||||
|
|
||||||
[More available in the examples repo →](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 →](https://github.com/actix/examples/tree/master/forms/multipart)
|
||||||
|
|
36
actix-multipart/examples/form.rs
Normal file
36
actix-multipart/examples/form.rs
Normal 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
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)),
|
||||||
},
|
},
|
||||||
)?))
|
)?))
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)),
|
||||||
}
|
}
|
||||||
})?))
|
})?))
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue