diff --git a/src/build.rs b/src/build.rs index b72e002..b5096eb 100644 --- a/src/build.rs +++ b/src/build.rs @@ -46,10 +46,23 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box> { ); } - let status = command.status()?; + let status = command.status().map_err(|e| { + format!( + "Failed to run 'dpkg-buildpackage': {}. \ + Is 'dpkg-dev' (which provides dpkg-buildpackage) installed?", + e + ) + })?; if !status.success() { - return Err(format!("dpkg-buildpackage failed with status: {}", status).into()); + return Err(format!( + "dpkg-buildpackage failed with status: {}. \ + Re-run with 'RUST_LOG=debug' for more details, or run \ + 'dpkg-buildpackage -S -I -i -nc -d' manually in '{}' to see the full output.", + status, + cwd.display() + ) + .into()); } if signing_key.is_some() { diff --git a/src/changelog.rs b/src/changelog.rs index 06270cc..4cc42e9 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -119,10 +119,24 @@ fn increment_suffix(version: &str, suffix: &str) -> String { pub fn parse_changelog_header( path: &Path, ) -> Result<(String, String, String), Box> { - let file = File::open(path)?; + let file = File::open(path).map_err(|e| { + format!( + "Failed to read changelog '{}': {}. \ + Make sure you are running this command from the root of a source package \ + (a directory containing a 'debian/' subdirectory with a 'changelog' file).", + path.display(), + e + ) + })?; let mut reader = io::BufReader::new(file); let mut first_line = String::new(); - reader.read_line(&mut first_line)?; + reader.read_line(&mut first_line).map_err(|e| { + format!( + "Failed to read first line of changelog '{}': {}", + path.display(), + e + ) + })?; // Format: package (version) series; urgency=urgency let re = Regex::new(r"^(\S+) \(([^)]+)\) (.*); .*")?; @@ -132,16 +146,37 @@ pub fn parse_changelog_header( let series = caps.get(3).map_or("", |m| m.as_str()).to_string(); Ok((package, version, series)) } else { - Err(format!("Invalid changelog header format in {}", path.display()).into()) + Err(format!( + "Invalid changelog header format in '{}'. \ + The first line must look like: `package (version) series; urgency=...`, \ + but got: {:?}", + path.display(), + first_line.trim_end() + ) + .into()) } } /// Parse a changelog file footer to extract maintainer information /// Returns (name, email) tuple from the last modification entry pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box> { - let mut file = File::open(path)?; + let mut file = File::open(path).map_err(|e| { + format!( + "Failed to read changelog '{}': {}. \ + Make sure you are running this command from the root of a source package \ + (a directory containing a 'debian/' subdirectory with a 'changelog' file).", + path.display(), + e + ) + })?; let mut content = String::new(); - file.read_to_string(&mut content)?; + file.read_to_string(&mut content).map_err(|e| { + format!( + "Failed to read changelog '{}': {}", + path.display(), + e + ) + })?; // Find the last maintainer line (format: -- Name Date) let re = Regex::new(r"--\s*([^<]+?)\s*<([^>]+)>\s*")?; @@ -159,7 +194,13 @@ pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box Date', \ + but none was found.", + path.display() + ) + .into()) } } @@ -283,9 +324,31 @@ fn get_maintainer_info() -> Result<(String, String), Box> } // From git config - let config = git2::Config::open_default()?; - let name = config.get_string("user.name")?; - let email = config.get_string("user.email")?; + let config = git2::Config::open_default().map_err(|e| { + format!( + "Could not determine maintainer information. \ + Neither $DEBFULLNAME/$DEBEMAIL nor git configuration is available: {}. \ + Set the DEBFULLNAME and DEBEMAIL environment variables, \ + or configure git with `git config --global user.name` and `git config --global user.email`.", + e + ) + })?; + let name = config.get_string("user.name").map_err(|e| { + format!( + "Could not find git 'user.name' configuration: {}. \ + Set it with `git config --global user.name \"Your Name\"` \ + or define the DEBFULLNAME environment variable.", + e + ) + })?; + let email = config.get_string("user.email").map_err(|e| { + format!( + "Could not find git 'user.email' configuration: {}. \ + Set it with `git config --global user.email \"you@example.com\"` \ + or define the DEBEMAIL environment variable.", + e + ) + })?; Ok((name, email)) } diff --git a/src/context/api.rs b/src/context/api.rs index 7ee38b3..71d41b1 100644 --- a/src/context/api.rs +++ b/src/context/api.rs @@ -292,21 +292,43 @@ impl<'a> ContextCommand<'a> { /// Run command and obtain exit status pub fn status(&mut self) -> io::Result { - self.context.driver().as_ref().unwrap().run( - &self.program, - &self.args, - &self.env, - self.cwd.as_deref(), - ) + let program = self.program.clone(); + self.context + .driver() + .as_ref() + .unwrap() + .run(&self.program, &self.args, &self.env, self.cwd.as_deref()) + .map_err(|e| contextualize_spawn_error(&program, e)) } /// Run command, capturing output pub fn output(&mut self) -> io::Result { - self.context.driver().as_ref().unwrap().run_output( - &self.program, - &self.args, - &self.env, - self.cwd.as_deref(), - ) + let program = self.program.clone(); + self.context + .driver() + .as_ref() + .unwrap() + .run_output(&self.program, &self.args, &self.env, self.cwd.as_deref()) + .map_err(|e| contextualize_spawn_error(&program, e)) + } +} + +/// Wrap an I/O error from launching a command with a more helpful message. +/// +/// In particular, a `NotFound` error (e.g. "No such file or directory") is almost always +/// caused by the requested program not being installed or not on `PATH`; we make that +/// explicit instead of leaking the raw OS error. +fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error { + if e.kind() == io::ErrorKind::NotFound { + io::Error::new( + e.kind(), + format!( + "Could not run '{program}': {e}. \ + The program does not seem to be installed or is not on PATH; \ + install the corresponding package and retry.", + ), + ) + } else { + io::Error::new(e.kind(), format!("Could not run '{program}': {e}")) } } diff --git a/src/context/local.rs b/src/context/local.rs index b20f008..d115e16 100644 --- a/src/context/local.rs +++ b/src/context/local.rs @@ -2,6 +2,7 @@ /// Context driver: Does nothing use super::api::ContextDriver; use std::io; +use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::SystemTime; @@ -112,6 +113,14 @@ impl ContextDriver for LocalDriver { } fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { + // Reproduce symlinks as symlinks rather than following them, so that + // dangling/absolute symlinks do not abort the copy. + if std::fs::symlink_metadata(src)?.file_type().is_symlink() { + let target = std::fs::read_link(src)?; + let _ = std::fs::remove_file(dest); + return symlink(&target, dest); + } + if src.is_dir() { std::fs::create_dir_all(dest)?; for entry in std::fs::read_dir(src)? { diff --git a/src/context/mod.rs b/src/context/mod.rs index 5800197..cd1c412 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -147,4 +147,37 @@ mod tests { "subcontent" ); } + + /// Copying a tree containing a dangling symlink must reproduce the link + /// itself rather than failing to follow it (which previously surfaced as + /// a bare "No such file or directory (os error 2)"). + #[test] + fn test_context_copy_preserves_dangling_symlink() { + use std::os::unix::fs::symlink; + let temp_dir = tempfile::tempdir().unwrap(); + let ctx = Context::new(ContextConfig::Local); + + let src_dir = temp_dir.path().join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + // A regular file alongside the symlink, to ensure normal copies still work. + std::fs::write(src_dir.join("real.txt"), "data").unwrap(); + // A dangling symlink pointing to a non-existent target. + symlink("/nonexistent/target", src_dir.join("dangling")).unwrap(); + + let dest_dir = temp_dir.path().join("dest"); + ctx.copy_path(&src_dir, &dest_dir).unwrap(); + + // The regular file was copied. + assert_eq!( + ctx.read_file(&dest_dir.join("real.txt")).unwrap(), + "data" + ); + // The symlink was reproduced as a symlink (not followed). + let meta = std::fs::symlink_metadata(dest_dir.join("dangling")).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(dest_dir.join("dangling")).unwrap(), + std::path::Path::new("/nonexistent/target") + ); + } } diff --git a/src/context/unshare.rs b/src/context/unshare.rs index 89ac3de..bc6f2b0 100644 --- a/src/context/unshare.rs +++ b/src/context/unshare.rs @@ -1,6 +1,8 @@ use super::api::{Context, ContextCommand, ContextDriver}; use log::debug; +use std::fs; use std::io; +use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -9,23 +11,88 @@ pub struct UnshareDriver { pub parent: Option>, } -/// Recursively copy a directory and all its contents +/// Recursively copy a directory and all its contents. +/// +/// Symlinks are copied as symlinks (preserving the link target rather than +/// following it), so that dangling links do not abort the copy. fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { // Create the destination directory - std::fs::create_dir_all(dest)?; + std::fs::create_dir_all(dest).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to create directory '{}': {}", dest.display(), e), + ) + })?; // Iterate through the source directory - for entry in std::fs::read_dir(src)? { - let entry = entry?; + let read = std::fs::read_dir(src).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read directory '{}': {}", src.display(), e), + ) + })?; + for entry in read { + let entry = entry.map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read entry in '{}': {}", src.display(), e), + ) + })?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); - if src_path.is_dir() { + // Handle symlinks explicitly: reproduce the link itself instead of + // following it. This avoids failing on dangling/absolute symlinks. + let metadata = match std::fs::symlink_metadata(&src_path) { + Ok(m) => m, + Err(e) => { + return Err(io::Error::new( + e.kind(), + format!( + "Failed to read metadata of '{}': {}", + src_path.display(), + e + ), + )) + } + }; + + if metadata.file_type().is_symlink() { + let target = std::fs::read_link(&src_path).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read symlink '{}': {}", src_path.display(), e), + ) + })?; + // Remove an existing destination entry (e.g. from a previous attempt) + let _ = fs::remove_file(&dest_path); + symlink(&target, &dest_path).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "Failed to create symlink '{}' -> '{}': {}", + dest_path.display(), + target.display(), + e + ), + ) + })?; + } else if src_path.is_dir() { // Recursively copy subdirectories copy_dir_recursive(&src_path, &dest_path)?; } else { - // Copy files - std::fs::copy(&src_path, &dest_path)?; + // Copy regular files + std::fs::copy(&src_path, &dest_path).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "Failed to copy '{}' to '{}': {}", + src_path.display(), + dest_path.display(), + e + ), + ) + })?; } } diff --git a/src/deb/local.rs b/src/deb/local.rs index b44dccb..6270a10 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -138,10 +138,20 @@ pub async fn build( .command("apt-get") .envs(env.clone()) .arg("update") - .status()?; + .status() + .map_err(|e| { + format!( + "Failed to run 'apt-get update' inside the build context: {}. \ + If this is a local build, make sure apt-get is available and \ + try executing with sudo.", + e + ) + })?; if !status.success() { return Err( - "Could not execute apt-get update. If this is a local build, try executing with sudo." + "apt-get update failed inside the build context. \ + If this is a local build, try executing with sudo, \ + or re-run with RUST_LOG=debug for more details." .into(), ); } diff --git a/src/distro_info.rs b/src/distro_info.rs index d2ea4d5..cbb9d32 100644 --- a/src/distro_info.rs +++ b/src/distro_info.rs @@ -110,11 +110,30 @@ fn parse_series_csv(content: &str) -> Result, Box Vec { + DATA.dist.keys().cloned().collect() +} + /// Get time-ordered list of series information for a distribution, development series first pub async fn get_ordered_series(dist: &str) -> Result, Box> { - let series_info = &DATA.dist.get(dist).unwrap().series; + let dist_data = DATA.dist.get(dist).ok_or_else(|| { + format!( + "Unknown distribution '{}'. Supported distributions are: {}.", + dist, + supported_dists().join(", ") + ) + })?; + let series_info = &dist_data.series; let content = if Path::new(series_info.local.as_str()).exists() { - std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv"))? + std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv")) + .map_err(|e| { + format!( + "Failed to read distribution series data for '{dist}' \ + from '{}': {}. The 'distro-info' package provides these CSV files.", + series_info.local, e + ) + })? } else { reqwest::get(series_info.network.as_str()) .await? @@ -188,13 +207,20 @@ pub async fn get_dist_from_series(series: &str) -> Result /// Get the package pockets available for a given distribution /// /// Example: get_dist_pockets(ubuntu) => ["proposed", "updates", ""] -pub fn get_dist_pockets(dist: &str) -> Vec { - let mut pockets = DATA.dist.get(dist).unwrap().pockets.clone(); +pub fn get_dist_pockets(dist: &str) -> Result, Box> { + let dist_data = DATA.dist.get(dist).ok_or_else(|| { + format!( + "Unknown distribution '{}'. Supported distributions are: {}.", + dist, + supported_dists().join(", ") + ) + })?; + let mut pockets = dist_data.pockets.clone(); // Explicitely add 'main' pocket, which is just the empty string pockets.push("".to_string()); - pockets + Ok(pockets) } /// Get the sources URL for a distribution, series, pocket, and component @@ -210,8 +236,17 @@ pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &s /// Get the archive base URL for a distribution /// /// Example: ubuntu => http://archive.ubuntu.com/ubuntu -pub fn get_base_url(dist: &str) -> String { - DATA.dist.get(dist).unwrap().base_url.clone() +pub fn get_base_url(dist: &str) -> Result> { + DATA.dist.get(dist) + .map(|d| d.base_url.clone()) + .ok_or_else(|| { + format!( + "Unknown distribution '{}'. Supported distributions are: {}.", + dist, + supported_dists().join(", ") + ) + .into() + }) } /// Obtain the URLs for the archive keyrings of a distribution series @@ -246,7 +281,15 @@ pub async fn get_keyring_urls(series: &str) -> Result, Box Result, Box> { - let series_info = &DATA.dist.get("debian").unwrap().series; + let dist_data = DATA.dist.get("debian").ok_or_else(|| { + format!( + "Debian distribution data is missing from the built-in configuration. \ + This is a bug; supported distributions are: {}.", + supported_dists().join(", ") + ) + })?; + let series_info = &dist_data.series; let content = if Path::new(series_info.local.as_str()).exists() { - std::fs::read_to_string(series_info.local.as_str())? + std::fs::read_to_string(series_info.local.as_str()) + .map_err(|e| { + format!( + "Failed to read Debian series data from '{}': {}. \ + The 'distro-info' package provides this file.", + series_info.local, e + ) + })? } else { reqwest::get(series_info.network.as_str()) .await? diff --git a/src/main.rs b/src/main.rs index 7dbb07d..910a518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,17 @@ use log::{error, info}; mod ui; +/// Obtain the current working directory, exiting with a helpful message on failure. +fn current_dir_or_exit() -> std::path::PathBuf { + match std::env::current_dir() { + Ok(p) => p, + Err(e) => { + error!("Could not determine the current working directory: {}", e); + std::process::exit(1); + } + } +} + fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); let logger = @@ -137,7 +148,7 @@ fn main() { info!("Done."); } Some(("chlog", sub_matches)) => { - let cwd = std::env::current_dir().unwrap(); + let cwd = current_dir_or_exit(); let version = sub_matches.get_one::("version").map(|s| s.as_str()); if let Err(e) = generate_entry("debian/changelog", Some(&cwd), version) { @@ -145,21 +156,38 @@ fn main() { std::process::exit(1); } - let editor = std::env::var("EDITOR").unwrap(); - let _status = std::process::Command::new(editor) + let editor = match std::env::var("EDITOR") { + Ok(e) => e, + Err(_) => { + error!( + "No editor configured. Set the EDITOR environment variable \ + (e.g. `EDITOR=nano` or `export EDITOR=vim`) and retry." + ); + std::process::exit(1); + } + }; + let _status = std::process::Command::new(&editor) .current_dir(&cwd) .args(["debian/changelog"]) - .status(); + .status() + .map_err(|e| { + error!( + "Could not launch editor '{}': {}. \ + Make sure it is installed and available on PATH.", + editor, e + ); + std::process::exit(1); + }); } Some(("build", _sub_matches)) => { - let cwd = std::env::current_dir().unwrap(); + let cwd = current_dir_or_exit(); if let Err(e) = pkh::build::build_source_package(Some(&cwd)) { error!("{}", e); std::process::exit(1); } } Some(("deb", sub_matches)) => { - let cwd = std::env::current_dir().unwrap(); + let cwd = current_dir_or_exit(); let series = sub_matches.get_one::("series").map(|s| s.as_str()); let arch = sub_matches.get_one::("arch").map(|s| s.as_str()); let cross = sub_matches.get_one::("cross").unwrap_or(&false); @@ -221,7 +249,13 @@ fn main() { "ssh" => { let endpoint = args .get_one::("endpoint") - .expect("Endpoint is required for ssh context"); + .unwrap_or_else(|| { + error!( + "An --endpoint is required to create an ssh context. \ + Expected format: [ssh://][user@]host[:port]" + ); + std::process::exit(1); + }); // Parse host, user, port from endpoint // Formats: [ssh://][user@]host[:port] diff --git a/src/package_info.rs b/src/package_info.rs index 2c4438a..fbcf584 100644 --- a/src/package_info.rs +++ b/src/package_info.rs @@ -213,7 +213,7 @@ async fn get( } // Determine the base URL to use (either provided PPA URL or default archive) - let distro_base_url = crate::distro_info::get_base_url(&dist); + let distro_base_url = crate::distro_info::get_base_url(&dist)?; let base_url = if let Some(ppa_url) = base_url { ppa_url.to_string() } else { @@ -298,7 +298,7 @@ async fn find_package( } let pockets = if pocket.is_empty() { - crate::distro_info::get_dist_pockets(dist) + crate::distro_info::get_dist_pockets(dist)? } else { vec![pocket.to_string()] }; diff --git a/src/pull.rs b/src/pull.rs index 19df76e..c715583 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -1,5 +1,6 @@ use std::cmp::min; use std::error::Error; +use std::os::unix::fs::symlink; use std::path::Path; use std::path::PathBuf; @@ -101,7 +102,13 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), Box> { let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { + // Reproduce symlinks as symlinks rather than following them, so that + // dangling/absolute symlinks do not abort the copy. + if std::fs::symlink_metadata(&src_path)?.file_type().is_symlink() { + let target = std::fs::read_link(&src_path)?; + let _ = std::fs::remove_file(&dst_path); + symlink(&target, &dst_path)?; + } else if src_path.is_dir() { copy_dir_all(&src_path, &dst_path)?; } else { std::fs::copy(&src_path, &dst_path)?; @@ -183,12 +190,25 @@ fn checkout_pristine_tar(package_dir: &Path, filename: &str) -> Result<(), Box