1pub mod multipart;
8pub mod quotedprintable;
9
10use std::collections::HashMap;
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum MimeError {
19 InvalidMediaType,
20 InvalidParameter,
21 Other(String),
22}
23
24impl fmt::Display for MimeError {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::InvalidMediaType => write!(f, "invalid media type"),
28 Self::InvalidParameter => write!(f, "invalid MIME parameter"),
29 Self::Other(s) => write!(f, "{s}"),
30 }
31 }
32}
33
34impl std::error::Error for MimeError {}
35
36pub fn parse_media_type(v: &str) -> Result<(String, HashMap<String, String>), MimeError> {
49 let v = v.trim();
50 if v.is_empty() {
51 return Err(MimeError::InvalidMediaType);
52 }
53
54 let (media_type, rest) = match v.find(';') {
56 Some(i) => (v[..i].trim(), &v[i + 1..]),
57 None => (v, ""),
58 };
59
60 let media_type = media_type.to_ascii_lowercase();
62 if !media_type.contains('/') {
63 return Err(MimeError::InvalidMediaType);
64 }
65 let slash = media_type.find('/').unwrap();
66 let t = &media_type[..slash];
67 let s = &media_type[slash + 1..];
68 if !is_token(t) || !is_token(s) {
69 return Err(MimeError::InvalidMediaType);
70 }
71
72 let params = parse_params(rest)?;
74
75 Ok((media_type, params))
76}
77
78pub fn format_media_type(t: &str, params: &HashMap<String, String>) -> Option<String> {
82 let t = t.to_ascii_lowercase();
83 if !t.contains('/') {
84 return None;
85 }
86 let slash = t.find('/').unwrap();
87 if !is_token(&t[..slash]) || !is_token(&t[slash + 1..]) {
88 return None;
89 }
90
91 let mut out = t;
92 let mut keys: Vec<&String> = params.keys().collect();
93 keys.sort();
94 for k in keys {
95 let v = ¶ms[k];
96 out.push_str("; ");
97 out.push_str(&k.to_ascii_lowercase());
98 out.push('=');
99 if needs_quoting(v) {
100 out.push('"');
101 for c in v.chars() {
102 if c == '"' || c == '\\' { out.push('\\'); }
103 out.push(c);
104 }
105 out.push('"');
106 } else {
107 out.push_str(v);
108 }
109 }
110 Some(out)
111}
112
113static TYPES_BY_EXT: &[(&str, &str)] = &[
118 (".avif", "image/avif"),
119 (".css", "text/css; charset=utf-8"),
120 (".gif", "image/gif"),
121 (".htm", "text/html; charset=utf-8"),
122 (".html", "text/html; charset=utf-8"),
123 (".jpeg", "image/jpeg"),
124 (".jpg", "image/jpeg"),
125 (".js", "text/javascript; charset=utf-8"),
126 (".json", "application/json"),
127 (".mjs", "text/javascript; charset=utf-8"),
128 (".pdf", "application/pdf"),
129 (".png", "image/png"),
130 (".svg", "image/svg+xml"),
131 (".txt", "text/plain; charset=utf-8"),
132 (".wasm", "application/wasm"),
133 (".webp", "image/webp"),
134 (".xml", "text/xml; charset=utf-8"),
135 (".zip", "application/zip"),
136];
137
138pub fn type_by_extension(ext: &str) -> Option<&'static str> {
141 let ext = ext.to_ascii_lowercase();
142 TYPES_BY_EXT
143 .iter()
144 .find(|(e, _)| *e == ext.as_str())
145 .map(|(_, t)| *t)
146}
147
148pub fn extensions_by_type(typ: &str) -> Vec<&'static str> {
151 let base = typ.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
152 TYPES_BY_EXT
153 .iter()
154 .filter(|(_, t)| {
155 let t_base = t.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
156 t_base == base
157 })
158 .map(|(e, _)| *e)
159 .collect()
160}
161
162pub fn detect_content_type(data: &[u8]) -> &'static str {
169 let d = &data[..data.len().min(512)];
170
171 let trimmed = trim_whitespace(d);
173 for sig in HTML_SIGS {
174 if starts_with_ci(trimmed, sig) {
175 return "text/html; charset=utf-8";
176 }
177 }
178
179 for &(sig, mime) in EXACT_SIGS {
181 if d.len() >= sig.len() && &d[..sig.len()] == sig {
182 return mime;
183 }
184 }
185
186 if d.iter().all(|&b| b >= 0x20 || b == b'\t' || b == b'\n' || b == b'\r') {
188 return "text/plain; charset=utf-8";
189 }
190
191 "application/octet-stream"
192}
193
194const HTML_SIGS: &[&[u8]] = &[
195 b"<!DOCTYPE", b"<html", b"<head", b"<script", b"<iframe",
196 b"<h1", b"<h2", b"<h3", b"<h4", b"<h5", b"<h6",
197 b"<font", b"<table", b"<a ", b"<style", b"<title",
198 b"<b", b"<body", b"<br", b"<p",
199];
200
201const EXACT_SIGS: &[(&[u8], &str)] = &[
202 (b"\xFF\xD8\xFF", "image/jpeg"),
203 (b"\x89PNG\r\n\x1a\n", "image/png"),
204 (b"GIF87a", "image/gif"),
205 (b"GIF89a", "image/gif"),
206 (b"RIFF", "audio/wave"),
207 (b"\x1f\x8b", "application/x-gzip"),
208 (b"PK\x03\x04", "application/zip"),
209 (b"%PDF-", "application/pdf"),
210 (b"\x00\x00\x01\x00", "image/x-icon"),
211 (b"<?xml", "text/xml; charset=utf-8"),
212 (b"<svg", "image/svg+xml"),
213];
214
215fn trim_whitespace(d: &[u8]) -> &[u8] {
216 let start = d.iter().position(|&b| !b.is_ascii_whitespace()).unwrap_or(d.len());
217 &d[start..]
218}
219
220fn starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool {
221 haystack.len() >= needle.len()
222 && haystack[..needle.len()]
223 .iter()
224 .zip(needle.iter())
225 .all(|(a, b)| a.to_ascii_lowercase() == b.to_ascii_lowercase())
226}
227
228fn is_token(s: &str) -> bool {
233 !s.is_empty() && s.bytes().all(super::mime::is_token_byte)
234}
235
236fn is_token_byte(b: u8) -> bool {
237 matches!(b,
238 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
239 | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
240 | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
241 )
242}
243
244fn needs_quoting(s: &str) -> bool {
245 !s.bytes().all(is_token_byte)
246}
247
248fn parse_params(s: &str) -> Result<HashMap<String, String>, MimeError> {
250 let mut params = HashMap::new();
251 let mut rest = s;
252 while !rest.trim().is_empty() {
253 rest = rest.trim_start_matches(|c: char| c == ';' || c.is_ascii_whitespace());
254 if rest.is_empty() {
255 break;
256 }
257 let eq = rest.find('=').ok_or(MimeError::InvalidParameter)?;
258 let key = rest[..eq].trim().to_ascii_lowercase();
259 if !is_token(&key) {
260 return Err(MimeError::InvalidParameter);
261 }
262 rest = rest[eq + 1..].trim_start();
263
264 let (value, remaining) = if rest.starts_with('"') {
265 consume_quoted_string(&rest[1..])?
266 } else {
267 let end = rest.find(';').unwrap_or(rest.len());
269 (rest[..end].trim().to_owned(), &rest[end..])
270 };
271
272 params.insert(key, value);
273 rest = remaining;
274 }
275 Ok(params)
276}
277
278fn consume_quoted_string(s: &str) -> Result<(String, &str), MimeError> {
281 let mut out = String::new();
282 let mut chars = s.char_indices();
283 loop {
284 match chars.next() {
285 None => return Err(MimeError::InvalidParameter),
286 Some((_, '"')) => {
287 let remaining = match chars.next() {
289 None => "",
290 Some((i, _)) => &s[i..],
291 };
292 let close_pos = s[..s.len() - remaining.len() - 1].len();
295 return Ok((out, &s[close_pos + 1..]));
296 }
297 Some((_, '\\')) => {
298 match chars.next() {
299 None => return Err(MimeError::InvalidParameter),
300 Some((_, c)) => out.push(c),
301 }
302 }
303 Some((_, c)) => out.push(c),
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn parse_simple() {
314 let (t, p) = parse_media_type("text/html; charset=utf-8").unwrap();
315 assert_eq!(t, "text/html");
316 assert_eq!(p["charset"], "utf-8");
317 }
318
319 #[test]
320 fn parse_no_params() {
321 let (t, p) = parse_media_type("application/json").unwrap();
322 assert_eq!(t, "application/json");
323 assert!(p.is_empty());
324 }
325
326 #[test]
327 fn parse_quoted_param() {
328 let (t, p) = parse_media_type(r#"multipart/form-data; boundary="----abc""#).unwrap();
329 assert_eq!(t, "multipart/form-data");
330 assert_eq!(p["boundary"], "----abc");
331 }
332
333 #[test]
334 fn format_round_trip() {
335 let mut params = HashMap::new();
336 params.insert("charset".into(), "utf-8".into());
337 let s = format_media_type("text/html", ¶ms).unwrap();
338 assert_eq!(s, "text/html; charset=utf-8");
339 }
340
341 #[test]
342 fn type_by_ext() {
343 assert!(type_by_extension(".html").unwrap().contains("text/html"));
344 assert!(type_by_extension(".png").unwrap().contains("image/png"));
345 assert!(type_by_extension(".unknown").is_none());
346 }
347
348 #[test]
349 fn detect_png() {
350 let png = b"\x89PNG\r\n\x1a\nsome data";
351 assert_eq!(detect_content_type(png), "image/png");
352 }
353
354 #[test]
355 fn detect_html() {
356 let html = b"<!DOCTYPE html><html><body></body></html>";
357 assert_eq!(detect_content_type(html), "text/html; charset=utf-8");
358 }
359
360 #[test]
361 fn detect_text() {
362 let text = b"Hello, plain text here.";
363 assert_eq!(detect_content_type(text), "text/plain; charset=utf-8");
364 }
365
366 #[test]
367 fn detect_binary() {
368 let bin: &[u8] = &[0x00, 0x01, 0x02, 0x03];
369 assert_eq!(detect_content_type(bin), "application/octet-stream");
370 }
371}