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:
+164
-74
@@ -190,82 +190,98 @@ impl Iterator for DebianSources {
|
||||
type Item = PackageStanza;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let stanza = self.splitted_sources.next()?;
|
||||
// Iterate over stanzas in a loop: package-less (blank) stanzas are
|
||||
// skipped without recursion, so that a crafted index with many
|
||||
// consecutive blank stanzas cannot blow the stack
|
||||
loop {
|
||||
let stanza = self.splitted_sources.next()?;
|
||||
|
||||
// Parse stanza into a hashmap of strings, the fields
|
||||
let mut fields: HashMap<String, String> = HashMap::new();
|
||||
let mut current_key = String::new();
|
||||
// Parse stanza into a hashmap of strings, the fields
|
||||
let mut fields: HashMap<String, String> = HashMap::new();
|
||||
let mut current_key = String::new();
|
||||
|
||||
for line in stanza.lines() {
|
||||
if line.is_empty() {
|
||||
for line in stanza.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
// Continuation line
|
||||
if let Some(val) = fields.get_mut(¤t_key) {
|
||||
val.push('\n');
|
||||
val.push_str(line.trim());
|
||||
}
|
||||
} else if let Some((key, value)) = line.split_once(':') {
|
||||
current_key = key.trim().to_string();
|
||||
fields.insert(current_key.clone(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(package) = fields.get("Package") else {
|
||||
// Skip empty stanza
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let package = package.to_string();
|
||||
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
// Continuation line
|
||||
if let Some(val) = fields.get_mut(¤t_key) {
|
||||
val.push('\n');
|
||||
val.push_str(line.trim());
|
||||
}
|
||||
} else if let Some((key, value)) = line.split_once(':') {
|
||||
current_key = key.trim().to_string();
|
||||
fields.insert(current_key.clone(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
// A stanza without a version is malformed remote data: skip it
|
||||
// rather than panicking
|
||||
let Some(version) = fields.get("Version") else {
|
||||
debug!(
|
||||
"Skipping malformed stanza for package '{}' without a 'Version' field",
|
||||
package
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let version = version.to_string();
|
||||
|
||||
let pkg = fields.get("Package");
|
||||
if pkg.is_none() {
|
||||
// Skip empty stanza
|
||||
return self.next();
|
||||
}
|
||||
|
||||
// Parse package files.
|
||||
// Prefer the strongest available checksum field: Checksums-Sha256,
|
||||
// then Checksums-Sha512, then the legacy 'Files' (MD5) field.
|
||||
// Some archives (e.g. the Ubuntu development series) no longer ship
|
||||
// Checksums-Sha256, so falling back is required to keep working.
|
||||
let mut files = Vec::new();
|
||||
let (checksum_field, algo) = if fields.contains_key("Checksums-Sha256") {
|
||||
("Checksums-Sha256", ChecksumAlgo::Sha256)
|
||||
} else if fields.contains_key("Checksums-Sha512") {
|
||||
("Checksums-Sha512", ChecksumAlgo::Sha512)
|
||||
} else {
|
||||
("Files", ChecksumAlgo::Md5)
|
||||
};
|
||||
if let Some(checksums) = fields.get(checksum_field) {
|
||||
for line in checksums.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
files.push(FileEntry {
|
||||
checksum: parts[0].to_string(),
|
||||
size: parts[1].parse().unwrap_or(0),
|
||||
name: parts[2].to_string(),
|
||||
checksum_algo: algo,
|
||||
});
|
||||
// Parse package files.
|
||||
// Prefer the strongest available checksum field: Checksums-Sha256,
|
||||
// then Checksums-Sha512, then the legacy 'Files' (MD5) field.
|
||||
// Some archives (e.g. the Ubuntu development series) no longer ship
|
||||
// Checksums-Sha256, so falling back is required to keep working.
|
||||
let mut files = Vec::new();
|
||||
let (checksum_field, algo) = if fields.contains_key("Checksums-Sha256") {
|
||||
("Checksums-Sha256", ChecksumAlgo::Sha256)
|
||||
} else if fields.contains_key("Checksums-Sha512") {
|
||||
("Checksums-Sha512", ChecksumAlgo::Sha512)
|
||||
} else {
|
||||
("Files", ChecksumAlgo::Md5)
|
||||
};
|
||||
if let Some(checksums) = fields.get(checksum_field) {
|
||||
for line in checksums.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
files.push(FileEntry {
|
||||
checksum: parts[0].to_string(),
|
||||
size: parts[1].parse().unwrap_or(0),
|
||||
name: parts[2].to_string(),
|
||||
checksum_algo: algo,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Vcs-Git field: it may contain just a URL, or URL followed by -b <branch>
|
||||
// e.g., "https://salsa.debian.org/science-team/paraview.git -b debian/latest"
|
||||
let vcs_git = fields.get("Vcs-Git").map(|vcs| {
|
||||
// Split on whitespace and take the first part (the URL)
|
||||
// The URL should not contain spaces, so this is safe
|
||||
vcs.split_whitespace().next().unwrap_or(vcs).to_string()
|
||||
});
|
||||
|
||||
return Some(PackageStanza {
|
||||
package,
|
||||
version,
|
||||
directory: fields.get("Directory").cloned().unwrap_or_default(),
|
||||
format: fields
|
||||
.get("Format")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "1.0".to_string()),
|
||||
vcs_git,
|
||||
vcs_browser: fields.get("Vcs-Browser").cloned(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Vcs-Git field: it may contain just a URL, or URL followed by -b <branch>
|
||||
// e.g., "https://salsa.debian.org/science-team/paraview.git -b debian/latest"
|
||||
let vcs_git = fields.get("Vcs-Git").map(|vcs| {
|
||||
// Split on whitespace and take the first part (the URL)
|
||||
// The URL should not contain spaces, so this is safe
|
||||
vcs.split_whitespace().next().unwrap_or(vcs).to_string()
|
||||
});
|
||||
|
||||
Some(PackageStanza {
|
||||
package: fields.get("Package").unwrap().to_string(),
|
||||
version: fields.get("Version").unwrap().to_string(),
|
||||
directory: fields.get("Directory").cloned().unwrap_or_default(),
|
||||
format: fields
|
||||
.get("Format")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "1.0".to_string()),
|
||||
vcs_git,
|
||||
vcs_browser: fields.get("Vcs-Browser").cloned(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,21 +377,28 @@ async fn get(
|
||||
}
|
||||
debug!("Found components: {:?}", components);
|
||||
|
||||
// Collect the failures of individual fetch attempts so that, if the
|
||||
// package is not found, the final error explains what actually went
|
||||
// wrong instead of misleadingly claiming a plain 'not found'
|
||||
let mut fetch_errors: Vec<String> = Vec::new();
|
||||
|
||||
for component in components {
|
||||
let url = crate::distro_info::get_sources_url(&base_url, series, pocket, &component);
|
||||
|
||||
debug!("Fetching sources from: {}", url);
|
||||
|
||||
let response = match reqwest::get(&url).await {
|
||||
let response = match crate::distro_info::http_client().get(&url).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
debug!("Failed to fetch {}: {}", url, e);
|
||||
fetch_errors.push(format!("{suite}/{component}: {}", e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
debug!("Failed to fetch {}: status {}", url, response.status());
|
||||
fetch_errors.push(format!("{suite}/{component}: HTTP {}", response.status()));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -417,9 +440,14 @@ async fn get(
|
||||
}
|
||||
}
|
||||
|
||||
let details = if fetch_errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (last errors: {})", fetch_errors.join("; "))
|
||||
};
|
||||
Err(format!(
|
||||
"Package '{}' not found in {}/{}",
|
||||
package_name, dist, series
|
||||
"Package '{}' not found in {}/{}{}",
|
||||
package_name, dist, series, details
|
||||
)
|
||||
.into())
|
||||
}
|
||||
@@ -436,6 +464,11 @@ async fn find_package(
|
||||
) -> Result<PackageInfo, Box<dyn Error>> {
|
||||
let series_list = crate::distro_info::get_ordered_series_name(dist).await?;
|
||||
|
||||
// Collect the failures of the individual series/pocket probes so that,
|
||||
// if nothing is found, the final error summarizes what went wrong
|
||||
// (e.g. network errors, HTTP statuses) instead of a bare 'not found'
|
||||
let mut attempt_errors: Vec<String> = Vec::new();
|
||||
|
||||
for (i, series) in series_list.iter().enumerate() {
|
||||
if let Some(cb) = progress {
|
||||
cb("", &format!("Checking {}...", series), i, series_list.len());
|
||||
@@ -486,13 +519,32 @@ async fn find_package(
|
||||
if e.downcast_ref::<release::VerifyError>().is_some() {
|
||||
return Err(e);
|
||||
}
|
||||
// Remember the failure for the final error message, and
|
||||
// keep probing the other series/pockets
|
||||
let suite = if p.is_empty() {
|
||||
series.clone()
|
||||
} else {
|
||||
format!("{series}-{p}")
|
||||
};
|
||||
attempt_errors.push(format!("{}: {}", suite, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Package '{}' not found.", package_name).into())
|
||||
// Keep only the last few attempts so the message stays readable
|
||||
if attempt_errors.len() > 5 {
|
||||
let drain_to = attempt_errors.len() - 5;
|
||||
attempt_errors.drain(..drain_to);
|
||||
}
|
||||
let details = if attempt_errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (last errors: {})", attempt_errors.join("; "))
|
||||
};
|
||||
|
||||
Err(format!("Package '{}' not found.{}", package_name, details).into())
|
||||
}
|
||||
|
||||
/// Fetch the 'Release' file at the root of a flat repository, and return its suite name
|
||||
@@ -502,7 +554,7 @@ async fn find_package(
|
||||
/// read from the 'Codename' field, falling back to 'Suite'.
|
||||
async fn get_flat_repo_series(base_url: &str) -> Result<String, Box<dyn Error>> {
|
||||
let url = format!("{}/Release", base_url.trim_end_matches('/'));
|
||||
let response = reqwest::get(&url).await?;
|
||||
let response = crate::distro_info::http_client().get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"No Release file at '{}' (HTTP {}) - is '{}' the suite URL of a flat repository? \
|
||||
@@ -540,7 +592,7 @@ async fn get_flat_repo_sources(
|
||||
let mut errors = Vec::new();
|
||||
for name in ["Sources.xz", "Sources.gz", "Sources"] {
|
||||
let url = format!("{base}/{name}");
|
||||
match reqwest::get(&url).await {
|
||||
match crate::distro_info::http_client().get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let data = response.bytes().await?.to_vec();
|
||||
|
||||
@@ -860,6 +912,44 @@ Directory: pool/main/h/hello
|
||||
assert_eq!(info.version, "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sources_many_blank_stanzas() {
|
||||
// A crafted index with many consecutive package-less stanzas must be
|
||||
// iterated without recursion: 100k blank stanzas would overflow the
|
||||
// stack with the old recursive 'return self.next()' implementation
|
||||
let mut data = String::new();
|
||||
for _ in 0..100_000 {
|
||||
data.push_str("Not-Really-Package: x\n\n");
|
||||
}
|
||||
data.push_str("Package: hello\nVersion: 1.0\n");
|
||||
|
||||
let info = parse_sources(data.as_bytes(), "hello", None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(info.package, "hello");
|
||||
assert_eq!(info.version, "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sources_stanza_without_version() {
|
||||
// A stanza with a 'Package' but no 'Version' field is malformed
|
||||
// remote data: it must be skipped rather than panic
|
||||
let data = "Package: noversion\nDirectory: pool/main/n/noversion\n\n\
|
||||
Package: hello\nVersion: 1.0\n";
|
||||
|
||||
let info = parse_sources(data.as_bytes(), "hello", None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(info.package, "hello");
|
||||
assert_eq!(info.version, "1.0");
|
||||
|
||||
assert!(
|
||||
parse_sources(data.as_bytes(), "noversion", None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_package_fallback() {
|
||||
// python2.7 is in bullseye but not above
|
||||
|
||||
Reference in New Issue
Block a user