use std::fmt::Display;
use std::str::FromStr;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ContentType(String);
impl ContentType {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn media_type(&self) -> MediaType<'_> {
let head = self.0.split(';').next().unwrap_or("").trim();
let (type_, subtype) = head.split_once('/').unwrap_or((head, ""));
MediaType { type_, subtype }
}
pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
let _ = segments.next();
segments.filter_map(|segment| {
let (name, value) = segment.trim().split_once('=')?;
Some((name.trim(), ParameterValue::from_raw(value.trim())))
})
}
#[must_use]
pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
self.parameters()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value)
}
#[must_use]
pub fn boundary(&self) -> Option<ParameterValue<'_>> {
self.parameter("boundary")
}
#[must_use]
pub fn charset(&self) -> Option<ParameterValue<'_>> {
self.parameter("charset")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MediaType<'a> {
type_: &'a str,
subtype: &'a str,
}
impl<'a> MediaType<'a> {
#[must_use]
pub const fn type_(&self) -> &'a str {
self.type_
}
#[must_use]
pub const fn subtype(&self) -> &'a str {
self.subtype
}
#[must_use]
pub fn is_text(&self) -> bool {
self.type_.eq_ignore_ascii_case("text")
}
#[must_use]
pub fn is_multipart(&self) -> bool {
self.type_.eq_ignore_ascii_case("multipart")
}
#[must_use]
pub fn is_image(&self) -> bool {
self.type_.eq_ignore_ascii_case("image")
}
#[must_use]
pub fn matches(&self, expected: &str) -> bool {
let Some((ty, sub)) = expected.split_once('/') else {
return false;
};
self.type_.eq_ignore_ascii_case(ty) && self.subtype.eq_ignore_ascii_case(sub)
}
}
#[derive(Clone, Debug)]
pub struct ParameterValue<'a> {
raw: &'a str,
}
impl<'a> ParameterValue<'a> {
fn from_raw(raw: &'a str) -> Self {
Self { raw }
}
#[must_use]
pub const fn as_raw(&self) -> &'a str {
self.raw
}
#[must_use]
pub fn unquoted(&self) -> std::borrow::Cow<'a, str> {
let raw = self.raw;
if !raw.starts_with('"') || !raw.ends_with('"') || raw.len() < 2 {
return std::borrow::Cow::Borrowed(raw);
}
let inner = &raw[1..raw.len() - 1];
if !inner.contains('\\') {
return std::borrow::Cow::Borrowed(inner);
}
let mut out = String::with_capacity(inner.len());
let mut escaped = false;
for ch in inner.chars() {
if escaped {
out.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
out.push(ch);
}
}
std::borrow::Cow::Owned(out)
}
}
impl PartialEq<&str> for ParameterValue<'_> {
fn eq(&self, other: &&str) -> bool {
self.unquoted().as_ref() == *other
}
}
impl Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("content type must have a type/subtype form")]
pub struct ContentTypeParseError;
impl FromStr for ContentType {
type Err = ContentTypeParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
normalize_parameterized_value(s, true)
.map(Self)
.ok_or(ContentTypeParseError)
}
}
fn is_mime_token(value: &str) -> bool {
value.bytes().all(is_mime_token_byte)
}
fn split_content_type_segments(value: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut escaped = false;
for (index, ch) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
if in_quotes && ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_quotes = !in_quotes;
continue;
}
if ch == ';' && !in_quotes {
segments.push(&value[start..index]);
start = index + ch.len_utf8();
}
}
segments.push(&value[start..]);
segments
}
const fn is_mime_token_byte(byte: u8) -> bool {
matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
| b'0'..=b'9'
| b'A'..=b'Z'
| b'a'..=b'z'
)
}
fn is_parameter_value(value: &str) -> bool {
if value.starts_with('"') {
return is_quoted_parameter_value(value);
}
is_mime_token(value)
}
fn is_quoted_parameter_value(value: &str) -> bool {
if !(value.ends_with('"') && value.len() >= 2) {
return false;
}
let mut escaped = false;
for byte in value[1..value.len() - 1].bytes() {
if escaped {
if is_forbidden_quoted_parameter_byte(byte) {
return false;
}
escaped = false;
continue;
}
if byte == b'\\' {
escaped = true;
continue;
}
if byte == b'"' || is_forbidden_quoted_parameter_byte(byte) {
return false;
}
}
!escaped
}
const fn is_forbidden_quoted_parameter_byte(byte: u8) -> bool {
byte != b'\t' && byte.is_ascii_control()
}
impl TryFrom<&str> for ContentType {
type Error = ContentTypeParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
impl From<ContentType> for String {
fn from(value: ContentType) -> Self {
value.0
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for ContentType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ContentType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for ContentType {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
let value = match u.int_in_range::<u8>(0..=4)? {
0 => "text/plain",
1 => "text/html; charset=utf-8",
2 => "application/octet-stream",
3 => "image/png",
_ => "multipart/mixed; boundary=boundary",
};
value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
}
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ContentTransferEncoding {
SevenBit,
EightBit,
Binary,
QuotedPrintable,
Base64,
Other(String),
}
impl ContentTransferEncoding {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::SevenBit => "7bit",
Self::EightBit => "8bit",
Self::Binary => "binary",
Self::QuotedPrintable => "quoted-printable",
Self::Base64 => "base64",
Self::Other(value) => value.as_str(),
}
}
}
impl Display for ContentTransferEncoding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("content-transfer-encoding cannot be empty")]
pub struct ContentTransferEncodingParseError;
impl FromStr for ContentTransferEncoding {
type Err = ContentTransferEncodingParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let value = s.trim();
if value.is_empty() || !is_mime_token(value) {
return Err(ContentTransferEncodingParseError);
}
Ok(if value.eq_ignore_ascii_case("7bit") {
Self::SevenBit
} else if value.eq_ignore_ascii_case("8bit") {
Self::EightBit
} else if value.eq_ignore_ascii_case("binary") {
Self::Binary
} else if value.eq_ignore_ascii_case("quoted-printable") {
Self::QuotedPrintable
} else if value.eq_ignore_ascii_case("base64") {
Self::Base64
} else {
Self::Other(value.to_ascii_lowercase())
})
}
}
impl TryFrom<&str> for ContentTransferEncoding {
type Error = ContentTransferEncodingParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for ContentTransferEncoding {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ContentTransferEncoding {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for ContentTransferEncoding {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
Ok(match u.int_in_range::<u8>(0..=5)? {
0 => Self::SevenBit,
1 => Self::EightBit,
2 => Self::Binary,
3 => Self::QuotedPrintable,
4 => Self::Base64,
_ => Self::Other("x-experimental".to_owned()),
})
}
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ContentDisposition(String);
impl ContentDisposition {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn kind(&self) -> &str {
self.0.split(';').next().unwrap_or("").trim()
}
pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
let _ = segments.next();
segments.filter_map(|segment| {
let (name, value) = segment.trim().split_once('=')?;
Some((name.trim(), ParameterValue::from_raw(value.trim())))
})
}
#[must_use]
pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
self.parameters()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value)
}
#[must_use]
pub fn filename(&self) -> Option<ParameterValue<'_>> {
self.parameter("filename")
.or_else(|| self.parameter("filename*"))
}
#[must_use]
pub fn is_inline(&self) -> bool {
self.kind().eq_ignore_ascii_case("inline")
}
#[must_use]
pub fn is_attachment(&self) -> bool {
self.kind().eq_ignore_ascii_case("attachment")
}
}
impl Display for ContentDisposition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("content-disposition cannot be empty")]
pub struct ContentDispositionParseError;
impl FromStr for ContentDisposition {
type Err = ContentDispositionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
normalize_parameterized_value(s, false)
.map(Self)
.ok_or(ContentDispositionParseError)
}
}
impl TryFrom<&str> for ContentDisposition {
type Error = ContentDispositionParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
fn normalize_parameterized_value(value: &str, with_subtype: bool) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
let segments = split_content_type_segments(value);
let mut parts = segments.into_iter();
let head = parts.next()?.trim();
let canonical_head = if with_subtype {
let (ty, subtype) = head.split_once('/')?;
if ty.is_empty()
|| subtype.is_empty()
|| subtype.contains('/')
|| !is_mime_token(ty)
|| !is_mime_token(subtype)
{
return None;
}
format!(
"{}/{}",
ty.to_ascii_lowercase(),
subtype.to_ascii_lowercase()
)
} else {
if head.is_empty() || !is_mime_token(head) {
return None;
}
head.to_ascii_lowercase()
};
let mut canonical = canonical_head;
for parameter in parts {
let parameter = parameter.trim();
let (name, raw_value) = parameter.split_once('=')?;
let name = name.trim();
let raw_value = raw_value.trim();
if name.is_empty()
|| raw_value.is_empty()
|| !is_mime_token(name)
|| !is_parameter_value(raw_value)
{
return None;
}
canonical.push_str("; ");
canonical.push_str(&name.to_ascii_lowercase());
canonical.push('=');
canonical.push_str(raw_value);
}
Some(canonical)
}
#[cfg(feature = "serde")]
impl serde::Serialize for ContentDisposition {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ContentDisposition {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for ContentDisposition {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
let value = match u.int_in_range::<u8>(0..=2)? {
0 => "inline",
1 => "attachment",
_ => "attachment; filename=example.txt",
};
value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
}
}
#[cfg(feature = "mime")]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MimePart {
Leaf {
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
content_type: ContentType,
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
content_transfer_encoding: Option<ContentTransferEncoding>,
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
content_disposition: Option<ContentDisposition>,
body: Vec<u8>,
},
Multipart {
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
content_type: ContentType,
boundary: Option<String>,
parts: Vec<Self>,
},
}
#[cfg(test)]
mod tests {
use super::{ContentTransferEncoding, ContentType};
#[test]
fn content_type_accepts_valid_media_types_and_parameters() {
for value in [
"text/plain",
"text/plain;charset=utf-8",
"multipart/related; type=\"text/html\"",
"application/octet-stream; name=\"a;b.txt\"",
] {
assert!(
ContentType::try_from(value).is_ok(),
"expected valid content type: {value}"
);
}
}
#[test]
fn content_type_rejects_invalid_media_types() {
for value in [
"text/",
"/plain",
"text/plain/html",
"text /plain",
"text/plain; charset",
"text/plain; charset=\"unterminated",
] {
assert!(
ContentType::try_from(value).is_err(),
"expected invalid content type: {value}"
);
}
}
#[test]
fn content_type_rejects_quoted_parameter_with_control_chars() {
for value in [
"text/plain; name=\"x\u{0}y\"",
"text/plain; name=\"x\u{07}y\"",
"text/plain; name=\"x\u{0B}y\"",
"text/plain; name=\"x\u{1B}y\"",
] {
assert!(
ContentType::try_from(value).is_err(),
"expected control-char rejection: {value:?}"
);
}
}
#[test]
fn content_type_rejects_quoted_parameter_with_escaped_control_chars() {
for value in [
"text/plain; name=\"x\\\u{0}y\"",
"text/plain; name=\"x\\\u{07}y\"",
] {
assert!(
ContentType::try_from(value).is_err(),
"expected escaped-control-char rejection: {value:?}"
);
}
}
#[test]
fn content_type_accepts_tab_inside_quoted_parameter() {
assert!(ContentType::try_from("text/plain; name=\"a\tb\"").is_ok());
}
#[test]
fn content_type_media_type_view_splits_type_and_subtype() {
let ct: ContentType = "text/plain; charset=utf-8".parse().unwrap();
let media = ct.media_type();
assert_eq!(media.type_(), "text");
assert_eq!(media.subtype(), "plain");
assert!(media.is_text());
assert!(!media.is_multipart());
assert!(media.matches("text/plain"));
assert!(media.matches("TEXT/PLAIN"));
}
#[test]
fn content_type_parameter_lookup_is_case_insensitive_and_unquotes() {
let ct: ContentType = "multipart/mixed; Boundary=\"abc\\\"def\"".parse().unwrap();
let boundary = ct.boundary().expect("boundary present");
assert_eq!(boundary.as_raw(), "\"abc\\\"def\"");
assert_eq!(boundary.unquoted().as_ref(), "abc\"def");
}
#[test]
fn content_type_parameters_iterates_in_declaration_order() {
let ct: ContentType = "text/html; charset=utf-8; boundary=x".parse().unwrap();
let pairs: Vec<(String, String)> = ct
.parameters()
.map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
.collect();
assert_eq!(
pairs,
vec![
("charset".to_owned(), "utf-8".to_owned()),
("boundary".to_owned(), "x".to_owned()),
]
);
}
#[test]
fn content_transfer_encoding_canonicalizes_known_tokens() {
assert_eq!(
"Base64"
.parse::<ContentTransferEncoding>()
.unwrap()
.as_str(),
"base64"
);
assert_eq!(
"7BIT".parse::<ContentTransferEncoding>().unwrap().as_str(),
"7bit"
);
assert_eq!(
"Quoted-Printable"
.parse::<ContentTransferEncoding>()
.unwrap(),
ContentTransferEncoding::QuotedPrintable
);
let other: ContentTransferEncoding = "x-my-encoding".parse().unwrap();
assert_eq!(
other,
ContentTransferEncoding::Other("x-my-encoding".to_owned())
);
assert_eq!(other.as_str(), "x-my-encoding");
}
#[test]
fn content_disposition_kind_and_parameter_accessors() {
use super::ContentDisposition;
let cd: ContentDisposition = "attachment; filename=\"report.pdf\""
.parse()
.expect("disposition should parse");
assert_eq!(cd.kind(), "attachment");
assert!(cd.is_attachment());
assert!(!cd.is_inline());
let filename = cd.filename().expect("filename present");
assert_eq!(filename.unquoted().as_ref(), "report.pdf");
}
#[test]
fn content_disposition_filename_falls_back_to_extended_parameter() {
use super::ContentDisposition;
let cd: ContentDisposition = "attachment; filename*=utf-8''f%C3%A1jl.txt"
.parse()
.expect("disposition should parse");
let filename = cd.filename().expect("filename* present");
assert_eq!(filename.as_raw(), "utf-8''f%C3%A1jl.txt");
}
#[test]
fn content_disposition_inline_kind_is_case_insensitive() {
use super::ContentDisposition;
let cd: ContentDisposition = "INLINE".parse().expect("disposition should parse");
assert!(cd.is_inline());
assert!(!cd.is_attachment());
}
#[test]
fn content_disposition_parameters_iterates_in_declaration_order() {
use super::ContentDisposition;
let cd: ContentDisposition = "attachment; filename=report.pdf; size=42".parse().unwrap();
let pairs: Vec<(String, String)> = cd
.parameters()
.map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
.collect();
assert_eq!(
pairs,
vec![
("filename".to_owned(), "report.pdf".to_owned()),
("size".to_owned(), "42".to_owned()),
]
);
}
#[test]
fn content_disposition_parameter_lookup_is_case_insensitive() {
use super::ContentDisposition;
let cd: ContentDisposition = "attachment; FileName=\"x.txt\"".parse().unwrap();
assert_eq!(
cd.parameter("filename").unwrap().unquoted().as_ref(),
"x.txt"
);
assert_eq!(
cd.parameter("FILENAME").unwrap().unquoted().as_ref(),
"x.txt"
);
}
#[test]
fn content_transfer_encoding_other_is_case_insensitive() {
let a: ContentTransferEncoding = "X-MyEnc".parse().unwrap();
let b: ContentTransferEncoding = "x-myenc".parse().unwrap();
let c: ContentTransferEncoding = "X-MYENC".parse().unwrap();
assert_eq!(a, b);
assert_eq!(a, c);
assert_eq!(a.as_str(), "x-myenc");
assert_eq!(c.as_str(), "x-myenc");
use std::collections::HashSet;
let mut set: HashSet<ContentTransferEncoding> = HashSet::new();
set.insert(a);
assert!(set.contains(&b));
assert!(set.contains(&c));
}
#[test]
fn content_type_eq_is_case_insensitive_after_normalize() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let upper = ContentType::try_from("TEXT/PLAIN; CHARSET=UTF-8").unwrap();
let lower = ContentType::try_from("text/plain; charset=UTF-8").unwrap();
assert_eq!(upper, lower);
assert_eq!(upper.as_str(), "text/plain; charset=UTF-8");
let mut h_u = DefaultHasher::new();
upper.hash(&mut h_u);
let mut h_l = DefaultHasher::new();
lower.hash(&mut h_l);
assert_eq!(h_u.finish(), h_l.finish());
let preserved = ContentType::try_from("multipart/mixed; BOUNDARY=\"AbC\"").unwrap();
assert_eq!(preserved.as_str(), "multipart/mixed; boundary=\"AbC\"");
}
#[test]
fn content_disposition_eq_is_case_insensitive_after_normalize() {
use super::ContentDisposition;
let upper = ContentDisposition::try_from("ATTACHMENT; FILENAME=\"x.pdf\"").unwrap();
let lower = ContentDisposition::try_from("attachment; filename=\"x.pdf\"").unwrap();
assert_eq!(upper, lower);
assert_eq!(upper.as_str(), "attachment; filename=\"x.pdf\"");
}
}