434 lines
14 KiB
Rust
434 lines
14 KiB
Rust
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<String>,
|
|
/// Series creation date
|
|
pub created: NaiveDate,
|
|
/// Series release date
|
|
pub release: Option<NaiveDate>,
|
|
/// Series end-of-life date
|
|
pub eol: Option<NaiveDate>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SeriesInfo {
|
|
local: String,
|
|
network: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DistData {
|
|
base_url: String,
|
|
archive_keyring: String,
|
|
pockets: Vec<String>,
|
|
series: SeriesInfo,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Data {
|
|
dist: std::collections::HashMap<String, DistData>,
|
|
}
|
|
|
|
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<Vec<SeriesInformation>, Box<dyn Error>> {
|
|
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<Vec<SeriesInformation>, Box<dyn Error>> {
|
|
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<Vec<String>, Box<dyn Error>> {
|
|
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<String, Box<dyn Error>> {
|
|
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<Vec<String>, Box<dyn Error>> {
|
|
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<String, Box<dyn Error>> {
|
|
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<String> {
|
|
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<Vec<String>, Box<dyn Error>> {
|
|
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<Vec<String>, Box<dyn Error>> {
|
|
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<Option<String>, Box<dyn Error>> {
|
|
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());
|
|
}
|
|
}
|