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