use std::io;
use super::Request;
use crate::storage::Disk;
pub const MAX_UPLOAD: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UploadedFile {
pub field: String,
pub name: String,
pub content_type: String,
pub bytes: Vec<u8>,
}
impl UploadedFile {
pub fn extension(&self) -> Option<String> {
let (_, extension) = self.name.rsplit_once('.')?;
let valid = (1..=10).contains(&extension.len())
&& extension.bytes().all(|byte| byte.is_ascii_alphanumeric());
valid.then(|| extension.to_ascii_lowercase())
}
pub fn size(&self) -> usize {
self.bytes.len()
}
pub fn store(&self, disk: &Disk, folder: &str, allowed: &[&str]) -> io::Result<String> {
let extension = self
.extension()
.filter(|extension| allowed.iter().any(|ok| ok.eq_ignore_ascii_case(extension)))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{:?} is not a {} file", self.name, allowed.join(", ")),
)
})?;
let name = format!("{}.{extension}", crate::uuid::Uuid::v7());
let folder = folder.trim_matches('/');
let path = if folder.is_empty() {
name
} else {
format!("{folder}/{name}")
};
disk.put(&path, &self.bytes)?;
Ok(path)
}
}
impl Request {
pub fn file(&self, field: &str) -> Option<UploadedFile> {
self.files(field).into_iter().next()
}
pub fn files(&self, field: &str) -> Vec<UploadedFile> {
parts(self)
.unwrap_or_default()
.into_iter()
.filter(|part| part.name == field)
.filter_map(|part| {
let name = part.filename?;
(!name.is_empty()).then(|| UploadedFile {
field: part.name,
name,
content_type: part.content_type,
bytes: part.data.to_vec(),
})
})
.collect()
}
}
pub(super) fn form_field(request: &Request, key: &str) -> Option<Option<String>> {
let parts = parts(request)?;
Some(
parts
.into_iter()
.find(|part| part.filename.is_none() && part.name == key)
.map(|part| String::from_utf8_lossy(part.data).into_owned()),
)
}
pub(super) fn is_multipart(content_type: &str) -> bool {
content_type
.split(';')
.next()
.is_some_and(|kind| kind.trim().eq_ignore_ascii_case("multipart/form-data"))
}
struct Part<'a> {
name: String,
filename: Option<String>,
content_type: String,
data: &'a [u8],
}
fn parts(request: &Request) -> Option<Vec<Part<'_>>> {
let content_type = request.header("content-type")?;
if !is_multipart(content_type) {
return None;
}
let boundary = param(content_type, "boundary")?;
let delimiter = [b"\r\n--", boundary.as_bytes()].concat();
let body = [b"\r\n".as_slice(), &request.body].concat();
let mut rest = &request.body[..0];
if let Some(start) = find(&body, &delimiter) {
let offset = start + delimiter.len();
rest = &request.body[offset.saturating_sub(2)..];
}
let mut parts = Vec::new();
while let Some(after) = rest.strip_prefix(b"\r\n") {
let Some(head_end) = find(after, b"\r\n\r\n") else {
break;
};
let head = String::from_utf8_lossy(&after[..head_end]);
let content = &after[head_end + 4..];
let Some(end) = find(content, &delimiter) else {
break;
};
let mut part = Part {
name: String::new(),
filename: None,
content_type: String::new(),
data: &content[..end],
};
for line in head.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.trim().eq_ignore_ascii_case("content-disposition") {
part.name = param(value, "name").unwrap_or_default();
part.filename = param(value, "filename");
} else if key.trim().eq_ignore_ascii_case("content-type") {
part.content_type = value.trim().to_owned();
}
}
parts.push(part);
rest = &content[end + delimiter.len()..];
}
Some(parts)
}
fn param(header: &str, key: &str) -> Option<String> {
header.split(';').skip(1).find_map(|pair| {
let (name, value) = pair.split_once('=')?;
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(value);
name.trim()
.eq_ignore_ascii_case(key)
.then(|| value.to_owned())
})
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Disk;
fn request(body: &str) -> Request {
Request::post("/")
.with_header("Content-Type", "multipart/form-data; boundary=\"XyZ\"")
.with_body(body)
}
const BODY: &str = "preamble\r\n--XyZ\r\n\
Content-Disposition: form-data; name=\"_token\"\r\n\r\nabc\r\n\
--XyZ\r\n\
content-disposition: form-data; name=\"docs\"; filename=\"a.PDF\"\r\n\
Content-Type: application/pdf\r\n\r\nline one\r\nline two --XyZ\r\n\
--XyZ\r\n\
Content-Disposition: form-data; name=\"docs\"; filename=\"b.txt\"\r\n\r\n\r\n\
--XyZ\r\n\
Content-Disposition: form-data; name=\"empty\"; filename=\"\"\r\n\r\n\r\n\
--XyZ--\r\n";
#[test]
fn reads_text_fields_and_files() {
let request = request(BODY);
assert_eq!(request.form("_token").as_deref(), Some("abc"));
assert_eq!(request.form("docs"), None);
let docs = request.files("docs");
assert_eq!(docs.len(), 2);
assert_eq!(docs[0].name, "a.PDF");
assert_eq!(docs[0].content_type, "application/pdf");
assert_eq!(docs[0].bytes, b"line one\r\nline two --XyZ");
assert_eq!(docs[1].bytes, b"");
assert_eq!(request.file("empty"), None);
}
#[test]
fn url_encoded_forms_are_unchanged() {
let request = Request::post("/").with_body("a=1");
assert_eq!(request.form("a").as_deref(), Some("1"));
assert_eq!(request.file("a"), None);
}
#[test]
fn truncated_bodies_keep_the_complete_parts() {
let cut = &BODY[..BODY.find("b.txt").unwrap()];
let request = request(cut);
assert_eq!(request.form("_token").as_deref(), Some("abc"));
assert_eq!(request.files("docs").len(), 1);
}
#[test]
fn extension_is_checked_not_trusted() {
let file = |name: &str| UploadedFile {
field: "f".into(),
name: name.into(),
content_type: String::new(),
bytes: b"x".to_vec(),
};
assert_eq!(file("a.tar.GZ").extension().as_deref(), Some("gz"));
assert_eq!(file("noext").extension(), None);
assert_eq!(file("x.p/ng").extension(), None);
let root = std::env::temp_dir().join(format!("rustclamp-upload-{}", std::process::id()));
let disk = Disk::new(&root);
let saved = file("../../evil.PNG")
.store(&disk, "/avatars/", &["png"])
.unwrap();
assert!(saved.starts_with("avatars/") && saved.ends_with(".png"));
assert_eq!(disk.get(&saved).unwrap(), b"x");
let refused = file("page.html").store(&disk, "avatars", &["png", "jpg"]);
assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::InvalidInput);
std::fs::remove_dir_all(root).unwrap();
}
}