aria2_protocol/http/
header.rs1use tracing::debug;
2
3pub struct HttpHeaderProcessor;
4
5impl HttpHeaderProcessor {
6 pub fn extract_filename(content_disposition: &str) -> Option<String> {
7 let cd = content_disposition.trim();
8
9 let filename_star = Self::extract_filename_star(cd);
10 if filename_star.is_some() {
11 return filename_star;
12 }
13
14 Self::extract_filename_regular(cd)
15 }
16
17 fn extract_filename_star(cd: &str) -> Option<String> {
18 for part in cd.split(';') {
19 let part = part.trim();
20 if let Some(rest) = part.strip_prefix("filename*=") {
21 let rest = rest.trim().trim_matches('"');
22 if let Some(encoded) = rest.split_once('\'') {
23 let (_charset, encoded_name) = encoded;
24 return Some(Self::decode_rfc5987(encoded_name));
25 }
26 return Some(rest.to_string());
27 }
28 }
29 None
30 }
31
32 fn extract_filename_regular(cd: &str) -> Option<String> {
33 let cd = cd.trim();
34 let pos = cd.find("filename=")?;
35 let rest = &cd[pos + 9..];
36 let rest = rest.trim();
37 if let Some(rest) = rest.strip_prefix('"') {
38 if let Some(end_quote) = rest.find('"') {
39 return Some(rest[..end_quote].to_string());
40 }
41 } else {
42 let end_pos = rest.find(';').unwrap_or(rest.len());
43 return Some(rest[..end_pos].trim().to_string());
44 }
45 None
46 }
47
48 fn decode_rfc5987(input: &str) -> String {
49 let mut bytes = Vec::with_capacity(input.len());
50 let mut chars = input.chars().peekable();
51
52 while let Some(c) = chars.next() {
53 if c == '%' {
54 let hex: String = chars.by_ref().take(2).collect();
55 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
56 bytes.push(byte);
57 } else {
58 bytes.extend(c.to_string().as_bytes());
59 bytes.extend(hex.as_bytes());
60 }
61 } else {
62 bytes.push(c as u8);
63 }
64 }
65
66 String::from_utf8_lossy(&bytes).to_string()
67 }
68
69 pub fn sanitize_filename(filename: &str) -> String {
70 let forbidden = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
71 let mut result = String::with_capacity(filename.len());
72 for c in filename.chars() {
73 if forbidden.contains(&c) {
74 result.push('_');
75 } else {
76 result.push(c);
77 }
78 }
79 if result.is_empty() || result == "." || result == ".." {
80 "download".to_string()
81 } else {
82 result
83 }
84 }
85
86 pub fn extract_extension(url: &str) -> Option<String> {
87 let path = url.rsplit('/').next()?;
88 let path = path.split('?').next()?.split('#').next()?;
89 let dot_pos = path.rfind('.')?;
90 let ext = &path[dot_pos + 1..];
91 if ext.is_empty() {
92 return None;
93 }
94 Some(ext.to_lowercase())
95 }
96
97 pub fn guess_filename_from_url(url: &str) -> Option<String> {
98 let path = url
99 .rsplit('/')
100 .next()?
101 .split('?')
102 .next()?
103 .split('#')
104 .next()?;
105 if path.is_empty() || path == "/" {
106 return None;
107 }
108 Some(path.to_string())
109 }
110
111 pub fn resolve_filename(
112 url: &str,
113 content_disposition: Option<&str>,
114 default_name: &str,
115 ) -> String {
116 if let Some(cd) = content_disposition
117 && let Some(name) = Self::extract_filename(cd)
118 {
119 let sanitized = Self::sanitize_filename(&name);
120 debug!("从Content-Disposition解析文件名: {}", sanitized);
121 return sanitized;
122 }
123
124 if let Some(name) = Self::guess_filename_from_url(url) {
125 let sanitized = Self::sanitize_filename(&name);
126 debug!("从URL路径解析文件名: {}", sanitized);
127 return sanitized;
128 }
129
130 debug!("使用默认文件名: {}", default_name);
131 default_name.to_string()
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn test_extract_filename_regular() {
141 let cd = r#"attachment; filename="example.zip""#;
142 assert_eq!(
143 HttpHeaderProcessor::extract_filename(cd),
144 Some("example.zip".to_string())
145 );
146 }
147
148 #[test]
149 fn test_extract_filename_no_quotes() {
150 let cd = "attachment; filename=example.zip";
151 assert_eq!(
152 HttpHeaderProcessor::extract_filename(cd),
153 Some("example.zip".to_string())
154 );
155 }
156
157 #[test]
158 fn test_extract_filename_with_semicolon_in_name() {
159 let cd = r#"attachment; filename="file;name.zip""#;
160 assert_eq!(
161 HttpHeaderProcessor::extract_filename(cd),
162 Some("file;name.zip".to_string())
163 );
164 }
165
166 #[test]
167 fn test_extract_filename_star_rfc5987() {
168 let cd = "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.txt";
169 let result = HttpHeaderProcessor::extract_filename(cd);
170 assert!(result.is_some());
171 assert!(result.unwrap().contains("中文"));
172 }
173
174 #[test]
175 fn test_sanitize_filename() {
176 assert_eq!(
177 HttpHeaderProcessor::sanitize_filename("file/name.zip"),
178 "file_name.zip"
179 );
180 assert_eq!(HttpHeaderProcessor::sanitize_filename(".."), "download");
181 assert_eq!(HttpHeaderProcessor::sanitize_filename("."), "download");
182 assert_eq!(HttpHeaderProcessor::sanitize_filename(""), "download");
183 assert_eq!(
184 HttpHeaderProcessor::sanitize_filename("normal_file.tar.gz"),
185 "normal_file.tar.gz"
186 );
187 }
188
189 #[test]
190 fn test_guess_filename_from_url() {
191 assert_eq!(
192 HttpHeaderProcessor::guess_filename_from_url("https://example.com/path/to/file.zip"),
193 Some("file.zip".to_string())
194 );
195 assert_eq!(
196 HttpHeaderProcessor::guess_filename_from_url("https://example.com/"),
197 None
198 );
199 }
200
201 #[test]
202 fn test_resolve_filename_priority() {
203 let resolved = HttpHeaderProcessor::resolve_filename(
204 "https://example.com/download",
205 Some(r#"attachment; filename="real_name.zip""#),
206 "default.bin",
207 );
208 assert_eq!(resolved, "real_name.zip");
209
210 let resolved_fallback = HttpHeaderProcessor::resolve_filename(
211 "https://example.com/path/file.bin",
212 None,
213 "default.bin",
214 );
215 assert_eq!(resolved_fallback, "file.bin");
216 }
217
218 #[test]
219 fn test_extract_extension() {
220 assert_eq!(
221 HttpHeaderProcessor::extract_extension("https://example.com/file.tar.gz?v=1"),
222 Some("gz".to_string())
223 );
224 assert_eq!(
225 HttpHeaderProcessor::extract_extension("https://example.com/nofile"),
226 None
227 );
228 }
229}