diff --git a/Cargo.lock b/Cargo.lock index 7639f48..4962477 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.22.1" +version = "0.23.0" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 9c0cf0e..d0c7f93 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.22.1" +version = "0.23.0" edition = "2024" [lints.rust] diff --git a/contrib/depot.d/build.toml b/contrib/depot.d/build.toml index e6921f0..82ec42f 100644 --- a/contrib/depot.d/build.toml +++ b/contrib/depot.d/build.toml @@ -17,6 +17,8 @@ ldflags = ["-Wl,-O1"] # Default LTOFLAGS and automatic injection into CFLAGS/CXXFLAGS/LDFLAGS. ltoflags = ["-flto=auto"] +# Rust-specific LTO flags appended only to RUSTFLAGS when use_lto is enabled. +#RUSTLTOFLAGS = ["-Clinker-plugin-lto"] use_lto = true # CARCH short name (can be overridden per-package) diff --git a/meson.build b/meson.build index e75dce5..c0b7a91 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.22.1', + version: '0.23.0', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) @@ -10,6 +10,8 @@ fs = import('fs') cargo = find_program('cargo', required: true) find_prog = find_program('find', required: true) sh = find_program('sh', required: true) +build_html_doc = get_option('build-html-doc') +cli_doc = find_program('cli_doc', required: build_html_doc) src_root = meson.project_source_root() build_root = meson.project_build_root() @@ -72,6 +74,23 @@ depot_bin = custom_target( install_dir: get_option('bindir'), ) +if build_html_doc + custom_target( + 'depot-html-doc', + input: depot_bin, + output: 'depot.html', + command: [ + cli_doc, + '--output-filename', + '@OUTPUT@', + join_paths(build_root, 'depot'), + ], + build_by_default: true, + install: true, + install_dir: join_paths(get_option('datadir'), 'doc', meson.project_name()), + ) +endif + test( 'cargo-test', sh, diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..12476e4 --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,6 @@ +option( + 'build-html-doc', + type: 'boolean', + value: false, + description: 'Generate HTML CLI documentation with cli_doc and install it', +) diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index d331931..9f794d8 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -128,8 +128,7 @@ pub fn build( configure_cmd.arg(expanded); } - let status = configure_cmd - .status() + let status = crate::interrupts::command_status(&mut configure_cmd) .with_context(|| format!("Failed to run configure in {}", build_dir.display()))?; if !status.success() { @@ -164,7 +163,7 @@ pub fn build( crate::builder::prepare_tool_command(&mut make_cmd, &env_vars); - let status = make_cmd.status().with_context(|| { + let status = crate::interrupts::command_status(&mut make_cmd).with_context(|| { format!("Failed to run {} in {}", make_exec, make_dir.display()) })?; @@ -216,14 +215,15 @@ pub fn build( crate::builder::prepare_tool_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() - ) - })?; + let status = + crate::interrupts::command_status(&mut test_cmd).with_context(|| { + format!( + "Failed to run {} {} in {}", + make_exec, + test_targets_display, + test_dir.display() + ) + })?; if !status.success() { anyhow::bail!( "{} {} failed with status: {} (dir: {})", @@ -344,15 +344,16 @@ pub fn build( )); crate::builder::prepare_tool_command(&mut install_cmd, &install_env); - let status = install_cmd.status().with_context(|| { - format!( - "Failed to run {} {} for {} in {}", - make_exec, - install_targets.join(" "), - spec.package.name, - install_dir.display() - ) - })?; + let status = + crate::interrupts::command_status(&mut install_cmd).with_context(|| { + format!( + "Failed to run {} {} for {} in {}", + make_exec, + install_targets.join(" "), + spec.package.name, + install_dir.display() + ) + })?; if !status.success() { anyhow::bail!( diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index f7e3e39..3bb8e80 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -63,6 +63,9 @@ pub fn build( for arg in cmake_install_dir_args(flags) { cmake_cmd.arg(arg); } + for arg in cmake_lib32_target_args(flags, cross) { + cmake_cmd.arg(arg); + } // Add toolchain file for cross-compilation if let Some(ref tf) = toolchain_file { @@ -90,7 +93,8 @@ pub fn build( crate::builder::prepare_tool_command(&mut cmake_cmd, &env_vars); - let status = cmake_cmd.status().context("Failed to run cmake")?; + let status = + crate::interrupts::command_status(&mut cmake_cmd).context("Failed to run cmake")?; if !status.success() { anyhow::bail!("cmake configure failed"); } @@ -117,8 +121,7 @@ pub fn build( crate::builder::prepare_tool_command(&mut build_cmd, &env_vars); - let status = build_cmd - .status() + let status = crate::interrupts::command_status(&mut build_cmd) .with_context(|| format!("Failed to run cmake build for {}", spec.package.name))?; if !status.success() { anyhow::bail!("cmake build failed"); @@ -139,12 +142,13 @@ pub fn build( } crate::builder::prepare_tool_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 - ) - })?; + let status = + crate::interrupts::command_status(&mut test_cmd).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); } @@ -157,8 +161,7 @@ pub fn build( test_cmd.arg("--output-on-failure"); crate::builder::prepare_tool_command(&mut test_cmd, &env_vars); - let status = test_cmd - .status() + let status = crate::interrupts::command_status(&mut test_cmd) .with_context(|| format!("Failed to run ctest for {}", spec.package.name))?; if !status.success() { anyhow::bail!("ctest failed"); @@ -203,8 +206,7 @@ pub fn build( )); crate::builder::prepare_tool_command(&mut install_cmd, &install_env); - let status = install_cmd - .status() + let status = crate::interrupts::command_status(&mut install_cmd) .with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?; if !status.success() { if !install_targets.is_empty() { @@ -378,6 +380,55 @@ fn cmake_install_dir_args(flags: &crate::package::BuildFlags) -> Vec { .collect() } +fn cmake_lib32_target_args( + flags: &crate::package::BuildFlags, + cross: Option<&CrossConfig>, +) -> Vec { + if !flags.lib32_variant { + return Vec::new(); + } + + let target = match lib32_target_triple(flags, cross) { + Some(target) => target, + None => return Vec::new(), + }; + let arch = crate::cross::target_arch_from_triple(&target); + let defaults = [ + ("CMAKE_SYSTEM_PROCESSOR", arch.to_string()), + ("CMAKE_C_COMPILER_TARGET", target.clone()), + ("CMAKE_CXX_COMPILER_TARGET", target.clone()), + ("CMAKE_ASM_COMPILER_TARGET", target), + ]; + + defaults + .into_iter() + .filter(|(variable, _)| cmake_cache_entry_value(&flags.configure, variable).is_none()) + .map(|(variable, value)| format!("-D{variable}={value}")) + .collect() +} + +fn lib32_target_triple( + flags: &crate::package::BuildFlags, + cross: Option<&CrossConfig>, +) -> Option { + let host = if let Some(cc_cfg) = cross { + Some(cc_cfg.host_triple().to_string()) + } else if !flags.chost.trim().is_empty() { + Some(flags.chost.trim().to_string()) + } else { + let detected = CrossConfig::build_triple(); + if let Err(err) = &detected { + crate::log_warn!( + "Failed to detect native build triple for lib32 CMake target flags: {}", + err + ); + } + detected.ok() + }; + + host.map(|host| crate::cross::lib32_target_triple(&host)) +} + fn cmake_install_dir_value(prefix: &str, value: &str) -> String { let trimmed_prefix = prefix.trim(); let trimmed_value = value.trim(); @@ -610,6 +661,59 @@ mod tests { ); } + #[test] + fn test_cmake_lib32_target_args_include_compiler_target_defaults() { + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".into(), + ..BuildFlags::default() + }; + + let args = cmake_lib32_target_args(&flags, None); + assert!(args.iter().any(|a| a == "-DCMAKE_SYSTEM_PROCESSOR=i686")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_C_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_ASM_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + } + + #[test] + fn test_cmake_lib32_target_args_respect_explicit_overrides() { + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".into(), + configure: vec![ + "-DCMAKE_C_COMPILER_TARGET=i686-custom-linux-gnu".into(), + "-DCMAKE_SYSTEM_PROCESSOR=i686".into(), + ], + ..BuildFlags::default() + }; + + let args = cmake_lib32_target_args(&flags, None); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_C_COMPILER_TARGET=")) + ); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_SYSTEM_PROCESSOR=")) + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + } + #[test] fn test_cmake_install_dir_args_respect_explicit_user_overrides() { let flags = BuildFlags { diff --git a/src/builder/custom.rs b/src/builder/custom.rs index 4326a73..c3ab589 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -131,7 +131,7 @@ pub fn build( 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 = cmd.status().map_err(|e| { + let status = crate::interrupts::command_status(&mut cmd).map_err(|e| { anyhow::anyhow!( "Failed to run build script {}: {}", build_script.display(), diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index 0a69508..b297c10 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -48,8 +48,7 @@ pub fn build( cmd.current_dir(src_dir); crate::builder::prepare_tool_command(&mut cmd, &env_vars); - let status = cmd - .status() + let status = crate::interrupts::command_status(&mut cmd) .with_context(|| format!("Failed to run build command: {}", cmd_str))?; if !status.success() { anyhow::bail!("Build command failed: {}", cmd_str); @@ -89,8 +88,7 @@ pub fn build( )); crate::builder::prepare_tool_command(&mut cmd, &install_env); - let status = cmd - .status() + let status = crate::interrupts::command_status(&mut cmd) .with_context(|| format!("Failed to run install command: {}", cmd_str))?; if !status.success() { anyhow::bail!("Install command failed: {}", cmd_str); diff --git a/src/builder/meson.rs b/src/builder/meson.rs index 6c533e8..5c21f09 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -29,9 +29,12 @@ pub fn build( // Environment variables let env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); - // Generate cross file if cross-compiling + // Generate cross file if cross-compiling, or when the lib32 variant needs + // Meson to treat the build as x86 instead of the native x86_64 host. let cross_file = if let Some(cc_cfg) = cross { Some(cc_cfg.generate_meson_cross_file(&build_dir)?) + } else if flags.lib32_variant { + Some(generate_lib32_meson_cross_file(flags, &build_dir)?) } else { None }; @@ -56,7 +59,8 @@ pub fn build( crate::builder::prepare_tool_command(&mut meson_cmd, &env_vars); - let status = meson_cmd.status().context("Failed to run meson setup")?; + let status = crate::interrupts::command_status(&mut meson_cmd) + .context("Failed to run meson setup")?; if !status.success() { anyhow::bail!("meson setup failed"); } @@ -76,8 +80,7 @@ pub fn build( crate::builder::prepare_tool_command(&mut ninja_cmd, &env_vars); - let status = ninja_cmd - .status() + let status = crate::interrupts::command_status(&mut ninja_cmd) .with_context(|| format!("Failed to run ninja for {}", spec.package.name))?; if !status.success() { anyhow::bail!("ninja build failed"); @@ -105,8 +108,7 @@ pub fn build( crate::builder::prepare_tool_command(&mut test_cmd, &env_vars); - let status = test_cmd - .status() + let status = crate::interrupts::command_status(&mut test_cmd) .with_context(|| format!("Failed to run meson test for {}", spec.package.name))?; if !status.success() { anyhow::bail!("meson test failed"); @@ -141,8 +143,7 @@ pub fn build( )); crate::builder::prepare_tool_command(&mut install_cmd, &install_env); - let status = install_cmd - .status() + let status = crate::interrupts::command_status(&mut install_cmd) .with_context(|| format!("Failed to run meson install for {}", spec.package.name))?; if !status.success() { anyhow::bail!("meson install failed"); @@ -258,6 +259,109 @@ fn meson_setup_args( args } +fn generate_lib32_meson_cross_file( + flags: &crate::package::BuildFlags, + build_dir: &Path, +) -> Result { + let target = lib32_target_triple(flags); + let arch = crate::cross::target_arch_from_triple(&target); + let cpu_family = crate::cross::cpu_family_for_arch(arch); + let c = meson_binary_value( + &compiler_command_with_lib32_target(&flags.cc, &target), + "C compiler", + )?; + let cpp = meson_binary_value( + &compiler_command_with_lib32_target(&flags.cxx, &target), + "C++ compiler", + )?; + let ar = meson_binary_value(&command_words(&flags.ar), "archiver")?; + + let mut content = format!( + "# Meson cross file for lib32 builds\n# Generated by depot for target: {target}\n\n[binaries]\nc = {c}\ncpp = {cpp}\nar = {ar}\n" + ); + if !flags.ld.trim().is_empty() { + let ld = meson_binary_value(&command_words(&flags.ld), "linker")?; + content.push_str(&format!("ld = {ld}\n")); + } + content.push_str(&format!( + "\n[host_machine]\nsystem = 'linux'\ncpu_family = '{cpu_family}'\ncpu = '{arch}'\nendian = 'little'\n" + )); + + fs::create_dir_all(build_dir)?; + let cross_path = build_dir.join("lib32-cross-file.ini"); + fs::write(&cross_path, content) + .with_context(|| format!("Failed to write {}", cross_path.display()))?; + Ok(cross_path) +} + +fn lib32_target_triple(flags: &crate::package::BuildFlags) -> String { + let host = if !flags.chost.trim().is_empty() { + flags.chost.trim().to_string() + } else { + match CrossConfig::build_triple() { + Ok(triple) => triple, + Err(err) => { + crate::log_warn!( + "Failed to detect native build triple for lib32 Meson target file: {}", + err + ); + "x86_64-unknown-linux-gnu".to_string() + } + } + }; + crate::cross::lib32_target_triple(&host) +} + +fn compiler_command_with_lib32_target(command: &str, target: &str) -> Vec { + let mut parts = command_words(command); + if compiler_command_supports_target(&parts) && !compiler_command_has_target(&parts) { + parts.push(format!("--target={target}")); + } + parts +} + +fn command_words(command: &str) -> Vec { + command + .split_whitespace() + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn compiler_command_supports_target(parts: &[String]) -> bool { + parts.first().is_some_and(|tool| { + Path::new(tool) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains("clang")) + }) +} + +fn compiler_command_has_target(parts: &[String]) -> bool { + parts.iter().any(|part| { + part == "--target" + || part == "-target" + || part.starts_with("--target=") + || part.starts_with("-target=") + }) +} + +fn meson_binary_value(parts: &[String], label: &str) -> Result { + if parts.is_empty() { + anyhow::bail!("Missing {} command for lib32 Meson cross file", label); + } + + let rendered = parts + .iter() + .map(|part| format!("'{}'", part.replace('\\', "\\\\").replace('\'', "\\'"))) + .collect::>(); + if rendered.len() == 1 { + Ok(rendered[0].clone()) + } else { + Ok(format!("[{}]", rendered.join(", "))) + } +} + /// Expand environment variables in a string (e.g., $DEPOT_SYSROOT) fn expand_env_vars(input: &str) -> String { let mut result = input.to_string(); @@ -439,6 +543,48 @@ mod tests { assert!(!args.iter().any(|a| a == "-Dcpp_ld=ld.lld")); } + #[test] + fn test_compiler_command_with_lib32_target_adds_clang_target() { + let parts = compiler_command_with_lib32_target("clang -m32", "i686-sfg-linux-gnu"); + assert_eq!( + parts, + vec![ + "clang".to_string(), + "-m32".to_string(), + "--target=i686-sfg-linux-gnu".to_string() + ] + ); + } + + #[test] + fn test_compiler_command_with_lib32_target_skips_non_clang_compilers() { + let parts = compiler_command_with_lib32_target("gcc -m32", "i686-sfg-linux-gnu"); + assert_eq!(parts, vec!["gcc".to_string(), "-m32".to_string()]); + } + + #[test] + fn test_generate_lib32_meson_cross_file_sets_x86_host_machine() -> Result<()> { + let tmp = tempdir()?; + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".to_string(), + cc: "clang -m32".to_string(), + cxx: "clang++ -m32".to_string(), + ar: "llvm-ar".to_string(), + ld: "ld.lld".to_string(), + ..BuildFlags::default() + }; + + let path = generate_lib32_meson_cross_file(&flags, tmp.path())?; + let content = fs::read_to_string(path)?; + assert!(content.contains("Generated by depot for target: i686-sfg-linux-gnu")); + assert!(content.contains("c = ['clang', '-m32', '--target=i686-sfg-linux-gnu']")); + assert!(content.contains("cpp = ['clang++', '-m32', '--target=i686-sfg-linux-gnu']")); + assert!(content.contains("cpu_family = 'x86'")); + assert!(content.contains("cpu = 'i686'")); + Ok(()) + } + #[test] fn test_resolve_build_dir_uses_flag() { let flags = BuildFlags { diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 7e7f1b7..74dbb6d 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -194,12 +194,21 @@ fn compiler_flag_sets( (cflags, cxxflags, ldflags, ltoflags) } +fn rust_ltoflags(flags: &crate::package::BuildFlags) -> Vec { + sanitize_flag_list(flags.rustltoflags.clone(), "build.flags.rustltoflags") +} + pub(crate) fn effective_rustflags(flags: &crate::package::BuildFlags) -> Vec { - replaced_flags( + let mut rustflags = replaced_flags( &flags.rustflags, &flags.replace_rustflags, "build.flags.rustflags", - ) + ); + let rust_ltoflags = rust_ltoflags(flags); + if flags.use_lto && !rust_ltoflags.is_empty() { + rustflags.extend(rust_ltoflags); + } + rustflags } pub fn standard_build_env( @@ -224,6 +233,10 @@ pub fn standard_build_env( if !ltoflags.is_empty() { set_env_var(&mut env_vars, "LTOFLAGS", ltoflags.join(" ")); } + let rust_ltoflags = rust_ltoflags(flags); + if !rust_ltoflags.is_empty() { + set_env_var(&mut env_vars, "RUSTLTOFLAGS", rust_ltoflags.join(" ")); + } let ldflags = if !ldflags.is_empty() || !flags.libc.is_empty() { if flags.libc.is_empty() { @@ -717,6 +730,7 @@ mod tests { let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); spec.build.flags.cxxflags = vec!["-O2".into()]; spec.build.flags.ltoflags = vec!["-flto=auto".into()]; + spec.build.flags.rustltoflags = vec!["-Clinker-plugin-lto".into()]; spec.build.flags.use_lto = false; let env = standard_build_env(&spec, None, true, true); @@ -738,6 +752,12 @@ mod tests { .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=auto"), "expected LTOFLAGS variable to be exported even when use_lto is false" ); + assert!( + env.iter() + .any(|(k, v)| k == "RUSTLTOFLAGS" && v == "-Clinker-plugin-lto"), + "expected RUSTLTOFLAGS variable to be exported even when use_lto is false" + ); + assert_eq!(effective_rustflags(&spec.build.flags), Vec::::new()); } #[test] @@ -778,6 +798,24 @@ mod tests { assert_eq!(effective_rustflags(&flags), vec!["-C", "opt-level=2"]); } + #[test] + fn test_effective_rustflags_appends_rustltoflags_when_enabled() { + let mut flags = BuildFlags::default(); + flags.rustflags = vec!["-C".into(), "opt-level=3".into()]; + flags.rustltoflags = vec!["-Clinker-plugin-lto".into(), "-Cembed-bitcode=yes".into()]; + flags.use_lto = true; + + assert_eq!( + effective_rustflags(&flags), + vec![ + "-C", + "opt-level=3", + "-Clinker-plugin-lto", + "-Cembed-bitcode=yes" + ] + ); + } + #[test] fn test_standard_build_env_passthrough_does_not_override_default_vars() { let mut spec = mk_spec(Vec::new(), Vec::new()); diff --git a/src/builder/perl.rs b/src/builder/perl.rs index c4324d1..8c6a0ce 100644 --- a/src/builder/perl.rs +++ b/src/builder/perl.rs @@ -267,7 +267,7 @@ fn resolve_perl_configure_script(spec: &PackageSpec, actual_src: &Path) -> PathB } fn command_status_with_sh_fallback(cmd: &mut Command) -> std::io::Result { - match cmd.status() { + match crate::interrupts::command_status(cmd) { Ok(status) => Ok(status), Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { let Some(script) = resolved_script_path(cmd) else { @@ -296,7 +296,7 @@ fn command_status_with_sh_fallback(cmd: &mut Command) -> std::io::Result Err(err), } diff --git a/src/builder/python.rs b/src/builder/python.rs index 71b53f1..3cb4f8f 100644 --- a/src/builder/python.rs +++ b/src/builder/python.rs @@ -273,8 +273,7 @@ fn build_wheel_setup_py( .arg(dist_dir); crate::builder::prepare_tool_command(&mut cmd, &env_vars.to_vec()); - let status = cmd - .status() + let status = crate::interrupts::command_status(&mut cmd) .with_context(|| format!("Failed to run setup.py in {}", src_dir.display()))?; if !status.success() { bail!("setup.py bdist_wheel failed with status {}", status); @@ -315,25 +314,23 @@ fn build_wheel_pep517( ); crate::builder::prepare_command(&mut cmd, &build_env); - let output = cmd.output().with_context(|| { + let status = crate::interrupts::command_status(&mut cmd).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); + if !status.success() { 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, + "PEP 517 wheel build failed with status {}. Backend: {}. Declared build requirements: {}", + status, cfg.backend, - requires, - stderr.trim() + requires ); } diff --git a/src/builder/rust.rs b/src/builder/rust.rs index 8eed8a6..8bedfa8 100755 --- a/src/builder/rust.rs +++ b/src/builder/rust.rs @@ -102,8 +102,7 @@ pub fn build( // Set environment crate::builder::prepare_tool_command(&mut cargo_cmd, &env_vars); - let status = cargo_cmd - .status() + let status = crate::interrupts::command_status(&mut cargo_cmd) .with_context(|| format!("Failed to run cargo build for {}", spec.package.name))?; if !status.success() { anyhow::bail!("cargo build failed"); diff --git a/src/commands.rs b/src/commands.rs index 52b81b1..ea116e5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -9,14 +9,11 @@ use crate::{ use anyhow::{Context, Result}; use git2::Direction; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; -use signal_hook::consts::SIGINT; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use url::Url; use walkdir::WalkDir; @@ -105,6 +102,7 @@ fn should_delegate_live_rootfs_installs(rootfs: &Path) -> bool { const DEPOT_INSTALL_CONTEXT_ENV: &str = "DEPOT_INSTALL_CONTEXT"; const INSTALL_CONTEXT_UPDATE: &str = "update"; const INSTALL_CONTEXT_PLANNED: &str = "planned"; +const DEPOT_PACKAGE_NAME: &str = "depot"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum InstallInvocationContext { @@ -132,6 +130,55 @@ fn install_test_deps_enabled(cli_test_deps: bool, config: &config::Config) -> bo cli_test_deps || config.install_test_deps } +fn current_argv0() -> String { + std::env::args_os() + .next() + .filter(|arg| !arg.is_empty()) + .map(|arg| PathBuf::from(arg).display().to_string()) + .or_else(|| { + std::env::current_exe() + .ok() + .map(|path| path.display().to_string()) + }) + .unwrap_or_else(|| DEPOT_PACKAGE_NAME.to_string()) +} + +fn is_explicit_depot_self_update_request(packages: &[String]) -> bool { + packages.len() == 1 + && packages + .first() + .is_some_and(|package| package == DEPOT_PACKAGE_NAME) +} + +fn ensure_depot_self_update_not_required(config: &config::Config, rootfs: &Path) -> Result<()> { + let db_path = config.installed_db_path(rootfs); + if db::get_package_version(&db_path, DEPOT_PACKAGE_NAME) + .with_context(|| { + format!( + "Failed to query installed package database at {}", + db_path.display() + ) + })? + .is_none() + { + return Ok(()); + } + + let requested = [DEPOT_PACKAGE_NAME.to_string()]; + let updates = collect_update_candidates(config, rootfs, &requested) + .context("Failed to check for pending depot self-update")?; + if updates.is_empty() { + return Ok(()); + } + + anyhow::bail!( + "An update for '{}' is available. Run '{} update {}' before continuing.", + DEPOT_PACKAGE_NAME, + current_argv0(), + DEPOT_PACKAGE_NAME + ); +} + fn maybe_reexec_with_sudo(cli: &Cli) -> Result { if !should_reexec_with_sudo(cli) { return Ok(false); @@ -235,7 +282,7 @@ fn run_install_command_with_program( fn command_status_with_sh_fallback( cmd: &mut std::process::Command, ) -> std::io::Result { - match cmd.status() { + match crate::interrupts::command_status(cmd) { Ok(status) => Ok(status), Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied @@ -264,7 +311,7 @@ fn command_status_with_sh_fallback( } } } - fallback.status() + crate::interrupts::command_status(&mut fallback) } Err(err) => Err(err), } @@ -295,27 +342,21 @@ fn run_child_install_command( } #[derive(Clone)] -struct InterruptWatcher { - interrupted: Arc, -} +struct InterruptWatcher; impl InterruptWatcher { fn install() -> Result { - let interrupted = Arc::new(AtomicBool::new(false)); - signal_hook::flag::register(SIGINT, interrupted.clone()) - .context("Failed to register Ctrl-C handler")?; - Ok(Self { interrupted }) + crate::interrupts::install()?; + crate::interrupts::reset(); + Ok(Self) } fn was_interrupted(&self) -> bool { - self.interrupted.load(AtomicOrdering::Relaxed) + crate::interrupts::was_interrupted() } fn check(&self) -> Result<()> { - if self.was_interrupted() { - anyhow::bail!("Interrupted by Ctrl-C"); - } - Ok(()) + crate::interrupts::check() } } @@ -533,6 +574,7 @@ fn current_process_env_vars() -> Vec<(String, String)> { "PYTHONNOUSERSITE", "RANLIB", "RUSTFLAGS", + "RUSTLTOFLAGS", "SETUPTOOLS_USE_DISTUTILS", "STRIP", ]; @@ -767,6 +809,7 @@ fn load_package_archive_into_staging( archive_path.display() ) })? { + crate::interrupts::check()?; let mut entry = entry.with_context(|| { format!( "Failed to read archive entry from {}", @@ -800,7 +843,7 @@ fn load_package_archive_into_staging( .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; let mut archive = tar::Archive::new(zstd_decoder); archive.set_preserve_permissions(true); - archive.unpack(&extract_dir).with_context(|| { + crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { format!( "Failed to extract package archive {} into {}", archive_path.display(), @@ -836,7 +879,7 @@ fn extract_package_archive_to_staging( .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; let mut archive = tar::Archive::new(zstd_decoder); archive.set_preserve_permissions(true); - archive.unpack(&extract_dir).with_context(|| { + crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { format!( "Failed to extract package archive {} into {}", archive_path.display(), @@ -2514,8 +2557,6 @@ fn run_update_command( config: &config::Config, options: UpdateCommandOptions<'_>, ) -> Result<()> { - sync_source_repositories_for_update(config)?; - let updates = collect_update_candidates(config, options.rootfs, packages)?; if updates.is_empty() { ui::info("All installed packages are up to date."); @@ -3487,8 +3528,7 @@ fn execute_install_plan_with_child_commands( } cmd.arg("install").arg(path); - let status = cmd - .status() + let status = crate::interrupts::command_status(&mut cmd) .context("Failed to spawn planned install step")?; if !status.success() { anyhow::bail!("Planned install step for '{}' failed", step.package); @@ -4080,6 +4120,8 @@ fn run_direct_install_request( } pub fn run(cli: Cli) -> Result<()> { + crate::interrupts::install()?; + crate::interrupts::reset(); ui::set_assume_yes(command_assume_yes(&cli.command)); if maybe_reexec_with_sudo(&cli)? { return Ok(()); @@ -4126,6 +4168,7 @@ pub fn run(cli: Cli) -> Result<()> { // Load configuration early so we can use configured repos/paths. let config = config::Config::for_rootfs(&rootfs); + ensure_depot_self_update_not_required(&config, &rootfs)?; let install_test_deps = install_test_deps_enabled(cli_test_deps, &config); let mut planned_targets = Vec::new(); let mut planned_spec_paths = Vec::new(); @@ -4266,11 +4309,11 @@ pub fn run(cli: Cli) -> Result<()> { let dry_run = build_exec_args.dry_run; let cli_lib32_only = lib32_args.lib32_only; warn_if_running_as_root_for_build("build", &rootfs); + let config = config::Config::for_rootfs(&rootfs); + ensure_depot_self_update_not_required(&config, &rootfs)?; let spec_path = spec.or(spec_pos).context("No spec file provided")?; 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(&rootfs); let install_test_deps = install_test_deps_enabled(cli_test_deps, &config); let interrupt_watcher = if cleanup_deps { Some(InterruptWatcher::install()?) @@ -4783,6 +4826,10 @@ pub fn run(cli: Cli) -> Result<()> { let clean = build_exec_args.clean; let dry_run = build_exec_args.dry_run; let config = config::Config::for_rootfs(&rootfs); + sync_source_repositories_for_update(&config)?; + if !is_explicit_depot_self_update_request(&packages) { + ensure_depot_self_update_not_required(&config, &rootfs)?; + } run_update_command( &packages, &config, @@ -5422,6 +5469,57 @@ mod tests { spec_dir: PathBuf::from("."), } } + + fn register_installed_test_package( + config: &config::Config, + rootfs: &Path, + name: &str, + version: &str, + ) -> Result<()> { + let mut spec = test_package_spec(package::BuildType::Bin, None, &[]); + spec.package.name = name.to_string(); + spec.package.version = version.to_string(); + + let dest = rootfs.join("dest").join(name); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin").join(name), name)?; + db::register_package(&config.installed_db_path(rootfs), &spec, &dest)?; + Ok(()) + } + + fn write_test_repo_spec(path: &Path, name: &str, version: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + path, + format!( + r#"[package] +name = "{name}" +version = "{version}" +revision = 1 +description = "{name}" +homepage = "https://example.test/{name}" +license = "MIT" + +[[source]] +url = "https://example.test/{name}-{version}.tar.gz" +sha256 = "skip" +extract_dir = "{name}-{version}" + +[build] +type = "custom" + +[dependencies] +build = [] +runtime = [] +optional = [] +"# + ), + ) + .with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) + } use anyhow::Context; use std::io::Write; @@ -6154,6 +6252,97 @@ optional = [] Ok(()) } + #[test] + fn explicit_depot_self_update_request_requires_only_depot() { + assert!(is_explicit_depot_self_update_request(&[ + DEPOT_PACKAGE_NAME.to_string() + ])); + assert!(!is_explicit_depot_self_update_request(&[])); + assert!(!is_explicit_depot_self_update_request(&["pkg".to_string()])); + assert!(!is_explicit_depot_self_update_request(&[ + DEPOT_PACKAGE_NAME.to_string(), + "pkg".to_string() + ])); + } + + #[test] + fn depot_self_update_check_blocks_when_update_is_available() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir; + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.insert( + "core".into(), + config::SourceRepo { + url: "https://example.test/core.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.0.0")?; + write_test_repo_spec( + &repo_clones.join("core").join("depot.toml"), + DEPOT_PACKAGE_NAME, + "1.1.0", + )?; + + let err = ensure_depot_self_update_not_required(&config, &rootfs) + .expect_err("outdated depot should block command execution"); + assert!(err.to_string().contains("update depot")); + Ok(()) + } + + #[test] + fn depot_self_update_check_allows_when_depot_is_current() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir; + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.insert( + "core".into(), + config::SourceRepo { + url: "https://example.test/core.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.1.0")?; + write_test_repo_spec( + &repo_clones.join("core").join("depot.toml"), + DEPOT_PACKAGE_NAME, + "1.1.0", + )?; + + ensure_depot_self_update_not_required(&config, &rootfs)?; + Ok(()) + } + #[test] fn collect_missing_update_dependencies_skips_planned_provides_and_installed_deps() -> Result<()> { diff --git a/src/cross.rs b/src/cross.rs index 5e52f82..34d8750 100755 --- a/src/cross.rs +++ b/src/cross.rs @@ -119,7 +119,7 @@ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) "#, self.prefix, - self.target_arch(), + target_arch_from_triple(&self.prefix), self.cc, self.cxx, self.ar, @@ -169,8 +169,8 @@ endian = 'little' self.objcopy, self.objdump, self.readelf, - self.cpu_family(), - self.target_arch(), + cpu_family_for_arch(target_arch_from_triple(&self.prefix)), + target_arch_from_triple(&self.prefix), ); fs::create_dir_all(build_dir)?; @@ -179,28 +179,65 @@ endian = 'little' Ok(cross_path) } +} - /// Extract target architecture from prefix - fn target_arch(&self) -> &str { - self.prefix.split('-').next().unwrap_or("unknown") +/// Extract the leading architecture component from a target triple. +pub fn target_arch_from_triple(triple: &str) -> &str { + triple.split('-').next().unwrap_or("unknown") +} + +/// Return the Meson CPU family for an architecture token. +pub fn cpu_family_for_arch(arch: &str) -> &str { + match arch { + "x86_64" | "amd64" => "x86_64", + "i686" | "i586" | "i486" | "i386" => "x86", + "aarch64" | "arm64" => "aarch64", + "arm" | "armv7" | "armv7l" => "arm", + "riscv64" => "riscv64", + "riscv32" => "riscv32", + "powerpc64" | "ppc64" => "ppc64", + "powerpc" | "ppc" => "ppc", + "mips64" => "mips64", + "mips" => "mips", + _ => arch, } +} - /// Get CPU family for Meson - fn cpu_family(&self) -> &str { - let arch = self.target_arch(); - match arch { - "x86_64" | "amd64" => "x86_64", - "i686" | "i586" | "i486" | "i386" => "x86", - "aarch64" | "arm64" => "aarch64", - "arm" | "armv7" | "armv7l" => "arm", - "riscv64" => "riscv64", - "riscv32" => "riscv32", - "powerpc64" | "ppc64" => "ppc64", - "powerpc" | "ppc" => "ppc", - "mips64" => "mips64", - "mips" => "mips", - _ => arch, - } +/// Derive the lib32 target triple while preserving the explicit x86 variant. +pub fn lib32_target_triple(host: &str) -> String { + let mut parts = host.splitn(2, '-'); + let arch = parts.next().unwrap_or(host); + let rest = parts.next(); + let lib32_arch = match arch { + "x86_64" | "amd64" => "i686", + "i686" | "i586" | "i486" | "i386" => arch, + _ => arch, + }; + + match rest { + Some(rest) if !rest.is_empty() => format!("{lib32_arch}-{rest}"), + _ => lib32_arch.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::lib32_target_triple; + + #[test] + fn lib32_target_triple_preserves_explicit_x86_variant() { + assert_eq!( + lib32_target_triple("x86_64-sfg-linux-gnu"), + "i686-sfg-linux-gnu" + ); + assert_eq!( + lib32_target_triple("i686-sfg-linux-gnu"), + "i686-sfg-linux-gnu" + ); + assert_eq!( + lib32_target_triple("i586-sfg-linux-gnu"), + "i586-sfg-linux-gnu" + ); } } diff --git a/src/interrupts.rs b/src/interrupts.rs new file mode 100644 index 0000000..f87faca --- /dev/null +++ b/src/interrupts.rs @@ -0,0 +1,273 @@ +use anyhow::{Context, Result, bail}; +use signal_hook::consts::SIGINT; +use std::fs; +use std::io::{self, ErrorKind, Read, Write}; +use std::path::Path; +use std::process::{Child, Command, ExitStatus}; +use std::sync::Once; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +static INTERRUPTED: AtomicBool = AtomicBool::new(false); +static INSTALL_HANDLER: Once = Once::new(); +static INSTALL_ERROR: OnceLock = OnceLock::new(); + +pub(crate) fn install() -> Result<()> { + INSTALL_HANDLER.call_once(|| { + if let Err(err) = unsafe { + signal_hook::low_level::register(SIGINT, || { + INTERRUPTED.store(true, Ordering::Relaxed); + }) + } { + let _ = INSTALL_ERROR.set(err.to_string()); + }; + }); + + if let Some(err) = INSTALL_ERROR.get() { + bail!("Failed to register Ctrl-C handler: {}", err); + } + + Ok(()) +} + +pub(crate) fn reset() { + INTERRUPTED.store(false, Ordering::Relaxed); +} + +pub(crate) fn was_interrupted() -> bool { + INTERRUPTED.load(Ordering::Relaxed) +} + +pub(crate) fn check() -> Result<()> { + install()?; + check_with(was_interrupted) +} + +pub(crate) fn command_status(cmd: &mut Command) -> io::Result { + install().map_err(io::Error::other)?; + configure_child_process_group(cmd); + let mut child = cmd.spawn()?; + wait_for_child_with(&mut child, was_interrupted) +} + +pub(crate) fn copy_interruptibly( + reader: &mut R, + writer: &mut W, +) -> io::Result { + install().map_err(io::Error::other)?; + copy_interruptibly_with(reader, writer, was_interrupted) +} + +pub(crate) fn unpack_tar_archive( + archive: &mut tar::Archive, + dest: &Path, +) -> Result<()> { + install()?; + unpack_tar_archive_with(archive, dest, was_interrupted) +} + +fn check_with(interrupted: impl Fn() -> bool) -> Result<()> { + if interrupted() { + bail!("Interrupted by Ctrl-C"); + } + Ok(()) +} + +fn copy_interruptibly_with( + reader: &mut R, + writer: &mut W, + interrupted: impl Fn() -> bool, +) -> io::Result { + let mut copied = 0u64; + let mut buf = [0u8; 64 * 1024]; + loop { + if interrupted() { + return Err(io::Error::new( + ErrorKind::Interrupted, + "Interrupted by Ctrl-C", + )); + } + let n = reader.read(&mut buf)?; + if n == 0 { + return Ok(copied); + } + writer.write_all(&buf[..n])?; + copied += n as u64; + } +} + +fn unpack_tar_archive_with( + archive: &mut tar::Archive, + dest: &Path, + interrupted: impl Fn() -> bool, +) -> Result<()> { + fs::create_dir_all(dest) + .with_context(|| format!("Failed to create extraction dir {}", dest.display()))?; + for entry in archive + .entries() + .context("Failed to iterate tar archive entries")? + { + check_with(&interrupted)?; + let mut entry = entry.context("Failed to read tar archive entry")?; + let path = entry + .path() + .context("Failed to inspect tar archive entry path")? + .into_owned(); + if !entry + .unpack_in(dest) + .with_context(|| format!("Failed to unpack tar archive entry {}", path.display()))? + { + bail!( + "Refusing to extract unsafe tar archive entry outside destination: {}", + path.display() + ); + } + } + Ok(()) +} + +#[cfg(unix)] +fn configure_child_process_group(cmd: &mut Command) { + use std::os::unix::process::CommandExt; + + // Put the child in its own process group so Ctrl-C can be forwarded to the + // whole build/extract pipeline without killing the depot parent process. + unsafe { + cmd.pre_exec(|| { + if nix::libc::setpgid(0, 0) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } +} + +#[cfg(not(unix))] +fn configure_child_process_group(_cmd: &mut Command) {} + +fn wait_for_child_with( + child: &mut Child, + interrupted: impl Fn() -> bool, +) -> io::Result { + let mut interrupted_at: Option = None; + let mut sent_term = false; + let mut sent_kill = false; + + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + + if interrupted() { + if interrupted_at.is_none() { + interrupted_at = Some(Instant::now()); + signal_child_process_group(child, nix::libc::SIGINT); + } else if interrupted_at + .is_some_and(|started| started.elapsed() >= Duration::from_secs(2) && !sent_term) + { + sent_term = true; + signal_child_process_group(child, nix::libc::SIGTERM); + } else if interrupted_at + .is_some_and(|started| started.elapsed() >= Duration::from_secs(4) && !sent_kill) + { + sent_kill = true; + signal_child_process_group(child, nix::libc::SIGKILL); + } + } + + thread::sleep(Duration::from_millis(50)); + } +} + +#[cfg(unix)] +fn signal_child_process_group(child: &Child, signal: i32) { + let pgid = -(child.id() as i32); + let rc = unsafe { nix::libc::kill(pgid, signal) }; + if rc == 0 { + return; + } + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(nix::libc::ESRCH) { + crate::log_warn!( + "Failed to forward signal {} to child process group {}: {}", + signal, + child.id(), + err + ); + } +} + +#[cfg(not(unix))] +fn signal_child_process_group(_child: &Child, _signal: i32) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::sync::Arc; + + #[test] + fn copy_interruptibly_returns_interrupted_error() { + let mut reader = Cursor::new(b"hello".to_vec()); + let mut writer = Vec::new(); + let interrupted = AtomicBool::new(true); + let err = copy_interruptibly_with(&mut reader, &mut writer, || { + interrupted.load(Ordering::Relaxed) + }) + .expect_err("copy should stop once interrupted"); + assert_eq!(err.kind(), ErrorKind::Interrupted); + } + + #[test] + fn unpack_tar_archive_stops_when_interrupted() { + let mut tar_data = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_data); + let mut header = tar::Header::new_gnu(); + header.set_path("hello.txt").unwrap(); + header.set_size(5); + header.set_mode(0o644); + header.set_cksum(); + builder + .append(&header, Cursor::new(b"hello".to_vec())) + .unwrap(); + builder.finish().unwrap(); + } + + let temp = tempfile::tempdir().unwrap(); + let mut archive = tar::Archive::new(Cursor::new(tar_data)); + let interrupted = AtomicBool::new(true); + let err = unpack_tar_archive_with(&mut archive, temp.path(), || { + interrupted.load(Ordering::Relaxed) + }) + .expect_err("tar unpack should stop once interrupted"); + assert!(err.to_string().contains("Interrupted by Ctrl-C")); + } + + #[test] + #[cfg(unix)] + fn command_status_interrupts_child_process() { + use std::os::unix::process::ExitStatusExt; + + let interrupted = Arc::new(AtomicBool::new(false)); + let trigger_flag = interrupted.clone(); + let trigger = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + trigger_flag.store(true, Ordering::Relaxed); + }); + + let start = Instant::now(); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("sleep 10"); + configure_child_process_group(&mut cmd); + let mut child = cmd.spawn().expect("sleep command should spawn"); + let status = wait_for_child_with(&mut child, || interrupted.load(Ordering::Relaxed)) + .expect("sleep command should be interruptible"); + trigger.join().unwrap(); + + assert!(start.elapsed() < Duration::from_secs(3)); + assert_eq!(status.signal(), Some(SIGINT)); + } +} diff --git a/src/main.rs b/src/main.rs index 787d06d..a5d8a83 100755 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod deps; mod fakeroot; mod index; mod install; +mod interrupts; mod locking; mod metadata_time; mod package; diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 57df7f0..956d538 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -942,6 +942,19 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.rustltoflags.is_empty() { + flags_tbl.insert( + "rustltoflags".into(), + Value::Array( + spec.build + .flags + .rustltoflags + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.keep.is_empty() { flags_tbl.insert( "keep".into(), diff --git a/src/package/spec.rs b/src/package/spec.rs index 41ee568..07840d2 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -472,6 +472,17 @@ impl PackageSpec { self.build.flags.ltoflags = vec![s.to_string()]; } } + "rustltoflags" | "rust_ltoflags" | "rust-ltoflags" => { + if let Some(arr) = v.as_array() { + self.build.flags.rustltoflags = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.rustltoflags = vec![s.to_string()]; + } + } "replace_ltoflags" | "replace_lto-flags" | "replace_lto_flags" => { if let Some(arr) = v.as_array() { self.build.flags.replace_ltoflags = arr @@ -1067,6 +1078,18 @@ impl PackageSpec { } } } + "rustltoflags" | "rust_ltoflags" | "rust-ltoflags" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .rustltoflags + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.rustltoflags.push(s.to_string()); + } + } + } "replace_ltoflags" | "replace_lto-flags" | "replace_lto_flags" => { for v in values { if let Some(arr) = v.as_array() { @@ -2316,6 +2339,7 @@ make_dirs = ["lib"] make_test_dirs = ["tests"] make_install_dirs = ["lib"] ltoflags = ["-flto=auto"] +RUSTLTOFLAGS = ["-Clinker-plugin-lto"] replace_ltoflags = ["auto=>thin"] rustflags = ["-C", "debuginfo=2"] replace_rustflags = ["debuginfo=2=>opt-level=2"] @@ -2518,6 +2542,12 @@ post_configure = ["echo configured"] .ltoflags .contains(&"-flto=auto".to_string()) ); + assert!( + spec.build + .flags + .rustltoflags + .contains(&"-Clinker-plugin-lto".to_string()) + ); assert!( spec.build .flags @@ -3868,6 +3898,18 @@ pub struct BuildFlags { deserialize_with = "deserialize_string_or_array" )] pub ltoflags: Vec, + /// Rust LTO flags exported to `RUSTLTOFLAGS`. + /// + /// When `use_lto` is true (default), these flags are also appended to + /// `RUSTFLAGS`. + #[serde( + default, + alias = "rust-ltoflags", + alias = "rust_ltoflags", + alias = "RUSTLTOFLAGS", + deserialize_with = "deserialize_string_or_array" + )] + pub rustltoflags: Vec, /// Ordered replacement rules applied to `ltoflags` before export/injection. #[serde( default, @@ -4236,6 +4278,7 @@ impl Default for BuildFlags { ldflags: Vec::new(), replace_ldflags: Vec::new(), ltoflags: Vec::new(), + rustltoflags: Vec::new(), replace_ltoflags: Vec::new(), keep: Vec::new(), split_docs: false, diff --git a/src/source/extractor.rs b/src/source/extractor.rs index 7500ca1..83582f8 100755 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -9,6 +9,7 @@ use lzma_rust2::{LzipReader, LzmaReader}; use std::fs::{self, File}; use std::io::{Cursor, Read, Write}; use std::os::unix::fs as unix_fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempfile::{NamedTempFile, tempdir}; @@ -42,6 +43,7 @@ pub fn extract_archive( source: &Source, build_dir: &Path, ) -> Result { + crate::interrupts::install()?; let extract_dir_name = spec.expand_vars(&source.extract_dir); let extract_path = build_dir.join(&extract_dir_name); @@ -157,7 +159,7 @@ fn extract_zip(path: &Path, dest: &Path) -> Result<()> { let tmp = tempdir()?; let file = File::open(path)?; let mut archive = zip::ZipArchive::new(file)?; - archive.extract(tmp.path())?; + extract_zip_archive(&mut archive, tmp.path())?; finalize_extracted_tree(tmp.path(), dest) } @@ -190,6 +192,20 @@ fn extract_tar_compress(path: &Path, dest: &Path) -> Result<()> { let mut child = Command::new("gzip"); child.arg("-cd").arg(path); child.stdout(Stdio::piped()); + crate::interrupts::check()?; + // Run gzip in its own process group so Ctrl-C can interrupt decompression too. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + child.pre_exec(|| { + if nix::libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } let mut child = child.spawn().with_context(|| { format!( "Failed to spawn gzip for .Z decompression: {}", @@ -201,13 +217,12 @@ fn extract_tar_compress(path: &Path, dest: &Path) -> Result<()> { .take() .context("Failed to capture gzip stdout")?; let mut archive = tar::Archive::new(stdout); - archive - .unpack(tmp.path()) - .with_context(|| format!("Failed to unpack .tar.Z archive {}", path.display()))?; + let unpack_result = crate::interrupts::unpack_tar_archive(&mut archive, tmp.path()) + .with_context(|| format!("Failed to unpack .tar.Z archive {}", path.display())); drop(archive); - let status = child - .wait() + let status = wait_for_child_interruptibly(&mut child) .with_context(|| format!("Failed waiting for gzip on {}", path.display()))?; + unpack_result?; if !status.success() { bail!("gzip failed while decompressing {}", path.display()); } @@ -224,7 +239,7 @@ fn extract_cpio(path: &Path, dest: &Path) -> Result<()> { fn extract_tar_reader(reader: R, dest: &Path) -> Result<()> { let tmp = tempdir()?; let mut archive = tar::Archive::new(reader); - archive.unpack(tmp.path())?; + crate::interrupts::unpack_tar_archive(&mut archive, tmp.path())?; finalize_extracted_tree(tmp.path(), dest) } @@ -281,7 +296,7 @@ fn extract_gz_file(path: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; let mut out = File::create(dest.join(out_name))?; - std::io::copy(&mut decoder, &mut out)?; + crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?; Ok(()) } @@ -302,7 +317,7 @@ fn extract_xz_file(path: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; let mut out = File::create(dest.join(out_name))?; - std::io::copy(&mut decoder, &mut out)?; + crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?; Ok(()) } @@ -323,7 +338,7 @@ fn extract_zst_file(path: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; let mut out = File::create(dest.join(out_name))?; - std::io::copy(&mut decoder, &mut out)?; + crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?; Ok(()) } @@ -333,13 +348,14 @@ fn extract_deb(path: &Path, dest: &Path) -> Result<()> { let mut ar = ar::Archive::new(file); while let Some(entry_result) = ar.next_entry() { + crate::interrupts::check()?; let mut entry = entry_result?; let id = String::from_utf8_lossy(entry.header().identifier()).to_string(); let lower = id.to_ascii_lowercase(); if lower.starts_with("data.tar") { // write the inner member to a temporary file and reuse tar extraction logic let mut tmpf = NamedTempFile::new()?; - std::io::copy(&mut entry, &mut tmpf)?; + crate::interrupts::copy_interruptibly(&mut entry, &mut tmpf)?; let tmp_path = tmpf.path().to_path_buf(); if lower.ends_with(".gz") { @@ -364,6 +380,7 @@ fn extract_deb(path: &Path, dest: &Path) -> Result<()> { fn extract_cpio_newc_from_reader(mut r: R, dest: &Path) -> Result<()> { use std::str; loop { + crate::interrupts::check()?; // read 6-byte magic let mut magic = [0u8; 6]; if let Err(e) = r.read_exact(&mut magic) { @@ -437,6 +454,7 @@ fn extract_cpio_newc_from_reader(mut r: R, dest: &Path) -> Result<()> { let mut remaining = filesize; let mut buf = [0u8; 8192]; while remaining > 0 { + crate::interrupts::check()?; let to_read = std::cmp::min(remaining, buf.len()); let n = r.read(&mut buf[..to_read])?; if n == 0 { @@ -466,6 +484,7 @@ fn extract_cpio_newc_from_reader(mut r: R, dest: &Path) -> Result<()> { fn extract_rpm(path: &Path, dest: &Path) -> Result<()> { // Read entire file and search for compression/cpio magic + crate::interrupts::check()?; let data = std::fs::read(path)?; // search for known signatures @@ -520,6 +539,7 @@ fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; for entry in fs::read_dir(src)? { + crate::interrupts::check()?; let entry = entry?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); @@ -580,6 +600,7 @@ fn copy_file_preserve_metadata(src: &Path, dst: &Path) -> Result<()> { fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { for entry in WalkDir::new(src) { + crate::interrupts::check()?; let entry = entry?; let rel = entry.path().strip_prefix(src).unwrap(); let target = dst.join(rel); @@ -598,6 +619,97 @@ fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { Ok(()) } +fn extract_zip_archive( + archive: &mut zip::ZipArchive, + dest: &Path, +) -> Result<()> { + for index in 0..archive.len() { + crate::interrupts::check()?; + let mut entry = archive.by_index(index)?; + let enclosed = entry + .enclosed_name() + .with_context(|| format!("Zip archive entry has unsafe path: {}", entry.name()))?; + let out_path = dest.join(enclosed); + + if entry.is_dir() { + fs::create_dir_all(&out_path)?; + continue; + } + + let mode = entry.unix_mode().unwrap_or(0); + if (mode & 0o170000) == 0o120000 { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut target = Vec::new(); + crate::interrupts::copy_interruptibly(&mut entry, &mut target)?; + let target = String::from_utf8(target).context("Zip symlink target was not UTF-8")?; + let _ = fs::remove_file(&out_path); + unix_fs::symlink(target, &out_path)?; + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out = File::create(&out_path)?; + crate::interrupts::copy_interruptibly(&mut entry, &mut out)?; + if mode != 0 { + fs::set_permissions(&out_path, fs::Permissions::from_mode(mode & 0o7777))?; + } + } + + Ok(()) +} + +fn wait_for_child_interruptibly( + child: &mut std::process::Child, +) -> std::io::Result { + let mut interrupted_at = None; + let mut sent_term = false; + let mut sent_kill = false; + + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + + if crate::interrupts::was_interrupted() { + if interrupted_at.is_none() { + interrupted_at = Some(std::time::Instant::now()); + signal_child_group(child, nix::libc::SIGINT); + } else if interrupted_at.is_some_and(|started| { + started.elapsed() >= std::time::Duration::from_secs(2) && !sent_term + }) { + sent_term = true; + signal_child_group(child, nix::libc::SIGTERM); + } else if interrupted_at.is_some_and(|started| { + started.elapsed() >= std::time::Duration::from_secs(4) && !sent_kill + }) { + sent_kill = true; + signal_child_group(child, nix::libc::SIGKILL); + } + } + + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +fn signal_child_group(child: &std::process::Child, signal: i32) { + let rc = unsafe { nix::libc::kill(-(child.id() as i32), signal) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(nix::libc::ESRCH) { + crate::log_warn!( + "Failed to forward signal {} to extractor child process group {}: {}", + signal, + child.id(), + err + ); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/source/hooks.rs b/src/source/hooks.rs index a0f8028..bc1611d 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -68,13 +68,13 @@ fn apply_patches( crate::log_info!(" patch: {}", patch_path.display()); // Apply with patch(1). Keep it simple: -p1 is the common case. - let status = Command::new("patch") - .current_dir(src_dir) - .env("PATH", crate::runtime_env::safe_script_path()) - .arg("-p1") - .arg("-i") - .arg(&patch_path) - .status() + let mut patch_cmd = Command::new("patch"); + patch_cmd.current_dir(src_dir); + patch_cmd.env("PATH", crate::runtime_env::safe_script_path()); + patch_cmd.arg("-p1"); + patch_cmd.arg("-i"); + patch_cmd.arg(&patch_path); + let status = crate::interrupts::command_status(&mut patch_cmd) .with_context(|| format!("Failed to execute patch for {}", patch_path.display()))?; if !status.success() { @@ -107,10 +107,8 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path let mut shell_cmd = Command::new("sh"); shell_cmd.current_dir(src_dir); crate::builder::prepare_command(&mut shell_cmd, &env_vars); - let status = shell_cmd - .arg("-c") - .arg(&wrapped_cmd) - .status() + shell_cmd.arg("-c").arg(&wrapped_cmd); + let status = crate::interrupts::command_status(&mut shell_cmd) .with_context(|| format!("Failed to run post_extract command: {}", cmd_str))?; if !status.success() { @@ -149,10 +147,8 @@ pub fn run_post_configure_commands( let mut shell_cmd = Command::new("sh"); shell_cmd.current_dir(src_dir); crate::builder::prepare_command(&mut shell_cmd, &env_vars); - let status = shell_cmd - .arg("-c") - .arg(&wrapped_cmd) - .status() + shell_cmd.arg("-c").arg(&wrapped_cmd); + let status = crate::interrupts::command_status(&mut shell_cmd) .with_context(|| format!("Failed to run post_configure command: {}", cmd_str))?; if !status.success() { @@ -187,10 +183,8 @@ pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &P let mut shell_cmd = Command::new("sh"); shell_cmd.current_dir(src_dir); crate::builder::prepare_command(&mut shell_cmd, &env_vars); - let status = shell_cmd - .arg("-c") - .arg(&wrapped_cmd) - .status() + shell_cmd.arg("-c").arg(&wrapped_cmd); + let status = crate::interrupts::command_status(&mut shell_cmd) .with_context(|| format!("Failed to run post_compile command: {}", cmd_str))?; if !status.success() { @@ -229,10 +223,8 @@ pub fn run_post_install_commands_in_dir( let mut shell_cmd = Command::new("sh"); shell_cmd.current_dir(work_dir); crate::builder::prepare_command(&mut shell_cmd, &env_vars); - let status = shell_cmd - .arg("-c") - .arg(&wrapped_cmd) - .status() + shell_cmd.arg("-c").arg(&wrapped_cmd); + let status = crate::interrupts::command_status(&mut shell_cmd) .with_context(|| format!("Failed to run post_install command: {}", cmd_str))?; if !status.success() { diff --git a/src/source/mod.rs b/src/source/mod.rs index 22ca10d..cdc8c5b 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -296,6 +296,7 @@ pub fn prepare(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result let mut primary: Option = None; for (idx, src) in spec.sources().iter().enumerate() { + crate::interrupts::check()?; let src_dir = prepare_one(spec, src, cache_dir, build_dir) .with_context(|| format!("Failed to prepare source #{}", idx + 1))?; if idx == 0 { @@ -409,6 +410,7 @@ fn prepare_one( fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; for entry in WalkDir::new(src) { + crate::interrupts::check()?; let entry = entry?; let rel = entry.path().strip_prefix(src).unwrap(); let target = dst.join(rel);