1use std::path::{Component, Path, PathBuf};
8
9use anyhow::{Context, Result, bail};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FilenameSource {
14 Explicit,
15 ContentDisposition,
16 UrlPath,
17 Fallback,
18}
19
20#[derive(Debug, Clone)]
21pub struct Destination {
22 pub path: PathBuf,
23 pub filename: String,
24 pub source: FilenameSource,
25}
26
27pub fn choose(
29 explicit_output: Option<&str>,
30 dir: Option<&str>,
31 content_disposition: Option<&str>,
32 final_url: &url::Url,
33) -> Result<Destination> {
34 if let Some(out) = explicit_output {
37 let out = Path::new(out);
38 let (base_dir, name) = match out.parent() {
39 Some(p) if !p.as_os_str().is_empty() => (
40 resolve_dir(Some(&p.to_string_lossy()))?,
41 out.file_name()
42 .context("--output must end in a filename")?
43 .to_string_lossy()
44 .to_string(),
45 ),
46 _ => (resolve_dir(dir)?, out.to_string_lossy().to_string()),
47 };
48 if name.is_empty() {
49 bail!("--output must end in a filename");
50 }
51 return Ok(Destination {
52 path: base_dir.join(&name),
53 filename: name,
54 source: FilenameSource::Explicit,
55 });
56 }
57
58 let base_dir = resolve_dir(dir)?;
59
60 let (name, source) = content_disposition
61 .and_then(from_content_disposition)
62 .map(|n| (n, FilenameSource::ContentDisposition))
63 .or_else(|| from_url(final_url).map(|n| (n, FilenameSource::UrlPath)))
64 .unwrap_or_else(|| (fallback_name(final_url), FilenameSource::Fallback));
65
66 let name = sanitize(&name).unwrap_or_else(|| fallback_name(final_url));
67
68 Ok(Destination {
69 path: base_dir.join(&name),
70 filename: name,
71 source,
72 })
73}
74
75fn resolve_dir(dir: Option<&str>) -> Result<PathBuf> {
76 let path = match dir {
77 Some(d) => expand_tilde(d),
78 None => std::env::current_dir().context("cannot determine current directory")?,
79 };
80 Ok(path)
81}
82
83pub fn expand_tilde(input: &str) -> PathBuf {
84 if let Some(rest) = input.strip_prefix("~/") {
85 if let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) {
86 return home.join(rest);
87 }
88 }
89 PathBuf::from(input)
90}
91
92fn from_content_disposition(header: &str) -> Option<String> {
96 if let Some(idx) = find_param(header, "filename*") {
98 let value = header[idx..].split(';').next()?.trim();
99 if let Some((charset_and_lang, encoded)) = rsplit_ext_value(value) {
100 let charset = charset_and_lang.to_ascii_lowercase();
101 if charset.starts_with("utf-8") || charset.starts_with("iso-8859-1") {
102 let decoded = percent_decode(encoded);
103 if let Ok(s) = String::from_utf8(decoded) {
104 if let Some(clean) = sanitize(&s) {
105 return Some(clean);
106 }
107 }
108 }
109 }
110 }
111
112 let idx = find_param(header, "filename")?;
113 let rest = &header[idx..];
114 let raw = if let Some(stripped) = rest.strip_prefix('"') {
115 stripped.split('"').next()?.to_string()
116 } else {
117 rest.split(';').next()?.trim().to_string()
118 };
119 sanitize(&raw)
120}
121
122fn find_param(header: &str, name: &str) -> Option<usize> {
124 let lower = header.to_ascii_lowercase();
125 let mut from = 0;
126 while let Some(pos) = lower[from..].find(name) {
127 let abs = from + pos;
128 let after = abs + name.len();
129 let boundary = abs == 0
131 || lower[..abs]
132 .chars()
133 .next_back()
134 .is_some_and(|c| c == ';' || c == ' ');
135 let eq = lower[after..].trim_start().starts_with('=');
136 let exact = !name.ends_with('*') || lower[after..].trim_start().starts_with('=');
138 if boundary && eq && exact && !(name == "filename" && lower[after..].starts_with('*')) {
139 let value_start = after + lower[after..].find('=')? + 1;
140 return Some(value_start);
141 }
142 from = abs + name.len();
143 }
144 None
145}
146
147fn rsplit_ext_value(value: &str) -> Option<(&str, &str)> {
149 let mut parts = value.splitn(3, '\'');
150 let charset = parts.next()?;
151 let _lang = parts.next()?;
152 let name = parts.next()?;
153 Some((charset, name))
154}
155
156fn percent_decode(input: &str) -> Vec<u8> {
157 let bytes = input.as_bytes();
158 let mut out = Vec::with_capacity(bytes.len());
159 let mut i = 0;
160 while i < bytes.len() {
161 if bytes[i] == b'%' && i + 2 < bytes.len() {
162 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
163 if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
164 out.push(v);
165 i += 3;
166 continue;
167 }
168 }
169 out.push(bytes[i]);
170 i += 1;
171 }
172 out
173}
174
175fn from_url(url: &url::Url) -> Option<String> {
176 let last = url.path_segments()?.next_back()?;
177 if last.is_empty() {
178 return None;
179 }
180 let decoded = percent_decode(last);
181 let s = String::from_utf8_lossy(&decoded).to_string();
182 sanitize(&s)
183}
184
185fn fallback_name(url: &url::Url) -> String {
186 let host = url.host_str().unwrap_or("download");
187 let stem = sanitize(host).unwrap_or_else(|| "download".to_string());
188 format!("{stem}.download")
189}
190
191pub fn sanitize(raw: &str) -> Option<String> {
194 let last = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
198
199 let cleaned: String = last
200 .chars()
201 .filter(|c| !c.is_control() && *c != '\0')
202 .collect();
203 let cleaned = cleaned.trim().trim_end_matches('.').to_string();
204
205 if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
206 return None;
207 }
208
209 let stem = cleaned
212 .split('.')
213 .next()
214 .unwrap_or(&cleaned)
215 .to_ascii_uppercase();
216 const RESERVED: [&str; 22] = [
217 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
218 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
219 ];
220 if RESERVED.contains(&stem.as_str()) {
221 return None;
222 }
223
224 let cleaned = truncate_bytes(&cleaned, 255);
227 if cleaned.is_empty() {
228 None
229 } else {
230 Some(cleaned)
231 }
232}
233
234fn truncate_bytes(s: &str, max: usize) -> String {
235 if s.len() <= max {
236 return s.to_string();
237 }
238 let ext = Path::new(s)
239 .extension()
240 .map(|e| format!(".{}", e.to_string_lossy()))
241 .unwrap_or_default();
242 let keep = max.saturating_sub(ext.len());
243 let mut end = keep.min(s.len());
244 while end > 0 && !s.is_char_boundary(end) {
245 end -= 1;
246 }
247 format!("{}{}", &s[..end], ext)
248}
249
250pub fn assert_within(dir: &Path, path: &Path) -> Result<()> {
254 if path.components().any(|c| matches!(c, Component::ParentDir)) {
255 bail!("refusing a destination containing `..`: {}", path.display());
256 }
257 let parent = path.parent().unwrap_or(Path::new("."));
258 let (a, b) = (normalise(dir), normalise(parent));
259 if a != b {
260 bail!(
261 "refusing to write outside {}: resolved to {}",
262 dir.display(),
263 path.display()
264 );
265 }
266 Ok(())
267}
268
269fn normalise(p: &Path) -> PathBuf {
272 let mut out = PathBuf::new();
273 for c in p.components() {
274 match c {
275 Component::CurDir => {}
276 Component::ParentDir => {
277 out.pop();
278 }
279 other => out.push(other.as_os_str()),
280 }
281 }
282 out
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn u(s: &str) -> url::Url {
290 url::Url::parse(s).unwrap()
291 }
292
293 #[test]
294 fn rejects_traversal() {
295 assert_eq!(sanitize("../../etc/passwd"), Some("passwd".into()));
296 assert_eq!(
297 sanitize("..\\..\\windows\\system32"),
298 Some("system32".into())
299 );
300 assert_eq!(sanitize(".."), None);
301 assert_eq!(sanitize("/"), None);
302 assert_eq!(sanitize(""), None);
303 assert_eq!(sanitize(" "), None);
304 assert_eq!(sanitize("a\0b"), Some("ab".into()));
305 assert_eq!(sanitize("evil\n.iso"), Some("evil.iso".into()));
306 }
307
308 #[test]
309 fn rejects_reserved_names() {
310 assert_eq!(sanitize("NUL"), None);
311 assert_eq!(sanitize("con.txt"), None);
312 assert_eq!(sanitize("console.txt"), Some("console.txt".into()));
313 }
314
315 #[test]
316 fn truncates_long_names() {
317 let long = format!("{}.iso", "a".repeat(400));
318 let out = sanitize(&long).unwrap();
319 assert!(out.len() <= 255);
320 assert!(out.ends_with(".iso"));
321 }
322
323 #[test]
324 fn parses_content_disposition() {
325 assert_eq!(
326 from_content_disposition("attachment; filename=\"linux.iso\""),
327 Some("linux.iso".into())
328 );
329 assert_eq!(
330 from_content_disposition("attachment; filename=plain.bin"),
331 Some("plain.bin".into())
332 );
333 assert_eq!(
334 from_content_disposition("attachment; filename*=UTF-8''caf%C3%A9%20menu.pdf"),
335 Some("café menu.pdf".into())
336 );
337 assert_eq!(
339 from_content_disposition(
340 "attachment; filename=\"fallback.bin\"; filename*=UTF-8''real.bin"
341 ),
342 Some("real.bin".into())
343 );
344 assert_eq!(
346 from_content_disposition("attachment; filename=\"../../../etc/shadow\""),
347 Some("shadow".into())
348 );
349 assert_eq!(from_content_disposition("inline"), None);
350 }
351
352 #[test]
353 fn falls_through_priority_order() {
354 let d = choose(
355 None,
356 Some("/tmp"),
357 None,
358 &u("https://x.example/a/b/file.tar.gz"),
359 )
360 .unwrap();
361 assert_eq!(d.filename, "file.tar.gz");
362 assert_eq!(d.source, FilenameSource::UrlPath);
363
364 let d = choose(
365 None,
366 Some("/tmp"),
367 Some("attachment; filename=real.bin"),
368 &u("https://x.example/a/b/file.tar.gz"),
369 )
370 .unwrap();
371 assert_eq!(d.filename, "real.bin");
372
373 let d = choose(
374 Some("mine.iso"),
375 Some("/tmp"),
376 Some("attachment; filename=real.bin"),
377 &u("https://x.example/file.tar.gz"),
378 )
379 .unwrap();
380 assert_eq!(d.path, PathBuf::from("/tmp/mine.iso"));
381
382 let d = choose(None, Some("/tmp"), None, &u("https://x.example/")).unwrap();
383 assert_eq!(d.filename, "x.example.download");
384 assert_eq!(d.source, FilenameSource::Fallback);
385 }
386
387 #[test]
388 fn percent_decodes_url_names() {
389 let d = choose(
390 None,
391 Some("/tmp"),
392 None,
393 &u("https://x.example/my%20file.iso"),
394 )
395 .unwrap();
396 assert_eq!(d.filename, "my file.iso");
397 }
398
399 #[test]
400 fn within_check() {
401 assert!(assert_within(Path::new("/tmp"), Path::new("/tmp/a.iso")).is_ok());
402 assert!(assert_within(Path::new("/tmp"), Path::new("/tmp/sub/a.iso")).is_err());
403 assert!(assert_within(Path::new("/tmp"), Path::new("/etc/a.iso")).is_err());
404 assert!(assert_within(Path::new("/tmp"), Path::new("/tmp/../etc/a.iso")).is_err());
405 }
406}