rightkit_http/
multipart.rs1use 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
135fn sanitize(value: &str) -> String {
136 value
137 .chars()
138 .map(|c| match c {
139 '"' | '\r' | '\n' => '_',
140 other => other,
141 })
142 .collect()
143}
144
145fn validate_boundary(boundary: &str) -> Result<(), HttpError> {
146 let ok_len = (1..=70).contains(&boundary.len());
147 let ok_chars = boundary
148 .bytes()
149 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'));
150 if ok_len && ok_chars {
151 Ok(())
152 } else {
153 Err(HttpError::InvalidRequest(
154 "multipart boundary must be 1 to 70 ASCII letters, digits, '-', '_', '.' or '+'".into(),
155 ))
156 }
157}
158
159fn contains(haystack: &[u8], needle: &[u8]) -> bool {
160 !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
161}
162
163fn random_boundary() -> String {
164 let mut bytes = [0u8; 12];
165 if getrandom::fill(&mut bytes).is_err() {
166 let nanos = std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .map(|d| d.as_nanos())
169 .unwrap_or(0);
170 return format!("rightkit-fallback-{nanos:x}");
171 }
172 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
173 format!("rightkit-{hex}")
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn pinned_boundary_byte_shape() {
182 let body = Multipart::with_boundary("XB")
183 .text("a", "hi")
184 .file_bytes("f", "x.txt", "text/plain", b"DATA".to_vec())
185 .into_body()
186 .unwrap();
187 let expected = "--XB\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nhi\r\n\
188--XB\r\nContent-Disposition: form-data; name=\"f\"; filename=\"x.txt\"\r\n\
189Content-Type: text/plain\r\n\r\nDATA\r\n--XB--\r\n";
190 assert_eq!(String::from_utf8(body).unwrap(), expected);
191 }
192
193 #[test]
194 fn names_and_filenames_are_sanitised() {
195 let body = Multipart::with_boundary("XB")
196 .text("we\"ird\r\nname", "v")
197 .file_bytes("f", "a\"b\r\nc.txt", "text/plain", Vec::new())
198 .into_body()
199 .unwrap();
200 let text = String::from_utf8(body).unwrap();
201 assert!(text.contains("name=\"we_ird__name\""), "{text}");
202 assert!(text.contains("filename=\"a_b__c.txt\""), "{text}");
203 }
204
205 #[test]
206 fn boundary_inside_a_part_is_rejected() {
207 let err = Multipart::with_boundary("XB")
208 .text("a", "contains XB here")
209 .into_body()
210 .unwrap_err();
211 assert!(matches!(err, HttpError::InvalidRequest(_)));
212 }
213
214 #[test]
215 fn invalid_boundary_is_rejected() {
216 assert!(Multipart::with_boundary("").into_body().is_err());
217 assert!(Multipart::with_boundary("a b\"c").into_body().is_err());
218 assert!(Multipart::with_boundary("x".repeat(71))
219 .into_body()
220 .is_err());
221 }
222
223 #[test]
224 fn content_type_carries_boundary() {
225 assert_eq!(
226 Multipart::with_boundary("XB").content_type(),
227 "multipart/form-data; boundary=XB"
228 );
229 }
230
231 #[test]
232 fn request_multipart_sets_body_and_content_type() {
233 let req = Request::post("http://x/")
234 .set_header("content-type", "text/plain")
235 .multipart(Multipart::with_boundary("XB").text("a", "1"))
236 .unwrap();
237 assert_eq!(
238 crate::client::header_of(&req.headers, "content-type"),
239 Some("multipart/form-data; boundary=XB")
240 );
241 assert_eq!(
242 req.headers
243 .iter()
244 .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
245 .count(),
246 1
247 );
248 assert!(req.body.starts_with(b"--XB\r\n"));
249 }
250}