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
+148 -26
View File
@@ -384,33 +384,26 @@ pub fn run_source_build(
let mut tarball_paths = Vec::new();
let mut dsc_file_names: Vec<String> = Vec::new();
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
if let Some(value) = dsc_para.get(field) {
for line in value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() != 3 {
continue;
}
if !dsc_files.contains_key(tokens[2]) {
dsc_file_names.push(tokens[2].to_string());
}
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
match field {
"Checksums-Sha1" => slot.sha1 = Some(tokens[0].to_string()),
_ => slot.sha256 = Some(tokens[0].to_string()),
}
slot.size = tokens[1].parse().ok().or(slot.size);
// Distribution order follows the Checksums fields (Checksums-Sha1 then
// Checksums-Sha256), like dpkg-genchanges; the `Files` field only
// supplements the md5 digests.
for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] {
let Some(value) = dsc_para.get(field) else {
continue;
};
for cl in parse_checksum_field(field, value)
.map_err(|e| format!("cannot parse '{}': {e}", ref_dsc_path.display()))?
{
if !dsc_files.contains_key(&cl.name) {
dsc_file_names.push(cl.name.clone());
}
}
}
if let Some(files_value) = dsc_para.get("Files") {
for line in files_value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() >= 3 {
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
slot.md5 = Some(tokens[0].to_string());
slot.size = tokens[1].parse().ok().or(slot.size);
let slot = dsc_files.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),
}
slot.size = Some(cl.size);
}
}
for name in &dsc_file_names {
@@ -426,7 +419,12 @@ pub fn run_source_build(
)
.into());
}
let partial = &dsc_files[name];
let partial = dsc_files.get(name).ok_or_else(|| {
format!(
"file '{name}' listed in '{}' has no checksum entry",
ref_dsc_path.display()
)
})?;
checksums.insert_entry(
name,
ChecksumEntry {
@@ -546,6 +544,63 @@ struct PartialChecksum {
sha256: Option<String>,
}
/// One validated line of a `Checksums-Sha1` / `Checksums-Sha256` / `Files`
/// field body.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChecksumLine {
/// Digest as written in the first column.
digest: String,
/// File size in bytes.
size: u64,
/// File name (last column of the line).
name: String,
}
/// Parse the body of a `Checksums-Sha1` / `Checksums-Sha256` / `Files` field
/// (one file per line) into validated entries. Shared by the source-build
/// pipeline and the binary-only metadata generation so both accept exactly
/// the same lines.
///
/// Both layouts are accepted, detected by column count:
/// - 3 columns: `<digest> <size> <name>` (modern `Checksums-*` fields and
/// the `Files` field of freshly built `.dsc`/`.changes`),
/// - 5 columns: `<digest> <size> <section> <priority> <name>` (legacy
/// `Files` fields, where the name is the last token).
///
/// Any other line (notably 4 columns) or a non-numeric size is a malformed
/// line and yields an error naming `field` and the offending line.
fn parse_checksum_field(field: &str, value: &str) -> Result<Vec<ChecksumLine>, String> {
let mut entries = Vec::new();
for line in value.lines() {
if line.trim().is_empty() {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
let (digest, size, name) = match tokens.as_slice() {
[digest, size, name] => (*digest, *size, *name),
// Legacy 5-column `Files` layout: digest size section priority name.
[digest, size, _section, _priority, name] => (*digest, *size, *name),
_ => {
return Err(format!(
"malformed '{field}' line (expected 'checksum size name' \
or legacy 'checksum size section priority name', got {} \
columns): '{line}'",
tokens.len()
));
}
};
let size: u64 = size.parse().map_err(|_| {
format!("malformed '{field}' line (size '{size}' is not a number): '{line}'")
})?;
entries.push(ChecksumLine {
digest: digest.to_string(),
size,
name: name.to_string(),
});
}
Ok(entries)
}
/// Run a build command in `cwd` with extra environment variables layered on
/// top of the inherited environment.
///
@@ -633,6 +688,73 @@ mod tests {
let v = DebianVersion::parse("1.0-2").unwrap();
assert_eq!(v.no_epoch(), "1.0-2");
}
#[test]
fn checksum_field_parses_three_column_lines() {
let entries = parse_checksum_field(
"Checksums-Sha256",
" aaa111 12 hello_1.0.orig.tar.xz\n bbb222 3 hello_1.0-1.debian.tar.xz",
)
.expect("valid 3-column field");
assert_eq!(
entries,
vec![
ChecksumLine {
digest: "aaa111".into(),
size: 12,
name: "hello_1.0.orig.tar.xz".into(),
},
ChecksumLine {
digest: "bbb222".into(),
size: 3,
name: "hello_1.0-1.debian.tar.xz".into(),
},
]
);
}
#[test]
fn checksum_field_parses_legacy_five_column_files() {
// Old archive .dsc/.changes carry `Files` as
// md5 size section priority name.
let entries = parse_checksum_field(
"Files",
" d111 100 editors optional hello_1.0.orig.tar.gz\n \
d222 55 web optional hello_1.0-1.diff.gz",
)
.expect("valid legacy 5-column field");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].digest, "d111");
assert_eq!(entries[0].size, 100);
assert_eq!(entries[0].name, "hello_1.0.orig.tar.gz");
assert_eq!(entries[1].name, "hello_1.0-1.diff.gz");
}
#[test]
fn checksum_field_rejects_four_column_line() {
let err = parse_checksum_field(
"Checksums-Sha256",
" aaa111 12 hello_1.0.orig.tar.xz\n ccc333 12 bogus hello_1.0-1.debian.tar.xz",
)
.expect_err("4-column line must be rejected");
assert!(err.contains("Checksums-Sha256"), "{err}");
assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}");
}
#[test]
fn checksum_field_rejects_non_numeric_size() {
let err = parse_checksum_field("Files", " d111 twelve hello.tar.xz")
.expect_err("non-numeric size must be rejected");
assert!(err.contains("twelve"), "{err}");
}
#[test]
fn checksum_field_skips_blank_lines() {
let entries = parse_checksum_field("Files", "\n d111 100 hello.tar.xz\n\n")
.expect("blank lines are ignored");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "hello.tar.xz");
}
}
/// Differential tests: build synthetic (or real archive) source packages