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.
This commit is contained in:
2026-09-16 03:42:30 +02:00
parent dc6a019a13
commit 38562abe2c
4 changed files with 151 additions and 14 deletions
+6 -4
View File
@@ -390,10 +390,12 @@ fn include_dsc_artifacts(
checksums: &mut FileChecksums, checksums: &mut FileChecksums,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?; let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?;
let para = crate::debian::control::parse_paragraphs(&dsc_content) let para = crate::debian::control::parse_paragraphs(
.into_iter() crate::debian::control::strip_clearsigned_armour(&dsc_content),
.next() )
.ok_or_else(|| format!("'{dsc_name}' is empty"))?; .into_iter()
.next()
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
// Names and partial checksums are filled from the very same validated // Names and partial checksums are filled from the very same validated
// lines, so a listed name can never miss its checksum entry. // lines, so a listed name can never miss its checksum entry.
+6 -4
View File
@@ -376,10 +376,12 @@ pub fn run_source_build(
// order the .dsc itself lists them. // order the .dsc itself lists them.
let dsc_content = std::fs::read_to_string(&ref_dsc_path) let dsc_content = std::fs::read_to_string(&ref_dsc_path)
.map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?; .map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?;
let dsc_para = parse_paragraphs(&dsc_content) let dsc_para = parse_paragraphs(crate::debian::control::strip_clearsigned_armour(
.into_iter() &dsc_content,
.next() ))
.ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?; .into_iter()
.next()
.ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?;
let mut tarball_paths = Vec::new(); let mut tarball_paths = Vec::new();
let mut dsc_file_names: Vec<String> = Vec::new(); let mut dsc_file_names: Vec<String> = Vec::new();
+136 -5
View File
@@ -12,7 +12,9 @@ use std::path::Path;
/// ///
/// Values are stored with continuation-line breaks as `\n` and without the /// Values are stored with continuation-line breaks as `\n` and without the
/// leading whitespace of continuation lines. Serialization re-adds a single /// 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)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct Paragraph { pub struct Paragraph {
fields: Vec<(String, String)>, fields: Vec<(String, String)>,
@@ -66,7 +68,9 @@ impl Paragraph {
/// ///
/// Comment lines (starting with `#`) are ignored. Blank lines separate /// Comment lines (starting with `#`) are ignored. Blank lines separate
/// paragraphs. Continuation lines must start with a space or a tab; exactly /// 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<Paragraph> { pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
let mut paragraphs = Vec::new(); let mut paragraphs = Vec::new();
let mut current = Paragraph::new(); let mut current = Paragraph::new();
@@ -89,7 +93,14 @@ pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
// Continuation line // Continuation line
if line.starts_with(' ') || line.starts_with('\t') { 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 if let Some(field) = &last_field
&& let Some((_, v)) = current && let Some((_, v)) = current
.fields .fields
@@ -147,14 +158,65 @@ pub fn write_paragraph(p: &Paragraph) -> String {
} }
for line in lines { for line in lines {
out.push('\n'); out.push('\n');
out.push(' '); if line.is_empty() {
out.push_str(line); // 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.push('\n');
} }
out 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -197,6 +259,75 @@ mod tests {
assert_eq!(reparsed[0].get("Description"), Some(value)); 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] #[test]
fn set_replaces_case_insensitive() { fn set_replaces_case_insensitive() {
let mut p = Paragraph::new(); let mut p = Paragraph::new();
+3 -1
View File
@@ -24,6 +24,8 @@ pub use changelog::{
parse_previous_version_from_str, parse_previous_version_from_str,
}; };
pub use checksums::{Entry as ChecksumEntry, FileChecksums}; 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 files::{FilesEntry, FilesList};
pub use version::DebianVersion; pub use version::DebianVersion;