Skip to main content

nostd_multipart/
disposition.rs

1//! `Content-Disposition` helper (DESIGN.md rule 8). Pure functions, no state.
2
3use crate::chars::{is_token, param, trim_ows};
4use crate::Error;
5
6/// A parsed `Content-Disposition` header value.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ContentDisposition<'a> {
9    disposition_type: &'a [u8],
10    name: Option<ParamValue<'a>>,
11    filename: Option<ParamValue<'a>>,
12    filename_star: Option<&'a [u8]>,
13}
14
15impl<'a> ContentDisposition<'a> {
16    /// The disposition type token, e.g. `form-data`, as sent (case preserved).
17    #[must_use]
18    pub const fn disposition_type(&self) -> &'a [u8] {
19        self.disposition_type
20    }
21
22    /// `true` if the disposition type is `form-data` (case-insensitive).
23    #[must_use]
24    pub fn is_form_data(&self) -> bool {
25        self.disposition_type.eq_ignore_ascii_case(b"form-data")
26    }
27
28    /// The `name` parameter, if present (first occurrence).
29    #[must_use]
30    pub const fn name(&self) -> Option<ParamValue<'a>> {
31        self.name
32    }
33
34    /// The `filename` parameter, if present (first occurrence).
35    #[must_use]
36    pub const fn filename(&self) -> Option<ParamValue<'a>> {
37        self.filename
38    }
39
40    /// The raw RFC 5987 `filename*` ext-value (e.g. `utf-8''%E2%82%AC.txt`), not
41    /// decoded. Browsers do not send it for form uploads.
42    #[must_use]
43    pub const fn filename_star(&self) -> Option<&'a [u8]> {
44        self.filename_star
45    }
46}
47
48/// A parameter value: the raw bytes plus lazy decoding of RFC 822 quoted-pairs
49/// (`\x` → `x`) and the WHATWG escapes `%22`, `%0D`, `%0A` (→ `"`, CR, LF). Other
50/// `%` sequences are left as they are.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ParamValue<'a> {
53    raw: &'a [u8],
54}
55
56impl<'a> ParamValue<'a> {
57    /// The undecoded bytes (between the quotes for a quoted-string).
58    #[must_use]
59    pub const fn raw(&self) -> &'a [u8] {
60        self.raw
61    }
62
63    /// Iterator over the decoded bytes.
64    #[must_use]
65    pub const fn bytes(&self) -> Decoded<'a> {
66        Decoded { rest: self.raw }
67    }
68
69    /// Number of decoded bytes.
70    #[must_use]
71    pub fn decoded_len(&self) -> usize {
72        self.bytes().count()
73    }
74
75    /// Writes the decoded bytes into `out`; `None` if `out` is too small.
76    pub fn decode_into<'o>(&self, out: &'o mut [u8]) -> Option<&'o [u8]> {
77        let mut n = 0usize;
78        let mut slots = out.iter_mut();
79        for b in self.bytes() {
80            *slots.next()? = b;
81            n = n.saturating_add(1);
82        }
83        out.get(..n)
84    }
85
86    /// `true` if the decoded bytes equal `other`.
87    #[must_use]
88    pub fn eq_bytes(&self, other: &[u8]) -> bool {
89        self.bytes().eq(other.iter().copied())
90    }
91}
92
93impl PartialEq<[u8]> for ParamValue<'_> {
94    fn eq(&self, other: &[u8]) -> bool {
95        ParamValue::eq_bytes(self, other)
96    }
97}
98
99impl PartialEq<&[u8]> for ParamValue<'_> {
100    fn eq(&self, other: &&[u8]) -> bool {
101        ParamValue::eq_bytes(self, other)
102    }
103}
104
105impl PartialEq<str> for ParamValue<'_> {
106    fn eq(&self, other: &str) -> bool {
107        ParamValue::eq_bytes(self, other.as_bytes())
108    }
109}
110
111impl PartialEq<&str> for ParamValue<'_> {
112    fn eq(&self, other: &&str) -> bool {
113        ParamValue::eq_bytes(self, other.as_bytes())
114    }
115}
116
117/// Iterator over the decoded bytes of a [`ParamValue`].
118#[derive(Debug, Clone)]
119pub struct Decoded<'a> {
120    rest: &'a [u8],
121}
122
123impl Iterator for Decoded<'_> {
124    type Item = u8;
125
126    fn next(&mut self) -> Option<u8> {
127        let (&b, rest) = self.rest.split_first()?;
128        if b == b'\\' {
129            if let Some((&escaped, after)) = rest.split_first() {
130                self.rest = after;
131                return Some(escaped);
132            }
133        } else if b == b'%' {
134            let decoded = match rest.get(..2) {
135                Some(b"22") => Some(b'"'),
136                Some(b"0D" | b"0d") => Some(b'\r'),
137                Some(b"0A" | b"0a") => Some(b'\n'),
138                _ => None,
139            };
140            if let Some(d) = decoded {
141                self.rest = rest.get(2..).unwrap_or(&[]);
142                return Some(d);
143            }
144        }
145        self.rest = rest;
146        Some(b)
147    }
148}
149
150/// Parses a `Content-Disposition` header value (rule 8).
151///
152/// Lenient where browsers are: whitespace around `;` and `=` is ignored, an
153/// unquoted value extends to the next `;`, a trailing `;` is tolerated, unknown
154/// parameters are skipped.
155///
156/// # Errors
157/// [`Error::Disposition`] for an empty or non-token disposition type, a parameter
158/// without `=`, an empty or non-token attribute name, an unterminated
159/// quoted-string, or bytes other than `;` after a quoted-string.
160pub fn content_disposition(value: &[u8]) -> Result<ContentDisposition<'_>, Error> {
161    let value = trim_ows(value);
162    let end = value.iter().position(|&b| b == b';').unwrap_or(value.len());
163    let (dtype, mut rest) = value.split_at_checked(end).ok_or(Error::Disposition)?;
164    let dtype = trim_ows(dtype);
165    if !is_token(dtype) {
166        return Err(Error::Disposition);
167    }
168    let mut cd = ContentDisposition {
169        disposition_type: dtype,
170        name: None,
171        filename: None,
172        filename_star: None,
173    };
174    while let Some(after_semi) = rest.strip_prefix(b";") {
175        let Some((attr, raw, _, tail)) = param(after_semi)? else {
176            break;
177        };
178        if attr.eq_ignore_ascii_case(b"name") {
179            cd.name.get_or_insert(ParamValue { raw });
180        } else if attr.eq_ignore_ascii_case(b"filename") {
181            cd.filename.get_or_insert(ParamValue { raw });
182        } else if attr.eq_ignore_ascii_case(b"filename*") {
183            cd.filename_star.get_or_insert(raw);
184        }
185        rest = tail;
186    }
187    Ok(cd)
188}