Skip to main content

aria2_protocol/http/
header.rs

1use 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}