mod cross; mod ephemeral; mod local; use crate::context::{self, Context}; use crate::ui::deb::{DebUi, Phase}; use std::error::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; /// Build mode for the binary build #[derive(PartialEq)] pub enum BuildMode { /// Local build, directly on the context Local, } /// Build package in 'cwd' to a .deb /// /// Returns the list of produced .deb files retrieved locally. When `ui` is /// set, a live view (status bar + rolling log pane) is displayed and all /// subprocess output is captured through it; on failure the widget is cleared /// and a summary of captured errors is printed. #[allow(clippy::too_many_arguments)] pub async fn build_binary_package( arch: Option<&str>, series: Option<&str>, pocket: Option<&str>, cwd: Option<&Path>, cross: bool, mode: Option, ppa: Option<&[&str]>, inject_packages: Option<&[&str]>, ctx: Option>, ui: Option>, jobs: Option, ) -> Result, Box> { let result = build_binary_package_impl( arch, series, pocket, cwd, cross, mode, ppa, inject_packages, ctx, &ui, jobs, ) .await; if let (Some(u), Err(_)) = (&ui, &result) { u.finish_failure(); } result } /// Implementation of [`build_binary_package`], without failure handling #[allow(clippy::too_many_arguments)] async fn build_binary_package_impl( arch: Option<&str>, series: Option<&str>, pocket: Option<&str>, cwd: Option<&Path>, cross: bool, mode: Option, ppa: Option<&[&str]>, inject_packages: Option<&[&str]>, ctx: Option>, ui: &Option>, jobs: Option, ) -> Result, Box> { let cwd = cwd.unwrap_or_else(|| Path::new(".")); // Parse changelog to get package name, version and series let changelog_path = cwd.join("debian/changelog"); let (package, version, package_series) = crate::changelog::parse_changelog_header(&changelog_path)?; let series = if let Some(s) = series { s } else { &package_series }; let current_arch = crate::get_current_arch(); let arch = arch.unwrap_or(¤t_arch); // Make sure we select a specific mode, either using user-requested // or by using default for user-supplied parameters let mode = if let Some(m) = mode { m } else { // By default, we use local build BuildMode::Local }; // Create an ephemeral unshare context for all Local builds // Use qemu_binfmt when target architecture differs from host and cross is not requested let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross { Some(arch) } else { None }; // Use provided context or get current let base_ctx = ctx.unwrap_or_else(context::current); // Identify the target in the live UI once the changelog is parsed, so // even the chroot download output is attributed and tee'd if let Some(u) = ui { u.set_target(&package, &version, series, arch); } // Create an ephemeral unshare context for all Local builds. It is kept in // this scope so it outlives the guarded section below and is only dropped // once the live view has been cleared. let mut guard = if mode == BuildMode::Local { Some( ephemeral::EphemeralContextGuard::new_with_context( series, chroot_arch, base_ctx.clone(), ui.clone(), ) .await?, ) } else { None }; let result = async { // Get the build context - either the ephemeral context or the base context let build_ctx = if mode == BuildMode::Local { context::current() } else { base_ctx.clone() }; // Prepare build directory let build_root = build_ctx.create_temp_dir()?; // Ensure availability of all needed files for the build let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?; build_ctx.ensure_available(parent_dir, &build_root)?; let parent_dir_name = parent_dir .file_name() .ok_or("Cannot find parent directory name")?; let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap()); // Run the build using target build mode match mode { BuildMode::Local => { local::build( &package, &version, arch, series, pocket, &build_root, cross, ppa, inject_packages, build_ctx.clone(), ui.clone(), jobs, ) .await? } } // Retrieve produced artifacts (.deb files plus the upload metadata // (.buildinfo/.changes) generated natively after the build) if let Some(u) = ui { u.phase(Phase::RetrievingArtifacts); } let remote_files = build_ctx.list_files(Path::new(&build_root))?; let deb_files: Vec = remote_files .into_iter() .filter(|f| { f.extension().is_some_and(|ext| { matches!( ext.to_str(), Some("deb") | Some("buildinfo") | Some("changes") ) }) }) .collect(); let total_debs = deb_files.len(); let mut artifacts = Vec::with_capacity(total_debs); for (idx, remote_file) in deb_files.iter().enumerate() { let file_name = remote_file.file_name().ok_or("Invalid remote filename")?; let local_dest = parent_dir.join(file_name); build_ctx.retrieve_path(remote_file, &local_dest)?; artifacts.push(local_dest); if let Some(u) = ui { u.count_progress("Retrieving artifacts", idx + 1, total_debs); } } if let Some(u) = ui { u.finish_success(&artifacts, u.elapsed()); } Ok(artifacts) } .await; // Clear the live view before returning: the ephemeral guard is dropped at // the end of this function and its cleanup commands (umount, rm -rf of // the chroot) inherit the terminal, so they must not fight the widget. if let Some(u) = ui { u.suspend(); } // Mark build as successful to trigger chroot cleanup if result.is_ok() && let Some(ref mut g) = guard { g.mark_build_successful(); } result } /// Find the current package directory by trying both patterns: /// - package/package /// - package/package-origversion /// - custom directories from quirks configuration pub(crate) fn find_package_directory( parent_dir: &Path, package: &str, version: &str, ctx: &context::Context, ) -> Result> { // Check quirks first for custom package directories let custom_dirs = crate::quirks::get_package_directories(package); for custom_dir in custom_dirs { let package_dir = parent_dir.join(&custom_dir); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { log::debug!( "Found package directory via quirks: {}", package_dir.display() ); return Ok(package_dir); } } // Try the current working directory name as a package directory // This is useful when the user is inside a non-standard package directory // e.g. ~/kernels/linux-riscv/linux-main -> tries "linux-main" if let Ok(cwd) = std::env::current_dir() && let Some(cwd_name) = cwd.file_name() { let cwd_name_str = cwd_name.to_string_lossy(); let package_dir = parent_dir.join(cwd_name_str.as_ref()); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { log::debug!( "Found package directory via CWD name: {}", package_dir.display() ); return Ok(package_dir); } } // Try package/package pattern first let package_dir = parent_dir.join(package).join(package); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Compute origversion from version: remove everything after first '-', after stripping epoch let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version); let origversion = version_without_epoch .split_once('-') .map(|(v, _)| v) .unwrap_or(version); // Try package/package-origversion pattern let package_dir = parent_dir .join(package) .join(format!("{}-{}", package, origversion)); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Try 'package' only let package_dir = parent_dir.join(package); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Try package-origversion only let package_dir = parent_dir.join(format!("{}-{}", package, origversion)); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // List all directories under 'package/' and log them let package_parent = parent_dir; if ctx.exists(package_parent)? { log::debug!( "Listing all directories under '{}':", package_parent.display() ); let entries = ctx.list_files(package_parent)?; let mut found_dirs = Vec::new(); for entry in entries { if entry.is_dir() { if let Some(file_name) = entry.file_name() { found_dirs.push(file_name.to_string_lossy().into_owned()); } log::debug!(" - {}", entry.display()); } } // If we found directories but none matched our patterns, provide helpful error if !found_dirs.is_empty() { return Err(format!( "Could not find package directory for {} in {}. Found directories: {}", package, parent_dir.display(), found_dirs.join(", ") ) .into()); } } Err(format!( "Could not find package directory for {} in {}", package, parent_dir.display() ) .into()) } fn find_dsc_file( build_root: &str, package: &str, version: &str, ctx: &Arc, ) -> Result> { // Strip epoch from version (e.g., "1:2.3.4-5" -> "2.3.4-5") let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version); let dsc_name = format!("{}_{}.dsc", package, version_without_epoch); let dsc_path = PathBuf::from(build_root).join(&dsc_name); // Check if the .dsc file exists in context if !ctx.exists(&dsc_path)? { return Err(format!("Could not find .dsc file at {}", dsc_path.display()).into()); } Ok(dsc_path) } /// Check whether an apt source URI points to a distribution archive /// (as opposed to a PPA or another third-party repository) pub(crate) fn is_archive_source(uri: &str) -> bool { uri.contains("archive.ubuntu.com") || uri.contains("security.ubuntu.com") || uri.contains("ports.ubuntu.com") || uri.contains("deb.debian.org") } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; async fn test_build_end_to_end( package: &str, series: &str, dist: Option<&str>, arch: Option<&str>, cross: bool, ) { log::info!( "Starting end-to-end test for package: {} (series: {}, arch: {:?}, cross: {})", package, series, arch, cross ); let temp_dir = tempfile::tempdir().unwrap(); let cwd = temp_dir.path(); log::debug!("Created temporary directory: {}", cwd.display()); log::info!("Pulling package {} from {}...", package, series); let package_info = crate::package_info::lookup(package, None, Some(series), "", dist, None, None) .await .expect("Cannot lookup package information"); crate::pull::pull(&package_info, Some(cwd), None, true) .await .expect("Cannot pull package"); log::info!("Successfully pulled package {}", package); // Create a fresh local context for this test let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local)); // Change directory to the package directory let cwd = crate::deb::find_package_directory(cwd, package, &package_info.stanza.version, &ctx) .expect("Cannot find package directory"); log::debug!("Package directory: {}", cwd.display()); log::info!("Starting binary package build..."); crate::deb::build_binary_package( arch, Some(series), None, Some(&cwd), cross, None, None, None, Some(ctx), None, None, ) .await .expect("Cannot build binary package (deb)"); log::info!("Successfully built binary package"); // Check that the .deb files are present let parent_dir = cwd.parent().expect("Cannot find parent directory"); let deb_files = std::fs::read_dir(parent_dir) .expect("Cannot read build directory") .filter_map(|entry| entry.ok()) .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "deb")) .collect::>(); log::info!("Found {} .deb files after build", deb_files.len()); for file in &deb_files { log::debug!(" - {}", file.path().display()); } assert!(!deb_files.is_empty(), "No .deb files found after build"); log::info!( "End-to-end test completed successfully for package: {}", package ); } // Tests no longer need to be 'serial' since each test uses its own // explicit context instead of shared global state. #[tokio::test] #[test_log::test] async fn test_deb_hello_ubuntu_end_to_end() { test_build_end_to_end("hello", "noble", None, None, false).await; } /// This ensures that we can cross-build packages #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_hello_ubuntu_cross_end_to_end() { test_build_end_to_end("hello", "noble", None, Some("riscv64"), true).await; } /// This ensures that we can build packages from sid, even on older /// releases. It can sometimes be difficult with mmdebstrap issues /// for example. #[tokio::test] #[test_log::test] async fn test_deb_hello_debian_sid_end_to_end() { test_build_end_to_end("hello", "sid", None, None, false).await; } /// This is a specific test case for the linux-riscv package on Ubuntu /// It is important to ensure that pkh can cross-compile linux-riscv, as /// for risc-v hardware is still rare and cross-compilation is necessary /// to debug and test /// NOTE: Ideally, we want to run this in CI, but it takes more than 1h /// to fully build the linux-riscv package on an amd64 builder, which is too /// much time #[ignore] #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() { test_build_end_to_end("linux-riscv", "questing", None, Some("riscv64"), true).await; } /// This is a specific test case for the latest gcc package on Debian /// The GCC package is complex and hard to build, with specific stages /// and system-bound scripts. Building it requires specific things that /// we want to ensure are not broken. /// NOTE: Ideally, we want to run this in CI, but it takes more than 20h /// to fully build the gcc-15 package on an amd64 builder, which is too /// much time. #[ignore] #[cfg(target_arch = "x86_64")] #[tokio::test] #[test_log::test] async fn test_deb_gcc_debian_end_to_end() { test_build_end_to_end("gcc-15", "sid", None, None, false).await; } }