Skip to main content

rget/
naming.rs

1//! Destination filename selection and sanitisation.
2//!
3//! Everything a server tells us about a filename is hostile input. The only
4//! guarantee this module makes, and it makes it unconditionally: the returned
5//! path's parent is exactly the requested directory.
6
7use std::path::{Component, Path, PathBuf};
8
9use anyhow::{Context, Result, bail};
10
11/// Where a filename came from — used for `--verbose` reporting.
12#[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
27/// Pick a filename, in the priority order from PRD §21.
28pub 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    // An explicit -o may carry its own directory component; the user is
35    // trusted, the server is not.
36    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
92/// Parse RFC 6266 `Content-Disposition`, preferring the RFC 5987 `filename*`
93/// form. Returns `None` for anything we do not fully understand — falling
94/// through to the URL path is always safe.
95fn from_content_disposition(header: &str) -> Option<String> {
96    // filename*=UTF-8''foo%20bar.iso
97    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
122/// Find the start of a parameter's value (`name=` → index just past `=`).
123fn 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        // Must be a parameter boundary before, and `=` (or `*=`) after.
130        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        // Do not let a search for `filename` match `filename*`.
137        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
147/// Split `UTF-8''name` into (`UTF-8'`, `name`).
148fn 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
191/// Reduce a server-supplied string to a single safe path component, or `None`
192/// if nothing safe remains.
193pub fn sanitize(raw: &str) -> Option<String> {
194    // Take the last component after *both* separators: a Windows-style
195    // "..\\..\\evil" must not survive on Unix either, since the resulting
196    // filename would be a nasty surprise when copied between machines.
197    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    // Reject reserved device names, which are hazardous on Windows and merely
210    // confusing elsewhere.
211    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    // Filesystem limit is 255 bytes on every target we care about. Truncate on
225    // a char boundary and keep the extension if we can.
226    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
250/// Final gate before we open anything: the resolved path must sit directly in
251/// the intended directory. Defends against a `..` that slipped through and
252/// against symlinked parents.
253pub 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
269/// Lexical normalisation; we deliberately do not canonicalise, because the
270/// destination usually does not exist yet.
271fn 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        // filename* wins over filename
338        assert_eq!(
339            from_content_disposition(
340                "attachment; filename=\"fallback.bin\"; filename*=UTF-8''real.bin"
341            ),
342            Some("real.bin".into())
343        );
344        // hostile
345        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}