base-web-multipart 0.1.1

Sans-I/O parsing and writing of MIME multipart bodies, following RFC 2046 and RFC 7578
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

use crate::ParameterRules;
use crate::syntax::{
    PARAMETER_COUNT_LIMIT, field_text, is_field_value_byte, is_token, lowercase,
    parse_parameterized, write_parameter_value,
};
use base_message::{FieldType, FieldValueError, combination::Singleton};
use std::borrow::Cow;
use std::fmt;

/// 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`.
///
/// 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.
///
/// 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.
///
/// ```
/// use base_web_multipart::{ContentDisposition, ParameterRules};
/// use base_message::FieldType;
///
/// let disposition = ContentDisposition::parse(b"form-data; name=\"avatar\"; filename=\"caf\xc3\xa9.png\"")?;
/// assert_eq!(disposition.disposition_type(), "form-data");
/// assert_eq!(disposition.name(ParameterRules::FormData)?.as_deref(), Some("avatar"));
/// assert_eq!(disposition.filename(ParameterRules::FormData)?.as_deref(), Some("café.png"));
///
/// let written = ContentDisposition::form_data("note")?.with_parameter("filename", "a \"quoted\" name")?;
/// assert_eq!(written.to_string(), "form-data; name=\"note\"; filename=\"a \\\"quoted\\\" name\"");
/// # Ok::<(), base_message::FieldValueError>(())
/// ```
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ContentDisposition<'a> {
    disposition_type: Cow<'a, str>,
    parameters: Vec<(Cow<'a, str>, Cow<'a, str>)>,
}

impl ContentDisposition<'static> {
    /// A disposition without parameters, such as `attachment`. The type is lowercased.
    ///
    /// # Errors
    ///
    /// When the type is not a token.
    pub fn new(disposition_type: &str) -> Result<Self, FieldValueError> {
        if !is_token(disposition_type) {
            return Err(FieldValueError::new("disposition type is not a token"));
        }
        Ok(ContentDisposition {
            disposition_type: Cow::Owned(disposition_type.to_ascii_lowercase()),
            parameters: Vec::new(),
        })
    }

    /// A `form-data` disposition for a form field with this name, as RFC 7578 section 4.2 requires of each part.
    ///
    /// # Errors
    ///
    /// When the name contains a control character, such as CR or LF, which a quoted string cannot carry.
    pub fn form_data(name: &str) -> Result<Self, FieldValueError> {
        ContentDisposition {
            disposition_type: Cow::Borrowed("form-data"),
            parameters: Vec::new(),
        }
        .with_parameter("name", name)
    }
}

impl<'a> ContentDisposition<'a> {
    /// The disposition with a parameter added. The name is lowercased.
    ///
    /// # Errors
    ///
    /// 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.
    pub fn with_parameter(
        mut self,
        name: &str,
        value: &str,
    ) -> Result<ContentDisposition<'a>, FieldValueError> {
        if !is_token(name) {
            return Err(FieldValueError::new("parameter name is not a token"));
        }
        if !value.bytes().all(is_field_value_byte) {
            return Err(FieldValueError::new(
                "parameter value contains a control character",
            ));
        }
        if name.ends_with('*') && !is_token(value) {
            return Err(FieldValueError::new(
                "extended parameter value is not a token",
            ));
        }
        let name = lowercase(name).into_owned();
        if self.parameter(&name).is_some() {
            return Err(FieldValueError::new("duplicate parameter"));
        }
        if self.parameters.len() == PARAMETER_COUNT_LIMIT {
            return Err(FieldValueError::new("too many parameters"));
        }
        self.parameters
            .push((Cow::Owned(name), Cow::Owned(value.to_owned())));
        Ok(self)
    }

    /// The lowercase disposition type, such as `form-data`, `attachment`, or `inline`.
    pub fn disposition_type(&self) -> &str {
        &self.disposition_type
    }

    /// The value of a parameter. Names are compared case-insensitively.
    pub fn parameter(&self, name: &str) -> Option<&str> {
        self.parameters
            .iter()
            .find(|(parameter, _)| parameter.eq_ignore_ascii_case(name))
            .map(|(_, value)| &**value)
    }

    /// The lowercase parameter names and their values, in order.
    pub fn parameters(&self) -> impl ExactSizeIterator<Item = (&str, &str)> {
        self.parameters
            .iter()
            .map(|(name, value)| (&**name, &**value))
    }

    /// The `name` parameter, a form field's name, following these rules for extended parameters.
    ///
    /// # Errors
    ///
    /// When the rules do not allow the disposition's extended parameters, or an extended `name*` cannot be decoded.
    pub fn name(&self, rules: ParameterRules) -> Result<Option<Cow<'_, str>>, FieldValueError> {
        self.resolve("name", rules)
    }

    /// 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.
    ///
    /// # Errors
    ///
    /// When the rules do not allow the disposition's extended parameters, or an extended `filename*` cannot be decoded.
    pub fn filename(&self, rules: ParameterRules) -> Result<Option<Cow<'_, str>>, FieldValueError> {
        self.resolve("filename", rules)
    }

    /// A parameter's value under these rules. The name is lowercase, as stored parameter names are.
    fn resolve(
        &self,
        name: &str,
        rules: ParameterRules,
    ) -> Result<Option<Cow<'_, str>>, FieldValueError> {
        match rules {
            ParameterRules::FormData => {
                // RFC 7578 section 4.2: the RFC 5987 encoding must not be used.
                if self
                    .parameters
                    .iter()
                    .any(|(parameter, _)| parameter.contains('*'))
                {
                    return Err(FieldValueError::new(
                        "extended parameters are not allowed in form data",
                    ));
                }
            }
            ParameterRules::Mime => {
                let mut extended = None;
                for (parameter, value) in &self.parameters {
                    match parameter
                        .strip_prefix(name)
                        .and_then(|rest| rest.strip_prefix('*'))
                    {
                        Some("") => extended = Some(value),
                        // A continuation, such as `filename*0`, which other readers join; until this one does, refusing it keeps them from disagreeing.
                        Some(_) => {
                            return Err(FieldValueError::new(
                                "parameter continuations are not supported",
                            ));
                        }
                        None => {}
                    }
                }
                if let Some(value) = extended {
                    return decode_extended_value(value).map(Some);
                }
            }
        }
        Ok(self.parameter(name).map(Cow::Borrowed))
    }

    /// The disposition, owning its text.
    pub fn into_owned(self) -> ContentDisposition<'static> {
        let owned = |text: Cow<'a, str>| Cow::Owned(text.into_owned());
        ContentDisposition {
            disposition_type: owned(self.disposition_type),
            parameters: self
                .parameters
                .into_iter()
                .map(|(name, value)| (owned(name), owned(value)))
                .collect(),
        }
    }
}

