//! Custom build scripts 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; use tempfile::NamedTempFile; pub fn build( spec: &PackageSpec, src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, export_compiler_flags: bool, _host_build_dir: Option<&Path>, ) -> Result<()> { let flags = &spec.build.flags; let build_dir = if let Some(dir) = &flags.build_dir { let bdir = src_dir.join(dir); fs::create_dir_all(&bdir)?; bdir } else { src_dir.to_path_buf() }; let install_destdir = crate::builder::install_destdir_path(&build_dir, destdir, flags.lib32_variant); // Create destdir fs::create_dir_all(destdir)?; if flags.lib32_variant { if install_destdir.exists() { fs::remove_dir_all(&install_destdir).with_context(|| { format!( "Failed to clean temporary lib32 install dir: {}", install_destdir.display() ) })?; } fs::create_dir_all(&install_destdir).with_context(|| { format!( "Failed to create temporary lib32 install dir: {}", install_destdir.display() ) })?; } let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); let shell_helpers = crate::shell_helpers::ShellHelpers::new(&install_destdir)?; shell_helpers.apply_to_env_vars(&mut env_vars); crate::builder::apply_build_helper_context_env(&mut env_vars, spec)?; crate::builder::apply_build_helper_dirs_env(&mut env_vars, Some(src_dir), Some(&build_dir)); // For custom builds, the spec's build.sh is authoritative when present. // Upstream source archives sometimes ship unrelated helper scripts with the // same name, and the package spec needs to override them deterministically. let build_script = src_dir.join("build.sh"); let spec_build = spec.spec_dir.join("build.sh"); if spec_build.exists() { fs::create_dir_all(src_dir)?; fs::copy(&spec_build, &build_script).with_context(|| { format!( "Failed to copy build.sh from spec dir: {}", spec_build.display() ) })?; // Ensure executable bit #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&build_script)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&build_script, perms)?; } crate::log_info!("Using build.sh from spec dir: {}", spec_build.display()); } if !build_script.exists() { anyhow::bail!( "Custom build type requires build.sh in source directory: {}", src_dir.display() ); } use crate::builder::state::{BuildStep, StateTracker}; let mut state = StateTracker::new_with_namespace( src_dir, spec.build.flags.lib32_variant.then_some("lib32"), )?; 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) { crate::builder::set_env_var( &mut env_vars, "DESTDIR", install_destdir.to_string_lossy().into_owned(), ); crate::builder::set_env_var( &mut env_vars, "DEPOT_PRIMARY_DESTDIR", install_destdir.to_string_lossy().into_owned(), ); let starbuild_workdir = if spec.sources().is_empty() { src_dir.to_path_buf() } else { src_dir .parent() .map(Path::to_path_buf) .unwrap_or_else(|| src_dir.to_path_buf()) }; crate::builder::set_env_var( &mut env_vars, "DEPOT_STARBUILD_WORKDIR", starbuild_workdir.to_string_lossy().into_owned(), ); add_output_destdir_envs(spec, &install_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() { build_script.clone() } else { std::env::current_dir()?.join(&build_script) }; let function_mode = custom_function_mode_enabled(&abs_build_script)?; if function_mode { crate::log_info!( "Running custom build script (function mode; internal fakeroot only during install)..." ); crate::log_info!( "Using custom build.sh function mode (per-output install functions enabled)" ); run_function_mode_build_script( spec, &build_dir, &install_destdir, &abs_build_script, &env_vars, )?; } else { crate::log_info!( "Running custom build script{}...", if fakeroot::is_root() { "" } else { " (with internal fakeroot)" } ); // Use POSIX `sh` (doing something wrong if your system doesn't have it...) let mut cmd = fakeroot::wrap_install_command("sh", &install_destdir)?; let wrapper = crate::shell_helpers::wrap_shell_command(". \"$1\""); // Run custom scripts through `sh -c` so helper commands like `haul` // work even when the helper scripts live on a `noexec` mount. cmd.arg("-eu") .arg("-c") .arg(wrapper) .arg("sh") .arg(&abs_build_script); cmd.current_dir(&build_dir); crate::builder::prepare_tool_command(&mut cmd, &env_vars); // Run the command and include the OS error on spawn failures for clearer diagnostics let status = crate::interrupts::command_status(&mut cmd).map_err(|e| { anyhow::anyhow!( "Failed to run build script {}: {}", build_script.display(), e ) })?; if !status.success() { anyhow::bail!("Custom build script failed with status: {}", status); } } if flags.lib32_variant { crate::builder::stage_lib32_install_tree(&install_destdir, destdir)?; } state.mark_done(BuildStep::PostInstallDone)?; } else { 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 function_mode_shell_prelude() -> String { let mut wrapper = crate::shell_helpers::wrap_shell_command(""); wrapper.push_str("\nset -eu\n"); wrapper.push_str("depot_has_function() {\n"); wrapper.push_str(" case \"$(type \"$1\" 2>/dev/null || :)\" in\n"); wrapper.push_str(" *function*) return 0 ;;\n"); wrapper.push_str(" *) return 1 ;;\n"); wrapper.push_str(" esac\n"); wrapper.push_str("}\n"); wrapper } fn build_function_mode_build_command(build_script: &Path, state_file: &Path) -> Command { let mut wrapper = function_mode_shell_prelude(); wrapper.push_str("depot_build_ran=0\n"); wrapper.push_str(". \"$1\"\n"); wrapper.push_str("if depot_has_function depot_build; then depot_build; depot_build_ran=1;\n"); wrapper.push_str("elif depot_has_function build; then build; depot_build_ran=1;\n"); wrapper.push_str("fi\n"); wrapper.push_str("printf '%s\\n' \"$depot_build_ran\" > \"$2\"\n"); let mut cmd = Command::new("sh"); cmd.arg("-c") .arg(wrapper) .arg("sh") .arg(build_script) .arg(state_file); cmd } fn build_function_mode_install_command( spec: &PackageSpec, destdir: &Path, build_script: &Path, build_ran: bool, ) -> Result { let mut wrapper = function_mode_shell_prelude(); wrapper.push_str(&format!( "depot_build_ran={}\n", if build_ran { 1 } else { 0 } )); wrapper.push_str(". \"$1\"\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("depot_output_installed=0\n"); if out.name == *primary { wrapper.push_str( "if depot_has_function depot_install; then depot_install; depot_output_installed=1;\n", ); wrapper.push_str( "elif depot_has_function install; then install; depot_output_installed=1;\n", ); wrapper.push_str(&format!( "elif depot_has_function depot_install_{fn_suffix}; then depot_install_{fn_suffix}; depot_output_installed=1;\n" )); wrapper.push_str(&format!( "elif depot_has_function install_{fn_suffix}; then install_{fn_suffix}; depot_output_installed=1;\n" )); wrapper.push_str("elif [ \"$depot_build_ran\" = 1 ]; then depot_output_installed=1;\n"); } else { wrapper.push_str(&format!( "if depot_has_function depot_install_{fn_suffix}; then depot_install_{fn_suffix}; depot_output_installed=1;\n" )); wrapper.push_str(&format!( "elif depot_has_function install_{fn_suffix}; then install_{fn_suffix}; depot_output_installed=1;\n" )); } wrapper.push_str("fi\n"); wrapper.push_str("if [ \"$depot_output_installed\" != 1 ]; then\n"); wrapper.push_str( " echo \"depot: missing install handler for output '$DEPOT_OUTPUT_NAME' in custom function mode\" >&2\n", ); wrapper.push_str(" exit 1\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 run_function_mode_build_script( spec: &PackageSpec, build_dir: &Path, install_destdir: &Path, build_script: &Path, env_vars: &crate::builder::EnvVars, ) -> Result<()> { let state_file = NamedTempFile::new_in(build_dir).with_context(|| { format!( "Failed to create function mode state file in {}", build_dir.display() ) })?; let state_path = state_file.path().to_path_buf(); let mut build_cmd = build_function_mode_build_command(build_script, &state_path); build_cmd.current_dir(build_dir); let mut build_env = env_vars.clone(); crate::builder::set_env_var( &mut build_env, "DESTDIR", install_destdir.to_string_lossy().into_owned(), ); crate::builder::prepare_tool_command(&mut build_cmd, &build_env); let build_status = crate::interrupts::command_status(&mut build_cmd).map_err(|e| { anyhow::anyhow!( "Failed to run build script {}: {}", build_script.display(), e ) })?; if !build_status.success() { anyhow::bail!("Custom build script failed with status: {}", build_status); } let build_ran = fs::read_to_string(&state_path) .ok() .map(|value| value.trim() == "1") .unwrap_or(false); let mut install_cmd = build_function_mode_install_command(spec, install_destdir, build_script, build_ran)?; install_cmd.current_dir(build_dir); crate::builder::prepare_tool_command(&mut install_cmd, env_vars); let install_status = crate::interrupts::command_status(&mut install_cmd).map_err(|e| { anyhow::anyhow!( "Failed to run build script {}: {}", build_script.display(), e ) })?; if !install_status.success() { anyhow::bail!("Custom build script failed with status: {}", install_status); } Ok(()) } 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::*; use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; use tempfile::tempdir; fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { name: name.into(), real_name: None, version: version.into(), revision: 1, description: "d".into(), homepage: "h".into(), abi_breaking: false, built_against: Vec::new(), license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Default::default(), manual_sources: Vec::new(), source: vec![crate::package::Source { url: "h".into(), sha256: "s".into(), extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, flags: BuildFlags::default(), }, dependencies: Dependencies::default(), package_alternatives: Default::default(), package_dependencies: Default::default(), spec_dir: std::path::PathBuf::from("."), } } #[test] fn test_build_errors_without_build_sh() -> Result<()> { let tmp_src = tempdir()?; let tmp_dest = tempdir()?; let spec = mk_spec("custom-no-build", "1.0"); let res = build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None); assert!(res.is_err()); Ok(()) } #[test] fn test_build_uses_build_sh_from_spec_dir() -> Result<()> { let tmp_src = tempdir()?; let tmp_dest = tempdir()?; let spec_dir = tempdir()?; // write a no-op build.sh into spec_dir let build_sh = spec_dir.path().join("build.sh"); std::fs::write(&build_sh, "#!/bin/sh\nexit 0\n")?; #[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("custom-from-spec", "1.0"); spec.spec_dir = spec_dir.path().to_path_buf(); // src_dir is empty; build() should copy build.sh from spec_dir and run it (no-op) build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None)?; // If we reached here, build() succeeded and build.sh was copied into src assert!(tmp_src.path().join("build.sh").exists()); Ok(()) } #[test] fn test_build_prefers_spec_build_sh_over_source_build_sh() -> Result<()> { let tmp_src = tempdir()?; let tmp_dest = tempdir()?; let spec_dir = tempdir()?; let source_build_sh = tmp_src.path().join("build.sh"); std::fs::write(&source_build_sh, "#!/bin/sh\nexit 77\n")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&source_build_sh)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(&source_build_sh, perms)?; } let spec_build_sh = spec_dir.path().join("build.sh"); std::fs::write( &spec_build_sh, "#!/bin/sh\nprintf 'from spec\\n' > \"$DESTDIR/spec-won\"\n", )?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&spec_build_sh)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(&spec_build_sh, perms)?; } let mut spec = mk_spec("custom-prefer-spec", "1.0"); spec.spec_dir = spec_dir.path().to_path_buf(); build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None)?; assert_eq!( std::fs::read_to_string(tmp_dest.path().join("spec-won"))?, "from spec\n" ); 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(), real_name: None, version: "1.0".into(), revision: 1, description: "d".into(), homepage: "h".into(), abi_breaking: false, built_against: Vec::new(), license: vec!["MIT".into()], }); build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None)?; 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(()) } #[test] fn test_build_function_mode_only_uses_fakeroot_for_install() -> 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() { id -u > build-uid.txt } depot_install() { mkdir -p "$DESTDIR/usr/share" id -u > "$DESTDIR/usr/share/install-uid.txt" } "#, )?; #[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 spec = mk_spec("custom-function-fakeroot-split", "1.0"); build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None)?; assert_eq!( std::fs::read_to_string(tmp_src.path().join("build-uid.txt"))?, format!("{}\n", nix::unistd::geteuid().as_raw()) ); assert_eq!( std::fs::read_to_string(tmp_dest.path().join("usr/share/install-uid.txt"))?, "0\n" ); Ok(()) } #[test] fn test_build_function_mode_commands_split_fakeroot_boundary() -> Result<()> { let tmp_dest = tempdir()?; let build_script = Path::new("/tmp/build.sh"); let state_file = Path::new("/tmp/build-state"); let install_destdir = tmp_dest.path(); let build_cmd = build_function_mode_build_command(build_script, state_file); assert_eq!(build_cmd.get_program(), std::ffi::OsStr::new("sh")); let spec = mk_spec("custom-function-fakeroot-split", "1.0"); let install_cmd = build_function_mode_install_command(&spec, install_destdir, build_script, true)?; assert_eq!(install_cmd.get_program(), std::ffi::OsStr::new("sh")); Ok(()) } #[test] fn test_build_function_mode_errors_when_output_handler_missing() -> 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" } "#, )?; #[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(), real_name: None, version: "1.0".into(), revision: 1, description: "d".into(), homepage: "h".into(), abi_breaking: false, built_against: Vec::new(), license: vec!["MIT".into()], }); let err = build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None) .expect_err("missing per-output install handler should fail"); assert!(err.to_string().contains("Custom build script failed")); Ok(()) } #[test] fn test_build_non_function_mode_stops_on_first_command_failure() -> 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 false exit 0 "#, )?; #[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 spec = mk_spec("custom-fail-fast", "1.0"); let err = build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None) .expect_err("non-function custom scripts should fail when a command fails"); assert!(err.to_string().contains("Custom build script failed")); Ok(()) } #[test] fn test_build_lib32_stages_only_usr_lib_payload() -> 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 mkdir -p "$DESTDIR/usr/lib" "$DESTDIR/usr/share/man/man1" printf 'lib32' > "$DESTDIR/usr/lib/libfoo.so.1" printf 'manpage' > "$DESTDIR/usr/share/man/man1/foo.1" "#, )?; #[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("custom-lib32", "1.0"); spec.build.flags.lib32_variant = true; build(&spec, tmp_src.path(), tmp_dest.path(), None, true, None)?; assert_eq!( std::fs::read_to_string(tmp_dest.path().join("usr/lib32/libfoo.so.1"))?, "lib32" ); assert!(!tmp_dest.path().join("usr/share/man/man1/foo.1").exists()); assert!(!tmp_dest.path().join("usr/lib").exists()); Ok(()) } }