Skip to main content

form_net/multipart/
filename.rs

1//! Views of a client's file name. form-net never uses a file name as a path.
2
3use core::fmt;
4
5use crate::error::{EncodingError, FormError, Location};
6
7/// Where a file name came from.
8#[non_exhaustive]
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum FileNameSource {
11    /// The `filename` parameter.
12    Plain,
13    /// An accepted `filename*` parameter (RFC 8187, decoded).
14    Extended,
15}
16
17/// The client's file name, with a view for each way a caller might want it.
18///
19/// **form-net never uses a client file name as a path or as part of one.**
20/// `Debug` prints the length only: file names are user data.
21///
22/// # Examples
23///
24/// ```
25/// let body = b"--X\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"C:\\Users\\me\\a b.pdf\"\r\n\r\nx\r\n--X--";
26/// let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
27/// let name = mp.file("doc").unwrap().filename().unwrap();
28/// assert_eq!(name.raw(), b"C:\\Users\\me\\a b.pdf");
29/// assert_eq!(name.basename(), b"a b.pdf");
30/// # Ok::<(), form_net::FormError>(())
31/// ```
32#[derive(Clone, Copy)]
33pub struct FileName<'a> {
34    raw: &'a [u8],
35    source: FileNameSource,
36}
37
38impl<'a> FileName<'a> {
39    pub(crate) const fn new(raw: &'a [u8], source: FileNameSource) -> Self {
40        Self { raw, source }
41    }
42
43    /// The bytes as sent between the quotes of `filename`, untouched; or the
44    /// decoded bytes of an accepted `filename*`.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a%22b.txt\"\r\n\r\n\r\n--X--";
50    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
51    /// let name = mp.file("f").unwrap().filename().unwrap();
52    /// assert_eq!(name.raw(), b"a%22b.txt");
53    /// # Ok::<(), form_net::FormError>(())
54    /// ```
55    #[must_use]
56    pub const fn raw(&self) -> &'a [u8] {
57        self.raw
58    }
59
60    /// The raw bytes as UTF-8.
61    ///
62    /// # Errors
63    ///
64    /// `Encoding { InvalidUtf8 }`.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// # let body = "--X\r\nContent-Disposition: form-data; name=f; filename=\"café.txt\"\r\n\r\n\r\n--X--";
70    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body.as_bytes())?;
71    /// assert_eq!(mp.file("f").unwrap().filename().unwrap().as_str()?, "café.txt");
72    /// # Ok::<(), form_net::FormError>(())
73    /// ```
74    pub fn as_str(&self) -> Result<&'a str, FormError> {
75        core::str::from_utf8(self.raw)
76            .map_err(|_| FormError::encoding(EncodingError::InvalidUtf8, Location::at(0)))
77    }
78
79    /// Whether the name is empty: a file input left empty.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"\"\r\n\r\n\r\n--X--";
85    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
86    /// assert!(mp.file("f").unwrap().filename().unwrap().is_empty());
87    /// # Ok::<(), form_net::FormError>(())
88    /// ```
89    #[must_use]
90    pub const fn is_empty(&self) -> bool {
91        self.raw.is_empty()
92    }
93
94    /// `Plain` for `filename`, `Extended` for `filename*`.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use form_net::FileNameSource;
100    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=a\r\n\r\n\r\n--X--";
101    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
102    /// assert_eq!(mp.file("f").unwrap().filename().unwrap().source(), FileNameSource::Plain);
103    /// # Ok::<(), form_net::FormError>(())
104    /// ```
105    #[must_use]
106    pub const fn source(&self) -> FileNameSource {
107        self.source
108    }
109
110    /// The bytes after the last `/` or `\` (some non-browser clients send
111    /// full client paths).
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a/b\\c.txt\"\r\n\r\n\r\n--X--";
117    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
118    /// assert_eq!(mp.file("f").unwrap().filename().unwrap().basename(), b"c.txt");
119    /// # Ok::<(), form_net::FormError>(())
120    /// ```
121    #[must_use]
122    pub fn basename(&self) -> &'a [u8] {
123        match self.raw.iter().rposition(|&b| b == b'/' || b == b'\\') {
124            Some(i) => self.raw.get(i.wrapping_add(1)..).unwrap_or(&[]),
125            None => self.raw,
126        }
127    }
128
129    /// The text after the last `.` of the base name, when it is 1 to 16
130    /// ASCII letters or digits. A hint that proves nothing about the
131    /// content: only the bytes tell what a file is.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a/b.tar.gz\"\r\n\r\n\r\n--X--";
137    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
138    /// assert_eq!(mp.file("f").unwrap().filename().unwrap().extension(), Some("gz"));
139    /// # Ok::<(), form_net::FormError>(())
140    /// ```
141    #[must_use]
142    pub fn extension(&self) -> Option<&'a str> {
143        let base = self.basename();
144        let dot = base.iter().rposition(|&b| b == b'.')?;
145        let ext = base.get(dot.wrapping_add(1)..)?;
146        if ext.is_empty() || ext.len() > 16 || !ext.iter().all(u8::is_ascii_alphanumeric) {
147            return None;
148        }
149        core::str::from_utf8(ext).ok()
150    }
151
152    /// Writes the name into `out` with the HTML escapes `%0A`, `%0D`, `%22`
153    /// decoded to LF, CR, `"`, and nothing else, and returns it as text.
154    /// Browsers do not escape `%` itself, so a file the user named `a%22b`
155    /// reads as `a"b`; that ambiguity is inherent in the HTML encoding.
156    ///
157    /// # Errors
158    ///
159    /// `BufferTooSmall` when `out` is shorter than [`raw`](Self::raw);
160    /// `Encoding { InvalidUtf8 }`.
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a%22b.txt\"\r\n\r\n\r\n--X--";
166    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
167    /// let mut out = [0u8; 32];
168    /// let name = mp.file("f").unwrap().filename().unwrap();
169    /// assert_eq!(name.html_unescape_into(&mut out)?, "a\"b.txt");
170    /// # Ok::<(), form_net::FormError>(())
171    /// ```
172    pub fn html_unescape_into<'b>(&self, out: &'b mut [u8]) -> Result<&'b str, FormError> {
173        if out.len() < self.raw.len() {
174            return Err(FormError::BufferTooSmall {
175                needed: self.raw.len(),
176            });
177        }
178        let n = html_unescape(self.raw, out);
179        core::str::from_utf8(out.get(..n).unwrap_or(&[]))
180            .map_err(|_| FormError::encoding(EncodingError::InvalidUtf8, Location::at(0)))
181    }
182
183    /// The name as UTF-8 in Unicode NFC (macOS clients may send NFD, so the
184    /// same visible name can arrive as different bytes).
185    ///
186    /// # Errors
187    ///
188    /// `Encoding { InvalidUtf8 }`.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// # let body = "--X\r\nContent-Disposition: form-data; name=f; filename=\"cafe\u{301}.txt\"\r\n\r\n\r\n--X--";
194    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body.as_bytes())?;
195    /// let name = mp.file("f").unwrap().filename().unwrap();
196    /// assert_eq!(name.nfc()?, "caf\u{e9}.txt");
197    /// # Ok::<(), form_net::FormError>(())
198    /// ```
199    #[cfg(feature = "unicode")]
200    #[cfg_attr(docsrs, doc(cfg(feature = "unicode")))]
201    pub fn nfc(&self) -> Result<alloc::string::String, FormError> {
202        Ok(unicode_lang::normalize(
203            self.as_str()?,
204            unicode_lang::Form::Nfc,
205        ))
206    }
207
208    /// As [`html_unescape_into`](Self::html_unescape_into), borrowed when the
209    /// name has nothing to decode.
210    ///
211    /// # Errors
212    ///
213    /// `Encoding { InvalidUtf8 }`.
214    ///
215    /// # Examples
216    ///
217    /// ```
218    /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a%0Ab\"\r\n\r\n\r\n--X--";
219    /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
220    /// assert_eq!(mp.file("f").unwrap().filename().unwrap().html_unescaped()?, "a\nb");
221    /// # Ok::<(), form_net::FormError>(())
222    /// ```
223    #[cfg(feature = "alloc")]
224    #[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
225    pub fn html_unescaped(&self) -> Result<alloc::borrow::Cow<'a, str>, FormError> {
226        if !has_html_escape(self.raw) {
227            return self.as_str().map(alloc::borrow::Cow::Borrowed);
228        }
229        let mut out = alloc::vec![0u8; self.raw.len()];
230        let n = html_unescape(self.raw, &mut out);
231        out.truncate(n);
232        alloc::string::String::from_utf8(out)
233            .map(alloc::borrow::Cow::Owned)
234            .map_err(|_| FormError::encoding(EncodingError::InvalidUtf8, Location::at(0)))
235    }
236}
237
238impl fmt::Debug for FileName<'_> {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "FileName({} bytes)", self.raw.len())
241    }
242}
243
244/// The byte an HTML multipart escape at the start of `s` stands for.
245#[inline]
246fn html_escape_at(s: &[u8]) -> Option<u8> {
247    match s.get(..3)? {
248        b"%0A" => Some(b'\n'),
249        b"%0D" => Some(b'\r'),
250        b"%22" => Some(b'"'),
251        _ => None,
252    }
253}
254
255pub(crate) fn has_html_escape(s: &[u8]) -> bool {
256    let mut i = 0usize;
257    loop {
258        i = crate::scan::find_byte(s, i, b'%');
259        if i >= s.len() {
260            return false;
261        }
262        if html_escape_at(s.get(i..).unwrap_or(&[])).is_some() {
263            return true;
264        }
265        i = i.wrapping_add(1);
266    }
267}
268
269/// Decodes the three HTML escapes of `src` into `out` (at least as long) and
270/// returns the length written.
271pub(crate) fn html_unescape(src: &[u8], out: &mut [u8]) -> usize {
272    let mut i = 0usize;
273    let mut w = 0usize;
274    while i < src.len() {
275        let rest = src.get(i..).unwrap_or(&[]);
276        let (byte, step) = match html_escape_at(rest) {
277            Some(b) => (b, 3usize),
278            None => (*rest.first().unwrap_or(&0), 1usize),
279        };
280        if let Some(slot) = out.get_mut(w) {
281            *slot = byte;
282        }
283        w = w.wrapping_add(1);
284        i = i.wrapping_add(step);
285    }
286    w
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn name(raw: &[u8]) -> FileName<'_> {
294        FileName::new(raw, FileNameSource::Plain)
295    }
296
297    #[test]
298    fn test_basename_cases() {
299        assert_eq!(name(b"a/b\\c.txt").basename(), b"c.txt");
300        assert_eq!(name(b"c.txt").basename(), b"c.txt");
301        assert_eq!(name(b"/").basename(), b"");
302        assert_eq!(name(b"").basename(), b"");
303        assert_eq!(name(b"\\\\").basename(), b"");
304    }
305
306    #[test]
307    fn test_html_unescape_only_three_escapes() {
308        let mut out = [0u8; 32];
309        assert_eq!(
310            name(b"%0A%0D%22").html_unescape_into(&mut out).unwrap(),
311            "\n\r\""
312        );
313        assert_eq!(
314            name(b"%25%0a%41").html_unescape_into(&mut out).unwrap(),
315            "%25%0a%41"
316        );
317        assert_eq!(name(b"%2").html_unescape_into(&mut out).unwrap(), "%2");
318        assert!(name(b"\xff").html_unescape_into(&mut out).is_err());
319        let mut small = [0u8; 2];
320        assert_eq!(
321            name(b"abc").html_unescape_into(&mut small),
322            Err(FormError::BufferTooSmall { needed: 3 })
323        );
324    }
325
326    #[cfg(feature = "alloc")]
327    #[test]
328    fn test_html_unescaped_borrows_when_plain() {
329        assert!(matches!(
330            name(b"plain").html_unescaped().unwrap(),
331            alloc::borrow::Cow::Borrowed("plain")
332        ));
333        assert_eq!(name(b"a%22").html_unescaped().unwrap(), "a\"");
334    }
335
336    #[test]
337    fn test_extension_cases() {
338        assert_eq!(name(b".bashrc").extension(), Some("bashrc"));
339        assert_eq!(name(b"a.").extension(), None);
340        assert_eq!(name(b"a.tar.gz").extension(), Some("gz"));
341        assert_eq!(name("a.\u{c4}\u{d6}".as_bytes()).extension(), None);
342        assert_eq!(
343            name(b"a.1234567890123456").extension(),
344            Some("1234567890123456")
345        );
346        assert_eq!(name(b"a.12345678901234567").extension(), None);
347        assert_eq!(name(b"dir.d/file").extension(), None);
348        assert_eq!(name(b"noext").extension(), None);
349    }
350
351    #[test]
352    fn test_debug_hides_the_name() {
353        assert_eq!(
354            alloc::format!("{:?}", name(b"secret.pdf")),
355            "FileName(10 bytes)"
356        );
357    }
358}