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 ///
301 /// # Examples
302 ///
303 /// ```
304 /// let mut w = form_net::MultipartWriter::new(Vec::new())?;
305 /// w.text("title", "Holiday")?;
306 /// let content_type = w.content_type();
307 /// let body = w.finish()?;
308 /// let mp = form_net::parse_multipart(content_type.as_bytes(), &body)?;
309 /// assert_eq!(mp.field("title"), Some("Holiday"));
310 /// # Ok::<(), form_net::FormError>(())
311 /// ```
312 #[cfg(feature = "random")]
313 #[cfg_attr(docsrs, doc(cfg(feature = "random")))]
314 pub fn new(out: W) -> Result<Self, FormError> {
315 let mut w = Self::with_boundary(out, Boundary::generate()?);
316 // 128 random bits: a collision with the data is negligible.
317 w.verify = false;
318 Ok(w)
319 }
320
321 /// A writer with the caller's boundary; data is scanned for it.
322 ///
323 /// # Examples
324 ///
325 /// ```
326 /// use form_net::{Boundary, MultipartWriter};
327 ///
328 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
329 /// w.text("a", "1")?;
330 /// let body = w.finish()?;
331 /// assert_eq!(body, b"--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--X--\r\n");
332 /// # Ok::<(), form_net::FormError>(())
333 /// ```
334 pub fn with_boundary(out: W, boundary: Boundary) -> Self {
335 MultipartWriter {
336 out,
337 boundary,
338 verify: true,
339 first: true,
340 in_file: false,
341 poisoned: false,
342 k: 2,
343 m: 1,
344 pending_cr: false,
345 }
346 }
347
348 /// The boundary.
349 ///
350 /// # Examples
351 ///
352 /// ```
353 /// use form_net::{Boundary, MultipartWriter};
354 ///
355 /// let w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"form-net-x")?);
356 /// assert_eq!(w.boundary().as_bytes(), b"form-net-x");
357 /// # Ok::<(), form_net::FormError>(())
358 /// ```
359 pub const fn boundary(&self) -> &Boundary {
360 &self.boundary
361 }
362
363 /// The `Content-Type` header value to send:
364 /// `multipart/form-data; boundary=...`, quoted when the boundary
365 /// holds a character that is not a token character.
366 ///
367 /// # Examples
368 ///
369 /// ```
370 /// use form_net::{Boundary, MultipartWriter};
371 ///
372 /// let w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"abc")?);
373 /// assert_eq!(w.content_type(), "multipart/form-data; boundary=abc");
374 /// // A space is not a token character: the boundary is quoted.
375 /// let w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"a b")?);
376 /// assert_eq!(w.content_type(), "multipart/form-data; boundary=\"a b\"");
377 /// # Ok::<(), form_net::FormError>(())
378 /// ```
379 #[must_use]
380 pub fn content_type(&self) -> String {
381 let b = self.boundary.as_bytes();
382 let token = b.iter().all(|&c| parse_net::class::is_tchar(c));
383 let b = core::str::from_utf8(b).unwrap_or("");
384 if token {
385 alloc::format!("multipart/form-data; boundary={b}")
386 } else {
387 alloc::format!("multipart/form-data; boundary=\"{b}\"")
388 }
389 }
390
391 /// Turns the delimiter scan on or off. It is on for a caller
392 /// boundary and off for a generated one.
393 ///
394 /// # Examples
395 ///
396 /// ```
397 /// use form_net::{Boundary, MultipartWriter};
398 ///
399 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
400 /// w.verify_boundary(true);
401 /// let err = w.text("a", "1\r\n--X").unwrap_err();
402 /// assert_eq!(err.code(), "syntax.boundary_in_content");
403 /// # Ok::<(), form_net::FormError>(())
404 /// ```
405 pub fn verify_boundary(&mut self, on: bool) {
406 self.verify = on;
407 }
408
409 /// Writes a text field. Line breaks in `value` become CRLF. A refused
410 /// head writes nothing; after any error the output is incomplete and
411 /// must be discarded.
412 ///
413 /// # Errors
414 ///
415 /// The head errors of [`encode_part_head`](crate::encode_part_head);
416 /// `Syntax { BoundaryInContent }`; `Encoding { UnsupportedCharset }`
417 /// for a `_charset_` field naming anything but UTF-8 or US-ASCII
418 /// (the text after it would not read back); `Io`; `Poisoned`.
419 ///
420 /// # Examples
421 ///
422 /// ```
423 /// use form_net::{Boundary, MultipartWriter};
424 ///
425 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
426 /// w.text("note", "line 1\nline 2")?;
427 /// let body = w.finish()?;
428 /// let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", &body)?;
429 /// assert_eq!(mp.field("note"), Some("line 1\r\nline 2"));
430 /// # Ok::<(), form_net::FormError>(())
431 /// ```
432 pub fn text(&mut self, name: &str, value: &str) -> Result<(), FormError> {
433 self.guard(|w| {
434 if name == "_charset_"
435 && !(value.eq_ignore_ascii_case("utf-8")
436 || value.eq_ignore_ascii_case("us-ascii"))
437 {
438 return Err(FormError::encoding(
439 EncodingError::UnsupportedCharset,
440 Location::NONE,
441 ));
442 }
443 w.head(name, None, None)?;
444 w.in_file = false;
445 w.pending_cr = false;
446 let bytes = value.as_bytes();
447 let mut from = 0usize;
448 for (i, &b) in bytes.iter().enumerate() {
449 match b {
450 b'\r' => {
451 w.data(bytes.get(from..i).unwrap_or(&[]))?;
452 w.data(b"\r\n")?;
453 w.pending_cr = true;
454 from = i.saturating_add(1);
455 }
456 b'\n' => {
457 w.data(bytes.get(from..i).unwrap_or(&[]))?;
458 if !w.pending_cr {
459 w.data(b"\r\n")?;
460 }
461 w.pending_cr = false;
462 from = i.saturating_add(1);
463 }
464 _ => w.pending_cr = false,
465 }
466 }
467 w.data(bytes.get(from..).unwrap_or(&[]))
468 })
469 }
470
471 /// Starts a file part; its data follows through
472 /// [`file_data`](Self::file_data).
473 ///
474 /// # Errors
475 ///
476 /// As [`text`](Self::text), without the charset rule.
477 ///
478 /// # Examples
479 ///
480 /// ```
481 /// use form_net::{Boundary, MultipartWriter};
482 ///
483 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
484 /// w.file_start("doc", "a.txt", Some("text/plain"))?;
485 /// w.file_data(b"hello")?;
486 /// let body = w.finish()?;
487 /// let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", &body)?;
488 /// assert_eq!(mp.file("doc").map(|p| p.bytes()), Some(&b"hello"[..]));
489 /// # Ok::<(), form_net::FormError>(())
490 /// ```
491 pub fn file_start(
492 &mut self,
493 name: &str,
494 filename: &str,
495 content_type: Option<&str>,
496 ) -> Result<(), FormError> {
497 self.guard(|w| {
498 w.head(name, Some(filename), content_type)?;
499 w.in_file = true;
500 Ok(())
501 })
502 }
503
504 /// More data of the current file part.
505 ///
506 /// # Errors
507 ///
508 /// `Syntax { BoundaryInContent }`; `Io`; `Poisoned` outside a file
509 /// part.
510 ///
511 /// # Examples
512 ///
513 /// ```
514 /// use form_net::{Boundary, MultipartWriter};
515 ///
516 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
517 /// w.file_start("log", "log.txt", None)?;
518 /// for chunk in [&b"part one, "[..], b"part two"] {
519 /// w.file_data(chunk)?;
520 /// }
521 /// let body = w.finish()?;
522 /// let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", &body)?;
523 /// assert_eq!(mp.file("log").map(|p| p.bytes()), Some(&b"part one, part two"[..]));
524 /// # Ok::<(), form_net::FormError>(())
525 /// ```
526 pub fn file_data(&mut self, chunk: &[u8]) -> Result<(), FormError> {
527 if !self.in_file {
528 self.poisoned = true;
529 return Err(FormError::Poisoned);
530 }
531 self.guard(|w| w.data(chunk))
532 }
533
534 /// A whole file part read from `data`. Returns its length.
535 ///
536 /// # Errors
537 ///
538 /// As [`file_start`](Self::file_start) and
539 /// [`file_data`](Self::file_data); `Io` from `data`.
540 ///
541 /// # Examples
542 ///
543 /// ```
544 /// use form_net::{Boundary, MultipartWriter};
545 ///
546 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
547 /// let len = w.file("photo", "beach.jpg", Some("image/jpeg"), &b"\xff\xd8 jpeg"[..])?;
548 /// assert_eq!(len, 7);
549 /// let body = w.finish()?;
550 /// let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", &body)?;
551 /// assert_eq!(mp.file("photo").map(|p| p.bytes()), Some(&b"\xff\xd8 jpeg"[..]));
552 /// # Ok::<(), form_net::FormError>(())
553 /// ```
554 pub fn file<R: Read>(
555 &mut self,
556 name: &str,
557 filename: &str,
558 content_type: Option<&str>,
559 mut data: R,
560 ) -> Result<u64, FormError> {
561 self.file_start(name, filename, content_type)?;
562 let mut buf = [0u8; 8192];
563 let mut total = 0u64;
564 let r = loop {
565 let n = match data.read(&mut buf) {
566 Ok(0) => break Ok(total),
567 Ok(n) => n,
568 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
569 Err(e) => {
570 self.poisoned = true;
571 break Err(FormError::Io { kind: e.kind() });
572 }
573 };
574 if let Err(e) = self.file_data(buf.get(..n).unwrap_or(&[])) {
575 break Err(e);
576 }
577 total = total.saturating_add(n as u64);
578 };
579 crate::wipe::wipe(&mut buf);
580 r
581 }
582
583 /// Writes the close delimiter and returns the writer.
584 ///
585 /// # Errors
586 ///
587 /// `Io`; `Poisoned`.
588 ///
589 /// # Examples
590 ///
591 /// ```
592 /// use form_net::{Boundary, MultipartWriter};
593 ///
594 /// let mut w = MultipartWriter::with_boundary(Vec::new(), Boundary::new(b"X")?);
595 /// w.text("a", "1")?;
596 /// let body = w.finish()?;
597 /// assert!(body.ends_with(b"\r\n--X--\r\n"));
598 /// # Ok::<(), form_net::FormError>(())
599 /// ```
600 pub fn finish(mut self) -> Result<W, FormError> {
601 self.guard(|w| {
602 let mut out = IoOut(&mut w.out);
603 if !w.first {
604 out.put(b"\r\n")?;
605 }
606 out.put(b"--")?;
607 out.put(w.boundary.as_bytes())?;
608 out.put(b"--\r\n")?;
609 w.out.flush().map_err(|e| FormError::Io { kind: e.kind() })
610 })?;
611 Ok(self.out)
612 }
613
614 /// Runs `f`; any error poisons the writer.
615 fn guard(
616 &mut self,
617 f: impl FnOnce(&mut Self) -> Result<(), FormError>,
618 ) -> Result<(), FormError> {
619 if self.poisoned {
620 return Err(FormError::Poisoned);
621 }
622 let r = f(self);
623 if r.is_err() {
624 self.poisoned = true;
625 }
626 r
627 }
628
629 fn head(
630 &mut self,
631 name: &str,
632 filename: Option<&str>,
633 content_type: Option<&str>,
634 ) -> Result<(), FormError> {
635 // Everything is checked before the first byte goes out.
636 head_len(&self.boundary, self.first, name, filename, content_type)?;
637 put_head(
638 &mut IoOut(&mut self.out),
639 &self.boundary,
640 self.first,
641 name,
642 filename,
643 content_type,
644 )?;
645 self.first = false;
646 // The data starts right after a CRLF: as if it had been seen.
647 self.k = 2;
648 self.m = 1;
649 Ok(())
650 }
651
652 /// Writes part data, scanning it for the delimiter, and for
653 /// `--boundary` after a lone CR or LF, when verifying: the parser
654 /// refuses both (D-113).
655 fn data(&mut self, bytes: &[u8]) -> Result<(), FormError> {
656 if self.verify {
657 let (k, m, found) = self.boundary.run(self.k, self.m, bytes);
658 if found != crate::multipart::boundary::Hit::None {
659 return Err(FormError::syntax(
660 SyntaxError::BoundaryInContent,
661 Location::NONE,
662 ));
663 }
664 self.k = k;
665 self.m = m;
666 }
667 IoOut(&mut self.out).put(bytes)
668 }
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn head(
677 first: bool,
678 name: &str,
679 filename: Option<&str>,
680 ct: Option<&str>,
681 ) -> Result<alloc::vec::Vec<u8>, FormError> {
682 let b = Boundary::new(b"X").unwrap();
683 let mut out = [0u8; 256];
684 let n = encode_part_head(&b, first, name, filename, ct, &mut out)?;
685 Ok(out[..n].to_vec())
686 }
687
688 #[test]
689 fn test_head_shape_and_escapes() {
690 assert_eq!(
691 head(false, "a\r\nb", Some("c\".txt"), Some("text/plain")).unwrap(),
692 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"
693 );
694 assert_eq!(
695 head(true, "t\tab", None, None).unwrap(),
696 b"--X\r\nContent-Disposition: form-data; name=\"t\tab\"\r\n\r\n"
697 );
698 }
699
700 #[test]
701 fn test_head_refusals() {
702 let refused = |r: Result<alloc::vec::Vec<u8>, FormError>| match r {
703 Err(FormError::Syntax { reason, .. }) => reason,
704 other => panic!("{other:?}"),
705 };
706 assert_eq!(refused(head(true, "", None, None)), SyntaxError::EmptyName);
707 assert_eq!(
708 refused(head(true, "a\0", None, None)),
709 SyntaxError::ControlInHeader
710 );
711 assert_eq!(
712 refused(head(true, "a\\b", None, None)),
713 SyntaxError::AmbiguousQuote
714 );
715 assert_eq!(
716 refused(head(true, "a%22", None, None)),
717 SyntaxError::AmbiguousQuote
718 );
719 assert_eq!(
720 refused(head(true, "a", Some("x\\"), None)),
721 SyntaxError::AmbiguousQuote
722 );
723 assert!(head(true, "a", Some("C:\\x\\y.txt"), None).is_ok());
724 assert_eq!(
725 refused(head(true, "a", None, Some("text/plain\r\nX: y"))),
726 SyntaxError::ControlInHeader
727 );
728 assert_eq!(
729 refused(head(true, "a", None, Some("not a type"))),
730 SyntaxError::InvalidContentType
731 );
732 assert_eq!(
733 refused(head(true, "a", None, Some("multipart/mixed; boundary=y"))),
734 SyntaxError::InvalidContentType
735 );
736 assert_eq!(
737 refused(head(true, "a", None, Some("text/plain; a=1; a=2"))),
738 SyntaxError::InvalidContentType
739 );
740 }
741
742 #[test]
743 fn test_head_buffer_too_small_writes_nothing() {
744 let b = Boundary::new(b"X").unwrap();
745 let mut out = [7u8; 10];
746 let e = encode_part_head(&b, true, "name", None, None, &mut out).unwrap_err();
747 assert_eq!(e, FormError::BufferTooSmall { needed: 52 });
748 assert!(out.iter().all(|&x| x == 7));
749 }
750}