1use std::fmt::Display;
13use std::str::FromStr;
14
15#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub struct ContentType(String);
28
29impl ContentType {
30 #[must_use]
32 pub fn as_str(&self) -> &str {
33 self.0.as_str()
34 }
35
36 #[must_use]
41 pub fn media_type(&self) -> MediaType<'_> {
42 let head = self.0.split(';').next().unwrap_or("").trim();
43 let (type_, subtype) = head.split_once('/').unwrap_or((head, ""));
44 MediaType { type_, subtype }
45 }
46
47 pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
52 let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
53 let _ = segments.next();
55 segments.filter_map(|segment| {
56 let (name, value) = segment.trim().split_once('=')?;
57 Some((name.trim(), ParameterValue::from_raw(value.trim())))
58 })
59 }
60
61 #[must_use]
63 pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
64 self.parameters()
65 .find(|(key, _)| key.eq_ignore_ascii_case(name))
66 .map(|(_, value)| value)
67 }
68
69 #[must_use]
71 pub fn boundary(&self) -> Option<ParameterValue<'_>> {
72 self.parameter("boundary")
73 }
74
75 #[must_use]
77 pub fn charset(&self) -> Option<ParameterValue<'_>> {
78 self.parameter("charset")
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
84pub struct MediaType<'a> {
85 type_: &'a str,
86 subtype: &'a str,
87}
88
89impl<'a> MediaType<'a> {
90 #[must_use]
92 pub const fn type_(&self) -> &'a str {
93 self.type_
94 }
95
96 #[must_use]
98 pub const fn subtype(&self) -> &'a str {
99 self.subtype
100 }
101
102 #[must_use]
104 pub fn is_text(&self) -> bool {
105 self.type_.eq_ignore_ascii_case("text")
106 }
107
108 #[must_use]
110 pub fn is_multipart(&self) -> bool {
111 self.type_.eq_ignore_ascii_case("multipart")
112 }
113
114 #[must_use]
116 pub fn is_image(&self) -> bool {
117 self.type_.eq_ignore_ascii_case("image")
118 }
119
120 #[must_use]
122 pub fn matches(&self, expected: &str) -> bool {
123 let Some((ty, sub)) = expected.split_once('/') else {
124 return false;
125 };
126 self.type_.eq_ignore_ascii_case(ty) && self.subtype.eq_ignore_ascii_case(sub)
127 }
128}
129
130#[derive(Clone, Debug)]
132pub struct ParameterValue<'a> {
133 raw: &'a str,
134}
135
136impl<'a> ParameterValue<'a> {
137 fn from_raw(raw: &'a str) -> Self {
138 Self { raw }
139 }
140
141 #[must_use]
144 pub const fn as_raw(&self) -> &'a str {
145 self.raw
146 }
147
148 #[must_use]
151 pub fn unquoted(&self) -> std::borrow::Cow<'a, str> {
152 let raw = self.raw;
153 if !raw.starts_with('"') || !raw.ends_with('"') || raw.len() < 2 {
154 return std::borrow::Cow::Borrowed(raw);
155 }
156
157 let inner = &raw[1..raw.len() - 1];
158 if !inner.contains('\\') {
159 return std::borrow::Cow::Borrowed(inner);
160 }
161
162 let mut out = String::with_capacity(inner.len());
163 let mut escaped = false;
164 for ch in inner.chars() {
165 if escaped {
166 out.push(ch);
167 escaped = false;
168 } else if ch == '\\' {
169 escaped = true;
170 } else {
171 out.push(ch);
172 }
173 }
174 std::borrow::Cow::Owned(out)
175 }
176}
177
178impl PartialEq<&str> for ParameterValue<'_> {
179 fn eq(&self, other: &&str) -> bool {
180 self.unquoted().as_ref() == *other
181 }
182}
183
184impl Display for ContentType {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.write_str(self.as_str())
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
192#[error("content type must have a type/subtype form")]
193pub struct ContentTypeParseError;
194
195impl FromStr for ContentType {
196 type Err = ContentTypeParseError;
197
198 fn from_str(s: &str) -> Result<Self, Self::Err> {
199 normalize_parameterized_value(s, true)
200 .map(Self)
201 .ok_or(ContentTypeParseError)
202 }
203}
204
205fn is_mime_token(value: &str) -> bool {
206 value.bytes().all(is_mime_token_byte)
207}
208
209fn split_content_type_segments(value: &str) -> Vec<&str> {
210 let mut segments = Vec::new();
211 let mut start = 0;
212 let mut in_quotes = false;
213 let mut escaped = false;
214
215 for (index, ch) in value.char_indices() {
216 if escaped {
217 escaped = false;
218 continue;
219 }
220
221 if in_quotes && ch == '\\' {
222 escaped = true;
223 continue;
224 }
225
226 if ch == '"' {
227 in_quotes = !in_quotes;
228 continue;
229 }
230
231 if ch == ';' && !in_quotes {
232 segments.push(&value[start..index]);
233 start = index + ch.len_utf8();
234 }
235 }
236
237 segments.push(&value[start..]);
238 segments
239}
240
241const fn is_mime_token_byte(byte: u8) -> bool {
242 matches!(
243 byte,
244 b'!' | b'#'
245 | b'$'
246 | b'%'
247 | b'&'
248 | b'\''
249 | b'*'
250 | b'+'
251 | b'-'
252 | b'.'
253 | b'^'
254 | b'_'
255 | b'`'
256 | b'|'
257 | b'~'
258 | b'0'..=b'9'
259 | b'A'..=b'Z'
260 | b'a'..=b'z'
261 )
262}
263
264fn is_parameter_value(value: &str) -> bool {
265 if value.starts_with('"') {
266 return is_quoted_parameter_value(value);
267 }
268
269 is_mime_token(value)
270}
271
272fn is_quoted_parameter_value(value: &str) -> bool {
273 if !(value.ends_with('"') && value.len() >= 2) {
274 return false;
275 }
276
277 let mut escaped = false;
278 for byte in value[1..value.len() - 1].bytes() {
279 if escaped {
280 if is_forbidden_quoted_parameter_byte(byte) {
281 return false;
282 }
283 escaped = false;
284 continue;
285 }
286
287 if byte == b'\\' {
288 escaped = true;
289 continue;
290 }
291
292 if byte == b'"' || is_forbidden_quoted_parameter_byte(byte) {
293 return false;
294 }
295 }
296
297 !escaped
298}
299
300const fn is_forbidden_quoted_parameter_byte(byte: u8) -> bool {
307 byte != b'\t' && byte.is_ascii_control()
308}
309
310impl TryFrom<&str> for ContentType {
311 type Error = ContentTypeParseError;
312
313 fn try_from(value: &str) -> Result<Self, Self::Error> {
314 Self::from_str(value)
315 }
316}
317
318impl From<ContentType> for String {
319 fn from(value: ContentType) -> Self {
320 value.0
321 }
322}
323
324#[cfg(feature = "serde")]
325impl serde::Serialize for ContentType {
326 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
327 where
328 S: serde::Serializer,
329 {
330 serializer.serialize_str(self.as_str())
331 }
332}
333
334#[cfg(feature = "serde")]
335impl<'de> serde::Deserialize<'de> for ContentType {
336 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
337 where
338 D: serde::Deserializer<'de>,
339 {
340 let value = String::deserialize(deserializer)?;
341 value.parse().map_err(serde::de::Error::custom)
342 }
343}
344
345#[cfg(feature = "schemars")]
346impl schemars::JsonSchema for ContentType {
347 fn inline_schema() -> bool {
348 true
349 }
350
351 fn schema_name() -> std::borrow::Cow<'static, str> {
352 "ContentType".into()
353 }
354
355 fn schema_id() -> std::borrow::Cow<'static, str> {
356 concat!(module_path!(), "::ContentType").into()
357 }
358
359 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
360 schemars::json_schema!({
361 "type": "string",
362 "description": "MIME Content-Type field value"
363 })
364 }
365}
366
367#[cfg(feature = "arbitrary")]
368impl<'a> arbitrary::Arbitrary<'a> for ContentType {
369 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
370 let value = match u.int_in_range::<u8>(0..=4)? {
371 0 => "text/plain",
372 1 => "text/html; charset=utf-8",
373 2 => "application/octet-stream",
374 3 => "image/png",
375 _ => "multipart/mixed; boundary=boundary",
376 };
377 value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
378 }
379}
380
381#[derive(Clone, Debug, PartialEq, Eq, Hash)]
398#[non_exhaustive]
399pub enum ContentTransferEncoding {
400 SevenBit,
402 EightBit,
404 Binary,
406 QuotedPrintable,
408 Base64,
410 Other(String),
412}
413
414impl ContentTransferEncoding {
415 #[must_use]
417 pub fn as_str(&self) -> &str {
418 match self {
419 Self::SevenBit => "7bit",
420 Self::EightBit => "8bit",
421 Self::Binary => "binary",
422 Self::QuotedPrintable => "quoted-printable",
423 Self::Base64 => "base64",
424 Self::Other(value) => value.as_str(),
425 }
426 }
427}
428
429impl Display for ContentTransferEncoding {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.write_str(self.as_str())
432 }
433}
434
435#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
437#[error("content-transfer-encoding cannot be empty")]
438pub struct ContentTransferEncodingParseError;
439
440impl FromStr for ContentTransferEncoding {
441 type Err = ContentTransferEncodingParseError;
442
443 fn from_str(s: &str) -> Result<Self, Self::Err> {
444 let value = s.trim();
445 if value.is_empty() || !is_mime_token(value) {
446 return Err(ContentTransferEncodingParseError);
447 }
448 Ok(if value.eq_ignore_ascii_case("7bit") {
449 Self::SevenBit
450 } else if value.eq_ignore_ascii_case("8bit") {
451 Self::EightBit
452 } else if value.eq_ignore_ascii_case("binary") {
453 Self::Binary
454 } else if value.eq_ignore_ascii_case("quoted-printable") {
455 Self::QuotedPrintable
456 } else if value.eq_ignore_ascii_case("base64") {
457 Self::Base64
458 } else {
459 Self::Other(value.to_ascii_lowercase())
460 })
461 }
462}
463
464impl TryFrom<&str> for ContentTransferEncoding {
465 type Error = ContentTransferEncodingParseError;
466
467 fn try_from(value: &str) -> Result<Self, Self::Error> {
468 Self::from_str(value)
469 }
470}
471
472#[cfg(feature = "serde")]
473impl serde::Serialize for ContentTransferEncoding {
474 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
475 where
476 S: serde::Serializer,
477 {
478 serializer.serialize_str(self.as_str())
479 }
480}
481
482#[cfg(feature = "serde")]
483impl<'de> serde::Deserialize<'de> for ContentTransferEncoding {
484 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
485 where
486 D: serde::Deserializer<'de>,
487 {
488 let value = String::deserialize(deserializer)?;
489 value.parse().map_err(serde::de::Error::custom)
490 }
491}
492
493#[cfg(feature = "schemars")]
494impl schemars::JsonSchema for ContentTransferEncoding {
495 fn inline_schema() -> bool {
496 true
497 }
498
499 fn schema_name() -> std::borrow::Cow<'static, str> {
500 "ContentTransferEncoding".into()
501 }
502
503 fn schema_id() -> std::borrow::Cow<'static, str> {
504 concat!(module_path!(), "::ContentTransferEncoding").into()
505 }
506
507 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
508 schemars::json_schema!({
509 "type": "string",
510 "description": "RFC 2045 Content-Transfer-Encoding token"
511 })
512 }
513}
514
515#[cfg(feature = "arbitrary")]
516impl<'a> arbitrary::Arbitrary<'a> for ContentTransferEncoding {
517 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
518 Ok(match u.int_in_range::<u8>(0..=5)? {
519 0 => Self::SevenBit,
520 1 => Self::EightBit,
521 2 => Self::Binary,
522 3 => Self::QuotedPrintable,
523 4 => Self::Base64,
524 _ => Self::Other("x-experimental".to_owned()),
525 })
526 }
527}
528
529#[derive(Clone, Debug, PartialEq, Eq, Hash)]
541pub struct ContentDisposition(String);
542
543impl ContentDisposition {
544 #[must_use]
546 pub fn as_str(&self) -> &str {
547 self.0.as_str()
548 }
549
550 #[must_use]
556 pub fn kind(&self) -> &str {
557 self.0.split(';').next().unwrap_or("").trim()
558 }
559
560 pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
565 let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
566 let _ = segments.next();
568 segments.filter_map(|segment| {
569 let (name, value) = segment.trim().split_once('=')?;
570 Some((name.trim(), ParameterValue::from_raw(value.trim())))
571 })
572 }
573
574 #[must_use]
576 pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
577 self.parameters()
578 .find(|(key, _)| key.eq_ignore_ascii_case(name))
579 .map(|(_, value)| value)
580 }
581
582 #[must_use]
590 pub fn filename(&self) -> Option<ParameterValue<'_>> {
591 self.parameter("filename")
592 .or_else(|| self.parameter("filename*"))
593 }
594
595 #[must_use]
598 pub fn is_inline(&self) -> bool {
599 self.kind().eq_ignore_ascii_case("inline")
600 }
601
602 #[must_use]
605 pub fn is_attachment(&self) -> bool {
606 self.kind().eq_ignore_ascii_case("attachment")
607 }
608}
609
610impl Display for ContentDisposition {
611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612 f.write_str(self.as_str())
613 }
614}
615
616#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
618#[error("content-disposition cannot be empty")]
619pub struct ContentDispositionParseError;
620
621impl FromStr for ContentDisposition {
622 type Err = ContentDispositionParseError;
623
624 fn from_str(s: &str) -> Result<Self, Self::Err> {
625 normalize_parameterized_value(s, false)
626 .map(Self)
627 .ok_or(ContentDispositionParseError)
628 }
629}
630
631impl TryFrom<&str> for ContentDisposition {
632 type Error = ContentDispositionParseError;
633
634 fn try_from(value: &str) -> Result<Self, Self::Error> {
635 Self::from_str(value)
636 }
637}
638
639fn normalize_parameterized_value(value: &str, with_subtype: bool) -> Option<String> {
648 let value = value.trim();
649 if value.is_empty() {
650 return None;
651 }
652
653 let segments = split_content_type_segments(value);
654 let mut parts = segments.into_iter();
655 let head = parts.next()?.trim();
656
657 let canonical_head = if with_subtype {
658 let (ty, subtype) = head.split_once('/')?;
659 if ty.is_empty()
660 || subtype.is_empty()
661 || subtype.contains('/')
662 || !is_mime_token(ty)
663 || !is_mime_token(subtype)
664 {
665 return None;
666 }
667 format!(
668 "{}/{}",
669 ty.to_ascii_lowercase(),
670 subtype.to_ascii_lowercase()
671 )
672 } else {
673 if head.is_empty() || !is_mime_token(head) {
674 return None;
675 }
676 head.to_ascii_lowercase()
677 };
678
679 let mut canonical = canonical_head;
680 for parameter in parts {
681 let parameter = parameter.trim();
682 let (name, raw_value) = parameter.split_once('=')?;
683 let name = name.trim();
684 let raw_value = raw_value.trim();
685 if name.is_empty()
686 || raw_value.is_empty()
687 || !is_mime_token(name)
688 || !is_parameter_value(raw_value)
689 {
690 return None;
691 }
692 canonical.push_str("; ");
693 canonical.push_str(&name.to_ascii_lowercase());
694 canonical.push('=');
695 canonical.push_str(raw_value);
696 }
697
698 Some(canonical)
699}
700
701#[cfg(feature = "serde")]
702impl serde::Serialize for ContentDisposition {
703 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
704 where
705 S: serde::Serializer,
706 {
707 serializer.serialize_str(self.as_str())
708 }
709}
710
711#[cfg(feature = "serde")]
712impl<'de> serde::Deserialize<'de> for ContentDisposition {
713 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
714 where
715 D: serde::Deserializer<'de>,
716 {
717 let value = String::deserialize(deserializer)?;
718 value.parse().map_err(serde::de::Error::custom)
719 }
720}
721
722#[cfg(feature = "schemars")]
723impl schemars::JsonSchema for ContentDisposition {
724 fn inline_schema() -> bool {
725 true
726 }
727
728 fn schema_name() -> std::borrow::Cow<'static, str> {
729 "ContentDisposition".into()
730 }
731
732 fn schema_id() -> std::borrow::Cow<'static, str> {
733 concat!(module_path!(), "::ContentDisposition").into()
734 }
735
736 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
737 schemars::json_schema!({
738 "type": "string",
739 "description": "RFC 2183 Content-Disposition field value"
740 })
741 }
742}
743
744#[cfg(feature = "arbitrary")]
745impl<'a> arbitrary::Arbitrary<'a> for ContentDisposition {
746 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
747 let value = match u.int_in_range::<u8>(0..=2)? {
748 0 => "inline",
749 1 => "attachment",
750 _ => "attachment; filename=example.txt",
751 };
752 value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
753 }
754}
755
756#[cfg(feature = "mime")]
804#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
805#[derive(Clone, Debug, PartialEq, Eq)]
806pub enum MimePart {
807 Leaf {
809 content_type: ContentType,
811 content_transfer_encoding: Option<ContentTransferEncoding>,
813 content_disposition: Option<ContentDisposition>,
815 body: Vec<u8>,
817 },
818 Multipart {
820 content_type: ContentType,
822 boundary: Option<String>,
824 parts: Vec<Self>,
826 },
827}
828
829#[cfg(all(feature = "mime", feature = "serde"))]
830impl serde::Serialize for MimePart {
831 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
832 where
833 S: serde::Serializer,
834 {
835 use base64::Engine as _;
836 use serde::ser::SerializeStruct as _;
837
838 match self {
839 Self::Leaf {
840 content_type,
841 content_transfer_encoding,
842 content_disposition,
843 body,
844 } => {
845 let mut len = 3; if content_transfer_encoding.is_some() {
847 len += 1;
848 }
849 if content_disposition.is_some() {
850 len += 1;
851 }
852 let encoded = base64::engine::general_purpose::STANDARD.encode(body);
853 let mut value = serializer.serialize_struct("MimePart", len)?;
854 value.serialize_field("type", "leaf")?;
855 value.serialize_field("content_type", content_type)?;
856 if let Some(cte) = content_transfer_encoding {
857 value.serialize_field("content_transfer_encoding", cte)?;
858 }
859 if let Some(cd) = content_disposition {
860 value.serialize_field("content_disposition", cd)?;
861 }
862 value.serialize_field("body", &encoded)?;
863 value.end()
864 }
865 Self::Multipart {
866 content_type,
867 boundary,
868 parts,
869 } => {
870 let mut len = 3; if boundary.is_some() {
872 len += 1;
873 }
874 let mut value = serializer.serialize_struct("MimePart", len)?;
875 value.serialize_field("type", "multipart")?;
876 value.serialize_field("content_type", content_type)?;
877 if let Some(b) = boundary {
878 value.serialize_field("boundary", b)?;
879 }
880 value.serialize_field("parts", parts)?;
881 value.end()
882 }
883 }
884 }
885}
886
887#[cfg(all(feature = "mime", feature = "serde"))]
888impl<'de> serde::Deserialize<'de> for MimePart {
889 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
890 where
891 D: serde::Deserializer<'de>,
892 {
893 use base64::Engine as _;
894
895 #[derive(serde::Deserialize)]
896 #[serde(tag = "type", rename_all = "snake_case")]
897 enum RawMimePart {
898 Leaf {
899 content_type: ContentType,
900 #[serde(default)]
901 content_transfer_encoding: Option<ContentTransferEncoding>,
902 #[serde(default)]
903 content_disposition: Option<ContentDisposition>,
904 body: String,
905 },
906 Multipart {
907 content_type: ContentType,
908 #[serde(default)]
909 boundary: Option<String>,
910 #[serde(default)]
911 parts: Vec<MimePart>,
912 },
913 }
914
915 Ok(match RawMimePart::deserialize(deserializer)? {
916 RawMimePart::Leaf {
917 content_type,
918 content_transfer_encoding,
919 content_disposition,
920 body,
921 } => {
922 let decoded = base64::engine::general_purpose::STANDARD
923 .decode(body.as_bytes())
924 .map_err(|err| {
925 serde::de::Error::custom(format!("invalid base64 MIME body: {err}"))
926 })?;
927 Self::Leaf {
928 content_type,
929 content_transfer_encoding,
930 content_disposition,
931 body: decoded,
932 }
933 }
934 RawMimePart::Multipart {
935 content_type,
936 boundary,
937 parts,
938 } => Self::Multipart {
939 content_type,
940 boundary,
941 parts,
942 },
943 })
944 }
945}
946
947#[cfg(all(feature = "mime", feature = "schemars"))]
948impl schemars::JsonSchema for MimePart {
949 fn schema_name() -> std::borrow::Cow<'static, str> {
950 "MimePart".into()
951 }
952
953 fn schema_id() -> std::borrow::Cow<'static, str> {
954 concat!(module_path!(), "::MimePart").into()
955 }
956
957 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
963 let recursive = generator.subschema_for::<MimePart>();
964 schemars::json_schema!({
965 "oneOf": [
966 {
967 "type": "object",
968 "properties": {
969 "type": {"const": "leaf"},
970 "content_type": {
971 "type": "string",
972 "description": "MIME Content-Type field value"
973 },
974 "content_transfer_encoding": {
975 "type": "string",
976 "description": "RFC 2045 Content-Transfer-Encoding token"
977 },
978 "content_disposition": {
979 "type": "string",
980 "description": "RFC 2183 Content-Disposition field value"
981 },
982 "body": {
983 "type": "string",
984 "contentEncoding": "base64",
985 "description": "Base64-encoded MIME part body (RFC 4648, with padding)"
986 }
987 },
988 "required": ["type", "content_type", "body"]
989 },
990 {
991 "type": "object",
992 "properties": {
993 "type": {"const": "multipart"},
994 "content_type": {
995 "type": "string",
996 "description": "MIME Content-Type field value"
997 },
998 "boundary": {"type": "string"},
999 "parts": {"type": "array", "items": recursive}
1000 },
1001 "required": ["type", "content_type", "parts"]
1002 }
1003 ]
1004 })
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use std::collections::HashSet;
1011
1012 use super::{ContentTransferEncoding, ContentType};
1013
1014 #[test]
1015 fn content_type_accepts_valid_media_types_and_parameters() {
1016 for value in [
1017 "text/plain",
1018 "text/plain;charset=utf-8",
1019 "multipart/related; type=\"text/html\"",
1020 "application/octet-stream; name=\"a;b.txt\"",
1021 ] {
1022 assert!(
1023 ContentType::try_from(value).is_ok(),
1024 "expected valid content type: {value}"
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn content_type_rejects_invalid_media_types() {
1031 for value in [
1032 "text/",
1033 "/plain",
1034 "text/plain/html",
1035 "text /plain",
1036 "text/plain; charset",
1037 "text/plain; charset=\"unterminated",
1038 ] {
1039 assert!(
1040 ContentType::try_from(value).is_err(),
1041 "expected invalid content type: {value}"
1042 );
1043 }
1044 }
1045
1046 #[test]
1047 fn content_type_rejects_quoted_parameter_with_control_chars() {
1048 for value in [
1051 "text/plain; name=\"x\u{0}y\"",
1052 "text/plain; name=\"x\u{07}y\"",
1053 "text/plain; name=\"x\u{0B}y\"",
1054 "text/plain; name=\"x\u{1B}y\"",
1055 ] {
1056 assert!(
1057 ContentType::try_from(value).is_err(),
1058 "expected control-char rejection: {value:?}"
1059 );
1060 }
1061 }
1062
1063 #[test]
1064 fn content_type_rejects_quoted_parameter_with_escaped_control_chars() {
1065 for value in [
1067 "text/plain; name=\"x\\\u{0}y\"",
1068 "text/plain; name=\"x\\\u{07}y\"",
1069 ] {
1070 assert!(
1071 ContentType::try_from(value).is_err(),
1072 "expected escaped-control-char rejection: {value:?}"
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn content_type_accepts_tab_inside_quoted_parameter() {
1079 assert!(ContentType::try_from("text/plain; name=\"a\tb\"").is_ok());
1081 }
1082
1083 #[test]
1084 fn content_type_media_type_view_splits_type_and_subtype() {
1085 let ct: ContentType = "text/plain; charset=utf-8".parse().unwrap();
1086 let media = ct.media_type();
1087 assert_eq!(media.type_(), "text");
1088 assert_eq!(media.subtype(), "plain");
1089 assert!(media.is_text());
1090 assert!(!media.is_multipart());
1091 assert!(media.matches("text/plain"));
1092 assert!(media.matches("TEXT/PLAIN"));
1093 }
1094
1095 #[test]
1096 fn content_type_parameter_lookup_is_case_insensitive_and_unquotes() {
1097 let ct: ContentType = "multipart/mixed; Boundary=\"abc\\\"def\"".parse().unwrap();
1098 let boundary = ct.boundary().expect("boundary present");
1099 assert_eq!(boundary.as_raw(), "\"abc\\\"def\"");
1100 assert_eq!(boundary.unquoted().as_ref(), "abc\"def");
1101 }
1102
1103 #[test]
1104 fn content_type_parameters_iterates_in_declaration_order() {
1105 let ct: ContentType = "text/html; charset=utf-8; boundary=x".parse().unwrap();
1106 let pairs: Vec<(String, String)> = ct
1107 .parameters()
1108 .map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
1109 .collect();
1110 assert_eq!(
1111 pairs,
1112 vec![
1113 ("charset".to_owned(), "utf-8".to_owned()),
1114 ("boundary".to_owned(), "x".to_owned()),
1115 ]
1116 );
1117 }
1118
1119 #[test]
1120 fn content_transfer_encoding_canonicalizes_known_tokens() {
1121 assert_eq!(
1122 "Base64"
1123 .parse::<ContentTransferEncoding>()
1124 .unwrap()
1125 .as_str(),
1126 "base64"
1127 );
1128 assert_eq!(
1129 "7BIT".parse::<ContentTransferEncoding>().unwrap().as_str(),
1130 "7bit"
1131 );
1132 assert_eq!(
1133 "Quoted-Printable"
1134 .parse::<ContentTransferEncoding>()
1135 .unwrap(),
1136 ContentTransferEncoding::QuotedPrintable
1137 );
1138
1139 let other: ContentTransferEncoding = "x-my-encoding".parse().unwrap();
1140 assert_eq!(
1141 other,
1142 ContentTransferEncoding::Other("x-my-encoding".to_owned())
1143 );
1144 assert_eq!(other.as_str(), "x-my-encoding");
1145 }
1146
1147 #[test]
1148 fn content_disposition_kind_and_parameter_accessors() {
1149 use super::ContentDisposition;
1150 let cd: ContentDisposition = "attachment; filename=\"report.pdf\""
1151 .parse()
1152 .expect("disposition should parse");
1153 assert_eq!(cd.kind(), "attachment");
1154 assert!(cd.is_attachment());
1155 assert!(!cd.is_inline());
1156 let filename = cd.filename().expect("filename present");
1157 assert_eq!(filename.unquoted().as_ref(), "report.pdf");
1158 }
1159
1160 #[test]
1161 fn content_disposition_filename_falls_back_to_extended_parameter() {
1162 use super::ContentDisposition;
1163 let cd: ContentDisposition = "attachment; filename*=utf-8''f%C3%A1jl.txt"
1164 .parse()
1165 .expect("disposition should parse");
1166
1167 let filename = cd.filename().expect("filename* present");
1168 assert_eq!(filename.as_raw(), "utf-8''f%C3%A1jl.txt");
1169 }
1170
1171 #[test]
1172 fn content_disposition_inline_kind_is_case_insensitive() {
1173 use super::ContentDisposition;
1174 let cd: ContentDisposition = "INLINE".parse().expect("disposition should parse");
1175 assert!(cd.is_inline());
1176 assert!(!cd.is_attachment());
1177 }
1178
1179 #[test]
1180 fn content_disposition_parameters_iterates_in_declaration_order() {
1181 use super::ContentDisposition;
1182 let cd: ContentDisposition = "attachment; filename=report.pdf; size=42".parse().unwrap();
1183 let pairs: Vec<(String, String)> = cd
1184 .parameters()
1185 .map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
1186 .collect();
1187 assert_eq!(
1188 pairs,
1189 vec![
1190 ("filename".to_owned(), "report.pdf".to_owned()),
1191 ("size".to_owned(), "42".to_owned()),
1192 ]
1193 );
1194 }
1195
1196 #[test]
1197 fn content_disposition_parameter_lookup_is_case_insensitive() {
1198 use super::ContentDisposition;
1199 let cd: ContentDisposition = "attachment; FileName=\"x.txt\"".parse().unwrap();
1200 assert_eq!(
1201 cd.parameter("filename").unwrap().unquoted().as_ref(),
1202 "x.txt"
1203 );
1204 assert_eq!(
1205 cd.parameter("FILENAME").unwrap().unquoted().as_ref(),
1206 "x.txt"
1207 );
1208 }
1209
1210 #[test]
1211 fn content_transfer_encoding_other_is_case_insensitive() {
1212 let a: ContentTransferEncoding = "X-MyEnc".parse().unwrap();
1216 let b: ContentTransferEncoding = "x-myenc".parse().unwrap();
1217 let c: ContentTransferEncoding = "X-MYENC".parse().unwrap();
1218 assert_eq!(a, b);
1219 assert_eq!(a, c);
1220 assert_eq!(a.as_str(), "x-myenc");
1221 assert_eq!(c.as_str(), "x-myenc");
1222
1223 let mut set: HashSet<ContentTransferEncoding> = HashSet::new();
1225 set.insert(a);
1226 assert!(set.contains(&b));
1227 assert!(set.contains(&c));
1228 }
1229
1230 #[test]
1231 fn content_type_eq_is_case_insensitive_after_normalize() {
1232 use std::collections::hash_map::DefaultHasher;
1233 use std::hash::{Hash, Hasher};
1234
1235 let upper = ContentType::try_from("TEXT/PLAIN; CHARSET=UTF-8").unwrap();
1236 let lower = ContentType::try_from("text/plain; charset=UTF-8").unwrap();
1237
1238 assert_eq!(upper, lower);
1239 assert_eq!(upper.as_str(), "text/plain; charset=UTF-8");
1240
1241 let mut h_u = DefaultHasher::new();
1242 upper.hash(&mut h_u);
1243 let mut h_l = DefaultHasher::new();
1244 lower.hash(&mut h_l);
1245 assert_eq!(h_u.finish(), h_l.finish());
1246
1247 let preserved = ContentType::try_from("multipart/mixed; BOUNDARY=\"AbC\"").unwrap();
1250 assert_eq!(preserved.as_str(), "multipart/mixed; boundary=\"AbC\"");
1251 }
1252
1253 #[test]
1254 fn content_disposition_eq_is_case_insensitive_after_normalize() {
1255 use super::ContentDisposition;
1256 let upper = ContentDisposition::try_from("ATTACHMENT; FILENAME=\"x.pdf\"").unwrap();
1257 let lower = ContentDisposition::try_from("attachment; filename=\"x.pdf\"").unwrap();
1258
1259 assert_eq!(upper, lower);
1260 assert_eq!(upper.as_str(), "attachment; filename=\"x.pdf\"");
1261 }
1262}