//! 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, /// 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, /// 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> { 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> { // ------------------------------------------------------------------ // 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 = Vec::new(); let mut dsc_files: HashMap = 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, md5: Option, sha1: Option, sha256: Option, } /// 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, ) -> Result<(), Box> { 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"); } }