Skip to main content

base_web_multipart/
multipart_writer.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::Boundary;
5use crate::syntax::{is_field_name_byte, is_field_value_byte};
6use memchr::memmem::Finder;
7use std::fmt;
8
9/// A sans-I/O writer for a multipart body, which appends the body's bytes to a caller's buffer, to be sent as the caller chooses.
10///
11/// It refuses whatever would change the body's structure for a reader: a field name or value that is not valid, which could inject header lines, and a header line or content that contains the boundary after a line break, which would end the part early. That includes the boundary after a bare CR or LF, which some readers also take for a delimiter. A [generated](Boundary::generate) boundary makes content containing it practically impossible, but the check also covers boundaries chosen by the caller.
12///
13/// The writer does not apply a reader's limits: a body with more parts or header fields than a reader allows, such as [`MultipartLimits`](crate::MultipartLimits)'s defaults, is written, and that reader refuses it.
14///
15/// ```
16/// use base_web_multipart::{Boundary, ContentDisposition, MultipartWriter};
17///
18/// let mut writer = MultipartWriter::new(Boundary::new("b")?);
19/// let mut body = Vec::new();
20/// let disposition = ContentDisposition::form_data("note")?.to_string();
21/// writer.start_part([("Content-Disposition", disposition.as_bytes())], &mut body)?;
22/// writer.write_content(b"hello", &mut body)?;
23/// writer.finish(&mut body);
24/// assert_eq!(body, b"--b\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--b--\r\n");
25/// # Ok::<(), Box<dyn std::error::Error>>(())
26/// ```
27#[derive(Clone, Debug)]
28pub struct MultipartWriter {
29    boundary: Boundary,
30    /// `CRLF "--" boundary`, which ends a part's content.
31    delimiter: Vec<u8>,
32    /// Finds `"--" boundary`, so that what precedes it can be checked.
33    finder: Finder<'static>,
34    state: State,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38enum State {
39    /// No part has started.
40    Start,
41    /// In a part's content. When its last bytes are a line break and the start of `"--" boundary`, the bytes of `"--" boundary` they match, which the next content must not complete.
42    Content { matched: Option<usize> },
43}
44
45/// Why a writer refused to write.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum MultipartWriteError {
49    /// A field name is empty or has a character other than visible ASCII except the colon.
50    InvalidFieldName,
51    /// A field value has a control character other than horizontal tab, such as CR or LF.
52    InvalidFieldValue,
53    /// A header line or content contains the boundary after a line break, so a reader would see the part end there.
54    Delimiter,
55    /// Content was written before any part started.
56    NoPart,
57}
58
59impl MultipartWriter {
60    /// A writer for a body with this boundary.
61    pub fn new(boundary: Boundary) -> Self {
62        let delimiter = [b"\r\n--", boundary.as_str().as_bytes()].concat();
63        let finder = Finder::new(&delimiter[2..]).into_owned();
64        Self {
65            boundary,
66            delimiter,
67            finder,
68            state: State::Start,
69        }
70    }
71
72    /// The boundary, for the body's media type, such as from [`Boundary::form_data_media_type`].
73    pub fn boundary(&self) -> &Boundary {
74        &self.boundary
75    }
76
77    /// End any current part and begin another, with these header fields, in order.
78    ///
79    /// # Errors
80    ///
81    /// When a field name or value is invalid, or a header line begins with the boundary delimiter. Nothing is written, and the writer is unchanged.
82    pub fn start_part<N, V>(
83        &mut self,
84        fields: impl IntoIterator<Item = (N, V)>,
85        output: &mut Vec<u8>,
86    ) -> Result<(), MultipartWriteError>
87    where
88        N: AsRef<str>,
89        V: AsRef<[u8]>,
90    {
91        let length = output.len();
92        let result = self.write_head(fields, output);
93        if result.is_err() {
94            output.truncate(length);
95        }
96        result
97    }
98
99    fn write_head<N, V>(
100        &mut self,
101        fields: impl IntoIterator<Item = (N, V)>,
102        output: &mut Vec<u8>,
103    ) -> Result<(), MultipartWriteError>
104    where
105        N: AsRef<str>,
106        V: AsRef<[u8]>,
107    {
108        let delimiter = if self.state == State::Start {
109            &self.delimiter[2..]
110        } else {
111            &self.delimiter[..]
112        };
113        output.extend_from_slice(delimiter);
114        output.extend_from_slice(b"\r\n");
115        for (name, value) in fields {
116            let (name, value) = (name.as_ref().as_bytes(), value.as_ref());
117            if name.is_empty() || !name.iter().all(|&byte| is_field_name_byte(byte)) {
118                return Err(MultipartWriteError::InvalidFieldName);
119            }
120            if !value.iter().all(|&byte| is_field_value_byte(byte)) {
121                return Err(MultipartWriteError::InvalidFieldValue);
122            }
123            let line = output.len();
124            output.extend_from_slice(name);
125            output.extend_from_slice(b": ");
126            output.extend_from_slice(value);
127            // Each line follows a line break, so a line beginning with the boundary would form the delimiter.
128            if output[line..].starts_with(&self.delimiter[2..]) {
129                return Err(MultipartWriteError::Delimiter);
130            }
131            output.extend_from_slice(b"\r\n");
132        }
133        output.extend_from_slice(b"\r\n");
134        // The content follows the line break that ends the header section.
135        self.state = State::Content { matched: Some(0) };
136        Ok(())
137    }
138
139    /// Append some of the current part's content.
140    ///
141    /// # Errors
142    ///
143    /// When no part has started, or the content, with the content before it, contains the boundary after a line break. Nothing is written, and the writer is unchanged.
144    pub fn write_content(
145        &mut self,
146        content: &[u8],
147        output: &mut Vec<u8>,
148    ) -> Result<(), MultipartWriteError> {
149        let State::Content { matched } = self.state else {
150            return Err(MultipartWriteError::NoPart);
151        };
152        let matched = self.scan(matched, content)?;
153        output.extend_from_slice(content);
154        self.state = State::Content { matched };
155        Ok(())
156    }
157
158    /// End the body with its closing boundary. A body with no parts is the closing boundary alone.
159    pub fn finish(self, output: &mut Vec<u8>) {
160        let delimiter = if self.state == State::Start {
161            &self.delimiter[2..]
162        } else {
163            &self.delimiter[..]
164        };
165        output.extend_from_slice(delimiter);
166        output.extend_from_slice(b"--\r\n");
167    }
168
169    /// The bytes of `"--" boundary` that follow the last line break at the end of the content, given those the content before it matched, or an error when the content completes `"--" boundary` after a line break. `"--" boundary` contains no line break, so a match can only begin right after one.
170    fn scan(
171        &self,
172        matched: Option<usize>,
173        content: &[u8],
174    ) -> Result<Option<usize>, MultipartWriteError> {
175        let dash_boundary = &self.delimiter[2..];
176        let continues = |matched: usize, after: &[u8]| {
177            let remaining = &dash_boundary[matched..];
178            let length = remaining.len().min(after.len());
179            (after[..length] == remaining[..length]).then_some(matched + length)
180        };
181        if let Some(matched) = matched.and_then(|matched| continues(matched, content)) {
182            return if matched == dash_boundary.len() {
183                Err(MultipartWriteError::Delimiter)
184            } else {
185                Ok(Some(matched))
186            };
187        }
188        if self
189            .finder
190            .find_iter(content)
191            .any(|index| index > 0 && matches!(content[index - 1], b'\r' | b'\n'))
192        {
193            return Err(MultipartWriteError::Delimiter);
194        }
195        let start = content.len().saturating_sub(dash_boundary.len());
196        Ok(memchr::memchr2_iter(b'\r', b'\n', &content[start..])
197            .find_map(|index| continues(0, &content[start + index + 1..])))
198    }
199}
200
201impl fmt::Display for MultipartWriteError {
202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203        formatter.write_str(match self {
204            Self::InvalidFieldName => "invalid multipart part header field name",
205            Self::InvalidFieldValue => "invalid multipart part header field value",
206            Self::Delimiter => "multipart part contains its boundary delimiter",
207            Self::NoPart => "multipart content written before any part",
208        })
209    }
210}
211
212impl std::error::Error for MultipartWriteError {}