use std::{iter::Peekable, str::Chars};
#[derive(Debug, PartialEq, Eq)]
pub struct ContentDisposition {
pub filename: Option<String>,
}
impl ContentDisposition {
#[must_use]
pub fn parse(header_value: &str) -> Self {
let mut filename = None;
let mut filename_star = None;
let rest = match header_value.find(';') {
Some(idx) => &header_value[idx + 1..],
None => return Self { filename: None },
};
let mut chars = rest.chars().peekable();
while chars.peek().is_some() {
Self::consume_whitespace(&mut chars);
let key = Self::read_key(&mut chars);
if key.is_empty() {
match chars.peek() {
Some(';') => {
chars.next();
continue;
}
_ => break,
}
}
match chars.peek() {
Some('=') => {
chars.next(); }
Some(';') => {
chars.next();
continue;
}
None => break, _ => {
Self::skip_until(&mut chars, ';');
continue;
}
}
Self::consume_whitespace(&mut chars);
let value = match chars.peek() {
Some('"') => {
chars.next(); Self::read_quoted_string(&mut chars)
}
_ => Self::read_token(&mut chars),
};
Self::consume_whitespace(&mut chars);
if matches!(chars.peek(), Some(';')) {
chars.next();
}
if key.eq_ignore_ascii_case("filename") {
filename = Some(value);
} else if key.eq_ignore_ascii_case("filename*") {
filename_star = Self::parse_filename_star(&value);
}
}
Self {
filename: filename_star.or(filename),
}
}
fn consume_whitespace(chars: &mut Peekable<Chars<'_>>) {
while let Some(c) = chars.peek()
&& c.is_whitespace()
{
chars.next();
}
}
fn read_key(chars: &mut Peekable<Chars<'_>>) -> String {
let mut s = String::new();
while let Some(&c) = chars.peek()
&& c != '='
&& c != ';'
{
s.push(c);
chars.next();
}
s.trim().to_string()
}
fn read_token(chars: &mut Peekable<Chars<'_>>) -> String {
let mut s = String::new();
while let Some(&c) = chars.peek()
&& c != '='
&& c != ';'
&& !c.is_whitespace()
{
s.push(c);
chars.next();
}
s
}
fn read_quoted_string(chars: &mut Peekable<Chars<'_>>) -> String {
let mut s = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => {
if let Some(escaped) = chars.next() {
s.push(escaped);
}
}
_ => s.push(c),
}
}
s
}
fn skip_until(chars: &mut Peekable<Chars<'_>>, target: char) {
for c in chars.by_ref() {
if c == target {
break;
}
}
}
fn parse_filename_star(val: &str) -> Option<String> {
let mut parts = val.splitn(3, '\'');
let charset = parts.next()?;
parts.next()?;
let encoded_text = parts.next()?;
if charset.eq_ignore_ascii_case("UTF-8") {
urlencoding::decode(encoded_text)
.ok()
.map(|s| s.to_string())
} else {
None
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn test_multiple_params_no_semicolon() {
let s = "attachment; filename=foo.txt size=10";
let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), "foo.txt");
}
#[test]
fn test_quoted_with_spaces() {
let s = r#"attachment; filename="foo\" bar.txt"; size=10"#;
let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), "foo\" bar.txt");
}
#[test]
fn test_flag_parameter() {
let s = r#"attachment; hidden; filename="test.txt""#;
let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), "test.txt");
}
#[test]
fn test_complex_filename_star() {
let s = "attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt";
let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), "测试.txt");
let s = r#"attachment; filename=";;;"; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt"#;
let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), "测试.txt");
}
#[test]
fn test_empty_values() {
let s = r#"attachment; filename=";\";;"; filename*=""#; let cd = ContentDisposition::parse(s);
assert_eq!(cd.filename.unwrap(), ";\";;");
}
#[test]
fn test_no_semicolon_is_none() {
assert_eq!(ContentDisposition::parse("attachment").filename, None);
}
#[test]
fn test_empty_header() {
assert_eq!(ContentDisposition::parse("").filename, None);
assert_eq!(ContentDisposition::parse(" ").filename, None);
}
#[test]
fn test_filename_star_non_utf8_ignored() {
let s = "attachment; filename*=ISO-8859-1''%A3.txt";
assert_eq!(ContentDisposition::parse(s).filename, None);
}
#[test]
fn test_filename_star_wins_over_filename() {
let s = r#"attachment; filename="old.txt"; filename*=UTF-8''%E6%B5%8B.txt"#;
assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "测.txt");
}
#[test]
fn test_unquoted_token_stops_at_space() {
let s = "attachment; filename=foo bar.txt";
assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "foo");
}
#[test]
fn test_consecutive_semicolons() {
let s = "attachment;;; filename=foo.txt";
assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "foo.txt");
}
#[test]
fn test_quoted_with_escaped_quote() {
let s = r#"attachment; filename="a\"b.txt""#;
assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "a\"b.txt");
}
#[test]
fn test_empty_key_followed_by_equals_breaks() {
assert_eq!(ContentDisposition::parse("attachment;=x").filename, None);
assert_eq!(
ContentDisposition::parse("attachment;=x; filename=ok.txt").filename,
None
);
}
#[test]
fn filename_star_raw_non_ascii_single_char_is_preserved() {
let s = "attachment; filename*=UTF-8''测";
assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some("测"));
}
#[test]
fn filename_star_literal_cjk_is_preserved() {
let s = "attachment; filename*=UTF-8''测试";
assert_eq!(
ContentDisposition::parse(s).filename,
Some("测试".to_string())
);
}
#[test]
fn filename_star_empty_value_overrides_filename_with_empty() {
let s = r#"attachment; filename="fallback.txt"; filename*=UTF-8''"#;
assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some(""));
}
#[test]
fn test_filename_star_single_decode_is_correct() {
let s = "attachment; filename*=UTF-8''%25100";
assert_eq!(
ContentDisposition::parse(s).filename,
Some("%100".to_string())
);
}
#[test]
fn test_filename_star_mixed_percent_and_literal_preserved() {
let s = "attachment; filename*=UTF-8''%41é";
assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some("Aé"));
}
}