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:
+3
-1
@@ -390,7 +390,9 @@ fn include_dsc_artifacts(
|
||||
checksums: &mut FileChecksums,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
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(
|
||||
crate::debian::control::strip_clearsigned_armour(&dsc_content),
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
|
||||
|
||||
+3
-1
@@ -376,7 +376,9 @@ 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)
|
||||
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()))?;
|
||||
|
||||
+134
-3
@@ -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<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
@@ -89,7 +93,14 @@ pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
|
||||
// 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');
|
||||
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();
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user