Files
pkh/src/build/mod.rs
T
2026-08-23 01:46:04 +02:00

446 lines
16 KiB
Rust

//! 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 env;
use std::collections::{BTreeMap, HashMap};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::debian::{
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
};
/// 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 = crate::debian::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) {
crate::utils::gpg::validate_key_id(&keyid)?;
println!("signfile {}", dsc_name);
crate::utils::gpg::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);
crate::utils::gpg::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);
crate::utils::gpg::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(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::debian::DebianVersion;
#[test]
fn partial_checksum_defaults() {
let p = PartialChecksum::default();
assert!(p.size.is_none());
assert!(p.md5.is_none());
}
#[test]
fn debian_version_from_debian_module_usable() {
let v = DebianVersion::parse("1.0-2").unwrap();
assert_eq!(v.no_epoch(), "1.0-2");
}
}