1use std::path::Path;
8
9use crate::client::Request;
10use crate::error::HttpError;
11
12#[derive(Debug, Clone)]
13pub struct Multipart {
14 boundary: String,
15 parts: Vec<Part>,
16}
17
18#[derive(Debug, Clone)]
19struct Part {
20 name: String,
21 filename: Option<String>,
22 content_type: Option<String>,
23 body: Vec<u8>,
24}
25
26impl Multipart {
27 pub fn new() -> Self {
29 Self {
30 boundary: random_boundary(),
31 parts: Vec::new(),
32 }
33 }
34 pub fn with_boundary(boundary: impl Into<String>) -> Self {
37 Self {
38 boundary: boundary.into(),
39 parts: Vec::new(),
40 }
41 }
42 pub fn text(mut self, name: &str, value: &str) -> Self {
44 self.parts.push(Part {
45 name: sanitize(name),
46 filename: None,
47 content_type: None,
48 body: value.as_bytes().to_vec(),
49 });
50 self
51 }
52 pub fn file_bytes(
54 mut self,
55 name: &str,
56 filename: &str,
57 content_type: &str,
58 bytes: impl Into<Vec<u8>>,
59 ) -> Self {
60 self.parts.push(Part {
61 name: sanitize(name),
62 filename: Some(sanitize(filename)),
63 content_type: Some(sanitize(content_type)),
64 body: bytes.into(),
65 });
66 self
67 }
68 pub fn file_path(self, name: &str, path: &Path, content_type: &str) -> Result<Self, HttpError> {
70 let filename = path
71 .file_name()
72 .and_then(|f| f.to_str())
73 .ok_or_else(|| {
74 HttpError::InvalidRequest("multipart file path has no UTF-8 file name".into())
75 })?
76 .to_string();
77 let bytes = std::fs::read(path)
78 .map_err(|e| HttpError::InvalidRequest(format!("cannot read multipart file: {e}")))?;
79 Ok(self.file_bytes(name, &filename, content_type, bytes))
80 }
81 pub fn content_type(&self) -> String {
83 format!("multipart/form-data; boundary={}", self.boundary)
84 }
85 pub fn into_body(self) -> Result<Vec<u8>, HttpError> {
87 validate_boundary(&self.boundary)?;
88 let boundary = self.boundary.as_bytes();
89 let delimiter = format!("--{}", self.boundary);
90 let mut out = Vec::new();
91 for part in &self.parts {
92 let mut head = format!("Content-Disposition: form-data; name=\"{}\"", part.name);
93 if let Some(filename) = &part.filename {
94 head.push_str(&format!("; filename=\"{filename}\""));
95 }
96 head.push_str("\r\n");
97 if let Some(content_type) = &part.content_type {
98 head.push_str(&format!("Content-Type: {content_type}\r\n"));
99 }
100 if contains(head.as_bytes(), boundary) || contains(&part.body, boundary) {
101 return Err(HttpError::InvalidRequest(
102 "multipart boundary occurs inside a part; use another boundary".into(),
103 ));
104 }
105 out.extend_from_slice(delimiter.as_bytes());
106 out.extend_from_slice(b"\r\n");
107 out.extend_from_slice(head.as_bytes());
108 out.extend_from_slice(b"\r\n");
109 out.extend_from_slice(&part.body);
110 out.extend_from_slice(b"\r\n");
111 }
112 out.extend_from_slice(delimiter.as_bytes());
113 out.extend_from_slice(b"--\r\n");
114 Ok(out)
115 }
116}
117
118impl Default for Multipart {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl Request {
125 pub fn multipart(self, form: Multipart) -> Result<Self, HttpError> {
127 let content_type = form.content_type();
128 let body = form.into_body()?;
129 let mut req = self.set_header("Content-Type", content_type);
130 req.body = body;
131 Ok(req)
132 }
133}
134
135#[derive(Debug, Clone)]
139pub struct MultipartRelated {
140 boundary: String,
141 parts: Vec<(String, Vec<u8>)>,
142}
143
144impl MultipartRelated {
145 pub fn new() -> Self {
147 Self {
148 boundary: random_boundary(),
149 parts: Vec::new(),
150 }
151 }
152 pub fn with_boundary(boundary: impl Into<String>) -> Self {
154 Self {
155 boundary: boundary.into(),
156 parts: Vec::new(),
157 }
158 }
159 pub fn part(mut self, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
161 self.parts.push((sanitize(content_type), body.into()));
162 self
163 }
164 pub fn content_type(&self) -> String {
166 format!("multipart/related; boundary={}", self.boundary)
167 }
168 pub fn into_body(self) -> Result<Vec<u8>, HttpError> {
170 validate_boundary(&self.boundary)?;
171 let boundary = self.boundary.as_bytes();
172 let delimiter = format!("--{}", self.boundary);
173 let mut out = Vec::new();
174 for (content_type, body) in &self.parts {
175 let head = format!("Content-Type: {content_type}\r\n");
176 if contains(head.as_bytes(), boundary) || contains(body, boundary) {
177 return Err(HttpError::InvalidRequest(
178 "multipart boundary occurs inside a part; use another boundary".into(),
179 ));
180 }
181 out.extend_from_slice(delimiter.as_bytes());
182 out.extend_from_slice(b"\r\n");
183 out.extend_from_slice(head.as_bytes());
184 out.extend_from_slice(b"\r\n");
185 out.extend_from_slice(body);
186 out.extend_from_slice(b"\r\n");
187 }
188 out.extend_from_slice(delimiter.as_bytes());
189 out.extend_from_slice(b"--\r\n");
190 Ok(out)
191 }
192}
193
194impl Default for MultipartRelated {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl Request {
201 pub fn multipart_related(self, form: MultipartRelated) -> Result<Self, HttpError> {
204 let content_type = form.content_type();
205 let body = form.into_body()?;
206 let mut req = self.set_header("Content-Type", content_type);
207 req.body = body;
208 Ok(req)
209 }
210}
211
212fn sanitize(value: &str) -> String {
213 value
214 .chars()
215 .map(|c| match c {
216 '"' | '\r' | '\n' => '_',
217 other => other,
218 })
219 .collect()
220}
221
222fn validate_boundary(boundary: &str) -> Result<(), HttpError> {
223 let ok_len = (1..=70).contains(&boundary.len());
224 let ok_chars = boundary
225 .bytes()
226 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'));
227 if ok_len && ok_chars {
228 Ok(())
229 } else {
230 Err(HttpError::InvalidRequest(
231 "multipart boundary must be 1 to 70 ASCII letters, digits, '-', '_', '.' or '+'".into(),
232 ))
233 }
234}
235
236fn contains(haystack: &[u8], needle: &[u8]) -> bool {
237 !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
238}
239
240fn random_boundary() -> String {
241 let mut bytes = [0u8; 12];
242 if getrandom::fill(&mut bytes).is_err() {
243 let nanos = std::time::SystemTime::now()
244 .duration_since(std::time::UNIX_EPOCH)
245 .map(|d| d.as_nanos())
246 .unwrap_or(0);
247 return format!("rightkit-fallback-{nanos:x}");
248 }
249 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
250 format!("rightkit-{hex}")
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn pinned_boundary_byte_shape() {
259 let body = Multipart::with_boundary("XB")
260 .text("a", "hi")
261 .file_bytes("f", "x.txt", "text/plain", b"DATA".to_vec())
262 .into_body()
263 .unwrap();
264 let expected = "--XB\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nhi\r\n\
265--XB\r\nContent-Disposition: form-data; name=\"f\"; filename=\"x.txt\"\r\n\
266Content-Type: text/plain\r\n\r\nDATA\r\n--XB--\r\n";
267 assert_eq!(String::from_utf8(body).unwrap(), expected);
268 }
269
270 #[test]
271 fn names_and_filenames_are_sanitised() {
272 let body = Multipart::with_boundary("XB")
273 .text("we\"ird\r\nname", "v")
274 .file_bytes("f", "a\"b\r\nc.txt", "text/plain", Vec::new())
275 .into_body()
276 .unwrap();
277 let text = String::from_utf8(body).unwrap();
278 assert!(text.contains("name=\"we_ird__name\""), "{text}");
279 assert!(text.contains("filename=\"a_b__c.txt\""), "{text}");
280 }
281
282 #[test]
283 fn boundary_inside_a_part_is_rejected() {
284 let err = Multipart::with_boundary("XB")
285 .text("a", "contains XB here")
286 .into_body()
287 .unwrap_err();
288 assert!(matches!(err, HttpError::InvalidRequest(_)));
289 }
290
291 #[test]
292 fn invalid_boundary_is_rejected() {
293 assert!(Multipart::with_boundary("").into_body().is_err());
294 assert!(Multipart::with_boundary("a b\"c").into_body().is_err());
295 assert!(Multipart::with_boundary("x".repeat(71))
296 .into_body()
297 .is_err());
298 }
299
300 #[test]
301 fn content_type_carries_boundary() {
302 assert_eq!(
303 Multipart::with_boundary("XB").content_type(),
304 "multipart/form-data; boundary=XB"
305 );
306 }
307
308 #[test]
309 fn request_multipart_sets_body_and_content_type() {
310 let req = Request::post("http://x/")
311 .set_header("content-type", "text/plain")
312 .multipart(Multipart::with_boundary("XB").text("a", "1"))
313 .unwrap();
314 assert_eq!(
315 crate::client::header_of(&req.headers, "content-type"),
316 Some("multipart/form-data; boundary=XB")
317 );
318 assert_eq!(
319 req.headers
320 .iter()
321 .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
322 .count(),
323 1
324 );
325 assert!(req.body.starts_with(b"--XB\r\n"));
326 }
327
328 #[test]
329 fn related_body_frames_each_part_with_its_content_type() {
330 let body = MultipartRelated::with_boundary("b1")
331 .part("application/json; charset=UTF-8", "{\"name\":\"x\"}")
332 .part("message/rfc822", "Subject: hi\r\n\r\nbody")
333 .into_body()
334 .unwrap();
335 let text = String::from_utf8(body).unwrap();
336 assert_eq!(
337 text,
338 "--b1\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{\"name\":\"x\"}\r\n\
339 --b1\r\nContent-Type: message/rfc822\r\n\r\nSubject: hi\r\n\r\nbody\r\n--b1--\r\n"
340 );
341 assert_eq!(
342 MultipartRelated::with_boundary("b1").content_type(),
343 "multipart/related; boundary=b1"
344 );
345 assert!(MultipartRelated::with_boundary("b1")
346 .part("text/plain", "has b1 inside")
347 .into_body()
348 .is_err());
349 }
350}