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:
+6
-4
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user