use std::{fmt, ops::Deref, str::FromStr};
use ruma_macros::{
AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr,
};
use super::{
is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string,
unescape_string,
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct ContentDisposition {
pub disposition_type: ContentDispositionType,
pub filename: Option<String>,
}
impl ContentDisposition {
pub fn new(disposition_type: ContentDispositionType) -> Self {
Self { disposition_type, filename: None }
}
pub fn with_filename(mut self, filename: Option<String>) -> Self {
self.filename = filename;
self
}
}
impl fmt::Display for ContentDisposition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.disposition_type)?;
if let Some(filename) = &self.filename {
if filename.is_ascii() {
let filename = sanitize_for_ascii_quoted_string(filename);
write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
} else {
write!(f, "; filename*={}", rfc8187::encode(filename))?;
}
}
Ok(())
}
}
impl TryFrom<&[u8]> for ContentDisposition {
type Error = ContentDispositionParseError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let mut pos = 0;
skip_ascii_whitespaces(value, &mut pos);
if pos == value.len() {
return Err(ContentDispositionParseError::MissingDispositionType);
}
let disposition_type_start = pos;
while let Some(byte) = value.get(pos) {
if byte.is_ascii_whitespace() || *byte == b';' {
break;
}
pos += 1;
}
let disposition_type =
ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
let mut filename_ext = None;
let mut filename = None;
while pos != value.len() {
if let Some(param) = RawParam::parse_next(value, &mut pos) {
if param.name.eq_ignore_ascii_case(b"filename*") {
if let Some(value) = param.decode_value() {
filename_ext = Some(value);
break;
}
} else if param.name.eq_ignore_ascii_case(b"filename") {
if let Some(value) = param.decode_value() {
filename = Some(value);
}
}
}
}
Ok(Self { disposition_type, filename: filename_ext.or(filename) })
}
}
impl FromStr for ContentDisposition {
type Err = ContentDispositionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
struct RawParam<'a> {
name: &'a [u8],
value: &'a [u8],
is_quoted_string: bool,
}
impl<'a> RawParam<'a> {
fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
let name = parse_param_name(bytes, pos)?;
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
return None;
}
if bytes[*pos] != b'=' {
*pos = bytes.len();
return None;
}
*pos += 1;
skip_ascii_whitespaces(bytes, pos);
let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
Some(Self { name, value, is_quoted_string })
}
fn decode_value(&self) -> Option<String> {
if self.name.ends_with(b"*") {
rfc8187::decode(self.value).ok().map(|s| s.into_owned())
} else {
let s = String::from_utf8_lossy(self.value);
if self.is_quoted_string {
Some(unescape_string(&s))
} else {
Some(s.into_owned())
}
}
}
}
fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
while let Some(byte) = bytes.get(*pos) {
if !byte.is_ascii_whitespace() {
break;
}
*pos += 1;
}
}
fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
return None;
}
let name_start = *pos;
while let Some(byte) = bytes.get(*pos) {
if !is_tchar(*byte) {
break;
}
*pos += 1;
}
if *pos == bytes.len() {
return None;
}
if bytes[*pos] == b';' {
*pos += 1;
return None;
}
let name = &bytes[name_start..*pos];
if name.is_empty() {
*pos = bytes.len();
return None;
}
Some(name)
}
fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
return None;
}
let is_quoted_string = bytes[*pos] == b'"';
if is_quoted_string {
*pos += 1;
}
let value_start = *pos;
let mut escape_next = false;
while let Some(byte) = bytes.get(*pos) {
if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
break;
}
if is_quoted_string && *byte == b'"' && !escape_next {
break;
}
escape_next = *byte == b'\\' && !escape_next;
*pos += 1;
}
let value = &bytes[value_start..*pos];
if is_quoted_string && *pos != bytes.len() {
*pos += 1;
}
skip_ascii_whitespaces(bytes, pos);
if *pos != bytes.len() {
if bytes[*pos] == b';' {
*pos += 1;
} else {
*pos = bytes.len();
return None;
}
}
Some((value, is_quoted_string))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ContentDispositionParseError {
#[error("disposition type is missing")]
MissingDispositionType,
#[error("invalid disposition type: {0}")]
InvalidDispositionType(#[from] TokenStringParseError),
}
#[derive(
Clone,
Default,
AsRefStr,
DebugAsRefStr,
AsStrAsRefStr,
DisplayAsRefStr,
PartialOrdAsRefStr,
OrdAsRefStr,
)]
#[ruma_enum(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ContentDispositionType {
#[default]
Inline,
Attachment,
#[doc(hidden)]
_Custom(TokenString),
}
impl ContentDispositionType {
pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
Self::from_str(s)
}
}
impl From<TokenString> for ContentDispositionType {
fn from(value: TokenString) -> Self {
if value.eq_ignore_ascii_case("inline") {
Self::Inline
} else if value.eq_ignore_ascii_case("attachment") {
Self::Attachment
} else {
Self::_Custom(value)
}
}
}
impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
type Error = TokenStringParseError;
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.eq_ignore_ascii_case(b"inline") {
Ok(Self::Inline)
} else if value.eq_ignore_ascii_case(b"attachment") {
Ok(Self::Attachment)
} else {
TokenString::try_from(value).map(Self::_Custom)
}
}
}
impl FromStr for ContentDispositionType {
type Err = TokenStringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
impl PartialEq<ContentDispositionType> for ContentDispositionType {
fn eq(&self, other: &ContentDispositionType) -> bool {
self.as_str().eq_ignore_ascii_case(other.as_str())
}
}
impl Eq for ContentDispositionType {}
impl PartialEq<TokenString> for ContentDispositionType {
fn eq(&self, other: &TokenString) -> bool {
self.as_str().eq_ignore_ascii_case(other.as_str())
}
}
impl<'a> PartialEq<&'a str> for ContentDispositionType {
fn eq(&self, other: &&'a str) -> bool {
self.as_str().eq_ignore_ascii_case(other)
}
}
#[derive(
Clone,
PartialEq,
Eq,
DebugAsRefStr,
AsStrAsRefStr,
DisplayAsRefStr,
PartialOrdAsRefStr,
OrdAsRefStr,
)]
pub struct TokenString(Box<str>);
impl TokenString {
pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
Self::from_str(s)
}
}
impl Deref for TokenString {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_ref()
}
}
impl AsRef<str> for TokenString {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<'a> PartialEq<&'a str> for TokenString {
fn eq(&self, other: &&'a str) -> bool {
self.as_str().eq(*other)
}
}
impl<'a> TryFrom<&'a [u8]> for TokenString {
type Error = TokenStringParseError;
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.is_empty() {
Err(TokenStringParseError::Empty)
} else if is_token(value) {
let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
Ok(Self(s.into()))
} else {
Err(TokenStringParseError::InvalidCharacter)
}
}
}
impl FromStr for TokenString {
type Err = TokenStringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TokenStringParseError {
#[error("string is empty")]
Empty,
#[error("string contains invalid character")]
InvalidCharacter,
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{ContentDisposition, ContentDispositionType};
#[test]
fn parse_content_disposition_valid() {
let content_disposition = ContentDisposition::from_str("inline").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename, None);
let content_disposition =
ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
assert_eq!(content_disposition.disposition_type.as_str(), "custom");
assert_eq!(content_disposition.filename, None);
let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
let content_disposition =
ContentDisposition::from_str(" INLINE ;FILENAME = my_file ").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
let content_disposition = ContentDisposition::from_str(
r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#,
)
.unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
let content_disposition =
ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#)
.unwrap();
assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
}
#[test]
fn parse_content_disposition_invalid_type() {
ContentDisposition::from_str("").unwrap_err();
ContentDisposition::from_str("; foo=bar").unwrap_err();
}
#[test]
fn parse_content_disposition_invalid_parameters() {
let content_disposition =
ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
let content_disposition =
ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
let content_disposition =
ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
}
#[test]
fn content_disposition_serialize() {
let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
let serialized = content_disposition.to_string();
assert_eq!(serialized, "inline");
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename=my_file");
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my file".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, r#"attachment; filename="my file""#);
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some(r#""my"\file"#.to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("Mi Corazón".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my\r\nfile".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename=myfile");
}
#[test]
fn rfc6266_examples() {
let unquoted = "Attachment; filename=example.html";
let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, "attachment; filename=example.html");
let quoted = r#"INLINE; FILENAME= "an example.html""#;
let content_disposition = ContentDisposition::from_str(quoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"inline; filename="an example.html""#);
let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
let rfc8187_with_fallback =
r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
}
#[test]
fn rfc8187_examples() {
let unquoted = "attachment; foo= bar; filename=Economy";
let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, "attachment; filename=Economy");
let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
let content_disposition = ContentDisposition::from_str(quoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
let rfc8187_other =
r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
let reserialized = content_disposition.to_string();
assert_eq!(
reserialized,
r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
);
let rfc8187_with_fallback = r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#);
}
}