Skip to main content

form_net/multipart/
writer.rs

1//! The client side: part heads encoded as browsers encode them, and a
2//! writer whose every body parses back with form-net to the same fields.
3
4use crate::error::{FormError, Location, SyntaxError};
5use crate::multipart::boundary::Boundary;
6
7const DISPOSITION: &[u8] = b"Content-Disposition: form-data; name=\"";
8const FILENAME: &[u8] = b"\"; filename=\"";
9const CONTENT_TYPE: &[u8] = b"Content-Type: ";
10
11/// Where encoded bytes go: a caller buffer or a writer.
12trait Out {
13    fn put(&mut self, bytes: &[u8]) -> Result<(), FormError>;
14}
15
16struct SliceOut<'o> {
17    out: &'o mut [u8],
18    pos: usize,
19}
20
21impl Out for SliceOut<'_> {
22    fn put(&mut self, bytes: &[u8]) -> Result<(), FormError> {
23        let end = self.pos.saturating_add(bytes.len());
24        match self.out.get_mut(self.pos..end) {
25            Some(dst) => {
26                dst.copy_from_slice(bytes);
27                self.pos = end;
28                Ok(())
29            }
30            None => Err(FormError::BufferTooSmall { needed: end }),
31        }
32    }
33}
34
35fn syntax(reason: SyntaxError) -> FormError {
36    FormError::syntax(reason, Location::NONE)
37}
38
39/// Checks a name or file name and returns its length once escaped.
40///
41/// LF, CR, and `"` are escaped as `%0A`, `%0D`, `%22` (the browser rule);
42/// any other control byte but HTAB is refused. So are the sequences that
43/// would read back as another character: a literal `%0A`, `%0D`, or `%22`
44/// (a reader decodes it), and for names any backslash (form-net refuses one
45/// in a name, D-082), for file names a trailing one (it would read as `\"`).
46fn escaped_len(text: &str, is_name: bool) -> Result<usize, FormError> {
47    let bytes = text.as_bytes();
48    if is_name && bytes.is_empty() {
49        return Err(syntax(SyntaxError::EmptyName));
50    }
51    if crate::multipart::filename::has_html_escape(bytes) {
52        return Err(syntax(SyntaxError::AmbiguousQuote));
53    }
54    if (is_name && bytes.contains(&b'\\')) || (!is_name && bytes.last() == Some(&b'\\')) {
55        return Err(syntax(SyntaxError::AmbiguousQuote));
56    }
57    let mut len = 0usize;
58    for &b in bytes {
59        len = len.saturating_add(match b {
60            b'\n' | b'\r' | b'"' => 3,
61            b'\t' => 1,
62            b if b < 0x20 || b == 0x7F => return Err(syntax(SyntaxError::ControlInHeader)),
63            _ => 1,
64        });
65    }
66    Ok(len)
67}
68
69/// Writes `text` with the browser escapes. `escaped_len` has checked it.
70fn put_escaped(out: &mut impl Out, text: &str) -> Result<(), FormError> {
71    let bytes = text.as_bytes();
72    let mut from = 0usize;
73    for (i, &b) in bytes.iter().enumerate() {
74        let esc: &[u8] = match b {
75            b'\n' => b"%0A",
76            b'\r' => b"%0D",
77            b'"' => b"%22",
78            _ => continue,
79        };
80        out.put(bytes.get(from..i).unwrap_or(&[]))?;
81        out.put(esc)?;
82        from = i.saturating_add(1);
83    }
84    out.put(bytes.get(from..).unwrap_or(&[]))
85}
86
87/// Checks a part's `Content-Type` value the way the parser will read it.
88fn check_content_type(ct: &str) -> Result<(), FormError> {
89    let raw = ct.as_bytes();
90    if raw.iter().any(|&b| (b < 0x20 && b != b'\t') || b == 0x7F) {
91        return Err(syntax(SyntaxError::ControlInHeader));
92    }
93    if raw.windows(2).any(|w| w == b"\\\"") {
94        return Err(syntax(SyntaxError::AmbiguousQuote));
95    }
96    let invalid = || syntax(SyntaxError::InvalidContentType);
97    if crate::kind::has_empty_parameter(raw) {
98        // The parser refuses it (D-136); the writer never sends what it
99        // would not read.
100        return Err(invalid());
101    }
102    let mt = parse_net::media::parse(raw, &crate::kind::HEADER_LIMITS).map_err(|_| invalid())?;
103    if crate::kind::repeated_name(&mt.params()).is_some()
104        || mt.type_().eq_ignore_ascii_case(b"multipart")
105    {
106        return Err(invalid());
107    }
108    Ok(())
109}
110
111/// The exact length of a part head, after checking every input.
112fn head_len(
113    boundary: &Boundary,
114    first: bool,
115    name: &str,
116    filename: Option<&str>,
117    content_type: Option<&str>,
118) -> Result<usize, FormError> {
119    // A name or file name holding `name=` is refused by the parser (D-136):
120    // escaping cannot add or remove one, so the plain text is checked.
121    if crate::kind::holds_name_assignment(name.as_bytes())
122        || filename.is_some_and(|f| crate::kind::holds_name_assignment(f.as_bytes()))
123    {
124        return Err(syntax(SyntaxError::MalformedParameter));
125    }
126    let mut len = if first { 0usize } else { 2 };
127    len = len
128        .saturating_add(2)
129        .saturating_add(boundary.as_bytes().len())
130        .saturating_add(2)
131        .saturating_add(DISPOSITION.len())
132        .saturating_add(escaped_len(name, true)?);
133    if let Some(f) = filename {
134        len = len
135            .saturating_add(FILENAME.len())
136            .saturating_add(escaped_len(f, false)?);
137    }
138    // The closing quote and CRLF of the disposition line.
139    len = len.saturating_add(3);
140    if let Some(ct) = content_type {
141        check_content_type(ct)?;
142        len = len
143            .saturating_add(CONTENT_TYPE.len())
144            .saturating_add(ct.len())
145            .saturating_add(2);
146    }
147    Ok(len.saturating_add(2))
148}
149
150/// Writes a head that `head_len` accepted.
151fn put_head(
152    out: &mut impl Out,
153    boundary: &Boundary,
154    first: bool,
155    name: &str,
156    filename: Option<&str>,
157    content_type: Option<&str>,
158) -> Result<(), FormError> {
159    if !first {
160        out.put(b"\r\n")?;
161    }
162    out.put(b"--")?;
163    out.put(boundary.as_bytes())?;
164    out.put(b"\r\n")?;
165    out.put(DISPOSITION)?;
166    put_escaped(out, name)?;
167    if let Some(f) = filename {
168        out.put(FILENAME)?;
169        put_escaped(out, f)?;
170    }
171    out.put(b"\"\r\n")?;
172    if let Some(ct) = content_type {
173        out.put(CONTENT_TYPE)?;
174        out.put(ct.as_bytes())?;
175        out.put(b"\r\n")?;
176    }
177    out.put(b"\r\n")
178}
179
180/// Writes one part's head into `out` without allocating: the delimiter
181/// (with its leading CRLF unless `first`), the `Content-Disposition` line
182/// with browser escaping, the optional `Content-Type` line, and the blank
183/// line. Returns the bytes written.
184///
185/// After the last part's data the caller writes the close delimiter:
186/// [`Boundary::delimiter`] followed by `--\r\n`; for a body with no parts,
187/// `--`, the boundary, and `--\r\n`.
188///
189/// # Errors
190///
191/// `BufferTooSmall { needed }` (nothing is written);
192/// `Syntax { EmptyName | ControlInHeader | AmbiguousQuote | InvalidContentType
193/// | MalformedParameter }` (`MalformedParameter`: a name or file name that
194/// holds `name=`, which the parser refuses, D-136).
195///
196/// # Examples
197///
198/// ```
199/// let b = form_net::Boundary::new(b"X")?;
200/// let mut out = [0u8; 128];
201/// let n = form_net::encode_part_head(&b, true, "a\"b", None, None, &mut out)?;
202/// assert_eq!(&out[..n], b"--X\r\nContent-Disposition: form-data; name=\"a%22b\"\r\n\r\n");
203/// # Ok::<(), form_net::FormError>(())
204/// ```
205pub fn encode_part_head(
206    boundary: &Boundary,
207    first: bool,
208    name: &str,
209    filename: Option<&str>,
210    content_type: Option<&str>,
211    out: &mut [u8],
212) -> Result<usize, FormError> {
213    let needed = head_len(boundary, first, name, filename, content_type)?;
214    if out.len() < needed {
215        return Err(FormError::BufferTooSmall { needed });
216    }
217    let mut sink = SliceOut { out, pos: 0 };
218    put_head(&mut sink, boundary, first, name, filename, content_type)?;
219    Ok(sink.pos)
220}
221
222#[cfg(feature = "std")]
223pub use writer_std::MultipartWriter;
224
225#[cfg(feature = "std")]
226mod writer_std {
227    use alloc::string::String;
228    use core::fmt;
229    use std::io::{Read, Write};
230
231    use super::{Out, head_len, put_head};
232    use crate::error::{EncodingError, FormError, Location, SyntaxError};
233    use crate::multipart::boundary::Boundary;
234
235    struct IoOut<'w, W: Write>(&'w mut W);
236
237    impl<W: Write> Out for IoOut<'_, W> {
238        fn put(&mut self, bytes: &[u8]) -> Result<(), FormError> {
239            self.0
240                .write_all(bytes)
241                .map_err(|e| FormError::Io { kind: e.kind() })
242        }
243    }
244
245    /// Writes a `multipart/form-data` body as browsers do.
246    ///
247    /// Names and file names are escaped by the browser rule; every input
248    /// that could read back differently is refused; text line breaks become
249    /// CRLF. With a caller boundary, data is scanned for the delimiter
250    /// across chunk splits and refused if it contains one. So every body the
251    /// writer produces parses back with form-net to the same fields.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// let mut body = Vec::new();
257    /// // `MultipartWriter::new` (feature `random`) generates the boundary.
258    /// let boundary = form_net::Boundary::new(b"----form-net-example")?;
259    /// let mut w = form_net::MultipartWriter::with_boundary(&mut body, boundary);
260    /// w.text("title", "Holiday")?;
261    /// w.file("photo", "beach.jpg", Some("image/jpeg"), &b"\xff\xd8 jpeg"[..])?;
262    /// let content_type = w.content_type();
263    /// w.finish()?;
264    ///
265    /// let mp = form_net::parse_multipart(content_type.as_bytes(), &body)?;
266    /// assert_eq!(mp.field("title"), Some("Holiday"));
267    /// assert_eq!(mp.file("photo").map(|p| p.bytes()), Some(&b"\xff\xd8 jpeg"[..]));
268    /// # Ok::<(), form_net::FormError>(())
269    /// ```
270    pub struct MultipartWriter<W: Write> {
271        out: W,
272        boundary: Boundary,
273        verify: bool,
274        first: bool,
275        in_file: bool,
276        poisoned: bool,
277        /// The delimiter matcher's state over the current part's data.
278        k: usize,
279        /// The bare-break matcher's state (D-113).
280        m: usize,
281        /// Text normalisation: the last byte written was a CR.
282        pending_cr: bool,
283    }
284
285    impl<W: Write> fmt::Debug for MultipartWriter<W> {
286        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287            f.debug_struct("MultipartWriter")
288                .field("boundary", &self.boundary)
289                .field("in_file", &self.in_file)
290                .finish_non_exhaustive()
291        }
292    }
293
294    impl<W: Write> MultipartWriter<W> {
295        /// A writer with a generated boundary.
296        ///
297        /// # Errors
298        ///
299        /// `Random` when the OS random source fails.
300        #[cfg(feature = "random")]
301        #[cfg_attr(docsrs, doc(cfg(feature = "random")))]
302        pub fn new(out: W) -> Result<Self, FormError> {
303            let mut w = Self::with_boundary(out, Boundary::generate()?);
304            // 128 random bits: a collision with the data is negligible.
305            w.verify = false;
306            Ok(w)
307        }
308
309        /// A writer with the caller's boundary; data is scanned for it.
310        pub fn with_boundary(out: W, boundary: Boundary) -> Self {
311            MultipartWriter {
312                out,
313                boundary,
314                verify: true,
315                first: true,
316                in_file: false,
317                poisoned: false,
318                k: 2,
319                m: 1,
320                pending_cr: false,
321            }
322        }
323
324        /// The boundary.
325        pub const fn boundary(&self) -> &Boundary {
326            &self.boundary
327        }
328
329        /// The `Content-Type` header value to send:
330        /// `multipart/form-data; boundary=...`, quoted when the boundary
331        /// holds a character that is not a token character.
332        #[must_use]
333        pub fn content_type(&self) -> String {
334            let b = self.boundary.as_bytes();
335            let token = b.iter().all(|&c| parse_net::class::is_tchar(c));
336            let b = core::str::from_utf8(b).unwrap_or("");
337            if token {
338                alloc::format!("multipart/form-data; boundary={b}")
339            } else {
340                alloc::format!("multipart/form-data; boundary=\"{b}\"")
341            }
342        }
343
344        /// Turns the delimiter scan on or off. It is on for a caller
345        /// boundary and off for a generated one.
346        pub fn verify_boundary(&mut self, on: bool) {
347            self.verify = on;
348        }
349
350        /// Writes a text field. Line breaks in `value` become CRLF. A refused
351        /// head writes nothing; after any error the output is incomplete and
352        /// must be discarded.
353        ///
354        /// # Errors
355        ///
356        /// The head errors of [`encode_part_head`](crate::encode_part_head);
357        /// `Syntax { BoundaryInContent }`; `Encoding { UnsupportedCharset }`
358        /// for a `_charset_` field naming anything but UTF-8 or US-ASCII
359        /// (the text after it would not read back); `Io`; `Poisoned`.
360        pub fn text(&mut self, name: &str, value: &str) -> Result<(), FormError> {
361            self.guard(|w| {
362                if name == "_charset_"
363                    && !(value.eq_ignore_ascii_case("utf-8")
364                        || value.eq_ignore_ascii_case("us-ascii"))
365                {
366                    return Err(FormError::encoding(
367                        EncodingError::UnsupportedCharset,
368                        Location::NONE,
369                    ));
370                }
371                w.head(name, None, None)?;
372                w.in_file = false;
373                w.pending_cr = false;
374                let bytes = value.as_bytes();
375                let mut from = 0usize;
376                for (i, &b) in bytes.iter().enumerate() {
377                    match b {
378                        b'\r' => {
379                            w.data(bytes.get(from..i).unwrap_or(&[]))?;
380                            w.data(b"\r\n")?;
381                            w.pending_cr = true;
382                            from = i.saturating_add(1);
383                        }
384                        b'\n' => {
385                            w.data(bytes.get(from..i).unwrap_or(&[]))?;
386                            if !w.pending_cr {
387                                w.data(b"\r\n")?;
388                            }
389                            w.pending_cr = false;
390                            from = i.saturating_add(1);
391                        }
392                        _ => w.pending_cr = false,
393                    }
394                }
395                w.data(bytes.get(from..).unwrap_or(&[]))
396            })
397        }
398
399        /// Starts a file part; its data follows through
400        /// [`file_data`](Self::file_data).
401        ///
402        /// # Errors
403        ///
404        /// As [`text`](Self::text), without the charset rule.
405        pub fn file_start(
406            &mut self,
407            name: &str,
408            filename: &str,
409            content_type: Option<&str>,
410        ) -> Result<(), FormError> {
411            self.guard(|w| {
412                w.head(name, Some(filename), content_type)?;
413                w.in_file = true;
414                Ok(())
415            })
416        }
417
418        /// More data of the current file part.
419        ///
420        /// # Errors
421        ///
422        /// `Syntax { BoundaryInContent }`; `Io`; `Poisoned` outside a file
423        /// part.
424        pub fn file_data(&mut self, chunk: &[u8]) -> Result<(), FormError> {
425            if !self.in_file {
426                self.poisoned = true;
427                return Err(FormError::Poisoned);
428            }
429            self.guard(|w| w.data(chunk))
430        }
431
432        /// A whole file part read from `data`. Returns its length.
433        ///
434        /// # Errors
435        ///
436        /// As [`file_start`](Self::file_start) and
437        /// [`file_data`](Self::file_data); `Io` from `data`.
438        pub fn file<R: Read>(
439            &mut self,
440            name: &str,
441            filename: &str,
442            content_type: Option<&str>,
443            mut data: R,
444        ) -> Result<u64, FormError> {
445            self.file_start(name, filename, content_type)?;
446            let mut buf = [0u8; 8192];
447            let mut total = 0u64;
448            let r = loop {
449                let n = match data.read(&mut buf) {
450                    Ok(0) => break Ok(total),
451                    Ok(n) => n,
452                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
453                    Err(e) => {
454                        self.poisoned = true;
455                        break Err(FormError::Io { kind: e.kind() });
456                    }
457                };
458                if let Err(e) = self.file_data(buf.get(..n).unwrap_or(&[])) {
459                    break Err(e);
460                }
461                total = total.saturating_add(n as u64);
462            };
463            crate::wipe::wipe(&mut buf);
464            r
465        }
466
467        /// Writes the close delimiter and returns the writer.
468        ///
469        /// # Errors
470        ///
471        /// `Io`; `Poisoned`.
472        pub fn finish(mut self) -> Result<W, FormError> {
473            self.guard(|w| {
474                let mut out = IoOut(&mut w.out);
475                if !w.first {
476                    out.put(b"\r\n")?;
477                }
478                out.put(b"--")?;
479                out.put(w.boundary.as_bytes())?;
480                out.put(b"--\r\n")?;
481                w.out.flush().map_err(|e| FormError::Io { kind: e.kind() })
482            })?;
483            Ok(self.out)
484        }
485
486        /// Runs `f`; any error poisons the writer.
487        fn guard(
488            &mut self,
489            f: impl FnOnce(&mut Self) -> Result<(), FormError>,
490        ) -> Result<(), FormError> {
491            if self.poisoned {
492                return Err(FormError::Poisoned);
493            }
494            let r = f(self);
495            if r.is_err() {
496                self.poisoned = true;
497            }
498            r
499        }
500
501        fn head(
502            &mut self,
503            name: &str,
504            filename: Option<&str>,
505            content_type: Option<&str>,
506        ) -> Result<(), FormError> {
507            // Everything is checked before the first byte goes out.
508            head_len(&self.boundary, self.first, name, filename, content_type)?;
509            put_head(
510                &mut IoOut(&mut self.out),
511                &self.boundary,
512                self.first,
513                name,
514                filename,
515                content_type,
516            )?;
517            self.first = false;
518            // The data starts right after a CRLF: as if it had been seen.
519            self.k = 2;
520            self.m = 1;
521            Ok(())
522        }
523
524        /// Writes part data, scanning it for the delimiter, and for
525        /// `--boundary` after a lone CR or LF, when verifying: the parser
526        /// refuses both (D-113).
527        fn data(&mut self, bytes: &[u8]) -> Result<(), FormError> {
528            if self.verify {
529                let (k, m, found) = self.boundary.run(self.k, self.m, bytes);
530                if found != crate::multipart::boundary::Hit::None {
531                    return Err(FormError::syntax(
532                        SyntaxError::BoundaryInContent,
533                        Location::NONE,
534                    ));
535                }
536                self.k = k;
537                self.m = m;
538            }
539            IoOut(&mut self.out).put(bytes)
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn head(
549        first: bool,
550        name: &str,
551        filename: Option<&str>,
552        ct: Option<&str>,
553    ) -> Result<alloc::vec::Vec<u8>, FormError> {
554        let b = Boundary::new(b"X").unwrap();
555        let mut out = [0u8; 256];
556        let n = encode_part_head(&b, first, name, filename, ct, &mut out)?;
557        Ok(out[..n].to_vec())
558    }
559
560    #[test]
561    fn test_head_shape_and_escapes() {
562        assert_eq!(
563            head(false, "a\r\nb", Some("c\".txt"), Some("text/plain")).unwrap(),
564            b"\r\n--X\r\nContent-Disposition: form-data; name=\"a%0D%0Ab\"; filename=\"c%22.txt\"\r\nContent-Type: text/plain\r\n\r\n"
565        );
566        assert_eq!(
567            head(true, "t\tab", None, None).unwrap(),
568            b"--X\r\nContent-Disposition: form-data; name=\"t\tab\"\r\n\r\n"
569        );
570    }
571
572    #[test]
573    fn test_head_refusals() {
574        let refused = |r: Result<alloc::vec::Vec<u8>, FormError>| match r {
575            Err(FormError::Syntax { reason, .. }) => reason,
576            other => panic!("{other:?}"),
577        };
578        assert_eq!(refused(head(true, "", None, None)), SyntaxError::EmptyName);
579        assert_eq!(
580            refused(head(true, "a\0", None, None)),
581            SyntaxError::ControlInHeader
582        );
583        assert_eq!(
584            refused(head(true, "a\\b", None, None)),
585            SyntaxError::AmbiguousQuote
586        );
587        assert_eq!(
588            refused(head(true, "a%22", None, None)),
589            SyntaxError::AmbiguousQuote
590        );
591        assert_eq!(
592            refused(head(true, "a", Some("x\\"), None)),
593            SyntaxError::AmbiguousQuote
594        );
595        assert!(head(true, "a", Some("C:\\x\\y.txt"), None).is_ok());
596        assert_eq!(
597            refused(head(true, "a", None, Some("text/plain\r\nX: y"))),
598            SyntaxError::ControlInHeader
599        );
600        assert_eq!(
601            refused(head(true, "a", None, Some("not a type"))),
602            SyntaxError::InvalidContentType
603        );
604        assert_eq!(
605            refused(head(true, "a", None, Some("multipart/mixed; boundary=y"))),
606            SyntaxError::InvalidContentType
607        );
608        assert_eq!(
609            refused(head(true, "a", None, Some("text/plain; a=1; a=2"))),
610            SyntaxError::InvalidContentType
611        );
612    }
613
614    #[test]
615    fn test_head_buffer_too_small_writes_nothing() {
616        let b = Boundary::new(b"X").unwrap();
617        let mut out = [7u8; 10];
618        let e = encode_part_head(&b, true, "name", None, None, &mut out).unwrap_err();
619        assert_eq!(e, FormError::BufferTooSmall { needed: 52 });
620        assert!(out.iter().all(|&x| x == 7));
621    }
622}