use aimux_core::AiMuxError;
#[derive(Debug)]
pub struct MultipartForm {
boundary: String,
parts: Vec<u8>,
}
impl MultipartForm {
pub fn new() -> Self {
let boundary = format!(
"----formdata-aimux-{}",
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
);
Self {
boundary,
parts: Vec::new(),
}
}
pub fn text(&mut self, name: &str, value: &str) -> Result<&mut Self, AiMuxError> {
validate_multipart_param(name, "name")?;
self.parts
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
self.parts.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
);
self.parts.extend_from_slice(value.as_bytes());
self.parts.extend_from_slice(b"\r\n");
Ok(self)
}
pub fn file(
&mut self,
name: &str,
filename: &str,
media_type: &str,
data: &[u8],
) -> Result<&mut Self, AiMuxError> {
validate_multipart_param(name, "name")?;
validate_multipart_param(filename, "filename")?;
validate_multipart_param(media_type, "media_type")?;
self.parts
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
self.parts.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n")
.as_bytes(),
);
self.parts
.extend_from_slice(format!("Content-Type: {media_type}\r\n\r\n").as_bytes());
self.parts.extend_from_slice(data);
self.parts.extend_from_slice(b"\r\n");
Ok(self)
}
pub fn finish(mut self) -> (Vec<u8>, String) {
self.parts
.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
let content_type = format!("multipart/form-data; boundary={}", self.boundary);
(self.parts, content_type)
}
}
impl Default for MultipartForm {
fn default() -> Self {
Self::new()
}
}
pub fn media_type_to_extension(media_type: &str) -> String {
media_type
.strip_prefix("audio/")
.unwrap_or(media_type)
.to_string()
}
fn validate_multipart_param(value: &str, label: &str) -> Result<(), AiMuxError> {
if value
.as_bytes()
.iter()
.any(|&b| b == b'"' || b == b'\r' || b == b'\n' || b == 0)
{
return Err(AiMuxError::InvalidArgument(format!(
"invalid multipart {label}: must not contain '\"', CR, LF or NUL"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_param_rejected(value: &str) {
assert!(
validate_multipart_param(value, "name").is_err(),
"expected value to be rejected: {value:?}"
);
}
#[test]
fn validate_accepts_safe_params() {
assert!(validate_multipart_param("model", "name").is_ok());
assert!(validate_multipart_param("file", "name").is_ok());
assert!(validate_multipart_param("audio.wav", "filename").is_ok());
assert!(validate_multipart_param("audio/wav", "media_type").is_ok());
assert!(validate_multipart_param("timestamp_granularities[]", "name").is_ok());
}
#[test]
fn validate_rejects_quote() {
assert_param_rejected("evil\"");
}
#[test]
fn validate_rejects_crlf() {
assert_param_rejected("evil\r\nX-Injected: yes");
assert_param_rejected("line1\nline2");
assert_param_rejected("line1\rline2");
}
#[test]
fn validate_rejects_nul() {
assert_param_rejected("evil\u{0}more");
}
#[test]
fn text_rejects_malicious_name() {
let mut form = MultipartForm::new();
let err = form.text("evil\"", "value").unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)), "got {err:?}");
assert!(err.to_string().contains("multipart name"));
let mut form = MultipartForm::new();
let err = form.text("evil\r\nX-Injected: yes", "value").unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)));
let mut form = MultipartForm::new();
let _ = form.text("evil\r\n", "value");
assert!(
form.parts.is_empty(),
"parts must remain empty on rejection"
);
}
#[test]
fn file_rejects_malicious_filename_and_media_type() {
let mut form = MultipartForm::new();
let err = form
.file("file", "evil\".wav", "audio/wav", b"data")
.unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)));
assert!(err.to_string().contains("multipart filename"));
let mut form = MultipartForm::new();
let err = form
.file("file", "evil\r\nX-Injected: yes", "audio/wav", b"data")
.unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)));
let mut form = MultipartForm::new();
let err = form
.file("file", "audio.wav", "audio/wav\r\nX-Injected: yes", b"data")
.unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)));
assert!(err.to_string().contains("multipart media_type"));
let mut form = MultipartForm::new();
let _ = form.file("evil\"", "audio.wav", "audio/wav", b"data");
assert!(
form.parts.is_empty(),
"parts must remain empty on rejection"
);
}
#[test]
fn text_and_file_accept_valid_fields() {
let mut form = MultipartForm::new();
form.text("model", "whisper-1").unwrap();
form.file("file", "audio.wav", "audio/wav", b"data")
.unwrap();
form.text("diarize", "true").unwrap();
let (body, content_type) = form.finish();
assert!(content_type.starts_with("multipart/form-data; boundary="));
let body = String::from_utf8(body).unwrap();
assert!(body.contains("name=\"model\""));
assert!(body.contains("filename=\"audio.wav\""));
assert!(body.contains("Content-Type: audio/wav"));
assert!(body.contains("diarize"));
assert!(!body.contains("X-Injected"));
}
}