aria2_core/validation/
protocol_detector.rs1use 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 return Err(Aria2Error::Fatal(FatalError::Config(format!(
160 "File exists but is not a .torrent or .metalink: {}",
161 trimmed
162 ))));
163 }
164
165 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 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 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}