impl<'a> FieldType<'a> for ContentDisposition<'a> {
    type Combination = Singleton;

    fn parse(value: &'a [u8]) -> Result<Self, FieldValueError> {
        let (disposition_type, parameters) = parse_parameterized(field_text(value)?)?;
        Ok(Self {
            disposition_type,
            parameters,
        })
    }
}

/// Writes the disposition as a field value, with every parameter value quoted except extended values.
impl fmt::Display for ContentDisposition<'_> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut text = String::from(&*self.disposition_type);
        for (name, value) in &self.parameters {
            text.push_str("; ");
            text.push_str(name);
            text.push('=');
            write_parameter_value(&mut text, name, value);
        }
        formatter.write_str(&text)
    }
}

/// 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.
impl fmt::Debug for ContentDisposition<'_> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let parameters: Vec<_> = self
            .parameters
            .iter()
            .map(|(name, value)| (&**name, format!("<{} bytes>", value.len())))
            .collect();
        formatter
            .debug_struct("ContentDisposition")
            .field("disposition_type", &self.disposition_type)
            .field("parameters", &parameters)
            .finish()
    }
}

/// Decodes an RFC 2231 extended value, `charset'language'percent-encoded`, in UTF-8 or US-ASCII, borrowing when nothing is percent-encoded.
fn decode_extended_value(value: &str) -> Result<Cow<'_, str>, FieldValueError> {
    let invalid = |reason| Err(FieldValueError::new(reason));
    let Some((charset, Some((_language, encoded)))) = value
        .split_once('\'')
        .map(|(charset, rest)| (charset, rest.split_once('\'')))
    else {
        return invalid("extended parameter value has no character set");
    };
    let ascii = if charset.eq_ignore_ascii_case("us-ascii") {
        true
    } else if charset.eq_ignore_ascii_case("utf-8") {
        false
    } else {
        return invalid("extended parameter value has an unsupported character set");
    };
    let mut bytes = Vec::with_capacity(encoded.len());
    let mut input = encoded.bytes();
    while let Some(byte) = input.next() {
        if byte != b'%' {
            bytes.push(byte);
            continue;
        }
        let digit = |byte: Option<u8>| byte.and_then(|byte| char::from(byte).to_digit(16));
        match (digit(input.next()), digit(input.next())) {
            (Some(high), Some(low)) => bytes.push((high * 16 + low) as u8),
            _ => return invalid("extended parameter value has an invalid percent-encoding"),
        }
    }
    if ascii && !bytes.is_ascii() {
        return invalid("extended parameter value is not US-ASCII");
    }
    if bytes == encoded.as_bytes() {
        return Ok(Cow::Borrowed(encoded));
    }
    String::from_utf8(bytes)
        .map(Cow::Owned)
        .map_err(|_| FieldValueError::new("extended parameter value is not UTF-8"))
}