commit ab6adf11f97f5c70778cda19fdf34a15f4db3e59 from: Murilo Ijanc date: Sat Apr 25 20:47:18 2026 UTC add multipart/form-data body builder commit - e7ae8fa0ff68290bf542ce584771be3c8b27ad01 commit + ab6adf11f97f5c70778cda19fdf34a15f4db3e59 blob - /dev/null blob + d0803c7f5add4ef4a0a504b4b9ea81988b8bb739 (mode 644) --- /dev/null +++ examples/upload.rs @@ -0,0 +1,68 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +//! Upload a file via multipart/form-data and print the response. +//! +//! Usage: ex-upload [content-type] +//! +//! The default content type is application/octet-stream. + +use std::env; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 4 || args.len() > 5 { + eprintln!("usage: ex-upload [content-type]"); + process::exit(1); + } + let url = &args[1]; + let field = &args[2]; + let path = &args[3]; + let ct = args + .get(4) + .map(String::as_str) + .unwrap_or("application/octet-stream"); + + let form = match http::Multipart::new().file_path(field, path, ct) { + Ok(f) => f, + Err(e) => { + eprintln!("ex-upload: {e}"); + process::exit(2); + } + }; + + let resp = match http::post(url).multipart(form).send() { + Ok(r) => r, + Err(e) => { + eprintln!("ex-upload: {e}"); + process::exit(2); + } + }; + println!("{} {} {}", resp.version, resp.status, resp.reason); + for (name, value) in resp.headers.iter() { + println!("{name}: {value}"); + } + println!(); + match resp.body_string() { + Ok(s) => print!("{s}"), + Err(e) => { + eprintln!("ex-upload: read body: {e}"); + process::exit(2); + } + } +} blob - 9bc68ece720239d874c3dbe21714cb39c6d9aba0 blob + 298a17645c08fc2a0326c40e97efe0179afffdb8 --- http.rs +++ http.rs @@ -1329,6 +1329,16 @@ impl RequestBuilder { self } + /// Set the body to a `multipart/form-data` payload (RFC 2388) and + /// the Content-Type header (with boundary). Overwrites any previous + /// body and Content-Type. + pub fn multipart(mut self, form: Multipart) -> Self { + let ct = form.content_type(); + self.body = form.into_bytes(); + self.headers.set("Content-Type", ct); + self + } + /// Override the read/write timeout. Default is 30 seconds. pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; @@ -1503,6 +1513,165 @@ pub fn url_form(pairs: &[(&str, &str)]) -> String { } ////////////////////////////////////////////////////////////////////////////// +// multipart/form-data (RFC 2388) +////////////////////////////////////////////////////////////////////////////// + +/// A `multipart/form-data` body builder. RFC 2388. +/// +/// Each part has a name and either a text value or a file payload +/// (filename + content-type + bytes). Pass the finished form to +/// [`RequestBuilder::multipart`], which sets the Content-Type header +/// (with the boundary) and serializes the body. +#[derive(Clone, Debug)] +pub struct Multipart { + boundary: String, + parts: Vec, +} + +#[derive(Clone, Debug)] +struct Part { + name: String, + filename: Option, + content_type: Option, + body: Vec, +} + +impl Multipart { + /// Create a new form with a fresh boundary. + pub fn new() -> Self { + Self { + boundary: gen_boundary(), + parts: Vec::new(), + } + } + + /// Append a text field. + pub fn text( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.parts.push(Part { + name: name.into(), + filename: None, + content_type: None, + body: value.into().into_bytes(), + }); + self + } + + /// Append a file field with explicit filename, content type, and bytes. + pub fn file( + mut self, + name: impl Into, + filename: impl Into, + content_type: impl Into, + body: impl Into>, + ) -> Self { + self.parts.push(Part { + name: name.into(), + filename: Some(filename.into()), + content_type: Some(content_type.into()), + body: body.into(), + }); + self + } + + /// Append a file field by reading `path` from disk. The filename + /// sent is the path's final component. + pub fn file_path( + self, + name: impl Into, + path: impl AsRef, + content_type: impl Into, + ) -> Result { + let path = path.as_ref(); + let body = std::fs::read(path) + .map_err(|e| Error::new(format!("read {}: {e}", path.display())))?; + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| Error::new("path has no file name"))? + .to_owned(); + Ok(self.file(name, filename, content_type, body)) + } + + /// The boundary string (without the leading dashes). + pub fn boundary(&self) -> &str { + &self.boundary + } + + /// Value to use in the `Content-Type` header. + pub fn content_type(&self) -> String { + format!("multipart/form-data; boundary={}", self.boundary) + } + + /// Serialize the form body to bytes. + pub fn into_bytes(self) -> Vec { + let mut out = Vec::new(); + self.write_to(&mut out).expect("Vec write is infallible"); + out + } + + /// Write the serialized form body to `w`. + pub fn write_to(&self, w: &mut impl Write) -> io::Result<()> { + for part in &self.parts { + write!(w, "--{}\r\n", self.boundary)?; + w.write_all(b"Content-Disposition: form-data; name=\"")?; + write_quoted(w, &part.name)?; + w.write_all(b"\"")?; + if let Some(filename) = &part.filename { + w.write_all(b"; filename=\"")?; + write_quoted(w, filename)?; + w.write_all(b"\"")?; + } + w.write_all(b"\r\n")?; + if let Some(ct) = &part.content_type { + write!(w, "Content-Type: {ct}\r\n")?; + } + w.write_all(b"\r\n")?; + w.write_all(&part.body)?; + w.write_all(b"\r\n")?; + } + write!(w, "--{}--\r\n", self.boundary) + } +} + +impl Default for Multipart { + fn default() -> Self { + Self::new() + } +} + +/// Backslash-escape `"` and `\` per RFC 2616 §2.2 quoted-string. CR/LF +/// are not legal inside a quoted-string, so they are dropped. +fn write_quoted(w: &mut impl Write, s: &str) -> io::Result<()> { + for &b in s.as_bytes() { + match b { + b'"' | b'\\' => w.write_all(&[b'\\', b])?, + b'\r' | b'\n' => continue, + _ => w.write_all(&[b])?, + } + } + Ok(()) +} + +/// A boundary that is unique within a process: nanos + pid + counter, +/// as 70-bchar-safe hex. RFC 2046 §5.1.1 only requires uniqueness +/// against the body, not unpredictability. +fn gen_boundary() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let pid = std::process::id() as u64; + format!("----http-rs-{nanos:016x}-{pid:08x}-{n:08x}") +} + +////////////////////////////////////////////////////////////////////////////// // Tests ////////////////////////////////////////////////////////////////////////////// @@ -1808,4 +1977,67 @@ mod tests { assert!(!is_final_chunked("chunked, gzip")); assert!(!is_final_chunked("gzip")); } + + #[test] + fn multipart_format() { + let form = Multipart::new().text("name", "Murilo").file( + "upload", + "hello.txt", + "text/plain", + b"hello".to_vec(), + ); + let boundary = form.boundary().to_owned(); + let s = String::from_utf8(form.into_bytes()).unwrap(); + let expected = format!( + "--{b}\r\n\ + Content-Disposition: form-data; name=\"name\"\r\n\ + \r\n\ + Murilo\r\n\ + --{b}\r\n\ + Content-Disposition: form-data; name=\"upload\"; \ + filename=\"hello.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + hello\r\n\ + --{b}--\r\n", + b = boundary, + ); + assert_eq!(s, expected); + } + + #[test] + fn multipart_content_type_header() { + let form = Multipart::new(); + let ct = form.content_type(); + let prefix = "multipart/form-data; boundary="; + assert!(ct.starts_with(prefix)); + assert_eq!(&ct[prefix.len()..], form.boundary()); + } + + #[test] + fn multipart_quotes_special_chars() { + let form = Multipart::new().file( + "f", + "a\"b\\c.txt", + "application/octet-stream", + b"x".to_vec(), + ); + let s = String::from_utf8(form.into_bytes()).unwrap(); + assert!(s.contains("filename=\"a\\\"b\\\\c.txt\"")); + } + + #[test] + fn multipart_unique_boundaries() { + let a = Multipart::new(); + let b = Multipart::new(); + assert_ne!(a.boundary(), b.boundary()); + } + + #[test] + fn multipart_empty_form() { + let form = Multipart::new(); + let boundary = form.boundary().to_owned(); + let s = String::from_utf8(form.into_bytes()).unwrap(); + assert_eq!(s, format!("--{boundary}--\r\n")); + } }