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}