use chrono::NaiveDate; use lazy_static::lazy_static; use serde::Deserialize; use std::error::Error; use std::path::Path; #[derive(Debug, Clone)] /// Information about a specific distribution series pub struct SeriesInformation { /// Distribution series pub series: String, /// Codename, i.e. full name of series pub codename: String, /// Series version as numbers pub version: Option, /// Series creation date pub created: NaiveDate, /// Series release date pub release: Option, /// Series end-of-life date pub eol: Option, } #[derive(Debug, Deserialize)] struct SeriesInfo { local: String, network: String, } #[derive(Debug, Deserialize)] struct DistData { base_url: String, archive_keyring: String, pockets: Vec, series: SeriesInfo, } #[derive(Debug, Deserialize)] struct Data { dist: std::collections::HashMap, } const DATA_YAML: &str = include_str!("../distro_info.yml"); lazy_static! { static ref DATA: Data = serde_yaml::from_str(DATA_YAML).unwrap(); } fn parse_series_csv(content: &str) -> Result, Box> { let mut rdr = csv::ReaderBuilder::new() .flexible(true) .from_reader(content.as_bytes()); let headers = rdr.headers()?.clone(); let series_idx = headers .iter() .position(|h| h == "series") .ok_or("Column 'series' not found")?; let codename_idx = headers .iter() .position(|h| h == "codename") .ok_or("Column 'codename' not found")?; let version_idx = headers .iter() .position(|h| h == "version") .ok_or("Column 'version' not found")?; let created_idx = headers .iter() .position(|h| h == "created") .ok_or("Column 'created' not found")?; let release_idx = headers .iter() .position(|h| h == "release") .ok_or("Column 'release' not found")?; let eol_idx = headers .iter() .position(|h| h == "eol") .ok_or("Column 'eol' not found")?; let mut series_info_list = Vec::new(); for result in rdr.records() { let record = result?; let series = record.get(series_idx).unwrap().to_string(); let codename = record.get(codename_idx).unwrap().to_string(); let version = record.get(version_idx).map(|s| s.to_string()); let created = record .get(created_idx) .map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap()) .unwrap(); let release = record .get(release_idx) .map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap()); let eol = record .get(eol_idx) .map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap()); series_info_list.push(SeriesInformation { series, codename, version, created, release, eol, }); } // Revert to sort by most recent series_info_list.reverse(); Ok(series_info_list) } /// Get time-ordered list of series information for a distribution, development series first pub async fn get_ordered_series(dist: &str) -> Result, Box> { let series_info = &DATA.dist.get(dist).unwrap().series; let content = if Path::new(series_info.local.as_str()).exists() { std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv"))? } else { reqwest::get(series_info.network.as_str()) .await? .text() .await? }; let series_info_list = parse_series_csv(&content)?; Ok(series_info_list) } /// Get time-ordered list of series names for a distribution, development series first pub async fn get_ordered_series_name(dist: &str) -> Result, Box> { let series = get_ordered_series(dist).await?; Ok(series.iter().map(|info| info.series.clone()).collect()) } /// Get the latest released series for a dist (excluding future releases and special cases like sid) pub async fn get_latest_released_series(dist: &str) -> Result> { let latest = get_n_latest_released_series(dist, 1).await?; latest .first() .cloned() .ok_or("No released series found".into()) } /// Get the N latest released series for a dist (excluding future releases and special cases like sid) pub async fn get_n_latest_released_series( dist: &str, n: usize, ) -> Result, Box> { let series_info_list = get_ordered_series(dist).await?; let today = chrono::Local::now().date_naive(); let mut released_series = Vec::new(); for series_info in series_info_list { // Skip 'sid' and series without release dates or with future release dates if series_info.series != "sid" && series_info.series != "experimental" && series_info.release.is_some() && series_info.release.unwrap() <= today { released_series.push(series_info); } } // Sort by release date descending (newest first) released_series.sort_by(|a, b| b.release.cmp(&a.release)); Ok(released_series .iter() .take(n) .map(|s| s.series.clone()) .collect()) } /// Obtain the distribution (eg. debian, ubuntu) from a distribution series (eg. noble, bookworm) pub async fn get_dist_from_series(series: &str) -> Result> { for dist in DATA.dist.keys() { if get_ordered_series_name(dist) .await? .contains(&series.to_string()) { return Ok(dist.to_string()); } } Err(format!("Unknown series: {}", series).into()) } /// Get the package pockets available for a given distribution /// /// Example: get_dist_pockets(ubuntu) => ["proposed", "updates", ""] pub fn get_dist_pockets(dist: &str) -> Vec { let mut pockets = DATA.dist.get(dist).unwrap().pockets.clone(); // Explicitely add 'main' pocket, which is just the empty string pockets.push("".to_string()); pockets } /// Get the sources URL for a distribution, series, pocket, and component pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String { let pocket_full = if pocket.is_empty() { String::new() } else { format!("-{}", pocket) }; format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz") } /// Get the archive base URL for a distribution /// /// Example: ubuntu => http://archive.ubuntu.com/ubuntu pub fn get_base_url(dist: &str) -> String { DATA.dist.get(dist).unwrap().base_url.clone() } /// Obtain the URLs for the archive keyrings of a distribution series /// /// For 'sid' and 'experimental', returns keyrings from the 3 latest releases /// since sid needs keys from all recent releases. pub async fn get_keyring_urls(series: &str) -> Result, Box> { let dist = get_dist_from_series(series).await?; let dist_data = DATA .dist .get(&dist) .ok_or(format!("Unsupported distribution: {}", dist))?; // For Debian, we need the series number to form the keyring URL if dist == "debian" { // Special case for 'sid' - use keyrings from the 3 latest released versions if series == "sid" || series == "experimental" { let latest_released = get_n_latest_released_series("debian", 3).await?; let mut urls = Vec::new(); for released_series in latest_released { if let Some(series_num) = get_debian_series_number(&released_series).await? { urls.push( dist_data .archive_keyring .replace("{series_num}", &series_num), ); } } if urls.is_empty() { Err("No keyring URLs found for sid/experimental".into()) } else { Ok(urls) } } else { let series_num = get_debian_series_number(series).await?.unwrap(); // Replace {series_num} placeholder with the actual series number Ok(vec![ dist_data .archive_keyring .replace("{series_num}", &series_num), ]) } } else { // For other distributions like Ubuntu, use the keyring directly Ok(vec![dist_data.archive_keyring.clone()]) } } /// Obtain the URL for the 'Release' file of a distribution series fn get_release_url(base_url: &str, series: &str, pocket: &str) -> String { let pocket_full = if pocket.is_empty() { String::new() } else { format!("-{}", pocket) }; format!("{base_url}/dists/{series}{pocket_full}/Release") } /// Obtain the components of a distribution series by parsing the 'Release' file pub async fn get_components( base_url: &str, series: &str, pocket: &str, ) -> Result, Box> { let url = get_release_url(base_url, series, pocket); log::debug!("Fetching Release file from: {}", url); let content = reqwest::get(&url).await?.text().await?; for line in content.lines() { if line.starts_with("Components:") && let Some((_, components)) = line.split_once(':') { return Ok(components .split_whitespace() .map(|s| s.to_string()) .collect()); } } Err("Components not found.".into()) } /// Map a Debian series name to its version number pub async fn get_debian_series_number(series: &str) -> Result, Box> { let series_info = &DATA.dist.get("debian").unwrap().series; let content = if Path::new(series_info.local.as_str()).exists() { std::fs::read_to_string(series_info.local.as_str())? } else { reqwest::get(series_info.network.as_str()) .await? .text() .await? }; let mut rdr = csv::ReaderBuilder::new() .flexible(true) .from_reader(content.as_bytes()); let headers = rdr.headers()?.clone(); let series_idx = headers .iter() .position(|h| h == "series") .ok_or("Column 'series' not found")?; let version_idx = headers .iter() .position(|h| h == "version") .ok_or("Column 'version' not found")?; for result in rdr.records() { let record = result?; if let (Some(s), Some(v)) = (record.get(series_idx), record.get(version_idx)) && s.to_lowercase() == series.to_lowercase() { return Ok(Some(v.to_string())); } } Ok(None) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_debian_series() { let series = get_ordered_series_name("debian").await.unwrap(); assert!(series.contains(&"sid".to_string())); assert!(series.contains(&"bookworm".to_string())); } #[tokio::test] async fn test_get_ubuntu_series() { let series = get_ordered_series_name("ubuntu").await.unwrap(); assert!(series.contains(&"noble".to_string())); assert!(series.contains(&"jammy".to_string())); } #[tokio::test] async fn test_get_dist_from_series() { assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian"); assert_eq!(get_dist_from_series("noble").await.unwrap(), "ubuntu"); } #[tokio::test] async fn test_get_debian_series_number() { // Test with known Debian series let bookworm_number = get_debian_series_number("bookworm").await.unwrap(); assert!(bookworm_number.is_some()); assert_eq!(bookworm_number.unwrap(), "12"); let trixie_number = get_debian_series_number("trixie").await.unwrap(); assert!(trixie_number.is_some()); assert_eq!(trixie_number.unwrap(), "13"); // Test with unknown series let unknown_number = get_debian_series_number("unknown").await.unwrap(); assert!(unknown_number.is_none()); } #[tokio::test] async fn test_get_keyring_urls_sid() { // Test that 'sid' returns keyrings from the 3 latest released versions let sid_keyrings = get_keyring_urls("sid").await.unwrap(); // Should have keyring URLs for sid assert!(!sid_keyrings.is_empty()); assert!(sid_keyrings.len() <= 3); // Each URL should be a valid Debian keyring URL for url in &sid_keyrings { assert!( url.contains("ftp-master.debian.org/keys"), "URL '{}' does not contain expected pattern", url ); } } #[tokio::test] async fn test_get_keyring_url_regular_series() { // Test that regular series (like bookworm) returns a single keyring URL let bookworm_keyring = &get_keyring_urls("bookworm").await.unwrap()[0]; assert!( bookworm_keyring.contains("ftp-master.debian.org/keys"), "URL '{}' does not contain expected pattern", bookworm_keyring ); } #[tokio::test] async fn test_get_n_latest_released_series() { // Test getting 3 latest released series let latest_3 = get_n_latest_released_series("debian", 3).await.unwrap(); // Should have at most 3 series assert!(!latest_3.is_empty()); assert!(latest_3.len() <= 3); // Should not contain 'sid' or 'experimental' assert!(!latest_3.contains(&"sid".to_string())); assert!(!latest_3.contains(&"experimental".to_string())); } #[tokio::test] async fn test_get_latest_released_debian_series() { // Test that we get a valid released series let latest_released = get_latest_released_series("debian").await.unwrap(); // Should not be 'sid' or 'experimental' assert_ne!(latest_released, "sid"); assert_ne!(latest_released, "experimental"); // Should have a version number let version = get_debian_series_number(&latest_released).await.unwrap(); assert!(version.is_some()); } }