From d63ad03e986e83ad60193818c44582ad3322dd5e Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sun, 22 Feb 2026 14:52:45 -0600 Subject: [PATCH] feat: improve depot UX and packaging workflow - add shared terminal UI helpers for colored info/warn/success logs and yes/no prompts - add Python build backend (PEP 517/setup.py wheel build + install) and expose python in interactive spec creation - expand spec/build flags (post_configure, make phase targets/dirs, configure_file, no_strip, no_compress_man) - support split-output staging with internal .depot output dirs, shell helpers (haul/subdestdir), and per-output deps/provides overrides - improve staging with ELF auto-strip and zstd manpage compression (with opt-out flags) - enforce runtime deps before install and support legacy lifecycle hook script names - improve manual source handling with files/urls lists and stricter validation --- src/builder/autotools.rs | 615 ++++++++++++++++++++++---- src/builder/bin.rs | 4 +- src/builder/cmake.rs | 181 +++++++- src/builder/custom.rs | 199 ++++++++- src/builder/makefile.rs | 21 +- src/builder/meson.rs | 246 +++++++++-- src/builder/mod.rs | 22 +- src/builder/python.rs | 825 ++++++++++++++++++++++++++++++++++ src/builder/rust.rs | 8 +- src/config.rs | 2 +- src/cross.rs | 12 +- src/db/mod.rs | 46 +- src/db/repo.rs | 26 +- src/deps.rs | 84 +++- src/index.rs | 6 +- src/install/scripts.rs | 187 +++++++- src/main.rs | 169 +++---- src/package/interactive.rs | 187 +++++++- src/package/packager.rs | 37 +- src/package/spec.rs | 882 ++++++++++++++++++++++++++++++++++++- src/shell_helpers.rs | 259 +++++++++++ src/source/extractor.rs | 4 +- src/source/fetcher.rs | 8 +- src/source/git.rs | 4 +- src/source/hooks.rs | 118 ++++- src/source/mod.rs | 267 ++++++++--- src/staging/mod.rs | 497 ++++++++++++++++++++- src/ui.rs | 135 ++++++ 28 files changed, 4639 insertions(+), 412 deletions(-) create mode 100644 src/builder/python.rs create mode 100644 src/shell_helpers.rs create mode 100644 src/ui.rs diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index 87deca8..19eeb1a 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -6,7 +6,7 @@ use crate::package::PackageSpec; use crate::source::hooks; use anyhow::{Context, Result}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; pub fn build( @@ -17,6 +17,7 @@ pub fn build( export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; + let make_exec = resolve_make_exec(&flags.make_exec); let export_compiler_flags = export_compiler_flags && !flags.no_flags; let actual_src = resolve_actual_src(spec, src_dir)?; @@ -45,22 +46,18 @@ pub fn build( let build_dir = if let Some(dir) = &flags.build_dir { let bdir = actual_src.join(dir); fs::create_dir_all(&bdir)?; - println!(" Build directory: {}", bdir.display()); + crate::log_info!(" Build directory: {}", bdir.display()); bdir } else { actual_src.clone() }; if !state.is_done(BuildStep::Configured) { - println!("Running configure..."); - let configure_path = if flags.build_dir.is_some() { - "../configure" - } else { - "./configure" - }; - println!(" Configure path: {}", configure_path); + crate::log_info!("Running configure..."); + let configure_path = resolve_configure_path(spec, &actual_src); + crate::log_info!(" Configure path: {}", configure_path.display()); - let mut configure_cmd = Command::new(configure_path); + let mut configure_cmd = Command::new(&configure_path); configure_cmd.current_dir(&build_dir); crate::builder::prepare_command(&mut configure_cmd, &env_vars); @@ -69,15 +66,11 @@ pub fn build( // Some projects use non-GNU configure scripts that reject --host/--build. // Probe support first and only add these options when advertised. - let help_text = configure_help_text(configure_path, &build_dir, &env_vars); - let supports_host = help_text - .as_deref() - .map(|s| configure_help_supports_option(s, "--host")) - .unwrap_or(true); - let supports_build = help_text - .as_deref() - .map(|s| configure_help_supports_option(s, "--build")) - .unwrap_or(true); + let help_text = configure_help_text(&configure_path, &build_dir, &env_vars); + let supports_host = + configure_supports_option(help_text.as_deref(), "--host", &flags.configure_file); + let supports_build = + configure_supports_option(help_text.as_deref(), "--build", &flags.configure_file); let requested_host = if let Some(cc_cfg) = cross { Some(cc_cfg.host_triple().to_string()) @@ -99,7 +92,7 @@ pub fn build( if supports_host { configure_cmd.arg(format!("--host={}", host)); } else { - println!(" configure does not support --host; skipping {}", host); + crate::log_info!(" configure does not support --host; skipping {}", host); } } @@ -107,12 +100,13 @@ pub fn build( if supports_build { configure_cmd.arg(format!("--build={}", build)); } else { - println!(" configure does not support --build; skipping {}", build); + crate::log_info!(" configure does not support --build; skipping {}", build); } } for arg in &flags.configure { - configure_cmd.arg(arg); + let expanded = expand_configure_arg(spec, arg, &env_vars); + configure_cmd.arg(expanded); } let status = configure_cmd @@ -122,64 +116,155 @@ pub fn build( if !status.success() { anyhow::bail!("configure failed with status: {}", status); } + + // Run post-configure hooks (after configure, before make) + hooks::run_post_configure_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::Configured)?; } else { - println!("Skipping configure (already done)"); + crate::log_info!("Skipping configure (already done)"); } if !state.is_done(BuildStep::PostCompileDone) { // Run make - println!("Running make..."); - let mut make_cmd = Command::new("make"); - make_cmd.current_dir(&build_dir); - make_cmd.arg("-j").arg(num_cpus().to_string()); - add_make_variable_overrides(&mut make_cmd, &flags.make_vars, "build")?; + let build_targets = phase_targets(&flags.make_target, &flags.make_targets, None); + let make_dirs = resolve_make_dirs(&build_dir, &flags.make_dirs, "build.flags.make_dirs")?; + for make_dir in make_dirs { + crate::log_info!("Running {} in {}...", make_exec, make_dir.display()); + let mut make_cmd = Command::new(make_exec); + make_cmd.current_dir(&make_dir); + make_cmd.arg("-j").arg(num_cpus().to_string()); + add_make_variable_overrides_if_supported( + &mut make_cmd, + make_exec, + &flags.make_vars, + "build", + )?; + for target in &build_targets { + make_cmd.arg(target); + } - crate::builder::prepare_command(&mut make_cmd, &env_vars); + crate::builder::prepare_command(&mut make_cmd, &env_vars); - let status = make_cmd - .status() - .with_context(|| format!("Failed to run make in {}", build_dir.display()))?; + let status = make_cmd.status().with_context(|| { + format!("Failed to run {} in {}", make_exec, make_dir.display()) + })?; - if !status.success() { - anyhow::bail!("make failed with status: {}", status); + if !status.success() { + anyhow::bail!( + "{} failed with status: {} (dir: {})", + make_exec, + status, + make_dir.display() + ); + } } - if let Some(test_target) = maybe_find_autotools_test_target(&build_dir, flags.skip_tests)? { - println!("Running make {}...", test_target); - let mut test_cmd = Command::new("make"); - test_cmd.current_dir(&build_dir); - add_make_variable_overrides(&mut test_cmd, &flags.make_test_vars, "test")?; - test_cmd.arg(test_target); - crate::builder::prepare_command(&mut test_cmd, &env_vars); - - let status = test_cmd.status().with_context(|| { - format!( - "Failed to run make {} in {}", - test_target, - build_dir.display() - ) - })?; - if !status.success() { - anyhow::bail!("make {} failed with status: {}", test_target, status); - } - } else if flags.skip_tests { - println!("Skipping tests: disabled by build.flags.skip_tests"); + if flags.skip_tests { + crate::log_info!("Skipping tests: disabled by build.flags.skip_tests"); } else { - println!("Skipping tests: no 'check' or 'test' target in Makefile"); + let test_dirs = resolve_make_dirs( + &build_dir, + &flags.make_test_dirs, + "build.flags.make_test_dirs", + )?; + let mut ran_any_tests = false; + let configured_test_targets = + phase_targets(&flags.make_test_target, &flags.make_test_targets, None); + for test_dir in test_dirs { + let test_targets = if !configured_test_targets.is_empty() { + configured_test_targets.clone() + } else if make_exec_supports_make_assignments(make_exec) { + maybe_find_autotools_test_target(&test_dir, false)? + .map(|t| vec![t.to_string()]) + .unwrap_or_default() + } else { + Vec::new() + }; + + if !test_targets.is_empty() { + crate::log_info!("Running {} in {}...", make_exec, test_dir.display()); + let mut test_cmd = Command::new(make_exec); + test_cmd.current_dir(&test_dir); + add_make_variable_overrides_if_supported( + &mut test_cmd, + make_exec, + &flags.make_test_vars, + "test", + )?; + for test_target in &test_targets { + test_cmd.arg(test_target); + } + crate::builder::prepare_command(&mut test_cmd, &env_vars); + + let test_targets_display = test_targets.join(" "); + let status = test_cmd.status().with_context(|| { + format!( + "Failed to run {} {} in {}", + make_exec, + test_targets_display, + test_dir.display() + ) + })?; + if !status.success() { + anyhow::bail!( + "{} {} failed with status: {} (dir: {})", + make_exec, + test_targets_display, + status, + test_dir.display() + ); + } + ran_any_tests = true; + } + } + + if !ran_any_tests { + if flags.make_test_dirs.is_empty() { + if !configured_test_targets.is_empty() { + crate::log_info!("Skipping tests: no test directories to run"); + } else if make_exec_supports_make_assignments(make_exec) { + crate::log_info!("Skipping tests: no 'check' or 'test' target in Makefile"); + } else { + crate::log_info!( + "Skipping tests: set build.flags.make_test_target when using build.flags.make_exec='{}'", + make_exec + ); + } + } else if !configured_test_targets.is_empty() { + crate::log_info!( + "Skipping tests: no test targets ran in build.flags.make_test_dirs" + ); + } else if make_exec_supports_make_assignments(make_exec) { + crate::log_info!( + "Skipping tests: no 'check' or 'test' target in build.flags.make_test_dirs" + ); + } else { + crate::log_info!( + "Skipping tests: set build.flags.make_test_target when using build.flags.make_exec='{}'", + make_exec + ); + } + } } // Run post-compile hooks (after make, before make install) hooks::run_post_compile_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; } else { - println!("Skipping make and post-compile hooks (already done)"); + crate::log_info!("Skipping make and post-compile hooks (already done)"); } if !state.is_done(BuildStep::PostInstallDone) { // Run make install with fakeroot if not root - println!( - "Running make install{}...", + crate::log_info!( + "Running {} {}{}...", + make_exec, + phase_targets( + &flags.make_install_target, + &flags.make_install_targets, + Some("install") + ) + .join(" "), if fakeroot::is_root() { "" } else { @@ -187,34 +272,67 @@ pub fn build( } ); - let mut install_cmd = fakeroot::wrap_install_command("make", destdir); - install_cmd.current_dir(&build_dir); - if !has_make_variable_override(&flags.make_install_vars, "DESTDIR") { - install_cmd.arg(format!("DESTDIR={}", destdir.to_string_lossy())); - } - add_make_variable_overrides(&mut install_cmd, &flags.make_install_vars, "install")?; - install_cmd.arg("install"); + let install_dirs = resolve_make_dirs( + &build_dir, + &flags.make_install_dirs, + "build.flags.make_install_dirs", + )?; + let install_targets = phase_targets( + &flags.make_install_target, + &flags.make_install_targets, + Some("install"), + ); + for install_dir in install_dirs { + let mut install_cmd = fakeroot::wrap_install_command(make_exec, destdir); + install_cmd.current_dir(&install_dir); + if make_exec_supports_make_assignments(make_exec) + && !has_make_variable_override(&flags.make_install_vars, "DESTDIR") + { + install_cmd.arg(format!("DESTDIR={}", destdir.to_string_lossy())); + } + add_make_variable_overrides_if_supported( + &mut install_cmd, + make_exec, + &flags.make_install_vars, + "install", + )?; + for install_target in &install_targets { + install_cmd.arg(install_target); + } - let mut install_env = env_vars.clone(); - install_env.push(( - "DESTDIR".to_string(), - destdir.to_string_lossy().into_owned(), - )); - crate::builder::prepare_command(&mut install_cmd, &install_env); + let mut install_env = env_vars.clone(); + install_env.push(( + "DESTDIR".to_string(), + destdir.to_string_lossy().into_owned(), + )); + crate::builder::prepare_command(&mut install_cmd, &install_env); - let status = install_cmd - .status() - .with_context(|| format!("Failed to run make install for {}", spec.package.name))?; + let status = install_cmd.status().with_context(|| { + format!( + "Failed to run {} {} for {} in {}", + make_exec, + install_targets.join(" "), + spec.package.name, + install_dir.display() + ) + })?; - if !status.success() { - anyhow::bail!("make install failed with status: {}", status); + if !status.success() { + anyhow::bail!( + "{} {} failed with status: {} (dir: {})", + make_exec, + install_targets.join(" "), + status, + install_dir.display() + ); + } } // Run post-install hooks (after make install) hooks::run_post_install_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostInstallDone)?; } else { - println!("Skipping make install and post-install hooks (already done)"); + crate::log_info!("Skipping make install and post-install hooks (already done)"); } Ok(()) @@ -248,8 +366,23 @@ fn resolve_actual_src(spec: &PackageSpec, src_dir: &Path) -> Result PathBuf { + let configured = spec.expand_vars(&spec.build.flags.configure_file); + let trimmed = configured.trim(); + if trimmed.is_empty() { + return actual_src.join("configure"); + } + + let path = Path::new(trimmed); + if path.is_absolute() { + path.to_path_buf() + } else { + actual_src.join(path) + } +} + fn configure_help_text( - configure_path: &str, + configure_path: &Path, build_dir: &Path, env_vars: &crate::builder::EnvVars, ) -> Option { @@ -273,6 +406,12 @@ fn configure_help_supports_option(help_text: &str, option: &str) -> bool { help_text.contains(&with_eq) || help_text.contains(&with_space) || help_text.contains(option) } +fn configure_supports_option(help_text: Option<&str>, option: &str, configure_file: &str) -> bool { + help_text + .map(|text| configure_help_supports_option(text, option)) + .unwrap_or(configure_file.trim().is_empty()) +} + fn find_autotools_test_target(build_dir: &Path) -> Result> { for target in ["check", "test"] { if makefile_has_target(build_dir, target)? { @@ -292,6 +431,48 @@ fn maybe_find_autotools_test_target( find_autotools_test_target(build_dir) } +fn resolve_make_dirs(build_dir: &Path, dirs: &[String], field_name: &str) -> Result> { + if dirs.is_empty() { + return Ok(vec![build_dir.to_path_buf()]); + } + + let canonical_build = fs::canonicalize(build_dir) + .with_context(|| format!("Failed to resolve build directory {}", build_dir.display()))?; + let mut out = Vec::with_capacity(dirs.len()); + for raw in dirs { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("{} contains an empty directory entry", field_name); + } + let rel = Path::new(trimmed); + if rel.is_absolute() { + anyhow::bail!("{} entry '{}' must be a relative path", field_name, trimmed); + } + + let candidate = build_dir.join(rel); + let canonical_candidate = fs::canonicalize(&candidate).with_context(|| { + format!( + "{} entry '{}' does not exist in {}", + field_name, + trimmed, + build_dir.display() + ) + })?; + if !canonical_candidate.starts_with(&canonical_build) { + anyhow::bail!( + "{} entry '{}' resolves outside build directory", + field_name, + trimmed + ); + } + if !canonical_candidate.is_dir() { + anyhow::bail!("{} entry '{}' is not a directory", field_name, trimmed); + } + out.push(canonical_candidate); + } + Ok(out) +} + fn makefile_has_target(build_dir: &Path, target: &str) -> Result { for name in ["GNUmakefile", "Makefile", "makefile"] { let path = build_dir.join(name); @@ -339,6 +520,75 @@ fn makefile_content_has_target(content: &str, target: &str) -> bool { false } +fn nonempty_trimmed(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +fn resolve_make_exec(configured: &str) -> &str { + nonempty_trimmed(configured).unwrap_or("make") +} + +fn phase_targets(single: &str, many: &[String], default: Option<&str>) -> Vec { + let mut targets = Vec::new(); + if let Some(target) = nonempty_trimmed(single) { + targets.push(target.to_string()); + } + for target in many { + if let Some(target) = nonempty_trimmed(target) { + targets.push(target.to_string()); + } + } + if targets.is_empty() + && let Some(default_target) = default + { + targets.push(default_target.to_string()); + } + targets +} + +fn make_exec_supports_make_assignments(make_exec: &str) -> bool { + let Some(tool) = Path::new(make_exec) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase()) + else { + return false; + }; + + tool == "make" + || tool == "gmake" + || tool == "bmake" + || tool == "nmake" + || tool.ends_with("-make") + || tool.ends_with("make.exe") +} + +fn add_make_variable_overrides_if_supported( + cmd: &mut Command, + make_exec: &str, + vars: &[String], + phase: &str, +) -> Result<()> { + if make_exec_supports_make_assignments(make_exec) { + return add_make_variable_overrides(cmd, vars, phase); + } + if !vars.is_empty() { + let field_name = match phase { + "build" => "build.flags.make_vars", + "test" => "build.flags.make_test_vars", + "install" => "build.flags.make_install_vars", + _ => "build.flags.make_*_vars", + }; + anyhow::bail!( + "{} is only supported with make-like executables; build.flags.make_exec='{}'", + field_name, + make_exec + ); + } + Ok(()) +} + fn add_make_variable_overrides(cmd: &mut Command, vars: &[String], phase: &str) -> Result<()> { for raw in vars { let var = raw.trim(); @@ -396,7 +646,7 @@ fn expand_shell_commands(input: &str, cc: &str) -> Result { } _ => { // Silently skip failed commands (e.g., gcc doesn't support -print-resource-dir) - eprintln!("Warning: shell command '{}' failed, skipping", cmd); + crate::log_warn!("Shell command '{}' failed, skipping", cmd); String::new() } }; @@ -410,6 +660,29 @@ fn expand_shell_commands(input: &str, cc: &str) -> Result { Ok(result) } +fn expand_env_vars(input: &str) -> String { + let mut result = input.to_string(); + for (key, value) in std::env::vars() { + result = result.replace(&format!("${}", key), &value); + result = result.replace(&format!("${{{}}}", key), &value); + } + result +} + +fn expand_with_envs(input: &str, envs: &[(String, String)]) -> String { + let mut result = input.to_string(); + for (k, v) in envs { + result = result.replace(&format!("${}", k), v); + result = result.replace(&format!("${{{}}}", k), v); + } + expand_env_vars(&result) +} + +fn expand_configure_arg(spec: &PackageSpec, arg: &str, envs: &[(String, String)]) -> String { + let with_spec_vars = spec.expand_vars(arg); + expand_with_envs(&with_spec_vars, envs) +} + #[cfg(test)] mod tests { use super::*; @@ -433,6 +706,48 @@ mod tests { Ok(()) } + #[test] + fn test_expand_with_envs_prefers_provided_envs() { + let envs = vec![ + ("CARCH".to_string(), "x86_64".to_string()), + ("CHOST".to_string(), "x86_64-sfg-linux-gnu".to_string()), + ]; + let out = expand_with_envs("--with-gcc-arch=$CARCH --host=${CHOST}", &envs); + assert!(out.contains("--with-gcc-arch=x86_64")); + assert!(out.contains("--host=x86_64-sfg-linux-gnu")); + } + + #[test] + fn test_expand_configure_arg_expands_spec_and_env_vars() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.2.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let envs = vec![("CARCH".to_string(), "aarch64".to_string())]; + let expanded = + expand_configure_arg(&spec, "--program-prefix=$name-$version-$CARCH-", &envs); + assert_eq!(expanded, "--program-prefix=foo-1.2.3-aarch64-"); + } + #[test] fn test_num_cpus_at_least_one() { let n = num_cpus(); @@ -447,6 +762,16 @@ mod tests { assert!(!configure_help_supports_option(help, "--target")); } + #[test] + fn test_configure_supports_option_defaults_by_configure_file_usage() { + assert!(configure_supports_option(None, "--host", "")); + assert!(!configure_supports_option( + None, + "--host", + "build-aux/Configure" + )); + } + #[test] fn test_makefile_content_has_target_detects_check_and_test() { let content = r#" @@ -486,6 +811,31 @@ foo: bar Ok(()) } + #[test] + fn test_resolve_make_dirs_defaults_to_build_dir() -> Result<()> { + let tmp = tempdir().unwrap(); + let dirs = resolve_make_dirs(tmp.path(), &[], "build.flags.make_dirs")?; + assert_eq!(dirs, vec![tmp.path().to_path_buf()]); + Ok(()) + } + + #[test] + fn test_resolve_make_dirs_resolves_multiple_relative_dirs() -> Result<()> { + let tmp = tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("lib"))?; + std::fs::create_dir_all(tmp.path().join("libelf"))?; + let dirs = resolve_make_dirs( + tmp.path(), + &["lib".to_string(), "libelf".to_string()], + "build.flags.make_dirs", + )?; + assert_eq!( + dirs, + vec![tmp.path().join("lib"), tmp.path().join("libelf")] + ); + Ok(()) + } + #[test] fn test_add_make_variable_overrides_accepts_valid_assignments() -> Result<()> { let mut cmd = Command::new("make"); @@ -530,6 +880,49 @@ foo: bar )); } + #[test] + fn test_resolve_make_exec_defaults_and_trims() { + assert_eq!(resolve_make_exec(""), "make"); + assert_eq!(resolve_make_exec(" "), "make"); + assert_eq!(resolve_make_exec(" ninja "), "ninja"); + } + + #[test] + fn test_make_exec_supports_make_assignments_detects_make_variants() { + assert!(make_exec_supports_make_assignments("make")); + assert!(make_exec_supports_make_assignments("/usr/bin/gmake")); + assert!(!make_exec_supports_make_assignments("ninja")); + } + + #[test] + fn test_phase_targets_merges_singular_plural_and_default() { + assert_eq!( + phase_targets("bootstrap", &["stage1".into(), "stage2".into()], None), + vec![ + "bootstrap".to_string(), + "stage1".to_string(), + "stage2".to_string() + ] + ); + assert_eq!( + phase_targets("", &[], Some("install")), + vec!["install".to_string()] + ); + } + + #[test] + fn test_add_make_variable_overrides_if_supported_rejects_ninja_vars() { + let mut cmd = Command::new("ninja"); + let err = add_make_variable_overrides_if_supported( + &mut cmd, + "ninja", + &["V=1".to_string()], + "build", + ) + .expect_err("ninja should reject make variable override syntax"); + assert!(err.to_string().contains("build.flags.make_vars")); + } + #[test] fn test_resolve_actual_src_expands_source_subdir_vars() { let tmp = tempdir().unwrap(); @@ -558,10 +951,74 @@ foo: bar }, }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; let resolved = resolve_actual_src(&spec, &src_root).unwrap(); assert_eq!(resolved, expanded); } + + #[test] + fn test_resolve_configure_path_defaults_to_source_configure() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let actual_src = PathBuf::from("/tmp/src"); + let configure = resolve_configure_path(&spec, &actual_src); + assert_eq!(configure, actual_src.join("configure")); + } + + #[test] + fn test_resolve_configure_path_uses_configure_file_and_expands_vars() { + let mut flags = BuildFlags::default(); + flags.configure_file = "build-aux/$name-configure".into(); + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let actual_src = PathBuf::from("/tmp/src"); + let configure = resolve_configure_path(&spec, &actual_src); + assert_eq!(configure, actual_src.join("build-aux/foo-configure")); + } } diff --git a/src/builder/bin.rs b/src/builder/bin.rs index 3c05e4c..9473f0c 100644 --- a/src/builder/bin.rs +++ b/src/builder/bin.rs @@ -18,7 +18,7 @@ pub fn build( _cross: Option<&CrossConfig>, _export_compiler_flags: bool, ) -> Result<()> { - println!( + crate::log_info!( "Binary install: copying files from {} to {} (pkg type={})", src_dir.display(), destdir.display(), @@ -87,6 +87,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: std::path::PathBuf::from("."), } } diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 3489268..44b4665 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -16,6 +16,7 @@ pub fn build( export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; + let make_exec_override = flags.make_exec.trim(); // Determine actual source directory (support source_subdir) let actual_src = resolve_actual_src(spec, src_dir)?; @@ -49,7 +50,7 @@ pub fn build( // Run cmake configure if !state.is_done(BuildStep::Configured) { - println!("Running cmake configure..."); + crate::log_info!("Running cmake configure..."); let mut cmake_cmd = Command::new("cmake"); cmake_cmd.current_dir(&build_dir); cmake_cmd.arg("-S").arg(&actual_src); @@ -62,6 +63,17 @@ pub fn build( cmake_cmd.arg(format!("-DCMAKE_TOOLCHAIN_FILE={}", tf.display())); } + if !make_exec_override.is_empty() { + if !cmake_configure_flags_specify_generator(&flags.configure) + && let Some(generator) = cmake_generator_for_make_exec(make_exec_override) + { + cmake_cmd.arg("-G").arg(generator); + } + if !cmake_configure_flags_set_make_program(&flags.configure) { + cmake_cmd.arg(format!("-DCMAKE_MAKE_PROGRAM={make_exec_override}")); + } + } + // Add custom configure flags from spec (supports cross-compilation overrides). // Expand using env-vars we will supply to the child process first so patterns // like `$CXX` (coming from flags.cxx) are substituted. @@ -76,17 +88,26 @@ pub fn build( if !status.success() { anyhow::bail!("cmake configure failed"); } + + crate::source::hooks::run_post_configure_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::Configured)?; } else { - println!("Skipping cmake configure (already done)"); + crate::log_info!("Skipping cmake configure (already done)"); } if !state.is_done(BuildStep::PostCompileDone) { // Run cmake build - println!("Running cmake build..."); + crate::log_info!("Running cmake build..."); + let build_targets = phase_targets(&flags.make_target, &flags.make_targets); let mut build_cmd = Command::new("cmake"); build_cmd.arg("--build").arg(&build_dir); build_cmd.arg("-j").arg(num_cpus().to_string()); + if !build_targets.is_empty() { + build_cmd.arg("--target"); + for target in &build_targets { + build_cmd.arg(target); + } + } crate::builder::prepare_command(&mut build_cmd, &env_vars); @@ -97,15 +118,44 @@ pub fn build( anyhow::bail!("cmake build failed"); } + if flags.skip_tests { + crate::log_info!("Skipping tests: disabled by build.flags.skip_tests"); + } else { + let test_targets = phase_targets(&flags.make_test_target, &flags.make_test_targets); + if !test_targets.is_empty() { + let joined = test_targets.join(" "); + crate::log_info!("Running cmake test target(s): {}...", joined); + let mut test_cmd = Command::new("cmake"); + test_cmd.arg("--build").arg(&build_dir); + test_cmd.arg("--target"); + for target in &test_targets { + test_cmd.arg(target); + } + crate::builder::prepare_command(&mut test_cmd, &env_vars); + + let status = test_cmd.status().with_context(|| { + format!( + "Failed to run cmake build target(s) '{}' for {}", + joined, spec.package.name + ) + })?; + if !status.success() { + anyhow::bail!("cmake test target(s) '{}' failed", joined); + } + } else { + crate::log_info!("Skipping tests: no build.flags.make_test_target(s) for cmake"); + } + } + crate::source::hooks::run_post_compile_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; } else { - println!("Skipping cmake build and post-compile hooks (already done)"); + crate::log_info!("Skipping cmake build and post-compile hooks (already done)"); } if !state.is_done(BuildStep::PostInstallDone) { // Run cmake install with fakeroot if not root - println!( + crate::log_info!( "Running cmake install{}...", if fakeroot::is_root() { "" @@ -114,8 +164,18 @@ pub fn build( } ); + let install_targets = + phase_targets(&flags.make_install_target, &flags.make_install_targets); let mut install_cmd = fakeroot::wrap_install_command("cmake", destdir); - install_cmd.arg("--install").arg(&build_dir); + if !install_targets.is_empty() { + install_cmd.arg("--build").arg(&build_dir); + install_cmd.arg("--target"); + for target in &install_targets { + install_cmd.arg(target); + } + } else { + install_cmd.arg("--install").arg(&build_dir); + } let mut install_env = env_vars.clone(); install_env.push(( @@ -128,13 +188,19 @@ pub fn build( .status() .with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?; if !status.success() { + if !install_targets.is_empty() { + anyhow::bail!( + "cmake install target(s) '{}' failed", + install_targets.join(" ") + ); + } anyhow::bail!("cmake install failed"); } crate::source::hooks::run_post_install_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostInstallDone)?; } else { - println!("Skipping cmake install and post-install hooks (already done)"); + crate::log_info!("Skipping cmake install and post-install hooks (already done)"); } Ok(()) @@ -161,6 +227,61 @@ fn expand_with_envs(input: &str, envs: &[(String, String)]) -> String { expand_env_vars(&result) } +fn nonempty_trimmed(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +fn phase_targets(single: &str, many: &[String]) -> Vec { + let mut targets = Vec::new(); + if let Some(target) = nonempty_trimmed(single) { + targets.push(target.to_string()); + } + for target in many { + if let Some(target) = nonempty_trimmed(target) { + targets.push(target.to_string()); + } + } + targets +} + +fn cmake_generator_for_make_exec(make_exec: &str) -> Option<&'static str> { + let tool = Path::new(make_exec) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase())?; + if tool.contains("ninja") { + Some("Ninja") + } else if tool == "make" + || tool == "gmake" + || tool == "bmake" + || tool == "nmake" + || tool.ends_with("-make") + || tool.ends_with("make.exe") + { + Some("Unix Makefiles") + } else { + None + } +} + +fn cmake_configure_flags_specify_generator(flags: &[String]) -> bool { + flags.iter().any(|flag| { + let trimmed = flag.trim(); + trimmed == "-G" + || trimmed.starts_with("-G") + || trimmed == "--generator" + || trimmed.starts_with("--generator=") + }) +} + +fn cmake_configure_flags_set_make_program(flags: &[String]) -> bool { + flags.iter().any(|flag| { + let trimmed = flag.trim(); + trimmed.starts_with("-DCMAKE_MAKE_PROGRAM=") + }) +} + fn num_cpus() -> usize { std::thread::available_parallelism() .map(|n| n.get()) @@ -257,6 +378,50 @@ mod tests { assert!(n >= 1); } + #[test] + fn test_phase_targets_merges_singular_and_plural() { + assert_eq!( + phase_targets("bootstrap", &["stage1".into(), "stage2".into()]), + vec![ + "bootstrap".to_string(), + "stage1".to_string(), + "stage2".to_string() + ] + ); + assert!(phase_targets("", &[]).is_empty()); + } + + #[test] + fn test_cmake_generator_for_make_exec_detects_ninja_and_make() { + assert_eq!(cmake_generator_for_make_exec("ninja"), Some("Ninja")); + assert_eq!( + cmake_generator_for_make_exec("/usr/bin/gmake"), + Some("Unix Makefiles") + ); + assert_eq!(cmake_generator_for_make_exec("samurai"), None); + } + + #[test] + fn test_cmake_configure_flag_detectors() { + assert!(cmake_configure_flags_specify_generator(&[ + "-G".to_string(), + "Ninja".to_string() + ])); + assert!(cmake_configure_flags_specify_generator(&[ + "--generator=Unix Makefiles".to_string() + ])); + assert!(!cmake_configure_flags_specify_generator(&[ + "-DCMAKE_BUILD_TYPE=Release".to_string() + ])); + + assert!(cmake_configure_flags_set_make_program(&[ + "-DCMAKE_MAKE_PROGRAM=/usr/bin/ninja".to_string() + ])); + assert!(!cmake_configure_flags_set_make_program(&[ + "-DCMAKE_C_COMPILER=clang".to_string() + ])); + } + #[test] fn resolve_actual_src_prefers_srcdir_then_specdir_and_handles_absolute() { let tmp = tempdir().unwrap(); @@ -291,6 +456,8 @@ mod tests { }, }, dependencies: Default::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: spec_dir.clone(), }; diff --git a/src/builder/custom.rs b/src/builder/custom.rs index 17d2505..f329888 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -3,9 +3,11 @@ use crate::cross::CrossConfig; use crate::fakeroot; use crate::package::PackageSpec; +use crate::source::hooks; use anyhow::{Context, Result}; use std::fs; use std::path::Path; +use std::process::Command; pub fn build( spec: &PackageSpec, @@ -20,6 +22,8 @@ pub fn build( fs::create_dir_all(destdir)?; let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); + let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; + shell_helpers.apply_to_env_vars(&mut env_vars); // For custom builds, look for a build.sh script in the source directory let build_script = src_dir.join("build.sh"); @@ -44,7 +48,7 @@ pub fn build( perms.set_mode(0o755); fs::set_permissions(&build_script, perms)?; } - println!("Using build.sh from spec dir: {}", spec_build.display()); + crate::log_info!("Using build.sh from spec dir: {}", spec_build.display()); } if !build_script.exists() { @@ -57,8 +61,15 @@ pub fn build( use crate::builder::state::{BuildStep, StateTracker}; let mut state = StateTracker::new(src_dir)?; + if !state.is_done(BuildStep::Configured) { + hooks::run_post_configure_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::Configured)?; + } else { + crate::log_info!("Skipping post-configure hooks (already done)"); + } + if !state.is_done(BuildStep::PostInstallDone) { - println!( + crate::log_info!( "Running custom build script{}...", if fakeroot::is_root() { "" @@ -75,9 +86,17 @@ pub fn build( src_dir.to_path_buf() }; - // Use POSIX `sh` (more likely to be available in minimal/chroot environments) - let mut cmd = fakeroot::wrap_install_command("sh", destdir); - cmd.current_dir(&build_dir); + crate::builder::set_env_var( + &mut env_vars, + "DESTDIR", + destdir.to_string_lossy().into_owned(), + ); + crate::builder::set_env_var( + &mut env_vars, + "DEPOT_PRIMARY_DESTDIR", + destdir.to_string_lossy().into_owned(), + ); + add_output_destdir_envs(spec, destdir, &mut env_vars); // Ensure build script path is absolute for when we are in a sub-build-dir let abs_build_script = if build_script.is_absolute() { @@ -85,13 +104,19 @@ pub fn build( } else { std::env::current_dir()?.join(&build_script) }; - cmd.arg(&abs_build_script); - crate::builder::set_env_var( - &mut env_vars, - "DESTDIR", - destdir.to_string_lossy().into_owned(), - ); + // Use POSIX `sh` (more likely to be available in minimal/chroot environments) + let mut cmd = if custom_function_mode_enabled(&abs_build_script)? { + crate::log_info!( + "Using custom build.sh function mode (per-output install functions enabled)" + ); + build_function_mode_command(spec, destdir, &abs_build_script)? + } else { + let mut cmd = fakeroot::wrap_install_command("sh", destdir); + cmd.arg(&abs_build_script); + cmd + }; + cmd.current_dir(&build_dir); crate::builder::prepare_command(&mut cmd, &env_vars); @@ -109,12 +134,108 @@ pub fn build( } state.mark_done(BuildStep::PostInstallDone)?; } else { - println!("Skipping custom build script (already done)"); + crate::log_info!("Skipping custom build script (already done)"); } Ok(()) } +fn add_output_destdir_envs( + spec: &PackageSpec, + destdir: &Path, + env_vars: &mut crate::builder::EnvVars, +) { + for out in spec.outputs() { + let out_destdir = if out.name == spec.package.name { + destdir.to_path_buf() + } else { + crate::staging::output_staging_dir(destdir, &out.name) + }; + let suffix = crate::shell_helpers::shell_ident_suffix(&out.name); + crate::builder::set_env_var( + env_vars, + &format!("DEPOT_SUBDESTDIR_{suffix}"), + out_destdir.to_string_lossy().into_owned(), + ); + } +} + +fn custom_function_mode_enabled(build_script: &Path) -> Result { + let contents = fs::read_to_string(build_script) + .with_context(|| format!("Failed to read build script: {}", build_script.display()))?; + Ok(contents.contains("depot_build()") + || contents.contains("depot_install()") + || contents.contains("depot_install_") + || contents.contains("install_")) +} + +fn build_function_mode_command( + spec: &PackageSpec, + destdir: &Path, + build_script: &Path, +) -> Result { + let mut wrapper = String::new(); + wrapper.push_str("set -eu\n"); + wrapper.push_str(". \"$1\"\n"); + wrapper.push_str("if command -v depot_build >/dev/null 2>&1; then depot_build;\n"); + wrapper.push_str("elif command -v build >/dev/null 2>&1; then build;\n"); + wrapper.push_str("fi\n"); + + let primary = &spec.package.name; + for out in spec.outputs() { + let out_destdir = if out.name == *primary { + destdir.to_path_buf() + } else { + crate::staging::output_staging_dir(destdir, &out.name) + }; + let fn_suffix = shell_fn_suffix(&out.name); + let q_name = sh_single_quote(&out.name); + let q_dest = sh_single_quote(&out_destdir.to_string_lossy()); + + wrapper.push_str(&format!( + "DEPOT_OUTPUT_NAME='{q_name}'; DEPOT_OUTPUT_DESTDIR='{q_dest}'; DESTDIR=\"$DEPOT_OUTPUT_DESTDIR\"; export DEPOT_OUTPUT_NAME DEPOT_OUTPUT_DESTDIR DESTDIR\n" + )); + wrapper.push_str(&format!( + "if command -v depot_install_{fn_suffix} >/dev/null 2>&1; then depot_install_{fn_suffix};\n" + )); + wrapper.push_str(&format!( + "elif command -v install_{fn_suffix} >/dev/null 2>&1; then install_{fn_suffix};\n" + )); + if out.name == *primary { + wrapper + .push_str("elif command -v depot_install >/dev/null 2>&1; then depot_install;\n"); + wrapper.push_str("elif command -v install >/dev/null 2>&1; then install;\n"); + } + wrapper.push_str("fi\n"); + } + + let mut cmd = fakeroot::wrap_install_command("sh", destdir); + cmd.arg("-c").arg(wrapper).arg("sh").arg(build_script); + Ok(cmd) +} + +fn shell_fn_suffix(pkg_name: &str) -> String { + let mut out = String::with_capacity(pkg_name.len().max(1)); + for ch in pkg_name.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + out.push('_'); + } + if out.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn sh_single_quote(s: &str) -> String { + s.replace('\'', "'\"'\"'") +} + #[cfg(test)] mod tests { use super::*; @@ -146,6 +267,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: std::path::PathBuf::from("."), } } @@ -188,4 +311,56 @@ mod tests { assert!(tmp_src.path().join("build.sh").exists()); Ok(()) } + + #[test] + fn test_build_function_mode_uses_per_output_destdirs() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + + let build_sh = tmp_src.path().join("build.sh"); + std::fs::write( + &build_sh, + r#"#!/bin/sh +depot_build() { + : +} +depot_install() { + mkdir -p "$DESTDIR/usr/share" + echo primary > "$DESTDIR/usr/share/primary.txt" +} +depot_install_dev_pkg() { + mkdir -p "$DESTDIR/usr/include" + echo header > "$DESTDIR/usr/include/dev.h" +} +"#, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&build_sh)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&build_sh, perms)?; + } + + let mut spec = mk_spec("demo", "1.0"); + spec.packages.push(crate::package::PackageInfo { + name: "dev-pkg".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }); + + build(&spec, tmp_src.path(), tmp_dest.path(), None, true)?; + + assert!(tmp_dest.path().join("usr/share/primary.txt").exists()); + assert!( + tmp_dest + .path() + .join(".depot/outputs/dev-pkg/usr/include/dev.h") + .exists() + ); + Ok(()) + } } diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index 8a6b751..afa5c1f 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -26,12 +26,19 @@ pub fn build( ); } + if !state.is_done(BuildStep::Configured) { + crate::source::hooks::run_post_configure_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::Configured)?; + } else { + crate::log_info!("Skipping post-configure hooks (already done)"); + } + if !state.is_done(BuildStep::PostCompileDone) { - println!("Running makefile build commands..."); + crate::log_info!("Running makefile build commands..."); for cmd_str in &spec.build.flags.makefile_commands { let cmd_str = spec.expand_vars(cmd_str); - println!(" Executing: {}", cmd_str); + crate::log_info!(" Executing: {}", cmd_str); let mut cmd = Command::new("sh"); cmd.arg("-c").arg(&cmd_str); @@ -49,12 +56,12 @@ pub fn build( crate::source::hooks::run_post_compile_commands(spec, src_dir, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; } else { - println!("Skipping makefile build commands (already done)"); + crate::log_info!("Skipping makefile build commands (already done)"); } if !state.is_done(BuildStep::PostInstallDone) { // Run install commands with fakeroot - println!( + crate::log_info!( "Running makefile install commands{}...", if crate::fakeroot::is_root() { "" @@ -65,7 +72,7 @@ pub fn build( for cmd_str in &spec.build.flags.makefile_install_commands { let cmd_str = spec.expand_vars(cmd_str); - println!(" Executing: {}", cmd_str); + crate::log_info!(" Executing: {}", cmd_str); // We need to run each command under fakeroot let mut cmd = crate::fakeroot::wrap_install_command("sh", destdir); @@ -90,7 +97,7 @@ pub fn build( crate::source::hooks::run_post_install_commands(spec, src_dir, destdir)?; state.mark_done(BuildStep::PostInstallDone)?; } else { - println!("Skipping makefile install commands (already done)"); + crate::log_info!("Skipping makefile install commands (already done)"); } Ok(()) @@ -128,6 +135,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: std::path::PathBuf::from("."), } } diff --git a/src/builder/meson.rs b/src/builder/meson.rs index c62da89..279368b 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -3,9 +3,9 @@ use crate::cross::CrossConfig; use crate::fakeroot; use crate::package::PackageSpec; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; pub fn build( @@ -16,7 +16,11 @@ pub fn build( export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; - let build_dir = src_dir.join("builddir"); + + // Determine actual source directory (support source_subdir) + let actual_src = resolve_actual_src(spec, src_dir)?; + + let build_dir = resolve_build_dir(&actual_src, flags); // Create directories fs::create_dir_all(&build_dir)?; @@ -25,14 +29,6 @@ pub fn build( // Environment variables let env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); - // Extract prefix from configure flags - let prefix = flags - .configure - .iter() - .find(|s| s.starts_with("--prefix=")) - .map(|s| s.trim_start_matches("--prefix=")) - .unwrap_or(&flags.prefix); - // Generate cross file if cross-compiling let cross_file = if let Some(cc_cfg) = cross { Some(cc_cfg.generate_meson_cross_file(&build_dir)?) @@ -41,21 +37,18 @@ pub fn build( }; use crate::builder::state::{BuildStep, StateTracker}; - let mut state = StateTracker::new(src_dir)?; + let mut state = StateTracker::new(&actual_src)?; // Run meson setup if !state.is_done(BuildStep::Configured) { - println!("Running meson setup..."); + crate::log_info!("Running meson setup..."); let mut meson_cmd = Command::new("meson"); - meson_cmd.current_dir(src_dir); + meson_cmd.current_dir(&actual_src); meson_cmd.arg("setup"); meson_cmd.arg(&build_dir); - meson_cmd.arg(format!("--prefix={}", prefix)); - meson_cmd.arg("--buildtype=release"); - // Add cross file for cross-compilation - if let Some(ref cf) = cross_file { - meson_cmd.arg(format!("--cross-file={}", cf.display())); + for arg in meson_setup_args(flags, cross_file.as_deref(), &env_vars) { + meson_cmd.arg(arg); } crate::builder::prepare_command(&mut meson_cmd, &env_vars); @@ -64,14 +57,16 @@ pub fn build( if !status.success() { anyhow::bail!("meson setup failed"); } + + crate::source::hooks::run_post_configure_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::Configured)?; } else { - println!("Skipping meson setup (already done)"); + crate::log_info!("Skipping meson setup (already done)"); } if !state.is_done(BuildStep::PostCompileDone) { // Run ninja build - println!("Running ninja..."); + crate::log_info!("Running ninja..."); let mut ninja_cmd = Command::new("ninja"); ninja_cmd.current_dir(&build_dir); ninja_cmd.arg("-j").arg(num_cpus().to_string()); @@ -85,15 +80,15 @@ pub fn build( anyhow::bail!("ninja build failed"); } - crate::source::hooks::run_post_compile_commands(spec, src_dir, destdir)?; + crate::source::hooks::run_post_compile_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; } else { - println!("Skipping ninja build and post-compile hooks (already done)"); + crate::log_info!("Skipping ninja build and post-compile hooks (already done)"); } if !state.is_done(BuildStep::PostInstallDone) { // Run meson install with fakeroot if not root - println!( + crate::log_info!( "Running meson install{}...", if fakeroot::is_root() { "" @@ -120,10 +115,10 @@ pub fn build( anyhow::bail!("meson install failed"); } - crate::source::hooks::run_post_install_commands(spec, src_dir, destdir)?; + crate::source::hooks::run_post_install_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostInstallDone)?; } else { - println!("Skipping meson install and post-install hooks (already done)"); + crate::log_info!("Skipping meson install and post-install hooks (already done)"); } Ok(()) @@ -135,13 +130,212 @@ fn num_cpus() -> usize { .unwrap_or(1) } +fn resolve_build_dir(actual_src: &Path, flags: &crate::package::BuildFlags) -> PathBuf { + if let Some(dir) = flags + .build_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + actual_src.join(dir) + } else { + actual_src.join("builddir") + } +} + +fn has_option(configure: &[String], long: &str) -> bool { + let prefix = format!("{long}="); + for arg in configure { + if arg == long || arg.starts_with(&prefix) { + return true; + } + } + false +} + +fn meson_setup_args( + flags: &crate::package::BuildFlags, + cross_file: Option<&Path>, + env_vars: &[(String, String)], +) -> Vec { + let mut args = Vec::new(); + + if !has_option(&flags.configure, "--prefix") { + args.push(format!("--prefix={}", flags.prefix)); + } + if !has_option(&flags.configure, "--buildtype") { + args.push("--buildtype=release".to_string()); + } + + if let Some(cf) = cross_file { + args.push(format!("--cross-file={}", cf.display())); + } + + // Append user flags last so they can override defaults when Meson allows it. + for arg in &flags.configure { + args.push(expand_with_envs(arg, env_vars)); + } + + args +} + +/// Expand environment variables in a string (e.g., $DEPOT_SYSROOT) +fn expand_env_vars(input: &str) -> String { + let mut result = input.to_string(); + // Simple expansion for $VAR and ${VAR} patterns using process environment only + for (key, value) in std::env::vars() { + result = result.replace(&format!("${key}"), &value); + result = result.replace(&format!("${{{key}}}"), &value); + } + result +} + +/// Expand using a provided set of env vars (used to expand flags before spawning child). +fn expand_with_envs(input: &str, envs: &[(String, String)]) -> String { + let mut result = input.to_string(); + for (k, v) in envs { + result = result.replace(&format!("${k}"), v); + result = result.replace(&format!("${{{k}}}"), v); + } + expand_env_vars(&result) +} + +/// Resolve `source_subdir` with multiple fallbacks: +/// - empty -> use `src_dir` +/// - absolute path -> use if exists +/// - `src_dir/` -> use if exists +/// - `spec.spec_dir/` -> use if exists +/// - bare relative path (cwd) +fn resolve_actual_src(spec: &crate::package::PackageSpec, src_dir: &Path) -> Result { + let source_subdir = spec.expand_vars(&spec.build.flags.source_subdir); + if source_subdir.is_empty() { + return Ok(src_dir.to_path_buf()); + } + + let candidate = Path::new(&source_subdir); + if candidate.is_absolute() { + if candidate.exists() { + return Ok(candidate.to_path_buf()); + } + bail!( + "Source directory not found: {} (source_subdir: {} -> {})", + candidate.display(), + spec.build.flags.source_subdir, + source_subdir + ); + } + + let under_src = src_dir.join(&source_subdir); + if under_src.exists() { + return Ok(under_src); + } + + let under_spec = spec.spec_dir.join(&source_subdir); + if under_spec.exists() { + return Ok(under_spec); + } + + if candidate.exists() { + return Ok(candidate.to_path_buf()); + } + + bail!( + "Source directory not found: {} (expanded from '{}'; tried src_dir, spec_dir, and absolute path)", + source_subdir, + spec.build.flags.source_subdir + ); +} + #[cfg(test)] mod tests { use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source}; + use tempfile::tempdir; #[test] fn test_num_cpus_at_least_one() { let n = num_cpus(); assert!(n >= 1); } + + #[test] + fn test_meson_setup_args_include_configure_flags() { + let mut flags = BuildFlags { + prefix: "/usr".to_string(), + ..BuildFlags::default() + }; + flags.configure = vec!["-Dmanpages=false".to_string()]; + + let args = meson_setup_args(&flags, None, &[]); + assert!(args.iter().any(|a| a == "-Dmanpages=false")); + assert!(args.iter().any(|a| a == "--prefix=/usr")); + assert!(args.iter().any(|a| a == "--buildtype=release")); + } + + #[test] + fn test_meson_setup_args_honor_explicit_prefix() { + let mut flags = BuildFlags::default(); + flags.prefix = "/usr".to_string(); + flags.configure = vec!["--prefix=/opt".to_string()]; + + let args = meson_setup_args(&flags, None, &[]); + assert_eq!(args.iter().filter(|a| a.starts_with("--prefix")).count(), 1); + assert!(args.iter().any(|a| a == "--prefix=/opt")); + } + + #[test] + fn test_resolve_build_dir_uses_flag() { + let flags = BuildFlags { + build_dir: Some("build".to_string()), + ..BuildFlags::default() + }; + let src = Path::new("/tmp/src"); + assert_eq!( + resolve_build_dir(src, &flags), + PathBuf::from("/tmp/src/build") + ); + } + + #[test] + fn test_resolve_actual_src_uses_source_subdir_under_source() -> Result<()> { + let src = tempdir()?; + let spec_dir = tempdir()?; + fs::create_dir_all(src.path().join("sub"))?; + + let spec = PackageSpec { + package: PackageInfo { + name: "pkg".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "u".into(), + sha256: "s".into(), + extract_dir: "e".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Meson, + flags: BuildFlags { + source_subdir: "sub".into(), + ..BuildFlags::default() + }, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: spec_dir.path().to_path_buf(), + }; + + let resolved = resolve_actual_src(&spec, src.path())?; + assert_eq!(resolved, src.path().join("sub")); + Ok(()) + } } diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 0248742..fb8cfc2 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -6,6 +6,7 @@ mod cmake; mod custom; mod makefile; mod meson; +mod python; mod rust; pub mod state; @@ -123,11 +124,15 @@ pub fn standard_build_env( pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) { cmd.env_clear(); // Preserve essential environment variables - for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR", "DEPOT_ROOTFS"] { + for var in &["PATH", "LANG", "HOME", "DESTDIR", "DEPOT_ROOTFS"] { if let Ok(val) = std::env::var(var) { cmd.env(var, val); } } + // Use a deterministic POSIX shell for build tooling. Inheriting an + // interactive shell (e.g. zsh) can make Autotools-generated scripts + // produce non-reproducible or incompatible shell fragments. + cmd.env("SHELL", "/bin/sh"); // Set requested environment variables for (key, val) in env_vars { cmd.env(key, val); @@ -143,12 +148,13 @@ pub fn build( export_compiler_flags: bool, ) -> Result<()> { if let Some(cc) = cross { - println!( + crate::log_info!( "Cross-compiling for {} with {:?}...", - cc.prefix, spec.build.build_type + cc.prefix, + spec.build.build_type ); } else { - println!("Building with {:?}...", spec.build.build_type); + crate::log_info!("Building with {:?}...", spec.build.build_type); } // Clean destdir to prevent stale files/directories (e.g., directories where symlinks should be) @@ -163,6 +169,7 @@ pub fn build( BuildType::CMake => cmake::build(spec, src_dir, destdir, cross, export_compiler_flags), BuildType::Meson => meson::build(spec, src_dir, destdir, cross, export_compiler_flags), BuildType::Custom => custom::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Python => python::build(spec, src_dir, destdir, cross, export_compiler_flags), BuildType::Rust => rust::build(spec, src_dir, destdir, cross, export_compiler_flags), BuildType::Bin => bin::build(spec, src_dir, destdir, cross, export_compiler_flags), BuildType::Makefile => { @@ -206,6 +213,8 @@ mod tests { flags, }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), } } @@ -219,6 +228,7 @@ mod tests { unsafe { std::env::set_var("PATH", "/usr/bin"); std::env::set_var("HOME", "/home/test"); + std::env::set_var("SHELL", "/bin/zsh"); std::env::set_var("DEPOT_ROOTFS", "/my/rootfs"); } @@ -228,6 +238,10 @@ mod tests { assert!(envs.get(OsStr::new("PATH")).is_some()); assert!(envs.get(OsStr::new("HOME")).is_some()); assert!(envs.get(OsStr::new("FORBIDDEN")).is_none()); + assert_eq!( + envs.get(OsStr::new("SHELL")), + Some(&Some(std::ffi::OsString::from("/bin/sh").as_os_str())) + ); assert_eq!( envs.get(OsStr::new("MYVAR")), Some(&Some(std::ffi::OsString::from("myval").as_os_str())) diff --git a/src/builder/python.rs b/src/builder/python.rs new file mode 100644 index 0000000..d637597 --- /dev/null +++ b/src/builder/python.rs @@ -0,0 +1,825 @@ +//! Python build backend without external helper modules. +//! +//! This backend intentionally avoids `python -m build` and +//! `python -m installer`. It drives PEP 517 backends directly via +//! `python3` and installs wheel contents in Rust. + +use crate::builder::state::{BuildStep, StateTracker}; +use crate::cross::CrossConfig; +use crate::package::PackageSpec; +use crate::source::hooks; +use anyhow::{Context, Result, bail}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +struct Pep517Config { + backend: String, + requires: Vec, + backend_paths: Vec, +} + +enum BuildFrontend { + Pep517(Pep517Config), + LegacySetupPy, +} + +pub fn build( + spec: &PackageSpec, + src_dir: &Path, + destdir: &Path, + cross: Option<&CrossConfig>, + export_compiler_flags: bool, +) -> Result<()> { + let flags = &spec.build.flags; + let actual_src = resolve_actual_src(spec, src_dir)?; + fs::create_dir_all(destdir) + .with_context(|| format!("Failed to create DESTDIR: {}", destdir.display()))?; + + let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); + crate::builder::set_env_var(&mut env_vars, "PYTHONNOUSERSITE", "1"); + crate::builder::set_env_var(&mut env_vars, "PYTHONDONTWRITEBYTECODE", "1"); + crate::builder::set_env_var(&mut env_vars, "SETUPTOOLS_USE_DISTUTILS", "local"); + + let mut state = StateTracker::new(&actual_src)?; + if !state.is_done(BuildStep::Configured) { + hooks::run_post_configure_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::Configured)?; + } + + let dist_dir = actual_src.join("dist"); + if !state.is_done(BuildStep::PostCompileDone) { + if dist_dir.exists() { + fs::remove_dir_all(&dist_dir) + .with_context(|| format!("Failed to clean dist dir: {}", dist_dir.display()))?; + } + fs::create_dir_all(&dist_dir) + .with_context(|| format!("Failed to create dist dir: {}", dist_dir.display()))?; + + match detect_frontend(&actual_src)? { + BuildFrontend::Pep517(cfg) => { + build_wheel_pep517(&actual_src, &dist_dir, &env_vars, &cfg)? + } + BuildFrontend::LegacySetupPy => { + build_wheel_setup_py(&actual_src, &dist_dir, &env_vars)?; + } + } + + hooks::run_post_compile_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostCompileDone)?; + } else { + crate::log_info!("Skipping python wheel build and post-compile hooks (already done)"); + } + + if !state.is_done(BuildStep::PostInstallDone) { + let wheels = collect_wheels(&dist_dir)?; + let py_version = detect_python_major_minor(&env_vars)?; + let prefix_rel = normalized_prefix(&flags.prefix)?; + for wheel in wheels { + install_wheel(&wheel, destdir, &prefix_rel, &py_version)?; + } + + hooks::run_post_install_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostInstallDone)?; + } else { + crate::log_info!("Skipping python wheel install and post-install hooks (already done)"); + } + + Ok(()) +} + +fn resolve_actual_src(spec: &PackageSpec, src_dir: &Path) -> Result { + let source_subdir = spec.expand_vars(&spec.build.flags.source_subdir); + if source_subdir.is_empty() { + return Ok(src_dir.to_path_buf()); + } + + let candidate = Path::new(&source_subdir); + if candidate.is_absolute() { + if candidate.exists() { + return Ok(candidate.to_path_buf()); + } + bail!( + "Source directory not found: {} (source_subdir: {} -> {})", + candidate.display(), + spec.build.flags.source_subdir, + source_subdir + ); + } + + let under_src = src_dir.join(&source_subdir); + if under_src.exists() { + return Ok(under_src); + } + + let under_spec = spec.spec_dir.join(&source_subdir); + if under_spec.exists() { + return Ok(under_spec); + } + + if candidate.exists() { + return Ok(candidate.to_path_buf()); + } + + bail!( + "Source directory not found: {} (expanded from '{}'; tried src_dir, spec_dir, and absolute path)", + source_subdir, + spec.build.flags.source_subdir + ); +} + +fn detect_frontend(src_dir: &Path) -> Result { + let pyproject = src_dir.join("pyproject.toml"); + if !pyproject.exists() { + if src_dir.join("setup.py").exists() { + return Ok(BuildFrontend::LegacySetupPy); + } + bail!( + "Python build requires pyproject.toml (PEP 517) or setup.py in {}", + src_dir.display() + ); + } + + let content = fs::read_to_string(&pyproject) + .with_context(|| format!("Failed to read {}", pyproject.display()))?; + let parsed: toml::Value = toml::from_str(&content) + .with_context(|| format!("Failed to parse {}", pyproject.display()))?; + + let build_system = parsed.get("build-system").and_then(|v| v.as_table()); + let backend = build_system + .and_then(|t| t.get("build-backend")) + .and_then(|v| v.as_str()) + .unwrap_or("setuptools.build_meta:__legacy__") + .to_string(); + + let requires = match build_system.and_then(|t| t.get("requires")) { + Some(v) => { + let arr = v.as_array().with_context(|| { + format!( + "Invalid build-system.requires in {}: expected array", + pyproject.display() + ) + })?; + arr.iter() + .map(|x| { + x.as_str().map(String::from).with_context(|| { + format!( + "Invalid build-system.requires entry in {}: expected string", + pyproject.display() + ) + }) + }) + .collect::>>()? + } + None => Vec::new(), + }; + + let src_root = src_dir + .canonicalize() + .with_context(|| format!("Failed to canonicalize source dir {}", src_dir.display()))?; + + let backend_paths = match build_system.and_then(|t| t.get("backend-path")) { + Some(v) => { + let arr = v.as_array().with_context(|| { + format!( + "Invalid build-system.backend-path in {}: expected array", + pyproject.display() + ) + })?; + let mut out = Vec::with_capacity(arr.len()); + for raw in arr { + let rel = raw.as_str().with_context(|| { + format!( + "Invalid build-system.backend-path entry in {}: expected string", + pyproject.display() + ) + })?; + let rel_path = Path::new(rel); + if rel_path.is_absolute() { + bail!( + "Invalid build-system.backend-path entry in {}: absolute paths are not allowed ({})", + pyproject.display(), + rel + ); + } + let joined = src_dir.join(rel_path); + let canon = joined.canonicalize().with_context(|| { + format!( + "build-system.backend-path entry not found in {}: {}", + pyproject.display(), + joined.display() + ) + })?; + if !canon.starts_with(&src_root) { + bail!( + "Invalid build-system.backend-path entry in {}: path escapes source tree ({})", + pyproject.display(), + rel + ); + } + out.push(canon); + } + out + } + None => Vec::new(), + }; + + Ok(BuildFrontend::Pep517(Pep517Config { + backend, + requires, + backend_paths, + })) +} + +fn build_wheel_setup_py( + src_dir: &Path, + dist_dir: &Path, + env_vars: &[(String, String)], +) -> Result<()> { + crate::log_info!("Building wheel with setup.py..."); + let mut cmd = Command::new("python3"); + cmd.current_dir(src_dir) + .arg("setup.py") + .arg("bdist_wheel") + .arg("--dist-dir") + .arg(dist_dir); + crate::builder::prepare_command(&mut cmd, &env_vars.to_vec()); + + let status = cmd + .status() + .with_context(|| format!("Failed to run setup.py in {}", src_dir.display()))?; + if !status.success() { + bail!("setup.py bdist_wheel failed with status {}", status); + } + Ok(()) +} + +fn build_wheel_pep517( + src_dir: &Path, + dist_dir: &Path, + env_vars: &[(String, String)], + cfg: &Pep517Config, +) -> Result<()> { + crate::log_info!("Building wheel via PEP 517 backend {}...", cfg.backend); + + let mut cmd = Command::new("python3"); + cmd.current_dir(src_dir) + .arg("-c") + .arg(PEP517_BUILD_SNIPPET) + .arg(dist_dir); + + let mut build_env = env_vars.to_vec(); + crate::builder::set_env_var(&mut build_env, "DEPOT_PY_BACKEND", cfg.backend.clone()); + crate::builder::set_env_var( + &mut build_env, + "DEPOT_PY_BACKEND_PATHS", + cfg.backend_paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join("\n"), + ); + crate::builder::prepare_command(&mut cmd, &build_env); + + let output = cmd.output().with_context(|| { + format!( + "Failed to run python3 for PEP 517 build in {}", + src_dir.display() + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let requires = if cfg.requires.is_empty() { + "(none declared)".to_string() + } else { + cfg.requires.join(", ") + }; + bail!( + "PEP 517 wheel build failed with status {}. Backend: {}. Declared build requirements: {}. stderr: {}", + output.status, + cfg.backend, + requires, + stderr.trim() + ); + } + + Ok(()) +} + +fn collect_wheels(dist_dir: &Path) -> Result> { + let mut wheels = Vec::new(); + for entry in fs::read_dir(dist_dir).with_context(|| { + format!( + "Failed to read wheel output directory {}", + dist_dir.display() + ) + })? { + let entry = entry?; + let path = entry.path(); + if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "whl") + { + wheels.push(path); + } + } + wheels.sort(); + if wheels.is_empty() { + bail!("No wheel files produced in {}", dist_dir.display()); + } + Ok(wheels) +} + +fn detect_python_major_minor(env_vars: &[(String, String)]) -> Result { + let mut cmd = Command::new("python3"); + cmd.arg("-c") + .arg("import sys;print(f\"{sys.version_info[0]}.{sys.version_info[1]}\")"); + crate::builder::prepare_command(&mut cmd, &env_vars.to_vec()); + let output = cmd + .output() + .context("Failed to execute python3 for version detection")?; + if !output.status.success() { + bail!("python3 version probe failed with status {}", output.status); + } + let version = String::from_utf8(output.stdout) + .context("python3 version probe output was not valid UTF-8")? + .trim() + .to_string(); + if version.split('.').count() != 2 || !version.chars().all(|c| c == '.' || c.is_ascii_digit()) { + bail!("Unexpected python3 version probe output: {}", version); + } + Ok(version) +} + +fn normalized_prefix(prefix: &str) -> Result { + let trimmed = prefix.trim(); + let rel = trimmed.trim_start_matches('/'); + let path = PathBuf::from(rel); + ensure_safe_relative_path(&path, "build.flags.prefix")?; + Ok(path) +} + +fn site_packages_rel(prefix_rel: &Path, py_version: &str) -> PathBuf { + let mut out = PathBuf::new(); + if !prefix_rel.as_os_str().is_empty() { + out.push(prefix_rel); + } + out.push("lib"); + out.push(format!("python{}", py_version)); + out.push("site-packages"); + out +} + +fn install_wheel( + wheel_path: &Path, + destdir: &Path, + prefix_rel: &Path, + py_version: &str, +) -> Result<()> { + crate::log_info!("Installing wheel: {}", wheel_path.display()); + let site_packages = site_packages_rel(prefix_rel, py_version); + + let file = fs::File::open(wheel_path) + .with_context(|| format!("Failed to open wheel {}", wheel_path.display()))?; + let mut archive = zip::ZipArchive::new(file) + .with_context(|| format!("Failed to read wheel zip {}", wheel_path.display()))?; + + let mut entry_points_contents: Option = None; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).with_context(|| { + format!( + "Failed to read entry {} in wheel {}", + i, + wheel_path.display() + ) + })?; + + let rel = entry.enclosed_name().ok_or_else(|| { + anyhow::anyhow!( + "Unsafe wheel path in {}: {}", + wheel_path.display(), + entry.name() + ) + })?; + let target_rel = map_wheel_path(&rel, prefix_rel, &site_packages)?; + let target_path = destdir.join(&target_rel); + + if let Some(mode) = entry.unix_mode() + && (mode & 0o170000) == 0o120000 + { + bail!( + "Symlink entry is not allowed in wheel {}: {}", + wheel_path.display(), + rel.display() + ); + } + + if entry.is_dir() { + fs::create_dir_all(&target_path) + .with_context(|| format!("Failed to create directory {}", target_path.display()))?; + continue; + } + + let mut data = Vec::new(); + entry.read_to_end(&mut data).with_context(|| { + format!( + "Failed to read file content from wheel {}: {}", + wheel_path.display(), + rel.display() + ) + })?; + + if entry_points_contents.is_none() + && is_top_level_dist_info_entry_points(&rel) + && let Ok(s) = String::from_utf8(data.clone()) + { + entry_points_contents = Some(s); + } + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + fs::write(&target_path, &data) + .with_context(|| format!("Failed to write {}", target_path.display()))?; + + #[cfg(unix)] + if let Some(mode) = entry.unix_mode() { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&target_path)?.permissions(); + perms.set_mode(mode & 0o777); + fs::set_permissions(&target_path, perms)?; + } + } + + if let Some(contents) = entry_points_contents { + let scripts = parse_console_scripts(&contents)?; + write_entry_point_scripts(destdir, prefix_rel, &scripts)?; + } + + Ok(()) +} + +fn is_top_level_dist_info_entry_points(rel: &Path) -> bool { + let parts = match path_parts(rel, "wheel entry") { + Ok(p) => p, + Err(_) => return false, + }; + if parts.len() != 2 { + return false; + } + parts[0].ends_with(".dist-info") && parts[1] == "entry_points.txt" +} + +fn map_wheel_path(rel: &Path, prefix_rel: &Path, site_packages: &Path) -> Result { + let parts = path_parts(rel, "wheel path")?; + if parts.is_empty() { + bail!("Invalid empty wheel path"); + } + + if parts[0].ends_with(".data") { + if parts.len() < 3 { + bail!( + "Invalid wheel .data path (expected at least 3 components): {}", + rel.display() + ); + } + let mut out = match parts[1] { + "purelib" | "platlib" => site_packages.to_path_buf(), + "scripts" => join_rel(prefix_rel, "bin"), + "headers" => join_rel(prefix_rel, "include"), + "data" => prefix_rel.to_path_buf(), + scheme => { + bail!( + "Unsupported wheel .data scheme '{}' in {}", + scheme, + rel.display() + ) + } + }; + for part in &parts[2..] { + out.push(part); + } + ensure_safe_relative_path(&out, "installed wheel path")?; + return Ok(out); + } + + let mut out = site_packages.to_path_buf(); + for part in parts { + out.push(part); + } + ensure_safe_relative_path(&out, "installed wheel path")?; + Ok(out) +} + +fn join_rel(base: &Path, tail: &str) -> PathBuf { + let mut out = PathBuf::new(); + if !base.as_os_str().is_empty() { + out.push(base); + } + out.push(tail); + out +} + +fn path_parts<'a>(path: &'a Path, label: &str) -> Result> { + let mut parts = Vec::new(); + for c in path.components() { + match c { + Component::Normal(seg) => { + let s = seg.to_str().with_context(|| { + format!("Non-UTF-8 {} component in {}", label, path.display()) + })?; + parts.push(s); + } + Component::CurDir => {} + _ => bail!("Unsafe {} component in {}", label, path.display()), + } + } + Ok(parts) +} + +fn ensure_safe_relative_path(path: &Path, label: &str) -> Result<()> { + if path.is_absolute() { + bail!("{} must be relative: {}", label, path.display()); + } + for c in path.components() { + match c { + Component::Normal(_) => {} + Component::CurDir => {} + _ => bail!("Unsafe {} path: {}", label, path.display()), + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConsoleScript { + name: String, + module: String, + attr_path: String, +} + +fn parse_console_scripts(entry_points: &str) -> Result> { + let mut in_console = false; + let mut scripts = Vec::new(); + + for (idx, raw) in entry_points.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_console = &line[1..line.len() - 1] == "console_scripts"; + continue; + } + if !in_console { + continue; + } + + let (name_raw, target_raw) = line + .split_once('=') + .with_context(|| format!("Invalid console_scripts entry on line {}", idx + 1))?; + let name = name_raw.trim(); + if name.is_empty() || name.contains('/') || name.contains('\\') { + bail!( + "Invalid console_scripts script name '{}' on line {}", + name, + idx + 1 + ); + } + + let mut target = target_raw.trim(); + if let Some(i) = target.find('[') { + target = &target[..i]; + target = target.trim(); + } + + let (module, attr_path) = target + .split_once(':') + .with_context(|| format!("Invalid console_scripts target on line {}", idx + 1))?; + let module = module.trim(); + let attr_path = attr_path.trim(); + if module.is_empty() || attr_path.is_empty() { + bail!( + "Invalid console_scripts target '{}' on line {}", + target, + idx + 1 + ); + } + + scripts.push(ConsoleScript { + name: name.to_string(), + module: module.to_string(), + attr_path: attr_path.to_string(), + }); + } + + Ok(scripts) +} + +fn write_entry_point_scripts( + destdir: &Path, + prefix_rel: &Path, + scripts: &[ConsoleScript], +) -> Result<()> { + if scripts.is_empty() { + return Ok(()); + } + let scripts_dir = join_rel(prefix_rel, "bin"); + let scripts_path = destdir.join(&scripts_dir); + fs::create_dir_all(&scripts_path) + .with_context(|| format!("Failed to create {}", scripts_path.display()))?; + + for script in scripts { + let target = scripts_path.join(&script.name); + let module = py_single_quote_escape(&script.module); + let attrs = py_single_quote_escape(&script.attr_path); + let content = format!( + "#!/usr/bin/python3\nimport importlib\nimport sys\n\nobj = importlib.import_module('{module}')\nfor attr in '{attrs}'.split('.'):\n obj = getattr(obj, attr)\n\nif __name__ == '__main__':\n raise SystemExit(obj())\n", + ); + fs::write(&target, content) + .with_context(|| format!("Failed to write {}", target.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&target)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&target, perms)?; + } + } + Ok(()) +} + +fn py_single_quote_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('\'', "\\'") +} + +const PEP517_BUILD_SNIPPET: &str = r#" +import importlib +import os +import pathlib +import sys + +dist_dir = pathlib.Path(sys.argv[1]) +dist_dir.mkdir(parents=True, exist_ok=True) +backend_spec = os.environ.get("DEPOT_PY_BACKEND", "").strip() +if not backend_spec: + raise RuntimeError("DEPOT_PY_BACKEND is required") + +paths = [p for p in os.environ.get("DEPOT_PY_BACKEND_PATHS", "").splitlines() if p] +for p in reversed(paths): + if p not in sys.path: + sys.path.insert(0, p) + +module_name, _, object_path = backend_spec.partition(":") +backend = importlib.import_module(module_name) +if object_path: + for attr in object_path.split("."): + if attr: + backend = getattr(backend, attr) + +config_settings = {} +if not hasattr(backend, "build_wheel"): + raise RuntimeError(f"Backend {backend_spec!r} does not provide build_wheel") +wheel_name = backend.build_wheel(str(dist_dir), config_settings, None) +if not wheel_name: + raise RuntimeError(f"Backend {backend_spec!r} returned an empty wheel name") +"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source}; + use std::io::Write; + use tempfile::tempdir; + use zip::write::SimpleFileOptions; + + fn mk_spec() -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "py-test".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.test/src.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "src".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Python, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } + } + + #[test] + fn parse_console_scripts_basic() -> Result<()> { + let text = r#" +[console_scripts] +hello = demo.cli:main +tool = pkg.mod:run [extra] +"#; + let scripts = parse_console_scripts(text)?; + assert_eq!(scripts.len(), 2); + assert_eq!(scripts[0].name, "hello"); + assert_eq!(scripts[0].module, "demo.cli"); + assert_eq!(scripts[0].attr_path, "main"); + assert_eq!(scripts[1].name, "tool"); + assert_eq!(scripts[1].attr_path, "run"); + Ok(()) + } + + #[test] + fn map_wheel_data_paths() -> Result<()> { + let prefix = PathBuf::from("usr"); + let site = PathBuf::from("usr/lib/python3.12/site-packages"); + let mapped = map_wheel_path(Path::new("pkg-1.0.data/scripts/tool"), &prefix, &site)?; + assert_eq!(mapped, PathBuf::from("usr/bin/tool")); + Ok(()) + } + + #[test] + fn install_wheel_extracts_files_and_scripts() -> Result<()> { + let _spec = mk_spec(); + let tmp = tempdir()?; + let wheel_path = tmp.path().join("demo-1.0-py3-none-any.whl"); + + let file = fs::File::create(&wheel_path)?; + let mut zip = zip::ZipWriter::new(file); + let file_opts = SimpleFileOptions::default().unix_permissions(0o644); + let exec_opts = SimpleFileOptions::default().unix_permissions(0o755); + + zip.start_file("demo/__init__.py", file_opts)?; + zip.write_all(b"__all__ = []\n")?; + zip.start_file("demo-1.0.dist-info/WHEEL", file_opts)?; + zip.write_all(b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n")?; + zip.start_file("demo-1.0.dist-info/entry_points.txt", file_opts)?; + zip.write_all(b"[console_scripts]\ndemo = demo.__main__:main\n")?; + zip.start_file("demo-1.0.data/scripts/raw-script", exec_opts)?; + zip.write_all(b"#!/bin/sh\necho raw\n")?; + zip.finish()?; + + let dest = tmp.path().join("dest"); + fs::create_dir_all(&dest)?; + let prefix = PathBuf::from("usr"); + install_wheel(&wheel_path, &dest, &prefix, "3.12")?; + + assert!( + dest.join("usr/lib/python3.12/site-packages/demo/__init__.py") + .exists() + ); + assert!(dest.join("usr/bin/raw-script").exists()); + assert!(dest.join("usr/bin/demo").exists()); + Ok(()) + } + + #[test] + fn install_wheel_ignores_nested_dist_info_entry_points() -> Result<()> { + let tmp = tempdir()?; + let wheel_path = tmp.path().join("demo-1.0-py3-none-any.whl"); + + let file = fs::File::create(&wheel_path)?; + let mut zip = zip::ZipWriter::new(file); + let file_opts = SimpleFileOptions::default().unix_permissions(0o644); + + zip.start_file("demo/__init__.py", file_opts)?; + zip.write_all(b"__all__ = []\n")?; + zip.start_file("demo-1.0.dist-info/WHEEL", file_opts)?; + zip.write_all(b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n")?; + zip.start_file("demo-1.0.dist-info/entry_points.txt", file_opts)?; + zip.write_all(b"[console_scripts]\ndemo = demo.__main__:main\n")?; + zip.start_file( + "demo/vendor/wheel-0.1.dist-info/entry_points.txt", + file_opts, + )?; + zip.write_all(b"[console_scripts]\nwheel = wheel.cli:main\n")?; + zip.finish()?; + + let dest = tmp.path().join("dest"); + fs::create_dir_all(&dest)?; + let prefix = PathBuf::from("usr"); + install_wheel(&wheel_path, &dest, &prefix, "3.12")?; + + assert!(dest.join("usr/bin/demo").exists()); + assert!(!dest.join("usr/bin/wheel").exists()); + Ok(()) + } +} diff --git a/src/builder/rust.rs b/src/builder/rust.rs index 7125059..0c5c6fe 100755 --- a/src/builder/rust.rs +++ b/src/builder/rust.rs @@ -73,8 +73,10 @@ pub fn build( crate::builder::set_env_var(&mut env_vars, "RUSTUP_TOOLCHAIN", "stable"); } + hooks::run_post_configure_commands(spec, src_dir, destdir)?; + // Run cargo build - println!( + crate::log_info!( "Running cargo build ({})...", if is_release { "release" } else { "debug" } ); @@ -110,7 +112,7 @@ pub fn build( hooks::run_post_compile_commands(spec, src_dir, destdir)?; // Install binaries to destdir - println!("Installing binaries to DESTDIR..."); + crate::log_info!("Installing binaries to DESTDIR..."); // Determine target directory let target_dir = if let Some(ref t) = target { @@ -160,7 +162,7 @@ pub fn build( .is_some() { let dest = bin_dir.join(&*file_name); - println!(" Installing: {}", file_name); + crate::log_info!(" Installing: {}", file_name); fs::copy(&path, &dest).with_context(|| { format!("Failed to copy {} to {}", path.display(), dest.display()) })?; diff --git a/src/config.rs b/src/config.rs index 4ffe1dc..b932e05 100755 --- a/src/config.rs +++ b/src/config.rs @@ -74,7 +74,7 @@ impl Config { }; if let Err(e) = config.load_system(&abs_rootfs) { - eprintln!("Warning: Failed to load system config: {}", e); + crate::log_warn!("Failed to load system config: {}", e); } config diff --git a/src/cross.rs b/src/cross.rs index 8cb63f0..867449f 100755 --- a/src/cross.rs +++ b/src/cross.rs @@ -57,12 +57,12 @@ impl CrossConfig { let readelf = find_tool(prefix, &["readelf", "llvm-readelf"], false) .unwrap_or_else(|_| format!("{}-readelf", prefix)); - println!("Cross-compilation tools discovered:"); - println!(" CC: {}", cc); - println!(" CXX: {}", cxx); - println!(" AR: {}", ar); - println!(" RANLIB: {}", ranlib); - println!(" STRIP: {}", strip); + crate::log_info!("Cross-compilation tools discovered:"); + crate::log_info!(" CC: {}", cc); + crate::log_info!(" CXX: {}", cxx); + crate::log_info!(" AR: {}", ar); + crate::log_info!(" RANLIB: {}", ranlib); + crate::log_info!(" STRIP: {}", strip); Ok(Self { prefix: prefix.to_string(), diff --git a/src/db/mod.rs b/src/db/mod.rs index 7c6dcf5..4894b6e 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -117,15 +117,17 @@ pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> R let disk_path = rootfs.join(f); if disk_path.exists() { let _ = std::fs::remove_file(&disk_path); - println!( + crate::log_info!( "Auto-removed conflicting path: {} (was owned by {})", - f, owner + f, + owner ); } } else { - println!( + crate::log_info!( "Auto-cleared DB ownership for path: {} (previously owned by {})", - f, owner + f, + owner ); } } @@ -149,7 +151,7 @@ pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> R tx.commit()?; - println!( + crate::log_info!( "Registered {} files and {} directories in database", manifest.files.len(), manifest.directories.len() @@ -226,7 +228,7 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { let path = rootfs.join(file); match fs::remove_file(&path) { Ok(()) => { - println!(" Removed file: {}", file); + crate::log_info!(" Removed file: {}", file); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Already gone, keep going. @@ -255,14 +257,14 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { )?; if other_owners > 0 { - println!(" Keeping directory (owned by other package): {}", dir); + crate::log_info!(" Keeping directory (owned by other package): {}", dir); continue; } // Try to remove (will fail if not empty, which is fine) match fs::remove_dir(&path) { Ok(()) => { - println!(" Removed directory: {}", dir); + crate::log_info!(" Removed directory: {}", dir); dirs_removed += 1; } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -270,7 +272,7 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { } Err(e) if e.raw_os_error() == Some(39) || e.raw_os_error() == Some(66) => { // ENOTEMPTY (39 on Linux, 66 on macOS) - directory not empty - println!(" Keeping directory (not empty): {}", dir); + crate::log_info!(" Keeping directory (not empty): {}", dir); } Err(_) => { // Other errors (permission, etc.) - just skip silently @@ -290,16 +292,16 @@ pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { )?; conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?; - println!( + crate::log_info!( "Removed {} files and {} directories", files.len(), dirs_removed ); if !removal_errors.is_empty() { - eprintln!("Warning: failed to remove some paths:"); + crate::log_warn!("Failed to remove some paths:"); for err in removal_errors { - eprintln!(" {}", err); + crate::log_warn!(" {}", err); } } Ok(()) @@ -321,10 +323,10 @@ pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> { ) .context(format!("Package '{}' not found", name))?; - println!("Package: {} v{}-{}", name, version, revision); - println!("Description: {}", description); - println!("Homepage: {}", homepage); - println!("License: {}", license); + crate::log_info!("Package: {} v{}-{}", name, version, revision); + crate::log_info!("Description: {}", description); + crate::log_info!("Homepage: {}", homepage); + crate::log_info!("License: {}", license); // Count files let file_count: i64 = conn.query_row( @@ -333,7 +335,7 @@ pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> { |row| row.get(0), )?; - println!("Files: {}", file_count); + crate::log_info!("Files: {}", file_count); Ok(()) } @@ -341,7 +343,7 @@ pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> { /// List all installed packages pub fn list_packages(db_path: &Path) -> Result<()> { if !db_path.exists() { - println!("No packages installed."); + crate::log_info!("No packages installed."); return Ok(()); } @@ -352,12 +354,12 @@ pub fn list_packages(db_path: &Path) -> Result<()> { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; - println!("{:<30} VERSION", "PACKAGE"); - println!("{}", "-".repeat(50)); + crate::log_info!("{:<30} VERSION", "PACKAGE"); + crate::log_info!("{}", "-".repeat(50)); for pkg in packages { let (name, version) = pkg?; - println!("{:<30} {}", name, version); + crate::log_info!("{:<30} {}", name, version); } Ok(()) @@ -494,6 +496,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), } } diff --git a/src/db/repo.rs b/src/db/repo.rs index 6c1f3b8..ffe5063 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -94,7 +94,7 @@ impl RepoManager { } fn index_package(&self, conn: &mut Connection, pkg_path: &Path) -> Result<()> { - println!("Indexing package {}...", pkg_path.display()); + crate::log_info!("Indexing package {}...", pkg_path.display()); let filename = pkg_path.file_name().unwrap().to_string_lossy(); let size = pkg_path.metadata()?.len(); @@ -238,7 +238,7 @@ pub fn sync_mirrors( for (name, url) in mirrors { let target = base.join(name); if !target.exists() { - println!("Cloning mirror '{}' -> {}", name, target.display()); + crate::log_info!("Cloning mirror '{}' -> {}", name, target.display()); // Use git2 RepoBuilder to clone let mut cb = RemoteCallbacks::new(); @@ -256,7 +256,7 @@ pub fn sync_mirrors( .clone(url, &target) .with_context(|| format!("Failed to clone {}", url))?; } else { - println!("Updating mirror '{}' in {}", name, target.display()); + crate::log_info!("Updating mirror '{}' in {}", name, target.display()); // Open repository and fetch updates let repo = Repository::open(&target) .with_context(|| format!("Failed to open repository at {}", target.display()))?; @@ -310,15 +310,15 @@ pub fn mirrors_status( let base = repo_dir.to_path_buf(); if !base.exists() { - println!("Repo base directory does not exist: {}", base.display()); + crate::log_info!("Repo base directory does not exist: {}", base.display()); return Ok(()); } for (name, _url) in mirrors { let target = base.join(name); - println!("--- {} ---", name); + crate::log_info!("--- {} ---", name); if !target.exists() { - println!("Not cloned: {}", target.display()); + crate::log_info!("Not cloned: {}", target.display()); continue; } @@ -350,7 +350,7 @@ pub fn mirrors_status( let statuses = match repo.statuses(None) { Ok(s) => s, Err(_) => { - println!("Warning: failed to read status for {}", target.display()); + crate::log_warn!("Failed to read status for {}", target.display()); continue; } }; @@ -360,16 +360,16 @@ pub fn mirrors_status( ) }); - println!("Path: {}", target.display()); - println!("Branch/HEAD: {}", branch); - println!("HEAD OID: {}", short); + crate::log_info!("Path: {}", target.display()); + crate::log_info!("Branch/HEAD: {}", branch); + crate::log_info!("HEAD OID: {}", short); if !commit_time.is_empty() { - println!("Latest commit time (epoch): {}", commit_time); + crate::log_info!("Latest commit time (epoch): {}", commit_time); } - println!("Dirty: {}", if dirty { "yes" } else { "no" }); + crate::log_info!("Dirty: {}", if dirty { "yes" } else { "no" }); } Err(e) => { - println!("Failed to open repo at {}: {}", target.display(), e); + crate::log_info!("Failed to open repo at {}: {}", target.display(), e); } } } diff --git a/src/deps.rs b/src/deps.rs index a8b14ab..5396d66 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -10,6 +10,7 @@ use crate::db; use crate::package::PackageSpec; +use crate::ui; use anyhow::Result; use std::path::Path; @@ -200,26 +201,35 @@ pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> { let missing_test = check_test_deps(spec, db_path)?; if !spec.dependencies.build.is_empty() { - println!("Build dependencies: {}", spec.dependencies.build.join(", ")); + ui::info(format!( + "Build dependencies: {}", + spec.dependencies.build.join(", ") + )); if !missing_build.is_empty() { - println!(" Missing: {}", missing_build.join(", ")); + ui::warn(format!("Build deps missing: {}", missing_build.join(", "))); } } if !spec.dependencies.runtime.is_empty() { - println!( + ui::info(format!( "Runtime dependencies: {}", spec.dependencies.runtime.join(", ") - ); + )); if !missing_runtime.is_empty() { - println!(" Missing: {}", missing_runtime.join(", ")); + ui::warn(format!( + "Runtime deps missing: {}", + missing_runtime.join(", ") + )); } } if !spec.dependencies.test.is_empty() { - println!("Test dependencies: {}", spec.dependencies.test.join(", ")); + ui::info(format!( + "Test dependencies: {}", + spec.dependencies.test.join(", ") + )); if !missing_test.is_empty() { - println!(" Missing: {}", missing_test.join(", ")); + ui::warn(format!("Test deps missing: {}", missing_test.join(", "))); } } @@ -232,7 +242,7 @@ pub fn require_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { if !missing.is_empty() { anyhow::bail!( - "Missing build dependencies: {}\nInstall them first with: nyapm install ", + "Missing build dependencies: {}\nInstall them first with: depot install ", missing.join(", ") ); } @@ -246,7 +256,21 @@ pub fn require_test_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { if !missing.is_empty() { anyhow::bail!( - "Missing test dependencies: {}\nInstall them first with: nyapm install ", + "Missing test dependencies: {}\nInstall them first with: depot install ", + missing.join(", ") + ); + } + + Ok(()) +} + +/// Verify all runtime dependencies are installed, error if not. +pub fn require_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { + let missing = check_runtime_deps(spec, db_path)?; + + if !missing.is_empty() { + anyhow::bail!( + "Missing runtime dependencies: {}\nInstall them first with: depot install ", missing.join(", ") ); } @@ -333,10 +357,52 @@ mod tests { runtime: Vec::new(), test: vec!["bats".into(), "python".into()], }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: std::path::PathBuf::from("."), }; let missing = check_test_deps(&spec, Path::new("/definitely/not/a/real/db")).unwrap(); assert_eq!(missing, vec!["bats".to_string(), "python".to_string()]); } + + #[test] + fn test_require_runtime_deps_errors_when_db_missing() { + let spec = PackageSpec { + package: crate::package::PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "https://example.test/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: crate::package::Build { + build_type: crate::package::BuildType::Custom, + flags: crate::package::BuildFlags::default(), + }, + dependencies: crate::package::Dependencies { + build: Vec::new(), + runtime: vec!["python".into()], + test: Vec::new(), + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: std::path::PathBuf::from("."), + }; + + let err = require_runtime_deps(&spec, Path::new("/definitely/not/a/real/db")) + .expect_err("runtime deps should be required"); + assert!(err.to_string().contains("Missing runtime dependencies")); + } } diff --git a/src/index.rs b/src/index.rs index 4760535..3365bdd 100755 --- a/src/index.rs +++ b/src/index.rs @@ -83,7 +83,7 @@ impl PackageIndex { } } - println!( + crate::log_info!( "Indexed {} packages ({} provides)", index.by_name.len(), index.by_provides.len() @@ -102,8 +102,8 @@ impl PackageIndex { // Then try by provides if let Some(paths) = self.by_provides.get(name) { if paths.len() > 1 { - eprintln!( - "Warning: Multiple packages provide '{}': {:?}", + crate::log_warn!( + "Multiple packages provide '{}': {:?}", name, paths .iter() diff --git a/src/install/scripts.rs b/src/install/scripts.rs index 6c92a3a..6f00fc5 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -9,6 +9,14 @@ use std::process::Command; use walkdir::WalkDir; const STAGED_SCRIPTS_DIR: &str = "scripts"; +const ALL_HOOKS: [Hook; 6] = [ + Hook::PreInstall, + Hook::PostInstall, + Hook::PreUpdate, + Hook::PostUpdate, + Hook::PreRemove, + Hook::PostRemove, +]; /// Lifecycle hook names supported by Depot package scripts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -54,16 +62,24 @@ impl Hook { } } - fn candidate_names(self) -> [String; 4] { + fn candidate_names(self) -> [String; 6] { let canonical = self.canonical_name(); let dashed = canonical.replace('_', "-"); + let compact = canonical.replace('_', ""); [ canonical.to_string(), format!("{}.sh", canonical), dashed.clone(), format!("{}.sh", dashed), + compact.clone(), + format!("{}.sh", compact), ] } + + fn legacy_root_candidate_names(self) -> [String; 2] { + let compact = self.canonical_name().replace('_', ""); + [compact.clone(), format!("{}.sh", compact)] + } } /// Return the staged scripts directory path inside a package staging tree. @@ -81,19 +97,26 @@ pub fn installed_scripts_dir(rootfs: &Path, pkg_name: &str) -> PathBuf { /// Copy optional scripts from `/scripts` into `/scripts`. /// +/// Also stages legacy root hook files (for example `postinstall.sh`) into their +/// canonical names under `/scripts`. +/// /// Returns `true` if scripts were found and staged. pub fn stage_scripts_from_spec_dir(spec: &PackageSpec, destdir: &Path) -> Result { let source_dir = spec.spec_dir.join(STAGED_SCRIPTS_DIR); - if !source_dir.exists() { - return Ok(false); - } - if !source_dir.is_dir() { + let has_scripts_dir = if source_dir.exists() { true } else { false }; + + if has_scripts_dir && !source_dir.is_dir() { bail!( "Scripts path exists but is not a directory: {}", source_dir.display() ); } + let legacy_hooks = collect_legacy_root_hooks(&spec.spec_dir)?; + if !has_scripts_dir && legacy_hooks.is_empty() { + return Ok(false); + } + let staged_dir = staged_scripts_dir(destdir); if staged_dir.exists() { remove_tree_or_file(&staged_dir).with_context(|| { @@ -104,8 +127,12 @@ pub fn stage_scripts_from_spec_dir(spec: &PackageSpec, destdir: &Path) -> Result })?; } - copy_tree(&source_dir, &staged_dir) - .with_context(|| format!("Failed to stage scripts from {}", source_dir.display()))?; + if has_scripts_dir { + copy_tree(&source_dir, &staged_dir) + .with_context(|| format!("Failed to stage scripts from {}", source_dir.display()))?; + } + + stage_legacy_root_hooks(&legacy_hooks, &staged_dir)?; Ok(true) } @@ -180,7 +207,7 @@ pub fn run_hook_if_present( return Ok(false); }; - println!( + crate::log_info!( "Running lifecycle hook {}: {}", hook.canonical_name(), script_path.display() @@ -216,7 +243,7 @@ fn run_script_with_rootfs_context( .arg(rootfs) .arg("/bin/sh") .arg("-c") - .arg("cd / && exec \"$1\"") + .arg("cd / && exec /bin/sh \"$1\"") .arg("sh") .arg(rel_script) .env("DEPOT_PACKAGE", pkg_name) @@ -305,6 +332,100 @@ fn resolve_hook_script(script_dir: &Path, hook: Hook) -> Result> Ok(found.into_iter().next()) } +fn collect_legacy_root_hooks(spec_dir: &Path) -> Result> { + let mut found = Vec::new(); + + for hook in ALL_HOOKS { + let mut hook_matches = Vec::new(); + for candidate in hook.legacy_root_candidate_names() { + let path = spec_dir.join(candidate); + let metadata = match path.symlink_metadata() { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(e).with_context(|| { + format!("Failed to inspect legacy hook script: {}", path.display()) + }); + } + }; + + let file_type = metadata.file_type(); + if !file_type.is_file() && !file_type.is_symlink() { + bail!( + "Legacy lifecycle hook candidate exists but is not a file: {}", + path.display() + ); + } + + hook_matches.push(path); + } + + if hook_matches.len() > 1 { + let names = hook_matches + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + bail!( + "Ambiguous legacy lifecycle hook '{}': multiple script candidates found: {}", + hook.canonical_name(), + names + ); + } + + if let Some(path) = hook_matches.into_iter().next() { + found.push((hook, path)); + } + } + + Ok(found) +} + +fn stage_legacy_root_hooks(legacy_hooks: &[(Hook, PathBuf)], staged_dir: &Path) -> Result<()> { + if legacy_hooks.is_empty() { + return Ok(()); + } + + fs::create_dir_all(staged_dir) + .with_context(|| format!("Failed to create directory: {}", staged_dir.display()))?; + + for (hook, src_path) in legacy_hooks { + let dst_path = staged_dir.join(hook.canonical_name()); + if dst_path.exists() { + bail!( + "Lifecycle hook '{}' is defined more than once (legacy root hook conflicts with scripts/ entry): {}", + hook.canonical_name(), + dst_path.display() + ); + } + + let metadata = src_path.symlink_metadata().with_context(|| { + format!( + "Failed to inspect legacy hook script: {}", + src_path.display() + ) + })?; + + if metadata.file_type().is_symlink() { + let target = fs::read_link(src_path) + .with_context(|| format!("Failed to read symlink: {}", src_path.display()))?; + std::os::unix::fs::symlink(target, &dst_path) + .with_context(|| format!("Failed to create symlink: {}", dst_path.display()))?; + } else { + fs::copy(src_path, &dst_path).with_context(|| { + format!( + "Failed to copy legacy hook '{}' to '{}'", + src_path.display(), + dst_path.display() + ) + })?; + ensure_executable(&dst_path)?; + } + } + + Ok(()) +} + fn safe_rel_path(rel: &Path) -> Result { let mut normalized = PathBuf::new(); for component in rel.components() { @@ -477,6 +598,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: spec_dir.to_path_buf(), } } @@ -529,6 +652,28 @@ mod tests { ); } + #[test] + fn run_hook_if_present_accepts_compact_script_name() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("postinstall.sh"), + "echo compact > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), + "compact\n" + ); + } + #[test] fn run_hook_if_present_rejects_ambiguous_names() { let tmp = tempfile::tempdir().unwrap(); @@ -590,4 +735,28 @@ mod tests { assert!(!has_scripts); assert!(!installed_scripts_dir(&rootfs, "foo").exists()); } + + #[test] + fn stage_scripts_from_spec_dir_stages_legacy_root_hook() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(spec_dir.join("postinstall.sh"), "echo post").unwrap(); + + let spec = mk_spec(&spec_dir); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(staged); + assert!(destdir.join("scripts/post_install").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(destdir.join("scripts/post_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } + } } diff --git a/src/main.rs b/src/main.rs index 8bac1ec..248e1c4 100755 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,10 @@ mod fakeroot; mod index; mod install; mod package; +mod shell_helpers; mod source; mod staging; +mod ui; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; @@ -37,24 +39,30 @@ fn clean_build_workspace(config: &config::Config) -> Result<()> { fs::remove_dir_all(&config.build_dir).with_context(|| { format!("Failed to clean build dir: {}", config.build_dir.display()) })?; - println!("Cleaned build workspace: {}", config.build_dir.display()); + ui::success(format!( + "Cleaned build workspace: {}", + config.build_dir.display() + )); } Ok(()) } fn warn_if_running_as_root_for_build(command: &str, rootfs: &Path) { if crate::fakeroot::is_root() { - eprintln!( - "\x1b[33mWARNING: Running '{}' as root is discouraged.\x1b[0m", - command + ui::warn(format!("Running '{}' as root is discouraged.", command)); + ui::warn( + "A misconfigured build environment or malicious/buggy build file can overwrite or delete critical system files.", ); - eprintln!( - "\x1b[33mA misconfigured build environment or malicious/buggy build file can overwrite or delete critical system files.\x1b[0m" - ); - eprintln!( - "\x1b[33mRecommendation: use a non-root build user and only install as root.\x1b[0m" - ); - eprintln!("\x1b[33mCurrent rootfs target: {}\x1b[0m", rootfs.display()); + ui::warn("Recommendation: use a non-root build user and only install as root."); + ui::warn(format!("Current rootfs target: {}", rootfs.display())); + } +} + +fn output_destdir_for(base_destdir: &Path, primary_pkg: &str, output_pkg: &str) -> PathBuf { + if output_pkg == primary_pkg { + base_destdir.to_path_buf() + } else { + staging::output_staging_dir(base_destdir, output_pkg) } } @@ -114,13 +122,9 @@ fn install_staged_to_rootfs( &pkg_spec.build.flags.keep, )?; - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - spec_for_out.package = out; - if let Err(e) = db::register_package(&db_path, &spec_for_out, destdir) { - let _ = tx.rollback(); - return Err(e); - } + if let Err(e) = db::register_package(&db_path, pkg_spec, destdir) { + let _ = tx.rollback(); + return Err(e); } tx.commit()?; @@ -273,7 +277,7 @@ fn main() -> Result<()> { // try to locate a spec under configured repo dir or local packages/. if !spec_path.exists() { let name = spec_path.to_string_lossy().to_string(); - println!("Looking up package '{}' in local indexes...", name); + ui::info(format!("Looking up package '{}' in local indexes...", name)); let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); if let Some(found) = pkg_index.find(&name) { @@ -281,12 +285,12 @@ fn main() -> Result<()> { } } - println!("Installing package from: {}", spec_path.display()); + ui::info(format!("Installing package from: {}", spec_path.display())); let (pkg_spec, staging_dir): (package::PackageSpec, Option) = if spec_path.to_string_lossy().ends_with(".tar.zst") { // Install from archive - println!("Detected package archive: {}", spec_path.display()); + ui::info(format!("Detected package archive: {}", spec_path.display())); let tmp_dir = tempfile::TempDir::new()?; let extract_dir = tmp_dir.path().to_path_buf(); @@ -383,6 +387,8 @@ fn main() -> Result<()> { Vec::new() }, }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; @@ -402,10 +408,10 @@ fn main() -> Result<()> { (pkg_spec, None) }; - println!( + ui::info(format!( "Package: {} v{}-{}", pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision - ); + )); // Ensure database directory exists std::fs::create_dir_all(&config.db_dir).with_context(|| { @@ -454,14 +460,8 @@ fn main() -> Result<()> { ); } - println!("\nMissing dependencies: {}", missing.join(", ")); - println!("Do you want to attempt to install them? [Y/n] "); - - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let input = input.trim().to_lowercase(); - - if input == "y" || input == "yes" || input.is_empty() { + ui::warn(format!("Missing dependencies: {}", missing.join(", "))); + if ui::prompt_yes_no("Attempt to install them now?", true)? { // Build package index for fast lookups let pkg_index = index::PackageIndex::build_with_repo_dir(Some( config.repo_clone_dir.clone(), @@ -480,7 +480,7 @@ fn main() -> Result<()> { let candidate = pkg_index.find(&dep); if let Some(dep_spec_path) = candidate { - println!("Installing dependency: {}...", dep); + ui::info(format!("Installing dependency: {}...", dep)); let mut cmd = std::process::Command::new(std::env::current_exe()?); cmd.arg("-r").arg(&cli.rootfs); @@ -516,22 +516,14 @@ fn main() -> Result<()> { } } - // Enforce build dependencies (runtime deps are warnings only if not installed/prompt declined) + // Enforce required dependencies before building/installing. deps::require_build_deps(&pkg_spec, &db_path)?; + deps::require_runtime_deps(&pkg_spec, &db_path)?; if needs_test_deps { deps::require_test_deps(&pkg_spec, &db_path)?; } } - // Ensure database directory exists - std::fs::create_dir_all(&config.db_dir).with_context(|| { - format!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - let db_path = config.db_dir.join("packages.db"); - let destdir = if let Some(dir) = &staging_dir { dir.path().to_path_buf() } else { @@ -569,34 +561,28 @@ fn main() -> Result<()> { staging::process(&destdir, &pkg_spec)?; // 5-6. Install/update to rootfs and register in DB - install_staged_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?; + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); + install_staged_to_rootfs(&spec_for_out, &out_destdir, &cli.rootfs, &config)?; - // 7. Check runtime dependencies (warn only) - if !cli.no_deps { - let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?; - if !missing_runtime.is_empty() { - eprintln!( - "\x1b[33mWarning: Missing runtime dependencies: {}\x1b[0m", - missing_runtime.join(", ") - ); - eprintln!( - "\x1b[33mContinuing without runtime deps; binaries may not run correctly.\x1b[0m" - ); - eprintln!("\x1b[33mUse --no-deps to suppress this warning.\x1b[0m"); - } + ui::success(format!( + "Successfully installed {} v{}", + spec_for_out.package.name, spec_for_out.package.version + )); } - println!( - "Successfully installed {} v{}", - pkg_spec.package.name, pkg_spec.package.version - ); - if cli.clean { clean_build_workspace(&config)?; } } Commands::Remove { package } => { - println!("Removing package: {}", package); + ui::info(format!("Removing package: {}", package)); let config = config::Config::for_rootfs(&cli.rootfs); let db_path = config.db_dir.join("packages.db"); let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package); @@ -616,7 +602,7 @@ fn main() -> Result<()> { let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package); post_remove?; cleanup_scripts?; - println!("Successfully removed {}", package); + ui::success(format!("Successfully removed {}", package)); } Commands::Build { spec_pos, @@ -625,7 +611,7 @@ fn main() -> Result<()> { } => { warn_if_running_as_root_for_build("build", &cli.rootfs); let spec_path = spec.or(spec_pos).context("No spec file provided")?; - println!("Building package from: {}", spec_path.display()); + ui::info(format!("Building package from: {}", spec_path.display())); let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; let config = config::Config::for_rootfs(&cli.rootfs); @@ -687,23 +673,37 @@ fn main() -> Result<()> { let mut created_files = Vec::new(); for out in pkg_spec.outputs() { let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); let packager = - package::Packager::new(spec_for_out.clone(), destdir.clone(), config.clone()); + package::Packager::new(spec_for_out.clone(), out_destdir, config.clone()); let pkg_file = packager.create_package(Path::new("."), arch)?; created_files.push(pkg_file); } for f in &created_files { - println!("Build complete. Package created: {}", f.display()); + ui::success(format!("Build complete. Package created: {}", f.display())); } if install { - install_staged_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?; - println!( - "Successfully installed {} v{}", - pkg_spec.package.name, pkg_spec.package.version - ); + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); + install_staged_to_rootfs(&spec_for_out, &out_destdir, &cli.rootfs, &config)?; + ui::success(format!( + "Successfully installed {} v{}", + spec_for_out.package.name, spec_for_out.package.version + )); + } } if cli.clean { @@ -736,7 +736,10 @@ fn main() -> Result<()> { RepoCommands::Create { dir } => { let repo = db::repo::RepoManager::new(dir); let db_path = repo.create_repo_db()?; - println!("Created repository database: {}", db_path.display()); + ui::success(format!( + "Created repository database: {}", + db_path.display() + )); } RepoCommands::Sync => { // Only root may run sync @@ -745,19 +748,19 @@ fn main() -> Result<()> { } let config = config::Config::for_rootfs(&cli.rootfs); if config.mirrors.is_empty() { - println!("No mirrors configured in /etc/depot.d/mirrors.toml"); + ui::info("No mirrors configured in /etc/depot.d/mirrors.toml"); } else { db::repo::sync_mirrors(&config.repo_clone_dir, &config.mirrors)?; - println!( + ui::success(format!( "Mirrors synchronized into {}", config.repo_clone_dir.display() - ); + )); } } RepoCommands::Status => { let config = config::Config::for_rootfs(&cli.rootfs); if config.mirrors.is_empty() { - println!("No mirrors configured in /etc/depot.d/mirrors.toml"); + ui::info("No mirrors configured in /etc/depot.d/mirrors.toml"); } else { db::repo::mirrors_status(&config.repo_clone_dir, &config.mirrors)?; } @@ -786,19 +789,17 @@ fn main() -> Result<()> { output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); if output_path.exists() { - println!( - "Warning: File {} already exists. Overwrite? [y/N]", - output_path.display() - ); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { + ui::warn(format!("File {} already exists.", output_path.display())); + if !ui::prompt_yes_no("Overwrite it?", false)? { anyhow::bail!("Aborted"); } } fs::write(&output_path, toml_string)?; - println!("Package specification saved to {}", output_path.display()); + ui::success(format!( + "Package specification saved to {}", + output_path.display() + )); } } diff --git a/src/package/interactive.rs b/src/package/interactive.rs index b0edd00..04bdc3b 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -17,6 +17,7 @@ impl fmt::Display for BuildType { BuildType::CMake => write!(f, "CMake"), BuildType::Meson => write!(f, "Meson"), BuildType::Custom => write!(f, "Custom"), + BuildType::Python => write!(f, "Python"), BuildType::Rust => write!(f, "Rust"), BuildType::Makefile => write!(f, "Makefile"), BuildType::Bin => write!(f, "Binary installer"), @@ -177,8 +178,8 @@ fn expand_known_package_vars(input: &str, name: &str, version: &str) -> String { } pub fn create_interactive() -> Result { - println!("Interactive Package Specification Creator"); - println!("-----------------------------------------"); + crate::log_info!("Interactive Package Specification Creator"); + crate::log_info!("-----------------------------------------"); // Ask early whether this is a GNU project so we can pre-fill homepage and // avoid repeating the same question later for autotools sources. @@ -237,6 +238,7 @@ pub fn create_interactive() -> Result { BuildType::CMake, BuildType::Meson, BuildType::Makefile, + BuildType::Python, BuildType::Rust, BuildType::Custom, BuildType::Bin, @@ -409,6 +411,22 @@ pub fn create_interactive() -> Result { } if show_advanced && matches!(build_type, BuildType::Autotools) { + flags.configure_file = prompt_optional_text( + "Configure script path (optional):", + "Relative to source root, e.g. build-aux/configure", + )?; + flags.make_dirs = prompt_repeating_list( + "Make dir (build phase)", + "Relative to build dir, e.g. lib, libelf (empty = build root)", + )?; + flags.make_test_dirs = prompt_repeating_list( + "Make dir (test phase)", + "Relative to build dir, e.g. tests (empty = build root)", + )?; + flags.make_install_dirs = prompt_repeating_list( + "Make dir (install phase)", + "Relative to build dir, e.g. lib, apps (empty = build root)", + )?; flags.skip_tests = Confirm::new("Skip automatic tests (make check/test)?") .with_help_message( "If enabled, Depot will not auto-run detected Autotools test targets", @@ -418,7 +436,7 @@ pub fn create_interactive() -> Result { } if matches!(build_type, BuildType::Makefile) { - println!("Define Makefile build and install commands."); + crate::log_info!("Define Makefile build and install commands."); flags.makefile_commands = prompt_repeating_list( "makefile build command", "Examples: make -j$(nproc), make all", @@ -467,6 +485,10 @@ pub fn create_interactive() -> Result { } if show_advanced { + flags.post_configure = prompt_repeating_list( + "post_configure command", + "Runs after configure/setup, before build", + )?; flags.post_compile = prompt_repeating_list("post_compile command", "Runs after build, before install")?; flags.post_install = @@ -501,6 +523,8 @@ pub fn create_interactive() -> Result { runtime: runtime_deps, test: test_deps, }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }) } @@ -596,6 +620,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { BuildType::CMake => "cmake", BuildType::Meson => "meson", BuildType::Custom => "custom", + BuildType::Python => "python", BuildType::Rust => "rust", BuildType::Makefile => "makefile", BuildType::Bin => "bin", @@ -655,6 +680,15 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if spec.build.flags.no_flags != defaults.no_flags { flags_tbl.insert("no_flags".into(), Value::Boolean(spec.build.flags.no_flags)); } + if spec.build.flags.no_strip != defaults.no_strip { + flags_tbl.insert("no_strip".into(), Value::Boolean(spec.build.flags.no_strip)); + } + if spec.build.flags.no_compress_man != defaults.no_compress_man { + flags_tbl.insert( + "no_compress_man".into(), + Value::Boolean(spec.build.flags.no_compress_man), + ); + } if spec.build.flags.skip_tests != defaults.skip_tests { flags_tbl.insert( "skip_tests".into(), @@ -674,6 +708,12 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.configure_file.is_empty() { + flags_tbl.insert( + "configure_file".into(), + Value::String(spec.build.flags.configure_file.clone()), + ); + } if spec.build.flags.prefix != defaults.prefix { flags_tbl.insert( "prefix".into(), @@ -723,6 +763,44 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.make_exec.is_empty() { + flags_tbl.insert( + "make_exec".into(), + Value::String(spec.build.flags.make_exec.clone()), + ); + } + if !spec.build.flags.make_target.is_empty() { + flags_tbl.insert( + "make_target".into(), + Value::String(spec.build.flags.make_target.clone()), + ); + } + if !spec.build.flags.make_targets.is_empty() { + flags_tbl.insert( + "make_targets".into(), + Value::Array( + spec.build + .flags + .make_targets + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.make_dirs.is_empty() { + flags_tbl.insert( + "make_dirs".into(), + Value::Array( + spec.build + .flags + .make_dirs + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.make_test_vars.is_empty() { flags_tbl.insert( "make_test_vars".into(), @@ -736,6 +814,38 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.make_test_target.is_empty() { + flags_tbl.insert( + "make_test_target".into(), + Value::String(spec.build.flags.make_test_target.clone()), + ); + } + if !spec.build.flags.make_test_targets.is_empty() { + flags_tbl.insert( + "make_test_targets".into(), + Value::Array( + spec.build + .flags + .make_test_targets + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.make_test_dirs.is_empty() { + flags_tbl.insert( + "make_test_dirs".into(), + Value::Array( + spec.build + .flags + .make_test_dirs + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.make_install_vars.is_empty() { flags_tbl.insert( "make_install_vars".into(), @@ -749,6 +859,38 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.make_install_target.is_empty() { + flags_tbl.insert( + "make_install_target".into(), + Value::String(spec.build.flags.make_install_target.clone()), + ); + } + if !spec.build.flags.make_install_targets.is_empty() { + flags_tbl.insert( + "make_install_targets".into(), + Value::Array( + spec.build + .flags + .make_install_targets + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.make_install_dirs.is_empty() { + flags_tbl.insert( + "make_install_dirs".into(), + Value::Array( + spec.build + .flags + .make_install_dirs + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.passthrough_env.is_empty() { flags_tbl.insert( "passthrough_env".into(), @@ -775,6 +917,19 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.post_configure.is_empty() { + flags_tbl.insert( + "post_configure".into(), + Value::Array( + spec.build + .flags + .post_configure + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.post_install.is_empty() { flags_tbl.insert( "post_install".into(), @@ -949,6 +1104,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; @@ -996,6 +1153,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; @@ -1030,6 +1189,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; @@ -1044,6 +1205,8 @@ mod tests { let mut flags = BuildFlags::default(); flags.source_subdir = "project/subdir".into(); flags.configure = vec!["--disable-static".into(), "--enable-foo".into()]; + flags.configure_file = "build-aux/configure".into(); + flags.post_configure = vec!["./configure-helper.sh".into()]; flags.post_compile = vec!["make check".into()]; flags.post_install = vec!["strip $DESTDIR/usr/bin/foo".into()]; flags.makefile_commands = vec!["make".into()]; @@ -1053,10 +1216,15 @@ mod tests { flags.target = "x86_64-unknown-linux-gnu".into(); flags.keep = vec!["etc/locale.gen".into()]; flags.no_flags = true; + flags.no_strip = true; + flags.no_compress_man = true; flags.skip_tests = true; flags.make_vars = vec!["V=1".into()]; + flags.make_dirs = vec!["lib".into(), "libelf".into()]; flags.make_test_vars = vec!["TESTS=unit".into()]; + flags.make_test_dirs = vec!["tests".into()]; flags.make_install_vars = vec!["STRIPPROG=true".into()]; + flags.make_install_dirs = vec!["lib".into(), "apps".into()]; let spec = PackageSpec { package: PackageInfo { @@ -1082,12 +1250,16 @@ mod tests { flags, }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; let toml = spec_to_minimal_toml(&spec).unwrap(); assert!(toml.contains("source_subdir = \"project/subdir\"")); assert!(toml.contains("configure = [")); + assert!(toml.contains("configure_file = \"build-aux/configure\"")); + assert!(toml.contains("post_configure = [")); assert!(toml.contains("post_compile = [")); assert!(toml.contains("post_install = [")); assert!(toml.contains("makefile_commands = [")); @@ -1098,10 +1270,15 @@ mod tests { assert!(toml.contains("keep = [")); assert!(toml.contains("\"etc/locale.gen\"")); assert!(toml.contains("no_flags = true")); + assert!(toml.contains("no_strip = true")); + assert!(toml.contains("no_compress_man = true")); assert!(toml.contains("skip_tests = true")); assert!(toml.contains("make_vars = [")); + assert!(toml.contains("make_dirs = [")); assert!(toml.contains("make_test_vars = [")); + assert!(toml.contains("make_test_dirs = [")); assert!(toml.contains("make_install_vars = [")); + assert!(toml.contains("make_install_dirs = [")); assert!(toml.contains("patches = [")); assert!(toml.contains("post_extract = [")); } @@ -1132,6 +1309,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; @@ -1169,6 +1348,8 @@ mod tests { runtime: vec![], test: vec!["python".into(), "bats".into()], }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }; diff --git a/src/package/packager.rs b/src/package/packager.rs index c18a68a..b3edba3 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -8,6 +8,14 @@ use std::path::{Path, PathBuf}; use tar::Builder; use zstd::stream::write::Encoder; +fn is_internal_staging_rel_path(rel_path: &Path) -> bool { + let s = rel_path.to_string_lossy(); + let p = s.trim_start_matches('/'); + p == crate::staging::INTERNAL_DEPOT_DIR + || p.strip_prefix(crate::staging::INTERNAL_DEPOT_DIR) + .is_some_and(|rest| rest.starts_with('/')) +} + fn license_value(licenses: &[String]) -> toml::Value { if licenses.len() == 1 { toml::Value::String(licenses[0].clone()) @@ -41,7 +49,7 @@ impl Packager { let filename = self.spec.package_filename(arch); let output_path = output_dir.join(&filename); - println!("Creating package {}...", filename); + crate::log_info!("Creating package {}...", filename); // Generate .files.yaml self.generate_files_yaml()?; @@ -78,6 +86,9 @@ impl Packager { if rel_path.as_os_str().is_empty() { continue; } + if is_internal_staging_rel_path(rel_path) { + continue; + } let file_type = entry.file_type(); if file_type.is_dir() { @@ -101,7 +112,7 @@ impl Packager { let encoder = tar.into_inner()?; encoder.finish()?; - println!("Created package: {}", output_path.display()); + crate::log_info!("Created package: {}", output_path.display()); Ok(output_path) } @@ -222,6 +233,9 @@ impl Packager { let path = entry.path(); let file_type = entry.file_type()?; let relative = path.strip_prefix(base)?.to_string_lossy().to_string(); + if is_internal_staging_rel_path(Path::new(&relative)) { + continue; + } if file_type.is_dir() { self.collect_files(base, &path, files)?; @@ -264,6 +278,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), }, destdir, @@ -289,6 +305,23 @@ mod tests { assert!(files.contains(&"etc/config".to_string())); } + #[test] + fn test_collect_files_skips_internal_output_staging() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path(); + fs::create_dir_all(dest.join("usr/bin")).unwrap(); + fs::create_dir_all(dest.join(".depot/outputs/clang/usr/bin")).unwrap(); + fs::write(dest.join("usr/bin/foo"), "x").unwrap(); + fs::write(dest.join(".depot/outputs/clang/usr/bin/clang"), "x").unwrap(); + + let packager = mk_packager(dest.to_path_buf()); + let mut files = Vec::new(); + packager.collect_files(dest, dest, &mut files).unwrap(); + + assert!(files.contains(&"usr/bin/foo".to_string())); + assert!(!files.iter().any(|f| f.starts_with(".depot/outputs/"))); + } + #[test] fn test_generate_files_yaml() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/package/spec.rs b/src/package/spec.rs index f88cf60..8b38dbe 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; +use std::collections::BTreeMap; use std::fmt; use std::fs; use std::path::Path; @@ -27,6 +28,20 @@ pub struct PackageSpec { pub build: Build, #[serde(default)] pub dependencies: Dependencies, + /// Optional per-output alternatives/provides overrides keyed by package name. + /// + /// Example: + /// [package_alternatives.clang] + /// provides = ["cc", "c++", "gcc"] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub package_alternatives: BTreeMap, + /// Optional per-output dependency overrides keyed by package name. + /// + /// Example: + /// [package_dependencies.clang] + /// runtime = ["llvm-libs", "llvm-libgcc"] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub package_dependencies: BTreeMap, /// Directory containing the spec file (used to resolve relative paths such as patches). #[serde(skip)] @@ -71,21 +86,41 @@ impl PackageSpec { .as_ref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); + let file_count = manual.files.iter().filter(|s| !s.trim().is_empty()).count(); + let url_count = manual.urls.iter().filter(|s| !s.trim().is_empty()).count(); + let local_count = usize::from(has_file) + file_count; + let remote_count = usize::from(has_url) + url_count; - match (has_file, has_url) { - (true, false) | (false, true) => {} - (false, false) => { - anyhow::bail!( - "manual_sources[{}] must specify exactly one of 'file' or 'url'", - idx - ); - } - (true, true) => { - anyhow::bail!( - "manual_sources[{}] cannot specify both 'file' and 'url'", - idx - ); - } + if local_count == 0 && remote_count == 0 { + anyhow::bail!( + "manual_sources[{}] must specify one of 'file', 'files', 'url', or 'urls'", + idx + ); + } + if local_count > 0 && remote_count > 0 { + anyhow::bail!( + "manual_sources[{}] cannot mix local ('file'/'files') and remote ('url'/'urls') entries", + idx + ); + } + if (local_count > 1 || remote_count > 1) + && manual.dest.as_ref().is_some_and(|d| !d.trim().is_empty()) + { + anyhow::bail!( + "manual_sources[{}] cannot use 'dest' with multiple entries in one block", + idx + ); + } + if (local_count > 1 || remote_count > 1) + && manual + .sha256 + .as_ref() + .is_some_and(|h| !h.trim().is_empty() && h.trim() != "skip") + { + anyhow::bail!( + "manual_sources[{}] cannot use one 'sha256' for multiple entries in one block", + idx + ); } } Ok(()) @@ -113,6 +148,26 @@ impl PackageSpec { v } + /// Return dependencies for a specific output package name. + /// + /// If no per-output override exists, returns the top-level dependencies. + pub fn dependencies_for_output(&self, pkg_name: &str) -> Dependencies { + self.package_dependencies + .get(pkg_name) + .cloned() + .unwrap_or_else(|| self.dependencies.clone()) + } + + /// Return alternatives/provides for a specific output package name. + /// + /// If no per-output override exists, returns the top-level alternatives. + pub fn alternatives_for_output(&self, pkg_name: &str) -> Alternatives { + self.package_alternatives + .get(pkg_name) + .cloned() + .unwrap_or_else(|| self.alternatives.clone()) + } + /// Apply system configuration overrides and appends pub fn apply_config(&mut self, config: &crate::config::Config) { // Apply build overrides from /etc/depot.d/build.toml @@ -220,6 +275,40 @@ impl PackageSpec { s.split_whitespace().map(String::from).collect(); } } + "make_exec" | "make-exec" => { + if let Some(s) = v.as_str() { + self.build.flags.make_exec = s.to_string(); + } + } + "make_target" | "make-target" | "make_build_target" | "make-build-target" => { + if let Some(s) = v.as_str() { + self.build.flags.make_target = s.to_string(); + } + } + "make_targets" | "make-targets" | "make_build_targets" | "make-build-targets" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_targets = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_targets = + s.split_whitespace().map(String::from).collect(); + } + } + "make_dirs" | "make-dirs" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_dirs = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_dirs = + s.split_whitespace().map(String::from).collect(); + } + } "make_test_vars" | "make-test-vars" => { if let Some(arr) = v.as_array() { self.build.flags.make_test_vars = arr @@ -232,6 +321,35 @@ impl PackageSpec { s.split_whitespace().map(String::from).collect(); } } + "make_test_target" | "make-test-target" => { + if let Some(s) = v.as_str() { + self.build.flags.make_test_target = s.to_string(); + } + } + "make_test_targets" | "make-test-targets" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_test_targets = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_test_targets = + s.split_whitespace().map(String::from).collect(); + } + } + "make_test_dirs" | "make-test-dirs" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_test_dirs = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_test_dirs = + s.split_whitespace().map(String::from).collect(); + } + } "make_install_vars" | "make-install-vars" => { if let Some(arr) = v.as_array() { self.build.flags.make_install_vars = arr @@ -244,6 +362,35 @@ impl PackageSpec { s.split_whitespace().map(String::from).collect(); } } + "make_install_target" | "make-install-target" => { + if let Some(s) = v.as_str() { + self.build.flags.make_install_target = s.to_string(); + } + } + "make_install_targets" | "make-install-targets" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_install_targets = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_install_targets = + s.split_whitespace().map(String::from).collect(); + } + } + "make_install_dirs" | "make-install-dirs" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_install_dirs = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_install_dirs = + s.split_whitespace().map(String::from).collect(); + } + } "passthrough_env" | "passthrough-env" | "pass_env" | "pass-env" | "export_env" | "export-env" => { if let Some(arr) = v.as_array() { @@ -262,11 +409,62 @@ impl PackageSpec { self.build.flags.no_flags = b; } } + "no_strip" | "no-strip" => { + if let Some(b) = v.as_bool() { + self.build.flags.no_strip = b; + } + } + "no_compress_man" + | "no-compress-man" + | "no_compress_manpages" + | "no-compress-manpages" => { + if let Some(b) = v.as_bool() { + self.build.flags.no_compress_man = b; + } + } "skip_tests" | "skip-tests" => { if let Some(b) = v.as_bool() { self.build.flags.skip_tests = b; } } + "configure_file" | "configure-file" => { + if let Some(s) = v.as_str() { + self.build.flags.configure_file = s.to_string(); + } + } + "post_configure" | "post-configure" => { + if let Some(arr) = v.as_array() { + self.build.flags.post_configure = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.post_configure = vec![s.to_string()]; + } + } + "post_compile" | "post-compile" => { + if let Some(arr) = v.as_array() { + self.build.flags.post_compile = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.post_compile = vec![s.to_string()]; + } + } + "post_install" | "post-install" => { + if let Some(arr) = v.as_array() { + self.build.flags.post_install = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.post_install = vec![s.to_string()]; + } + } // Add more fields as needed _ => {} } @@ -323,6 +521,47 @@ impl PackageSpec { } } } + "configure_file" | "configure-file" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.configure_file = s.to_string(); + } + } + "post_configure" | "post-configure" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .post_configure + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.post_configure.push(s.to_string()); + } + } + } + "post_compile" | "post-compile" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .post_compile + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.post_compile.push(s.to_string()); + } + } + } + "post_install" | "post-install" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .post_install + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.post_install.push(s.to_string()); + } + } + } "cargs" => { for v in values { if let Some(arr) = v.as_array() { @@ -392,6 +631,46 @@ impl PackageSpec { } } } + "make_exec" | "make-exec" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.make_exec = s.to_string(); + } + } + "make_target" | "make-target" | "make_build_target" | "make-build-target" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.make_target = s.to_string(); + } + } + "make_targets" | "make-targets" | "make_build_targets" | "make-build-targets" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_targets + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_targets + .extend(s.split_whitespace().map(String::from)); + } + } + } + "make_dirs" | "make-dirs" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_dirs + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_dirs + .extend(s.split_whitespace().map(String::from)); + } + } + } "make_test_vars" | "make-test-vars" => { for v in values { if let Some(arr) = v.as_array() { @@ -407,6 +686,41 @@ impl PackageSpec { } } } + "make_test_target" | "make-test-target" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.make_test_target = s.to_string(); + } + } + "make_test_targets" | "make-test-targets" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_test_targets + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_test_targets + .extend(s.split_whitespace().map(String::from)); + } + } + } + "make_test_dirs" | "make-test-dirs" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_test_dirs + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_test_dirs + .extend(s.split_whitespace().map(String::from)); + } + } + } "make_install_vars" | "make-install-vars" => { for v in values { if let Some(arr) = v.as_array() { @@ -422,6 +736,41 @@ impl PackageSpec { } } } + "make_install_target" | "make-install-target" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.make_install_target = s.to_string(); + } + } + "make_install_targets" | "make-install-targets" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_install_targets + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_install_targets + .extend(s.split_whitespace().map(String::from)); + } + } + } + "make_install_dirs" | "make-install-dirs" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_install_dirs + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_install_dirs + .extend(s.split_whitespace().map(String::from)); + } + } + } "passthrough_env" | "passthrough-env" | "pass_env" | "pass-env" | "export_env" | "export-env" => { for v in values { @@ -443,6 +792,19 @@ impl PackageSpec { self.build.flags.no_flags = b; } } + "no_strip" | "no-strip" => { + if let Some(b) = values.last().and_then(|v| v.as_bool()) { + self.build.flags.no_strip = b; + } + } + "no_compress_man" + | "no-compress-man" + | "no_compress_manpages" + | "no-compress-manpages" => { + if let Some(b) = values.last().and_then(|v| v.as_bool()) { + self.build.flags.no_compress_man = b; + } + } "skip_tests" | "skip-tests" => { if let Some(b) = values.last().and_then(|v| v.as_bool()) { self.build.flags.skip_tests = b; @@ -532,6 +894,136 @@ type = "custom" assert_eq!(spec.sources()[1].extract_dir, "bar"); } + #[test] + fn parse_package_dependencies_overrides() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[dependencies] +runtime = ["base"] + +[package_dependencies.clang] +runtime = ["llvm-libs", "llvm-libgcc"] + +[package_dependencies.llvm-libs] +runtime = ["llvm-libgcc", "zstd"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.dependencies_for_output("llvm").runtime, + vec!["base".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("clang").runtime, + vec!["llvm-libs".to_string(), "llvm-libgcc".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("llvm-libs").runtime, + vec!["llvm-libgcc".to_string(), "zstd".to_string()] + ); + } + + #[test] + fn parse_package_alternatives_overrides() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[alternatives] +provides = ["toolchain"] + +[package_alternatives.clang] +provides = ["cc", "c++", "gcc"] + +[package_alternatives.llvm] +provides = ["binutils"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.alternatives_for_output("llvm").provides, + vec!["binutils".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("clang").provides, + vec!["cc".to_string(), "c++".to_string(), "gcc".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("other").provides, + vec!["toolchain".to_string()] + ); + } + + #[test] + fn parse_python_build_type() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "python" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(matches!(spec.build.build_type, BuildType::Python)); + } + #[test] fn parse_multiple_licenses() { let tmp = tempfile::tempdir().unwrap(); @@ -652,7 +1144,10 @@ type = "custom" .unwrap(); let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); - assert!(err.to_string().contains("exactly one of 'file' or 'url'")); + assert!( + err.to_string() + .contains("must specify one of 'file', 'files', 'url', or 'urls'") + ); } #[test] @@ -683,10 +1178,40 @@ type = "custom" let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); assert!( err.to_string() - .contains("cannot specify both 'file' and 'url'") + .contains("cannot mix local ('file'/'files') and remote ('url'/'urls') entries") ); } + #[test] + fn parse_manual_source_with_files_array() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +files = ["other", "system-auth"] + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.manual_sources.len(), 1); + assert_eq!(spec.manual_sources[0].files, vec!["other", "system-auth"]); + assert!(spec.manual_sources[0].urls.is_empty()); + } + #[test] fn test_apply_config() { let mut spec = mk_spec("foo", "1.0"); @@ -700,9 +1225,16 @@ cc = "my-cc" cflags = ["-O2"] passthrough_env = ["RUSTFLAGS"] make_vars = ["V=1"] +make_dirs = ["lib"] +make_test_dirs = ["tests"] +make_install_dirs = ["lib"] no_flags = true +no_strip = true +no_compress_man = true skip_tests = true keep = ["etc/locale.gen"] +configure_file = "configure.gnu" +post_configure = ["echo configured"] "#, ) .unwrap(); @@ -723,6 +1255,14 @@ keep = ["etc/locale.gen"] "etc/locale.gen".to_string(), )])], ); + config.appends.insert( + "build.flags.no_strip".to_string(), + vec![toml::Value::Boolean(false)], + ); + config.appends.insert( + "build.flags.no_compress_man".to_string(), + vec![toml::Value::Boolean(false)], + ); config.appends.insert( "build.flags.passthrough_env".to_string(), vec![toml::Value::String("CARGO_HOME".to_string())], @@ -731,10 +1271,30 @@ keep = ["etc/locale.gen"] "build.flags.make_test_vars".to_string(), vec![toml::Value::String("TESTS=smoke".to_string())], ); + config.appends.insert( + "build.flags.make_dirs".to_string(), + vec![toml::Value::String("libelf".to_string())], + ); + config.appends.insert( + "build.flags.make_test_dirs".to_string(), + vec![toml::Value::String("fuzz".to_string())], + ); + config.appends.insert( + "build.flags.make_install_dirs".to_string(), + vec![toml::Value::String("tools".to_string())], + ); config.appends.insert( "build.flags.make_install_vars".to_string(), vec![toml::Value::String("DESTDIR=/tmp/pkg".to_string())], ); + config.appends.insert( + "build.flags.configure_file".to_string(), + vec![toml::Value::String("build-aux/configure".to_string())], + ); + config.appends.insert( + "build.flags.post_configure".to_string(), + vec![toml::Value::String("touch configured.stamp".to_string())], + ); spec.apply_config(&config); @@ -749,6 +1309,8 @@ keep = ["etc/locale.gen"] .contains(&"opt-level=3".to_string()) ); assert!(spec.build.flags.no_flags); + assert!(!spec.build.flags.no_strip); + assert!(!spec.build.flags.no_compress_man); assert!( spec.build .flags @@ -768,6 +1330,8 @@ keep = ["etc/locale.gen"] .contains(&"CARGO_HOME".to_string()) ); assert!(spec.build.flags.make_vars.contains(&"V=1".to_string())); + assert!(spec.build.flags.make_dirs.contains(&"lib".to_string())); + assert!(spec.build.flags.make_dirs.contains(&"libelf".to_string())); assert!(spec.build.flags.skip_tests); assert!( spec.build @@ -775,12 +1339,49 @@ keep = ["etc/locale.gen"] .make_test_vars .contains(&"TESTS=smoke".to_string()) ); + assert!( + spec.build + .flags + .make_test_dirs + .contains(&"tests".to_string()) + ); + assert!( + spec.build + .flags + .make_test_dirs + .contains(&"fuzz".to_string()) + ); assert!( spec.build .flags .make_install_vars .contains(&"DESTDIR=/tmp/pkg".to_string()) ); + assert!( + spec.build + .flags + .make_install_dirs + .contains(&"lib".to_string()) + ); + assert!( + spec.build + .flags + .make_install_dirs + .contains(&"tools".to_string()) + ); + assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); + assert!( + spec.build + .flags + .post_configure + .contains(&"echo configured".to_string()) + ); + assert!( + spec.build + .flags + .post_configure + .contains(&"touch configured.stamp".to_string()) + ); } #[test] @@ -816,6 +1417,41 @@ no_flags = true assert!(spec.build.flags.no_flags); } + #[test] + fn parse_no_strip_and_no_compress_man_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +no_strip = true +no-compress-man = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_strip); + assert!(spec.build.flags.no_compress_man); + } + #[test] fn parse_no_flags_alias_from_spec() { let tmp = tempfile::tempdir().unwrap(); @@ -915,6 +1551,75 @@ type = "autotools" assert!(spec.build.flags.skip_tests); } + #[test] + fn parse_configure_file_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +configure_file = "build-aux/configure" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); + } + + #[test] + fn parse_post_configure_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "cmake" + +[build.flags] +post_configure = ["cmake -L . > cmake-options.txt"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.post_configure, + vec!["cmake -L . > cmake-options.txt".to_string()] + ); + } + #[test] fn parse_keep_from_spec() { let tmp = tempfile::tempdir().unwrap(); @@ -1049,8 +1754,18 @@ type = "autotools" [build.flags] make_vars = ["V=1", "CC=clang"] +make_exec = "ninja" +make_target = "bootstrap" +make_targets = ["stage1", "stage2"] +make_dirs = ["lib", "libelf"] make_test_vars = ["TESTS=unit"] +make_test_target = "test" +make_test_targets = ["test-unit", "test-integration"] +make_test_dirs = ["tests"] make_install_vars = ["STRIPPROG=true"] +make_install_target = "install/strip" +make_install_targets = ["install-runtime", "install-devel"] +make_install_dirs = ["lib", "apps"] "#, ) .unwrap(); @@ -1060,14 +1775,42 @@ make_install_vars = ["STRIPPROG=true"] spec.build.flags.make_vars, vec!["V=1".to_string(), "CC=clang".to_string()] ); + assert_eq!(spec.build.flags.make_exec, "ninja"); + assert_eq!(spec.build.flags.make_target, "bootstrap"); + assert_eq!( + spec.build.flags.make_targets, + vec!["stage1".to_string(), "stage2".to_string()] + ); + assert_eq!( + spec.build.flags.make_dirs, + vec!["lib".to_string(), "libelf".to_string()] + ); assert_eq!( spec.build.flags.make_test_vars, vec!["TESTS=unit".to_string()] ); + assert_eq!(spec.build.flags.make_test_target, "test".to_string()); + assert_eq!( + spec.build.flags.make_test_targets, + vec!["test-unit".to_string(), "test-integration".to_string()] + ); + assert_eq!(spec.build.flags.make_test_dirs, vec!["tests".to_string()]); assert_eq!( spec.build.flags.make_install_vars, vec!["STRIPPROG=true".to_string()] ); + assert_eq!( + spec.build.flags.make_install_target, + "install/strip".to_string() + ); + assert_eq!( + spec.build.flags.make_install_targets, + vec!["install-runtime".to_string(), "install-devel".to_string()] + ); + assert_eq!( + spec.build.flags.make_install_dirs, + vec!["lib".to_string(), "apps".to_string()] + ); } #[test] @@ -1188,6 +1931,8 @@ type = "custom" flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: PathBuf::from("."), } } @@ -1313,9 +2058,15 @@ pub struct ManualSource { /// Filename in the spec directory (local manual source mode). #[serde(default)] pub file: Option, + /// Multiple filenames in the spec directory (local manual source mode). + #[serde(default)] + pub files: Vec, /// Remote URL to fetch (remote manual source mode). #[serde(default)] pub url: Option, + /// Multiple remote URLs to fetch (remote manual source mode). + #[serde(default)] + pub urls: Vec, /// Checksum (optional, use "skip" to bypass verification). #[serde(default)] pub sha256: Option, @@ -1362,6 +2113,7 @@ pub enum BuildType { CMake, Meson, Custom, + Python, Rust, Makefile, Bin, @@ -1380,11 +2132,25 @@ pub struct BuildFlags { /// Disable exporting CFLAGS/CXXFLAGS/LDFLAGS for this package build. #[serde(default, alias = "no-flags")] pub no_flags: bool, + /// Disable automatic stripping of ELF files during staging. + #[serde(default, alias = "no-strip")] + pub no_strip: bool, + /// Disable automatic zstd compression of man pages during staging. + #[serde( + default, + alias = "no-compress-man", + alias = "no_compress_manpages", + alias = "no-compress-manpages" + )] + pub no_compress_man: bool, /// Skip automatic build-system test execution (e.g. Autotools `make check`/`make test`). #[serde(default, alias = "skip-tests")] pub skip_tests: bool, #[serde(default)] pub configure: Vec, + /// Autotools configure script path, relative to source root or absolute. + #[serde(default, alias = "configure-file")] + pub configure_file: String, /// C compiler #[serde(default = "default_cc")] pub cc: String, @@ -1401,11 +2167,14 @@ pub struct BuildFlags { #[serde(default = "default_rootfs")] #[allow(dead_code)] pub rootfs: String, - /// Commands to run after compile (after make, before make install) - #[serde(default)] + /// Commands to run after configure/setup step, before compile/build step. + #[serde(default, alias = "post-configure")] + pub post_configure: Vec, + /// Commands to run after compile (after make, before make install). + #[serde(default, alias = "post-compile")] pub post_compile: Vec, /// Commands to run after install (after make install) - #[serde(default)] + #[serde(default, alias = "post-install")] pub post_install: Vec, /// Specific commands for 'makefile' build type @@ -1438,6 +2207,33 @@ pub struct BuildFlags { deserialize_with = "deserialize_string_or_array" )] pub make_vars: Vec, + /// Make-like executable for build/test/install phases (default: `make`), e.g. `ninja`. + #[serde(default, alias = "make-exec")] + pub make_exec: String, + /// Target for the compile/build phase (e.g. `all`, `bootstrap`). + #[serde( + default, + alias = "make-target", + alias = "make_build_target", + alias = "make-build-target" + )] + pub make_target: String, + /// Targets for the compile/build phase (e.g. `["all", "bootstrap"]`). + #[serde( + default, + alias = "make-targets", + alias = "make_build_targets", + alias = "make-build-targets", + deserialize_with = "deserialize_string_or_array" + )] + pub make_targets: Vec, + /// Subdirectories (relative to build directory) where `make` should run. + #[serde( + default, + alias = "make-dirs", + deserialize_with = "deserialize_string_or_array" + )] + pub make_dirs: Vec, /// Variable overrides passed directly to `make check` / `make test`. #[serde( default, @@ -1445,6 +2241,23 @@ pub struct BuildFlags { deserialize_with = "deserialize_string_or_array" )] pub make_test_vars: Vec, + /// Target for the test phase, passed to the make-like executable. + #[serde(default, alias = "make-test-target")] + pub make_test_target: String, + /// Targets for the test phase, passed to the make-like executable. + #[serde( + default, + alias = "make-test-targets", + deserialize_with = "deserialize_string_or_array" + )] + pub make_test_targets: Vec, + /// Subdirectories (relative to build directory) where test targets should run. + #[serde( + default, + alias = "make-test-dirs", + deserialize_with = "deserialize_string_or_array" + )] + pub make_test_dirs: Vec, /// Variable overrides passed directly to `make install`. #[serde( default, @@ -1452,6 +2265,23 @@ pub struct BuildFlags { deserialize_with = "deserialize_string_or_array" )] pub make_install_vars: Vec, + /// Target for the install phase (default: `install`). + #[serde(default, alias = "make-install-target")] + pub make_install_target: String, + /// Targets for the install phase. + #[serde( + default, + alias = "make-install-targets", + deserialize_with = "deserialize_string_or_array" + )] + pub make_install_targets: Vec, + /// Subdirectories (relative to build directory) where `make install` should run. + #[serde( + default, + alias = "make-install-dirs", + deserialize_with = "deserialize_string_or_array" + )] + pub make_install_dirs: Vec, /// Additional host environment variable names to export unchanged to build commands. /// Example: ["RUSTFLAGS", "CARGO_HOME"]. #[serde( @@ -1501,13 +2331,17 @@ impl Default for BuildFlags { ldflags: Vec::new(), keep: Vec::new(), no_flags: false, + no_strip: false, + no_compress_man: false, skip_tests: false, configure: Vec::new(), + configure_file: String::new(), cc: default_cc(), cxx: default_cxx(), ar: default_ar(), libc: String::new(), rootfs: default_rootfs(), + post_configure: Vec::new(), post_compile: Vec::new(), post_install: Vec::new(), makefile_commands: Vec::new(), @@ -1517,8 +2351,18 @@ impl Default for BuildFlags { cbuild: String::new(), carch: default_carch(), make_vars: Vec::new(), + make_exec: String::new(), + make_target: String::new(), + make_targets: Vec::new(), + make_dirs: Vec::new(), make_test_vars: Vec::new(), + make_test_target: String::new(), + make_test_targets: Vec::new(), + make_test_dirs: Vec::new(), make_install_vars: Vec::new(), + make_install_target: String::new(), + make_install_targets: Vec::new(), + make_install_dirs: Vec::new(), passthrough_env: Vec::new(), profile: default_profile(), target: String::new(), diff --git a/src/shell_helpers.rs b/src/shell_helpers.rs new file mode 100644 index 0000000..16182cf --- /dev/null +++ b/src/shell_helpers.rs @@ -0,0 +1,259 @@ +//! Shell helper commands exposed to package build/post-install scripts. + +use crate::builder::{EnvVars, set_env_var}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// Internal staging namespace reserved inside `DESTDIR`. +pub const INTERNAL_DEPOT_DIR: &str = ".depot"; +/// Internal split-output staging root inside `DESTDIR`. +pub const INTERNAL_OUTPUTS_DIR: &str = ".depot/outputs"; + +/// Ephemeral helper command directory to prepend to PATH while running scripts. +pub struct ShellHelpers { + _tempdir: TempDir, + path_value: String, + outputs_dir: PathBuf, +} + +impl ShellHelpers { + /// Create helper commands for a given staging tree (`DESTDIR`). + pub fn new(destdir: &Path) -> Result { + let tempdir = tempfile::tempdir().context("Failed to create shell helper tempdir")?; + let bin_dir = tempdir.path().join("bin"); + fs::create_dir_all(&bin_dir) + .with_context(|| format!("Failed to create helper bin dir: {}", bin_dir.display()))?; + + let haul_path = bin_dir.join("haul"); + fs::write(&haul_path, HAUL_SCRIPT).with_context(|| { + format!( + "Failed to write shell helper command: {}", + haul_path.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&haul_path) + .with_context(|| format!("Failed to stat helper: {}", haul_path.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&haul_path, perms) + .with_context(|| format!("Failed to chmod helper: {}", haul_path.display()))?; + } + + let subdestdir_path = bin_dir.join("subdestdir"); + fs::write(&subdestdir_path, SUBDESTDIR_SCRIPT).with_context(|| { + format!( + "Failed to write shell helper command: {}", + subdestdir_path.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&subdestdir_path) + .with_context(|| format!("Failed to stat helper: {}", subdestdir_path.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&subdestdir_path, perms).with_context(|| { + format!("Failed to chmod helper: {}", subdestdir_path.display()) + })?; + } + + let parent_path = std::env::var("PATH").unwrap_or_default(); + let helper_path = bin_dir.to_string_lossy().into_owned(); + let path_value = if parent_path.is_empty() { + helper_path + } else { + format!("{}:{}", helper_path, parent_path) + }; + + Ok(Self { + _tempdir: tempdir, + path_value, + outputs_dir: destdir.join(INTERNAL_OUTPUTS_DIR), + }) + } + + /// Apply helper-related variables to an environment vector used with `prepare_command`. + pub fn apply_to_env_vars(&self, env_vars: &mut EnvVars) { + set_env_var(env_vars, "PATH", self.path_value.clone()); + set_env_var( + env_vars, + "DEPOT_OUTPUTS_DIR", + self.outputs_dir.to_string_lossy().into_owned(), + ); + set_env_var(env_vars, "DEPOT_INTERNAL_DIR", INTERNAL_DEPOT_DIR); + } + + /// Apply helper-related variables directly to a `std::process::Command`. + pub fn apply_to_command(&self, cmd: &mut std::process::Command) { + cmd.env("PATH", &self.path_value) + .env("DEPOT_OUTPUTS_DIR", &self.outputs_dir) + .env("DEPOT_INTERNAL_DIR", INTERNAL_DEPOT_DIR); + } +} + +/// Convert a package name into a safe shell identifier suffix. +pub fn shell_ident_suffix(pkg_name: &str) -> String { + let mut out = String::with_capacity(pkg_name.len().max(1)); + for ch in pkg_name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + } else { + out.push('_'); + } + } + if out.is_empty() { + out.push('_'); + } + if out.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +const HAUL_SCRIPT: &str = r#"#!/bin/sh +set -eu + +usage() { + echo "Usage: haul [path-pattern ...]" >&2 + exit 2 +} + +fail() { + echo "haul: $*" >&2 + exit 1 +} + +[ "$#" -ge 2 ] || usage + +out_pkg=$1 +shift + +case "$out_pkg" in + ""|.|..|*/*) fail "invalid output package name: $out_pkg" ;; +esac + +[ "${DESTDIR:-}" != "" ] || fail "DESTDIR is not set" +[ "${DEPOT_OUTPUTS_DIR:-}" != "" ] || fail "DEPOT_OUTPUTS_DIR is not set" + +src_root=$DESTDIR +out_root=$DEPOT_OUTPUTS_DIR/$out_pkg + +case "$src_root" in + */) src_root=${src_root%/} ;; +esac +case "$out_root" in + */) out_root=${out_root%/} ;; +esac + +mkdir -p "$out_root" + +tmp_matches=$(mktemp "${TMPDIR:-/tmp}/depot-haul.XXXXXX") +trap 'rm -f "$tmp_matches"' EXIT HUP INT TERM +: > "$tmp_matches" + +collect_matches_for_pattern() { + pattern=$1 + before_count=$(wc -l < "$tmp_matches" | tr -d '[:space:]') + case "$pattern" in + ""|/|/*|../*|*/../*|..) + fail "unsafe pattern: $pattern" + ;; + .depot|.depot/*) + fail "refusing to haul internal staging paths: $pattern" + ;; + esac + + if [ ! -d "$src_root" ]; then + fail "DESTDIR does not exist: $src_root" + fi + + find "$src_root" -mindepth 1 \ + ! -path "$DEPOT_OUTPUTS_DIR" ! -path "$DEPOT_OUTPUTS_DIR/*" \ + -print | LC_ALL=C sort | + while IFS= read -r abs_path; do + rel_path=${abs_path#"$src_root"/} + [ "$rel_path" != "$abs_path" ] || continue + case "$rel_path" in + $pattern) + printf '%s\n' "$rel_path" >> "$tmp_matches" + ;; + esac + done + + after_count=$(wc -l < "$tmp_matches" | tr -d '[:space:]') + if [ "$before_count" = "$after_count" ]; then + fail "no matches for pattern: $pattern" + fi +} + +for pattern in "$@"; do + collect_matches_for_pattern "$pattern" +done + +LC_ALL=C sort -u "$tmp_matches" | while IFS= read -r rel_path; do + [ -n "$rel_path" ] || continue + src_path=$src_root/$rel_path + + if [ ! -e "$src_path" ] && [ ! -L "$src_path" ]; then + # Path may already have been moved because an ancestor directory matched. + continue + fi + + dst_path=$out_root/$rel_path + dst_parent=$(dirname "$dst_path") + mkdir -p "$dst_parent" + + if [ -e "$dst_path" ] || [ -L "$dst_path" ]; then + fail "destination already exists: $dst_path" + fi + + mv "$src_path" "$dst_path" +done + +# Clean empty directories left behind in the primary staging tree, but never +# touch the internal depot namespace. +find "$src_root" -depth -type d \ + ! -path "$src_root" \ + ! -path "$src_root/.depot" ! -path "$src_root/.depot/*" \ + -empty -exec rmdir {} + 2>/dev/null || true +"#; + +const SUBDESTDIR_SCRIPT: &str = r#"#!/bin/sh +set -eu + +fail() { + echo "subdestdir: $*" >&2 + exit 1 +} + +[ "$#" -eq 1 ] || fail "usage: subdestdir " +[ "${DEPOT_OUTPUTS_DIR:-}" != "" ] || fail "DEPOT_OUTPUTS_DIR is not set" + +pkg=$1 +case "$pkg" in + ""|.|..|*/*) fail "invalid output package name: $pkg" ;; +esac + +path=$DEPOT_OUTPUTS_DIR/$pkg +mkdir -p "$path" +printf '%s\n' "$path" +"#; + +#[cfg(test)] +mod tests { + use super::shell_ident_suffix; + + #[test] + fn shell_ident_suffix_normalizes_package_names() { + assert_eq!(shell_ident_suffix("clang"), "CLANG"); + assert_eq!(shell_ident_suffix("llvm-libgcc"), "LLVM_LIBGCC"); + assert_eq!(shell_ident_suffix("3foo"), "_3FOO"); + assert_eq!(shell_ident_suffix("foo.bar+baz"), "FOO_BAR_BAZ"); + } +} diff --git a/src/source/extractor.rs b/src/source/extractor.rs index 77070b5..3c8ee35 100755 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -35,7 +35,7 @@ pub fn extract_archive( .and_then(|n| n.to_str()) .unwrap_or(""); - println!("Extracting: {}", filename); + crate::log_info!("Extracting: {}", filename); if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { extract_tar_gz(archive_path, &extract_path)?; @@ -70,7 +70,7 @@ pub fn extract_archive( ); } - println!("Extracted to: {}", extract_path.display()); + crate::log_info!("Extracted to: {}", extract_path.display()); Ok(extract_path) } diff --git a/src/source/fetcher.rs b/src/source/fetcher.rs index 3ec562c..2b10675 100755 --- a/src/source/fetcher.rs +++ b/src/source/fetcher.rs @@ -24,11 +24,11 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R // Check if already cached and verified if dest_path.exists() && verify_checksum(&dest_path, &source.sha256)? { - println!("Using cached source: {}", dest_path.display()); + crate::log_info!("Using cached source: {}", dest_path.display()); return Ok(dest_path); } - println!("Fetching: {}", url); + crate::log_info!("Fetching: {}", url); // Parse URL early so we can handle non-HTTP schemes (FTP support) let parsed_url = Url::parse(&url).with_context(|| format!("Invalid URL: {}", url))?; @@ -165,7 +165,7 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R if !seen_alts.insert(alt.clone()) { bail!("Mirror retry loop detected for URL: {}", alt); } - println!("Retrying download from mirror: {}", alt); + crate::log_info!("Retrying download from mirror: {}", alt); fs::remove_file(&dest_path).ok(); // If mirror URL is FTP -> use suppaftp; otherwise use HTTP retry. @@ -280,7 +280,7 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R bail!("Checksum verification failed for {}", filename); } - println!("Checksum verified: {}", filename); + crate::log_info!("Checksum verified: {}", filename); Ok(dest_path) } diff --git a/src/source/git.rs b/src/source/git.rs index 50aa734..3c92590 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -43,7 +43,7 @@ pub fn checkout( .to_str() .ok_or_else(|| anyhow::anyhow!("Invalid mirror path"))?; - println!("Cloning git source into {}...", checkout_dir.display()); + crate::log_info!("Cloning git source into {}...", checkout_dir.display()); Repository::clone(mirror_url, checkout_dir) .with_context(|| format!("Failed to clone from mirror for {}", url))?; @@ -62,7 +62,7 @@ fn mirror_key(url: &str) -> String { fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> { if !mirror_dir.exists() { - println!("Cloning git mirror for {} ({})...", pkgname, url); + crate::log_info!("Cloning git mirror for {} ({})...", pkgname, url); let mut fo = FetchOptions::new(); fo.remote_callbacks(remote_callbacks()); let mut builder = git2::build::RepoBuilder::new(); diff --git a/src/source/hooks.rs b/src/source/hooks.rs index a70bf64..6cbdc54 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -50,13 +50,13 @@ fn apply_patches( ) })?; - println!("Applying {} patch(es)...", source.patches.len()); + crate::log_info!("Applying {} patch(es)...", source.patches.len()); for p in &source.patches { let p = spec.expand_vars(p); let patch_path = resolve_patch_path(spec, &p, &patch_cache_dir)?; - println!(" patch: {}", patch_path.display()); + crate::log_info!(" patch: {}", patch_path.display()); // Apply with patch(1). Keep it simple: -p1 is the common case. let status = Command::new("patch") @@ -80,14 +80,14 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path return Ok(()); } - println!( + crate::log_info!( "Running {} post-extract command(s)...", source.post_extract.len() ); for cmd in &source.post_extract { let cmd = spec.expand_vars(cmd); - println!(" post_extract: {}", cmd); + crate::log_info!(" post_extract: {}", cmd); // Use a shell for convenience; this is a package manager, so specs are trusted input. let status = Command::new("sh") @@ -107,32 +107,75 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path } /// Run post-compile commands (after make, before make install). -pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> { - let commands = &spec.build.flags.post_compile; +pub fn run_post_configure_commands( + spec: &PackageSpec, + src_dir: &Path, + destdir: &Path, +) -> Result<()> { + let commands = &spec.build.flags.post_configure; if commands.is_empty() { return Ok(()); } - println!("Running {} post-compile command(s)...", commands.len()); + crate::log_info!("Running {} post-configure command(s)...", commands.len()); + let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; for cmd in commands { - let cmd = spec.expand_vars(cmd); - println!(" post_compile: {}", cmd); + let cmd_str = spec.expand_vars(cmd); + crate::log_info!(" post_configure: {}", cmd_str); - let status = Command::new("sh") - .current_dir(src_dir) + let mut shell_cmd = Command::new("sh"); + shell_cmd.current_dir(src_dir); + shell_helpers.apply_to_command(&mut shell_cmd); + let status = shell_cmd .env("DEPOT_SPECDIR", &spec.spec_dir) .env("DESTDIR", destdir) .env("DEPOT_ROOTFS", &spec.build.flags.rootfs) .env("CC", &spec.build.flags.cc) .env("AR", &spec.build.flags.ar) .arg("-c") - .arg(&cmd) + .arg(&cmd_str) .status() - .with_context(|| format!("Failed to run post_compile command: {}", cmd))?; + .with_context(|| format!("Failed to run post_configure command: {}", cmd_str))?; if !status.success() { - bail!("post_compile command failed: {}", cmd); + bail!("post_configure command failed: {}", cmd_str); + } + } + + Ok(()) +} + +/// Run post-compile commands (after make, before make install). +pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> { + let commands = &spec.build.flags.post_compile; + if commands.is_empty() { + return Ok(()); + } + + crate::log_info!("Running {} post-compile command(s)...", commands.len()); + let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; + + for cmd in commands { + let cmd_str = spec.expand_vars(cmd); + crate::log_info!(" post_compile: {}", cmd_str); + + let mut shell_cmd = Command::new("sh"); + shell_cmd.current_dir(src_dir); + shell_helpers.apply_to_command(&mut shell_cmd); + let status = shell_cmd + .env("DEPOT_SPECDIR", &spec.spec_dir) + .env("DESTDIR", destdir) + .env("DEPOT_ROOTFS", &spec.build.flags.rootfs) + .env("CC", &spec.build.flags.cc) + .env("AR", &spec.build.flags.ar) + .arg("-c") + .arg(&cmd_str) + .status() + .with_context(|| format!("Failed to run post_compile command: {}", cmd_str))?; + + if !status.success() { + bail!("post_compile command failed: {}", cmd_str); } } @@ -146,26 +189,29 @@ pub fn run_post_install_commands(spec: &PackageSpec, src_dir: &Path, destdir: &P return Ok(()); } - println!("Running {} post-install command(s)...", commands.len()); + crate::log_info!("Running {} post-install command(s)...", commands.len()); + let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; for cmd in commands { - let cmd = spec.expand_vars(cmd); - println!(" post_install: {}", cmd); + let cmd_str = spec.expand_vars(cmd); + crate::log_info!(" post_install: {}", cmd_str); - let status = Command::new("sh") - .current_dir(src_dir) + let mut shell_cmd = Command::new("sh"); + shell_cmd.current_dir(src_dir); + shell_helpers.apply_to_command(&mut shell_cmd); + let status = shell_cmd .env("DEPOT_SPECDIR", &spec.spec_dir) .env("DESTDIR", destdir) .env("DEPOT_ROOTFS", &spec.build.flags.rootfs) .env("CC", &spec.build.flags.cc) .env("AR", &spec.build.flags.ar) .arg("-c") - .arg(&cmd) + .arg(&cmd_str) .status() - .with_context(|| format!("Failed to run post_install command: {}", cmd))?; + .with_context(|| format!("Failed to run post_install command: {}", cmd_str))?; if !status.success() { - bail!("post_install command failed: {}", cmd); + bail!("post_install command failed: {}", cmd_str); } } @@ -240,7 +286,7 @@ fn download(url: &str, dest: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::resolve_patch_path; + use super::{resolve_patch_path, run_post_install_commands}; use crate::package::{ Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, }; @@ -270,6 +316,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir: spec_dir.to_path_buf(), } } @@ -308,4 +356,28 @@ mod tests { let resolved = resolve_patch_path(&spec, url, &patch_cache_dir).unwrap(); assert_eq!(resolved, cached); } + + #[test] + fn post_install_commands_can_haul_into_output_staging() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let src_dir = tmp.path().join("src"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); + std::fs::write(destdir.join("usr/lib/libLLVM.so.1"), "llvm").unwrap(); + + let mut spec = dummy_spec(&spec_dir); + spec.build.flags.post_install = vec!["haul llvm-libs 'usr/lib/libLLVM*.so*'".into()]; + + run_post_install_commands(&spec, &src_dir, &destdir).unwrap(); + + assert!(!destdir.join("usr/lib/libLLVM.so.1").exists()); + assert!( + destdir + .join(".depot/outputs/llvm-libs/usr/lib/libLLVM.so.1") + .exists() + ); + } } diff --git a/src/source/mod.rs b/src/source/mod.rs index 3ca76b5..22dc9a9 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -25,12 +25,44 @@ pub fn copy_manual_sources(spec: &PackageSpec, cache_dir: &Path, build_dir: &Pat } fs::create_dir_all(build_dir)?; - println!("Copying {} manual source(s)...", spec.manual_sources.len()); + let manual_entry_count: usize = spec + .manual_sources + .iter() + .map(|m| { + usize::from(m.file.as_ref().is_some_and(|s| !s.trim().is_empty())) + + m.files.iter().filter(|s| !s.trim().is_empty()).count() + + usize::from(m.url.as_ref().is_some_and(|s| !s.trim().is_empty())) + + m.urls.iter().filter(|s| !s.trim().is_empty()).count() + }) + .sum(); + crate::log_info!("Copying {} manual source(s)...", manual_entry_count); for manual in &spec.manual_sources { - let (source_path, source_label, default_dest): (PathBuf, String, String) = - if let Some(file) = manual.file.as_ref() { - let file = spec.expand_vars(file); + let mut local_entries: Vec = Vec::new(); + if let Some(file) = manual.file.as_ref() { + if !file.trim().is_empty() { + local_entries.push(file.clone()); + } + } + local_entries.extend( + manual + .files + .iter() + .filter(|s| !s.trim().is_empty()) + .cloned(), + ); + + let mut url_entries: Vec = Vec::new(); + if let Some(url) = manual.url.as_ref() { + if !url.trim().is_empty() { + url_entries.push(url.clone()); + } + } + url_entries.extend(manual.urls.iter().filter(|s| !s.trim().is_empty()).cloned()); + + if !local_entries.is_empty() { + for raw_file in local_entries { + let file = spec.expand_vars(&raw_file); let src_path = spec.spec_dir.join(&file); if !src_path.exists() { bail!( @@ -40,79 +72,105 @@ pub fn copy_manual_sources(spec: &PackageSpec, cache_dir: &Path, build_dir: &Pat ); } - // Verify checksum if provided. if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") && !verify_file_hash(&src_path, expected_hash)? { bail!("Checksum mismatch for {}: expected {}", file, expected_hash); } - (src_path, file.clone(), file) - } else if let Some(url_raw) = manual.url.as_ref() { - let expanded_url = spec.expand_vars(url_raw); + let default_dest = file.clone(); + copy_manual_source_file(spec, build_dir, &src_path, &file, manual, &default_dest)?; + } + continue; + } + + if !url_entries.is_empty() { + for raw_url in url_entries { + let expanded_url = spec.expand_vars(&raw_url); let parsed = Url::parse(&expanded_url) .with_context(|| format!("Invalid URL: {}", expanded_url))?; - if parsed.scheme() == "file" { - let src_path = parsed - .to_file_path() - .map_err(|_| anyhow::anyhow!("Invalid file URL: {}", expanded_url))?; - if !src_path.exists() { - bail!("Manual source file URL not found: {}", src_path.display()); - } - if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") - && !verify_file_hash(&src_path, expected_hash)? - { - bail!( - "Checksum mismatch for {}: expected {}", - expanded_url, - expected_hash - ); - } - let default_dest = fetcher::derive_filename_from_url(&expanded_url); - (src_path, expanded_url, default_dest) - } else { - let source = crate::package::Source { - url: expanded_url.clone(), - sha256: manual.sha256.clone().unwrap_or_else(|| "skip".to_string()), - extract_dir: "manual-source".to_string(), - patches: Vec::new(), - post_extract: Vec::new(), + let (source_path, source_label, default_dest): (PathBuf, String, String) = + if parsed.scheme() == "file" { + let src_path = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file URL: {}", expanded_url))?; + if !src_path.exists() { + bail!("Manual source file URL not found: {}", src_path.display()); + } + if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") + && !verify_file_hash(&src_path, expected_hash)? + { + bail!( + "Checksum mismatch for {}: expected {}", + expanded_url, + expected_hash + ); + } + let default_dest = fetcher::derive_filename_from_url(&expanded_url); + (src_path, expanded_url, default_dest) + } else { + let source = crate::package::Source { + url: expanded_url.clone(), + sha256: manual.sha256.clone().unwrap_or_else(|| "skip".to_string()), + extract_dir: "manual-source".to_string(), + patches: Vec::new(), + post_extract: Vec::new(), + }; + let fetched = + fetcher::fetch_archive(spec, &source, &cache_dir.join("manual"))?; + let default_dest = fetcher::derive_filename_from_url(&expanded_url); + (fetched, expanded_url, default_dest) }; - let fetched = fetcher::fetch_archive(spec, &source, &cache_dir.join("manual"))?; - let default_dest = fetcher::derive_filename_from_url(&expanded_url); - (fetched, expanded_url, default_dest) - } - } else { - bail!("Manual source must define either 'file' or 'url'"); - }; - // Determine destination - let dest_name = if let Some(dest) = manual.dest.as_ref() { - spec.expand_vars(dest) - } else { - default_dest - }; - let dest_path = build_dir.join(&dest_name); - - // Create parent directories if needed - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent)?; + copy_manual_source_file( + spec, + build_dir, + &source_path, + &source_label, + manual, + &default_dest, + )?; + } + continue; } - println!(" {} -> {}", source_label, dest_path.display()); - fs::copy(&source_path, &dest_path).with_context(|| { - format!( - "Failed to copy {} to {}", - source_path.display(), - dest_path.display() - ) - })?; + bail!("Manual source must define one of 'file', 'files', 'url', or 'urls'"); } Ok(()) } +fn copy_manual_source_file( + spec: &PackageSpec, + build_dir: &Path, + source_path: &Path, + source_label: &str, + manual: &crate::package::ManualSource, + default_dest: &str, +) -> Result<()> { + let dest_name = if let Some(dest) = manual.dest.as_ref() { + spec.expand_vars(dest) + } else { + default_dest.to_string() + }; + let dest_path = build_dir.join(&dest_name); + + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + + crate::log_info!(" {} -> {}", source_label, dest_path.display()); + fs::copy(source_path, &dest_path).with_context(|| { + format!( + "Failed to copy {} to {}", + source_path.display(), + dest_path.display() + ) + })?; + Ok(()) +} + /// Verify a file against an `expected` checksum string. /// /// Formats accepted: `sha256:HEX`, `sha512:HEX`, `md5:HEX`, or plain `HEX` (assumed sha256). @@ -122,7 +180,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { let exp = expected.trim(); if exp.eq_ignore_ascii_case("skip") { - println!("Checksum verification skipped"); + crate::log_info!("Checksum verification skipped"); return Ok(true); } @@ -250,7 +308,7 @@ fn prepare_one( if dst.exists() { // If it exists and has a state file, assume we are resuming if dst.join(".depot_state").exists() { - println!( + crate::log_info!( "Resuming build in existing source directory: {}", dst.display() ); @@ -265,7 +323,7 @@ fn prepare_one( let src_dir = build_dir.join(&extract_dir_name); if src_dir.exists() { if src_dir.join(".depot_state").exists() { - println!( + crate::log_info!( "Resuming build in existing source directory: {}", src_dir.display() ); @@ -288,7 +346,7 @@ fn prepare_one( if let Some((base, rev)) = split_git_url(&url) { let checkout_dir = build_dir.join(&extract_dir_name); if checkout_dir.exists() && checkout_dir.join(".depot_state").exists() { - println!( + crate::log_info!( "Resuming build in existing git directory: {}", checkout_dir.display() ); @@ -307,7 +365,7 @@ fn prepare_one( let src_dir = build_dir.join(&extract_dir_name); if src_dir.exists() && src_dir.join(".depot_state").exists() { - println!( + crate::log_info!( "Resuming build in existing source directory: {}", src_dir.display() ); @@ -406,6 +464,8 @@ mod tests { flags: BuildFlags::default(), }, dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), spec_dir, } } @@ -437,6 +497,48 @@ mod tests { assert_eq!(rev, "HEAD"); } + #[test] + fn split_git_url_accepts_expanded_tag_or_hash_revision() { + let spec = PackageSpec { + package: PackageInfo { + name: "json".into(), + version: "3.11.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://github.com/nlohmann/json.git#v$version".into(), + sha256: "skip".into(), + extract_dir: "json-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let expanded = spec.expand_vars(&spec.source[0].url); + let (base, rev) = split_git_url(&expanded).unwrap(); + assert_eq!(base, "https://github.com/nlohmann/json.git"); + assert_eq!(rev, "v3.11.3"); + + let (base, rev) = + split_git_url("https://github.com/nlohmann/json.git#0123456789abcdef").unwrap(); + assert_eq!(base, "https://github.com/nlohmann/json.git"); + assert_eq!(rev, "0123456789abcdef"); + } + #[test] fn verify_file_hash_accepts_multiple_algorithms() { use sha2::{Digest, Sha256, Sha512}; @@ -489,7 +591,9 @@ mod tests { spec_dir.clone(), vec![ManualSource { file: Some("manual.patch".into()), + files: Vec::new(), url: None, + urls: Vec::new(), sha256: None, dest: None, }], @@ -517,7 +621,9 @@ mod tests { spec_dir, vec![ManualSource { file: None, + files: Vec::new(), url: Some(url), + urls: Vec::new(), sha256: Some("skip".into()), dest: Some("assets/manual.txt".into()), }], @@ -529,4 +635,37 @@ mod tests { "remote-data" ); } + + #[test] + fn copy_manual_sources_multi_files_in_one_block() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(spec_dir.join("pam")).unwrap(); + std::fs::write(spec_dir.join("pam/other"), "other").unwrap(); + std::fs::write(spec_dir.join("pam/system-auth"), "auth").unwrap(); + + let spec = mk_spec_with_manuals( + spec_dir.clone(), + vec![ManualSource { + file: None, + files: vec!["pam/other".into(), "pam/system-auth".into()], + url: None, + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("pam/other")).unwrap(), + "other" + ); + assert_eq!( + std::fs::read_to_string(build_dir.join("pam/system-auth")).unwrap(), + "auth" + ); + } } diff --git a/src/staging/mod.rs b/src/staging/mod.rs index 40cd36b..fab546e 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -3,23 +3,44 @@ use crate::package::PackageSpec; use anyhow::{Context, Result}; use std::collections::HashSet; +use std::ffi::OsString; use std::fs; +use std::io; +use std::io::Read; +use std::io::Write; use std::path::Component; use std::path::Path; use std::path::PathBuf; +use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use walkdir::WalkDir; +pub const INTERNAL_DEPOT_DIR: &str = ".depot"; +pub const INTERNAL_OUTPUTS_DIR: &str = ".depot/outputs"; + fn is_skipped_install_path(rel_path: &str) -> bool { let p = rel_path.trim_start_matches('/'); p == ".metadata.toml" || p == ".files.yaml" + || p == INTERNAL_DEPOT_DIR + || p.strip_prefix(INTERNAL_DEPOT_DIR) + .is_some_and(|rest| rest.starts_with('/')) || p == "scripts" || p.starts_with("scripts/") || p == "usr/share/info/dir" || p.starts_with("usr/share/info/dir.") } +/// Return the internal split-output staging root inside a package `destdir`. +pub fn output_staging_root(destdir: &Path) -> PathBuf { + destdir.join(INTERNAL_OUTPUTS_DIR) +} + +/// Return the staging directory for an additional output package. +pub fn output_staging_dir(destdir: &Path, pkg_name: &str) -> PathBuf { + output_staging_root(destdir).join(pkg_name) +} + fn normalize_relative_path(path: &str) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { @@ -57,9 +78,287 @@ fn normalize_relative_path(path: &str) -> Result { Ok(s) } +#[derive(Debug, Clone)] +enum KeepMatcher { + Exact(String), + Pattern(String), +} + +impl KeepMatcher { + fn from_spec(raw: &str) -> Result { + let normalized = normalize_relative_path(raw)?; + if normalized.contains('*') || normalized.contains('?') { + Ok(Self::Pattern(normalized)) + } else { + Ok(Self::Exact(normalized)) + } + } + + fn matches(&self, rel_path: &str) -> bool { + match self { + Self::Exact(p) => p == rel_path, + Self::Pattern(p) => glob_match_path(p, rel_path), + } + } +} + +fn glob_match_path(pattern: &str, path: &str) -> bool { + let p_parts: Vec<&str> = pattern.split('/').collect(); + let s_parts: Vec<&str> = path.split('/').collect(); + glob_match_path_parts(&p_parts, &s_parts) +} + +fn glob_match_path_parts(pattern_parts: &[&str], path_parts: &[&str]) -> bool { + if pattern_parts.is_empty() { + return path_parts.is_empty(); + } + + if pattern_parts[0] == "**" { + let mut next = 1usize; + while next < pattern_parts.len() && pattern_parts[next] == "**" { + next += 1; + } + let rest = &pattern_parts[next..]; + if rest.is_empty() { + return true; + } + for skip in 0..=path_parts.len() { + if glob_match_path_parts(rest, &path_parts[skip..]) { + return true; + } + } + return false; + } + + if path_parts.is_empty() { + return false; + } + + glob_match_segment(pattern_parts[0], path_parts[0]) + && glob_match_path_parts(&pattern_parts[1..], &path_parts[1..]) +} + +fn glob_match_segment(pattern: &str, text: &str) -> bool { + let p = pattern.as_bytes(); + let t = text.as_bytes(); + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option = None; + let mut star_match_ti = 0usize; + + while ti < t.len() { + if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + continue; + } + + if pi < p.len() && p[pi] == b'*' { + star = Some(pi); + pi += 1; + star_match_ti = ti; + continue; + } + + if let Some(star_pi) = star { + pi = star_pi + 1; + star_match_ti += 1; + ti = star_match_ti; + continue; + } + + return false; + } + + while pi < p.len() && p[pi] == b'*' { + pi += 1; + } + + pi == p.len() +} + +fn has_known_compressed_suffix(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".zst") + || lower.ends_with(".gz") + || lower.ends_with(".xz") + || lower.ends_with(".bz2") + || lower.ends_with(".lzma") + || lower.ends_with(".z") +} + +fn is_manpage_rel_path(rel_path: &str) -> bool { + let rel = rel_path.trim_start_matches('/'); + rel.starts_with("usr/share/man/") && !has_known_compressed_suffix(rel) +} + +fn append_os_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut s = OsString::from(path.as_os_str()); + s.push(suffix); + PathBuf::from(s) +} + +fn is_elf_file(path: &Path) -> Result { + let mut file = + fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let mut magic = [0u8; 4]; + let n = file + .read(&mut magic) + .with_context(|| format!("Failed to read {}", path.display()))?; + Ok(n == 4 && magic == [0x7F, b'E', b'L', b'F']) +} + +fn auto_strip_elf_files(destdir: &Path) -> Result { + let mut stripped = 0usize; + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if !is_elf_file(path)? { + continue; + } + + let status = Command::new("strip") + .arg("--strip-unneeded") + .arg(path) + .status() + .with_context(|| { + format!( + "Failed to execute strip for {} (disable with build.flags.no_strip = true)", + path.display() + ) + })?; + if !status.success() { + anyhow::bail!( + "strip failed for {} with status {} (disable with build.flags.no_strip = true)", + path.display(), + status + ); + } + stripped += 1; + } + Ok(stripped) +} + +fn compress_manpages_zstd(destdir: &Path) -> Result { + crate::log_info!("Compressing man pages with zstd..."); + let mut man_files = Vec::new(); + let mut man_symlinks = Vec::new(); + + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + let path = entry.path().to_path_buf(); + let rel = path + .strip_prefix(destdir) + .context("Failed to strip destdir prefix during manpage compression")? + .to_string_lossy() + .to_string(); + if !is_manpage_rel_path(&rel) { + continue; + } + if entry.file_type().is_file() { + man_files.push(path); + } else if entry.file_type().is_symlink() { + man_symlinks.push(path); + } + } + + let mut compressed = 0usize; + for path in &man_files { + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to inspect man page {}", path.display()))?; + let out_path = append_os_suffix(path, ".zst"); + let tmp_path = append_os_suffix(&out_path, ".tmp"); + + if tmp_path.exists() { + let _ = fs::remove_file(&tmp_path); + } + + let mut input = fs::File::open(path) + .with_context(|| format!("Failed to open man page {}", path.display()))?; + let out_file = fs::File::create(&tmp_path) + .with_context(|| format!("Failed to create {}", tmp_path.display()))?; + + let mut encoder = zstd::stream::write::Encoder::new(out_file, 22) + .with_context(|| format!("Failed to start zstd encoder for {}", path.display()))?; + io::copy(&mut input, &mut encoder) + .with_context(|| format!("Failed to compress {}", path.display()))?; + let mut out_file = encoder + .finish() + .with_context(|| format!("Failed to finish zstd compression for {}", path.display()))?; + out_file + .flush() + .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&tmp_path)?.permissions(); + perms.set_mode(metadata.permissions().mode() & 0o777); + fs::set_permissions(&tmp_path, perms)?; + } + + fs::rename(&tmp_path, &out_path).with_context(|| { + format!( + "Failed to finalize compressed man page {} -> {}", + tmp_path.display(), + out_path.display() + ) + })?; + fs::remove_file(path) + .with_context(|| format!("Failed to remove original man page {}", path.display()))?; + compressed += 1; + } + + let mut fixed_symlinks = 0usize; + for link_path in man_symlinks { + let target = fs::read_link(&link_path) + .with_context(|| format!("Failed to read manpage symlink {}", link_path.display()))?; + let target_s = target.to_string_lossy(); + if has_known_compressed_suffix(&target_s) { + continue; + } + + let new_link_path = append_os_suffix(&link_path, ".zst"); + let new_target = append_os_suffix(&target, ".zst"); + + if new_link_path.symlink_metadata().is_ok() { + fs::remove_file(&new_link_path).with_context(|| { + format!( + "Failed to remove existing compressed manpage symlink {}", + new_link_path.display() + ) + })?; + } + + std::os::unix::fs::symlink(&new_target, &new_link_path).with_context(|| { + format!( + "Failed to create compressed manpage symlink {} -> {}", + new_link_path.display(), + new_target.display() + ) + })?; + fs::remove_file(&link_path).with_context(|| { + format!( + "Failed to remove original manpage symlink {}", + link_path.display() + ) + })?; + fixed_symlinks += 1; + } + + if fixed_symlinks > 0 { + crate::log_info!( + "Updated {} man page symlink(s) for .zst targets", + fixed_symlinks + ); + } + + Ok(compressed) +} + /// Process staged files - remove .la files, strip binaries, etc. -pub fn process(destdir: &Path, _spec: &PackageSpec) -> Result<()> { - println!("Processing staged files..."); +pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> { + crate::log_info!("Processing staged files..."); let mut removed_count = 0; @@ -68,14 +367,32 @@ pub fn process(destdir: &Path, _spec: &PackageSpec) -> Result<()> { // Remove libtool .la files if path.extension().map(|e| e == "la").unwrap_or(false) { - println!(" Removing: {}", path.display()); + crate::log_info!(" Removing: {}", path.display()); fs::remove_file(path)?; removed_count += 1; } } if removed_count > 0 { - println!("Removed {} .la file(s)", removed_count); + crate::log_info!("Removed {} .la file(s)", removed_count); + } + + if spec.build.flags.no_strip { + crate::log_info!("Skipping auto-strip: disabled by build.flags.no_strip"); + } else { + let stripped = auto_strip_elf_files(destdir)?; + if stripped > 0 { + crate::log_info!("Stripped {} ELF file(s)", stripped); + } + } + + if spec.build.flags.no_compress_man { + crate::log_info!("Skipping manpage compression: disabled by build.flags.no_compress_man"); + } else { + let compressed = compress_manpages_zstd(destdir)?; + if compressed > 0 { + crate::log_info!("Compressed {} man page(s) with zstd -22", compressed); + } } Ok(()) @@ -127,7 +444,7 @@ pub fn add_licenses(src_dir: &Path, destdir: &Path, pkgname: &str) -> Result 0 { - println!( + crate::log_info!( "Copied {} license file(s) to {}/", copied, dst_base.display() @@ -225,10 +542,17 @@ pub fn install_atomic( remove_paths: &[String], keep_paths: &[String], ) -> Result { - let keep_set: HashSet = keep_paths + let keep_rules: Vec = keep_paths .iter() - .map(|p| normalize_relative_path(p)) - .collect::>>()?; + .map(|p| KeepMatcher::from_spec(p)) + .collect::>>()?; + let keep_set: HashSet = keep_rules + .iter() + .filter_map(|m| match m { + KeepMatcher::Exact(p) => Some(p.clone()), + KeepMatcher::Pattern(_) => None, + }) + .collect(); fs::create_dir_all(tx_base_dir) .with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?; @@ -265,6 +589,10 @@ pub fn install_atomic( let rel_path = src_path .strip_prefix(destdir) .context("Failed to strip destdir prefix")?; + let rel_path_str = rel_path.to_string_lossy().to_string(); + if is_skipped_install_path(&rel_path_str) { + continue; + } let dest_path = rootfs.join(rel_path); if !dest_path.exists() { @@ -295,7 +623,12 @@ pub fn install_atomic( continue; } - let keep_as_depotnew = keep_set.contains(&rel_path) && rootfs.join(&rel_path).exists(); + let keep_match = if keep_set.contains(&rel_path) { + true + } else { + keep_rules.iter().any(|m| m.matches(&rel_path)) + }; + let keep_as_depotnew = keep_match && rootfs.join(&rel_path).exists(); let install_rel_path = if keep_as_depotnew { format!("{}.depotnew", rel_path) } else { @@ -497,6 +830,115 @@ mod tests { assert!(!rootfs.join("etc/locale.gen.depotnew").exists()); } + #[test] + fn install_atomic_keep_wildcard_matches_directory_children() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("etc/pam.d")).unwrap(); + std::fs::create_dir_all(destdir.join("etc/pam.d")).unwrap(); + std::fs::create_dir_all(destdir.join("etc/pam.d/subdir")).unwrap(); + + std::fs::write(rootfs.join("etc/pam.d/system-auth"), "existing-auth").unwrap(); + std::fs::write(destdir.join("etc/pam.d/system-auth"), "pkg-auth").unwrap(); + std::fs::write(destdir.join("etc/pam.d/other"), "pkg-other").unwrap(); + std::fs::write(destdir.join("etc/pam.d/subdir/nested"), "pkg-nested").unwrap(); + + let keep = vec!["etc/pam.d/*".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); + + // Existing matched file is preserved and package version becomes .depotnew + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth")).unwrap(), + "existing-auth" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth.depotnew")).unwrap(), + "pkg-auth" + ); + + // New matched file installs normally because no existing file is present + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/other")).unwrap(), + "pkg-other" + ); + + // Single-segment * does not cross '/' + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/subdir/nested")).unwrap(), + "pkg-nested" + ); + assert!(!rootfs.join("etc/pam.d/subdir/nested.depotnew").exists()); + + tx.rollback().unwrap(); + } + + #[test] + fn keep_glob_matches_question_mark_and_not_path_separator() { + assert!(glob_match_path( + "etc/pam.d/system-????", + "etc/pam.d/system-auth" + )); + assert!(!glob_match_path("etc/pam.d/*", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/pam.d/*", "etc/pam.d/file")); + assert!(glob_match_path("etc/pam.d/**", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/**/file", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/pam.d/**", "etc/pam.d")); + } + + #[test] + fn is_manpage_rel_path_detects_uncompressed_manpages() { + assert!(is_manpage_rel_path("usr/share/man/man1/ls.1")); + assert!(is_manpage_rel_path("/usr/share/man/man5/pam.d.5")); + assert!(!is_manpage_rel_path("usr/share/man/man1/ls.1.zst")); + assert!(!is_manpage_rel_path("usr/share/doc/readme")); + } + + #[test] + fn is_elf_file_detects_magic_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let elf = tmp.path().join("elf.bin"); + let text = tmp.path().join("text.txt"); + std::fs::write(&elf, [0x7F, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); + std::fs::write(&text, b"#!/bin/sh\n").unwrap(); + + assert!(is_elf_file(&elf).unwrap()); + assert!(!is_elf_file(&text).unwrap()); + } + + #[test] + fn compress_manpages_zstd_rewrites_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("dest"); + let man1 = dest.join("usr/share/man/man1"); + std::fs::create_dir_all(&man1).unwrap(); + + let page = man1.join("foo.1"); + std::fs::write(&page, b"foo manpage\n").unwrap(); + std::os::unix::fs::symlink("foo.1", man1.join("bar.1")).unwrap(); + + let count = compress_manpages_zstd(&dest).unwrap(); + assert_eq!(count, 1); + assert!(!man1.join("foo.1").exists()); + assert!(man1.join("foo.1.zst").exists()); + assert!(!man1.join("bar.1").exists()); + + let link_meta = std::fs::symlink_metadata(man1.join("bar.1.zst")).unwrap(); + assert!(link_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(man1.join("bar.1.zst")).unwrap(), + PathBuf::from("foo.1.zst") + ); + + let file = std::fs::File::open(man1.join("foo.1.zst")).unwrap(); + let mut decoder = zstd::stream::read::Decoder::new(file).unwrap(); + let mut out = String::new(); + use std::io::Read as _; + decoder.read_to_string(&mut out).unwrap(); + assert_eq!(out, "foo manpage\n"); + } + #[test] fn install_atomic_rejects_unsafe_keep_paths() { let tmp = tempfile::tempdir().unwrap(); @@ -671,6 +1113,24 @@ mod tests { assert!(rootfs.join("usr/bin/ok").exists()); } + #[test] + fn install_atomic_skips_internal_output_staging_dir() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(rootfs.join("usr/bin/ok").exists()); + assert!(!rootfs.join(".depot").exists()); + } + #[test] fn generate_manifest_skips_info_dir_index() { let tmp = tempfile::tempdir().unwrap(); @@ -726,6 +1186,25 @@ mod tests { assert!(!manifest.files.contains(&"scripts/pre_install".to_string())); assert!(manifest.files.contains(&"usr/bin/ok".to_string())); } + + #[test] + fn generate_manifest_skips_internal_output_staging() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/llvm-config"), "ok").unwrap(); + std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(manifest.files.contains(&"usr/bin/llvm-config".to_string())); + assert!( + !manifest + .files + .contains(&".depot/outputs/clang/usr/bin/clang".to_string()) + ); + } } /// Manifest containing files and directories for a package diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..a64327f --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,135 @@ +//! Terminal UI helpers (colors + prompts). + +use anyhow::{Context, Result}; +use std::io::{self, IsTerminal, Write}; + +#[derive(Clone, Copy)] +enum Stream { + Stdout, + Stderr, +} + +fn colors_enabled(stream: Stream) -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + if matches!(std::env::var("TERM").as_deref(), Ok("dumb")) { + return false; + } + + match stream { + Stream::Stdout => io::stdout().is_terminal(), + Stream::Stderr => io::stderr().is_terminal(), + } +} + +fn paint(stream: Stream, text: &str, color_code: &str) -> String { + if colors_enabled(stream) { + format!("\x1b[{}m{}\x1b[0m", color_code, text) + } else { + text.to_string() + } +} + +fn label(stream: Stream, text: &str, color_code: &str) -> String { + format!("[{}]", paint(stream, text, color_code)) +} + +pub fn info(message: impl AsRef) { + println!( + "{} {}", + label(Stream::Stdout, "INFO", "36"), + message.as_ref() + ); +} + +pub fn success(message: impl AsRef) { + println!("{} {}", label(Stream::Stdout, "OK", "32"), message.as_ref()); +} + +pub fn warn(message: impl AsRef) { + eprintln!( + "{} {}", + label(Stream::Stderr, "WARN", "33"), + message.as_ref() + ); +} + +#[macro_export] +macro_rules! log_info { + ($($arg:tt)*) => { + $crate::ui::info(format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_ok { + ($($arg:tt)*) => { + $crate::ui::success(format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { + $crate::ui::warn(format!($($arg)*)) + }; +} + +fn parse_yes_no_input(input: &str, default_yes: bool) -> Option { + let trimmed = input.trim().to_ascii_lowercase(); + if trimmed.is_empty() { + return Some(default_yes); + } + match trimmed.as_str() { + "y" | "yes" => Some(true), + "n" | "no" => Some(false), + _ => None, + } +} + +pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { + let default_hint = if default_yes { "Y/n" } else { "y/N" }; + loop { + print!( + "{} {} [{}] ", + label(Stream::Stdout, "?", "35"), + prompt, + default_hint + ); + io::stdout() + .flush() + .context("Failed to flush prompt to stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read user input from stdin")?; + + if let Some(answer) = parse_yes_no_input(&input, default_yes) { + return Ok(answer); + } + + warn("Please answer with 'y' or 'n'."); + } +} + +#[cfg(test)] +mod tests { + use super::parse_yes_no_input; + + #[test] + fn parse_yes_no_defaults() { + assert_eq!(parse_yes_no_input("", true), Some(true)); + assert_eq!(parse_yes_no_input("", false), Some(false)); + } + + #[test] + fn parse_yes_no_values() { + assert_eq!(parse_yes_no_input("y", false), Some(true)); + assert_eq!(parse_yes_no_input("yes", false), Some(true)); + assert_eq!(parse_yes_no_input("N", true), Some(false)); + assert_eq!(parse_yes_no_input("no", true), Some(false)); + assert_eq!(parse_yes_no_input("maybe", true), None); + } +}