use super::DistroHandler;
use crate::core::Mirror;
use anyhow::{Context, Result};
use async_trait::async_trait;
use regex::Regex;
use reqwest::Client;
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 ZypperHandler {
repos_dir: PathBuf,
backup_dir: PathBuf,
distro_variant: ZypperVariant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ZypperVariant {
Leap,
Tumbleweed,
}
#[derive(Debug, Clone)]
struct RepoSection {
name: String,
enabled: bool,
autorefresh: bool,
baseurl: Option<String>,
mirrorlist: Option<String>,
repo_type: String,
gpgcheck: bool,
gpgkey: Option<String>,
priority: Option<u32>,
other_fields: HashMap<String, String>,
}
impl ZypperHandler {
pub fn new() -> Self {
let variant = Self::detect_variant();
Self {
repos_dir: PathBuf::from("/etc/zypp/repos.d"),
backup_dir: PathBuf::from("/var/backups/smirrors/zypper"),
distro_variant: variant,
}
}
fn detect_variant() -> ZypperVariant {
if let Ok(content) = fs::read_to_string("/etc/os-release") {
if content.contains("Tumbleweed") || content.contains("tumbleweed") {
return ZypperVariant::Tumbleweed;
}
}
ZypperVariant::Leap
}
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) {
if let Ok(section) = self.parse_repo_file(&content) {
if !section.enabled {
continue;
}
let url_str = section.baseurl.or(section.mirrorlist);
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());
if let Some(priority) = section.priority {
mirror.metadata.insert(
"priority".to_string(),
priority.to_string(),
);
}
mirrors.push(mirror);
}
}
}
}
}
}
}
Ok(mirrors)
}
fn parse_repo_file(&self, content: &str) -> Result<RepoSection> {
let mut section = RepoSection {
name: String::new(),
enabled: true,
autorefresh: false,
baseurl: None,
mirrorlist: None,
repo_type: "rpm-md".to_string(),
gpgcheck: true,
gpgkey: None,
priority: None,
other_fields: HashMap::new(),
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
section.name = line[1..line.len() - 1].to_string();
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();
match key.as_str() {
"enabled" => {
section.enabled = value == "1" || value.to_lowercase() == "true";
}
"autorefresh" => {
section.autorefresh = value == "1" || value.to_lowercase() == "true";
}
"baseurl" => {
section.baseurl = Some(value);
}
"mirrorlist" => {
section.mirrorlist = Some(value);
}
"type" => {
section.repo_type = value;
}
"gpgcheck" => {
section.gpgcheck = value == "1" || value.to_lowercase() == "true";
}
"gpgkey" => {
section.gpgkey = Some(value);
}
"priority" => {
if let Ok(priority) = value.parse::<u32>() {
section.priority = Some(priority);
}
}
_ => {
section.other_fields.insert(key, value);
}
}
}
}
Ok(section)
}
fn expand_variables(&self, url: &str) -> String {
let mut expanded = url.to_string();
if let Ok(version) = self.get_release_version() {
expanded = expanded.replace("$releasever", &version);
}
if let Ok(arch) = self.get_arch() {
expanded = expanded.replace("$basearch", &arch);
}
expanded
}
fn get_release_version(&self) -> Result<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("tumbleweed".to_string())
}
fn get_arch(&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();
Ok(arch)
}
async fn fetch_opensuse_mirrors(&self) -> Result<Vec<Mirror>> {
let client = Client::new();
let url = match self.distro_variant {
ZypperVariant::Tumbleweed => {
"https://download.opensuse.org/tumbleweed/repo/oss/"
}
ZypperVariant::Leap => "https://download.opensuse.org/distribution/leap/",
};
debug!("Fetching openSUSE mirrors");
let response = client.get(url).send().await;
if response.is_ok() {
self.parse_opensuse_mirrors().await
} else {
Ok(self.get_fallback_mirrors())
}
}
async fn parse_opensuse_mirrors(&self) -> Result<Vec<Mirror>> {
let client = Client::new();
let url = "https://mirrors.opensuse.org/";
let response = client
.get(url)
.send()
.await
.context("Failed to fetch openSUSE mirror list")?;
let html = response
.text()
.await
.context("Failed to read openSUSE mirror list")?;
let re = Regex::new(r#"(?:https?://[^\s"<>]+/(?:opensuse|distribution)/?)"#)?;
let mut mirrors = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
for cap in re.captures_iter(&html) {
if let Some(url_str) = cap.get(0) {
let url_str = url_str.as_str().trim_end_matches('/');
if let Ok(url) = Url::parse(url_str) {
let url_key = url.as_str();
if !seen_urls.contains(url_key) {
seen_urls.insert(url_key.to_string());
let mut mirror = Mirror::new(url.clone());
if let Some(country) = Self::extract_country_from_hostname(url_str) {
mirror.country = Some(country);
}
mirrors.push(mirror);
}
}
}
}
if mirrors.is_empty() {
mirrors = self.get_fallback_mirrors();
}
Ok(mirrors)
}
fn extract_country_from_hostname(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
}
fn get_fallback_mirrors(&self) -> Vec<Mirror> {
let base_urls = match self.distro_variant {
ZypperVariant::Tumbleweed => vec![
"https://download.opensuse.org/tumbleweed/repo/oss/",
"https://mirrors.kernel.org/opensuse/tumbleweed/repo/oss/",
"http://mirror.math.princeton.edu/pub/opensuse/tumbleweed/repo/oss/",
],
ZypperVariant::Leap => vec![
"https://download.opensuse.org/distribution/leap/",
"https://mirrors.kernel.org/opensuse/distribution/leap/",
"http://mirror.math.princeton.edu/pub/opensuse/distribution/leap/",
],
};
base_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 mut new_content = String::new();
let new_url = new_mirror.url.as_str().trim_end_matches('/');
for line in content.lines() {
let line_trimmed = line.trim();
let mut modified_line = line.to_string();
if 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())
.filter(|e| e.path().is_dir())
.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 directories found"))
}
}
impl Default for ZypperHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DistroHandler for ZypperHandler {
fn name(&self) -> &str {
match self.distro_variant {
ZypperVariant::Tumbleweed => "openSUSE Tumbleweed",
ZypperVariant::Leap => "openSUSE Leap",
}
}
fn detect(&self) -> bool {
self.repos_dir.exists()
&& std::process::Command::new("zypper")
.arg("--version")
.output()
.is_ok()
}
async fn get_available_mirrors(&self) -> Result<Vec<Mirror>> {
debug!("Fetching available mirrors for Zypper");
self.fetch_opensuse_mirrors().await
}
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 Zypper mirrors");
if !nix::unistd::geteuid().is_root() {
anyhow::bail!("Root privileges required to update Zypper 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!("Refreshing repositories");
let output = std::process::Command::new("zypper")
.arg("refresh")
.output();
if let Err(e) = output {
warn!("Failed to refresh repositories: {}", e);
}
info!("Successfully updated Zypper 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 Zypper configuration");
let output = std::process::Command::new("zypper")
.arg("repos")
.arg("--no-refresh")
.output()
.context("Failed to run zypper repos")?;
if !output.status.success() {
warn!(
"zypper repos failed: {}",
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 = ZypperHandler::new();
let content = r#"
[repo-oss]
name=openSUSE-Tumbleweed-Oss
enabled=1
autorefresh=1
baseurl=https://download.opensuse.org/tumbleweed/repo/oss/
type=rpm-md
gpgcheck=1
priority=99
"#;
let section = handler.parse_repo_file(content).unwrap();
assert_eq!(section.name, "repo-oss");
assert!(section.enabled);
assert!(section.autorefresh);
assert!(section.baseurl.is_some());
assert_eq!(section.priority, Some(99));
}
#[test]
fn test_expand_variables() {
let handler = ZypperHandler::new();
let url = "https://example.com/$releasever/$basearch/";
let expanded = handler.expand_variables(url);
assert!(!expanded.contains("$releasever"));
assert!(!expanded.contains("$basearch"));
}
#[test]
fn test_extract_country_from_hostname() {
assert_eq!(
ZypperHandler::extract_country_from_hostname("https://us.mirror.example.com/"),
Some("US".to_string())
);
assert_eq!(
ZypperHandler::extract_country_from_hostname("https://mirror.de.example.com/"),
Some("DE".to_string())
);
}
}