Handle malformed remote and edge-case data instead of panicking

- distro_info: malformed CSV rows are skipped with a warning, dates
  that fail to parse become None, and all plain HTTP requests go
  through a shared reqwest client with connect/total timeouts
- package_info: the Sources stanza iterator is iterative (a crafted
  index with many blank stanzas overflowed the stack), stanzas missing
  a Version are skipped, and failed series/pocket probes are summarized
  in the final 'not found' error instead of being silently dropped
- pull: no double unwrap on the remote-derived artifact filename, an
  empty series list is an error, and streaming downloads get a
  per-request timeout
- deb/cross: dpkg-architecture output parsing skips unexpected lines
  and its exit status is checked, as is dpkg --add-architecture
- changelog: version increments parse as u64 with checked arithmetic
  (1.0-20250123123456 used to panic on the u32 parse)
This commit is contained in:
2026-09-16 02:44:45 +02:00
parent 6a5c5a7106
commit f72b35acfa
5 changed files with 489 additions and 142 deletions
+122 -21
View File
@@ -3,6 +3,7 @@ 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
@@ -13,8 +14,8 @@ pub struct SeriesInformation {
pub codename: String,
/// Series version as numbers
pub version: Option<String>,
/// Series creation date
pub created: NaiveDate,
/// 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
@@ -42,7 +43,45 @@ struct Data {
const DATA_YAML: &str = include_str!("../distro_info.yml");
lazy_static! {
static ref DATA: Data = serde_yaml::from_str(DATA_YAML).unwrap();
// 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>> {
@@ -79,24 +118,39 @@ fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Err
let mut series_info_list = Vec::new();
for result in rdr.records() {
let record = result?;
let series = record.get(series_idx).unwrap().to_string();
let codename = record.get(codename_idx).unwrap().to_string();
let 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 = record
.get(created_idx)
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap())
.unwrap();
let release = record
.get(release_idx)
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
let eol = record
.get(eol_idx)
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
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,
codename,
series: series.to_string(),
codename: codename.to_string(),
version,
created,
release,
@@ -134,7 +188,9 @@ pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Bo
)
})?
} else {
reqwest::get(series_info.network.as_str())
http_client()
.get(series_info.network.as_str())
.send()
.await?
.text()
.await?
@@ -324,7 +380,7 @@ pub async fn get_components(
let url = get_release_url(base_url, series, pocket);
log::debug!("Fetching Release file from: {}", url);
let content = reqwest::get(&url).await?.text().await?;
let content = http_client().get(&url).send().await?.text().await?;
for line in content.lines() {
if line.starts_with("Components:")
@@ -359,7 +415,9 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
)
})?
} else {
reqwest::get(series_info.network.as_str())
http_client()
.get(series_info.network.as_str())
.send()
.await?
.text()
.await?
@@ -395,6 +453,49 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
mod tests {
use super::*;
#[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: