Skip to main content

dataurl/
mime.rs

1//! MIME type parsing and serialization, as defined by the WHATWG MIME Sniffing
2//! Standard (<https://mimesniff.spec.whatwg.org/#parsing-a-mime-type>).
3//!
4//! This is the parser used for data URLs, but it works for any MIME type,
5//! such as the value of a `Content-Type` header:
6//!
7//! ```
8//! use dataurl::mime::MimeType;
9//!
10//! let mime_type: MimeType = "Text/HTML; charset=\"Shift_JIS\"; foo=bar".parse()?;
11//! assert_eq!(mime_type.essence(), "text/html");
12//! assert_eq!(mime_type.main_type(), "text");
13//! assert_eq!(mime_type.subtype(), "html");
14//! assert_eq!(mime_type.charset(), Some("Shift_JIS"));
15//! assert_eq!(mime_type.to_string(), "text/html;charset=Shift_JIS;foo=bar");
16//! # Ok::<(), dataurl::Error>(())
17//! ```
18
19use std::fmt;
20use std::str::FromStr;
21
22use crate::Error;
23
24pub(crate) type Parameters = Vec<(String, String)>;
25
26/// A parsed MIME type: its essence (`type/subtype`, lowercase) and its parameters.
27///
28/// Parameter names are lowercase, and only the first occurrence of each is kept,
29/// the way browsers do it. Parameters that aren't valid are dropped rather than
30/// making the whole MIME type invalid.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct MimeType {
33    pub(crate) essence: String,
34    pub(crate) parameters: Parameters,
35}
36
37impl MimeType {
38    /// Parses a MIME type, failing if its `type/subtype` part isn't valid.
39    pub fn parse(input: &str) -> Result<MimeType, Error> {
40        parse(input).ok_or(Error::InvalidMediaType)
41    }
42
43    /// The `type/subtype` part, lowercase (e.g. `"image/svg+xml"`).
44    pub fn essence(&self) -> &str {
45        &self.essence
46    }
47
48    /// The part before the slash (e.g. `"image"`).
49    pub fn main_type(&self) -> &str {
50        self.essence
51            .split_once('/')
52            .map_or("", |(main_type, _)| main_type)
53    }
54
55    /// The part after the slash (e.g. `"svg+xml"`).
56    pub fn subtype(&self) -> &str {
57        self.essence
58            .split_once('/')
59            .map_or("", |(_, subtype)| subtype)
60    }
61
62    /// Iterates over parameters as `(name, value)` pairs, in order.
63    pub fn parameters(&self) -> impl Iterator<Item = (&str, &str)> {
64        self.parameters
65            .iter()
66            .map(|(n, v)| (n.as_str(), v.as_str()))
67    }
68
69    /// Returns the value of a parameter (names are case-insensitive).
70    pub fn parameter(&self, name: &str) -> Option<&str> {
71        self.parameters
72            .iter()
73            .find(|(n, _)| n.eq_ignore_ascii_case(name))
74            .map(|(_, v)| v.as_str())
75    }
76
77    /// The charset parameter, if present.
78    pub fn charset(&self) -> Option<&str> {
79        self.parameter("charset")
80    }
81}
82
83impl fmt::Display for MimeType {
84    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
85        let mut output = self.essence.clone();
86        serialize_parameters(&mut output, &self.parameters);
87        f.write_str(&output)
88    }
89}
90
91impl FromStr for MimeType {
92    type Err = Error;
93
94    fn from_str(input: &str) -> Result<MimeType, Error> {
95        MimeType::parse(input)
96    }
97}
98
99impl TryFrom<&str> for MimeType {
100    type Error = Error;
101
102    fn try_from(input: &str) -> Result<MimeType, Error> {
103        MimeType::parse(input)
104    }
105}
106
107pub(crate) fn is_http_whitespace(c: char) -> bool {
108    matches!(c, '\t' | '\n' | '\r' | ' ')
109}
110
111pub(crate) fn is_token_char(c: char) -> bool {
112    c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
113}
114
115fn is_quoted_string_token_char(c: char) -> bool {
116    matches!(c, '\t' | ' '..='~' | '\u{80}'..='\u{ff}')
117}
118
119pub(crate) fn is_valid_essence(type_or_subtype: &str) -> bool {
120    !type_or_subtype.is_empty() && type_or_subtype.chars().all(is_token_char)
121}
122
123/// Returns None if the input is not a valid MIME type.
124pub(crate) fn parse(input: &str) -> Option<MimeType> {
125    let s = input.trim_matches(is_http_whitespace);
126    let b = s.as_bytes();
127
128    let (top_level_type, rest) = s.split_once('/')?;
129    if !is_valid_essence(top_level_type) {
130        return None;
131    }
132
133    let subtype_end = rest.find(';').unwrap_or(rest.len());
134    let subtype = rest[..subtype_end].trim_end_matches(is_http_whitespace);
135    if !is_valid_essence(subtype) {
136        return None;
137    }
138
139    let mut mime_type = MimeType {
140        essence: format!(
141            "{}/{}",
142            top_level_type.to_ascii_lowercase(),
143            subtype.to_ascii_lowercase()
144        ),
145        parameters: Vec::new(),
146    };
147
148    // All delimiters are ASCII, so byte offsets always land on char boundaries
149    let mut pos = top_level_type.len() + 1 + subtype_end;
150    while pos < b.len() {
151        // Skip past the semicolon and any whitespace after it
152        pos += 1;
153        while pos < b.len() && is_http_whitespace(b[pos] as char) {
154            pos += 1;
155        }
156
157        let name_start = pos;
158        while pos < b.len() && b[pos] != b';' && b[pos] != b'=' {
159            pos += 1;
160        }
161        let name = s[name_start..pos].to_ascii_lowercase();
162
163        if pos < b.len() {
164            if b[pos] == b';' {
165                continue;
166            }
167            pos += 1; // Skip past "="
168        }
169        if pos >= b.len() {
170            break;
171        }
172
173        let value: String = if b[pos] == b'"' {
174            let (value, new_pos) = collect_http_quoted_string(s, pos);
175            pos = new_pos;
176            while pos < b.len() && b[pos] != b';' {
177                pos += 1;
178            }
179            value
180        } else {
181            let value_start = pos;
182            while pos < b.len() && b[pos] != b';' {
183                pos += 1;
184            }
185            let value = s[value_start..pos].trim_end_matches(is_http_whitespace);
186            if value.is_empty() {
187                continue;
188            }
189            value.to_string()
190        };
191
192        if !name.is_empty()
193            && name.chars().all(is_token_char)
194            && value.chars().all(is_quoted_string_token_char)
195            && !mime_type.parameters.iter().any(|(n, _)| *n == name)
196        {
197            mime_type.parameters.push((name, value));
198        }
199    }
200
201    Some(mime_type)
202}
203
204// https://fetch.spec.whatwg.org/#collect-an-http-quoted-string (with extract-value set to true)
205fn collect_http_quoted_string(s: &str, mut pos: usize) -> (String, usize) {
206    let b = s.as_bytes();
207    let mut value = String::new();
208
209    pos += 1; // Skip past the opening quote
210    loop {
211        let start = pos;
212        while pos < b.len() && b[pos] != b'"' && b[pos] != b'\\' {
213            pos += 1;
214        }
215        value.push_str(&s[start..pos]);
216
217        if pos >= b.len() {
218            break;
219        }
220
221        let quote_or_backslash = b[pos];
222        pos += 1;
223        if quote_or_backslash == b'\\' {
224            match s[pos..].chars().next() {
225                Some(c) => {
226                    value.push(c);
227                    pos += c.len_utf8();
228                }
229                None => {
230                    value.push('\\');
231                    break;
232                }
233            }
234        } else {
235            break;
236        }
237    }
238
239    (value, pos)
240}
241
242pub(crate) fn serialize_parameters(output: &mut String, parameters: &[(String, String)]) {
243    for (name, value) in parameters {
244        output.push(';');
245        output.push_str(name);
246        output.push('=');
247        if !value.is_empty() && value.chars().all(is_token_char) {
248            output.push_str(value);
249        } else {
250            output.push('"');
251            for c in value.chars() {
252                if c == '"' || c == '\\' {
253                    output.push('\\');
254                }
255                output.push(c);
256            }
257            output.push('"');
258        }
259    }
260}
261
262#[cfg(test)]
263#[path = "../tests/common/json.rs"]
264mod json;
265
266#[cfg(test)]
267mod tests {
268    use super::json::{self, Json};
269    use super::*;
270    use encoding_rs::Encoding;
271
272    fn run_wpt_mime_type_tests(fixture: &str) {
273        let mut count = 0;
274
275        for test in json::parse(fixture).as_array() {
276            // Strings in between test objects are section titles
277            if let Json::Object(_) = test {
278                let input = test.get("input").as_str();
279                let expected = test.get("output");
280
281                match parse(input) {
282                    Some(mime_type) => {
283                        let mut serialized = mime_type.essence.clone();
284                        serialize_parameters(&mut serialized, &mime_type.parameters);
285                        assert_eq!(
286                            Some(serialized.as_str()),
287                            expected.as_opt_str(),
288                            "{input:?}"
289                        );
290
291                        // The "encoding" fields aren't in every fixture
292                        if let Some(expected_encoding) = test.get_opt("encoding") {
293                            let encoding = mime_type
294                                .parameters
295                                .iter()
296                                .find(|(name, _)| name == "charset")
297                                .and_then(|(_, value)| Encoding::for_label(value.as_bytes()))
298                                .map(|e| e.name());
299                            assert_eq!(encoding, expected_encoding.as_opt_str(), "{input:?}");
300                        }
301                    }
302                    None => assert!(expected.is_null(), "{input:?} must not be parsed"),
303                }
304
305                count += 1;
306            }
307        }
308
309        assert!(count > 0);
310    }
311
312    #[test]
313    fn wpt_mime_types() {
314        run_wpt_mime_type_tests(include_str!("../tests/wpt/mime-types.json"));
315    }
316
317    #[test]
318    fn wpt_generated_mime_types() {
319        run_wpt_mime_type_tests(include_str!("../tests/wpt/generated-mime-types.json"));
320    }
321}