|
| 1 | +use anyhow::{bail, Context, Result}; |
| 2 | +use log::{trace, warn}; |
| 3 | +use std::io::{BufRead, BufReader, Cursor, Read}; |
| 4 | + |
| 5 | +#[derive(Debug, PartialEq, Eq)] |
| 6 | +pub struct MultipartBody { |
| 7 | + pub parts: Vec<MultipartBodyPart>, |
| 8 | +} |
| 9 | + |
| 10 | +#[derive(Debug, PartialEq, Eq)] |
| 11 | +pub struct MultipartBodyPart { |
| 12 | + pub name: String, |
| 13 | + pub filename: Option<String>, |
| 14 | + pub content_type: String, |
| 15 | + pub data: Vec<u8>, |
| 16 | +} |
| 17 | + |
| 18 | +impl MultipartBody { |
| 19 | + pub fn from_bytes(boundary: &str, bytes: &[u8]) -> Result<MultipartBody> { |
| 20 | + warn!("for now, only single part multipart body is supported... I know, that does not make sense"); |
| 21 | + let cursor = Cursor::new(bytes); |
| 22 | + let mut reader = BufReader::new(cursor); |
| 23 | + |
| 24 | + let actual_boundary = format!("--{}", boundary); |
| 25 | + |
| 26 | + let mut read_boundary = String::new(); |
| 27 | + let bytes_read = reader.read_line(&mut read_boundary)?; |
| 28 | + trace!("read boundary: {read_boundary:?} ({bytes_read} bytes)"); |
| 29 | + if !read_boundary.trim().eq(&actual_boundary) { |
| 30 | + bail!( |
| 31 | + "boundaries do not match: expected '{actual_boundary}' but got '{read_boundary}'" |
| 32 | + ); |
| 33 | + } |
| 34 | + |
| 35 | + let mut content_disposition = String::new(); |
| 36 | + let bytes_read = reader.read_line(&mut content_disposition)?; |
| 37 | + trace!("read content disposition: {content_disposition} ({bytes_read} bytes)"); |
| 38 | + let content_disposition = ContentDispositionHeader::from_line(&content_disposition)?; |
| 39 | + trace!("parse content_disposition: {content_disposition:?}"); |
| 40 | + |
| 41 | + let mut content_type = String::new(); |
| 42 | + let bytes_read = reader.read_line(&mut content_type)?; |
| 43 | + trace!("read content type: {content_type} ({bytes_read} bytes)"); |
| 44 | + let content_type = content_type |
| 45 | + .strip_prefix("Content-Type:") |
| 46 | + .context("expected Content-Type prefix")? |
| 47 | + .replace('"', "") |
| 48 | + .trim() |
| 49 | + .to_owned(); |
| 50 | + |
| 51 | + let mut empty_line = String::new(); |
| 52 | + let bytes_read = reader.read_line(&mut empty_line)?; |
| 53 | + trace!("read empty line: {bytes_read} bytes"); |
| 54 | + if !empty_line.trim().is_empty() { |
| 55 | + bail!("expected to read an empty line but got: {empty_line}"); |
| 56 | + } |
| 57 | + |
| 58 | + let mut buffer = Vec::new(); |
| 59 | + let bytes_read = reader.read_to_end(&mut buffer)?; |
| 60 | + trace!("read to end: {bytes_read} bytes"); |
| 61 | + |
| 62 | + let end_boundary = format!("{}--", actual_boundary); |
| 63 | + buffer.truncate(buffer.len() - end_boundary.len()); |
| 64 | + |
| 65 | + let res = MultipartBody { |
| 66 | + parts: vec![MultipartBodyPart { |
| 67 | + name: content_disposition.form_name, |
| 68 | + filename: content_disposition.filename, |
| 69 | + content_type, |
| 70 | + data: buffer, |
| 71 | + }], |
| 72 | + }; |
| 73 | + |
| 74 | + Ok(res) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +#[derive(Debug)] |
| 79 | +pub struct ContentDispositionHeader { |
| 80 | + pub form_name: String, |
| 81 | + pub filename: Option<String>, |
| 82 | +} |
| 83 | + |
| 84 | +impl ContentDispositionHeader { |
| 85 | + pub fn from_line(line: &str) -> Result<ContentDispositionHeader> { |
| 86 | + let data = line |
| 87 | + .strip_prefix("Content-Disposition:") |
| 88 | + .context("expected Content-Disposition prefix")?; |
| 89 | + |
| 90 | + let directives: Vec<_> = data.split(';').map(|d| d.trim()).collect(); |
| 91 | + let form_data_dir = *(directives |
| 92 | + .first() |
| 93 | + .context("directives should not be empty")?); |
| 94 | + if !form_data_dir.eq("form-data") { |
| 95 | + bail!( |
| 96 | + "expected first directive to be form-data but got: {}", |
| 97 | + form_data_dir |
| 98 | + ); |
| 99 | + } |
| 100 | + |
| 101 | + let name_dir = directives |
| 102 | + .iter() |
| 103 | + .find(|d| d.starts_with("name=")) |
| 104 | + .context("expected the name= directive")?; |
| 105 | + let name = name_dir |
| 106 | + .split_once('=') |
| 107 | + .context("name= directive should have a value")? |
| 108 | + .1 |
| 109 | + .replace('"', ""); |
| 110 | + |
| 111 | + let filename_dir = directives.iter().find(|d| d.starts_with("filename=")); |
| 112 | + |
| 113 | + let filename = match filename_dir { |
| 114 | + Some(val) => Some( |
| 115 | + val.split_once('=') |
| 116 | + .context("filename= directive should have a value")? |
| 117 | + .1 |
| 118 | + .replace('"', "") |
| 119 | + .to_owned(), |
| 120 | + ), |
| 121 | + None => None, |
| 122 | + }; |
| 123 | + |
| 124 | + Ok(ContentDispositionHeader { |
| 125 | + form_name: name.to_owned(), |
| 126 | + filename, |
| 127 | + }) |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +#[cfg(test)] |
| 132 | +mod tests { |
| 133 | + use super::*; |
| 134 | + |
| 135 | + #[test] |
| 136 | + fn test_multipart_body_single_text_part_ok() { |
| 137 | + let boundary = "ExampleBoundaryString"; |
| 138 | + let body = "--ExampleBoundaryString |
| 139 | +Content-Disposition: form-data; name=\"description\" |
| 140 | +Content-Type: text/html |
| 141 | +
|
| 142 | +This is a description |
| 143 | +--ExampleBoundaryString--" |
| 144 | + .as_bytes(); |
| 145 | + |
| 146 | + let actual = MultipartBody::from_bytes(boundary, &body).unwrap(); |
| 147 | + let expected = MultipartBody { |
| 148 | + parts: vec![MultipartBodyPart { |
| 149 | + name: "description".to_owned(), |
| 150 | + filename: None, |
| 151 | + content_type: "text/html".to_owned(), |
| 152 | + data: "This is a description\n".as_bytes().to_vec(), |
| 153 | + }], |
| 154 | + }; |
| 155 | + |
| 156 | + assert_eq!(expected, actual); |
| 157 | + } |
| 158 | + |
| 159 | + #[test] |
| 160 | + // TODO: This test checks for err because Multipart support right now is only with single part |
| 161 | + // In normal situation, the body in this function would denote a valid Multipart body |
| 162 | + fn test_mutlipart_body_multiple_parts_is_err() { |
| 163 | + let boundary = "--delimiter123"; |
| 164 | + let body = " |
| 165 | +--delimiter123 |
| 166 | +Content-Disposition: form-data; name=\"field1\" |
| 167 | +
|
| 168 | +value1 |
| 169 | +--delimiter123 |
| 170 | +Content-Disposition: form-data; name=\"field2\"; filename=\"example.txt\" |
| 171 | +
|
| 172 | +value2 |
| 173 | +--delimiter123--" |
| 174 | + .as_bytes(); |
| 175 | + |
| 176 | + assert!(MultipartBody::from_bytes(boundary, &body).is_err()); |
| 177 | + } |
| 178 | +} |
0 commit comments