Files
pkh/src/distro_info.rs
T
vhaudiquet 8c6f6f4028 distro_info: match changelog suite names with their series
Debian packages conventionally target 'unstable' in their
debian/changelog distribution field, but the series data (the
distro-info CSVs) only knows codenames: the suite is the alias
'unstable' of the series 'sid', a mapping the debian-distro-info tool
resolves internally without exposing it in its data.

Add a per-dist suite_aliases reference-data key (debian: unstable ->
sid), with two helpers on top: resolve_suite_alias, identifying a
changelog suite name with its series codename and the dist that
codename belongs to, and series_suite_alias, the inverse direction.
The two names identify the same series.
2026-09-21 11:52:39 +02:00

1201 lines
44 KiB
Rust

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<String>,
/// Series creation date (absent if missing or invalid in the CSV data)
pub created: Option<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,
}
/// 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<String>),
}
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<String>,
/// 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<String, Mirror>,
archive_keyring: String,
pockets: Vec<String>,
#[serde(default)]
sections: Vec<String>,
components: Vec<String>,
cross_pockets: Vec<String>,
#[serde(default)]
build_profiles: Vec<String>,
/// 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<String, String>,
series: SeriesInfo,
}
#[derive(Debug, Deserialize)]
struct Data {
dist: HashMap<String, DistData>,
}
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<reqwest::Response> {
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<Duration>,
) -> reqwest::Result<reqwest::Response> {
const ATTEMPTS: u32 = 3;
let mut last_error: Option<reqwest::Error> = 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<NaiveDate> {
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<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 = 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<String> {
let mut dists: Vec<String> = 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<dyn Error>> {
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<Vec<SeriesInformation>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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<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_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<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())
}
/// 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<String> {
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<Vec<String>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
Ok(dist_data(dist)?.components.clone())
}
/// Get the pockets a cross-build environment enables for a series (the
/// `<series>-<pocket>` 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<Vec<String>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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<dyn Error>> {
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<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?.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<Vec<String>, Box<dyn Error>> {
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<Option<String>, Box<dyn Error>> {
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<Option<(String, String)>, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>>,
get_cross_pockets as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
get_build_profiles as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
] {
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());
}
}