From 38562abe2c8b65d8962ac244a8ca049998b925e9 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 03:42:30 +0200 Subject: [PATCH] debian: fix deb822 writer/parser asymmetries write_paragraph emitted a bare-space continuation line for empty lines inside a value, which parse_paragraphs treated as a paragraph separator and silently dropped the rest of the value; blank lines are now encoded as ' .' like dpkg does and decoded back on read. Tab-indented continuation lines now strip exactly one tab instead of keeping it. Clearsigned .dsc content no longer leaks armor metadata into parsed fields: the Hash:/Comment: header and the signature trailer are stripped before parse_paragraphs at both .dsc parse sites. --- src/build/binary.rs | 10 +-- src/build/mod.rs | 10 +-- src/debian/control.rs | 141 ++++++++++++++++++++++++++++++++++++++++-- src/debian/mod.rs | 4 +- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/build/binary.rs b/src/build/binary.rs index 65e3ea7..83b565e 100644 --- a/src/build/binary.rs +++ b/src/build/binary.rs @@ -390,10 +390,12 @@ fn include_dsc_artifacts( checksums: &mut FileChecksums, ) -> Result<(), Box> { let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?; - let para = crate::debian::control::parse_paragraphs(&dsc_content) - .into_iter() - .next() - .ok_or_else(|| format!("'{dsc_name}' is empty"))?; + let para = crate::debian::control::parse_paragraphs( + crate::debian::control::strip_clearsigned_armour(&dsc_content), + ) + .into_iter() + .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. diff --git a/src/build/mod.rs b/src/build/mod.rs index c9aa47a..ad8b215 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -376,10 +376,12 @@ pub fn run_source_build( // order the .dsc itself lists them. let dsc_content = std::fs::read_to_string(&ref_dsc_path) .map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?; - let dsc_para = parse_paragraphs(&dsc_content) - .into_iter() - .next() - .ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?; + let dsc_para = parse_paragraphs(crate::debian::control::strip_clearsigned_armour( + &dsc_content, + )) + .into_iter() + .next() + .ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?; let mut tarball_paths = Vec::new(); let mut dsc_file_names: Vec = Vec::new(); diff --git a/src/debian/control.rs b/src/debian/control.rs index ec8ae50..2096778 100644 --- a/src/debian/control.rs +++ b/src/debian/control.rs @@ -12,7 +12,9 @@ use std::path::Path; /// /// Values are stored with continuation-line breaks as `\n` and without the /// leading whitespace of continuation lines. Serialization re-adds a single -/// leading space in front of every continuation line, matching dpkg output. +/// leading space in front of every continuation line, matching dpkg output; +/// blank lines inside a value are encoded as ` .` (and decoded back) so they +/// survive a write/parse round-trip. #[derive(Debug, Clone, Default, PartialEq)] pub struct Paragraph { fields: Vec<(String, String)>, @@ -66,7 +68,9 @@ impl Paragraph { /// /// Comment lines (starting with `#`) are ignored. Blank lines separate /// paragraphs. Continuation lines must start with a space or a tab; exactly -/// one leading space (or tab) is stripped from the stored value. +/// one leading space (or tab) is stripped from the stored value, and a +/// continuation whose content is a lone `.` decodes to an empty line +/// (dpkg's encoding for blank lines inside field values). pub fn parse_paragraphs(input: &str) -> Vec { let mut paragraphs = Vec::new(); let mut current = Paragraph::new(); @@ -89,7 +93,14 @@ pub fn parse_paragraphs(input: &str) -> Vec { // Continuation line if line.starts_with(' ') || line.starts_with('\t') { - let content = line.strip_prefix(' ').unwrap_or(line); + // Exactly one leading space or tab is stripped. + let content = line + .strip_prefix(' ') + .or_else(|| line.strip_prefix('\t')) + .unwrap_or(line); + // dpkg encodes a blank line inside a value as a lone `.` after + // the leading whitespace; mirror that on read. + let content = if content == "." { "" } else { content }; if let Some(field) = &last_field && let Some((_, v)) = current .fields @@ -147,14 +158,65 @@ pub fn write_paragraph(p: &Paragraph) -> String { } for line in lines { out.push('\n'); - out.push(' '); - out.push_str(line); + if line.is_empty() { + // dpkg encodes a blank line inside a value as ` .`; writing a + // bare continuation line would be mistaken for a paragraph + // separator on re-parse and silently drop the rest. + out.push_str(" ."); + } else { + out.push(' '); + out.push_str(line); + } } out.push('\n'); } out } +/// Return the signed body of a clearsigned message, as a slice of `text`. +/// +/// If `text` starts with the OpenPGP clearsigned-marker line, the armor +/// header block (the `Hash: ...` line and any `Comment:` lines, up to and +/// including the blank line that closes the header) is skipped, and the +/// result is cut at the `-----BEGIN PGP SIGNATURE-----` marker so the +/// signature trailer is dropped as well. This keeps the armor metadata from +/// being parsed as deb822 fields (`Hash:` would otherwise land in the first +/// stanza and `Comment:` in the last one). +/// +/// Input that is not clearsigned is returned unchanged, so callers can apply +/// this unconditionally before parsing. +pub fn strip_clearsigned_armour(text: &str) -> &str { + const BEGIN_SIGNED: &str = "-----BEGIN PGP SIGNED MESSAGE-----"; + const BEGIN_SIGNATURE: &str = "-----BEGIN PGP SIGNATURE-----"; + + if !text.starts_with(BEGIN_SIGNED) { + return text; + } + + // Walk past the armor headers to the blank line that precedes the body. + let mut body = text; + loop { + match body.split_once('\n') { + Some((line, remainder)) => { + body = remainder; + // An empty line ends the armor header block (`\r` covers a + // CRLF-terminated blank line). + if line.is_empty() || line == "\r" { + break; + } + } + // Malformed armor: no body at all. + None => return "", + } + } + + // Cut off the signature block, if present. + match body.find(BEGIN_SIGNATURE) { + Some(i) => &body[..i], + None => body, + } +} + #[cfg(test)] mod tests { use super::*; @@ -197,6 +259,75 @@ mod tests { assert_eq!(reparsed[0].get("Description"), Some(value)); } + #[test] + fn blank_lines_survive_roundtrip() { + let mut p = Paragraph::new(); + p.set("Description", "a\n\nb"); + let text = write_paragraph(&p); + // dpkg encoding: a blank line inside a value is written as ` .`. + assert_eq!(text, "Description: a\n .\n b\n"); + // parse -> write -> parse must not lose data. + let reparsed = parse_paragraphs(&text); + assert_eq!(reparsed[0].get("Description"), Some("a\n\nb")); + assert_eq!( + parse_paragraphs(&write_paragraph(&reparsed[0]))[0].get("Description"), + Some("a\n\nb") + ); + } + + #[test] + fn parse_lone_dot_continuation_is_blank_line() { + let paras = parse_paragraphs("Description:\n a\n .\n b\n"); + assert_eq!(paras[0].get("Description"), Some("\na\n\nb")); + } + + #[test] + fn tab_continuation_strips_exactly_one_tab() { + let paras = parse_paragraphs("Description: a\n\tb\n\t\tdeep\n"); + assert_eq!(paras[0].get("Description"), Some("a\nb\n\tdeep")); + } + + #[test] + fn strip_armour_extracts_signed_dsc_body() { + let signed = "\ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +Format: 3.0 (native) +Source: hello +Binary: hello +Architecture: any +Version: 1.0-1 +Checksums-Sha256: + abc 100 hello_1.0.tar.gz + +-----BEGIN PGP SIGNATURE----- + +iQEcBAABCgAGBQJabcdAAoJEL abc +-----END PGP SIGNATURE----- +"; + let body = strip_clearsigned_armour(signed); + assert!(body.starts_with("Format:")); + assert!(!body.contains("SIGNATURE")); + let paras = parse_paragraphs(body); + assert_eq!(paras.len(), 1); + // The armor `Hash:` header must not land in the stanza... + assert!(paras[0].get("Hash").is_none()); + assert_eq!(paras[0].get("Source"), Some("hello")); + // ...and the signature trailer must not contribute a `Comment:` field. + assert!(paras[0].get("Comment").is_none()); + assert_eq!( + paras[0].get("Checksums-Sha256"), + Some("\nabc 100 hello_1.0.tar.gz") + ); + } + + #[test] + fn strip_armour_passes_unsigned_text_through() { + let plain = "Source: hello\nVersion: 1.0\n"; + assert_eq!(strip_clearsigned_armour(plain), plain); + } + #[test] fn set_replaces_case_insensitive() { let mut p = Paragraph::new(); diff --git a/src/debian/mod.rs b/src/debian/mod.rs index c869fa8..3048f7d 100644 --- a/src/debian/mod.rs +++ b/src/debian/mod.rs @@ -24,6 +24,8 @@ pub use changelog::{ parse_previous_version_from_str, }; pub use checksums::{Entry as ChecksumEntry, FileChecksums}; -pub use control::{ControlInfo, Paragraph, parse_paragraphs, write_paragraph}; +pub use control::{ + ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph, +}; pub use files::{FilesEntry, FilesList}; pub use version::DebianVersion;