build: re-implement source builds natively, drop dpkg-buildpackage shell-out
Replace the 'dpkg-buildpackage -S' wrapper with a native pipeline in src/build/: - deb822 control parser/writer with dpkg-compatible multiline rendering (control.rs) - md5/sha1/sha256 checksum registry, insertion-ordered like dpkg's artifact accumulation (checksums.rs) - Debian version splitting/validation and full changelog entry parsing, including binNMU binary-only entries (metadata.rs) - build-type bitflags and rules-target/artifact-suffix mapping (buildtype.rs) - environment setup: SOURCE_DATE_EPOCH, DEB_BUILD_OPTIONS, dpkg-architecture env dump, vendor default profiles and the sanitized Environment field recorded in .buildinfo (env.rs) - debian/files registry with atomic saves (files.rs) - native .buildinfo writer, including the Installed-Build-Depends closure computed over the dpkg status database (buildinfo.rs) - native .changes writer emitting dpkg's canonical field order with legacy Files + Checksums-Sha1/Sha256 (changes.rs) - gpgme clearsigning with the transitive checksum cascade (dsc -> buildinfo -> changes), key discovery from the changelog maintainer and UNRELEASED no-sign handling (sign.rs) dpkg-source (-b/--before-build/--after-build) intentionally remains a subprocess; debian/rules execution is unchanged. Validated differentially against real dpkg-buildpackage -S -I -i -nc -d on native and 3.0 (quilt) fixture packages: .dsc byte-identical, .changes payload matches modulo machine-dependent Installed-Build-Depends and Environment content, all signatures verify with gpg, artifact ordering and UNRELEASED no-sign behavior match dpkg.
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
//! Native Debian source-package build pipeline.
|
||||
//!
|
||||
//! Re-implements the orchestration performed by `dpkg-buildpackage -S`
|
||||
//! (environment setup, `dpkg-source` lifecycle, `.buildinfo` / `.changes`
|
||||
//! generation and OpenPGP signing) natively in Rust, while delegating the
|
||||
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
|
||||
//! subprocess.
|
||||
|
||||
pub mod buildinfo;
|
||||
pub mod buildtype;
|
||||
pub mod changes;
|
||||
pub mod checksums;
|
||||
pub mod control;
|
||||
pub mod env;
|
||||
pub mod files;
|
||||
pub mod metadata;
|
||||
pub mod sign;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use checksums::{Entry as ChecksumEntry, FileChecksums};
|
||||
use control::parse_paragraphs;
|
||||
use files::{FilesEntry, FilesList};
|
||||
use metadata::ControlInfo;
|
||||
|
||||
/// Options for a native source-package build.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SourceBuildOptions {
|
||||
/// Explicit signing key id / fingerprint (`-k`). When unset, a secret
|
||||
/// key matching the changelog maintainer email is searched.
|
||||
pub sign_keyid: Option<String>,
|
||||
/// Sign even for an UNRELEASED changelog (`--force-sign`).
|
||||
pub force_sign: bool,
|
||||
}
|
||||
|
||||
/// Artifacts produced by a successful source build.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceBuildOutput {
|
||||
/// The generated `.dsc`.
|
||||
pub dsc: PathBuf,
|
||||
/// The generated `.buildinfo`.
|
||||
pub buildinfo: PathBuf,
|
||||
/// The generated `.changes`.
|
||||
pub changes: PathBuf,
|
||||
/// Source tarballs referenced by the `.dsc` (orig, debian tar...).
|
||||
pub tarballs: Vec<PathBuf>,
|
||||
/// Whether all artifacts were signed.
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
||||
///
|
||||
/// Keeps the historical pkh entry-point signature; see [`run_source_build`]
|
||||
/// for the configurable version.
|
||||
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||
let output = run_source_build(cwd, &SourceBuildOptions::default())?;
|
||||
|
||||
if output.signed {
|
||||
println!("Package built and signed successfully!");
|
||||
} else {
|
||||
println!("Package built successfully (unsigned).");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the full native source-build pipeline in `cwd`.
|
||||
///
|
||||
/// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`):
|
||||
/// 1. sanity checks and metadata resolution (changelog, control),
|
||||
/// 2. environment setup (`SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, arch vars),
|
||||
/// 3. signing decision (key discovery, UNRELEASED handling),
|
||||
/// 4. `dpkg-source --before-build` then `dpkg-source -b`,
|
||||
/// 5. native `.buildinfo` generation (+ registration in `debian/files`),
|
||||
/// 6. native `.changes` generation,
|
||||
/// 7. `dpkg-source --after-build`,
|
||||
/// 8. signing cascade: dsc → buildinfo → changes, recomputing checksums of
|
||||
/// already-generated files at each step.
|
||||
pub fn run_source_build(
|
||||
cwd: &Path,
|
||||
opts: &SourceBuildOptions,
|
||||
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Sanity checks
|
||||
// ------------------------------------------------------------------
|
||||
let parent = cwd
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.ok_or_else(|| format!("cannot determine output directory from '{}'", cwd.display()))?
|
||||
.to_path_buf();
|
||||
|
||||
let rules_path = cwd.join("debian/rules");
|
||||
if !rules_path.exists() {
|
||||
return Err(format!(
|
||||
"'{}' not found: '{}' does not look like a Debian source tree",
|
||||
rules_path.display(),
|
||||
cwd.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let changelog_path = cwd.join("debian/changelog");
|
||||
let control_path = cwd.join("debian/control");
|
||||
if !changelog_path.exists() {
|
||||
return Err(format!("'{}' not found", changelog_path.display()).into());
|
||||
}
|
||||
if !control_path.exists() {
|
||||
return Err(format!("'{}' not found", control_path.display()).into());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Metadata resolution
|
||||
// ------------------------------------------------------------------
|
||||
let entry = metadata::parse_changelog_entry(&changelog_path)?;
|
||||
let ctrl = ControlInfo::parse(&control_path)?;
|
||||
|
||||
log::info!("source package {}", entry.source);
|
||||
log::info!("source version {}", entry.version.full());
|
||||
log::info!("source distribution {}", entry.distribution);
|
||||
|
||||
let sversion = entry.version.no_epoch();
|
||||
let dsc_name = format!("{}_{}.dsc", entry.source, sversion);
|
||||
let dsc_path = parent.join(&dsc_name);
|
||||
let buildinfo_name = format!("{}_{}_source.buildinfo", entry.source, sversion);
|
||||
let buildinfo_path = parent.join(&buildinfo_name);
|
||||
let changes_name = format!("{}_{}_source.changes", entry.source, sversion);
|
||||
let changes_path = parent.join(&changes_name);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Environment setup
|
||||
// ------------------------------------------------------------------
|
||||
let vendor = env::current_vendor();
|
||||
let profiles = env::resolve_build_profiles(&[], &vendor);
|
||||
let mut pipeline_env = env::build_env(entry.timestamp, env::num_parallel(), &profiles);
|
||||
|
||||
let arch_vars = env::arch_env(None)?;
|
||||
pipeline_env.extend(arch_vars.clone());
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Signing decision
|
||||
// ------------------------------------------------------------------
|
||||
let mut signing_key = opts.sign_keyid.clone();
|
||||
if signing_key.is_none() {
|
||||
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
||||
Ok(Some(key)) => {
|
||||
log::info!("using GPG key {} for signing", key);
|
||||
signing_key = Some(key);
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"no GPG secret key found for {} <{}>, building without signing",
|
||||
entry.maintainer_name,
|
||||
entry.maintainer_email
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to check for GPG key: {}, building without signing", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let do_sign = match &signing_key {
|
||||
None => false,
|
||||
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
||||
log::warn!("not signing UNRELEASED build; use force_sign to override");
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. dpkg-source lifecycle: before-build + source build
|
||||
// ------------------------------------------------------------------
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "--before-build", "."], &pipeline_env)?;
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?;
|
||||
|
||||
if !dsc_path.exists() {
|
||||
return Err(format!(
|
||||
"dpkg-source did not produce the expected '{}'",
|
||||
dsc_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
||||
|
||||
let status_path = PathBuf::from("/var/lib/dpkg/status");
|
||||
let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
|
||||
let installed_bd = buildinfo::installed_build_depends(&status_path, &bd_fields)?;
|
||||
let environment = env::buildinfo_environment(&pipeline_env);
|
||||
|
||||
let render_buildinfo_doc = |checksums: &FileChecksums| {
|
||||
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
|
||||
source: entry.source.clone(),
|
||||
binaries: Vec::new(), // source-only build
|
||||
architecture: "source".to_string(),
|
||||
version: entry.version.full(),
|
||||
binary_only_changes: None,
|
||||
build_origin: vendor.clone(),
|
||||
build_architecture: arch_vars
|
||||
.get("DEB_BUILD_ARCH")
|
||||
.cloned()
|
||||
.unwrap_or_else(crate::get_current_arch),
|
||||
build_date: chrono::Local::now().to_rfc2822(),
|
||||
checksums: checksums.clone(),
|
||||
installed_build_depends: installed_bd.clone(),
|
||||
environment: environment.clone(),
|
||||
})
|
||||
};
|
||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||
|
||||
// Register the .buildinfo in debian/files (as dpkg-genbuildinfo does).
|
||||
let files_path = cwd.join("debian/files");
|
||||
let mut files_list = FilesList::load(&files_path)?;
|
||||
files_list.retain(|e| {
|
||||
!(e.package.as_deref() == Some(entry.source.as_str())
|
||||
&& e.package_type.as_deref() == Some("buildinfo"))
|
||||
});
|
||||
files_list.add(FilesEntry::new(
|
||||
&buildinfo_name,
|
||||
ctrl.section(),
|
||||
ctrl.priority(),
|
||||
));
|
||||
files_list.save_atomic(&files_path)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7. .changes generation (native dpkg-genchanges equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
// Pull the tarball checksums out of the generated .dsc so they are
|
||||
// distributed through the .changes like dpkg-genchanges does, in the
|
||||
// order the .dsc itself lists them.
|
||||
let dsc_content = std::fs::read_to_string(&dsc_path)
|
||||
.map_err(|e| format!("cannot read '{}': {}", dsc_path.display(), e))?;
|
||||
let dsc_para = parse_paragraphs(&dsc_content)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("'{}' is empty", dsc_path.display()))?;
|
||||
|
||||
let mut tarball_paths = Vec::new();
|
||||
let mut dsc_file_names: Vec<String> = Vec::new();
|
||||
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
|
||||
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||
if let Some(value) = dsc_para.get(field) {
|
||||
for line in value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
if !dsc_files.contains_key(tokens[2]) {
|
||||
dsc_file_names.push(tokens[2].to_string());
|
||||
}
|
||||
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
|
||||
match field {
|
||||
"Checksums-Sha1" => slot.sha1 = Some(tokens[0].to_string()),
|
||||
_ => slot.sha256 = Some(tokens[0].to_string()),
|
||||
}
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(files_value) = dsc_para.get("Files") {
|
||||
for line in files_value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() >= 3 {
|
||||
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
|
||||
slot.md5 = Some(tokens[0].to_string());
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &dsc_file_names {
|
||||
if name == &dsc_name {
|
||||
continue; // already computed directly above
|
||||
}
|
||||
let path = parent.join(name);
|
||||
if !path.exists() {
|
||||
return Err(format!(
|
||||
"file '{}' referenced by '{}' is missing",
|
||||
name,
|
||||
dsc_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let partial = &dsc_files[name];
|
||||
checksums.insert_entry(
|
||||
name,
|
||||
ChecksumEntry {
|
||||
size: partial.size.unwrap_or(0),
|
||||
md5: partial.md5.clone().unwrap_or_default(),
|
||||
sha1: partial.sha1.clone().unwrap_or_default(),
|
||||
sha256: partial.sha256.clone().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
tarball_paths.push(path);
|
||||
}
|
||||
|
||||
// The .buildinfo itself is distributed through the .changes, last (as
|
||||
// dpkg-genchanges does when it consumes debian/files).
|
||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||
|
||||
// The .changes lists section/priority for every distributed file; the
|
||||
// dsc and tarballs use the source stanza defaults (not persisted into
|
||||
// debian/files, matching dpkg).
|
||||
let mut changes_files = files_list.clone();
|
||||
changes_files.add(FilesEntry::new(&dsc_name, ctrl.section(), ctrl.priority()));
|
||||
for name in &dsc_file_names {
|
||||
if name != &dsc_name {
|
||||
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
|
||||
}
|
||||
}
|
||||
|
||||
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
||||
let render_changes_doc = |checksums: &FileChecksums| {
|
||||
changes::render_changes(&changes::ChangesInput {
|
||||
date: entry.date_raw.clone(),
|
||||
source: entry.source.clone(),
|
||||
binaries: Vec::new(), // source-only upload
|
||||
built_for_profiles: profiles.clone(),
|
||||
architecture: "source".to_string(),
|
||||
version: entry.version.full(),
|
||||
distribution: entry.distribution.clone(),
|
||||
urgency: entry.urgency.clone(),
|
||||
maintainer: ctrl.source.get("Maintainer").map(str::to_string),
|
||||
changed_by: Some(changed_by.clone()),
|
||||
descriptions: Vec::new(),
|
||||
changes_field: entry.changes_field.clone(),
|
||||
checksums: checksums.clone(),
|
||||
files_list: changes_files.clone(),
|
||||
})
|
||||
};
|
||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
||||
// ------------------------------------------------------------------
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "--after-build", "."], &pipeline_env)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 9. Signing cascade: dsc -> buildinfo -> changes
|
||||
// ------------------------------------------------------------------
|
||||
let mut signed = false;
|
||||
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
||||
sign::validate_key_id(&keyid)?;
|
||||
|
||||
println!("signfile {}", dsc_name);
|
||||
sign::clearsign_file(&dsc_path, &keyid)?;
|
||||
// The .dsc changed: refresh its checksums inside the .buildinfo.
|
||||
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", buildinfo_name);
|
||||
sign::clearsign_file(&buildinfo_path, &keyid)?;
|
||||
// Both .dsc and .buildinfo changed: refresh the .changes.
|
||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", changes_name);
|
||||
sign::clearsign_file(&changes_path, &keyid)?;
|
||||
|
||||
signed = true;
|
||||
}
|
||||
|
||||
Ok(SourceBuildOutput {
|
||||
dsc: dsc_path,
|
||||
buildinfo: buildinfo_path,
|
||||
changes: changes_path,
|
||||
tarballs: tarball_paths,
|
||||
signed,
|
||||
})
|
||||
}
|
||||
|
||||
/// A partially-known checksum entry taken from a `.dsc` checksum field.
|
||||
#[derive(Debug, Default)]
|
||||
struct PartialChecksum {
|
||||
size: Option<u64>,
|
||||
md5: Option<String>,
|
||||
sha1: Option<String>,
|
||||
sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Run a build command in `cwd` with extra environment variables layered on
|
||||
/// top of the inherited environment, with stdio attached to the terminal.
|
||||
/// Returns an error on non-zero exit status.
|
||||
fn run_command(
|
||||
cwd: &Path,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
log::debug!(
|
||||
"running: {} {} (in {})",
|
||||
program,
|
||||
args.join(" "),
|
||||
cwd.display()
|
||||
);
|
||||
let status = Command::new(program)
|
||||
.current_dir(cwd)
|
||||
.envs(env)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"'{} {}' failed with status: {}",
|
||||
program,
|
||||
args.join(" "),
|
||||
status
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Re-export commonly used types at the module root.
|
||||
pub use metadata::{ChangelogEntry, DebianVersion};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buildtype::BuildType;
|
||||
|
||||
#[test]
|
||||
fn partial_checksum_defaults() {
|
||||
let p = PartialChecksum::default();
|
||||
assert!(p.size.is_none());
|
||||
assert!(p.md5.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_version_reexport_usable() {
|
||||
let v = DebianVersion::parse("1.0-2").unwrap();
|
||||
assert_eq!(v.no_epoch(), "1.0-2");
|
||||
}
|
||||
|
||||
// The full pipeline is exercised end-to-end by running pkh against a
|
||||
// fixture package; unit tests cover the individual stages above.
|
||||
#[test]
|
||||
fn build_type_source_only_pipeline_mapping() {
|
||||
// Source-only builds always map to the 'source' artifact suffix.
|
||||
assert_eq!(buildtype::SOURCE.arch_suffix("amd64"), "source");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user