Skip to main content

media_type/
media_guess.rs

1//! Guessing of media types by file extension, and the reverse lookup.
2
3use core::{cmp::Ordering, iter, slice};
4
5#[cfg(any(feature = "alloc", feature = "reverse-lookup"))]
6use crate::MediaType;
7
8include!("media_types.rs");
9
10#[cfg(feature = "reverse-lookup")]
11include!(env!("REVERSE_LOOKUP_PATH"));
12
13#[cfg(feature = "reverse-lookup")]
14#[derive(Copy, Clone)]
15struct TopLevelExts {
16    start: usize,
17    end: usize,
18    subs: &'static [(&'static str, (usize, usize))],
19}
20
21fn get_media_types(ext: &str) -> Option<&'static [&'static str]> {
22    map_lookup(MEDIA_TYPES, ext)
23}
24
25/// Get the extensions for a given top-level and sub-level of a media type
26/// (`{toplevel}/{sublevel}`).
27///
28/// Returns `None` if `toplevel` or `sublevel` are unknown.
29///
30/// # Wildcards
31///
32/// If the top-level type is a wildcard (`*`), returns all known extensions.
33///
34/// If the sub-level is a wildcard, returns all extensions for the top-level type.
35#[cfg(feature = "reverse-lookup")]
36pub fn get_extensions(toplevel: &str, sublevel: &str) -> Option<&'static [&'static str]> {
37    if toplevel == "*" {
38        return Some(EXTS);
39    }
40
41    let top = map_lookup(REV_MAPPINGS, toplevel)?;
42
43    if sublevel == "*" {
44        return Some(&EXTS[top.start..top.end]);
45    }
46
47    let sub = map_lookup(top.subs, sublevel)?;
48    Some(&EXTS[sub.0..sub.1])
49}
50
51/// Get a list of known extensions for a given [`MediaType`].
52///
53/// Parameters are ignored: only the type and subtype are searched.
54///
55/// Returns `None` if the media type is unknown. See [`get_extensions`] for the wildcard behavior.
56#[cfg(feature = "reverse-lookup")]
57pub fn get_media_extensions(media_type: &MediaType) -> Option<&'static [&'static str]> {
58    get_extensions(media_type.type_(), media_type.subtype())
59}
60
61/// Get a list of known extensions for a media type string.
62///
63/// Parameters (everything after the first `;`) are ignored. The search is case-insensitive.
64///
65/// Returns `None` if `media_type` is not valid or unknown. See [`get_extensions`] for the wildcard
66/// behavior.
67#[cfg(feature = "reverse-lookup")]
68pub fn get_media_extensions_str(mut media_type: &str) -> Option<&'static [&'static str]> {
69    media_type = media_type.trim();
70
71    if let Some(separator) = media_type.find(';') {
72        media_type = &media_type[..separator];
73    }
74
75    let (toplevel, sublevel) = {
76        let slash = media_type.find('/')?;
77        (&media_type[..slash], &media_type[slash + 1..])
78    };
79
80    get_extensions(toplevel, sublevel)
81}
82
83/// A guess of the media type(s) of a file extension or path, as one or more [`MediaType`] values.
84///
85/// # Ordering
86///
87/// A given file format may have one or more applicable media types. The first one is the one
88/// declared by the most recent IETF RFC or the one that explicitly supersedes all others; the
89/// ordering of the additional ones is arbitrary.
90///
91/// # Values are not stable
92///
93/// The exact media types returned by a guess are not part of the crate's stable API and may be
94/// updated in patch releases to be as accurate as possible.
95#[derive(Copy, Clone, Debug, PartialEq, Eq)]
96pub struct MediaTypeGuess(&'static [&'static str]);
97
98impl MediaTypeGuess {
99    /// Guess the media type(s) of a file with the given extension.
100    ///
101    /// The search is case-insensitive. If `ext` is empty or has no known media type, an empty guess
102    /// is returned.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// # #[cfg(feature = "alloc")] {
108    /// assert_eq!(media_type::from_ext("png").first(), Some(media_type::IMAGE_PNG));
109    /// assert!(media_type::from_ext("").is_empty());
110    /// # }
111    /// ```
112    pub fn from_ext(ext: &str) -> MediaTypeGuess {
113        if ext.is_empty() {
114            return MediaTypeGuess(&[]);
115        }
116
117        get_media_types(ext).map_or(MediaTypeGuess(&[]), MediaTypeGuess)
118    }
119
120    /// Guess the media type(s) of `path` by its extension (as defined by
121    /// [`Path::extension`](std::path::Path::extension)). **No disk access is performed.**
122    ///
123    /// If `path` has no extension, its extension is not valid UTF-8, or it has no known media type,
124    /// an empty guess is returned. The search is case-insensitive.
125    ///
126    /// # Note
127    ///
128    /// There is no guarantee that the contents of the file match the media type associated with its
129    /// extension. Take care when making assumptions based on the return value of this function.
130    #[cfg(feature = "std")]
131    pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> MediaTypeGuess {
132        path.as_ref()
133            .extension()
134            .and_then(std::ffi::OsStr::to_str)
135            .map_or(MediaTypeGuess(&[]), MediaTypeGuess::from_ext)
136    }
137
138    /// Returns `true` if the guess did not find any media type for the given extension or path.
139    pub fn is_empty(&self) -> bool {
140        self.0.is_empty()
141    }
142
143    /// Return the number of media types in this guess.
144    pub fn count(&self) -> usize {
145        self.0.len()
146    }
147
148    /// Get the first guessed [`MediaType`], if any.
149    #[cfg(feature = "alloc")]
150    pub fn first(&self) -> Option<MediaType> {
151        self.first_raw().map(expect_media_type)
152    }
153
154    /// Get the first guessed media type as a raw string, if any.
155    pub fn first_raw(&self) -> Option<&'static str> {
156        self.0.first().copied()
157    }
158
159    /// Get the first guessed [`MediaType`], or [`APPLICATION_OCTET_STREAM`] if the guess is empty.
160    ///
161    /// [`APPLICATION_OCTET_STREAM`]: crate::APPLICATION_OCTET_STREAM
162    #[cfg(feature = "alloc")]
163    pub fn first_or_octet_stream(&self) -> MediaType {
164        self.first_or(crate::APPLICATION_OCTET_STREAM)
165    }
166
167    /// Get the first guessed [`MediaType`], or [`TEXT_PLAIN`] if the guess is empty.
168    ///
169    /// [`TEXT_PLAIN`]: crate::TEXT_PLAIN
170    #[cfg(feature = "alloc")]
171    pub fn first_or_text_plain(&self) -> MediaType {
172        self.first_or(crate::TEXT_PLAIN)
173    }
174
175    /// Get the first guessed [`MediaType`], or the given `default` if the guess is empty.
176    #[cfg(feature = "alloc")]
177    pub fn first_or(&self, default: MediaType) -> MediaType {
178        self.first().unwrap_or(default)
179    }
180
181    /// Get the first guessed [`MediaType`], or the result of `default` if the guess is empty.
182    #[cfg(feature = "alloc")]
183    pub fn first_or_else<F>(&self, default: F) -> MediaType
184    where
185        F: FnOnce() -> MediaType,
186    {
187        self.first().unwrap_or_else(default)
188    }
189
190    /// Return an iterator over the [`MediaType`] values contained in this guess.
191    #[cfg(feature = "alloc")]
192    pub fn iter(&self) -> Iter {
193        Iter(self.iter_raw().map(expect_media_type))
194    }
195
196    /// Return an iterator over the raw media type strings contained in this guess.
197    pub fn iter_raw(&self) -> IterRaw {
198        IterRaw(self.0.iter().copied())
199    }
200}
201
202#[cfg(feature = "alloc")]
203impl IntoIterator for MediaTypeGuess {
204    type Item = MediaType;
205    type IntoIter = Iter;
206
207    fn into_iter(self) -> Self::IntoIter {
208        self.iter()
209    }
210}
211
212#[cfg(feature = "alloc")]
213impl<'a> IntoIterator for &'a MediaTypeGuess {
214    type Item = MediaType;
215    type IntoIter = Iter;
216
217    fn into_iter(self) -> Self::IntoIter {
218        self.iter()
219    }
220}
221
222/// An iterator over the [`MediaType`] values of a [`MediaTypeGuess`].
223#[cfg(feature = "alloc")]
224#[derive(Clone, Debug)]
225pub struct Iter(iter::Map<IterRaw, fn(&'static str) -> MediaType>);
226
227#[cfg(feature = "alloc")]
228impl Iterator for Iter {
229    type Item = MediaType;
230
231    fn next(&mut self) -> Option<Self::Item> {
232        self.0.next()
233    }
234
235    fn size_hint(&self) -> (usize, Option<usize>) {
236        self.0.size_hint()
237    }
238}
239
240#[cfg(feature = "alloc")]
241impl DoubleEndedIterator for Iter {
242    fn next_back(&mut self) -> Option<Self::Item> {
243        self.0.next_back()
244    }
245}
246
247#[cfg(feature = "alloc")]
248impl ExactSizeIterator for Iter {
249    fn len(&self) -> usize {
250        self.0.len()
251    }
252}
253
254#[cfg(feature = "alloc")]
255impl iter::FusedIterator for Iter {}
256
257/// An iterator over the raw media type strings of a [`MediaTypeGuess`].
258#[derive(Clone, Debug)]
259pub struct IterRaw(iter::Copied<slice::Iter<'static, &'static str>>);
260
261impl Iterator for IterRaw {
262    type Item = &'static str;
263
264    fn next(&mut self) -> Option<Self::Item> {
265        self.0.next()
266    }
267
268    fn size_hint(&self) -> (usize, Option<usize>) {
269        self.0.size_hint()
270    }
271}
272
273impl DoubleEndedIterator for IterRaw {
274    fn next_back(&mut self) -> Option<Self::Item> {
275        self.0.next_back()
276    }
277}
278
279impl ExactSizeIterator for IterRaw {
280    fn len(&self) -> usize {
281        self.0.len()
282    }
283}
284
285impl iter::FusedIterator for IterRaw {}
286
287/// Guess the media type(s) of a file with the given extension.
288///
289/// See [`MediaTypeGuess::from_ext`].
290pub fn from_ext(ext: &str) -> MediaTypeGuess {
291    MediaTypeGuess::from_ext(ext)
292}
293
294/// Guess the media type(s) of `path` by its extension.
295///
296/// See [`MediaTypeGuess::from_path`].
297#[cfg(feature = "std")]
298pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> MediaTypeGuess {
299    MediaTypeGuess::from_path(path)
300}
301
302#[cfg(feature = "alloc")]
303fn expect_media_type(s: &str) -> MediaType {
304    // The strings come from the static table, so they always parse.
305    s.parse()
306        .unwrap_or_else(|error| panic!("failed to parse media type {:?}: {}", s, error))
307}
308
309/// Looks up `key` in `map`, comparing ASCII letters case-insensitively.
310///
311/// `map` must be sorted by its keys in ascending byte order and the stored keys must be lowercase
312/// ASCII, so that folding only the query keeps the ordering valid. No allocation is performed.
313fn map_lookup<K, V>(map: &'static [(K, V)], key: &str) -> Option<V>
314where
315    K: Copy + Into<&'static str>,
316    V: Copy,
317{
318    let mut left = 0;
319    let mut right = map.len();
320
321    while left < right {
322        let mid = left + (right - left) / 2;
323        match cmp_ignore_ascii_case(map[mid].0.into(), key) {
324            Ordering::Less => left = mid + 1,
325            Ordering::Greater => right = mid,
326            Ordering::Equal => return Some(map[mid].1),
327        }
328    }
329
330    None
331}
332
333/// Compares two strings byte-wise, folding ASCII letters to lowercase.
334fn cmp_ignore_ascii_case(a: &str, b: &str) -> Ordering {
335    let (a, b) = (a.as_bytes(), b.as_bytes());
336    let common = a.len().min(b.len());
337
338    for i in 0..common {
339        let (ca, cb) = (a[i].to_ascii_lowercase(), b[i].to_ascii_lowercase());
340        if ca != cb {
341            return ca.cmp(&cb);
342        }
343    }
344
345    a.len().cmp(&b.len())
346}
347
348#[cfg(test)]
349mod tests {
350    #![allow(clippy::needless_borrow)]
351
352    #[cfg(feature = "alloc")]
353    use alloc::string::ToString;
354
355    use super::*;
356
357    #[test]
358    fn test_type_bounds() {
359        fn assert_bounds<T: Clone + core::fmt::Debug + Send + Sync + 'static>() {}
360
361        assert_bounds::<MediaTypeGuess>();
362        assert_bounds::<IterRaw>();
363        #[cfg(feature = "alloc")]
364        assert_bounds::<Iter>();
365    }
366
367    #[test]
368    fn test_are_extensions_ascii() {
369        for (ext, _) in MEDIA_TYPES {
370            assert!(ext.is_ascii(), "extension is not ASCII: {:?}", ext);
371        }
372    }
373
374    #[test]
375    fn test_are_extensions_sorted() {
376        // This also checks that duplicate extension entries are adjacent.
377        for (&(ext, _), &(next, _)) in MEDIA_TYPES.iter().zip(MEDIA_TYPES.iter().skip(1)) {
378            assert!(
379                ext <= next,
380                "extensions in media_types.rs must be sorted lexicographically: {:?} <= {:?}",
381                ext,
382                next
383            );
384        }
385    }
386
387    #[cfg(feature = "alloc")]
388    #[test]
389    fn test_are_media_types_parseable() {
390        for (_, media_types) in MEDIA_TYPES {
391            for value in *media_types {
392                expect_media_type(value);
393            }
394        }
395    }
396
397    #[cfg(feature = "alloc")]
398    #[test]
399    fn test_media_type_guessing() {
400        assert_eq!(from_ext("gif").first_or_octet_stream().to_string(), "image/gif");
401        assert_eq!(from_ext("TXT").first_or_octet_stream().to_string(), "text/plain");
402        assert_eq!(
403            from_ext("blahblah").first_or_octet_stream().to_string(),
404            "application/octet-stream"
405        );
406        assert_eq!(from_ext("gif").first().unwrap().to_string(), "image/gif");
407        assert_eq!(from_ext("blahblah").first(), None);
408        assert_eq!(from_ext("zst").first(), Some(crate::APPLICATION_ZSTD));
409    }
410
411    #[cfg(all(feature = "std", feature = "alloc"))]
412    #[test]
413    fn test_media_type_guessing_from_path() {
414        assert_eq!(from_path("/path/to/file.gif").first(), Some(crate::IMAGE_GIF));
415        assert_eq!(from_path("file").first(), None);
416    }
417
418    #[cfg(feature = "reverse-lookup")]
419    #[test]
420    fn test_get_media_extensions_str_no_panic_if_bad_media_type() {
421        assert_eq!(get_media_extensions_str(""), None);
422    }
423
424    #[cfg(feature = "reverse-lookup")]
425    #[test]
426    fn test_reverse_mappings() {
427        assert!(get_extensions("image", "png").unwrap().contains(&"png"));
428        assert!(get_extensions("image", "*").unwrap().contains(&"png"));
429        assert!(get_extensions("*", "*").unwrap().contains(&"png"));
430        assert_eq!(get_extensions("nope", "nope"), None);
431        assert!(get_media_extensions(&crate::IMAGE_PNG).unwrap().contains(&"png"));
432    }
433}