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:
+122
-21
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user