fast_down/http/
content_disposition.rs1use std::{iter::Peekable, str::Chars};
9
10#[derive(Debug, PartialEq, Eq)]
15pub struct ContentDisposition {
16 pub filename: Option<String>,
18}
19impl ContentDisposition {
20 #[must_use]
22 pub fn parse(header_value: &str) -> Self {
23 let mut filename = None;
24 let mut filename_star = None;
25 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 let key = Self::read_key(&mut chars);
36 if key.is_empty() {
37 match chars.peek() {
39 Some(';') => {
40 chars.next();
41 continue;
42 }
43 _ => break,
44 }
45 }
46 match chars.peek() {
48 Some('=') => {
49 chars.next(); }
51 Some(';') => {
52 chars.next();
55 continue;
56 }
57 None => break, _ => {
59 Self::skip_until(&mut chars, ';');
61 continue;
62 }
63 }
64 Self::consume_whitespace(&mut chars);
65 let value = match chars.peek() {
67 Some('"') => {
68 chars.next(); Self::read_quoted_string(&mut chars)
70 }
71 _ => Self::read_token(&mut chars),
72 };
73 Self::consume_whitespace(&mut chars);
76 if matches!(chars.peek(), Some(';')) {
77 chars.next();
78 }
79 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 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 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*=""#; 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 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 assert_eq!(ContentDisposition::parse("attachment;=x").filename, None);
257 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 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 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 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 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 let s = "attachment; filename*=UTF-8''%41é";
315 assert_eq!(ContentDisposition::parse(s).filename.as_deref(), Some("Aé"));
316 }
317}