1use rama_core::bytes::{BufMut, Bytes, BytesMut};
21use rama_core::error::{BoxError, ErrorContext as _, ErrorExt as _};
22use rama_core::futures::{StreamExt, TryStreamExt, stream};
23use rama_core::stream::io::ReaderStream;
24use rama_core::telemetry::tracing;
25use rama_http_types::{HeaderMap, HeaderValue, header, mime};
26use rama_utils::collections::smallvec::SmallVec;
27use rama_utils::macros::generate_set_and_with;
28use rama_utils::str::smol_str::{SmolStr, format_smolstr};
29use rand::RngExt as _;
30use std::borrow::Cow;
31use std::path::Path;
32use std::pin::Pin;
33use tokio::io::AsyncReadExt as _;
34
35const PARTS_INLINE_CAP: usize = 4;
39
40const CRLF: &[u8] = b"\r\n";
41const DASH_DASH: &[u8] = b"--";
42const FIELD_DISPOSITION_PREFIX: &[u8] = b"Content-Disposition: form-data; name=\"";
43const FILENAME_PREFIX: &[u8] = b"; filename=\"";
44const CONTENT_TYPE_PREFIX: &[u8] = b"Content-Type: ";
45const QUOTE: &[u8] = b"\"";
46const HEADER_KV_SEP: &[u8] = b": ";
47
48type ChunkStream = Pin<Box<dyn rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send>>;
49
50#[derive(Debug)]
57#[must_use]
58pub struct Form {
59 boundary: SmolStr,
60 parts: SmallVec<[NamedPart; PARTS_INLINE_CAP]>,
61}
62
63#[derive(Debug)]
64struct NamedPart {
65 name: Cow<'static, str>,
66 part: Part,
67}
68
69impl Default for Form {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl Form {
76 pub fn new() -> Self {
78 Self {
79 boundary: gen_boundary(),
80 parts: SmallVec::new(),
81 }
82 }
83
84 #[must_use]
86 pub fn boundary(&self) -> &str {
87 &self.boundary
88 }
89
90 #[must_use]
92 #[expect(
93 clippy::unreachable,
94 reason = "boundary is constructed from validated bytes; HeaderValue::try_from is infallible by construction here, the Err arm exists only to satisfy unwrap_used/expect_used"
95 )]
96 pub fn content_type(&self) -> HeaderValue {
97 let value = format!("multipart/form-data; boundary={}", self.boundary);
98 match HeaderValue::try_from(value) {
105 Ok(v) => v,
106 Err(_) => unreachable!("multipart boundary always converts to a HeaderValue"),
107 }
108 }
109
110 pub fn text<N, V>(self, name: N, value: V) -> Self
112 where
113 N: Into<Cow<'static, str>>,
114 V: Into<Cow<'static, str>>,
115 {
116 self.part(name, Part::text(value))
117 }
118
119 pub fn bytes<N, B>(self, name: N, value: B) -> Self
121 where
122 N: Into<Cow<'static, str>>,
123 B: Into<Bytes>,
124 {
125 self.part(name, Part::bytes(value))
126 }
127
128 pub async fn file<N, P>(self, name: N, path: P) -> std::io::Result<Self>
132 where
133 N: Into<Cow<'static, str>>,
134 P: AsRef<Path>,
135 {
136 let part = Part::file(path).await?;
137 Ok(self.part(name, part))
138 }
139
140 pub async fn with_field_spec(self, spec: &str) -> Result<Self, FieldSpecError> {
147 let parsed = FieldSpec::parse(spec)?;
148 let name = parsed.name.to_owned();
149 let part = parsed.into_part().await?;
150 Ok(self.part(name, part))
151 }
152
153 pub fn part<N>(mut self, name: N, part: Part) -> Self
155 where
156 N: Into<Cow<'static, str>>,
157 {
158 self.parts.push(NamedPart {
159 name: name.into(),
160 part,
161 });
162 self
163 }
164
165 #[must_use]
171 pub fn content_length(&self) -> Option<u64> {
172 let mut total: u64 = 0;
173 for np in &self.parts {
174 let part_size = np.part.content_size?;
175 let header_len = part_headers_len(&self.boundary, &np.name, &np.part) as u64;
176 total = total.checked_add(header_len)?;
177 total = total.checked_add(part_size)?;
178 total = total.checked_add(CRLF.len() as u64)?;
179 }
180 let trailer_len =
181 (DASH_DASH.len() + self.boundary.len() + DASH_DASH.len() + CRLF.len()) as u64;
182 total = total.checked_add(trailer_len)?;
183 Some(total)
184 }
185
186 pub fn into_stream(
193 self,
194 ) -> impl rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send {
195 let boundary = self.boundary;
196 let n_parts = self.parts.len();
197
198 let trailer = {
202 let cap = if n_parts == 0 { 0 } else { CRLF.len() }
203 + DASH_DASH.len()
204 + boundary.len()
205 + DASH_DASH.len()
206 + CRLF.len();
207 let mut buf = BytesMut::with_capacity(cap);
208 if n_parts > 0 {
209 buf.put_slice(CRLF);
210 }
211 buf.put_slice(DASH_DASH);
212 buf.put_slice(boundary.as_bytes());
213 buf.put_slice(DASH_DASH);
214 buf.put_slice(CRLF);
215 buf.freeze()
216 };
217
218 let mut chunks: Vec<ChunkStream> = Vec::with_capacity(n_parts * 2 + 1);
220 for (i, np) in self.parts.into_iter().enumerate() {
221 let framing = render_framing(&boundary, &np.name, &np.part, i > 0);
225 chunks.push(Box::pin(stream::iter([Ok::<Bytes, BoxError>(framing)])));
226 chunks.push(match np.part.body {
227 PartBody::Bytes(b) => Box::pin(stream::iter([Ok::<Bytes, BoxError>(b)])),
228 PartBody::Stream(s) => s,
229 });
230 }
231 chunks.push(Box::pin(stream::iter([Ok::<Bytes, BoxError>(trailer)])));
232
233 stream::iter(chunks).flatten()
234 }
235
236 pub fn into_body(self) -> crate::Body {
241 crate::Body::from_stream(self.into_stream())
242 }
243}
244
245#[must_use]
254pub struct Part {
255 body: PartBody,
256 content_size: Option<u64>,
257 file_name: Option<Cow<'static, str>>,
258 mime: Option<mime::Mime>,
259 headers: HeaderMap,
260}
261
262impl std::fmt::Debug for Part {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("Part")
265 .field(
266 "body_kind",
267 &match &self.body {
268 PartBody::Bytes(b) => format!("bytes ({} B)", b.len()),
269 PartBody::Stream(_) => String::from("stream"),
270 },
271 )
272 .field("content_size", &self.content_size)
273 .field("file_name", &self.file_name)
274 .field("mime", &self.mime.as_ref().map(mime::Mime::essence_str))
275 .field("headers", &self.headers)
276 .finish()
277 }
278}
279
280enum PartBody {
281 Bytes(Bytes),
282 Stream(ChunkStream),
283}
284
285impl Part {
286 pub fn text<V: Into<Cow<'static, str>>>(value: V) -> Self {
288 let bytes = Bytes::from(value.into().into_owned().into_bytes());
289 let len = bytes.len() as u64;
290 Self {
291 body: PartBody::Bytes(bytes),
292 content_size: Some(len),
293 file_name: None,
294 mime: None,
295 headers: HeaderMap::new(),
296 }
297 }
298
299 pub fn bytes<B: Into<Bytes>>(value: B) -> Self {
301 let bytes: Bytes = value.into();
302 let len = bytes.len() as u64;
303 Self {
304 body: PartBody::Bytes(bytes),
305 content_size: Some(len),
306 file_name: None,
307 mime: None,
308 headers: HeaderMap::new(),
309 }
310 }
311
312 pub fn stream<S, O, E>(stream: S) -> Self
315 where
316 S: rama_core::futures::Stream<Item = Result<O, E>> + Send + 'static,
317 O: Into<Bytes> + 'static,
318 E: Into<BoxError> + 'static,
319 {
320 let mapped = stream.map_ok(Into::into).map_err(Into::into);
321 Self {
322 body: PartBody::Stream(Box::pin(mapped)),
323 content_size: None,
324 file_name: None,
325 mime: None,
326 headers: HeaderMap::new(),
327 }
328 }
329
330 pub async fn file<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
347 let path = path.as_ref();
348 let file_name: Option<Cow<'static, str>> = path
349 .file_name()
350 .map(|name| Cow::Owned(name.to_string_lossy().into_owned()));
351 let mime = path
352 .extension()
353 .and_then(std::ffi::OsStr::to_str)
354 .and_then(|ext| mime_guess::from_ext(ext).first())
355 .unwrap_or(mime::APPLICATION_OCTET_STREAM);
356
357 let file = rama_utils::fs::safe_open(path).await?;
358 let metadata = file.metadata().await?;
359 let len = metadata.len();
360
361 tracing::debug!(
362 path = %path.display(),
363 size = len,
364 mime = %mime,
365 "multipart::Part::file: opened file for streaming",
366 );
367
368 let stream = ReaderStream::new(file);
369 let mapped = stream.map_ok(Bytes::from).map_err(BoxError::from);
370
371 Ok(Self {
372 body: PartBody::Stream(Box::pin(mapped)),
373 content_size: Some(len),
374 file_name,
375 mime: Some(mime),
376 headers: HeaderMap::new(),
377 })
378 }
379
380 generate_set_and_with! {
381 pub fn file_name(mut self, file_name: impl Into<Cow<'static, str>>) -> Self {
387 self.file_name = Some(file_name.into());
388 self
389 }
390 }
391
392 generate_set_and_with! {
393 pub fn mime(mut self, mime: Option<mime::Mime>) -> Self {
398 self.mime = mime;
399 self
400 }
401 }
402
403 generate_set_and_with! {
404 pub fn mime_str(mut self, mime_str: &str) -> Result<Self, mime::FromStrError> {
408 self.mime = Some(mime_str.parse()?);
409 Ok(self)
410 }
411 }
412
413 generate_set_and_with! {
414 pub fn content_size(mut self, size: Option<u64>) -> Self {
417 self.content_size = size;
418 self
419 }
420 }
421
422 generate_set_and_with! {
423 pub fn headers(mut self, headers: HeaderMap) -> Self {
432 self.headers = headers;
433 self
434 }
435 }
436}
437
438fn render_framing(boundary: &str, name: &str, part: &Part, with_leading_crlf: bool) -> Bytes {
444 let cap =
445 if with_leading_crlf { CRLF.len() } else { 0 } + part_headers_len(boundary, name, part);
446 let mut buf = BytesMut::with_capacity(cap);
447 if with_leading_crlf {
448 buf.put_slice(CRLF);
449 }
450 buf.put_slice(DASH_DASH);
451 buf.put_slice(boundary.as_bytes());
452 buf.put_slice(CRLF);
453 buf.put_slice(FIELD_DISPOSITION_PREFIX);
454 write_quoted(&mut buf, name);
455 buf.put_slice(QUOTE);
456 if let Some(file_name) = part.file_name.as_deref() {
457 buf.put_slice(FILENAME_PREFIX);
458 write_quoted(&mut buf, file_name);
459 buf.put_slice(QUOTE);
460 }
461 buf.put_slice(CRLF);
462 if let Some(mime) = &part.mime {
463 buf.put_slice(CONTENT_TYPE_PREFIX);
464 buf.put_slice(mime.as_ref().as_bytes());
467 buf.put_slice(CRLF);
468 }
469 for (name, value) in &part.headers {
470 if name == header::CONTENT_DISPOSITION || name == header::CONTENT_TYPE {
471 continue;
472 }
473 buf.put_slice(name.as_str().as_bytes());
474 buf.put_slice(HEADER_KV_SEP);
475 buf.put_slice(value.as_bytes());
476 buf.put_slice(CRLF);
477 }
478 buf.put_slice(CRLF);
479 buf.freeze()
480}
481
482fn part_headers_len(boundary: &str, name: &str, part: &Part) -> usize {
486 let mut len = DASH_DASH.len()
488 + boundary.len()
489 + CRLF.len()
490 + FIELD_DISPOSITION_PREFIX.len()
491 + quoted_len(name)
492 + QUOTE.len();
493 if let Some(file_name) = part.file_name.as_deref() {
495 len += FILENAME_PREFIX.len() + quoted_len(file_name) + QUOTE.len();
496 }
497 len += CRLF.len();
498 if let Some(mime) = &part.mime {
501 len += CONTENT_TYPE_PREFIX.len() + mime.as_ref().len() + CRLF.len();
502 }
503 for (h_name, h_value) in &part.headers {
505 if h_name == header::CONTENT_DISPOSITION || h_name == header::CONTENT_TYPE {
506 continue;
507 }
508 len += h_name.as_str().len() + HEADER_KV_SEP.len() + h_value.as_bytes().len() + CRLF.len();
509 }
510 len += CRLF.len();
512 len
513}
514
515fn quoted_len(s: &str) -> usize {
517 s.bytes()
518 .map(|b| match b {
519 b'"' | b'\\' => 2,
520 _ => 1,
522 })
523 .sum()
524}
525
526fn write_quoted(buf: &mut BytesMut, s: &str) {
527 for byte in s.as_bytes() {
528 match *byte {
529 b'"' | b'\\' => {
530 buf.put_u8(b'\\');
531 buf.put_u8(*byte);
532 }
533 b'\r' | b'\n' => {
534 buf.put_u8(b' ');
536 }
537 b => buf.put_u8(b),
538 }
539 }
540}
541
542#[derive(Debug, Clone)]
567pub struct FieldSpec<'a> {
568 pub name: &'a str,
570 pub source: FieldSpecSource<'a>,
572 pub content_type: Option<&'a str>,
574 pub filename: Option<&'a str>,
576}
577
578#[derive(Debug, Clone)]
580pub enum FieldSpecSource<'a> {
581 Text(&'a str),
583 File(&'a str),
585 FileText(&'a str),
587}
588
589#[derive(Debug)]
591pub enum FieldSpecError {
592 MissingSeparator,
594 EmptyName,
596 InvalidModifier(BoxError),
599}
600
601impl std::fmt::Display for FieldSpecError {
602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603 match self {
604 Self::MissingSeparator => write!(f, "field spec is missing `=` separator"),
605 Self::EmptyName => write!(f, "field spec has empty name"),
606 Self::InvalidModifier(err) => write!(f, "invalid field spec: {err}"),
607 }
608 }
609}
610
611impl std::error::Error for FieldSpecError {
612 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
613 match self {
614 Self::InvalidModifier(err) => Some(&**err),
615 _ => None,
616 }
617 }
618}
619
620#[derive(Debug)]
623struct InlineErr(SmolStr);
624
625impl std::fmt::Display for InlineErr {
626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627 f.write_str(&self.0)
628 }
629}
630
631impl std::error::Error for InlineErr {}
632
633impl<'a> FieldSpec<'a> {
634 pub fn parse(spec: &'a str) -> Result<Self, FieldSpecError> {
636 let (name, rest) = spec
637 .split_once('=')
638 .ok_or(FieldSpecError::MissingSeparator)?;
639 if name.is_empty() {
640 return Err(FieldSpecError::EmptyName);
641 }
642
643 let mut content_type: Option<&str> = None;
646 let mut filename: Option<&str> = None;
647 let value_part: &str;
648
649 if let Some((value, modifiers)) = split_modifiers(rest) {
650 value_part = value;
651 for modifier in modifiers.split(';') {
652 let modifier = modifier.trim();
653 if modifier.is_empty() {
654 continue;
655 }
656 let (key, val) = modifier.split_once('=').ok_or_else(|| {
657 FieldSpecError::InvalidModifier(
658 InlineErr(format_smolstr!("missing `=` in modifier `{modifier}`")).into(),
659 )
660 })?;
661 match key.trim() {
662 "type" => content_type = Some(val),
663 "filename" => filename = Some(val),
664 other => {
665 return Err(FieldSpecError::InvalidModifier(
666 InlineErr(format_smolstr!("unknown modifier key `{other}`")).into(),
667 ));
668 }
669 }
670 }
671 } else {
672 value_part = rest;
673 }
674
675 let source = if let Some(path) = value_part.strip_prefix('@') {
676 FieldSpecSource::File(path)
677 } else if let Some(path) = value_part.strip_prefix('<') {
678 FieldSpecSource::FileText(path)
679 } else {
680 FieldSpecSource::Text(value_part)
681 };
682
683 Ok(Self {
684 name,
685 source,
686 content_type,
687 filename,
688 })
689 }
690
691 pub async fn into_part(self) -> Result<Part, FieldSpecError> {
693 let mut part = match self.source {
694 FieldSpecSource::Text(s) => Part::text(s.to_owned()),
695 FieldSpecSource::File("-") => Part::stream(read_stdin_stream()),
696 FieldSpecSource::File(path) => Part::file(path)
697 .await
698 .with_context(|| format_smolstr!("multipart field spec: open file `{path}`"))
699 .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?,
700 FieldSpecSource::FileText("-") => {
701 let s = read_stdin_to_string()
702 .await
703 .context("multipart field spec: read stdin as text")
704 .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
705 Part::text(s)
706 }
707 FieldSpecSource::FileText(path) => {
708 let s = tokio::fs::read_to_string(path)
709 .await
710 .with_context(|| format_smolstr!("multipart field spec: read file `{path}`"))
711 .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
712 Part::text(s)
713 }
714 };
715 if let Some(ct) = self.content_type {
716 part.try_set_mime_str(ct)
717 .with_context(|| format_smolstr!("invalid `;type=` mime in field spec: {ct}"))
718 .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
719 }
720 if let Some(fname) = self.filename {
721 part.set_file_name(fname.to_owned());
722 }
723 Ok(part)
724 }
725}
726
727fn split_modifiers(input: &str) -> Option<(&str, &str)> {
731 input.split_once(';')
734}
735
736async fn read_stdin_to_string() -> Result<String, BoxError> {
737 let mut buf = String::new();
738 tokio::io::stdin()
739 .read_to_string(&mut buf)
740 .await
741 .context("read multipart field value from stdin")?;
742 Ok(buf)
743}
744
745fn read_stdin_stream() -> impl rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send {
746 ReaderStream::new(tokio::io::stdin())
747 .map_ok(Bytes::from)
748 .map_err(BoxError::from)
749}
750
751fn gen_boundary() -> SmolStr {
752 let mut rng = rand::rng();
753 format_smolstr!(
754 "{:016x}-{:016x}-{:016x}-{:016x}",
755 rng.random::<u64>(),
756 rng.random::<u64>(),
757 rng.random::<u64>(),
758 rng.random::<u64>(),
759 )
760}
761
762#[cfg(test)]
763mod test {
764 use super::*;
765 use rama_core::futures::TryStreamExt;
766
767 async fn collect(form: Form) -> (HeaderValue, Option<u64>, Vec<u8>) {
768 let ct = form.content_type();
769 let len = form.content_length();
770 let bytes: Vec<u8> = form
771 .into_stream()
772 .map_ok(|chunk| chunk.to_vec())
773 .try_collect::<Vec<Vec<u8>>>()
774 .await
775 .unwrap()
776 .into_iter()
777 .flatten()
778 .collect();
779 (ct, len, bytes)
780 }
781
782 #[tokio::test]
783 async fn test_form_text_only() {
784 let form = Form::new().text("name", "glen").text("language", "rust");
785 let boundary = form.boundary().to_owned();
786 let (ct, len, bytes) = collect(form).await;
787 assert!(ct.to_str().unwrap().contains(&boundary));
788 assert_eq!(len.unwrap() as usize, bytes.len());
789 let s = std::str::from_utf8(&bytes).unwrap();
790 assert!(s.contains("name=\"name\""));
791 assert!(s.contains("name=\"language\""));
792 assert!(s.contains("\r\nglen\r\n"));
793 assert!(s.contains("\r\nrust\r\n"));
794 assert!(s.ends_with("--\r\n"));
795 }
796
797 #[tokio::test]
798 async fn test_form_bytes_with_filename_and_mime() {
799 let part = Part::bytes(b"\x00\x01\x02".as_slice())
800 .with_file_name("a.bin")
801 .with_mime(mime::APPLICATION_OCTET_STREAM);
802 let form = Form::new().part("avatar", part);
803 let (_, len, bytes) = collect(form).await;
804 assert!(len.is_some());
805 let s = std::str::from_utf8(&bytes[..bytes.iter().position(|&b| b == 0).unwrap()]).unwrap();
806 assert!(s.contains("filename=\"a.bin\""));
807 assert!(s.contains("Content-Type: application/octet-stream"));
808 }
809
810 #[tokio::test]
811 async fn test_form_unknown_length_when_streaming() {
812 let part = Part::stream(stream::iter([
813 Ok::<Bytes, BoxError>(Bytes::from_static(b"hello ")),
814 Ok::<Bytes, BoxError>(Bytes::from_static(b"world")),
815 ]));
816 let form = Form::new().part("payload", part);
817 assert!(form.content_length().is_none());
818 let (_, _len, bytes) = collect(form).await;
819 let s = std::str::from_utf8(&bytes).unwrap();
820 assert!(s.contains("hello world"));
821 }
822
823 #[tokio::test]
824 async fn test_form_known_length_when_streaming_with_content_size() {
825 let part = Part::stream(stream::iter([Ok::<Bytes, BoxError>(Bytes::from_static(
826 b"abcdef",
827 ))]))
828 .with_content_size(6);
829 let form = Form::new().part("payload", part);
830 let len = form.content_length().expect("length known");
831 let (_, _, bytes) = collect(form).await;
832 assert_eq!(len as usize, bytes.len());
833 }
834
835 #[tokio::test]
836 async fn test_form_quoting_escapes_quotes() {
837 let form = Form::new().text("we\"ird", "v");
838 let (_, _, bytes) = collect(form).await;
839 let s = std::str::from_utf8(&bytes).unwrap();
840 assert!(s.contains("name=\"we\\\"ird\""));
841 }
842
843 #[tokio::test]
844 async fn test_form_preserves_mime_parameters() {
845 let part = Part::bytes(b"hi".as_slice())
849 .try_with_mime_str("text/plain; charset=utf-8")
850 .unwrap();
851 let form = Form::new().part("note", part);
852 let len = form.content_length().expect("length known");
853 let (_, _, bytes) = collect(form).await;
854 assert_eq!(len as usize, bytes.len());
855 let s = std::str::from_utf8(&bytes).unwrap();
856 assert!(
857 s.contains("Content-Type: text/plain; charset=utf-8"),
858 "rendered body: {s}"
859 );
860 }
861
862 #[test]
863 fn test_field_spec_text() {
864 let s = FieldSpec::parse("name=glen").unwrap();
865 assert_eq!(s.name, "name");
866 assert!(matches!(s.source, FieldSpecSource::Text("glen")));
867 assert!(s.content_type.is_none());
868 assert!(s.filename.is_none());
869 }
870
871 #[test]
872 fn test_field_spec_file_with_modifiers() {
873 let s = FieldSpec::parse("avatar=@./photo.png;type=image/png;filename=me.png").unwrap();
874 assert_eq!(s.name, "avatar");
875 assert!(matches!(s.source, FieldSpecSource::File("./photo.png")));
876 assert_eq!(s.content_type, Some("image/png"));
877 assert_eq!(s.filename, Some("me.png"));
878 }
879
880 #[test]
881 fn test_field_spec_file_text() {
882 let s = FieldSpec::parse("greeting=<hello.txt").unwrap();
883 assert_eq!(s.name, "greeting");
884 assert!(matches!(s.source, FieldSpecSource::FileText("hello.txt")));
885 }
886
887 #[test]
888 fn test_field_spec_stdin() {
889 let s = FieldSpec::parse("blob=@-").unwrap();
890 assert!(matches!(s.source, FieldSpecSource::File("-")));
891 }
892
893 #[test]
894 fn test_field_spec_errors() {
895 assert!(matches!(
896 FieldSpec::parse("noequal"),
897 Err(FieldSpecError::MissingSeparator)
898 ));
899 assert!(matches!(
900 FieldSpec::parse("=value"),
901 Err(FieldSpecError::EmptyName)
902 ));
903 assert!(matches!(
904 FieldSpec::parse("name=v;invalid"),
905 Err(FieldSpecError::InvalidModifier(_))
906 ));
907 assert!(matches!(
908 FieldSpec::parse("name=v;weird=val"),
909 Err(FieldSpecError::InvalidModifier(_))
910 ));
911 }
912
913 #[tokio::test]
914 async fn test_form_with_field_spec_text() {
915 let form = Form::new()
916 .with_field_spec("name=glen")
917 .await
918 .unwrap()
919 .with_field_spec("lang=rust")
920 .await
921 .unwrap();
922 let (_, _, bytes) = collect(form).await;
923 let s = std::str::from_utf8(&bytes).unwrap();
924 assert!(s.contains("name=\"name\""));
925 assert!(s.contains("\r\nglen\r\n"));
926 assert!(s.contains("name=\"lang\""));
927 assert!(s.contains("\r\nrust\r\n"));
928 }
929
930 #[tokio::test]
931 async fn test_form_with_field_spec_file() {
932 let dir = tempfile::tempdir().unwrap();
933 let path = dir.path().join("hello.txt");
934 tokio::fs::write(&path, b"hi from disk").await.unwrap();
935 let spec = format!("note=@{};type=text/plain", path.display());
936
937 let form = Form::new().with_field_spec(&spec).await.unwrap();
938 let (_, _, bytes) = collect(form).await;
939 let s = std::str::from_utf8(&bytes).unwrap();
940 assert!(s.contains("name=\"note\""));
941 assert!(s.contains("filename=\"hello.txt\""));
942 assert!(s.contains("Content-Type: text/plain"));
943 assert!(s.contains("hi from disk"));
944 }
945
946 #[tokio::test]
947 async fn test_field_spec_file_text_allows_parent_dir_paths() {
948 let dir = tempfile::tempdir().unwrap();
949 let child = dir.path().join("child");
950 tokio::fs::create_dir(&child).await.unwrap();
951 let payload = dir.path().join("payload.txt");
952 tokio::fs::write(&payload, b"hello parent").await.unwrap();
953 let spec = format!("greeting=<{}", child.join("../payload.txt").display());
954
955 let form = Form::new().with_field_spec(&spec).await.unwrap();
956 let (_, _, bytes) = collect(form).await;
957 let s = std::str::from_utf8(&bytes).unwrap();
958 assert!(s.contains("\r\nhello parent\r\n"));
959 }
960}