Files
pkh/src/distro_info.rs
T
vhaudiquet f27d27ea99 new: add pkh put, a native dput replacement for PPA uploads
Upload built source packages over SFTP with host-key verification
(Launchpad fingerprints pinned in host_keys.yml, ask-to-accept
otherwise), Launchpad account discovery (git config lp.user), and
pre-flight checks the upload queue itself never does: changes file
discovery/validation, PPA existence via the Launchpad API, target
series validity, and debian/control Section validity (sections
bundled in distro_info.yml). Upload log prevents duplicate uploads
unless --force.
2026-09-16 21:38:53 +02:00

744 lines
26 KiB
Rust

use chrono::NaiveDate;
use lazy_static::lazy_static;
use serde::Deserialize;
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,
}
#[derive(Debug, Deserialize)]
struct DistData {
base_url: String,
archive_keyring: String,
pockets: Vec<String>,
#[serde(default)]
sections: 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! {
// The YAML is include_str!'d at compile time and statically valid; if it
// ever failed to parse it would be a build-time bug that cannot be
// recovered from at runtime, so panicking here is acceptable.
static ref DATA: Data = serde_yaml::from_str(DATA_YAML)
.expect("built-in distro_info.yml data is statically valid and must parse");
// Shared HTTP client used for all outgoing plain requests: timeouts keep
// a hanging remote (connect or transfer) from stalling pkh indefinitely.
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(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
}
/// 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")
pub fn supported_dists() -> Vec<String> {
DATA.dist.keys().cloned().collect()
}
/// 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(format!("/usr/share/distro-info/{dist}.csv")).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())
}
/// 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 dist_data = DATA.dist.get(dist).ok_or_else(|| {
format!(
"Unknown distribution '{}'. Supported distributions are: {}.",
dist,
supported_dists().join(", ")
)
})?;
let mut pockets = dist_data.pockets.clone();
// Explicitely add 'main' pocket, which is just the empty string, first
pockets.insert(0, "".to_string());
Ok(pockets)
}
/// 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>> {
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
format!(
"Unknown distribution '{}'. Supported distributions are: {}.",
dist,
supported_dists().join(", ")
)
})?;
Ok(dist_data.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
///
/// Example: ubuntu => https://archive.ubuntu.com/ubuntu
pub fn get_base_url(dist: &str) -> Result<String, Box<dyn Error>> {
DATA.dist
.get(dist)
.map(|d| d.base_url.clone())
.ok_or_else(|| {
format!(
"Unknown distribution '{}'. Supported distributions are: {}.",
dist,
supported_dists().join(", ")
)
.into()
})
}
/// 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)
}
#[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());
}
#[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()));
}
#[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());
}
}