Skip to main content

aria2_core/validation/
uri.rs

1use crate::error::{Aria2Error, Result};
2
3const SUPPORTED_SCHEMES: &[&str] = &["http", "https", "ftp", "sftp", "file"];
4const DANGEROUS_SCHEMES: &[&str] = &["javascript", "data", "vbscript"];
5
6const URI_MAX_FILENAME_LEN: usize = 255;
7
8#[derive(Debug, Clone)]
9pub struct ValidatedUri {
10    pub original: String,
11    pub scheme: String,
12    pub is_magnet: bool,
13    pub is_torrent: bool,
14}
15
16pub fn validate(uri: &str) -> Result<ValidatedUri> {
17    let trimmed = uri.trim();
18    if trimmed.is_empty() {
19        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
20            "URI不能为空".into(),
21        )));
22    }
23
24    if trimmed.starts_with("magnet:?") || trimmed.starts_with("magnet?") {
25        return Ok(ValidatedUri {
26            original: trimmed.to_string(),
27            scheme: "magnet".to_string(),
28            is_magnet: true,
29            is_torrent: false,
30        });
31    }
32
33    let (scheme, rest) = match trimmed.split_once("://") {
34        Some(pair) => pair,
35        None => {
36            return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
37                "URI缺少协议前缀".into(),
38            )));
39        }
40    };
41
42    let lower_scheme = scheme.to_lowercase();
43    for dangerous in DANGEROUS_SCHEMES {
44        if lower_scheme == *dangerous {
45            return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
46                format!("不安全的协议: {}", scheme),
47            )));
48        }
49    }
50    if !SUPPORTED_SCHEMES.contains(&lower_scheme.as_str()) && lower_scheme != "magnet" {
51        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
52            format!("不支持的协议: {}", scheme),
53        )));
54    }
55    if rest.is_empty() {
56        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
57            "URI缺少路径".into(),
58        )));
59    }
60
61    Ok(ValidatedUri {
62        original: trimmed.to_string(),
63        scheme: lower_scheme.clone(),
64        is_magnet: false,
65        is_torrent: lower_scheme == "file" && rest.ends_with(".torrent"),
66    })
67}
68
69pub fn is_magnet_link(uri: &str) -> bool {
70    let t = uri.trim().to_lowercase();
71    t.starts_with("magnet:?") || t.starts_with("magnet?")
72}
73
74pub fn is_torrent_file(path: &str) -> bool {
75    path.trim().ends_with(".torrent")
76}
77
78pub fn sanitize_filename_from_uri(uri: &str) -> String {
79    let uri = uri.trim();
80    let path_part = uri
81        .rsplit('/')
82        .next()
83        .unwrap_or("")
84        .rsplit('\\')
85        .next()
86        .unwrap_or("");
87
88    let decoded = urlencoding_decode(path_part);
89    let cleaned = remove_traversal(&decoded);
90    truncate_filename(&cleaned)
91}
92
93fn urlencoding_decode(s: &str) -> String {
94    let mut result = String::with_capacity(s.len());
95    let mut chars = s.chars().peekable();
96    while let Some(c) = chars.next() {
97        if c == '%' {
98            let hex: String = chars.by_ref().take(2).collect();
99            if hex.len() == 2
100                && let Ok(byte) = u8::from_str_radix(&hex, 16)
101            {
102                result.push(byte as char);
103                continue;
104            }
105            result.push(c);
106        } else {
107            result.push(c);
108        }
109    }
110    result
111}
112
113fn remove_traversal(s: &str) -> String {
114    s.replace("../", "").replace("..\\", "").replace("./", "")
115}
116
117fn truncate_filename(s: &str) -> String {
118    if s.len() > URI_MAX_FILENAME_LEN {
119        s[..URI_MAX_FILENAME_LEN].to_string()
120    } else {
121        s.to_string()
122    }
123}