660 lines
20 KiB
Rust
660 lines
20 KiB
Rust
//! multipart/form-data
|
|
use std::borrow::Cow;
|
|
use std::fmt;
|
|
use std::pin::Pin;
|
|
|
|
use bytes::Bytes;
|
|
use http::HeaderMap;
|
|
use mime_guess::Mime;
|
|
use percent_encoding::{self, AsciiSet, NON_ALPHANUMERIC};
|
|
|
|
use futures_core::Stream;
|
|
use futures_util::{future, stream, StreamExt};
|
|
|
|
use super::Body;
|
|
|
|
/// An async multipart/form-data request.
|
|
pub struct Form {
|
|
inner: FormParts<Part>,
|
|
}
|
|
|
|
/// A field in a multipart form.
|
|
pub struct Part {
|
|
meta: PartMetadata,
|
|
value: Body,
|
|
body_length: Option<u64>,
|
|
}
|
|
|
|
pub(crate) struct FormParts<P> {
|
|
pub(crate) boundary: String,
|
|
pub(crate) computed_headers: Vec<Vec<u8>>,
|
|
pub(crate) fields: Vec<(Cow<'static, str>, P)>,
|
|
pub(crate) percent_encoding: PercentEncoding,
|
|
}
|
|
|
|
pub(crate) struct PartMetadata {
|
|
mime: Option<Mime>,
|
|
file_name: Option<Cow<'static, str>>,
|
|
pub(crate) headers: HeaderMap,
|
|
}
|
|
|
|
pub(crate) trait PartProps {
|
|
fn value_len(&self) -> Option<u64>;
|
|
fn metadata(&self) -> &PartMetadata;
|
|
}
|
|
|
|
// ===== impl Form =====
|
|
|
|
impl Default for Form {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Form {
|
|
/// Creates a new async Form without any content.
|
|
pub fn new() -> Form {
|
|
Form {
|
|
inner: FormParts::new(),
|
|
}
|
|
}
|
|
|
|
/// Get the boundary that this form will use.
|
|
#[inline]
|
|
pub fn boundary(&self) -> &str {
|
|
self.inner.boundary()
|
|
}
|
|
|
|
/// Add a data field with supplied name and value.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// let form = reqwest::multipart::Form::new()
|
|
/// .text("username", "seanmonstar")
|
|
/// .text("password", "secret");
|
|
/// ```
|
|
pub fn text<T, U>(self, name: T, value: U) -> Form
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
U: Into<Cow<'static, str>>,
|
|
{
|
|
self.part(name, Part::text(value))
|
|
}
|
|
|
|
/// Adds a customized Part.
|
|
pub fn part<T>(self, name: T, part: Part) -> Form
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
self.with_inner(move |inner| inner.part(name, part))
|
|
}
|
|
|
|
/// Configure this `Form` to percent-encode using the `path-segment` rules.
|
|
pub fn percent_encode_path_segment(self) -> Form {
|
|
self.with_inner(|inner| inner.percent_encode_path_segment())
|
|
}
|
|
|
|
/// Configure this `Form` to percent-encode using the `attr-char` rules.
|
|
pub fn percent_encode_attr_chars(self) -> Form {
|
|
self.with_inner(|inner| inner.percent_encode_attr_chars())
|
|
}
|
|
|
|
/// Configure this `Form` to skip percent-encoding
|
|
pub fn percent_encode_noop(self) -> Form {
|
|
self.with_inner(|inner| inner.percent_encode_noop())
|
|
}
|
|
|
|
/// Consume this instance and transform into an instance of Body for use in a request.
|
|
pub(crate) fn stream(mut self) -> Body {
|
|
if self.inner.fields.is_empty() {
|
|
return Body::empty();
|
|
}
|
|
|
|
// create initial part to init reduce chain
|
|
let (name, part) = self.inner.fields.remove(0);
|
|
let start = Box::pin(self.part_stream(name, part))
|
|
as Pin<Box<dyn Stream<Item = crate::Result<Bytes>> + Send + Sync>>;
|
|
|
|
let fields = self.inner.take_fields();
|
|
// for each field, chain an additional stream
|
|
let stream = fields.into_iter().fold(start, |memo, (name, part)| {
|
|
let part_stream = self.part_stream(name, part);
|
|
Box::pin(memo.chain(part_stream))
|
|
as Pin<Box<dyn Stream<Item = crate::Result<Bytes>> + Send + Sync>>
|
|
});
|
|
// append special ending boundary
|
|
let last = stream::once(future::ready(Ok(
|
|
format!("--{}--\r\n", self.boundary()).into()
|
|
)));
|
|
Body::stream(stream.chain(last))
|
|
}
|
|
|
|
/// Generate a hyper::Body stream for a single Part instance of a Form request.
|
|
pub(crate) fn part_stream<T>(
|
|
&mut self,
|
|
name: T,
|
|
part: Part,
|
|
) -> impl Stream<Item = Result<Bytes, crate::Error>>
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
// start with boundary
|
|
let boundary = stream::once(future::ready(Ok(
|
|
format!("--{}\r\n", self.boundary()).into()
|
|
)));
|
|
// append headers
|
|
let header = stream::once(future::ready(Ok({
|
|
let mut h = self
|
|
.inner
|
|
.percent_encoding
|
|
.encode_headers(&name.into(), &part.meta);
|
|
h.extend_from_slice(b"\r\n\r\n");
|
|
h.into()
|
|
})));
|
|
// then append form data followed by terminating CRLF
|
|
boundary
|
|
.chain(header)
|
|
.chain(part.value.into_stream())
|
|
.chain(stream::once(future::ready(Ok("\r\n".into()))))
|
|
}
|
|
|
|
pub(crate) fn compute_length(&mut self) -> Option<u64> {
|
|
self.inner.compute_length()
|
|
}
|
|
|
|
fn with_inner<F>(self, func: F) -> Self
|
|
where
|
|
F: FnOnce(FormParts<Part>) -> FormParts<Part>,
|
|
{
|
|
Form {
|
|
inner: func(self.inner),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Form {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
self.inner.fmt_fields("Form", f)
|
|
}
|
|
}
|
|
|
|
// ===== impl Part =====
|
|
|
|
impl Part {
|
|
/// Makes a text parameter.
|
|
pub fn text<T>(value: T) -> Part
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
let body = match value.into() {
|
|
Cow::Borrowed(slice) => Body::from(slice),
|
|
Cow::Owned(string) => Body::from(string),
|
|
};
|
|
Part::new(body, None)
|
|
}
|
|
|
|
/// Makes a new parameter from arbitrary bytes.
|
|
pub fn bytes<T>(value: T) -> Part
|
|
where
|
|
T: Into<Cow<'static, [u8]>>,
|
|
{
|
|
let body = match value.into() {
|
|
Cow::Borrowed(slice) => Body::from(slice),
|
|
Cow::Owned(vec) => Body::from(vec),
|
|
};
|
|
Part::new(body, None)
|
|
}
|
|
|
|
/// Makes a new parameter from an arbitrary stream.
|
|
pub fn stream<T: Into<Body>>(value: T) -> Part {
|
|
Part::new(value.into(), None)
|
|
}
|
|
|
|
/// Makes a new parameter from an arbitrary stream with a known length. This is particularly
|
|
/// useful when adding something like file contents as a stream, where you can know the content
|
|
/// length beforehand.
|
|
pub fn stream_with_length<T: Into<Body>>(value: T, length: u64) -> Part {
|
|
Part::new(value.into(), Some(length))
|
|
}
|
|
|
|
fn new(value: Body, body_length: Option<u64>) -> Part {
|
|
Part {
|
|
meta: PartMetadata::new(),
|
|
value,
|
|
body_length,
|
|
}
|
|
}
|
|
|
|
/// Tries to set the mime of this part.
|
|
pub fn mime_str(self, mime: &str) -> crate::Result<Part> {
|
|
Ok(self.mime(mime.parse().map_err(crate::error::builder)?))
|
|
}
|
|
|
|
// Re-export when mime 0.4 is available, with split MediaType/MediaRange.
|
|
fn mime(self, mime: Mime) -> Part {
|
|
self.with_inner(move |inner| inner.mime(mime))
|
|
}
|
|
|
|
/// Sets the filename, builder style.
|
|
pub fn file_name<T>(self, filename: T) -> Part
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
self.with_inner(move |inner| inner.file_name(filename))
|
|
}
|
|
|
|
fn with_inner<F>(self, func: F) -> Self
|
|
where
|
|
F: FnOnce(PartMetadata) -> PartMetadata,
|
|
{
|
|
Part {
|
|
meta: func(self.meta),
|
|
..self
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Part {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
let mut dbg = f.debug_struct("Part");
|
|
dbg.field("value", &self.value);
|
|
self.meta.fmt_fields(&mut dbg);
|
|
dbg.finish()
|
|
}
|
|
}
|
|
|
|
impl PartProps for Part {
|
|
fn value_len(&self) -> Option<u64> {
|
|
if self.body_length.is_some() {
|
|
self.body_length
|
|
} else {
|
|
self.value.content_length()
|
|
}
|
|
}
|
|
|
|
fn metadata(&self) -> &PartMetadata {
|
|
&self.meta
|
|
}
|
|
}
|
|
|
|
// ===== impl FormParts =====
|
|
|
|
impl<P: PartProps> FormParts<P> {
|
|
pub(crate) fn new() -> Self {
|
|
FormParts {
|
|
boundary: gen_boundary(),
|
|
computed_headers: Vec::new(),
|
|
fields: Vec::new(),
|
|
percent_encoding: PercentEncoding::PathSegment,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn boundary(&self) -> &str {
|
|
&self.boundary
|
|
}
|
|
|
|
/// Adds a customized Part.
|
|
pub(crate) fn part<T>(mut self, name: T, part: P) -> Self
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
self.fields.push((name.into(), part));
|
|
self
|
|
}
|
|
|
|
/// Configure this `Form` to percent-encode using the `path-segment` rules.
|
|
pub(crate) fn percent_encode_path_segment(mut self) -> Self {
|
|
self.percent_encoding = PercentEncoding::PathSegment;
|
|
self
|
|
}
|
|
|
|
/// Configure this `Form` to percent-encode using the `attr-char` rules.
|
|
pub(crate) fn percent_encode_attr_chars(mut self) -> Self {
|
|
self.percent_encoding = PercentEncoding::AttrChar;
|
|
self
|
|
}
|
|
|
|
/// Configure this `Form` to skip percent-encoding
|
|
pub(crate) fn percent_encode_noop(mut self) -> Self {
|
|
self.percent_encoding = PercentEncoding::NoOp;
|
|
self
|
|
}
|
|
|
|
// If predictable, computes the length the request will have
|
|
// The length should be preditable if only String and file fields have been added,
|
|
// but not if a generic reader has been added;
|
|
pub(crate) fn compute_length(&mut self) -> Option<u64> {
|
|
let mut length = 0u64;
|
|
for &(ref name, ref field) in self.fields.iter() {
|
|
match field.value_len() {
|
|
Some(value_length) => {
|
|
// We are constructing the header just to get its length. To not have to
|
|
// construct it again when the request is sent we cache these headers.
|
|
let header = self.percent_encoding.encode_headers(name, field.metadata());
|
|
let header_length = header.len();
|
|
self.computed_headers.push(header);
|
|
// The additions mimic the format string out of which the field is constructed
|
|
// in Reader. Not the cleanest solution because if that format string is
|
|
// ever changed then this formula needs to be changed too which is not an
|
|
// obvious dependency in the code.
|
|
length += 2
|
|
+ self.boundary().len() as u64
|
|
+ 2
|
|
+ header_length as u64
|
|
+ 4
|
|
+ value_length
|
|
+ 2
|
|
}
|
|
_ => return None,
|
|
}
|
|
}
|
|
// If there is a at least one field there is a special boundary for the very last field.
|
|
if !self.fields.is_empty() {
|
|
length += 2 + self.boundary().len() as u64 + 4
|
|
}
|
|
Some(length)
|
|
}
|
|
|
|
/// Take the fields vector of this instance, replacing with an empty vector.
|
|
fn take_fields(&mut self) -> Vec<(Cow<'static, str>, P)> {
|
|
std::mem::replace(&mut self.fields, Vec::new())
|
|
}
|
|
}
|
|
|
|
impl<P: fmt::Debug> FormParts<P> {
|
|
pub(crate) fn fmt_fields(&self, ty_name: &'static str, f: &mut fmt::Formatter) -> fmt::Result {
|
|
f.debug_struct(ty_name)
|
|
.field("boundary", &self.boundary)
|
|
.field("parts", &self.fields)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
// ===== impl PartMetadata =====
|
|
|
|
impl PartMetadata {
|
|
pub(crate) fn new() -> Self {
|
|
PartMetadata {
|
|
mime: None,
|
|
file_name: None,
|
|
headers: HeaderMap::default(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mime(mut self, mime: Mime) -> Self {
|
|
self.mime = Some(mime);
|
|
self
|
|
}
|
|
|
|
pub(crate) fn file_name<T>(mut self, filename: T) -> Self
|
|
where
|
|
T: Into<Cow<'static, str>>,
|
|
{
|
|
self.file_name = Some(filename.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl PartMetadata {
|
|
pub(crate) fn fmt_fields<'f, 'fa, 'fb>(
|
|
&self,
|
|
debug_struct: &'f mut fmt::DebugStruct<'fa, 'fb>,
|
|
) -> &'f mut fmt::DebugStruct<'fa, 'fb> {
|
|
debug_struct
|
|
.field("mime", &self.mime)
|
|
.field("file_name", &self.file_name)
|
|
.field("headers", &self.headers)
|
|
}
|
|
}
|
|
|
|
// https://url.spec.whatwg.org/#fragment-percent-encode-set
|
|
const FRAGMENT_ENCODE_SET: &AsciiSet = &percent_encoding::CONTROLS
|
|
.add(b' ')
|
|
.add(b'"')
|
|
.add(b'<')
|
|
.add(b'>')
|
|
.add(b'`');
|
|
|
|
// https://url.spec.whatwg.org/#path-percent-encode-set
|
|
const PATH_ENCODE_SET: &AsciiSet = &FRAGMENT_ENCODE_SET.add(b'#').add(b'?').add(b'{').add(b'}');
|
|
|
|
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &PATH_ENCODE_SET.add(b'/').add(b'%');
|
|
|
|
// https://tools.ietf.org/html/rfc8187#section-3.2.1
|
|
const ATTR_CHAR_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
|
.remove(b'!')
|
|
.remove(b'#')
|
|
.remove(b'$')
|
|
.remove(b'&')
|
|
.remove(b'+')
|
|
.remove(b'-')
|
|
.remove(b'.')
|
|
.remove(b'^')
|
|
.remove(b'_')
|
|
.remove(b'`')
|
|
.remove(b'|')
|
|
.remove(b'~');
|
|
|
|
pub(crate) enum PercentEncoding {
|
|
PathSegment,
|
|
AttrChar,
|
|
NoOp,
|
|
}
|
|
|
|
impl PercentEncoding {
|
|
pub(crate) fn encode_headers(&self, name: &str, field: &PartMetadata) -> Vec<u8> {
|
|
let s = format!(
|
|
"Content-Disposition: form-data; {}{}{}",
|
|
self.format_parameter("name", name),
|
|
match field.file_name {
|
|
Some(ref file_name) => format!("; {}", self.format_filename(file_name)),
|
|
None => String::new(),
|
|
},
|
|
match field.mime {
|
|
Some(ref mime) => format!("\r\nContent-Type: {}", mime),
|
|
None => "".to_string(),
|
|
},
|
|
);
|
|
field
|
|
.headers
|
|
.iter()
|
|
.fold(s.into_bytes(), |mut header, (k, v)| {
|
|
header.extend_from_slice(b"\r\n");
|
|
header.extend_from_slice(k.as_str().as_bytes());
|
|
header.extend_from_slice(b": ");
|
|
header.extend_from_slice(v.as_bytes());
|
|
header
|
|
})
|
|
}
|
|
|
|
// According to RFC7578 Section 4.2, `filename*=` syntax is invalid.
|
|
// See https://github.com/seanmonstar/reqwest/issues/419.
|
|
fn format_filename(&self, filename: &str) -> String {
|
|
let legal_filename = filename
|
|
.replace("\\", "\\\\")
|
|
.replace("\"", "\\\"")
|
|
.replace("\r", "\\\r")
|
|
.replace("\n", "\\\n");
|
|
format!("filename=\"{}\"", legal_filename)
|
|
}
|
|
|
|
fn format_parameter(&self, name: &str, value: &str) -> String {
|
|
let legal_value = match *self {
|
|
PercentEncoding::PathSegment => {
|
|
percent_encoding::utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
|
|
}
|
|
PercentEncoding::AttrChar => {
|
|
percent_encoding::utf8_percent_encode(value, ATTR_CHAR_ENCODE_SET).to_string()
|
|
}
|
|
PercentEncoding::NoOp => value.to_string(),
|
|
};
|
|
if value.len() == legal_value.len() {
|
|
// nothing has been percent encoded
|
|
format!("{}=\"{}\"", name, value)
|
|
} else {
|
|
// something has been percent encoded
|
|
format!("{}*=utf-8''{}", name, legal_value)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn gen_boundary() -> String {
|
|
use crate::util::fast_random as random;
|
|
|
|
let a = random();
|
|
let b = random();
|
|
let c = random();
|
|
let d = random();
|
|
|
|
format!("{:016x}-{:016x}-{:016x}-{:016x}", a, b, c, d)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use futures_util::TryStreamExt;
|
|
use futures_util::{future, stream};
|
|
use tokio::{self, runtime};
|
|
|
|
#[test]
|
|
fn form_empty() {
|
|
let form = Form::new();
|
|
|
|
let rt = runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("new rt");
|
|
let body = form.stream().into_stream();
|
|
let s = body.map_ok(|try_c| try_c.to_vec()).try_concat();
|
|
|
|
let out = rt.block_on(s);
|
|
assert!(out.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn stream_to_end() {
|
|
let mut form = Form::new()
|
|
.part(
|
|
"reader1",
|
|
Part::stream(Body::stream(stream::once(future::ready::<
|
|
Result<String, crate::Error>,
|
|
>(Ok(
|
|
"part1".to_owned()
|
|
))))),
|
|
)
|
|
.part("key1", Part::text("value1"))
|
|
.part("key2", Part::text("value2").mime(mime::IMAGE_BMP))
|
|
.part(
|
|
"reader2",
|
|
Part::stream(Body::stream(stream::once(future::ready::<
|
|
Result<String, crate::Error>,
|
|
>(Ok(
|
|
"part2".to_owned()
|
|
))))),
|
|
)
|
|
.part("key3", Part::text("value3").file_name("filename"));
|
|
form.inner.boundary = "boundary".to_string();
|
|
let expected = "--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"reader1\"\r\n\r\n\
|
|
part1\r\n\
|
|
--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"key1\"\r\n\r\n\
|
|
value1\r\n\
|
|
--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"key2\"\r\n\
|
|
Content-Type: image/bmp\r\n\r\n\
|
|
value2\r\n\
|
|
--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"reader2\"\r\n\r\n\
|
|
part2\r\n\
|
|
--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"key3\"; filename=\"filename\"\r\n\r\n\
|
|
value3\r\n--boundary--\r\n";
|
|
let rt = runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("new rt");
|
|
let body = form.stream().into_stream();
|
|
let s = body.map(|try_c| try_c.map(|r| r.to_vec())).try_concat();
|
|
|
|
let out = rt.block_on(s).unwrap();
|
|
// These prints are for debug purposes in case the test fails
|
|
println!(
|
|
"START REAL\n{}\nEND REAL",
|
|
std::str::from_utf8(&out).unwrap()
|
|
);
|
|
println!("START EXPECTED\n{}\nEND EXPECTED", expected);
|
|
assert_eq!(std::str::from_utf8(&out).unwrap(), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn stream_to_end_with_header() {
|
|
let mut part = Part::text("value2").mime(mime::IMAGE_BMP);
|
|
part.meta.headers.insert("Hdr3", "/a/b/c".parse().unwrap());
|
|
let mut form = Form::new().part("key2", part);
|
|
form.inner.boundary = "boundary".to_string();
|
|
let expected = "--boundary\r\n\
|
|
Content-Disposition: form-data; name=\"key2\"\r\n\
|
|
Content-Type: image/bmp\r\n\
|
|
hdr3: /a/b/c\r\n\
|
|
\r\n\
|
|
value2\r\n\
|
|
--boundary--\r\n";
|
|
let rt = runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("new rt");
|
|
let body = form.stream().into_stream();
|
|
let s = body.map(|try_c| try_c.map(|r| r.to_vec())).try_concat();
|
|
|
|
let out = rt.block_on(s).unwrap();
|
|
// These prints are for debug purposes in case the test fails
|
|
println!(
|
|
"START REAL\n{}\nEND REAL",
|
|
std::str::from_utf8(&out).unwrap()
|
|
);
|
|
println!("START EXPECTED\n{}\nEND EXPECTED", expected);
|
|
assert_eq!(std::str::from_utf8(&out).unwrap(), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn correct_content_length() {
|
|
// Setup an arbitrary data stream
|
|
let stream_data = b"just some stream data";
|
|
let stream_len = stream_data.len();
|
|
let stream_data = stream_data
|
|
.chunks(3)
|
|
.map(|c| Ok::<_, std::io::Error>(Bytes::from(c)));
|
|
let the_stream = futures_util::stream::iter(stream_data);
|
|
|
|
let bytes_data = b"some bytes data".to_vec();
|
|
let bytes_len = bytes_data.len();
|
|
|
|
let stream_part = Part::stream_with_length(Body::stream(the_stream), stream_len as u64);
|
|
let body_part = Part::bytes(bytes_data);
|
|
|
|
// A simple check to make sure we get the configured body length
|
|
assert_eq!(stream_part.value_len().unwrap(), stream_len as u64);
|
|
|
|
// Make sure it delegates to the underlying body if length is not specified
|
|
assert_eq!(body_part.value_len().unwrap(), bytes_len as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn header_percent_encoding() {
|
|
let name = "start%'\"\r\nßend";
|
|
let field = Part::text("");
|
|
|
|
assert_eq!(
|
|
PercentEncoding::PathSegment.encode_headers(name, &field.meta),
|
|
&b"Content-Disposition: form-data; name*=utf-8''start%25'%22%0D%0A%C3%9Fend"[..]
|
|
);
|
|
|
|
assert_eq!(
|
|
PercentEncoding::AttrChar.encode_headers(name, &field.meta),
|
|
&b"Content-Disposition: form-data; name*=utf-8''start%25%27%22%0D%0A%C3%9Fend"[..]
|
|
);
|
|
}
|
|
}
|