Skip to main content

fast_down/http/
content_disposition.rs

1//! Parser for the HTTP `Content-Disposition` header.
2//!
3//! Used during prefetch to derive a suggested filename for a downloaded
4//! resource. [`ContentDisposition::parse`] handles both the quoted `filename`
5//! form and the RFC 5987 `filename*` (UTF-8, percent-encoded) form, with the
6//! latter taking precedence when both are present.
7
8use std::{iter::Peekable, str::Chars};
9
10/// Parsed `Content-Disposition` header, extracting the `filename` parameter.
11///
12/// Supports both `filename="..."` (quoted) and `filename*=UTF-8''...` (RFC 5987)
13/// encoding. When both are present, `filename*` takes precedence.
14#[derive(Debug, PartialEq, Eq)]
15pub struct ContentDisposition {
16    /// The filename parameter, if the header carried one.
17    pub filename: Option<String>,
18}
19impl ContentDisposition {
20    /// Parse a `Content-Disposition` header value.
21    #[must_use]
22    pub fn parse(header_value: &str) -> Self {
23        let mut filename = None;
24        let mut filename_star = None;
25        // 1. Skip the disposition-type (e.g. "attachment")
26        // If there's no semicolon, there are no parameters
27        let rest = match header_value.find(';') {
28            Some(idx) => &header_value[idx + 1..],
29            None => return Self { filename: None },
30        };
31        let mut chars = rest.chars().peekable();
32        while chars.peek().is_some() {
33            Self::consume_whitespace(&mut chars);
34            // Read the key
35            let key = Self::read_key(&mut chars);
36            if key.is_empty() {
37                // Handle consecutive semicolons (e.g. ";;")
38                match chars.peek() {
39                    Some(';') => {
40                        chars.next();
41                        continue;
42                    }
43                    _ => break,
44                }
45            }
46            // Check for the separator after the key
47            match chars.peek() {
48                Some('=') => {
49                    chars.next(); // consume '='
50                }
51                Some(';') => {
52                    // Key is immediately followed by `;`, so it's a flag parameter (e.g. "hidden;")
53                    // Skip this parameter and continue to the next one
54                    chars.next();
55                    continue;
56                }
57                None => break, // end of string
58                _ => {
59                    // Invalid character encountered; skip to the next semicolon to recover
60                    Self::skip_until(&mut chars, ';');
61                    continue;
62                }
63            }
64            Self::consume_whitespace(&mut chars);
65            // Read the value
66            let value = match chars.peek() {
67                Some('"') => {
68                    chars.next(); // consume the opening quote
69                    Self::read_quoted_string(&mut chars)
70                }
71                _ => Self::read_token(&mut chars),
72            };
73            // Consume the semicolon if it follows the value
74            // Note: if read_token stopped due to whitespace, skip spaces to find the semicolon
75            Self::consume_whitespace(&mut chars);
76            if matches!(chars.peek(), Some(';')) {
77                chars.next();
78            }
79            // Match the key
80            if key.eq_ignore_ascii_case("filename") {
81                filename = Some(value);
82            } else if key.eq_ignore_ascii_case("filename*") {
83                filename_star = Self::parse_filename_star(&value);
84            }
85        }
86        Self {
87            filename: filename_star.or(filename),
88        }
89    }
90
91    fn consume_whitespace(chars: &mut Peekable<Chars<'_>>) {
92        while let Some(c) = chars.peek()
93            && c.is_whitespace()
94        {
95            chars.next();
96        }
97    }
98
99    /// Read a key, stopping at `=` or `;`
100    fn read_key(chars: &mut Peekable<Chars<'_>>) -> String {
101        let mut s = String::new();
102        while let Some(&c) = chars.peek()
103            && c != '='
104            && c != ';'
105        {
106            s.push(c);
107            chars.next();
108        }
109        s.trim().to_string()
110    }
111
112    /// Read an unquoted token (value)
113    /// Stops at `=` or `;` or **whitespace**
114    fn read_token(chars: &mut Peekable<Chars<'_>>) -> String {
115        let mut s = String::new();
116        while let Some(&c) = chars.peek()
117            && c != '='
118            && c != ';'
119            && !c.is_whitespace()
120        {
121            s.push(c);
122            chars.next();
123        }
124        s
125    }
126
127    fn read_quoted_string(chars: &mut Peekable<Chars<'_>>) -> String {
128        let mut s = String::new();
129        while let Some(c) = chars.next() {
130            match c {
131                '"' => break,
132                '\\' => {
133                    if let Some(escaped) = chars.next() {
134                        s.push(escaped);
135                    }
136                }
137                _ => s.push(c),
138            }
139        }
140        s
141    }
142
143    fn skip_until(chars: &mut Peekable<Chars<'_>>, target: char) {
144        for c in chars.by_ref() {
145            if c == target {
146                break;
147            }
148        }
149    }
150
151    fn parse_filename_star(val: &str) -> Option<String> {
152        let mut parts = val.splitn(3, '\'');
153        let charset = parts.next()?;
154        parts.next()?;
155        let encoded_text = parts.next()?;
156        if charset.eq_ignore_ascii_case("UTF-8") {
157            urlencoding::decode(encoded_text)
158                .ok()
159                .map(|s| s.to_string())
160        } else {
161            None
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
169    use super::*;
170
171    #[test]
172    fn test_multiple_params_no_semicolon() {
173        let s = "attachment; filename=foo.txt size=10";
174        let cd = ContentDisposition::parse(s);
175        assert_eq!(cd.filename.unwrap(), "foo.txt");
176    }
177
178    #[test]
179    fn test_quoted_with_spaces() {
180        let s = r#"attachment; filename="foo\" bar.txt"; size=10"#;
181        let cd = ContentDisposition::parse(s);
182        assert_eq!(cd.filename.unwrap(), "foo\" bar.txt");
183    }
184
185    #[test]
186    fn test_flag_parameter() {
187        let s = r#"attachment; hidden; filename="test.txt""#;
188        let cd = ContentDisposition::parse(s);
189        assert_eq!(cd.filename.unwrap(), "test.txt");
190    }
191
192    #[test]
193    fn test_complex_filename_star() {
194        let s = "attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt";
195        let cd = ContentDisposition::parse(s);
196        assert_eq!(cd.filename.unwrap(), "测试.txt");
197        let s = r#"attachment; filename=";;;"; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt"#;
198        let cd = ContentDisposition::parse(s);
199        assert_eq!(cd.filename.unwrap(), "测试.txt");
200    }
201
202    #[test]
203    fn test_empty_values() {
204        let s = r#"attachment; filename=";\";;"; filename*=""#; // invalid filename* will be ignored
205        let cd = ContentDisposition::parse(s);
206        assert_eq!(cd.filename.unwrap(), ";\";;");
207    }
208
209    #[test]
210    fn test_no_semicolon_is_none() {
211        assert_eq!(ContentDisposition::parse("attachment").filename, None);
212    }
213
214    #[test]
215    fn test_empty_header() {
216        assert_eq!(ContentDisposition::parse("").filename, None);
217        assert_eq!(ContentDisposition::parse("   ").filename, None);
218    }
219
220    #[test]
221    fn test_filename_star_non_utf8_ignored() {
222        // A non-UTF-8 charset is unsupported, so `filename*` is dropped.
223        let s = "attachment; filename*=ISO-8859-1''%A3.txt";
224        assert_eq!(ContentDisposition::parse(s).filename, None);
225    }
226
227    #[test]
228    fn test_filename_star_wins_over_filename() {
229        let s = r#"attachment; filename="old.txt"; filename*=UTF-8''%E6%B5%8B.txt"#;
230        assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "测.txt");
231    }
232
233    #[test]
234    fn test_unquoted_token_stops_at_space() {
235        let s = "attachment; filename=foo bar.txt";
236        assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "foo");
237    }
238
239    #[test]
240    fn test_consecutive_semicolons() {
241        let s = "attachment;;; filename=foo.txt";
242        assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "foo.txt");
243    }
244
245    #[test]
246    fn test_quoted_with_escaped_quote() {
247        let s = r#"attachment; filename="a\"b.txt""#;
248        assert_eq!(ContentDisposition::parse(s).filename.unwrap(), "a\"b.txt");
249    }
250
251    #[test]
252    fn test_empty_key_followed_by_equals_breaks() {
253        // `attachment;=x`: after the `;`, `read_key` yields an empty key and the
254        // next character is `=` (neither `;` nor end-of-string), so the parser
255        // breaks out of the loop (line 41) and extracts no filename.
256        assert_eq!(ContentDisposition::parse("attachment;=x").filename, None);
257        // Same path when the empty-key-then-`=` occurs mid-header.
258        assert_eq!(
259            ContentDisposition::parse("attachment;=x; filename=ok.txt").filename,
260            None
261        );
262    }
263
264    #[test]
265    fn filename_star_raw_non_ascii_single_char_is_preserved() {
266        // A single literal CJK char is valid UTF-8; `urlencoding::decode`
267        // passes it through unchanged (the parser no longer truncates
268        // non-ASCII bytes to their low byte).
269        let s = "attachment; filename*=UTF-8''测";
270        assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some("测"));
271    }
272
273    #[test]
274    fn filename_star_literal_cjk_is_preserved() {
275        // `filename*` is not fully percent-encoded (literal CJK instead of
276        // `%XX`), which is non-standard per RFC 5987. The parser preserves the
277        // literal UTF-8 text via `urlencoding::decode` (compatibility fix for
278        // non-standard servers), so the value is kept rather than dropped.
279        let s = "attachment; filename*=UTF-8''测试";
280        assert_eq!(
281            ContentDisposition::parse(s).filename,
282            Some("测试".to_string())
283        );
284    }
285
286    #[test]
287    fn filename_star_empty_value_overrides_filename_with_empty() {
288        // `filename*=UTF-8''` (empty encoded text) parses to Some(""), and
289        // `filename_star.or(filename)` prefers it over a valid `filename`,
290        // yielding an empty filename. (`prefetch::get_filename` filters empty
291        // names, so the higher layer is unaffected.)
292        let s = r#"attachment; filename="fallback.txt"; filename*=UTF-8''"#;
293        assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some(""));
294    }
295
296    #[test]
297    fn test_filename_star_single_decode_is_correct() {
298        // The parser percent-decodes `filename*` exactly once: `%25100`
299        // (the server-intended literal "%100") decodes to "%100". The
300        // double-decode bug lives in `prefetch::get_filename`, not here.
301        let s = "attachment; filename*=UTF-8''%25100";
302        assert_eq!(
303            ContentDisposition::parse(s).filename,
304            Some("%100".to_string())
305        );
306    }
307
308    #[test]
309    fn test_filename_star_mixed_percent_and_literal_preserved() {
310        // Mixing a percent-encoded byte with a literal non-ASCII char: `%41`
311        // decodes to "A" and the literal "é" is preserved by
312        // `urlencoding::decode`. The parser keeps the whole value (compatibility
313        // fix for non-standard input).
314        let s = "attachment; filename*=UTF-8''%41é";
315        assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some("Aé"));
316    }
317}