use std::sync::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use color_eyre::eyre::{Result, eyre};
use crate::models::channel_config;
use crate::models::ChannelConfig;
use crate::mirror::{Mirrors, UrlProtocol};
#[cfg(windows)]
pub fn url2legal_filename(filename: &str) -> String {
const FORBIDDEN: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
let mut result = String::with_capacity(filename.len());
for c in filename.chars() {
if FORBIDDEN.contains(&c) {
result.push('_');
} else {
result.push(c);
}
}
result
}
#[cfg(not(windows))]
pub fn url2legal_filename(filename: &str) -> String {
filename.to_string()
}
#[cfg(windows)]
fn normalize_url_path_separators(path: &str) -> PathBuf {
let sanitized = url2legal_filename(path);
PathBuf::from(sanitized.replace('/', "\\"))
}
#[cfg(not(windows))]
fn normalize_url_path_separators(path: &str) -> PathBuf {
PathBuf::from(path)
}
static REPODATA_NAME2DISTRO_DIRS: std::sync::LazyLock<Mutex<HashMap<String, Vec<String>>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn extend_repodata_name2distro_dirs(channel_config: &ChannelConfig, repos: &[crate::repo::RepoRevise]) -> Result<()> {
let mut hashmap = REPODATA_NAME2DISTRO_DIRS.lock()
.map_err(|e| color_eyre::eyre::eyre!("Failed to lock repodata_name2distro_dirs: {}", e))?;
for repo in repos {
hashmap.insert(repo.repodata_name.clone(), channel_config.distro_dirs.clone());
log::debug!("repodata_name2distro_dirs[{}] = {:?}", repo.repodata_name.clone(), channel_config.distro_dirs.clone());
}
Ok(())
}
pub(crate) fn get_distro_dirs_for_repodata_name(repodata_name: &str) -> Vec<String> {
if let Ok(hashmap) = REPODATA_NAME2DISTRO_DIRS.lock() {
if let Some(distro_dirs) = hashmap.get(repodata_name) {
return distro_dirs.clone();
}
}
channel_config().distro_dirs.clone()
}
pub fn url2site(url: &str) -> String {
if let Some(scheme_end) = url.find("://") {
let after_scheme = &url[scheme_end + 3..];
let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
return after_scheme[..host_end].to_string();
}
url.to_string()
}
impl Mirrors {
pub fn find_distro_dir(
mirror: &crate::mirror::types::Mirror,
distro: &str,
arch: &str,
repodata_name: &str,
) -> String {
let sorted_dirs = get_distro_dirs_for_repodata_name(repodata_name);
log::trace!("find_distro_dir for mirror {}: distro={}, arch={}, repodata_name={}, sorted_dirs.len()={}, mirror.distro_dirs={:?}",
mirror.url, distro, arch, repodata_name, sorted_dirs.len(), mirror.distro_dirs);
let mut found_dir = String::new();
let mut skipped_reasons = Vec::new();
for item in &sorted_dirs {
let item_lower = item.to_lowercase();
let mut skip_reason = None;
if distro == "fedora" {
if item_lower.contains("alt") {
skip_reason = Some("contains 'alt'");
} else if item_lower.contains("archive") {
skip_reason = Some("contains 'archive'");
} else if arch == "x86_64" || arch == "aarch64" {
if item_lower.contains("secondary") {
skip_reason = Some("contains 'secondary' (x86_64/aarch64)");
}
} else {
if !item_lower.contains("secondary") {
skip_reason = Some("missing 'secondary' (non-x86_64/aarch64)");
}
}
}
if distro == "ubuntu" {
if arch == "x86_64" {
if item_lower.contains("ports") {
skip_reason = Some("contains 'ports' (x86_64)");
}
} else {
if !item_lower.contains("ports") {
skip_reason = Some("missing 'ports' (non-x86_64)");
}
}
}
if let Some(reason) = skip_reason {
skipped_reasons.push(format!("{}: {}", item, reason));
continue;
}
if let Some(orig_dir) = mirror.distro_dirs.get(item)
{
found_dir = orig_dir.clone();
log::trace!(
"find_distro_dir for mirror {}: matched item '{}' -> orig_dir '{}'",
mirror.url,
item,
orig_dir
);
break;
} else {
skipped_reasons.push(format!("{}: not in mirror.distro_dirs", item));
}
}
if found_dir.is_empty() && !skipped_reasons.is_empty() {
log::trace!(
"find_distro_dir for mirror {}: no match found. Skipped: {}",
mirror.url,
skipped_reasons.join(", ")
);
}
found_dir
}
pub fn format_mirror_url(&self, mirror_url: &str, top_level: bool, distro_dir: &str) -> Result<String> {
let distro = &channel_config().distro;
let url = if top_level || distro == "debian" {
format!("{}//", mirror_url.trim_end_matches('/'))
} else {
format!("{}/{}//", mirror_url.trim_end_matches('/'), distro_dir)
};
Ok(url)
}
pub fn validate_path_security(path: &Path, context: &str) -> Result<()> {
let path_str = path.to_string_lossy();
if path_str.contains("../") || path_str.contains("..\\") {
return Err(eyre!("Invalid path: directory traversal detected in '{}' ({})", path_str, context));
}
if let Some(file_name) = path.file_name() {
if file_name.to_string_lossy().is_empty() {
return Err(eyre!("Invalid path: empty file name in '{}' ({})", path_str, context));
}
} else {
return Err(eyre!("Invalid path: no file name component in '{}' ({})", path_str, context));
}
Ok(())
}
pub fn remote_url_to_path(url: &str, output_dir: &Path, repodata_name: &str) -> Result<PathBuf> {
if let Some((_, str_b)) = url.split_once("$mirror/") {
let distro_dirs = get_distro_dirs_for_repodata_name(repodata_name);
let local_subdir = distro_dirs.last().unwrap().clone();
let normalized_path = normalize_url_path_separators(str_b);
let path = if local_subdir != "debian" {
output_dir.join(&local_subdir).join(normalized_path)
} else {
output_dir.join(normalized_path)
};
return Ok(path);
}
if let Some((_, str_b)) = url.split_once("///") {
let normalized_path = normalize_url_path_separators(str_b);
let path = output_dir.join(normalized_path);
return Ok(path);
}
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(Self::resolve_http_url_path(url, output_dir));
}
Err(eyre!("Not a supported remote URL: '{}'", url))
}
pub fn local_url_to_path(spec: &str) -> Option<PathBuf> {
if let Some(local_path) = spec
.strip_prefix("file://")
.or_else(|| spec.strip_prefix("./"))
{
return Some(normalize_url_path_separators(local_path));
}
if spec.starts_with('/') {
return Some(normalize_url_path_separators(spec));
}
let path = Path::new(spec);
if path.exists() && path.is_file() {
return Some(path.to_path_buf());
}
None
}
pub fn detect_url_proto_path(url: &str, repodata_name: &str) -> Result<(UrlProtocol, PathBuf)> {
let output_dir = crate::dirs().epkg_downloads_cache.clone();
match Self::remote_url_to_path(url, &output_dir, repodata_name) {
Ok(path) => {
Self::validate_path_security(&path, "detect_url_proto_path: remote URL")?;
Ok((UrlProtocol::Http, path))
}
Err(_) => {
if let Some(path) = Self::local_url_to_path(url) {
Self::validate_path_security(&path, "detect_url_proto_path: local path")?;
Ok((UrlProtocol::Local, path))
} else {
if let Some(first_slash) = url.find('/') {
let dns_part = &url[..first_slash];
if dns_part.contains('.') {
let https_url = format!("https://{}", url);
let path = Self::resolve_http_url_path(&https_url, &output_dir);
Self::validate_path_security(&path, "detect_url_proto_path: DNS pattern")?;
return Ok((UrlProtocol::Http, path));
}
}
Err(eyre!("Unsupport URL: '{}'", url))
}
}
}
}
pub fn url_to_cache_path(url: &str, repodata_name: &str) -> Result<PathBuf> {
log::debug!("url_to_cache_path {} {}", url, repodata_name);
let (_, path) = Self::detect_url_proto_path(url, repodata_name)?;
Ok(path)
}
pub fn resolve_http_url_path(url: &str, output_dir: &Path) -> PathBuf {
let url_stripped = match url.strip_prefix("http://").or_else(|| url.strip_prefix("https://")) {
Some(stripped) => stripped,
None => url
};
let is_site = !url_stripped.contains('/');
let is_dir = url.ends_with('/');
let parts: Vec<&str> = url_stripped.split('/').filter(|s| !s.is_empty()).collect();
let mut path = output_dir.to_path_buf();
for part in parts {
let sanitized = url2legal_filename(part);
path = path.join(&sanitized);
}
if is_site || is_dir {
path.join("index.html")
} else {
path
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_resolve_http_url_path_basic() {
let output_dir = PathBuf::from("/cache");
let url = "https://example.com/path/to/file.txt";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/example.com/path/to/file.txt"));
}
#[test]
fn test_resolve_http_url_path_http() {
let output_dir = PathBuf::from("/cache");
let url = "http://example.com/path/to/file.txt";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/example.com/path/to/file.txt"));
}
#[test]
fn test_resolve_http_url_path_prevents_collision() {
let output_dir = PathBuf::from("/cache");
let url1 = "https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/linux-64/current_repodata.json.gz";
let url2 = "https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/noarch/current_repodata.json.gz";
let result1 = Mirrors::resolve_http_url_path(url1, &output_dir);
let result2 = Mirrors::resolve_http_url_path(url2, &output_dir);
assert_ne!(result1, result2, "Different paths should produce different cache paths");
assert_eq!(result1, PathBuf::from("/cache/mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/linux-64/current_repodata.json.gz"));
assert_eq!(result2, PathBuf::from("/cache/mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/noarch/current_repodata.json.gz"));
}
#[test]
fn test_resolve_http_url_path_with_empty_segments() {
let output_dir = PathBuf::from("/cache");
let url = "https://example.com//path//to//file.txt";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/example.com/path/to/file.txt"));
}
#[test]
fn test_resolve_http_url_path_root_path() {
let output_dir = PathBuf::from("/cache");
let url = "https://example.com/file.txt";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/example.com/file.txt"));
}
#[test]
fn test_resolve_http_url_path_non_http() {
let url = "file:///path/to/file.txt";
let result = Mirrors::local_url_to_path(url);
assert_eq!(result, Some(PathBuf::from("/path/to/file.txt")));
}
#[test]
fn test_resolve_http_url_path_complex_path() {
let output_dir = PathBuf::from("/cache");
let url = "https://repo.example.com/conda/main/linux-64/repodata.json";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/repo.example.com/conda/main/linux-64/repodata.json"));
}
#[test]
fn test_resolve_http_url_path_with_port() {
let output_dir = PathBuf::from("/cache");
let url = "https://example.com:8080/path/file.txt";
let result = Mirrors::resolve_http_url_path(url, &output_dir);
assert_eq!(result, PathBuf::from("/cache/example.com:8080/path/file.txt"));
}
#[test]
fn test_remote_url_to_path_integration() {
let output_dir = PathBuf::from("/cache");
let url1 = "https://mirror.com/repo/linux-64/file.gz";
let url2 = "https://mirror.com/repo/noarch/file.gz";
let result1 = Mirrors::remote_url_to_path(url1, &output_dir, "test").unwrap();
let result2 = Mirrors::remote_url_to_path(url2, &output_dir, "test").unwrap();
assert_ne!(result1, result2, "Different paths should produce different cache paths");
}
}