use crate::data::embed_data; use chrono::NaiveDate; use lazy_static::lazy_static; use serde::Deserialize; use std::collections::HashMap; use std::error::Error; use std::path::Path; use std::time::Duration; #[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 (absent if missing or invalid in the CSV data) pub created: Option, /// Series release date pub release: Option, /// Series end-of-life date pub eol: Option, } #[derive(Debug, Deserialize)] struct SeriesInfo { local: String, network: String, } /// Architectures an archive mirror serves: an explicit list, or the `all` /// sentinel meaning one mirror serves every architecture (Debian's mirror /// setup — an exhaustive list would rot each time an arch is added) #[derive(Debug, Deserialize)] #[serde(untagged)] enum MirrorArchs { /// The `all` sentinel All(String), /// An explicit list of dpkg architecture names List(Vec), } impl MirrorArchs { /// Whether the mirror serves `arch`. A scalar other than the `all` /// sentinel serves nothing (a test locks that reading of the data). fn serves(&self, arch: &str) -> bool { match self { MirrorArchs::All(sentinel) => sentinel == "all", MirrorArchs::List(archs) => archs.iter().any(|a| a == arch), } } } /// One archive mirror of a distribution: a URL serving a set of /// architectures, plus the sibling host serving its `-security` pocket /// for the same architectures (ports mirrors serve their own security) #[derive(Debug, Deserialize)] pub struct Mirror { /// Base URL of the mirror (the primary mirror's URL doubles as the /// dist's base URL, see [`get_base_url`]) pub url: String, /// Sibling host serving the `-security` pocket for these /// architectures; `None` when the mirror serves its own security #[serde(default)] security_url: Option, /// Architectures the mirror serves (see [`MirrorArchs`]) archs: MirrorArchs, } impl Mirror { /// Whether the mirror serves `arch` pub fn serves(&self, arch: &str) -> bool { self.archs.serves(arch) } } #[derive(Debug, Deserialize)] struct DistData { mirrors: HashMap, archive_keyring: String, pockets: Vec, #[serde(default)] sections: Vec, components: Vec, cross_pockets: Vec, #[serde(default)] build_profiles: Vec, /// Changelog suite names aliasing a distro-info series codename /// ('unstable' for Debian's 'sid'): the two names identify the same /// series ([`series_suite_alias`], [`resolve_suite_alias`]) #[serde(default)] suite_aliases: HashMap, series: SeriesInfo, } #[derive(Debug, Deserialize)] struct Data { dist: HashMap, } embed_data! { static ref DATA: Data = "../data/distro_info.yml" } lazy_static! { // Shared HTTP client used for all outgoing plain requests: timeouts keep // a hanging remote (connect or transfer) from stalling pkh indefinitely. // The short pool idle timeout and TCP keepalive avoid reusing keep-alive // connections that the remote closed in the meantime, which surfaces as // spurious 'error sending request' failures on busy mirrors/CDNs. static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .pool_idle_timeout(Duration::from_secs(10)) .tcp_keepalive(Duration::from_secs(30)) .build() .expect("building the shared HTTP client with static options cannot fail"); } /// Shared HTTP client with a connect timeout (10s) and a total request /// timeout (30s), to be used for all outgoing plain HTTP(S) requests pub(crate) fn http_client() -> &'static reqwest::Client { &HTTP_CLIENT } /// GET `url` with bounded retries on transient transport errors (a pooled /// keep-alive connection closed by the remote, a momentary network hiccup, /// ...): these always succeed again on a fresh connection, and mirrors are /// busy enough that unguarded single attempts make bulk operations flaky. /// /// The response status is not inspected: 404s and the like are meaningful /// answers, not transport failures. pub(crate) async fn http_get_retried(url: &str) -> reqwest::Result { http_get_retried_with_timeout(url, None).await } /// [`http_get_retried`] with a per-request timeout override, for large /// streaming downloads that exceed the shared client's total timeout pub(crate) async fn http_get_retried_with_timeout( url: &str, timeout: Option, ) -> reqwest::Result { const ATTEMPTS: u32 = 3; let mut last_error: Option = None; for attempt in 0..ATTEMPTS { let mut request = http_client().get(url); if let Some(timeout) = timeout { request = request.timeout(timeout); } match request.send().await { Ok(response) => return Ok(response), Err(e) => { if attempt + 1 < ATTEMPTS { log::debug!( "GET '{url}' failed (attempt {}/{}, retrying): {}", attempt + 1, ATTEMPTS, e ); tokio::time::sleep(Duration::from_millis(300 * (u64::from(attempt) + 1))).await; } last_error = Some(e); } } } Err(last_error.expect("at least one attempt was made")) } /// Parse an optional '%Y-%m-%d' date from a CSV cell, warning instead of /// panicking on invalid remote data fn parse_optional_date(value: Option<&str>, series: &str, field: &str) -> Option { value.and_then( |date_str| match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(date) => Some(date), Err(e) => { log::warn!( "Invalid '{}' date '{}' for series '{}': {}. Ignoring the date.", field, date_str, series, e ); None } }, ) } 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 = match result { Ok(record) => record, Err(e) => { log::warn!("Skipping malformed series CSV row: {}", e); continue; } }; // Rows missing essential identification fields are skipped: they // cannot be used nor reported meaningfully. Dates, on the other // hand, are all optional in the model, so a bad date keeps the row. let Some(series) = record.get(series_idx).filter(|s| !s.is_empty()) else { log::warn!( "Skipping series CSV row without a 'series' value: {:?}", record ); continue; }; let Some(codename) = record.get(codename_idx).filter(|s| !s.is_empty()) else { log::warn!( "Skipping series CSV row for series '{}' without a 'codename' value", series ); continue; }; let version = record.get(version_idx).map(|s| s.to_string()); let created = parse_optional_date(record.get(created_idx), series, "created"); let release = parse_optional_date(record.get(release_idx), series, "release"); let eol = parse_optional_date(record.get(eol_idx), series, "eol"); series_info_list.push(SeriesInformation { series: series.to_string(), codename: codename.to_string(), version, created, release, eol, }); } // Revert to sort by most recent series_info_list.reverse(); Ok(series_info_list) } /// List the distributions known to pkh (e.g. "debian", "ubuntu"), sorted so /// that menus and error messages derived from it are deterministic pub fn supported_dists() -> Vec { let mut dists: Vec = DATA.dist.keys().cloned().collect(); dists.sort(); dists } /// Name of a dist's primary mirror entry: its URL doubles as the dist's /// base URL ([`get_base_url`]) and is the first candidate of /// [`mirror_for_arch`] const PRIMARY_MIRROR: &str = "primary"; /// The data of a known distribution: the shared "unknown distribution" /// error of the per-dist accessors fn dist_data(dist: &str) -> Result<&'static DistData, Box> { DATA.dist.get(dist).ok_or_else(|| { format!( "Unknown distribution '{}'. Supported distributions are: {}.", dist, supported_dists().join(", ") ) .into() }) } /// Special changelog distribution marking an entry that has not been /// released to any archive series yet pub const UNRELEASED: &str = "UNRELEASED"; /// Whether `series` is the special [`UNRELEASED`] distribution rather than /// a real archive series pub fn is_unreleased(series: &str) -> bool { series == UNRELEASED } /// Get time-ordered list of series information for a distribution, development series first pub async fn get_ordered_series(dist: &str) -> Result, Box> { let dist_data = DATA.dist.get(dist).ok_or_else(|| { format!( "Unknown distribution '{}'. Supported distributions are: {}.", dist, supported_dists().join(", ") ) })?; let series_info = &dist_data.series; let content = if Path::new(series_info.local.as_str()).exists() { std::fs::read_to_string(series_info.local.as_str()).map_err(|e| { format!( "Failed to read distribution series data for '{dist}' \ from '{}': {}. The 'distro-info' package provides these CSV files.", series_info.local, e ) })? } else { http_client() .get(series_info.network.as_str()) .send() .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()) } /// The series to actually target when the changelog says [`UNRELEASED`]: /// the development series of `dist`, i.e. the first entry of /// [`get_ordered_series_name`] (which is documented "development series /// first"). UNRELEASED work conventionally targets the next release, not /// the last stable one. `dist` is matched case-insensitively, so vendor /// names with original casing (dpkg's `Vendor:` field is e.g. "Ubuntu") /// are accepted as-is. Any other `series` is returned unchanged. Errors /// when `dist` is unknown or has no series list. pub async fn effective_series(series: &str, dist: &str) -> Result> { if !is_unreleased(series) { return Ok(series.to_string()); } // The series data keys are lowercase, unlike the vendor names that // callers typically resolve from dpkg let dist = dist.to_lowercase(); get_ordered_series_name(&dist) .await? .into_iter() .next() .ok_or_else(|| format!("Distribution '{dist}' has no series to target").into()) } /// 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_key(|b| std::cmp::Reverse(b.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()) } /// The changelog suite name that aliases the series codename of `dist` /// (Debian's 'unstable' for 'sid'): the two names identify the same /// series. `None` when the series carries no suite alias. pub fn series_suite_alias(dist: &str, series: &str) -> Option { dist_data(dist) .ok()? .suite_aliases .iter() .find(|(_suite, codename)| codename.as_str() == series) .map(|(suite, _)| suite.clone()) } /// Identify a changelog suite name with the distro-info series codename /// it aliases (Debian's 'unstable' is 'sid'), and the dist that codename /// belongs to. `None` when `suite` is not a known alias of any dist. pub fn resolve_suite_alias(suite: &str) -> Option<(String, String)> { for (dist, data) in DATA.dist.iter() { if let Some(codename) = data.suite_aliases.get(suite) { return Some((dist.clone(), codename.clone())); } } None } /// Get the package pockets available for a given distribution, in search order /// /// The main archive ('') comes first so that a search without an explicit /// pocket prefers the released archive over its pockets; development pockets /// (e.g. '-proposed') come last. /// /// Example: get_dist_pockets(ubuntu) => ["", "updates", "security", "proposed"] pub fn get_dist_pockets(dist: &str) -> Result, Box> { let mut pockets = dist_data(dist)?.pockets.clone(); // Explicitely add 'main' pocket, which is just the empty string, first pockets.insert(0, "".to_string()); Ok(pockets) } /// Get the archive components of a distribution (ubuntu's main, /// restricted, universe, multiverse; Debian's main, contrib, non-free, /// non-free-firmware): the default set a build environment enables on its /// official sources. Live archive operations keep resolving components /// from Release files ([`get_components`]); this is the offline default. pub fn get_dist_components(dist: &str) -> Result, Box> { Ok(dist_data(dist)?.components.clone()) } /// Get the pockets a cross-build environment enables for a series (the /// `-` suite list is built from these): updates, /// backports and security. Deliberately separate from /// [`get_dist_pockets`], which is the *search order* of pull — folding /// backports into it would change pull behavior. pub fn get_cross_pockets(dist: &str) -> Result, Box> { Ok(dist_data(dist)?.cross_pockets.clone()) } /// Get the default build profiles of a distribution's vendor (Ubuntu /// activates `derivative.ubuntu noudeb`, Debian none), mirroring what /// `Dpkg::BuildProfiles` resolves when `DEB_BUILD_PROFILES` is unset. /// Vendors are matched case-insensitively by the caller (dpkg's `Vendor:` /// field keeps its original casing). pub fn get_build_profiles(dist: &str) -> Result, Box> { Ok(dist_data(dist)?.build_profiles.clone()) } /// Get the valid `Section` values of a distribution's packages, as accepted /// by its archives (a `section/subsection` in debian/control validates on /// the part before the '/') pub fn get_sections(dist: &str) -> Result, Box> { Ok(dist_data(dist)?.sections.clone()) } /// 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: the URL of its primary /// mirror (the former `base_url` key folded into `mirrors.primary.url` /// when the mirrors were modeled — the signature is kept so the pull /// paths do not churn) /// /// Example: ubuntu => https://archive.ubuntu.com/ubuntu pub fn get_base_url(dist: &str) -> Result> { let mirror = dist_data(dist)? .mirrors .get(PRIMARY_MIRROR) .ok_or_else(|| { format!( "Distribution '{dist}' has no '{PRIMARY_MIRROR}' mirror in the built-in \ configuration. This is a bug; supported distributions are: {}.", supported_dists().join(", ") ) })?; Ok(mirror.url.clone()) } /// The mirror of `dist` serving `arch`: the primary mirror first, then /// the other mirrors by name, so the answer is deterministic. Debian's /// `all` sentinel makes its primary mirror serve every architecture. /// Errors when the dist is unknown or no mirror serves the architecture /// (an architecture the built-in data does not know about). /// /// Example: mirror_for_arch(ubuntu, riscv64) => the ports mirror pub fn mirror_for_arch(dist: &str, arch: &str) -> Result<&'static Mirror, Box> { let data = dist_data(dist)?; if let Some(primary) = data.mirrors.get(PRIMARY_MIRROR) && primary.serves(arch) { return Ok(primary); } let mut others: Vec<&String> = data .mirrors .keys() .filter(|name| name.as_str() != PRIMARY_MIRROR) .collect(); others.sort(); others .into_iter() .filter_map(|name| data.mirrors.get(name)) .find(|mirror| mirror.serves(arch)) .ok_or_else(|| { format!( "No mirror of '{dist}' serves the '{arch}' architecture. Supported \ distributions are: {}.", supported_dists().join(", ") ) .into() }) } /// Host part of an apt-source URL: everything after the `://` scheme up /// to the first `/` (a `:port` suffix stripped). URLs without a scheme /// yield their leading segment. fn url_host(url: &str) -> &str { let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); let authority = after_scheme.split('/').next().unwrap_or(after_scheme); authority .split_once(':') .map_or(authority, |(host, _)| host) } /// Whether the host of `uri` is the host of `mirror_url` or a subdomain /// of it: the old substring checks (`uri.contains("archive.ubuntu.com")`) /// intentionally matched the country mirrors fronting each archive host /// (`fr.archive.ubuntu.com`), and an exact host comparison would have /// dropped them. The leading dot of the suffix keeps look-alike hosts /// (`notarchive.ubuntu.com`) out. fn uri_matches_url_host(uri: &str, mirror_url: &str) -> bool { let host = url_host(mirror_url); let uri_host = url_host(uri); uri_host == host || uri_host.ends_with(&format!(".{host}")) } /// Whether `uri` points at `mirror` or its security sibling: the URI's /// host is the mirror's (or the sibling's) host or a subdomain of it (see /// [`uri_matches_url_host`]) pub fn is_mirror_source(mirror: &Mirror, uri: &str) -> bool { uri_matches_url_host(uri, &mirror.url) || mirror .security_url .as_deref() .is_some_and(|security| uri_matches_url_host(uri, security)) } /// Whether `uri` points at an official source of `dist` — one of its /// archive mirrors or their security siblings — as opposed to a PPA or /// another third-party repository. Unknown distributions match nothing. pub fn is_official_source(dist: &str, uri: &str) -> bool { DATA.dist.get(dist).is_some_and(|data| { data.mirrors .values() .any(|mirror| is_mirror_source(mirror, uri)) }) } /// 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?.ok_or_else(|| { format!( "Could not determine the version number for Debian series '{series}'. \ Make sure the 'distro-info' package is installed, which provides the \ series CSV data used to map series names to version numbers." ) })?; // 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 = http_client().get(&url).send().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 dist_data = DATA.dist.get("debian").ok_or_else(|| { format!( "Debian distribution data is missing from the built-in configuration. \ This is a bug; supported distributions are: {}.", supported_dists().join(", ") ) })?; let series_info = &dist_data.series; let content = if Path::new(series_info.local.as_str()).exists() { std::fs::read_to_string(series_info.local.as_str()).map_err(|e| { format!( "Failed to read Debian series data from '{}': {}. \ The 'distro-info' package provides this file.", series_info.local, e ) })? } else { http_client() .get(series_info.network.as_str()) .send() .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) } /// The release number of a distribution series, paired with the dist it /// belongs to: the version column of the series data, stripped to its /// leading token ("12" for Debian bookworm, "26.04" out of Ubuntu /// resolute's "26.04 LTS"). `None` when the series carries no version at /// all (Debian's rolling sid/experimental have an empty column; /// pseudo-versions like "unstable" pass through, callers validate per /// vendor). Errors when no known distribution carries the series. pub async fn get_series_release_number( series: &str, ) -> Result, Box> { let dist = get_dist_from_series(series).await?; for info in get_ordered_series(&dist).await? { if info.series == series { let number = info .version .as_deref() .and_then(|version| version.split_whitespace().next()) .map(str::to_string); return Ok(number.map(|number| (dist, number))); } } Ok(None) } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_sections() { // Both distributions bundle the policy section list for dist in ["debian", "ubuntu"] { let sections = get_sections(dist).unwrap(); assert!(sections.contains(&"utils".to_string())); assert!(sections.contains(&"devel".to_string())); // 'unknown' is exactly what archives reject assert!(!sections.contains(&"unknown".to_string())); } assert!(get_sections("not-a-distro").is_err()); } /// The primary mirror's URL is the former `base_url`, byte for byte: /// the pull paths build their archive URLs from it. #[test] fn test_primary_mirror_url_is_the_base_url() { assert_eq!( get_base_url("ubuntu").unwrap(), "https://archive.ubuntu.com/ubuntu" ); assert_eq!( get_base_url("debian").unwrap(), "https://deb.debian.org/debian" ); assert!(get_base_url("not-a-distro").is_err()); } /// Mirror-per-architecture resolution: the local architectures come /// from Ubuntu's primary mirror, the others from ports; Debian's `all` /// sentinel makes its one mirror serve everything, including /// architectures the data never lists. #[test] fn test_mirror_for_arch() { assert_eq!( mirror_for_arch("ubuntu", "amd64").unwrap().url, "https://archive.ubuntu.com/ubuntu" ); assert_eq!( mirror_for_arch("ubuntu", "riscv64").unwrap().url, "http://ports.ubuntu.com/ubuntu-ports" ); for arch in ["amd64", "riscv64", "brand-new"] { assert_eq!( mirror_for_arch("debian", arch).unwrap().url, "https://deb.debian.org/debian", "the `all` sentinel serves every architecture, including {arch}" ); } // An architecture no Ubuntu mirror serves, and an unknown dist. assert!(mirror_for_arch("ubuntu", "mips64el").is_err()); assert!(mirror_for_arch("not-a-distro", "amd64").is_err()); } /// The `archs` forms and their reading: the `all` sentinel serves /// everything, an explicit list serves exactly its members, and any /// other scalar serves nothing (a data bug a validation would have to /// catch, hence the documented reading). #[test] fn test_mirror_archs_forms() { let all: MirrorArchs = serde_yaml::from_str("all").unwrap(); assert!(all.serves("anything")); let list: MirrorArchs = serde_yaml::from_str("[amd64, i386]").unwrap(); assert!(list.serves("amd64")); assert!(!list.serves("arm64")); let typo: MirrorArchs = serde_yaml::from_str("every").unwrap(); assert!(!typo.serves("amd64")); } /// Official-source matching is host-based but keeps matching the /// country mirrors the old substring checks matched (`fr.archive. /// ubuntu.com`): equality or a `.{host}` suffix, never a bare /// substring — `notarchive.ubuntu.com` must not match. #[test] fn test_is_official_source_matches_country_mirrors_only() { for uri in [ "https://archive.ubuntu.com/ubuntu", "http://security.ubuntu.com/ubuntu", "http://ports.ubuntu.com/ubuntu-ports", // Country mirrors front the same archives. "http://fr.archive.ubuntu.com/ubuntu", "https://de.security.ubuntu.com/ubuntu", ] { assert!(is_official_source("ubuntu", uri), "{uri}"); } for uri in [ "https://deb.debian.org/debian", "https://ppa.launchpadcontent.net/user/ppa/ubuntu", "http://notarchive.ubuntu.com/ubuntu", "http://archive.ubuntu.com.evil.example/ubuntu", ] { assert!(!is_official_source("ubuntu", uri), "{uri}"); } // Debian: its own mirror matches, Ubuntu's mirrors do not, and an // unknown dist matches nothing. assert!(is_official_source( "debian", "https://deb.debian.org/debian" )); assert!(!is_official_source( "debian", "http://security.ubuntu.com/ubuntu" )); assert!(!is_official_source( "not-a-distro", "https://deb.debian.org/debian" )); } /// The dist-level defaults the build paths read: components, /// cross-build pockets (deliberately not the pull search order) and /// vendor build profiles. #[test] fn test_dist_components_cross_pockets_and_build_profiles() { assert_eq!( get_dist_components("ubuntu").unwrap(), vec!["main", "restricted", "universe", "multiverse"] ); assert!( get_dist_components("debian") .unwrap() .contains(&"non-free-firmware".to_string()) ); for dist in ["debian", "ubuntu"] { assert_eq!( get_cross_pockets(dist).unwrap(), vec!["updates", "backports", "security"] ); } // Not the pull search order: no 'proposed', no empty main pocket. let cross = get_cross_pockets("ubuntu").unwrap(); assert!(!cross.contains(&"proposed".to_string())); assert!(!cross.contains(&"".to_string())); assert_eq!( get_build_profiles("ubuntu").unwrap(), vec!["derivative.ubuntu", "noudeb"] ); assert!(get_build_profiles("debian").unwrap().is_empty()); for getter in [ get_dist_components as fn(&str) -> Result, Box>, get_cross_pockets as fn(&str) -> Result, Box>, get_build_profiles as fn(&str) -> Result, Box>, ] { assert!(getter("not-a-distro").is_err()); } } #[test] fn test_parse_series_csv_malformed_rows() { // A short row (missing 'codename') is skipped, a row with an invalid // 'created' date is kept without a date, and invalid 'release'/'eol' // dates become None: none of this may panic on remote data let csv_data = "series,codename,version,created,release,eol\n\ noble,Noble N,24.04,2023-10-26,2024-04-25,2029-04-25\n\ lonely\n\ badbad,Bad B,1.0,not-a-date,2020-01-01,also-bad\n\ sid,sid,unstable,1999-01-01,,\n"; let series = parse_series_csv(csv_data).unwrap(); // Rows are returned most recent first (the parser reverses the list), // with the malformed 'lonely' row skipped entirely let names: Vec<&str> = series.iter().map(|s| s.series.as_str()).collect(); assert_eq!(names, vec!["sid", "badbad", "noble"]); let noble = &series[2]; assert_eq!(noble.codename, "Noble N"); assert_eq!(noble.version.as_deref(), Some("24.04")); assert_eq!( noble.created, Some(NaiveDate::from_ymd_opt(2023, 10, 26).unwrap()) ); assert_eq!( noble.release, Some(NaiveDate::from_ymd_opt(2024, 4, 25).unwrap()) ); assert_eq!( noble.eol, Some(NaiveDate::from_ymd_opt(2029, 4, 25).unwrap()) ); let badbad = &series[1]; assert_eq!(badbad.created, None); assert_eq!( badbad.release, Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()) ); assert_eq!(badbad.eol, None); } #[test] fn test_get_dist_pockets_order() { // Without an explicit pocket, packages are searched in this order: // main archive first, then updates, security, and proposed last let pockets = get_dist_pockets("ubuntu").unwrap(); assert_eq!( pockets, vec![ "".to_string(), "updates".to_string(), "security".to_string(), "proposed".to_string() ] ); let pockets = get_dist_pockets("debian").unwrap(); assert_eq!( pockets, vec![ "".to_string(), "updates".to_string(), "security".to_string(), "proposed-updates".to_string() ] ); } #[test] fn test_is_unreleased() { // Matching is exact: UNRELEASED is uppercase by Debian convention assert!(is_unreleased("UNRELEASED")); assert!(!is_unreleased("unreleased")); assert!(!is_unreleased("noble")); assert!(!is_unreleased("")); } #[tokio::test] async fn test_effective_series_passthrough() { // A real series is returned unchanged, and the dist is not even // looked up (an unknown dist only matters for UNRELEASED) assert_eq!(effective_series("noble", "ubuntu").await.unwrap(), "noble"); assert_eq!(effective_series("sid", "debian").await.unwrap(), "sid"); assert_eq!( effective_series("noble", "unknown-distro").await.unwrap(), "noble" ); } #[tokio::test] async fn test_effective_series_unreleased() { // UNRELEASED resolves to the development series of the dist, i.e. // the first entry of the time-ordered list. On current distro-info // data this is the next Ubuntu release, while Debian's list starts // with 'experimental' (sid comes second), so assert against the // data itself rather than a hardcoded name. for dist in ["ubuntu", "debian"] { let ordered = get_ordered_series_name(dist).await.unwrap(); let resolved = effective_series(UNRELEASED, dist).await.unwrap(); assert_eq!(resolved, ordered[0]); assert_ne!(resolved, UNRELEASED); } } #[tokio::test] async fn test_effective_series_unreleased_dist_case_insensitive() { // Distro data keys are lowercase but dpkg vendors keep original // casing ("Ubuntu"): the UNRELEASED lookup must resolve both let expected = effective_series(UNRELEASED, "ubuntu").await.unwrap(); assert_eq!( effective_series(UNRELEASED, "Ubuntu").await.unwrap(), expected ); assert_eq!( effective_series(UNRELEASED, "UBUNTU").await.unwrap(), expected ); } #[tokio::test] async fn test_effective_series_unknown_dist() { // UNRELEASED on an unknown distribution cannot be resolved assert!( effective_series(UNRELEASED, "unknown-distro") .await .is_err() ); } #[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())); } /// Suite aliases identify a changelog suite name with the series /// codename of the same series: Debian's 'unstable' is 'sid' #[test] fn test_suite_aliases() { assert_eq!( resolve_suite_alias("unstable"), Some(("debian".to_string(), "sid".to_string())) ); // A series codename or unknown suite is not an alias assert_eq!(resolve_suite_alias("sid"), None); assert_eq!(resolve_suite_alias("noble"), None); assert_eq!( series_suite_alias("debian", "sid"), Some("unstable".to_string()) ); assert_eq!(series_suite_alias("debian", "trixie"), None); assert_eq!(series_suite_alias("ubuntu", "noble"), None); } /// Every suite alias must map to a real series of its dist, or the /// selector would offer a phantom entry #[tokio::test] async fn test_suite_aliases_target_real_series() { for (dist, data) in DATA.dist.iter() { for (suite, codename) in &data.suite_aliases { let series = get_ordered_series_name(dist).await.unwrap_or_default(); assert!( series.contains(codename), "suite alias '{suite}' of {dist} maps to '{codename}', \ which is not a known series" ); } } } #[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_series_release_number() { let (dist, bookworm) = get_series_release_number("bookworm") .await .unwrap() .unwrap(); assert_eq!(dist, "debian"); assert_eq!(bookworm, "12"); // Ubuntu LTS rows carry a " LTS" decoration: only the leading // YY.MM token is the release number let (dist, noble) = get_series_release_number("noble").await.unwrap().unwrap(); assert_eq!(dist, "ubuntu"); assert_eq!(noble, "24.04"); // No known dist carries the series assert!(get_series_release_number("not-a-series").await.is_err()); } #[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()); } }