Skip to main content

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}