use core::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentDispositionError {
Empty,
InvalidFormat,
InvalidDispositionType,
InvalidParameter,
InvalidExtValue,
DuplicateParameter(String),
}
impl fmt::Display for ContentDispositionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ContentDispositionError::Empty => write!(f, "empty content-disposition"),
ContentDispositionError::InvalidFormat => {
write!(f, "invalid content-disposition format")
}
ContentDispositionError::InvalidDispositionType => {
write!(f, "invalid disposition-type")
}
ContentDispositionError::InvalidParameter => write!(f, "invalid parameter"),
ContentDispositionError::InvalidExtValue => write!(f, "invalid ext-value encoding"),
ContentDispositionError::DuplicateParameter(name) => {
write!(f, "duplicate parameter: {}", name)
}
}
}
}
impl std::error::Error for ContentDispositionError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispositionType {
Inline,
Attachment,
FormData,
Unknown(String),
}
impl DispositionType {
fn from_str(s: &str) -> Result<Self, ContentDispositionError> {
let lower = s.to_ascii_lowercase();
match lower.as_str() {
"inline" => Ok(DispositionType::Inline),
"attachment" => Ok(DispositionType::Attachment),
"form-data" => Ok(DispositionType::FormData),
_ => {
if is_valid_token(s) {
Ok(DispositionType::Unknown(lower))
} else {
Err(ContentDispositionError::InvalidDispositionType)
}
}
}
}
}
impl fmt::Display for DispositionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DispositionType::Inline => write!(f, "inline"),
DispositionType::Attachment => write!(f, "attachment"),
DispositionType::FormData => write!(f, "form-data"),
DispositionType::Unknown(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentDisposition {
disposition_type: DispositionType,
filename: Option<String>,
filename_ext: Option<String>,
name: Option<String>,
parameters: Vec<(String, String)>,
}
impl ContentDisposition {
pub fn parse(input: &str) -> Result<Self, ContentDispositionError> {
let input = input.trim();
if input.is_empty() {
return Err(ContentDispositionError::Empty);
}
let parts = split_params(input);
let type_str = parts
.first()
.ok_or(ContentDispositionError::InvalidFormat)?;
let disposition_type = DispositionType::from_str(type_str.trim())?;
let mut cd = ContentDisposition {
disposition_type,
filename: None,
filename_ext: None,
name: None,
parameters: Vec::new(),
};
let mut seen_params = Vec::new();
for part in parts.iter().skip(1) {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(eq_pos) = part.find('=') {
let param_name = part[..eq_pos].trim().to_ascii_lowercase();
let param_value = part[eq_pos + 1..].trim();
if seen_params.iter().any(|n: &String| n == ¶m_name) {
return Err(ContentDispositionError::DuplicateParameter(param_name));
}
seen_params.push(param_name.clone());
match param_name.as_str() {
"filename" => {
cd.filename = Some(parse_param_value(param_value)?);
}
"filename*" => {
cd.filename_ext = Some(parse_ext_value(param_value)?);
}
"name" => {
cd.name = Some(parse_param_value(param_value)?);
}
_ => {
cd.parameters
.push((param_name, parse_param_value(param_value)?));
}
}
}
}
Ok(cd)
}
pub fn new(disposition_type: DispositionType) -> Self {
ContentDisposition {
disposition_type,
filename: None,
filename_ext: None,
name: None,
parameters: Vec::new(),
}
}
pub fn disposition_type(&self) -> DispositionType {
self.disposition_type.clone()
}
pub fn filename(&self) -> Option<&str> {
self.filename_ext.as_deref().or(self.filename.as_deref())
}
pub fn filename_ascii(&self) -> Option<&str> {
self.filename.as_deref()
}
pub fn filename_ext(&self) -> Option<&str> {
self.filename_ext.as_deref()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn parameter(&self, name: &str) -> Option<&str> {
let name_lower = name.to_ascii_lowercase();
for (k, v) in &self.parameters {
if k == &name_lower {
return Some(v);
}
}
None
}
pub fn is_inline(&self) -> bool {
self.disposition_type == DispositionType::Inline
}
pub fn is_attachment(&self) -> bool {
self.disposition_type == DispositionType::Attachment
}
pub fn is_form_data(&self) -> bool {
self.disposition_type == DispositionType::FormData
}
pub fn with_filename(mut self, filename: &str) -> Self {
self.filename = Some(filename.to_string());
self
}
pub fn with_filename_ext(mut self, filename: &str) -> Self {
self.filename_ext = Some(filename.to_string());
self
}
pub fn with_name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
}
impl fmt::Display for ContentDisposition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.disposition_type)?;
if let Some(name) = &self.name {
write!(f, "; name=\"{}\"", escape_quoted_string(name))?;
}
if let Some(filename) = &self.filename {
write!(f, "; filename=\"{}\"", escape_quoted_string(filename))?;
}
if let Some(filename_ext) = &self.filename_ext {
write!(f, "; filename*=UTF-8''{}", encode_ext_value(filename_ext))?;
}
for (name, value) in &self.parameters {
write!(f, "; {}=\"{}\"", name, escape_quoted_string(value))?;
}
Ok(())
}
}
fn split_params(input: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut escape_next = false;
for c in input.chars() {
if escape_next {
current.push(c);
escape_next = false;
continue;
}
match c {
'\\' if in_quotes => {
current.push(c);
escape_next = true;
}
'"' => {
current.push(c);
in_quotes = !in_quotes;
}
';' if !in_quotes => {
parts.push(current);
current = String::new();
}
_ => {
current.push(c);
}
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn parse_param_value(value: &str) -> Result<String, ContentDispositionError> {
let value = value.trim();
if value.starts_with('"') {
if value.ends_with('"') && value.len() >= 2 {
parse_quoted_string(&value[1..value.len() - 1])
} else {
Err(ContentDispositionError::InvalidParameter)
}
} else {
if !is_valid_token(value) {
return Err(ContentDispositionError::InvalidParameter);
}
Ok(value.to_string())
}
}
fn is_valid_token(s: &str) -> bool {
!s.is_empty() && s.bytes().all(is_token_char)
}
fn is_token_char(b: u8) -> bool {
matches!(b,
b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' |
b'0'..=b'9' | b'A'..=b'Z' | b'^' | b'_' | b'`' | b'a'..=b'z' | b'|' | b'~'
)
}
fn parse_quoted_string(s: &str) -> Result<String, ContentDispositionError> {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(escaped) = chars.next() {
result.push(escaped);
} else {
return Err(ContentDispositionError::InvalidParameter);
}
} else {
result.push(c);
}
}
Ok(result)
}
fn parse_ext_value(value: &str) -> Result<String, ContentDispositionError> {
let value = value.trim();
let first_quote = value
.find('\'')
.ok_or(ContentDispositionError::InvalidExtValue)?;
let charset = &value[..first_quote];
let rest = &value[first_quote + 1..];
let second_quote = rest
.find('\'')
.ok_or(ContentDispositionError::InvalidExtValue)?;
let encoded_value = &rest[second_quote + 1..];
if !charset.eq_ignore_ascii_case("UTF-8") {
return Err(ContentDispositionError::InvalidExtValue);
}
percent_decode(encoded_value)
}
fn percent_decode(s: &str) -> Result<String, ContentDispositionError> {
let mut bytes = Vec::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() != 2 {
return Err(ContentDispositionError::InvalidExtValue);
}
let byte = u8::from_str_radix(&hex, 16)
.map_err(|_| ContentDispositionError::InvalidExtValue)?;
bytes.push(byte);
} else {
if !c.is_ascii() || !is_attr_char(c as u8) {
return Err(ContentDispositionError::InvalidExtValue);
}
bytes.push(c as u8);
}
}
String::from_utf8(bytes).map_err(|_| ContentDispositionError::InvalidExtValue)
}
fn encode_ext_value(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
if is_attr_char(byte) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&format!("{:02X}", byte));
}
}
result
}
fn is_attr_char(b: u8) -> bool {
matches!(b,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' |
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' |
b'^' | b'_' | b'`' | b'|' | b'~'
)
}
fn escape_quoted_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
if c == '"' || c == '\\' {
result.push('\\');
}
result.push(c);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_inline() {
let cd = ContentDisposition::parse("inline").unwrap();
assert_eq!(cd.disposition_type(), DispositionType::Inline);
assert!(cd.is_inline());
assert!(!cd.is_attachment());
}
#[test]
fn test_parse_attachment() {
let cd = ContentDisposition::parse("attachment").unwrap();
assert_eq!(cd.disposition_type(), DispositionType::Attachment);
assert!(cd.is_attachment());
}
#[test]
fn test_parse_attachment_with_filename() {
let cd = ContentDisposition::parse("attachment; filename=\"example.txt\"").unwrap();
assert!(cd.is_attachment());
assert_eq!(cd.filename(), Some("example.txt"));
}
#[test]
fn test_parse_filename_without_quotes() {
let cd = ContentDisposition::parse("attachment; filename=example.txt").unwrap();
assert_eq!(cd.filename(), Some("example.txt"));
}
#[test]
fn test_parse_filename_with_escape() {
let cd = ContentDisposition::parse(r#"attachment; filename="file\"name.txt""#).unwrap();
assert_eq!(cd.filename(), Some("file\"name.txt"));
}
#[test]
fn test_parse_filename_ext() {
let cd = ContentDisposition::parse(
"attachment; filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt",
)
.unwrap();
assert_eq!(cd.filename(), Some("日本語.txt"));
assert_eq!(cd.filename_ext(), Some("日本語.txt"));
}
#[test]
fn test_filename_ext_priority() {
let cd = ContentDisposition::parse(
"attachment; filename=\"fallback.txt\"; filename*=UTF-8''preferred.txt",
)
.unwrap();
assert_eq!(cd.filename(), Some("preferred.txt"));
assert_eq!(cd.filename_ascii(), Some("fallback.txt"));
}
#[test]
fn test_parse_form_data() {
let cd = ContentDisposition::parse("form-data; name=\"field1\"").unwrap();
assert!(cd.is_form_data());
assert_eq!(cd.name(), Some("field1"));
}
#[test]
fn test_parse_form_data_with_filename() {
let cd =
ContentDisposition::parse("form-data; name=\"file\"; filename=\"image.png\"").unwrap();
assert!(cd.is_form_data());
assert_eq!(cd.name(), Some("file"));
assert_eq!(cd.filename(), Some("image.png"));
}
#[test]
fn test_parse_case_insensitive() {
let cd = ContentDisposition::parse("ATTACHMENT; FILENAME=\"test.txt\"").unwrap();
assert!(cd.is_attachment());
assert_eq!(cd.filename(), Some("test.txt"));
}
#[test]
fn test_parse_empty() {
assert!(ContentDisposition::parse("").is_err());
}
#[test]
fn test_parse_invalid_type() {
assert!(ContentDisposition::parse("hello world").is_err());
assert!(ContentDisposition::parse("type@invalid").is_err());
}
#[test]
fn test_display() {
let cd = ContentDisposition::new(DispositionType::Attachment).with_filename("test.txt");
assert_eq!(cd.to_string(), "attachment; filename=\"test.txt\"");
}
#[test]
fn test_display_with_filename_ext() {
let cd = ContentDisposition::new(DispositionType::Attachment)
.with_filename("fallback.txt")
.with_filename_ext("日本語.txt");
let s = cd.to_string();
assert!(s.contains("attachment"));
assert!(s.contains("filename=\"fallback.txt\""));
assert!(s.contains("filename*=UTF-8''"));
}
#[test]
fn test_display_form_data() {
let cd = ContentDisposition::new(DispositionType::FormData)
.with_name("field")
.with_filename("file.txt");
let s = cd.to_string();
assert!(s.contains("form-data"));
assert!(s.contains("name=\"field\""));
assert!(s.contains("filename=\"file.txt\""));
}
#[test]
fn test_builder() {
let cd = ContentDisposition::new(DispositionType::Attachment)
.with_filename("example.txt")
.with_filename_ext("例.txt");
assert!(cd.is_attachment());
assert_eq!(cd.filename_ascii(), Some("example.txt"));
assert_eq!(cd.filename_ext(), Some("例.txt"));
assert_eq!(cd.filename(), Some("例.txt")); }
#[test]
fn test_ext_value_invalid_char() {
assert!(ContentDisposition::parse("attachment; filename*=UTF-8''hello world.txt").is_err());
assert!(ContentDisposition::parse("attachment; filename*=UTF-8''test@file.txt").is_err());
}
#[test]
fn test_ext_value_valid_chars() {
let cd =
ContentDisposition::parse("attachment; filename*=UTF-8''test-file_v1.0.txt").unwrap();
assert_eq!(cd.filename(), Some("test-file_v1.0.txt"));
}
#[test]
fn test_unknown_disposition_type() {
let cd = ContentDisposition::parse("signal").unwrap();
assert_eq!(
cd.disposition_type(),
DispositionType::Unknown("signal".to_string())
);
}
#[test]
fn test_unknown_disposition_type_with_params() {
let cd = ContentDisposition::parse("notification; id=123").unwrap();
assert_eq!(
cd.disposition_type(),
DispositionType::Unknown("notification".to_string())
);
assert_eq!(cd.parameter("id"), Some("123"));
}
#[test]
fn test_unknown_disposition_type_case_insensitive() {
let cd = ContentDisposition::parse("CUSTOM-TYPE").unwrap();
assert_eq!(
cd.disposition_type(),
DispositionType::Unknown("custom-type".to_string())
);
}
#[test]
fn test_invalid_disposition_type() {
assert!(ContentDisposition::parse("hello world").is_err());
}
#[test]
fn test_invalid_disposition_type_special_char() {
assert!(ContentDisposition::parse("type@invalid").is_err());
}
#[test]
fn test_unknown_disposition_display() {
let cd = ContentDisposition::parse("custom-type; name=\"test\"").unwrap();
let s = cd.to_string();
assert!(s.starts_with("custom-type"));
}
#[test]
fn test_invalid_token_parameter_value() {
assert!(ContentDisposition::parse("attachment; filename=hello@world.txt").is_err());
}
#[test]
fn test_invalid_token_parameter_value_space() {
assert!(ContentDisposition::parse("attachment; filename=hello world.txt").is_err());
}
#[test]
fn test_valid_token_parameter_value() {
let cd = ContentDisposition::parse("attachment; filename=valid-token_v1.0").unwrap();
assert_eq!(cd.filename(), Some("valid-token_v1.0"));
}
#[test]
fn test_quoted_special_chars() {
let cd = ContentDisposition::parse("attachment; filename=\"hello@world.txt\"").unwrap();
assert_eq!(cd.filename(), Some("hello@world.txt"));
}
}