aria2-core 0.2.2

High-performance download engine core: multi-protocol segmented downloads, rate limiting, config management, session persistence, and BitTorrent seeding
Documentation
use crate::error::{Aria2Error, Result};

const SUPPORTED_SCHEMES: &[&str] = &["http", "https", "ftp", "sftp", "file"];
const DANGEROUS_SCHEMES: &[&str] = &["javascript", "data", "vbscript"];

const URI_MAX_FILENAME_LEN: usize = 255;

#[derive(Debug, Clone)]
pub struct ValidatedUri {
    pub original: String,
    pub scheme: String,
    pub is_magnet: bool,
    pub is_torrent: bool,
}

pub fn validate(uri: &str) -> Result<ValidatedUri> {
    let trimmed = uri.trim();
    if trimmed.is_empty() {
        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
            "URI不能为空".into(),
        )));
    }

    if trimmed.starts_with("magnet:?") || trimmed.starts_with("magnet?") {
        return Ok(ValidatedUri {
            original: trimmed.to_string(),
            scheme: "magnet".to_string(),
            is_magnet: true,
            is_torrent: false,
        });
    }

    let (scheme, rest) = match trimmed.split_once("://") {
        Some(pair) => pair,
        None => {
            return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
                "URI缺少协议前缀".into(),
            )));
        }
    };

    let lower_scheme = scheme.to_lowercase();
    for dangerous in DANGEROUS_SCHEMES {
        if lower_scheme == *dangerous {
            return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
                format!("不安全的协议: {}", scheme),
            )));
        }
    }
    if !SUPPORTED_SCHEMES.contains(&lower_scheme.as_str()) && lower_scheme != "magnet" {
        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
            format!("不支持的协议: {}", scheme),
        )));
    }
    if rest.is_empty() {
        return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
            "URI缺少路径".into(),
        )));
    }

    Ok(ValidatedUri {
        original: trimmed.to_string(),
        scheme: lower_scheme.clone(),
        is_magnet: false,
        is_torrent: lower_scheme == "file" && rest.ends_with(".torrent"),
    })
}

pub fn is_magnet_link(uri: &str) -> bool {
    let t = uri.trim().to_lowercase();
    t.starts_with("magnet:?") || t.starts_with("magnet?")
}

pub fn is_torrent_file(path: &str) -> bool {
    path.trim().ends_with(".torrent")
}

pub fn sanitize_filename_from_uri(uri: &str) -> String {
    let uri = uri.trim();
    let path_part = uri
        .rsplit('/')
        .next()
        .unwrap_or("")
        .rsplit('\\')
        .next()
        .unwrap_or("");

    let decoded = urlencoding_decode(path_part);
    let cleaned = remove_traversal(&decoded);
    truncate_filename(&cleaned)
}

fn urlencoding_decode(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '%' {
            let hex: String = chars.by_ref().take(2).collect();
            if hex.len() == 2
                && let Ok(byte) = u8::from_str_radix(&hex, 16)
            {
                result.push(byte as char);
                continue;
            }
            result.push(c);
        } else {
            result.push(c);
        }
    }
    result
}

fn remove_traversal(s: &str) -> String {
    s.replace("../", "").replace("..\\", "").replace("./", "")
}

fn truncate_filename(s: &str) -> String {
    if s.len() > URI_MAX_FILENAME_LEN {
        s[..URI_MAX_FILENAME_LEN].to_string()
    } else {
        s.to_string()
    }
}