build,debian: extract reusable Debian format primitives from build/
Move the generic components out of src/build/ into a new src/debian/ module so they can be reused independently of the build pipeline: deb822 control parsing (plus the debian/control model), file checksum registry, debian/files registry, Debian version handling and changelog entry parsing. Merge OpenPGP clearsigning into utils/gpg.rs next to the existing key discovery helper, making signing available outside of builds. Delegate changelog.rs header/footer parsing to the new debian::changelog parser, removing the duplicate regex implementation. src/build/ keeps only build-specific logic: the pipeline driver, build types, environment setup and the .buildinfo/.changes writers.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
//! Debian control-file handling: a minimal deb822 paragraph parser/writer
|
||||
//! plus a `debian/control` model.
|
||||
//!
|
||||
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
|
||||
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
|
||||
//! separated by blank lines, `Field: value` entries with continuation lines
|
||||
//! starting by a single space or tab, and `#` comments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
|
||||
///
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
/// Create an empty paragraph.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a field value (case-insensitive field name).
|
||||
pub fn get(&self, field: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Set a field value, replacing any previous occurrence (case-insensitive).
|
||||
/// Appends the field at the end if it did not exist yet.
|
||||
pub fn set(&mut self, field: &str, value: &str) {
|
||||
for (k, v) in self.fields.iter_mut() {
|
||||
if k.eq_ignore_ascii_case(field) {
|
||||
*v = value.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.fields.push((field.to_string(), value.to_string()));
|
||||
}
|
||||
|
||||
/// Remove a field (case-insensitive). Returns true if it was present.
|
||||
pub fn remove(&mut self, field: &str) -> bool {
|
||||
let before = self.fields.len();
|
||||
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
|
||||
self.fields.len() != before
|
||||
}
|
||||
|
||||
/// Iterate over the `(field, value)` pairs in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
}
|
||||
|
||||
/// Return true if the paragraph holds no field.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a deb822 document into a list of paragraphs.
|
||||
///
|
||||
/// 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.
|
||||
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
let mut last_field: Option<String> = None;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
|
||||
// Comments and blank lines
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(std::mem::take(&mut current));
|
||||
last_field = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
let content = line.strip_prefix(' ').unwrap_or(line);
|
||||
if let Some(field) = &last_field {
|
||||
if let Some((_, v)) = current
|
||||
.fields
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
{
|
||||
v.push('\n');
|
||||
v.push_str(content);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Continuation without a preceding field line: skip it (malformed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field line: `Name: value`
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim();
|
||||
let value = line[colon + 1..].trim_start();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current.fields.push((name.to_string(), value.to_string()));
|
||||
last_field = Some(name.to_string());
|
||||
}
|
||||
// Anything else is malformed: ignore the line
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(current);
|
||||
}
|
||||
|
||||
paragraphs
|
||||
}
|
||||
|
||||
/// Serialize a paragraph to its deb822 textual representation (with a
|
||||
/// trailing newline).
|
||||
///
|
||||
/// A value starting with `\n` is rendered as a field with no inline first
|
||||
/// line (`Field:` followed by ` line` continuations), matching dpkg output
|
||||
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
|
||||
pub fn write_paragraph(p: &Paragraph) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, value) in p.iter() {
|
||||
out.push_str(name);
|
||||
out.push(':');
|
||||
let mut lines = value.split('\n').peekable();
|
||||
// An empty first segment means: no value on the field header line;
|
||||
// discard it so it is not rendered as an empty continuation line.
|
||||
if lines.peek().is_some_and(|first| !first.is_empty()) {
|
||||
out.push(' ');
|
||||
out.push_str(lines.next().unwrap());
|
||||
} else {
|
||||
lines.next();
|
||||
}
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_control() {
|
||||
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
|
||||
let paras = parse_paragraphs(input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
assert_eq!(paras[0].get("section"), Some("devel"));
|
||||
assert_eq!(paras[1].get("Package"), Some("hello"));
|
||||
assert_eq!(paras[1].get("Depends"), Some("libc6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiline_and_comments() {
|
||||
let input = "# a comment\nDescription: short\n long description\n" //
|
||||
.to_string() //
|
||||
+ " spanning lines\n\nPackage: x\n";
|
||||
let paras = parse_paragraphs(&input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(
|
||||
paras[0].get("Description"),
|
||||
Some("short\nlong description\nspanning lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multiline() {
|
||||
let value = "short\nlong description\nspanning lines";
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", value);
|
||||
let text = write_paragraph(&p);
|
||||
assert_eq!(text, "Description: short\n long description\n spanning lines\n");
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_case_insensitive() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Source", "a");
|
||||
p.set("source", "b");
|
||||
assert_eq!(p.get("SOURCE"), Some("b"));
|
||||
assert_eq!(p.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_field() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("A", "1");
|
||||
assert!(p.remove("a"));
|
||||
assert!(!p.remove("a"));
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlInfo {
|
||||
/// First paragraph (source package stanza).
|
||||
pub source: Paragraph,
|
||||
/// Remaining paragraphs (binary package stanzas).
|
||||
pub binaries: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
/// Parse a `debian/control` file.
|
||||
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
|
||||
Self::from_str(&content)
|
||||
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse control content from a string.
|
||||
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
|
||||
let paragraphs = parse_paragraphs(content);
|
||||
let mut iter = paragraphs.into_iter();
|
||||
let source = iter
|
||||
.next()
|
||||
.ok_or_else(|| "control file has no paragraphs".to_string())?;
|
||||
if source.get("Source").is_none() {
|
||||
return Err("first control paragraph has no 'Source' field".to_string());
|
||||
}
|
||||
let binaries: Vec<Paragraph> = iter.collect();
|
||||
for bin in &binaries {
|
||||
if bin.get("Package").is_none() {
|
||||
return Err("binary control paragraph has no 'Package' field".to_string());
|
||||
}
|
||||
}
|
||||
Ok(ControlInfo { source, binaries })
|
||||
}
|
||||
|
||||
/// The source package name.
|
||||
pub fn source_name(&self) -> &str {
|
||||
self.source.get("Source").expect("checked at parse")
|
||||
}
|
||||
|
||||
/// Section from the source stanza, or `'-'`.
|
||||
pub fn section(&self) -> &str {
|
||||
self.source.get("Section").unwrap_or("-")
|
||||
}
|
||||
|
||||
/// Priority from the source stanza, or `'-'`.
|
||||
pub fn priority(&self) -> &str {
|
||||
self.source.get("Priority").unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_info_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_parsing() {
|
||||
let ci = ControlInfo::from_str(
|
||||
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ci.source_name(), "hello");
|
||||
assert_eq!(ci.section(), "utils");
|
||||
assert_eq!(ci.priority(), "optional");
|
||||
assert_eq!(ci.binaries.len(), 1);
|
||||
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_defaults() {
|
||||
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
|
||||
assert_eq!(ci.section(), "-");
|
||||
assert_eq!(ci.priority(), "-");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user