typed_openapi/
multipart.rs1use crate::values::Part;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Encoded {
13 pub content_type: String,
14 pub bytes: Vec<u8>,
15}
16
17const STEM: &str = "----typed-openapi-boundary";
18
19#[must_use]
21pub fn encode(parts: &[Part]) -> Encoded {
22 let boundary = boundary(parts);
23 let mut bytes = Vec::new();
24 for part in parts {
25 bytes.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
26 bytes.extend_from_slice(content_disposition(part.name(), part.filename()).as_bytes());
27 if part.filename().is_some() {
28 bytes.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
29 }
30 bytes.extend_from_slice(b"\r\n");
31 bytes.extend_from_slice(part.bytes());
32 bytes.extend_from_slice(b"\r\n");
33 }
34 bytes.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
35 Encoded {
36 content_type: format!("multipart/form-data; boundary={boundary}"),
37 bytes,
38 }
39}
40
41fn content_disposition(name: &str, filename: Option<&str>) -> String {
45 use std::fmt::Write as _;
46
47 let mut line = format!("Content-Disposition: form-data; name=\"{}\"", quoted(name));
48 if let Some(filename) = filename {
49 let _ = write!(line, "; filename=\"{}\"", quoted(filename));
50 }
51 line.push_str("\r\n");
52 line
53}
54
55fn quoted(raw: &str) -> String {
56 raw.chars()
57 .map(|c| match c {
58 '"' => "%22".to_owned(),
59 '\r' => "%0D".to_owned(),
60 '\n' => "%0A".to_owned(),
61 other => other.to_string(),
62 })
63 .collect()
64}
65
66fn boundary(parts: &[Part]) -> String {
70 (0..u32::MAX)
71 .map(|n| format!("{STEM}-{n}"))
72 .find(|candidate| {
73 !parts
74 .iter()
75 .any(|part| contains(part.bytes(), candidate.as_bytes()))
76 })
77 .unwrap_or_else(|| STEM.to_owned())
78}
79
80fn contains(haystack: &[u8], needle: &[u8]) -> bool {
81 haystack
82 .windows(needle.len())
83 .any(|window| window == needle)
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn a_file_part_carries_its_filename_and_a_type() {
92 let encoded = encode(&[Part::file("file", "doc.bin", b"PDF".to_vec())]);
93 let text = String::from_utf8_lossy(&encoded.bytes).into_owned();
94 assert!(
95 text.contains("Content-Disposition: form-data; name=\"file\"; filename=\"doc.bin\""),
96 "{text}"
97 );
98 assert!(
99 text.contains("Content-Type: application/octet-stream"),
100 "{text}"
101 );
102 assert!(text.ends_with("--\r\n"), "{text}");
103 }
104
105 #[test]
106 fn a_text_part_carries_neither() {
107 let encoded = encode(&[Part::text("kind", "invoice")]);
108 let text = String::from_utf8_lossy(&encoded.bytes).into_owned();
109 assert!(!text.contains("filename"), "{text}");
110 assert!(!text.contains("Content-Type:"), "{text}");
111 assert!(text.contains("\r\n\r\ninvoice\r\n"), "{text}");
112 }
113
114 #[test]
115 fn the_boundary_moves_aside_for_content_that_contains_it() {
116 let colliding = format!("{STEM}-0").into_bytes();
117 let encoded = encode(&[Part::file("file", "f", colliding)]);
118 assert!(
119 encoded.content_type.ends_with(&format!("{STEM}-1")),
120 "{}",
121 encoded.content_type
122 );
123 }
124
125 #[test]
126 fn the_same_parts_encode_to_the_same_bytes() {
127 let parts = [
128 Part::text("a", "1"),
129 Part::file("f", "x.bin", vec![0, 1, 2]),
130 ];
131 assert_eq!(encode(&parts), encode(&parts));
132 }
133
134 #[test]
135 fn a_quote_in_a_name_cannot_break_out_of_the_header() {
136 let encoded = encode(&[Part::file("f", "a\"b\r\nX: y", Vec::new())]);
137 let text = String::from_utf8_lossy(&encoded.bytes).into_owned();
138 assert!(text.contains("filename=\"a%22b%0D%0AX: y\""), "{text}");
139 }
140}