use super::DistroHandler;
use crate::core::Mirror;
use anyhow::{Context, Result};
use async_trait::async_trait;
use regex::Regex;
use reqwest::Client;
use serde::Deserialize;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tracing::{debug, info, warn};
use url::Url;
pub struct PacmanHandler {
mirrorlist_file: PathBuf,
pacman_conf: PathBuf,
backup_dir: PathBuf,
}
#[derive(Debug, Deserialize)]
struct MirrorStatusResponse {
#[serde(default)]
urls: Vec<MirrorUrl>,
}
#[derive(Debug, Deserialize)]
struct MirrorUrl {
url: String,
#[serde(default)]
country: Option<String>,
#[serde(default)]
protocol: Option<String>,
#[serde(default)]
last_sync: Option<String>,
#[serde(default)]
completion_pct: Option<f64>,
#[serde(default)]
delay: Option<i64>,
#[serde(default)]
score: Option<f64>,
#[serde(default)]
active: Option<bool>,
}
#[derive(Debug, Clone)]
struct MirrorListEntry {
enabled: bool,
url: String,
line_number: usize,
}
impl PacmanHandler {
pub fn new() -> Self {
Self {
mirrorlist_file: PathBuf::from("/etc/pacman.d/mirrorlist"),
pacman_conf: PathBuf::from("/etc/pacman.conf"),
backup_dir: PathBuf::from("/var/backups/smirrors/pacman"),
}
}
fn parse_mirrorlist(&self) -> Result<Vec<Mirror>> {
let content = fs::read_to_string(&self.mirrorlist_file)
.context("Failed to read mirrorlist")?;
let entries = self.parse_mirrorlist_content(&content)?;
let mut mirrors = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
for entry in entries {
if !entry.enabled {
continue;
}
let url_str = self.expand_variables(&entry.url);
if let Ok(url) = Url::parse(&url_str) {
let url_key = url.as_str().trim_end_matches('/');
if !seen_urls.contains(url_key) {
seen_urls.insert(url_key.to_string());
let mut mirror = Mirror::new(url);
if let Some(country) = Self::extract_country_from_url(&entry.url) {
mirror.country = Some(country);
}
mirrors.push(mirror);
}
}
}
Ok(mirrors)
}
fn parse_mirrorlist_content(&self, content: &str) -> Result<Vec<MirrorListEntry>> {
let mut entries = Vec::new();
let re = Regex::new(r"(?x)
^\s*
(?P<comment>\#?) # Optional comment
\s*
Server\s*=\s*
(?P<url>\S+) # URL
\s*$
")?;
for (line_num, line) in content.lines().enumerate() {
if let Some(caps) = re.captures(line) {
let is_commented = caps.name("comment").map_or(false, |m| !m.as_str().is_empty());
let url = caps["url"].to_string();
entries.push(MirrorListEntry {
enabled: !is_commented,
url,
line_number: line_num,
});
}
}
Ok(entries)
}
fn expand_variables(&self, url: &str) -> String {
let mut expanded = url.to_string();
expanded = expanded.replace("$repo", "core");
expanded = expanded.replace("$arch", &self.get_arch());
expanded
}
fn get_arch(&self) -> String {
let output = std::process::Command::new("uname")
.arg("-m")
.output();
if let Ok(output) = output {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
"x86_64".to_string()
}
}
fn extract_country_from_url(url: &str) -> Option<String> {
if let Ok(parsed_url) = Url::parse(url) {
if let Some(host) = parsed_url.host_str() {
let parts: Vec<&str> = host.split('.').collect();
for part in parts {
if part.len() == 2 && part.chars().all(|c| c.is_ascii_alphabetic()) {
return Some(part.to_uppercase());
}
}
}
}
None
}
async fn fetch_arch_mirrors(&self) -> Result<Vec<Mirror>> {
let client = Client::new();
let url = "https://archlinux.org/mirrors/status/json/";
debug!("Fetching Arch mirrors from: {}", url);
let response = client
.get(url)
.send()
.await
.context("Failed to fetch Arch mirror status")?;
let status: MirrorStatusResponse = response
.json()
.await
.context("Failed to parse Arch mirror status")?;
self.parse_mirror_status(status)
}
fn parse_mirror_status(&self, status: MirrorStatusResponse) -> Result<Vec<Mirror>> {
let mut mirrors = Vec::new();
for mirror_url in status.urls {
if !mirror_url.active.unwrap_or(false) {
continue;
}
if let Some(completion) = mirror_url.completion_pct {
if completion < 99.0 {
continue;
}
}
let url_str = &mirror_url.url;
if let Ok(url) = Url::parse(url_str) {
let mut mirror = Mirror::new(url);
if let Some(country) = mirror_url.country {
mirror.country = Some(country);
}
if let Some(score) = mirror_url.score {
let normalized_score = (1.0 / (1.0 + score / 10.0)).min(1.0);
mirror.score = Some(normalized_score);
}
mirrors.push(mirror);
}
}
if mirrors.is_empty() {
mirrors = self.get_fallback_mirrors();
}
Ok(mirrors)
}
fn get_fallback_mirrors(&self) -> Vec<Mirror> {
let mirror_urls = vec![
"https://geo.mirror.pkgbuild.com/$repo/os/$arch",
"https://mirror.rackspace.com/archlinux/$repo/os/$arch",
"https://mirrors.kernel.org/archlinux/$repo/os/$arch",
"https://mirror.math.princeton.edu/pub/archlinux/$repo/os/$arch",
"https://mirrors.mit.edu/archlinux/$repo/os/$arch",
];
mirror_urls
.into_iter()
.filter_map(|url_str| Url::parse(url_str).ok().map(Mirror::new))
.collect()
}
fn update_mirrorlist(&self, mirrors: &[Mirror]) -> Result<()> {
if mirrors.is_empty() {
anyhow::bail!("No mirrors provided for update");
}
let mut new_content = String::new();
new_content.push_str("##\n");
new_content.push_str("## Arch Linux repository mirrorlist\n");
new_content.push_str("## Generated by SMirrors\n");
new_content.push_str(&format!(
"## Updated: {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
new_content.push_str("##\n\n");
for (i, mirror) in mirrors.iter().enumerate() {
let url = mirror.url.as_str();
let url = url.replace("/core/os/", "/$repo/os/");
let url = url.replace(&format!("/{}/", self.get_arch()), "/$arch/");
if i < 5 {
new_content.push_str(&format!("Server = {}\n", url));
} else {
new_content.push_str(&format!("#Server = {}\n", url));
}
}
let temp_file = self.mirrorlist_file.with_extension("tmp");
let mut file = fs::File::create(&temp_file)
.context("Failed to create temporary mirrorlist")?;
file.write_all(new_content.as_bytes())
.context("Failed to write temporary mirrorlist")?;
file.sync_all()
.context("Failed to sync temporary mirrorlist")?;
drop(file);
fs::rename(&temp_file, &self.mirrorlist_file)
.context("Failed to replace mirrorlist")?;
info!("Updated mirrorlist with {} mirrors", mirrors.len());
Ok(())
}
fn get_latest_backup(&self) -> Result<PathBuf> {
if !self.backup_dir.exists() {
anyhow::bail!("No backups found");
}
let mut backups: Vec<_> = fs::read_dir(&self.backup_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("mirrorlist."))
.unwrap_or(false)
})
.collect();
backups.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
backups
.last()
.map(|e| e.path())
.ok_or_else(|| anyhow::anyhow!("No backup files found"))
}
}
impl Default for PacmanHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DistroHandler for PacmanHandler {
fn name(&self) -> &str {
"Arch Linux Pacman"
}
fn detect(&self) -> bool {
self.pacman_conf.exists()
&& std::process::Command::new("pacman")
.arg("--version")
.output()
.is_ok()
}
async fn get_available_mirrors(&self) -> Result<Vec<Mirror>> {
debug!("Fetching available mirrors for Pacman");
self.fetch_arch_mirrors().await
}
fn get_current_mirrors(&self) -> Result<Vec<Mirror>> {
debug!("Reading current mirrors from mirrorlist");
self.parse_mirrorlist()
}
fn update_mirrors(&self, mirrors: &[Mirror]) -> Result<()> {
info!("Updating Pacman mirrors");
if !nix::unistd::geteuid().is_root() {
anyhow::bail!("Root privileges required to update Pacman mirrorlist");
}
self.backup()?;
self.update_mirrorlist(mirrors)?;
if !self.validate()? {
warn!("Validation failed, restoring backup");
self.restore_backup()?;
anyhow::bail!("Mirror update validation failed");
}
info!("Refreshing package databases");
let output = std::process::Command::new("pacman")
.arg("-Sy")
.arg("--noconfirm")
.output();
if let Err(e) = output {
warn!("Failed to refresh package databases: {}", e);
}
info!("Successfully updated Pacman mirrors");
Ok(())
}
fn backup(&self) -> Result<()> {
debug!("Creating backup of mirrorlist");
fs::create_dir_all(&self.backup_dir)
.context("Failed to create backup directory")?;
let timestamp = chrono::Utc::now().timestamp();
let backup_file = self
.backup_dir
.join(format!("mirrorlist.{}", timestamp));
fs::copy(&self.mirrorlist_file, &backup_file)
.context("Failed to copy mirrorlist to backup")?;
info!("Created backup: {:?}", backup_file);
let mut backups: Vec<_> = fs::read_dir(&self.backup_dir)?
.filter_map(|e| e.ok())
.collect();
if backups.len() > 10 {
backups.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
for entry in backups.iter().take(backups.len() - 10) {
let _ = fs::remove_file(entry.path());
}
}
Ok(())
}
fn restore_backup(&self) -> Result<()> {
info!("Restoring mirrorlist from backup");
let backup_file = self.get_latest_backup()?;
fs::copy(&backup_file, &self.mirrorlist_file)
.context("Failed to restore mirrorlist from backup")?;
info!("Restored from backup: {:?}", backup_file);
Ok(())
}
fn validate(&self) -> Result<bool> {
debug!("Validating Pacman configuration");
match self.parse_mirrorlist() {
Ok(mirrors) if !mirrors.is_empty() => {
debug!("Validation successful, found {} mirrors", mirrors.len());
Ok(true)
}
Ok(_) => {
warn!("Validation found no mirrors");
Ok(false)
}
Err(e) => {
warn!("Validation failed to parse mirrorlist: {}", e);
Ok(false)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_mirrorlist_content() {
let handler = PacmanHandler::new();
let content = r#"
##
## Arch Linux repository mirrorlist
##
## United States
Server = https://mirror.us.example.com/archlinux/$repo/os/$arch
#Server = https://mirror2.us.example.com/archlinux/$repo/os/$arch
## Germany
Server = https://mirror.de.example.com/archlinux/$repo/os/$arch
"#;
let entries = handler.parse_mirrorlist_content(content).unwrap();
assert_eq!(entries.len(), 3);
assert!(entries[0].enabled);
assert_eq!(
entries[0].url,
"https://mirror.us.example.com/archlinux/$repo/os/$arch"
);
assert!(!entries[1].enabled);
assert!(entries[2].enabled);
}
#[test]
fn test_expand_variables() {
let handler = PacmanHandler::new();
let url = "https://mirror.example.com/$repo/os/$arch";
let expanded = handler.expand_variables(url);
assert!(!expanded.contains("$repo"));
assert!(!expanded.contains("$arch"));
}
#[test]
fn test_extract_country_from_url() {
assert_eq!(
PacmanHandler::extract_country_from_url(
"https://us.mirror.example.com/archlinux/$repo/os/$arch"
),
Some("US".to_string())
);
assert_eq!(
PacmanHandler::extract_country_from_url(
"https://mirror.de.example.com/archlinux/$repo/os/$arch"
),
Some("DE".to_string())
);
}
}