Skip to main content

go_http/mime/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3/// Port of Go's `mime` package.
4///
5/// Covers `ParseMediaType`, `FormatMediaType`, `ExtensionsByType`,
6/// `TypeByExtension`, and the built-in type map.
7pub mod multipart;
8pub mod quotedprintable;
9
10use std::collections::HashMap;
11use std::fmt;
12
13// ---------------------------------------------------------------------------
14// MimeError
15// ---------------------------------------------------------------------------
16
17#[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
36// ---------------------------------------------------------------------------
37// ParseMediaType — port of Go mime.ParseMediaType
38// ---------------------------------------------------------------------------
39
40/// Parse a MIME media-type value and its parameters.
41///
42/// ```text
43/// Content-Type: text/html; charset=utf-8
44/// ```
45/// returns `("text/html", {"charset": "utf-8"})`.
46///
47/// Port of Go's `mime.ParseMediaType`.
48pub 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    // Split off the type/subtype from the parameters.
55    let (media_type, rest) = match v.find(';') {
56        Some(i) => (v[..i].trim(), &v[i + 1..]),
57        None    => (v, ""),
58    };
59
60    // Validate type/subtype.
61    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    // Parse parameters.
73    let params = parse_params(rest)?;
74
75    Ok((media_type, params))
76}
77
78/// Serialize a media type and parameter map back to a string.
79///
80/// Port of Go's `mime.FormatMediaType`.
81pub 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 = &params[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
113// ---------------------------------------------------------------------------
114// Built-in MIME type map — subset of Go's mime/type.go
115// ---------------------------------------------------------------------------
116
117static 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
138/// Return the MIME type for the given file extension (including the dot).
139/// Mirrors Go's `mime.TypeByExtension`.
140pub 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
148/// Return all extensions known for `typ` (e.g. `"image/jpeg"` → `[".jpeg", ".jpg"]`).
149/// Mirrors Go's `mime.ExtensionsByType`.
150pub 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
162// ---------------------------------------------------------------------------
163// Detect content type — port of Go net/http DetectContentType
164// ---------------------------------------------------------------------------
165
166/// Inspect up to the first 512 bytes and return a MIME type string.
167/// Mirrors Go's `http.DetectContentType`, which wraps `sniff.go`.
168pub fn detect_content_type(data: &[u8]) -> &'static str {
169    let d = &data[..data.len().min(512)];
170
171    // HTML sniff (very common).
172    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    // Exact byte signatures.
180    for &(sig, mime) in EXACT_SIGS {
181        if d.len() >= sig.len() && &d[..sig.len()] == sig {
182            return mime;
183        }
184    }
185
186    // Plain text: all bytes valid UTF-8 and no control bytes.
187    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
228// ---------------------------------------------------------------------------
229// Internal helpers
230// ---------------------------------------------------------------------------
231
232fn 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
248/// Parse `; key=value` parameter list.
249fn 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            // Token value — read until ';' or end.
268            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
278/// Consume a quoted-string body (after the opening `"`), returning
279/// (unescaped_value, remaining_input).
280fn 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                // Closing quote — next chars.next() gives the remaining slice.
288                let remaining = match chars.next() {
289                    None         => "",
290                    Some((i, _)) => &s[i..],
291                };
292                // Rewind one char — we consumed the char after the closing quote.
293                // Instead, split at the closing quote position.
294                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", &params).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}