Skip to main content

aria2_core/validation/
protocol_detector.rs

1use crate::error::{Aria2Error, FatalError, Result};
2
3#[derive(Debug, Clone, PartialEq)]
4pub enum InputType {
5    HttpUrl,
6    FtpUrl,
7    SftpUrl,
8    TorrentFile,
9    MetalinkFile,
10    MagnetLink,
11}
12
13impl std::fmt::Display for InputType {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            Self::HttpUrl => write!(f, "http/https"),
17            Self::FtpUrl => write!(f, "ftp"),
18            Self::SftpUrl => write!(f, "sftp"),
19            Self::TorrentFile => write!(f, "torrent"),
20            Self::MetalinkFile => write!(f, "metalink"),
21            Self::MagnetLink => write!(f, "magnet"),
22        }
23    }
24}
25
26pub struct DetectedInput {
27    pub input_type: InputType,
28    pub raw: String,
29    pub file_data: Option<Vec<u8>>,
30}
31
32fn looks_like_torrent(data: &[u8]) -> bool {
33    data.len() >= 3 && data[0] == b'd' && data[1] == b'8' && data[2] == b':'
34}
35
36fn looks_like_metalink(data: &[u8]) -> bool {
37    let preview = &data[..data.len().min(200)];
38    let start = String::from_utf8_lossy(preview);
39    let lower = start.to_lowercase();
40    lower.contains("<metalink") || lower.contains("xmlns=\"urn:ietf:params:xml:ns:metalink")
41}
42
43pub fn detect(input: &str) -> Result<DetectedInput> {
44    let trimmed = input.trim();
45    if trimmed.is_empty() {
46        return Err(Aria2Error::Fatal(FatalError::Config(
47            "Input is empty".into(),
48        )));
49    }
50
51    let lower = trimmed.to_lowercase();
52
53    if lower.starts_with("magnet:?") || lower.starts_with("magnet?") {
54        return Ok(DetectedInput {
55            input_type: InputType::MagnetLink,
56            raw: trimmed.to_string(),
57            file_data: None,
58        });
59    }
60
61    if let Some((scheme, _)) = trimmed.split_once("://") {
62        match scheme.to_lowercase().as_str() {
63            "http" | "https" => {
64                return Ok(DetectedInput {
65                    input_type: InputType::HttpUrl,
66                    raw: trimmed.to_string(),
67                    file_data: None,
68                });
69            }
70            "ftp" => {
71                return Ok(DetectedInput {
72                    input_type: InputType::FtpUrl,
73                    raw: trimmed.to_string(),
74                    file_data: None,
75                });
76            }
77            "sftp" => {
78                return Ok(DetectedInput {
79                    input_type: InputType::SftpUrl,
80                    raw: trimmed.to_string(),
81                    file_data: None,
82                });
83            }
84            "file" => {}
85            _ => {}
86        }
87    }
88
89    let path = std::path::Path::new(trimmed);
90    let filename = path
91        .file_name()
92        .and_then(|n| n.to_str())
93        .unwrap_or(trimmed)
94        .to_lowercase();
95
96    if filename.ends_with(".torrent") {
97        let data = std::fs::read(path).map_err(|e| {
98            Aria2Error::Fatal(FatalError::Config(format!(
99                "Cannot read torrent file: {}",
100                e
101            )))
102        })?;
103        if !looks_like_torrent(&data) {
104            return Err(Aria2Error::Fatal(FatalError::Config(
105                "File does not look like a valid .torrent (missing BEncode header)".into(),
106            )));
107        }
108        return Ok(DetectedInput {
109            input_type: InputType::TorrentFile,
110            raw: trimmed.to_string(),
111            file_data: Some(data),
112        });
113    }
114
115    if filename.ends_with(".metalink")
116        || filename.ends_with(".meta4")
117        || filename.ends_with(".meta4")
118    {
119        let data = std::fs::read(path).map_err(|e| {
120            Aria2Error::Fatal(FatalError::Config(format!(
121                "Cannot read metalink file: {}",
122                e
123            )))
124        })?;
125        if !looks_like_metalink(&data) {
126            return Err(Aria2Error::Fatal(FatalError::Config(
127                "File does not look like a valid metalink XML".into(),
128            )));
129        }
130        return Ok(DetectedInput {
131            input_type: InputType::MetalinkFile,
132            raw: trimmed.to_string(),
133            file_data: Some(data),
134        });
135    }
136
137    if path.exists() {
138        let data = std::fs::read(path).map_err(|e| {
139            Aria2Error::Fatal(FatalError::Config(format!("Cannot read file: {}", e)))
140        })?;
141        if looks_like_torrent(&data) {
142            return Ok(DetectedInput {
143                input_type: InputType::TorrentFile,
144                raw: trimmed.to_string(),
145                file_data: Some(data),
146            });
147        }
148        if looks_like_metalink(&data) {
149            return Ok(DetectedInput {
150                input_type: InputType::MetalinkFile,
151                raw: trimmed.to_string(),
152                file_data: Some(data),
153            });
154        }
155        // A local file that exists but is neither a torrent nor a metalink must
156        // not be reinterpreted as an HTTP URL. Returning an error here prevents
157        // the app from downloading its own executable (or any other local file)
158        // when a path accidentally leaks into detect().
159        return Err(Aria2Error::Fatal(FatalError::Config(format!(
160            "File exists but is not a .torrent or .metalink: {}",
161            trimmed
162        ))));
163    }
164
165    // Original aria2c never guesses scheme-less inputs as URLs.
166    // Reject ambiguous inputs and require users to provide full URIs.
167    Err(Aria2Error::Fatal(FatalError::Config(format!(
168        "Cannot detect input type for: {}. Please provide:\n  - A full URL with scheme (http://, https://, ftp://, sftp://)\n  - A magnet link (magnet:?xt=...)\n  - A .torrent or .metalink file path\n  - Or use --input-file to load URIs from a file",
169        trimmed
170    ))))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_detect_http_url() {
179        let d = detect("http://example.com/file.zip").unwrap();
180        assert_eq!(d.input_type, InputType::HttpUrl);
181        assert_eq!(d.raw, "http://example.com/file.zip");
182        assert!(d.file_data.is_none());
183    }
184
185    #[test]
186    fn test_detect_https_url() {
187        let d = detect("https://example.com/file.iso").unwrap();
188        assert_eq!(d.input_type, InputType::HttpUrl);
189    }
190
191    #[test]
192    fn test_detect_ftp_url() {
193        let d = detect("ftp://server/path/file.bin").unwrap();
194        assert_eq!(d.input_type, InputType::FtpUrl);
195    }
196
197    #[test]
198    fn test_detect_sftp_url() {
199        let d = detect("sftp://user@host/path").unwrap();
200        assert_eq!(d.input_type, InputType::SftpUrl);
201    }
202
203    #[test]
204    fn test_detect_magnet_link() {
205        let d = detect("magnet:?xt=urn:btih:abc123&dn=test").unwrap();
206        assert_eq!(d.input_type, InputType::MagnetLink);
207    }
208
209    #[test]
210    fn test_detect_empty_input() {
211        assert!(detect("").is_err());
212        assert!(detect("   ").is_err());
213    }
214
215    #[test]
216    fn test_detect_existing_local_non_container_file_is_rejected() {
217        // Regression guard: an existing local file that is neither a torrent nor
218        // a metalink must NOT be turned into an http:// URL. This previously
219        // caused the app to download its own executable on startup (the path
220        // contained a separator and matched the bare-hostname fallback).
221        let dir = tempfile::tempdir().expect("create temp dir");
222        let file_path = dir.path().join("aria2c.exe");
223        std::fs::write(&file_path, b"this is a plain binary, not a torrent").expect("write file");
224
225        let detected = detect(file_path.to_str().unwrap());
226        assert!(
227            detected.is_err(),
228            "Existing local non-torrent/non-metalink file must be rejected, but detect() succeeded"
229        );
230    }
231
232    #[test]
233    fn test_detect_bare_hostname_is_rejected() {
234        // Bare hostname/path without scheme must be rejected.
235        // This aligns with original aria2c behavior which never guesses scheme-less inputs.
236        let result = detect("example.com/path/file.zip");
237        assert!(
238            result.is_err(),
239            "Bare hostname without scheme must be rejected, but detect() succeeded"
240        );
241        let err_msg = match result {
242            Err(e) => e.to_string(),
243            Ok(_) => unreachable!("Already asserted is_err()"),
244        };
245        assert!(
246            err_msg.contains("Cannot detect input type"),
247            "Error message should mention detection failure"
248        );
249        assert!(
250            err_msg.contains("full URL with scheme"),
251            "Error message should suggest using a full URL with scheme"
252        );
253    }
254
255    #[test]
256    fn test_input_type_display() {
257        assert_eq!(InputType::HttpUrl.to_string(), "http/https");
258        assert_eq!(InputType::FtpUrl.to_string(), "ftp");
259        assert_eq!(InputType::TorrentFile.to_string(), "torrent");
260        assert_eq!(InputType::MetalinkFile.to_string(), "metalink");
261        assert_eq!(InputType::MagnetLink.to_string(), "magnet");
262    }
263}