parse_net/disposition.rs
1//! `Content-Disposition` (RFC 6266, and its use in `multipart/form-data`
2//! part headers, RFC 7578 section 4.2).
3//!
4//! # Examples
5//!
6//! ```
7//! use parse_net as pn;
8//!
9//! let cd = pn::parse_content_disposition(
10//! b"attachment; filename=\"EURO rates.txt\"; filename*=utf-8''%e2%82%ac%20rates.txt")?;
11//! assert_eq!(cd.kind(), pn::DispositionType::Attachment);
12//! let mut buf = [0u8; 64];
13//! assert_eq!(cd.filename().expect("present").decode_into(&mut buf)?, "\u{20ac} rates.txt");
14//! # Ok::<(), pn::ParseError>(())
15//! ```
16
17use crate::{
18 class,
19 error::{ErrorKind, ParseError, Result, WriteError, err},
20 ext_value::{Charset, ExtValue, decode_checked, write_ext_value},
21 limits::{DuplicatePolicy, Limits},
22 param::{self, Params},
23 scan::control_kind,
24 text::{Word, skip_ows, trim_ows},
25 write::{CountSink, Sink, reserve},
26};
27
28/// The disposition type.
29///
30/// # Examples
31///
32/// ```
33/// use parse_net::{parse_content_disposition, DispositionType};
34///
35/// assert_eq!(parse_content_disposition(b"INLINE")?.kind(), DispositionType::Inline);
36/// assert_eq!(parse_content_disposition(b"x-custom")?.kind(), DispositionType::Other(&b"x-custom"[..]));
37/// # Ok::<(), parse_net::ParseError>(())
38/// ```
39#[non_exhaustive]
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum DispositionType<'a> {
42 /// `inline`.
43 Inline,
44 /// `attachment`.
45 Attachment,
46 /// `form-data` (RFC 7578).
47 FormData,
48 /// Another type token, as sent. RFC 6266 section 4.2: treat an unknown
49 /// type as `attachment`.
50 Other(&'a [u8]),
51}
52
53/// A validated `Content-Disposition` value.
54///
55/// # Examples
56///
57/// ```
58/// let cd = parse_net::parse_content_disposition(b"attachment; filename=report.pdf")?;
59/// assert_eq!(cd.kind(), parse_net::DispositionType::Attachment);
60/// assert!(cd.filename_plain().is_some_and(|f| f.eq_bytes(b"report.pdf")));
61/// # Ok::<(), parse_net::ParseError>(())
62/// ```
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub struct ContentDisposition<'a> {
65 kind: DispositionType<'a>,
66 params: Params<'a>,
67}
68
69/// Parses `Content-Disposition` with the default [`Limits`].
70///
71/// # Errors
72///
73/// As [`disposition::parse`](crate::disposition::parse).
74///
75/// # Examples
76///
77/// ```
78/// let part = parse_net::parse_content_disposition(b"form-data; name=\"avatar\"; filename=\"../me.png\"")?;
79/// assert!(part.name().is_some_and(|n| n.eq_bytes(b"avatar")));
80/// assert!(part.filename().is_some_and(|f| f.has_path_separator()));
81/// # Ok::<(), parse_net::ParseError>(())
82/// ```
83pub fn parse_content_disposition(value: &[u8]) -> Result<ContentDisposition<'_>> {
84 parse(value, &Limits::DEFAULT)
85}
86
87/// Parses `disposition-type *( OWS ";" OWS disposition-parm )`.
88///
89/// The type token is case-insensitive. Repeated parameter names are refused
90/// whatever `limits` says (a repeated `filename` is a known differential). A
91/// `filename*` value must be an RFC 8187 value that decodes (valid UTF-8, no
92/// control character); an invalid one refuses the whole field instead of
93/// falling back to `filename` silently.
94///
95/// # Errors
96///
97/// [`ErrorKind::NotToken`] for the type; the parameter errors, with
98/// [`ErrorKind::DuplicateParameter`]; the [`ExtValue::parse`] errors for
99/// `filename*`; [`ErrorKind::TrailingData`].
100///
101/// # Examples
102///
103/// ```
104/// use parse_net::{disposition, ErrorKind, Limits};
105///
106/// let dup = disposition::parse(b"attachment; filename=a.txt; filename=a.exe", &Limits::DEFAULT);
107/// assert_eq!(dup.unwrap_err().kind(), ErrorKind::DuplicateParameter);
108/// let bad = disposition::parse(b"attachment; filename=a.txt; filename*=UTF-8''%ZZ", &Limits::DEFAULT);
109/// assert_eq!(bad.unwrap_err().kind(), ErrorKind::InvalidPercentEncoding);
110/// ```
111pub fn parse<'a>(value: &'a [u8], limits: &Limits) -> Result<ContentDisposition<'a>> {
112 let lead = skip_ows(value);
113 let v = trim_ows(value);
114 let type_len = v.iter().take_while(|&&b| class::is_tchar(b)).count();
115 if type_len == 0 {
116 return Err(match v.first() {
117 Some(&b) if crate::scan::is_control(b) => ParseError::new(control_kind(b), lead),
118 _ => ParseError::new(ErrorKind::NotToken, lead),
119 });
120 }
121 let type_ = v.get(..type_len).unwrap_or(&[]);
122 let refuse = limits.duplicate_params(DuplicatePolicy::Refuse);
123 let rest = v.get(type_len..).unwrap_or(&[]);
124 let (params, used) = param::parse(rest, &refuse).map_err(|e| e.shifted(lead + type_len))?;
125 let end = type_len + used;
126 if end < v.len() {
127 let at = end + skip_ows(v.get(end..).unwrap_or(&[]));
128 return Err(match v.get(at) {
129 Some(&b) if crate::scan::is_control(b) => ParseError::new(control_kind(b), lead + at),
130 _ => ParseError::new(ErrorKind::TrailingData, lead + at),
131 });
132 }
133 for p in params.iter() {
134 if p.name.eq_ignore_ascii_case(b"filename*") {
135 let at = lead
136 + (p.name.as_ptr() as usize).saturating_sub(v.as_ptr() as usize)
137 + p.name.len()
138 + 1;
139 let Word::Token(t) = p.value else {
140 return err(ErrorKind::InvalidCharset, at);
141 };
142 let ext = ExtValue::parse(t).map_err(|e| e.shifted(at))?;
143 // An undecodable `filename*` refuses the field, so no component
144 // falls back to `filename` where another would not (T7).
145 ext.check_text()
146 .map_err(|e| ParseError::new(e.kind(), at))?;
147 }
148 }
149 let kind = if type_.eq_ignore_ascii_case(b"inline") {
150 DispositionType::Inline
151 } else if type_.eq_ignore_ascii_case(b"attachment") {
152 DispositionType::Attachment
153 } else if type_.eq_ignore_ascii_case(b"form-data") {
154 DispositionType::FormData
155 } else {
156 DispositionType::Other(type_)
157 };
158 Ok(ContentDisposition { kind, params })
159}
160
161impl<'a> ContentDisposition<'a> {
162 /// The disposition type.
163 ///
164 /// # Examples
165 ///
166 /// ```
167 /// use parse_net::{parse_content_disposition, DispositionType};
168 ///
169 /// let cd = parse_content_disposition(b"Form-Data; name=field")?;
170 /// assert_eq!(cd.kind(), DispositionType::FormData);
171 /// # Ok::<(), parse_net::ParseError>(())
172 /// ```
173 #[must_use]
174 pub const fn kind(&self) -> DispositionType<'a> {
175 self.kind
176 }
177
178 /// Every parameter.
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// let cd = parse_net::parse_content_disposition(b"inline; size=120; x=\"y\"")?;
184 /// assert_eq!(cd.params().len(), 2);
185 /// assert!(cd.params().get("size").is_some_and(|v| v.eq_bytes(b"120")));
186 /// # Ok::<(), parse_net::ParseError>(())
187 /// ```
188 #[must_use]
189 pub const fn params(&self) -> Params<'a> {
190 self.params
191 }
192
193 /// The `name` parameter (the field name of a `form-data` part).
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use parse_net::parse_content_disposition;
199 ///
200 /// let part = parse_content_disposition(b"form-data; name=\"comment\"")?;
201 /// assert!(part.name().is_some_and(|n| n.eq_bytes(b"comment")));
202 /// assert!(parse_content_disposition(b"attachment")?.name().is_none());
203 /// # Ok::<(), parse_net::ParseError>(())
204 /// ```
205 #[must_use]
206 pub fn name(&self) -> Option<Word<'a>> {
207 self.params.get("name")
208 }
209
210 /// The file name: `filename*` when present, else `filename` (RFC 6266
211 /// section 4.3).
212 ///
213 /// # Examples
214 ///
215 /// ```
216 /// let cd = parse_net::parse_content_disposition(b"attachment; filename=plain.txt; filename*=UTF-8''ext.txt")?;
217 /// let mut buf = [0u8; 16];
218 /// assert_eq!(cd.filename().expect("present").decode_into(&mut buf)?, "ext.txt");
219 /// # Ok::<(), parse_net::ParseError>(())
220 /// ```
221 #[must_use]
222 pub fn filename(&self) -> Option<Filename<'a>> {
223 self.filename_ext()
224 .map(Filename::Ext)
225 .or_else(|| self.filename_plain().map(Filename::Plain))
226 }
227
228 /// The `filename` parameter as sent.
229 ///
230 /// # Examples
231 ///
232 /// ```
233 /// let cd = parse_net::parse_content_disposition(b"attachment; filename=plain.txt; filename*=UTF-8''ext.txt")?;
234 /// assert!(cd.filename_plain().is_some_and(|f| f.eq_bytes(b"plain.txt")));
235 /// # Ok::<(), parse_net::ParseError>(())
236 /// ```
237 #[must_use]
238 pub fn filename_plain(&self) -> Option<Word<'a>> {
239 self.params.get("filename")
240 }
241
242 /// The `filename*` parameter, decoded as an RFC 8187 value.
243 ///
244 /// # Examples
245 ///
246 /// ```
247 /// let cd = parse_net::parse_content_disposition(b"attachment; filename=plain.txt; filename*=UTF-8''ext.txt")?;
248 /// let ext = cd.filename_ext().expect("present");
249 /// assert_eq!((ext.charset(), ext.encoded()), (parse_net::Charset::Utf8, &b"ext.txt"[..]));
250 /// # Ok::<(), parse_net::ParseError>(())
251 /// ```
252 #[must_use]
253 pub fn filename_ext(&self) -> Option<ExtValue<'a>> {
254 match self.params.get("filename*")? {
255 Word::Token(t) => ExtValue::parse(t).ok(),
256 Word::Quoted(_) => None,
257 }
258 }
259}
260
261/// A file name from `Content-Disposition` or a `Link` `title`.
262///
263/// # Examples
264///
265/// ```
266/// use parse_net::{parse_content_disposition, Filename};
267///
268/// let cd = parse_content_disposition(b"attachment; filename=\"a b.txt\"")?;
269/// assert!(matches!(cd.filename(), Some(Filename::Plain(w)) if w.eq_bytes(b"a b.txt")));
270/// let cd = parse_content_disposition(b"attachment; filename*=UTF-8''a%20b.txt")?;
271/// assert!(matches!(cd.filename(), Some(Filename::Ext(_))));
272/// # Ok::<(), parse_net::ParseError>(())
273/// ```
274#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
275pub enum Filename<'a> {
276 /// The `filename` (or `title`) parameter.
277 Plain(Word<'a>),
278 /// The `filename*` (or `title*`) parameter.
279 Ext(ExtValue<'a>),
280}
281
282/// The bytes of a word with quoted pairs replaced.
283#[derive(Clone)]
284enum WordBytes<'a> {
285 Token(core::slice::Iter<'a, u8>),
286 Quoted(crate::text::Unescape<'a>),
287}
288
289impl Iterator for WordBytes<'_> {
290 type Item = u8;
291
292 fn next(&mut self) -> Option<u8> {
293 match self {
294 WordBytes::Token(t) => t.next().copied(),
295 WordBytes::Quoted(q) => q.next(),
296 }
297 }
298}
299
300fn word_bytes<'a>(w: &Word<'a>) -> WordBytes<'a> {
301 match w {
302 Word::Token(t) => WordBytes::Token(t.iter()),
303 Word::Quoted(q) => WordBytes::Quoted(q.bytes()),
304 }
305}
306
307/// Percent-decoding over a byte iterator; a `%` without two hex digits is
308/// kept as it is (the validating pass refuses it first).
309#[derive(Clone)]
310struct PctDecode<I> {
311 inner: I,
312}
313
314impl<I: Iterator<Item = u8> + Clone> Iterator for PctDecode<I> {
315 type Item = u8;
316
317 fn next(&mut self) -> Option<u8> {
318 let b = self.inner.next()?;
319 if b != b'%' {
320 return Some(b);
321 }
322 let mut look = self.inner.clone();
323 match (
324 look.next().and_then(class::hex_value),
325 look.next().and_then(class::hex_value),
326 ) {
327 (Some(h), Some(l)) => {
328 self.inner = look;
329 Some((h << 4) | l)
330 }
331 _ => Some(b),
332 }
333 }
334}
335
336/// The index of the first `%` in `bytes` not followed by two hex digits.
337fn has_bad_percent(bytes: impl Iterator<Item = u8>) -> Option<usize> {
338 let mut pending: Option<usize> = None;
339 for (i, b) in bytes.enumerate() {
340 match pending {
341 Some(at) if !class::is_hexdig(b) => return Some(at),
342 Some(at) if i == at + 2 => pending = None,
343 Some(_) => {}
344 None if b == b'%' => pending = Some(i),
345 None => {}
346 }
347 }
348 pending
349}
350
351impl Filename<'_> {
352 /// An upper bound on the length the `decode` functions write.
353 ///
354 /// # Examples
355 ///
356 /// ```
357 /// let cd = parse_net::parse_content_disposition(b"attachment; filename*=ISO-8859-1''caf%E9")?;
358 /// let name = cd.filename().expect("present");
359 /// assert_eq!(name.max_decoded_len(), 8);
360 /// let mut buf = [0u8; 8];
361 /// assert_eq!(name.decode_into(&mut buf)?, "caf\u{e9}");
362 /// # Ok::<(), parse_net::ParseError>(())
363 /// ```
364 #[must_use]
365 pub fn max_decoded_len(&self) -> usize {
366 match self {
367 Filename::Plain(w) => w.len(),
368 Filename::Ext(e) => e.max_decoded_len(),
369 }
370 }
371
372 /// Decodes into `out`: the unescaped `filename` as UTF-8, or the decoded
373 /// `filename*`. Separators (`/`, `\`) pass through; see
374 /// [`has_path_separator`](Self::has_path_separator).
375 ///
376 /// # Errors
377 ///
378 /// [`ErrorKind::InvalidUtf8`], [`ErrorKind::ControlInDecoded`],
379 /// [`ErrorKind::BufferTooSmall`]. Nothing is written on error.
380 ///
381 /// # Examples
382 ///
383 /// ```
384 /// use parse_net::{parse_content_disposition, ErrorKind};
385 ///
386 /// let cd = parse_content_disposition(b"attachment; filename=\"q\\\"uote.txt\"")?;
387 /// let name = cd.filename().expect("present");
388 /// let mut buf = [0u8; 16];
389 /// assert_eq!(name.decode_into(&mut buf)?, "q\"uote.txt");
390 /// assert_eq!(name.decode_into(&mut [0u8; 2]).unwrap_err().kind(), ErrorKind::BufferTooSmall);
391 /// # Ok::<(), parse_net::ParseError>(())
392 /// ```
393 pub fn decode_into<'b>(&self, out: &'b mut [u8]) -> Result<&'b str> {
394 match self {
395 Filename::Plain(w) => decode_checked(word_bytes(w), Charset::Utf8, out),
396 Filename::Ext(e) => e.decode_into(out),
397 }
398 }
399
400 /// As [`decode_into`](Self::decode_into), and `%XX` in a plain
401 /// `filename` is decoded too (RFC 7578 section 2 percent-encoded file
402 /// names).
403 ///
404 /// # Errors
405 ///
406 /// As [`decode_into`](Self::decode_into), and
407 /// [`ErrorKind::InvalidPercentEncoding`].
408 ///
409 /// # Examples
410 ///
411 /// ```
412 /// let cd = parse_net::parse_content_disposition(b"form-data; name=f; filename=\"caf%C3%A9.txt\"")?;
413 /// let mut buf = [0u8; 32];
414 /// assert_eq!(cd.filename().expect("present").decode_percent_into(&mut buf)?, "caf\u{e9}.txt");
415 /// # Ok::<(), parse_net::ParseError>(())
416 /// ```
417 pub fn decode_percent_into<'b>(&self, out: &'b mut [u8]) -> Result<&'b str> {
418 match self {
419 Filename::Plain(w) => {
420 if let Some(at) = has_bad_percent(word_bytes(w)) {
421 return err(ErrorKind::InvalidPercentEncoding, at);
422 }
423 decode_checked(
424 PctDecode {
425 inner: word_bytes(w),
426 },
427 Charset::Utf8,
428 out,
429 )
430 }
431 Filename::Ext(e) => e.decode_into(out),
432 }
433 }
434
435 /// Whether the decoded name contains `/` or `\` (in a plain name, also
436 /// after percent-decoding). The file-name policy belongs to the caller
437 /// (path-lib, customs-sec).
438 ///
439 /// # Examples
440 ///
441 /// ```
442 /// use parse_net::parse_content_disposition;
443 ///
444 /// let cd = parse_content_disposition(b"attachment; filename=\"..%2Fetc%2Fpasswd\"")?;
445 /// assert!(cd.filename().expect("present").has_path_separator());
446 /// let ok = parse_content_disposition(b"attachment; filename=notes.txt")?;
447 /// assert!(!ok.filename().expect("present").has_path_separator());
448 /// # Ok::<(), parse_net::ParseError>(())
449 /// ```
450 #[must_use]
451 pub fn has_path_separator(&self) -> bool {
452 let sep = |b: u8| b == b'/' || b == b'\\';
453 match self {
454 Filename::Plain(w) => {
455 word_bytes(w).any(sep)
456 || PctDecode {
457 inner: word_bytes(w),
458 }
459 .any(sep)
460 }
461 Filename::Ext(e) => e.raw_bytes().any(sep),
462 }
463 }
464
465 /// Whether the decoded name contains a bidirectional control character
466 /// (U+202A to U+202E, U+2066 to U+2069), used to disguise an extension
467 /// (`exe.txt` shown for `txt.exe`). Reported, not refused.
468 ///
469 /// # Examples
470 ///
471 /// ```
472 /// let cd = parse_net::parse_content_disposition(b"attachment; filename*=UTF-8''invoice%E2%80%AEfdp.exe")?;
473 /// assert!(cd.filename().expect("present").has_bidi_control());
474 /// # Ok::<(), parse_net::ParseError>(())
475 /// ```
476 #[must_use]
477 pub fn has_bidi_control(&self) -> bool {
478 fn scan(bytes: impl Iterator<Item = u8>) -> bool {
479 let (mut a, mut b) = (0u8, 0u8);
480 for c in bytes {
481 if a == 0xe2
482 && ((b == 0x80 && (0xaa..=0xae).contains(&c))
483 || (b == 0x81 && (0xa6..=0xa9).contains(&c)))
484 {
485 return true;
486 }
487 a = b;
488 b = c;
489 }
490 false
491 }
492 match self {
493 Filename::Plain(w) => {
494 scan(word_bytes(w))
495 || scan(PctDecode {
496 inner: word_bytes(w),
497 })
498 }
499 Filename::Ext(e) if e.charset() == Charset::Utf8 => scan(e.raw_bytes()),
500 Filename::Ext(_) => false,
501 }
502 }
503}
504
505/// Whether `c` must not appear in the ASCII fallback name.
506fn needs_fallback(c: char) -> bool {
507 !c.is_ascii() || c == '"' || c == '\\' || c.is_ascii_control()
508}
509
510fn put_attachment(filename: &str, out: &mut impl Sink) -> core::result::Result<(), WriteError> {
511 out.put(b"attachment; filename=\"")?;
512 for c in filename.chars() {
513 if needs_fallback(c) {
514 out.put(b"_")?;
515 } else {
516 let mut buf = [0u8; 4];
517 out.put(c.encode_utf8(&mut buf).as_bytes())?;
518 }
519 }
520 out.put(b"\"")?;
521 if filename.chars().any(needs_fallback) {
522 out.put(b"; filename*=")?;
523 write_ext_value(filename, out)?;
524 }
525 Ok(())
526}
527
528/// Writes `attachment; filename="<ASCII fallback>"`, followed by
529/// `; filename*=UTF-8''<encoded>` when the name is not plain printable ASCII
530/// without `"` and `\`. The fallback replaces each such character with `_`.
531///
532/// # Errors
533///
534/// [`WriteError::Forbidden`] for a control character in `filename`;
535/// [`WriteError::Full`] (nothing written when the sink reports its room).
536///
537/// # Examples
538///
539/// ```
540/// use parse_net::{write_attachment, SliceSink};
541///
542/// let mut buf = [0u8; 96];
543/// let mut out = SliceSink::new(&mut buf);
544/// write_attachment("\u{20ac} rates.txt", &mut out).expect("room");
545/// assert_eq!(out.written(), b"attachment; filename=\"_ rates.txt\"; filename*=UTF-8''%E2%82%AC%20rates.txt");
546/// ```
547pub fn write_attachment(
548 filename: &str,
549 out: &mut impl Sink,
550) -> core::result::Result<(), WriteError> {
551 for (i, c) in filename.char_indices() {
552 let p = u32::from(c);
553 if p < 0x20 || (0x7f..=0x9f).contains(&p) {
554 return Err(WriteError::forbidden(i));
555 }
556 }
557 let mut count = CountSink::new();
558 put_attachment(filename, &mut count)?;
559 reserve(out, count.count())?;
560 put_attachment(filename, out)
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn cd(v: &[u8]) -> Result<ContentDisposition<'_>> {
568 parse_content_disposition(v)
569 }
570
571 fn name(v: &[u8]) -> std::string::String {
572 let mut buf = [0u8; 128];
573 cd(v)
574 .unwrap()
575 .filename()
576 .unwrap()
577 .decode_into(&mut buf)
578 .unwrap()
579 .into()
580 }
581
582 #[test]
583 fn test_disposition_types_and_params() {
584 assert_eq!(cd(b"inline").unwrap().kind(), DispositionType::Inline);
585 assert_eq!(
586 cd(b"ATTACHMENT;").unwrap().kind(),
587 DispositionType::Attachment
588 );
589 assert_eq!(
590 cd(b"form-data; name=\"a\"; filename=\"\"").unwrap().kind(),
591 DispositionType::FormData
592 );
593 assert_eq!(
594 cd(b"x-custom").unwrap().kind(),
595 DispositionType::Other(b"x-custom")
596 );
597 assert_eq!(cd(b"").unwrap_err().kind(), ErrorKind::NotToken);
598 assert_eq!(
599 cd(b"attachment x").unwrap_err().kind(),
600 ErrorKind::TrailingData
601 );
602 assert_eq!(
603 cd(b"attachment; filename=a\rb").unwrap_err().kind(),
604 ErrorKind::BareCr
605 );
606 assert!(cd(b"attachment").unwrap().filename().is_none());
607 }
608
609 #[test]
610 fn test_filename_precedence_and_refusals() {
611 assert_eq!(
612 name(b"attachment; filename=\"a.txt\"; filename*=UTF-8''b.txt"),
613 "b.txt"
614 );
615 assert_eq!(
616 name(b"attachment; filename*=UTF-8''b.txt; filename=\"a.txt\""),
617 "b.txt"
618 );
619 assert_eq!(
620 name(b"attachment; filename=\"a \\\"q\\\".txt\""),
621 "a \"q\".txt"
622 );
623 let k = |v: &[u8]| cd(v).unwrap_err().kind();
624 // T7: an invalid filename* refuses the field.
625 assert_eq!(
626 k(b"attachment; filename=\"a.txt\"; filename*=UTF-8''%C3"),
627 ErrorKind::InvalidUtf8
628 );
629 assert_eq!(
630 k(b"attachment; filename*=\"UTF-8''a\""),
631 ErrorKind::InvalidCharset
632 );
633 assert_eq!(
634 k(b"attachment; filename*=UTF-16''a"),
635 ErrorKind::UnsupportedCharset
636 );
637 // T4: duplicates refused, whatever the limits say.
638 let last = Limits::DEFAULT.duplicate_params(DuplicatePolicy::Last);
639 assert_eq!(
640 parse(b"attachment; filename=a; FILENAME=b", &last)
641 .unwrap_err()
642 .kind(),
643 ErrorKind::DuplicateParameter
644 );
645 }
646
647 #[test]
648 fn test_filename_nul_refused() {
649 let mut buf = [0u8; 32];
650 let e = cd(b"attachment; filename*=UTF-8''a%00.txt").unwrap_err();
651 assert_eq!((e.kind(), e.offset()), (ErrorKind::ControlInDecoded, 22));
652 let p = cd(b"form-data; name=x; filename=\"a%00b\"").unwrap();
653 assert_eq!(
654 p.filename()
655 .unwrap()
656 .decode_percent_into(&mut buf)
657 .unwrap_err()
658 .kind(),
659 ErrorKind::ControlInDecoded
660 );
661 assert_eq!(
662 p.filename().unwrap().decode_into(&mut buf).unwrap(),
663 "a%00b"
664 );
665 }
666
667 #[test]
668 fn test_filename_overlong_utf8_refused() {
669 let mut buf = [0u8; 32];
670 assert_eq!(
671 cd(b"attachment; filename*=UTF-8''%C0%AF")
672 .unwrap_err()
673 .kind(),
674 ErrorKind::InvalidUtf8
675 );
676 let p = cd(b"attachment; filename=\"\xc0\xaf\"").unwrap();
677 assert_eq!(
678 p.filename()
679 .unwrap()
680 .decode_into(&mut buf)
681 .unwrap_err()
682 .kind(),
683 ErrorKind::InvalidUtf8
684 );
685 }
686
687 #[test]
688 fn test_filename_separators_and_bidi_reported() {
689 fn f(v: &[u8]) -> Filename<'_> {
690 cd(v).unwrap().filename().unwrap()
691 }
692 assert!(f(b"attachment; filename=\"../x\"").has_path_separator());
693 assert!(f(b"attachment; filename=\"a\\\\b\"").has_path_separator());
694 assert!(f(b"attachment; filename=\"a%2Fb\"").has_path_separator());
695 assert!(f(b"attachment; filename*=UTF-8''a%2Fb").has_path_separator());
696 assert!(!f(b"attachment; filename=plain.txt").has_path_separator());
697 assert!(f(b"attachment; filename*=UTF-8''%E2%81%A6x").has_bidi_control());
698 assert!(!f(b"attachment; filename*=UTF-8''%E2%82%AC").has_bidi_control());
699 assert!(!f(b"attachment; filename*=ISO-8859-1''%E2%A1%AE").has_bidi_control());
700 assert_eq!(
701 cd(b"attachment; filename*=ISO-8859-1''%85")
702 .unwrap_err()
703 .kind(),
704 ErrorKind::ControlInDecoded
705 );
706 }
707
708 #[test]
709 fn test_decode_percent_into_errors() {
710 let mut buf = [0u8; 32];
711 let p = cd(b"form-data; name=x; filename=\"a%zz\"").unwrap();
712 assert_eq!(
713 p.filename()
714 .unwrap()
715 .decode_percent_into(&mut buf)
716 .unwrap_err()
717 .kind(),
718 ErrorKind::InvalidPercentEncoding
719 );
720 let p = cd(b"form-data; name=x; filename=\"a%4\"").unwrap();
721 assert_eq!(
722 p.filename()
723 .unwrap()
724 .decode_percent_into(&mut buf)
725 .unwrap_err()
726 .kind(),
727 ErrorKind::InvalidPercentEncoding
728 );
729 }
730
731 #[test]
732 fn test_write_attachment_cases() {
733 let mut buf = [0u8; 128];
734 let mut out = crate::SliceSink::new(&mut buf);
735 write_attachment("report.pdf", &mut out).unwrap();
736 assert_eq!(out.written(), b"attachment; filename=\"report.pdf\"");
737 let back = cd(out.written()).unwrap();
738 assert!(back.filename_ext().is_none());
739 out.clear();
740 write_attachment("a\"b\\c", &mut out).unwrap();
741 assert_eq!(
742 out.written(),
743 b"attachment; filename=\"a_b_c\"; filename*=UTF-8''a%22b%5Cc"
744 );
745 let mut dec = [0u8; 16];
746 assert_eq!(
747 cd(out.written())
748 .unwrap()
749 .filename()
750 .unwrap()
751 .decode_into(&mut dec)
752 .unwrap(),
753 "a\"b\\c"
754 );
755 assert_eq!(
756 write_attachment("a\nb", &mut out),
757 Err(WriteError::Forbidden { at: 1 })
758 );
759 let mut small = [0u8; 10];
760 let mut out = crate::SliceSink::new(&mut small);
761 assert_eq!(write_attachment("x", &mut out), Err(WriteError::Full));
762 assert!(out.is_empty());
763 }
764}