Skip to main content

sendra_core/request/
multipart.rs

1//! [`MultipartPart`] and the hand-rolled `multipart/form-data` encoder
2//! [`crate::Request::resolve_body`] uses for [`crate::Request::multipart`].
3
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::SendraError;
9
10/// One part of a [`crate::Request::multipart`] body: either inline text (`value`) or
11/// a file (`path`), never both and never neither — enforced by
12/// [`crate::Request::validate`].
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(deny_unknown_fields)]
16pub struct MultipartPart {
17    pub name: String,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub value: Option<String>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub path: Option<String>,
22}
23
24/// Read a `body_file` (or a multipart file part) relative to `base_dir`, as
25/// UTF-8 text.
26///
27/// A non-UTF-8 file surfaces as [`SendraError::BodyFileIo`] wrapping an
28/// `InvalidData` error, matching what `std::fs::read_to_string` itself
29/// returns for the same failure, rather than a silent lossy conversion —
30/// unlike a *response* body, which Sendra has never promised to send
31/// unmodified.
32pub(crate) fn read_body_file(base_dir: &Path, path: &str) -> Result<String, SendraError> {
33    let full_path = base_dir.join(path);
34    std::fs::read_to_string(&full_path).map_err(|source| SendraError::BodyFileIo {
35        path: full_path,
36        source,
37    })
38}
39
40/// Encode a `multipart` body by hand, as `multipart/form-data` text, and
41/// return it along with the `Content-Type` (boundary included) it implies.
42///
43/// Not built with `reqwest::multipart::Form`: that type holds arbitrary
44/// bytes and cannot be cloned, `PartialEq`d or serialized, none of which
45/// [`crate::Request`] can give up — it is `Clone`, `PartialEq`, `Serialize` and
46/// `Deserialize` throughout, including in the config/substitution/scripting
47/// pipeline a multipart request passes through like any other. Writing the
48/// format directly keeps the whole body a `String`, consistent with
49/// [`resolve_body`](crate::Request::resolve_body)'s UTF-8-text rule for
50/// `body_file`.
51///
52/// The boundary is derived from the current time, which is unique enough
53/// per-request for a boundary's actual job: a delimiter unlikely to occur
54/// inside any part's own content, not a cryptographic guarantee.
55pub(crate) fn encode_multipart(
56    parts: &[MultipartPart],
57    base_dir: &Path,
58) -> Result<(String, String), SendraError> {
59    let boundary = format!(
60        "----sendra-{:x}",
61        std::time::SystemTime::now()
62            .duration_since(std::time::UNIX_EPOCH)
63            .unwrap_or_default()
64            .as_nanos()
65    );
66
67    let mut body = String::new();
68    for part in parts {
69        body.push_str("--");
70        body.push_str(&boundary);
71        body.push_str("\r\n");
72        match (&part.value, &part.path) {
73            (Some(value), None) => {
74                body.push_str(&format!(
75                    "Content-Disposition: form-data; name=\"{}\"\r\n\r\n",
76                    part.name
77                ));
78                body.push_str(value);
79            }
80            (None, Some(path)) => {
81                let filename = Path::new(path)
82                    .file_name()
83                    .and_then(|name| name.to_str())
84                    .unwrap_or(path);
85                body.push_str(&format!(
86                    "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n\r\n",
87                    part.name, filename
88                ));
89                body.push_str(&read_body_file(base_dir, path)?);
90            }
91            // Ruled out by `Request::validate` before `resolve_body` is ever
92            // reached; kept exhaustive rather than `unreachable!()` so a
93            // future caller of `encode_multipart` that skips validation gets
94            // an empty part instead of a panic.
95            (Some(_), Some(_)) | (None, None) => {}
96        }
97        body.push_str("\r\n");
98    }
99    body.push_str("--");
100    body.push_str(&boundary);
101    body.push_str("--\r\n");
102
103    let content_type = format!("multipart/form-data; boundary={boundary}");
104    Ok((body, content_type))
105}