Skip to main content

typed_openapi/
multipart.rs

1//! Encoding a `multipart/form-data` body (RFC 7578).
2//!
3//! Pure: parts in, bytes and a `Content-Type` out. The boundary is derived from
4//! the content rather than drawn at random, so the same parts always encode to
5//! the same bytes — which is what makes a dry run worth reading and a recorded
6//! test worth asserting on.
7
8use crate::values::Part;
9
10/// The bytes and the `Content-Type` header value they must be sent under.
11#[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/// Encode `parts` under a boundary that appears in none of them.
20#[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
41/// A header line whose value cannot break out of the header: `"` and the two
42/// line-ending bytes are the only characters that could, and RFC 7578 §5.1
43/// sanctions percent-encoding them.
44fn 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
66/// The first `STEM-<n>` that occurs in no part. Deterministic, and it
67/// terminates: each candidate that collides rules out at least one occurrence
68/// in a finite body.
69fn 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}