Skip to main content

base_web_multipart/
content_disposition.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::ParameterRules;
5use crate::syntax::{
6    PARAMETER_COUNT_LIMIT, field_text, is_field_value_byte, is_token, lowercase,
7    parse_parameterized, write_parameter_value,
8};
9use base_message::{FieldType, FieldValueError, combination::Singleton};
10use std::borrow::Cow;
11use std::fmt;
12
13/// A `Content-Disposition` field, as RFC 2183 defines it for MIME parts and RFC 7578 uses for form data: a disposition type, such as `form-data`, and parameters, such as `name` and `filename`.
14///
15/// Parsing borrows from the field value, copying only a quoted string with escapes. Names and filenames are returned as they were sent: RFC 7578 lets them be UTF-8, and browsers percent-encode the quotes and line breaks in them, which this type does not decode. In a quoted string, a backslash escapes only a quote or another backslash; any other backslash is kept, since browsers send Windows paths unescaped. Extended parameters, such as `filename*`, are kept as they were sent by [`parameter`](Self::parameter); [`name`](Self::name) and [`filename`](Self::filename) apply [`ParameterRules`] to them, since forms and other MIME parts treat them differently. They are written unquoted.
16///
17/// A value that is not UTF-8, as a form on a page in a legacy character set may send, is invalid, as are an empty parameter, such as a trailing `;`, and a value outside quotes that is not a token.
18///
19/// ```
20/// use base_web_multipart::{ContentDisposition, ParameterRules};
21/// use base_message::FieldType;
22///
23/// let disposition = ContentDisposition::parse(b"form-data; name=\"avatar\"; filename=\"caf\xc3\xa9.png\"")?;
24/// assert_eq!(disposition.disposition_type(), "form-data");
25/// assert_eq!(disposition.name(ParameterRules::FormData)?.as_deref(), Some("avatar"));
26/// assert_eq!(disposition.filename(ParameterRules::FormData)?.as_deref(), Some("café.png"));
27///
28/// let written = ContentDisposition::form_data("note")?.with_parameter("filename", "a \"quoted\" name")?;
29/// assert_eq!(written.to_string(), "form-data; name=\"note\"; filename=\"a \\\"quoted\\\" name\"");
30/// # Ok::<(), base_message::FieldValueError>(())
31/// ```
32#[derive(Clone, PartialEq, Eq, Hash)]
33pub struct ContentDisposition<'a> {
34    disposition_type: Cow<'a, str>,
35    parameters: Vec<(Cow<'a, str>, Cow<'a, str>)>,
36}
37
38impl ContentDisposition<'static> {
39    /// A disposition without parameters, such as `attachment`. The type is lowercased.
40    ///
41    /// # Errors
42    ///
43    /// When the type is not a token.
44    pub fn new(disposition_type: &str) -> Result<Self, FieldValueError> {
45        if !is_token(disposition_type) {
46            return Err(FieldValueError::new("disposition type is not a token"));
47        }
48        Ok(ContentDisposition {
49            disposition_type: Cow::Owned(disposition_type.to_ascii_lowercase()),
50            parameters: Vec::new(),
51        })
52    }
53
54    /// A `form-data` disposition for a form field with this name, as RFC 7578 section 4.2 requires of each part.
55    ///
56    /// # Errors
57    ///
58    /// When the name contains a control character, such as CR or LF, which a quoted string cannot carry.
59    pub fn form_data(name: &str) -> Result<Self, FieldValueError> {
60        ContentDisposition {
61            disposition_type: Cow::Borrowed("form-data"),
62            parameters: Vec::new(),
63        }
64        .with_parameter("name", name)
65    }
66}
67
68impl<'a> ContentDisposition<'a> {
69    /// The disposition with a parameter added. The name is lowercased.
70    ///
71    /// # Errors
72    ///
73    /// When the name is not a token or is already present, the value contains a control character other than horizontal tab, the name ends with `*`, marking an RFC 8187 extended value, and the value is not a token, or the disposition already has the most parameters it may have.
74    pub fn with_parameter(
75        mut self,
76        name: &str,
77        value: &str,
78    ) -> Result<ContentDisposition<'a>, FieldValueError> {
79        if !is_token(name) {
80            return Err(FieldValueError::new("parameter name is not a token"));
81        }
82        if !value.bytes().all(is_field_value_byte) {
83            return Err(FieldValueError::new(
84                "parameter value contains a control character",
85            ));
86        }
87        if name.ends_with('*') && !is_token(value) {
88            return Err(FieldValueError::new(
89                "extended parameter value is not a token",
90            ));
91        }
92        let name = lowercase(name).into_owned();
93        if self.parameter(&name).is_some() {
94            return Err(FieldValueError::new("duplicate parameter"));
95        }
96        if self.parameters.len() == PARAMETER_COUNT_LIMIT {
97            return Err(FieldValueError::new("too many parameters"));
98        }
99        self.parameters
100            .push((Cow::Owned(name), Cow::Owned(value.to_owned())));
101        Ok(self)
102    }
103
104    /// The lowercase disposition type, such as `form-data`, `attachment`, or `inline`.
105    pub fn disposition_type(&self) -> &str {
106        &self.disposition_type
107    }
108
109    /// The value of a parameter. Names are compared case-insensitively.
110    pub fn parameter(&self, name: &str) -> Option<&str> {
111        self.parameters
112            .iter()
113            .find(|(parameter, _)| parameter.eq_ignore_ascii_case(name))
114            .map(|(_, value)| &**value)
115    }
116
117    /// The lowercase parameter names and their values, in order.
118    pub fn parameters(&self) -> impl ExactSizeIterator<Item = (&str, &str)> {
119        self.parameters
120            .iter()
121            .map(|(name, value)| (&**name, &**value))
122    }
123
124    /// The `name` parameter, a form field's name, following these rules for extended parameters.
125    ///
126    /// # Errors
127    ///
128    /// When the rules do not allow the disposition's extended parameters, or an extended `name*` cannot be decoded.
129    pub fn name(&self, rules: ParameterRules) -> Result<Option<Cow<'_, str>>, FieldValueError> {
130        self.resolve("name", rules)
131    }
132
133    /// The `filename` parameter, a file's name as the sender gave it, following these rules for extended parameters. It must not be used as a local path without checking it.
134    ///
135    /// # Errors
136    ///
137    /// When the rules do not allow the disposition's extended parameters, or an extended `filename*` cannot be decoded.
138    pub fn filename(&self, rules: ParameterRules) -> Result<Option<Cow<'_, str>>, FieldValueError> {
139        self.resolve("filename", rules)
140    }
141
142    /// A parameter's value under these rules. The name is lowercase, as stored parameter names are.
143    fn resolve(
144        &self,
145        name: &str,
146        rules: ParameterRules,
147    ) -> Result<Option<Cow<'_, str>>, FieldValueError> {
148        match rules {
149            ParameterRules::FormData => {
150                // RFC 7578 section 4.2: the RFC 5987 encoding must not be used.
151                if self
152                    .parameters
153                    .iter()
154                    .any(|(parameter, _)| parameter.contains('*'))
155                {
156                    return Err(FieldValueError::new(
157                        "extended parameters are not allowed in form data",
158                    ));
159                }
160            }
161            ParameterRules::Mime => {
162                let mut extended = None;
163                for (parameter, value) in &self.parameters {
164                    match parameter
165                        .strip_prefix(name)
166                        .and_then(|rest| rest.strip_prefix('*'))
167                    {
168                        Some("") => extended = Some(value),
169                        // A continuation, such as `filename*0`, which other readers join; until this one does, refusing it keeps them from disagreeing.
170                        Some(_) => {
171                            return Err(FieldValueError::new(
172                                "parameter continuations are not supported",
173                            ));
174                        }
175                        None => {}
176                    }
177                }
178                if let Some(value) = extended {
179                    return decode_extended_value(value).map(Some);
180                }
181            }
182        }
183        Ok(self.parameter(name).map(Cow::Borrowed))
184    }
185
186    /// The disposition, owning its text.
187    pub fn into_owned(self) -> ContentDisposition<'static> {
188        let owned = |text: Cow<'a, str>| Cow::Owned(text.into_owned());
189        ContentDisposition {
190            disposition_type: owned(self.disposition_type),
191            parameters: self
192                .parameters
193                .into_iter()
194                .map(|(name, value)| (owned(name), owned(value)))
195                .collect(),
196        }
197    }
198}
199
200impl<'a> FieldType<'a> for ContentDisposition<'a> {
201    type Combination = Singleton;
202
203    fn parse(value: &'a [u8]) -> Result<Self, FieldValueError> {
204        let (disposition_type, parameters) = parse_parameterized(field_text(value)?)?;
205        Ok(Self {
206            disposition_type,
207            parameters,
208        })
209    }
210}
211
212/// Writes the disposition as a field value, with every parameter value quoted except extended values.
213impl fmt::Display for ContentDisposition<'_> {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        let mut text = String::from(&*self.disposition_type);
216        for (name, value) in &self.parameters {
217            text.push_str("; ");
218            text.push_str(name);
219            text.push('=');
220            write_parameter_value(&mut text, name, value);
221        }
222        formatter.write_str(&text)
223    }
224}
225
226/// Shows the disposition type and parameter names, and the lengths of parameter values, since a form field's name or a filename can identify a person.
227impl fmt::Debug for ContentDisposition<'_> {
228    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229        let parameters: Vec<_> = self
230            .parameters
231            .iter()
232            .map(|(name, value)| (&**name, format!("<{} bytes>", value.len())))
233            .collect();
234        formatter
235            .debug_struct("ContentDisposition")
236            .field("disposition_type", &self.disposition_type)
237            .field("parameters", &parameters)
238            .finish()
239    }
240}
241
242/// Decodes an RFC 2231 extended value, `charset'language'percent-encoded`, in UTF-8 or US-ASCII, borrowing when nothing is percent-encoded.
243fn decode_extended_value(value: &str) -> Result<Cow<'_, str>, FieldValueError> {
244    let invalid = |reason| Err(FieldValueError::new(reason));
245    let Some((charset, Some((_language, encoded)))) = value
246        .split_once('\'')
247        .map(|(charset, rest)| (charset, rest.split_once('\'')))
248    else {
249        return invalid("extended parameter value has no character set");
250    };
251    let ascii = if charset.eq_ignore_ascii_case("us-ascii") {
252        true
253    } else if charset.eq_ignore_ascii_case("utf-8") {
254        false
255    } else {
256        return invalid("extended parameter value has an unsupported character set");
257    };
258    let mut bytes = Vec::with_capacity(encoded.len());
259    let mut input = encoded.bytes();
260    while let Some(byte) = input.next() {
261        if byte != b'%' {
262            bytes.push(byte);
263            continue;
264        }
265        let digit = |byte: Option<u8>| byte.and_then(|byte| char::from(byte).to_digit(16));
266        match (digit(input.next()), digit(input.next())) {
267            (Some(high), Some(low)) => bytes.push((high * 16 + low) as u8),
268            _ => return invalid("extended parameter value has an invalid percent-encoding"),
269        }
270    }
271    if ascii && !bytes.is_ascii() {
272        return invalid("extended parameter value is not US-ASCII");
273    }
274    if bytes == encoded.as_bytes() {
275        return Ok(Cow::Borrowed(encoded));
276    }
277    String::from_utf8(bytes)
278        .map(Cow::Owned)
279        .map_err(|_| FieldValueError::new("extended parameter value is not UTF-8"))
280}