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::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use url::Url;
pub struct DnfHandler {
repos_dir: PathBuf,
backup_dir: PathBuf,
distro_variant: DnfVariant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DnfVariant {
Fedora,
RHEL,
}
#[derive(Debug, Clone)]
struct RepoSection {
name: String,
enabled: bool,
baseurl: Option<String>,
mirrorlist: Option<String>,
metalink: Option<String>,
gpgcheck: bool,
gpgkey: Option<String>,
other_fields: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct MirrorManagerResponse {
#[serde(default)]
mirrors: Vec<MirrorEntry>,
}
#[derive(Debug, Deserialize)]
struct MirrorEntry {
url: String,
#[serde(default)]
country: Option<String>,
#[serde(default)]
continent: Option<String>,
}
impl DnfHandler {
pub fn new() -> Self {
let variant = Self::detect_variant();
Self {
repos_dir: PathBuf::from("/etc/yum.repos.d"),
backup_dir: PathBuf::from("/var/backups/smirrors/dnf"),
distro_variant: variant,
}
}
fn detect_variant() -> DnfVariant {
if Path::new("/etc/fedora-release").exists() {
return DnfVariant::Fedora;
}
if let Ok(content) = fs::read_to_string("/etc/os-release") {
if content.contains("Fedora") || content.contains("ID=fedora") {
return DnfVariant::Fedora;
}
}
DnfVariant::RHEL
}
fn parse_all_repos(&self) -> Result<Vec<Mirror>> {
let mut mirrors = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
if !self.repos_dir.exists() {
return Ok(mirrors);
}
for entry in fs::read_dir(&self.repos_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("repo") {
if let Ok(content) = fs::read_to_string(&path) {
let sections = self.parse_repo_file(&content)?;
for section in sections {
if !section.enabled {
continue;
}
let url_str = section
.baseurl
.or(section.mirrorlist)
.or(section.metalink);
if let Some(url_str) = url_str {
let url_str = self.expand_variables(&url_str);
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);
mirror
.metadata
.insert("repo_name".to_string(), section.name.clone());
mirrors.push(mirror);
}
}
}
}
}
}
}
Ok(mirrors)
}
fn parse_repo_file(&self, content: &str) -> Result<Vec<RepoSection>> {
let mut sections = Vec::new();
let mut current_section: Option<RepoSection> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
if let Some(section) = current_section.take() {
sections.push(section);
}
let name = line[1..line.len() - 1].to_string();
current_section = Some(RepoSection {
name,
enabled: false,
baseurl: None,
mirrorlist: None,
metalink: None,
gpgcheck: true,
gpgkey: None,
other_fields: HashMap::new(),
});
continue;
}
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim().to_lowercase();
let value = line[eq_pos + 1..].trim().to_string();
if let Some(ref mut section) = current_section {
match key.as_str() {
"enabled" => {
section.enabled = value == "1" || value.to_lowercase() == "true";
}
"baseurl" => {
section.baseurl = Some(value);
}
"mirrorlist" => {
section.mirrorlist = Some(value);
}
"metalink" => {
section.metalink = Some(value);
}
"gpgcheck" => {
section.gpgcheck = value == "1" || value.to_lowercase() == "true";
}
"gpgkey" => {
section.gpgkey = Some(value);
}
_ => {
section.other_fields.insert(key, value);
}
}
}
}
}
if let Some(section) = current_section {
sections.push(section);
}
Ok(sections)
}
fn expand_variables(&self, url: &str) -> String {
let mut expanded = url.to_string();
if let Ok(releasever) = self.get_releasever() {
expanded = expanded.replace("$releasever", &releasever);
}
if let Ok(basearch) = self.get_basearch() {
expanded = expanded.replace("$basearch", &basearch);
}
expanded
}
fn get_releasever(&self) -> Result<String> {
match self.distro_variant {
DnfVariant::Fedora => {
if let Ok(content) = fs::read_to_string("/etc/fedora-release") {
let re = Regex::new(r"release\s+(\d+)")?;
if let Some(caps) = re.captures(&content) {
return Ok(caps[1].to_string());
}
}
}
DnfVariant::RHEL => {
if let Ok(content) = fs::read_to_string("/etc/redhat-release") {
let re = Regex::new(r"release\s+(\d+)")?;
if let Some(caps) = re.captures(&content) {
return Ok(caps[1].to_string());
}
}
}
}
if let Ok(content) = fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if line.starts_with("VERSION_ID=") {
let value = line.trim_start_matches("VERSION_ID=").trim_matches('"');
return Ok(value.to_string());
}
}
}
Ok("38".to_string()) }
fn get_basearch(&self) -> Result<String> {
let output = std::process::Command::new("uname")
.arg("-m")
.output()
.context("Failed to get architecture")?;
let arch = String::from_utf8(output.stdout)?.trim().to_string();
let basearch = match arch.as_str() {
"x86_64" => "x86_64",
"i686" | "i386" => "i386",
"aarch64" => "aarch64",
"armv7l" => "armhfp",
"ppc64le" => "ppc64le",
"s390x" => "s390x",
_ => &arch,
};
Ok(basearch.to_string())
}
async fn fetch_fedora_mirrors(&self) -> Result<Vec<Mirror>> {
let client = Client::new();
let releasever = self.get_releasever()?;
let basearch = self.get_basearch()?;
let url = format!(
"https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-{}&arch={}",
releasever, basearch
);
debug!("Fetching Fedora mirrors from: {}", url);
let response = client
.get(&url)
.send()
.await
.context("Failed to fetch Fedora mirror list")?;
let text = response
.text()
.await
.context("Failed to read Fedora mirror list")?;
self.parse_mirrorlist_response(&text)
}
fn parse_mirrorlist_response(&self, content: &str) -> Result<Vec<Mirror>> {
let mut mirrors = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Ok(url) = Url::parse(line) {
mirrors.push(Mirror::new(url));
}
}
if mirrors.is_empty() {
mirrors = self.get_fallback_fedora_mirrors();
}
Ok(mirrors)
}
fn get_fallback_fedora_mirrors(&self) -> Vec<Mirror> {
let mirror_urls = vec![
"https://download.fedoraproject.org/pub/fedora/linux/",
"https://mirrors.kernel.org/fedora/",
"http://mirror.math.princeton.edu/pub/fedora/linux/",
"http://mirrors.mit.edu/fedora/linux/",
];
mirror_urls
.into_iter()
.filter_map(|url_str| Url::parse(url_str).ok().map(Mirror::new))
.collect()
}
fn update_repo_file(&self, path: &Path, new_mirror: &Mirror) -> Result<()> {
let content = fs::read_to_string(path)?;
let sections = self.parse_repo_file(&content)?;
let mut new_content = String::new();
let mut current_section: Option<&RepoSection> = None;
let mut section_index = 0;
let new_url = new_mirror.url.as_str().trim_end_matches('/');
for line in content.lines() {
let line_trimmed = line.trim();
if line_trimmed.starts_with('[') && line_trimmed.ends_with(']') {
current_section = sections.get(section_index);
section_index += 1;
}
let mut modified_line = line.to_string();
if let Some(section) = current_section {
if section.enabled && line_trimmed.to_lowercase().starts_with("baseurl=") {
modified_line = format!("baseurl={}", new_url);
}
}
new_content.push_str(&modified_line);
new_content.push('\n');
}
let temp_file = path.with_extension("tmp");
let mut file = fs::File::create(&temp_file)?;
file.write_all(new_content.as_bytes())?;
file.sync_all()?;
drop(file);
fs::rename(&temp_file, path)?;
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())
.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 DnfHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DistroHandler for DnfHandler {
fn name(&self) -> &str {
match self.distro_variant {
DnfVariant::Fedora => "Fedora DNF",
DnfVariant::RHEL => "RHEL DNF/YUM",
}
}
fn detect(&self) -> bool {
self.repos_dir.exists()
&& (std::process::Command::new("dnf")
.arg("--version")
.output()
.is_ok()
|| std::process::Command::new("yum")
.arg("--version")
.output()
.is_ok())
}
async fn get_available_mirrors(&self) -> Result<Vec<Mirror>> {
debug!("Fetching available mirrors for DNF");
match self.distro_variant {
DnfVariant::Fedora => self.fetch_fedora_mirrors().await,
DnfVariant::RHEL => {
Ok(self.get_fallback_fedora_mirrors())
}
}
}
fn get_current_mirrors(&self) -> Result<Vec<Mirror>> {
debug!("Reading current mirrors from .repo files");
self.parse_all_repos()
}
fn update_mirrors(&self, mirrors: &[Mirror]) -> Result<()> {
info!("Updating DNF mirrors");
if !nix::unistd::geteuid().is_root() {
anyhow::bail!("Root privileges required to update DNF repositories");
}
if mirrors.is_empty() {
anyhow::bail!("No mirrors provided for update");
}
self.backup()?;
let best_mirror = &mirrors[0];
for entry in fs::read_dir(&self.repos_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("repo") {
if let Err(e) = self.update_repo_file(&path, best_mirror) {
warn!("Failed to update {:?}: {}", path, e);
}
}
}
if !self.validate()? {
warn!("Validation failed, restoring backup");
self.restore_backup()?;
anyhow::bail!("Mirror update validation failed");
}
info!("Successfully updated DNF mirrors");
Ok(())
}
fn backup(&self) -> Result<()> {
debug!("Creating backup of .repo files");
fs::create_dir_all(&self.backup_dir)?;
let timestamp = chrono::Utc::now().timestamp();
let backup_subdir = self.backup_dir.join(format!("backup-{}", timestamp));
fs::create_dir_all(&backup_subdir)?;
for entry in fs::read_dir(&self.repos_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("repo") {
if let Some(filename) = path.file_name() {
let backup_path = backup_subdir.join(filename);
fs::copy(&path, backup_path)?;
}
}
}
info!("Created backup: {:?}", backup_subdir);
let mut backups: Vec<_> = fs::read_dir(&self.backup_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.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_dir_all(entry.path());
}
}
Ok(())
}
fn restore_backup(&self) -> Result<()> {
info!("Restoring .repo files from backup");
let backup_dir = self.get_latest_backup()?;
for entry in fs::read_dir(&backup_dir)? {
let entry = entry?;
let backup_path = entry.path();
if backup_path.extension().and_then(|s| s.to_str()) == Some("repo") {
if let Some(filename) = backup_path.file_name() {
let target_path = self.repos_dir.join(filename);
fs::copy(&backup_path, target_path)?;
}
}
}
info!("Restored from backup: {:?}", backup_dir);
Ok(())
}
fn validate(&self) -> Result<bool> {
debug!("Validating DNF configuration");
let cmd = if std::process::Command::new("dnf")
.arg("--version")
.output()
.is_ok()
{
"dnf"
} else {
"yum"
};
let output = std::process::Command::new(cmd)
.arg("repolist")
.arg("--quiet")
.output()
.context("Failed to run repolist")?;
if !output.status.success() {
warn!(
"{} repolist failed: {}",
cmd,
String::from_utf8_lossy(&output.stderr)
);
return Ok(false);
}
match self.parse_all_repos() {
Ok(mirrors) if !mirrors.is_empty() => {
debug!("Validation successful, found {} repos", mirrors.len());
Ok(true)
}
Ok(_) => {
warn!("Validation found no repos");
Ok(false)
}
Err(e) => {
warn!("Validation failed to parse repos: {}", e);
Ok(false)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_repo_file() {
let handler = DnfHandler::new();
let content = r#"
[fedora]
name=Fedora $releasever - $basearch
baseurl=http://download.fedoraproject.org/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
[fedora-updates]
name=Fedora $releasever - $basearch - Updates
mirrorlist=https://mirrors.fedoraproject.org/mirrorlist?repo=updates-released-f$releasever&arch=$basearch
enabled=1
gpgcheck=1
"#;
let sections = handler.parse_repo_file(content).unwrap();
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].name, "fedora");
assert!(sections[0].enabled);
assert!(sections[0].baseurl.is_some());
assert_eq!(sections[1].name, "fedora-updates");
assert!(sections[1].enabled);
assert!(sections[1].mirrorlist.is_some());
}
#[test]
fn test_expand_variables() {
let handler = DnfHandler::new();
let url = "http://example.com/$releasever/$basearch/";
let expanded = handler.expand_variables(url);
assert!(!expanded.contains("$releasever"));
assert!(!expanded.contains("$basearch"));
}
#[test]
fn test_parse_mirrorlist_response() {
let handler = DnfHandler::new();
let content = r#"
# Fedora Mirrors
http://mirror1.example.com/fedora/
http://mirror2.example.com/fedora/
https://mirror3.example.com/fedora/
"#;
let mirrors = handler.parse_mirrorlist_response(content).unwrap();
assert_eq!(mirrors.len(), 3);
}
}