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 /// As [`html_unescape_into`](Self::html_unescape_into), borrowed when the
184 /// name has nothing to decode.
185 ///
186 /// # Errors
187 ///
188 /// `Encoding { InvalidUtf8 }`.
189 ///
190 /// # Examples
191 ///
192 /// ```
193 /// # let body = b"--X\r\nContent-Disposition: form-data; name=f; filename=\"a%0Ab\"\r\n\r\n\r\n--X--";
194 /// # let mp = form_net::parse_multipart(b"multipart/form-data; boundary=X", body)?;
195 /// assert_eq!(mp.file("f").unwrap().filename().unwrap().html_unescaped()?, "a\nb");
196 /// # Ok::<(), form_net::FormError>(())
197 /// ```
198 #[cfg(feature = "alloc")]
199 #[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
200 pub fn html_unescaped(&self) -> Result<alloc::borrow::Cow<'a, str>, FormError> {
201 if !has_html_escape(self.raw) {
202 return self.as_str().map(alloc::borrow::Cow::Borrowed);
203 }
204 let mut out = alloc::vec![0u8; self.raw.len()];
205 let n = html_unescape(self.raw, &mut out);
206 out.truncate(n);
207 alloc::string::String::from_utf8(out)
208 .map(alloc::borrow::Cow::Owned)
209 .map_err(|_| FormError::encoding(EncodingError::InvalidUtf8, Location::at(0)))
210 }
211}
212
213impl fmt::Debug for FileName<'_> {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 write!(f, "FileName({} bytes)", self.raw.len())
216 }
217}
218
219/// The byte an HTML multipart escape at the start of `s` stands for.
220#[inline]
221fn html_escape_at(s: &[u8]) -> Option<u8> {
222 match s.get(..3)? {
223 b"%0A" => Some(b'\n'),
224 b"%0D" => Some(b'\r'),
225 b"%22" => Some(b'"'),
226 _ => None,
227 }
228}
229
230pub(crate) fn has_html_escape(s: &[u8]) -> bool {
231 let mut i = 0usize;
232 loop {
233 i = crate::scan::find_byte(s, i, b'%');
234 if i >= s.len() {
235 return false;
236 }
237 if html_escape_at(s.get(i..).unwrap_or(&[])).is_some() {
238 return true;
239 }
240 i = i.wrapping_add(1);
241 }
242}
243
244/// Decodes the three HTML escapes of `src` into `out` (at least as long) and
245/// returns the length written.
246pub(crate) fn html_unescape(src: &[u8], out: &mut [u8]) -> usize {
247 let mut i = 0usize;
248 let mut w = 0usize;
249 while i < src.len() {
250 let rest = src.get(i..).unwrap_or(&[]);
251 let (byte, step) = match html_escape_at(rest) {
252 Some(b) => (b, 3usize),
253 None => (*rest.first().unwrap_or(&0), 1usize),
254 };
255 if let Some(slot) = out.get_mut(w) {
256 *slot = byte;
257 }
258 w = w.wrapping_add(1);
259 i = i.wrapping_add(step);
260 }
261 w
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn name(raw: &[u8]) -> FileName<'_> {
269 FileName::new(raw, FileNameSource::Plain)
270 }
271
272 #[test]
273 fn test_basename_cases() {
274 assert_eq!(name(b"a/b\\c.txt").basename(), b"c.txt");
275 assert_eq!(name(b"c.txt").basename(), b"c.txt");
276 assert_eq!(name(b"/").basename(), b"");
277 assert_eq!(name(b"").basename(), b"");
278 assert_eq!(name(b"\\\\").basename(), b"");
279 }
280
281 #[test]
282 fn test_html_unescape_only_three_escapes() {
283 let mut out = [0u8; 32];
284 assert_eq!(
285 name(b"%0A%0D%22").html_unescape_into(&mut out).unwrap(),
286 "\n\r\""
287 );
288 assert_eq!(
289 name(b"%25%0a%41").html_unescape_into(&mut out).unwrap(),
290 "%25%0a%41"
291 );
292 assert_eq!(name(b"%2").html_unescape_into(&mut out).unwrap(), "%2");
293 assert!(name(b"\xff").html_unescape_into(&mut out).is_err());
294 let mut small = [0u8; 2];
295 assert_eq!(
296 name(b"abc").html_unescape_into(&mut small),
297 Err(FormError::BufferTooSmall { needed: 3 })
298 );
299 }
300
301 #[cfg(feature = "alloc")]
302 #[test]
303 fn test_html_unescaped_borrows_when_plain() {
304 assert!(matches!(
305 name(b"plain").html_unescaped().unwrap(),
306 alloc::borrow::Cow::Borrowed("plain")
307 ));
308 assert_eq!(name(b"a%22").html_unescaped().unwrap(), "a\"");
309 }
310
311 #[test]
312 fn test_extension_cases() {
313 assert_eq!(name(b".bashrc").extension(), Some("bashrc"));
314 assert_eq!(name(b"a.").extension(), None);
315 assert_eq!(name(b"a.tar.gz").extension(), Some("gz"));
316 assert_eq!(name("a.\u{c4}\u{d6}".as_bytes()).extension(), None);
317 assert_eq!(
318 name(b"a.1234567890123456").extension(),
319 Some("1234567890123456")
320 );
321 assert_eq!(name(b"a.12345678901234567").extension(), None);
322 assert_eq!(name(b"dir.d/file").extension(), None);
323 assert_eq!(name(b"noext").extension(), None);
324 }
325
326 #[test]
327 fn test_debug_hides_the_name() {
328 assert_eq!(
329 alloc::format!("{:?}", name(b"secret.pdf")),
330 "FileName(10 bytes)"
331 );
332 }
333}