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