use crate::utils::trim_ows;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentDisposition<'a> {
pub name: Option<&'a [u8]>,
pub file_name: Option<&'a [u8]>,
}
#[must_use]
pub fn parse_content_disposition(value: &[u8]) -> Option<ContentDisposition<'_>> {
let input = trim_ows(value);
let semicolon = memchr::memchr(b';', input);
let disposition = match semicolon {
Some(idx) => trim_ows(&input[..idx]),
None => input,
};
if !disposition.eq_ignore_ascii_case(b"form-data") {
return None;
}
let Some(semicolon) = semicolon else {
return Some(ContentDisposition {
name: None,
file_name: None,
});
};
let mut name = None;
let mut file_name = None;
let mut rest = &input[semicolon.saturating_add(1)..];
while !trim_ows(rest).is_empty() {
let Some((key, parsed_value, next)) = parse_parameter(rest) else {
break;
};
if name.is_none() && key.eq_ignore_ascii_case(b"name") {
name = Some(parsed_value);
} else if file_name.is_none() && key.eq_ignore_ascii_case(b"filename") {
file_name = Some(parsed_value);
}
rest = next;
}
Some(ContentDisposition { name, file_name })
}
fn parse_parameter(input: &[u8]) -> Option<(&[u8], &[u8], &[u8])> {
let input = trim_ows(input);
let equals = memchr::memchr(b'=', input)?;
let key = trim_ows(&input[..equals]);
if key.is_empty() {
return None;
}
let value_input = trim_ows(&input[equals.saturating_add(1)..]);
let (value, next) = if value_input.first() == Some(&b'"') {
let value = parse_quoted(value_input)?;
let next = skip_until_semicolon(value.next);
(value.text, next)
} else {
let semicolon = memchr::memchr(b';', value_input);
match semicolon {
Some(idx) => (trim_ows(&value_input[..idx]), &value_input[idx.saturating_add(1)..]),
None => (trim_ows(value_input), &b""[..]),
}
};
Some((key, value, next))
}
fn parse_quoted(input: &[u8]) -> Option<ParsedQuoted<'_>> {
if input.first() != Some(&b'"') {
return None;
}
let idx = memchr::memchr2(b'"', b'\\', &input[1..])?;
let pos = idx.saturating_add(1);
if input[pos] == b'"' {
return Some(ParsedQuoted {
text: &input[1..pos],
next: &input[pos.saturating_add(1)..],
});
}
let mut escaped = false;
let mut end = None;
for (offset, byte) in input[pos..].iter().enumerate() {
if escaped {
escaped = false;
} else if *byte == b'\\' {
escaped = true;
} else if *byte == b'"' {
end = Some(pos.saturating_add(offset));
break;
}
}
let end = end?;
Some(ParsedQuoted {
text: &input[1..end],
next: &input[end.saturating_add(1)..],
})
}
fn skip_until_semicolon(input: &[u8]) -> &[u8] {
match memchr::memchr(b';', input) {
Some(idx) => &input[idx.saturating_add(1)..],
None => &b""[..],
}
}
struct ParsedQuoted<'a> {
text: &'a [u8],
next: &'a [u8],
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unreachable, clippy::unwrap_used)]
mod tests {
use super::*;
#[allow(clippy::type_complexity)]
fn parse(value: &[u8]) -> Option<(Option<&[u8]>, Option<&[u8]>)> {
let cd = parse_content_disposition(value)?;
Some((cd.name, cd.file_name))
}
#[allow(clippy::type_complexity, clippy::unnecessary_wraps)]
fn expected<'a>(name: Option<&'a [u8]>, file_name: Option<&'a [u8]>) -> Option<(Option<&'a [u8]>, Option<&'a [u8]>)> {
Some((name, file_name))
}
#[test]
fn parses_canonical_form() {
assert_eq!(
parse(b"form-data; name=\"file\"; filename=\"a.txt\""),
expected(Some(b"file"), Some(b"a.txt"))
);
}
#[test]
fn accepts_parameter_order_and_case() {
assert_eq!(
parse(b"FORM-DATA; FILENAME=\"a.txt\"; NAME=\"file\""),
expected(Some(b"file"), Some(b"a.txt"))
);
}
#[test]
fn accepts_token_values() {
assert_eq!(parse(b"form-data; name=file; filename=a.txt"), expected(Some(b"file"), Some(b"a.txt")));
}
#[test]
fn ignores_unknown_parameters() {
assert_eq!(
parse(b"form-data; size=10; name=\"file\"; filename*=UTF-8''a.txt; x=y"),
expected(Some(b"file"), None)
);
}
#[test]
fn handles_escaped_quote_without_early_termination() {
assert_eq!(
parse(b"form-data; name=\"fi\\\"le\"; filename=\"a.txt\""),
expected(Some(b"fi\\\"le"), Some(b"a.txt"))
);
}
#[test]
fn handles_missing_name() {
assert_eq!(parse(b"form-data; filename=\"a.txt\""), expected(None, Some(b"a.txt")));
assert_eq!(parse(b"form-data"), expected(None, None));
}
#[test]
fn rejects_non_form_data() {
assert_eq!(parse(b"attachment; filename=\"a.txt\""), None);
assert_eq!(parse(b""), None);
}
#[test]
fn tolerates_optional_whitespace() {
assert_eq!(
parse(b" form-data ; name = \"file\" ; filename = \"a.txt\" "),
expected(Some(b"file"), Some(b"a.txt"))
);
assert_eq!(parse(b"form-data; name=\" a \""), expected(Some(b" a "), None));
assert_eq!(parse(b"form-data; name=\"\ta\t\""), expected(Some(b"\ta\t"), None));
}
#[test]
fn quoted_value_is_returned_verbatim() {
assert_eq!(parse(b"form-data; name=\"a\\tb\""), expected(Some(b"a\\tb"), None));
}
#[test]
fn malformed_tail_parameter_stops_parsing() {
assert_eq!(parse(b"form-data; name=\"file\"; badparam"), expected(Some(b"file"), None));
}
#[test]
fn unterminated_quoted_value_stops_parsing() {
assert_eq!(parse(b"form-data; name=\"unterminated"), expected(None, None));
assert_eq!(parse(b"form-data; name=\"file\"; filename=\"a.txt"), expected(Some(b"file"), None));
assert_eq!(parse(b"form-data; name=\"abc\\"), expected(None, None));
}
#[test]
fn first_duplicate_parameter_wins() {
assert_eq!(parse(b"form-data; name=\"one\"; name=\"two\""), expected(Some(b"one"), None));
assert_eq!(
parse(b"form-data; name=\"file\"; filename=\"a\"; filename=\"b\""),
expected(Some(b"file"), Some(b"a"))
);
}
#[test]
fn parameter_helpers_reject_invalid_shapes() {
assert_eq!(parse_parameter(b"=value"), None);
assert!(parse_quoted(b"not-quoted").is_none());
}
}