build: parse .dsc checksum fields through one shared, validating parser

The source-build pipeline and the binNMU metadata path had drifted into
two inline parsers with different acceptance rules: binary.rs filled
names from any line with a third column but partials only from
exactly-three-column lines, so a 4+ column Checksums line made
&partials[name] panic by map index. Both paths now share one parser
that accepts the modern 3-column and the legacy 5-column Files layout,
rejects anything else with an error naming the field and line, and all
remaining lookups go through .get() with a clear error instead of
indexing. Legacy 5-column Files md5s were previously attributed to the
section token instead of the file name.
This commit is contained in:
2026-09-16 03:20:01 +02:00
parent f72b35acfa
commit dc6a019a13
2 changed files with 275 additions and 67 deletions
+127 -41
View File
@@ -18,6 +18,8 @@ use crate::debian::{
ChecksumEntry, ControlInfo, FileChecksums, FilesList, parse_changelog_entry_from_str,
};
use super::parse_checksum_field;
/// Digests of one artifact.
#[derive(Debug, Clone, Default)]
struct ArtifactHashes {
@@ -393,41 +395,29 @@ fn include_dsc_artifacts(
.next()
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
// Names and partial checksums are filled from the very same validated
// lines, so a listed name can never miss its checksum entry.
// Distribution order follows the Checksums fields (Checksums-Sha1 then
// Checksums-Sha256), like dpkg-genchanges; the `Files` field only
// supplements the md5 digests.
let mut names: Vec<String> = Vec::new();
let mut partials: BTreeMap<String, PartialDscChecksums> = BTreeMap::new();
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
if let Some(value) = para.get(field) {
for line in value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() != 3 {
continue;
}
let slot = partials.entry(tokens[2].to_string()).or_default();
if field == "Checksums-Sha1" {
slot.sha1 = Some(tokens[0].to_string());
} else {
slot.sha256 = Some(tokens[0].to_string());
}
slot.size = tokens[1].parse().ok().or(slot.size);
let mut partials: BTreeMap<String, super::PartialChecksum> = BTreeMap::new();
for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] {
let Some(value) = para.get(field) else {
continue;
};
for cl in parse_checksum_field(field, value)
.map_err(|e| format!("cannot parse '{dsc_name}': {e}"))?
{
let slot = partials.entry(cl.name.clone()).or_default();
match field {
"Checksums-Sha1" => slot.sha1 = Some(cl.digest),
"Checksums-Sha256" => slot.sha256 = Some(cl.digest),
_ => slot.md5 = Some(cl.digest),
}
}
}
if let Some(files_value) = para.get("Files") {
for line in files_value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() >= 3 {
let slot = partials.entry(tokens[2].to_string()).or_default();
slot.md5 = Some(tokens[0].to_string());
slot.size = tokens[1].parse().ok().or(slot.size);
}
}
}
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
if let Some(value) = para.get(field) {
for line in value.lines() {
if let Some(name) = line.split_whitespace().nth(2) {
names.push(name.to_string());
}
slot.size = Some(cl.size);
if field != "Files" && !names.contains(&cl.name) {
names.push(cl.name);
}
}
}
@@ -451,7 +441,9 @@ fn include_dsc_artifacts(
if name == dsc_name {
continue;
}
let p = &partials[name];
let p = partials
.get(name)
.ok_or_else(|| format!("file '{name}' listed in '{dsc_name}' has no checksum entry"))?;
checksums.insert_entry(
name,
ChecksumEntry {
@@ -465,11 +457,105 @@ fn include_dsc_artifacts(
Ok(())
}
/// Partially-known checksums taken from a `.dsc` checksum field.
#[derive(Debug, Default)]
struct PartialDscChecksums {
size: Option<u64>,
md5: Option<String>,
sha1: Option<String>,
sha256: Option<String>,
#[cfg(test)]
mod tests {
use super::*;
/// A minimal previous-version `.dsc` with a 3-column Checksums-Sha1
/// field, a 4-column Checksums-Sha256 line and a 3-column `Files`.
/// Regression: the old code filled `names` from any line with a third
/// column but `partials` only from exactly-3-column lines, so the
/// "bogus" name landed in `names` alone and `&partials["bogus"]`
/// panicked. It must produce a build error instead.
#[test]
fn dsc_four_column_checksum_line_errors_instead_of_panicking() {
let dsc_name = "hello_1.0-1.dsc";
let dsc = "\
Format: 3.0 (native)
Source: hello
Binary: hello
Architecture: any
Version: 1.0-1
Maintainer: A B <a@b.c>
Checksums-Sha1:
aaa111 12 hello_1.0.orig.tar.xz
Checksums-Sha256:
bbb222 12 bogus hello_1.0-1.debian.tar.xz
Files:
ddd333 12 hello_1.0.orig.tar.xz
";
let base = tempfile::tempdir().expect("tempdir");
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
let ctx = Arc::new(
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
);
let mut checksums = FileChecksums::new();
let err = include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
.expect_err("malformed Checksums-Sha256 line must fail the build");
let err = err.to_string();
assert!(err.contains("Checksums-Sha256"), "{err}");
assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}");
}
/// Happy path: tarball entries are assembled from the Checksums fields
/// (sha1/sha256) and merged with the legacy 5-column `Files` md5, in
/// Checksums-Sha1 order, with the `.dsc` itself hashed fresh first.
#[test]
fn include_dsc_artifacts_merges_legacy_files_layout() {
let dsc_name = "hello_1.0-1.dsc";
let tarball = "hello_1.0.orig.tar.xz";
let dsc = "\
Format: 3.0 (quilt)
Source: hello
Binary: hello
Architecture: any
Version: 1.0-1
Maintainer: A B <a@b.c>
Checksums-Sha1:
aaa111 12 hello_1.0.orig.tar.xz
Checksums-Sha256:
bbb222 12 hello_1.0.orig.tar.xz
Files:
ddd333 12 devel optional hello_1.0.orig.tar.xz
";
let base = tempfile::tempdir().expect("tempdir");
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
std::fs::write(base.path().join(tarball), "tarball bytes").expect("write tarball");
let ctx = Arc::new(
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
);
let mut checksums = FileChecksums::new();
include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
.expect("valid dsc must parse");
let collected: Vec<(String, crate::debian::ChecksumEntry)> = checksums
.iter()
.map(|(k, e)| (k.clone(), e.clone()))
.collect();
assert_eq!(
collected
.iter()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>(),
vec![dsc_name, tarball],
".dsc first, then Checksums-Sha1 order"
);
// The .dsc is hashed fresh from disk.
let dsc_entry = &collected[0].1;
assert_eq!(dsc_entry.size, dsc.len() as u64);
assert_eq!(dsc_entry.md5.len(), 32);
assert_eq!(dsc_entry.sha1.len(), 40);
assert_eq!(dsc_entry.sha256.len(), 64);
// The tarball reuses the .dsc-recorded digests, including the
// legacy 5-column `Files` md5 (section/priority skipped).
let tar_entry = &collected[1].1;
assert_eq!(tar_entry.size, 12);
assert_eq!(tar_entry.md5, "ddd333");
assert_eq!(tar_entry.sha1, "aaa111");
assert_eq!(tar_entry.sha256, "bbb222");
}
}