Skip to main content

content_type/
lib.rs

1//! # content-type — parse and format `Content-Type` headers
2//!
3//! Parse an HTTP `Content-Type` / media-type header value into its type and
4//! parameters, and format one back out, following RFC 9110. A faithful Rust port of
5//! the [`content-type`](https://www.npmjs.com/package/content-type) npm package
6//! (v2.0.0): a lenient [`parse`] and a strict, validating [`format`]. Zero
7//! dependencies and `#![no_std]`.
8//!
9//! ```
10//! use content_type::{parse, format, ContentType};
11//!
12//! let ct = parse("text/html; charset=utf-8");
13//! assert_eq!(ct.type_, "text/html");
14//! assert_eq!(ct.get_parameter("charset"), Some("utf-8"));
15//!
16//! let ct = ContentType::new("application/json").with_parameter("charset", "utf-8");
17//! assert_eq!(format(&ct).unwrap(), "application/json; charset=utf-8");
18//! ```
19
20#![no_std]
21#![doc(html_root_url = "https://docs.rs/content-type/0.1.0")]
22
23extern crate alloc;
24
25use alloc::string::{String, ToString};
26use alloc::vec::Vec;
27use core::fmt;
28
29// Compile-test the README's examples as part of `cargo test`.
30#[cfg(doctest)]
31#[doc = include_str!("../README.md")]
32struct ReadmeDoctests;
33
34/// A parsed `Content-Type`: a media type plus its parameters.
35///
36/// `type_` is the lower-cased media type (e.g. `text/html`). `parameters` are
37/// `(lower-cased name, value)` pairs in header order; values keep their original case.
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct ContentType {
40    /// The media type, lower-cased (e.g. `text/html`).
41    pub type_: String,
42    /// The parameters as `(lower-cased name, value)` pairs, in order.
43    pub parameters: Vec<(String, String)>,
44}
45
46impl ContentType {
47    /// Create a `ContentType` with the given media type and no parameters.
48    #[must_use]
49    pub fn new(type_: impl Into<String>) -> Self {
50        Self {
51            type_: type_.into(),
52            parameters: Vec::new(),
53        }
54    }
55
56    /// Builder: add a parameter (consuming and returning `self`).
57    #[must_use]
58    pub fn with_parameter(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
59        self.parameters.push((name.into(), value.into()));
60        self
61    }
62
63    /// Look up a parameter value by name, case-insensitively.
64    ///
65    /// Matches regardless of how the [`ContentType`] was built — [`parse`] stores
66    /// lower-cased names, but a name added via [`with_parameter`](Self::with_parameter)
67    /// keeps its case, so both the query and the stored name are lower-cased here.
68    #[must_use]
69    pub fn get_parameter(&self, name: &str) -> Option<&str> {
70        let lname = lowercase(name);
71        self.parameters
72            .iter()
73            .find(|(k, _)| lowercase(k) == lname)
74            .map(|(_, v)| v.as_str())
75    }
76}
77
78/// An error from [`format`].
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum FormatError {
81    /// The media type is empty or not a valid `type/subtype`.
82    InvalidType(String),
83    /// A parameter name is not a valid token.
84    InvalidParameterName(String),
85    /// A parameter value cannot be represented as a token or quoted string.
86    InvalidParameterValue(String),
87}
88
89impl fmt::Display for FormatError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            FormatError::InvalidType(t) => write!(f, "invalid type: {t}"),
93            FormatError::InvalidParameterName(n) => write!(f, "invalid parameter name: {n}"),
94            FormatError::InvalidParameterValue(v) => write!(f, "invalid parameter value: {v}"),
95        }
96    }
97}
98
99impl core::error::Error for FormatError {}
100
101/// Parse a `Content-Type` header value (lenient: never errors).
102///
103/// The media type and parameter names are lower-cased; parameter values keep their
104/// case. When a parameter name repeats, the first value wins. Quoted-string values
105/// are unescaped.
106///
107/// ```
108/// let ct = content_type::parse("Text/HTML; Charset=\"UTF-8\"");
109/// assert_eq!(ct.type_, "text/html");
110/// assert_eq!(ct.get_parameter("charset"), Some("UTF-8"));
111/// ```
112#[must_use]
113pub fn parse(header: &str) -> ContentType {
114    parse_with(header, true)
115}
116
117/// Parse a `Content-Type` header value, optionally skipping parameters.
118///
119/// With `parse_parameters = false`, only the media type is extracted (the reference's
120/// `{ parameters: false }` option).
121#[must_use]
122pub fn parse_with(header: &str, parse_parameters: bool) -> ContentType {
123    let chars: Vec<char> = header.chars().collect();
124    let len = chars.len();
125
126    let mut index = skip_ows(&chars, 0, len);
127    let value_start = index;
128    index = skip_value(&chars, index, len);
129    let value_end = trailing_ows(&chars, value_start, index);
130    let type_ = lowercase_slice(&chars[value_start..value_end]);
131
132    let parameters = if parse_parameters {
133        parse_parameters_impl(&chars, index, len)
134    } else {
135        Vec::new()
136    };
137
138    ContentType { type_, parameters }
139}
140
141/// Format a [`ContentType`] into a header string, validating the type and parameters.
142///
143/// Returns a [`FormatError`] for an invalid media type, parameter name, or value.
144///
145/// ```
146/// use content_type::ContentType;
147/// let ct = ContentType::new("text/plain").with_parameter("name", "two words");
148/// assert_eq!(content_type::format(&ct).unwrap(), "text/plain; name=\"two words\"");
149/// ```
150///
151/// # Errors
152///
153/// Returns [`FormatError`] if `type_` is not a valid `type/subtype`, a parameter name
154/// is not a token, or a value cannot be encoded.
155pub fn format(content_type: &ContentType) -> Result<String, FormatError> {
156    if !is_type(&content_type.type_) {
157        return Err(FormatError::InvalidType(content_type.type_.clone()));
158    }
159    let mut result = content_type.type_.clone();
160    for (name, value) in &content_type.parameters {
161        if !is_token(name) {
162            return Err(FormatError::InvalidParameterName(name.clone()));
163        }
164        result.push_str("; ");
165        result.push_str(name);
166        result.push('=');
167        result.push_str(&qstring(value)?);
168    }
169    Ok(result)
170}
171
172const SP: char = ' ';
173const HTAB: char = '\t';
174const SEMI: char = ';';
175const EQ: char = '=';
176const DQUOTE: char = '"';
177const BSLASH: char = '\\';
178
179fn parse_parameters_impl(chars: &[char], mut index: usize, len: usize) -> Vec<(String, String)> {
180    let mut parameters: Vec<(String, String)> = Vec::new();
181
182    'parameter: while index < len {
183        index = skip_ows(chars, index + 1, len); // skip the ';' then OWS
184        let key_start = index;
185        while index < len {
186            let code = chars[index];
187            if code == SEMI {
188                continue 'parameter;
189            }
190            if code == EQ {
191                let key_end = trailing_ows(chars, key_start, index);
192                let key = lowercase_slice(&chars[key_start..key_end]);
193                index = skip_ows(chars, index + 1, len);
194
195                if index < len && chars[index] == DQUOTE {
196                    index += 1;
197                    let mut value = String::new();
198                    let mut closed = false;
199                    while index < len {
200                        let code = chars[index];
201                        index += 1;
202                        if code == DQUOTE {
203                            index = skip_value(chars, index, len);
204                            closed = true;
205                            break;
206                        }
207                        if code == BSLASH && index < len {
208                            value.push(chars[index]);
209                            index += 1;
210                            continue;
211                        }
212                        value.push(code);
213                    }
214                    // An unterminated quoted string is dropped (matching the reference).
215                    if closed {
216                        insert_first(&mut parameters, key, value);
217                    }
218                    continue 'parameter;
219                }
220
221                let value_start = index;
222                index = skip_value(chars, index, len);
223                let value_end = trailing_ows(chars, value_start, index);
224                let value = chars[value_start..value_end].iter().collect();
225                insert_first(&mut parameters, key, value);
226                continue 'parameter;
227            }
228            index += 1;
229        }
230    }
231
232    parameters
233}
234
235/// Store `(key, value)` only if `key` is not already present (first occurrence wins).
236fn insert_first(parameters: &mut Vec<(String, String)>, key: String, value: String) {
237    if !parameters.iter().any(|(k, _)| *k == key) {
238        parameters.push((key, value));
239    }
240}
241
242/// Advance past characters until a `;` or the end.
243fn skip_value(chars: &[char], mut index: usize, len: usize) -> usize {
244    while index < len && chars[index] != SEMI {
245        index += 1;
246    }
247    index
248}
249
250/// Skip optional whitespace (SP / HTAB).
251fn skip_ows(chars: &[char], mut index: usize, len: usize) -> usize {
252    while index < len && (chars[index] == SP || chars[index] == HTAB) {
253        index += 1;
254    }
255    index
256}
257
258/// Trim trailing optional whitespace (SP / HTAB) from `chars[start..end]`.
259fn trailing_ows(chars: &[char], start: usize, mut end: usize) -> usize {
260    while end > start && (chars[end - 1] == SP || chars[end - 1] == HTAB) {
261        end -= 1;
262    }
263    end
264}
265
266fn lowercase(s: &str) -> String {
267    s.chars().flat_map(char::to_lowercase).collect()
268}
269
270fn lowercase_slice(chars: &[char]) -> String {
271    chars.iter().flat_map(|c| c.to_lowercase()).collect()
272}
273
274/// A token character per RFC 9110 (ASCII letters, digits, and the `tchar` symbols).
275fn is_token_char(c: char) -> bool {
276    c.is_ascii_alphanumeric()
277        || matches!(
278            c,
279            '!' | '#'
280                | '$'
281                | '%'
282                | '&'
283                | '\''
284                | '*'
285                | '+'
286                | '.'
287                | '^'
288                | '_'
289                | '`'
290                | '|'
291                | '~'
292                | '-'
293        )
294}
295
296/// Whether `s` is a non-empty token.
297fn is_token(s: &str) -> bool {
298    !s.is_empty() && s.chars().all(is_token_char)
299}
300
301/// Whether `s` is a valid `type/subtype` (token `/` token).
302fn is_type(s: &str) -> bool {
303    match s.split_once('/') {
304        Some((ty, sub)) => is_token(ty) && is_token(sub),
305        None => false,
306    }
307}
308
309/// Whether `c` is allowed unquoted-or-quoted text per the reference's `TEXT_REGEXP`.
310fn is_text_char(c: char) -> bool {
311    let n = c as u32;
312    n == 0x09 || (0x20..=0x7e).contains(&n) || (0x80..=0xff).contains(&n)
313}
314
315/// Serialize a parameter value: a bare token, or an escaped quoted string.
316fn qstring(value: &str) -> Result<String, FormatError> {
317    if is_token(value) {
318        return Ok(value.to_string());
319    }
320    if value.chars().all(is_text_char) {
321        let mut out = String::with_capacity(value.len() + 2);
322        out.push(DQUOTE);
323        for c in value.chars() {
324            if c == BSLASH || c == DQUOTE {
325                out.push(BSLASH);
326            }
327            out.push(c);
328        }
329        out.push(DQUOTE);
330        return Ok(out);
331    }
332    Err(FormatError::InvalidParameterValue(value.to_string()))
333}