Skip to main content

base_web_multipart/
boundary.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use base_web_media_type::{MediaType, MediaTypeError};
5use std::fmt;
6
7/// A multipart boundary, as RFC 2046 section 5.1.1 defines it: 1 to 70 characters from a restricted set, not ending in a space.
8///
9/// ```
10/// use base_web_multipart::Boundary;
11/// use base_web_media_type::MediaType;
12///
13/// let media_type = MediaType::parse("multipart/form-data; boundary=\"simple boundary\"")?;
14/// let boundary = Boundary::from_media_type(&media_type)?;
15/// assert_eq!(boundary.as_str(), "simple boundary");
16///
17/// let generated = Boundary::generate();
18/// assert_eq!(generated.as_str().len(), 32);
19/// assert_eq!(generated.form_data_media_type().parameter("boundary"), Some(generated.as_str()));
20/// # Ok::<(), Box<dyn std::error::Error>>(())
21/// ```
22#[derive(Clone, PartialEq, Eq, Hash)]
23pub struct Boundary(String);
24
25/// Why a boundary was rejected.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum BoundaryError {
29    /// The media type is not a `multipart` type.
30    NotMultipart,
31    /// The media type has no `boundary` parameter.
32    Missing,
33    /// The boundary is empty, longer than 70 characters, ends in a space, or has a character RFC 2046 does not allow.
34    Invalid,
35}
36
37impl Boundary {
38    /// The longest boundary RFC 2046 allows.
39    pub const LENGTH_LIMIT: usize = 70;
40
41    /// The random bytes in a generated boundary: 192 bits, written as 32 characters.
42    const RANDOM_BYTES: usize = 24;
43
44    /// A boundary from text.
45    ///
46    /// # Errors
47    ///
48    /// [`BoundaryError::Invalid`] when the text is not a boundary RFC 2046 allows.
49    pub fn new(text: &str) -> Result<Self, BoundaryError> {
50        let allowed = |byte: u8| byte.is_ascii_alphanumeric() || b"'()+_,-./:=? ".contains(&byte);
51        if text.is_empty()
52            || text.len() > Self::LENGTH_LIMIT
53            || text.ends_with(' ')
54            || !text.bytes().all(allowed)
55        {
56            return Err(BoundaryError::Invalid);
57        }
58        Ok(Self(text.to_owned()))
59    }
60
61    /// The boundary of a `multipart` media type, from its `boundary` parameter.
62    ///
63    /// # Errors
64    ///
65    /// [`BoundaryError::NotMultipart`] for another type, [`BoundaryError::Missing`] when there is no `boundary` parameter, and [`BoundaryError::Invalid`] when it is not a valid boundary.
66    pub fn from_media_type(media_type: &MediaType) -> Result<Self, BoundaryError> {
67        if media_type.top_level_type() != "multipart" {
68            return Err(BoundaryError::NotMultipart);
69        }
70        Self::new(
71            media_type
72                .parameter("boundary")
73                .ok_or(BoundaryError::Missing)?,
74        )
75    }
76
77    /// A new boundary of 192 random bits from the operating system, written as 32 URL-safe base64 characters, so that content cannot contain it by chance or by an attacker's design.
78    ///
79    /// # Panics
80    ///
81    /// When the operating system's random number generator fails, which it does not on supported platforms; the standard library's hash maps rely on it in the same way.
82    pub fn generate() -> Self {
83        let mut bytes = [0; Self::RANDOM_BYTES];
84        getrandom::fill(&mut bytes).expect("the operating system provides random bytes");
85        Self(base64url(&bytes))
86    }
87
88    /// The boundary as text.
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92
93    /// The media type `multipart/{subtype}` with this boundary as its parameter, such as `multipart/mixed; boundary=...`.
94    ///
95    /// # Errors
96    ///
97    /// When the subtype is not a token, or is a wildcard.
98    pub fn to_media_type(&self, subtype: &str) -> Result<MediaType, MediaTypeError> {
99        MediaType::new("multipart", subtype)?.with_parameter("boundary", &self.0)
100    }
101
102    /// The media type `multipart/form-data` with this boundary as its parameter.
103    pub fn form_data_media_type(&self) -> MediaType {
104        MediaType::MULTIPART_FORM_DATA
105            .with_parameter("boundary", &self.0)
106            .expect("a valid boundary is a valid parameter value")
107    }
108}
109
110impl std::str::FromStr for Boundary {
111    type Err = BoundaryError;
112
113    fn from_str(text: &str) -> Result<Self, Self::Err> {
114        Self::new(text)
115    }
116}
117
118impl AsRef<str> for Boundary {
119    fn as_ref(&self) -> &str {
120        &self.0
121    }
122}
123
124impl fmt::Debug for Boundary {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.debug_tuple("Boundary").field(&self.0).finish()
127    }
128}
129
130impl fmt::Display for Boundary {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter.write_str(&self.0)
133    }
134}
135
136impl fmt::Display for BoundaryError {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        formatter.write_str(match self {
139            Self::NotMultipart => "media type is not multipart",
140            Self::Missing => "multipart media type has no boundary",
141            Self::Invalid => "invalid multipart boundary",
142        })
143    }
144}
145
146impl std::error::Error for BoundaryError {}
147
148/// URL-safe base64 without padding, of a length divisible by 3.
149fn base64url(bytes: &[u8]) -> String {
150    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
151    let mut text = String::with_capacity(bytes.len() / 3 * 4);
152    for chunk in bytes.chunks_exact(3) {
153        let triple = u32::from(chunk[0]) << 16 | u32::from(chunk[1]) << 8 | u32::from(chunk[2]);
154        for shift in [18, 12, 6, 0] {
155            text.push(char::from(ALPHABET[(triple >> shift) as usize & 63]));
156        }
157    }
158    text
159}