446 lines
16 KiB
Rust
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");
|
|
}
|
|
}
|