diff --git a/Cargo.lock b/Cargo.lock index d081f92..b3b1b8b 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -168,6 +168,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -230,7 +239,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -285,9 +294,9 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clap_mangen" -version = "0.2.33" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +checksum = "d82842b45bf9f6a3be090dd860095ac30728042c08e0d6261ca7259b5d850f07" dependencies = [ "clap", "roff", @@ -312,6 +321,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -358,6 +373,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -404,6 +428,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ct-codecs" version = "1.1.6" @@ -418,7 +451,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.33.1" +version = "0.34.0" dependencies = [ "anyhow", "ar", @@ -444,8 +477,8 @@ dependencies = [ "semver", "serde", "serde_ignored", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "signal-hook 0.4.3", "suppaftp", "sys-mount", @@ -499,11 +532,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -838,7 +882,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -880,6 +924,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1318,7 +1371,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" dependencies = [ - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1519,7 +1572,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -1832,7 +1885,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1919,9 +1972,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -1933,8 +1986,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -1944,8 +2008,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2262,9 +2337,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -2277,27 +2352,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" @@ -2878,9 +2953,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" [[package]] name = "wit-bindgen" @@ -3108,7 +3183,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.10.6", "time", "typed-path", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 869f369..4537817 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.33.1" +version = "0.34.0" edition = "2024" [lints.rust] @@ -19,11 +19,11 @@ rusqlite = "0.39.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } yaml-rust2 = "0.11.0" -sha2 = "0.10.9" +sha2 = "0.11.0" tar = "0.4.45" tempfile = "=3.27.0" thiserror = "2.0.18" -toml = "1.0.6" +toml = "1.1.0+spec-1.1.0" url = "2.5.8" walkdir = "2.5.0" xz2 = "0.1.7" @@ -38,7 +38,7 @@ fd-lock = "4.0.4" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "native-tls"] } filetime = "0.2.27" clap_complete = "4.6.0" -clap_mangen = "0.2.33" +clap_mangen = "0.3.0" sys-mount = { version = "3.1.0", default-features = false } time = { version = "0.3.47", features = ["formatting", "parsing"] } b2sum-rust = "0.3.0" @@ -46,7 +46,7 @@ serde_ignored = "0.1.14" lz4_flex = "0.13.0" lzma-rust2 = "0.16.2" signal-hook = "0.4.3" -sha1 = "0.10.6" +sha1 = "0.11.0" [dev-dependencies] tempfile = "=3.27.0" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..8578bd9 --- /dev/null +++ b/build.rs @@ -0,0 +1,20 @@ +const BUILD_OPTION_KEYS: &[&str] = &[ + "BUILD_DEPOT_STATIC", + "DEPOT_AUTOTOOLS_PACKAGE", + "DEPOT_CMAKE_PACKAGE", + "DEPOT_MESON_PACKAGE", + "DEPOT_PERL_PACKAGE", + "DEPOT_CUSTOM_PACKAGE", + "DEPOT_PYTHON_PACKAGE", + "DEPOT_RUST_PACKAGE", + "DEPOT_MAKEFILE_PACKAGE", +]; + +fn main() { + for key in BUILD_OPTION_KEYS { + println!("cargo:rerun-if-env-changed={key}"); + if let Ok(value) = std::env::var(key) { + println!("cargo:rustc-env={key}={value}"); + } + } +} diff --git a/meson.build b/meson.build index e7651c7..4c203e3 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.33.1', + version: '0.34.0', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) @@ -12,6 +12,15 @@ 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) +build_depot_static = get_option('BUILD_DEPOT_STATIC') +depot_autotools_package = get_option('DEPOT_AUTOTOOLS_PACKAGE') +depot_cmake_package = get_option('DEPOT_CMAKE_PACKAGE') +depot_meson_package = get_option('DEPOT_MESON_PACKAGE') +depot_perl_package = get_option('DEPOT_PERL_PACKAGE') +depot_custom_package = get_option('DEPOT_CUSTOM_PACKAGE') +depot_python_package = get_option('DEPOT_PYTHON_PACKAGE') +depot_rust_package = get_option('DEPOT_RUST_PACKAGE') +depot_makefile_package = get_option('DEPOT_MAKEFILE_PACKAGE') src_root = meson.project_source_root() build_root = meson.project_build_root() @@ -66,6 +75,15 @@ depot_bin = custom_target( build_root, cargo_profile, cargo_release ? '1' : '0', + build_depot_static ? 'true' : 'false', + depot_autotools_package, + depot_cmake_package, + depot_meson_package, + depot_perl_package, + depot_custom_package, + depot_python_package, + depot_rust_package, + depot_makefile_package, '@OUTPUT@', ], console: true, @@ -101,6 +119,15 @@ test( build_root, cargo_profile, cargo_release ? '1' : '0', + build_depot_static ? 'true' : 'false', + depot_autotools_package, + depot_cmake_package, + depot_meson_package, + depot_perl_package, + depot_custom_package, + depot_python_package, + depot_rust_package, + depot_makefile_package, ], suite: ['cargo'], timeout: 0, diff --git a/meson_options.txt b/meson_options.txt index 12476e4..181dd04 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -4,3 +4,57 @@ option( value: false, description: 'Generate HTML CLI documentation with cli_doc and install it', ) +option( + 'BUILD_DEPOT_STATIC', + type: 'boolean', + value: false, + description: 'Compile Depot with a default static-build preference for supported package builders', +) +option( + 'DEPOT_AUTOTOOLS_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before autotools builds', +) +option( + 'DEPOT_CMAKE_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before CMake builds', +) +option( + 'DEPOT_MESON_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before Meson builds', +) +option( + 'DEPOT_PERL_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before Perl builds', +) +option( + 'DEPOT_CUSTOM_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before custom builds', +) +option( + 'DEPOT_PYTHON_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before Python builds', +) +option( + 'DEPOT_RUST_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before Rust builds', +) +option( + 'DEPOT_MAKEFILE_PACKAGE', + type: 'string', + value: '', + description: 'Compile in the helper package to auto-install before makefile builds', +) diff --git a/src/build_options.rs b/src/build_options.rs new file mode 100644 index 0000000..af6def3 --- /dev/null +++ b/src/build_options.rs @@ -0,0 +1,102 @@ +use crate::package::BuildType; +use anyhow::Result; + +const BUILD_DEPOT_STATIC_OPTION: &str = "BUILD_DEPOT_STATIC"; + +fn parse_boolish_option(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" | "enable" | "enabled" | "static" => Some(true), + "0" | "false" | "no" | "off" | "disable" | "disabled" | "shared" => Some(false), + _ => None, + } +} + +fn normalize_string_option(value: Option<&'static str>) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +pub(crate) fn requested_static_build() -> Result> { + let Some(raw) = option_env!("BUILD_DEPOT_STATIC") else { + return Ok(None); + }; + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + parse_boolish_option(raw).ok_or_else(|| { + anyhow::anyhow!( + "Invalid -D{} value '{}'; expected true/false, on/off, enable/disable, static/shared, or 1/0", + BUILD_DEPOT_STATIC_OPTION, + raw + ) + }).map(Some) +} + +pub(crate) fn build_tool_package_option(build_type: BuildType) -> Option<&'static str> { + match build_type { + BuildType::Autotools => Some("DEPOT_AUTOTOOLS_PACKAGE"), + BuildType::CMake => Some("DEPOT_CMAKE_PACKAGE"), + BuildType::Meson => Some("DEPOT_MESON_PACKAGE"), + BuildType::Perl => Some("DEPOT_PERL_PACKAGE"), + BuildType::Custom => Some("DEPOT_CUSTOM_PACKAGE"), + BuildType::Python => Some("DEPOT_PYTHON_PACKAGE"), + BuildType::Rust => Some("DEPOT_RUST_PACKAGE"), + BuildType::Makefile => Some("DEPOT_MAKEFILE_PACKAGE"), + BuildType::Bin | BuildType::Meta => None, + } +} + +pub(crate) fn requested_build_tool_package(build_type: BuildType) -> Option { + match build_type { + BuildType::Autotools => normalize_string_option(option_env!("DEPOT_AUTOTOOLS_PACKAGE")), + BuildType::CMake => normalize_string_option(option_env!("DEPOT_CMAKE_PACKAGE")), + BuildType::Meson => normalize_string_option(option_env!("DEPOT_MESON_PACKAGE")), + BuildType::Perl => normalize_string_option(option_env!("DEPOT_PERL_PACKAGE")), + BuildType::Custom => normalize_string_option(option_env!("DEPOT_CUSTOM_PACKAGE")), + BuildType::Python => normalize_string_option(option_env!("DEPOT_PYTHON_PACKAGE")), + BuildType::Rust => normalize_string_option(option_env!("DEPOT_RUST_PACKAGE")), + BuildType::Makefile => normalize_string_option(option_env!("DEPOT_MAKEFILE_PACKAGE")), + BuildType::Bin | BuildType::Meta => None, + } +} + +#[cfg(test)] +mod tests { + use super::{build_tool_package_option, normalize_string_option, parse_boolish_option}; + use crate::package::BuildType; + + #[test] + fn parse_boolish_option_accepts_expected_values() { + assert_eq!(parse_boolish_option("true"), Some(true)); + assert_eq!(parse_boolish_option("enable"), Some(true)); + assert_eq!(parse_boolish_option("shared"), Some(false)); + assert_eq!(parse_boolish_option("off"), Some(false)); + assert_eq!(parse_boolish_option("maybe"), None); + } + + #[test] + fn normalize_string_option_trims_and_drops_empty_values() { + assert_eq!( + normalize_string_option(Some(" meson-bootstrap ")), + Some("meson-bootstrap".to_string()) + ); + assert_eq!(normalize_string_option(Some(" ")), None); + assert_eq!(normalize_string_option(None), None); + } + + #[test] + fn build_tool_package_option_maps_supported_builders() { + assert_eq!( + build_tool_package_option(BuildType::Meson), + Some("DEPOT_MESON_PACKAGE") + ); + assert_eq!( + build_tool_package_option(BuildType::CMake), + Some("DEPOT_CMAKE_PACKAGE") + ); + assert_eq!(build_tool_package_option(BuildType::Bin), None); + } +} diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index 62be393..0eaa0df 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -134,6 +134,9 @@ pub fn build( let expanded = expand_configure_arg(spec, arg, &env_vars); configure_cmd.arg(expanded); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::Autotools)? { + configure_cmd.arg(arg); + } let status = crate::interrupts::command_status(&mut configure_cmd) .with_context(|| format!("Failed to run configure in {}", build_dir.display()))?; @@ -441,6 +444,9 @@ pub(crate) fn ensure_host_build( let expanded = expand_configure_arg(&host_spec, arg, &env_vars); configure_cmd.arg(expanded); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::Autotools)? { + configure_cmd.arg(arg); + } let status = crate::interrupts::command_status(&mut configure_cmd) .with_context(|| format!("Failed to run host configure in {}", build_dir.display()))?; diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 2b4a619..38f09fd 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -44,8 +44,7 @@ pub fn build( } // Extract prefix from configure flags (cmake-style -DCMAKE_INSTALL_PREFIX=) - let prefix = cmake_cache_entry_value(&flags.configure, "CMAKE_INSTALL_PREFIX") - .unwrap_or(flags.prefix.as_str()); + let prefix = effective_cmake_install_prefix(flags); // Generate toolchain file if cross-compiling let toolchain_file = if let Some(cc_cfg) = cross { @@ -68,7 +67,7 @@ pub fn build( cmake_cmd.arg("-B").arg(&build_dir); cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix)); cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release"); - for arg in cmake_install_dir_args(flags) { + for arg in cmake_install_dir_args(flags, prefix) { cmake_cmd.arg(arg); } for arg in cmake_lib32_target_args(flags, cross) { @@ -98,6 +97,9 @@ pub fn build( let expanded = expand_with_envs(flag, &env_vars); cmake_cmd.arg(&expanded); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::CMake)? { + cmake_cmd.arg(arg); + } crate::builder::prepare_tool_command(&mut cmake_cmd, &env_vars); @@ -280,8 +282,7 @@ pub(crate) fn ensure_host_build( let env_vars = crate::builder::standard_build_env(&host_spec, None, true, export_compiler_flags); - let prefix = cmake_cache_entry_value(&flags.configure, "CMAKE_INSTALL_PREFIX") - .unwrap_or(flags.prefix.as_str()); + let prefix = effective_cmake_install_prefix(flags); let mut state = StateTracker::new_with_namespace(&actual_src, Some("host"))?; @@ -296,7 +297,7 @@ pub(crate) fn ensure_host_build( cmake_cmd.arg("-B").arg(&build_dir); cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix)); cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release"); - for arg in cmake_install_dir_args(flags) { + for arg in cmake_install_dir_args(flags, prefix) { cmake_cmd.arg(arg); } @@ -316,6 +317,9 @@ pub(crate) fn ensure_host_build( let expanded = expand_with_envs(flag, &env_vars); cmake_cmd.arg(&expanded); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::CMake)? { + cmake_cmd.arg(arg); + } crate::builder::prepare_tool_command(&mut cmake_cmd, &env_vars); @@ -446,21 +450,72 @@ fn cmake_cache_entry_value<'a>(flags: &'a [String], variable: &str) -> Option<&' }) } -fn cmake_install_dir_args(flags: &crate::package::BuildFlags) -> Vec { +fn effective_cmake_install_prefix(flags: &crate::package::BuildFlags) -> &str { + cmake_cache_entry_value(&flags.configure, "CMAKE_INSTALL_PREFIX") + .unwrap_or(flags.prefix.as_str()) +} + +fn cmake_dir_value_for_prefix(prefix: &str, value: String) -> String { + let prefix_path = Path::new(prefix); + let value_path = Path::new(&value); + + if prefix_path.is_absolute() + && value_path.is_absolute() + && let Ok(relative) = value_path.strip_prefix(prefix_path) + { + let relative = relative.to_string_lossy().replace('\\', "/"); + return if relative.is_empty() { + ".".to_string() + } else { + relative + }; + } + + value +} + +fn cmake_install_dir_args(flags: &crate::package::BuildFlags, prefix: &str) -> Vec { let dirs = crate::builder::install_dirs(flags); let defaults = [ - ("CMAKE_INSTALL_BINDIR", dirs.bindir), - ("CMAKE_INSTALL_SBINDIR", dirs.sbindir), - ("CMAKE_INSTALL_LIBDIR", dirs.libdir), - ("CMAKE_INSTALL_LIBEXECDIR", dirs.libexecdir), + ( + "CMAKE_INSTALL_BINDIR", + cmake_dir_value_for_prefix(prefix, dirs.bindir), + ), + ( + "CMAKE_INSTALL_SBINDIR", + cmake_dir_value_for_prefix(prefix, dirs.sbindir), + ), + ( + "CMAKE_INSTALL_LIBDIR", + cmake_dir_value_for_prefix(prefix, dirs.libdir), + ), + ( + "CMAKE_INSTALL_LIBEXECDIR", + cmake_dir_value_for_prefix(prefix, dirs.libexecdir), + ), ("CMAKE_INSTALL_SYSCONFDIR", dirs.sysconfdir), ("CMAKE_INSTALL_LOCALSTATEDIR", dirs.localstatedir), ("CMAKE_INSTALL_SHAREDSTATEDIR", dirs.sharedstatedir), - ("CMAKE_INSTALL_INCLUDEDIR", dirs.includedir), - ("CMAKE_INSTALL_DATAROOTDIR", dirs.datarootdir), - ("CMAKE_INSTALL_DATADIR", dirs.datadir), - ("CMAKE_INSTALL_MANDIR", dirs.mandir), - ("CMAKE_INSTALL_INFODIR", dirs.infodir), + ( + "CMAKE_INSTALL_INCLUDEDIR", + cmake_dir_value_for_prefix(prefix, dirs.includedir), + ), + ( + "CMAKE_INSTALL_DATAROOTDIR", + cmake_dir_value_for_prefix(prefix, dirs.datarootdir), + ), + ( + "CMAKE_INSTALL_DATADIR", + cmake_dir_value_for_prefix(prefix, dirs.datadir), + ), + ( + "CMAKE_INSTALL_MANDIR", + cmake_dir_value_for_prefix(prefix, dirs.mandir), + ), + ( + "CMAKE_INSTALL_INFODIR", + cmake_dir_value_for_prefix(prefix, dirs.infodir), + ), ]; defaults @@ -710,15 +765,41 @@ mod tests { } #[test] - fn test_cmake_install_dir_args_include_defaults() { - let args = cmake_install_dir_args(&BuildFlags::default()); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=/usr/bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SBINDIR=/usr/bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=/usr/lib")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=/usr/lib") + fn test_effective_cmake_install_prefix_prefers_explicit_configure_flag() { + let flags = BuildFlags { + prefix: "/usr".into(), + configure: vec!["-DCMAKE_INSTALL_PREFIX=/opt/soundtouch".into()], + ..BuildFlags::default() + }; + + assert_eq!(effective_cmake_install_prefix(&flags), "/opt/soundtouch"); + } + + #[test] + fn test_cmake_dir_value_for_prefix_converts_prefix_owned_absolute_paths() { + assert_eq!(cmake_dir_value_for_prefix("/usr", "/usr/lib".into()), "lib"); + assert_eq!( + cmake_dir_value_for_prefix("/usr", "/usr/share/man".into()), + "share/man" ); + } + + #[test] + fn test_cmake_dir_value_for_prefix_keeps_non_prefix_absolute_paths() { + assert_eq!(cmake_dir_value_for_prefix("/usr", "/etc".into()), "/etc"); + assert_eq!( + cmake_dir_value_for_prefix("/opt/pkg", "/usr/bin".into()), + "/usr/bin" + ); + } + + #[test] + fn test_cmake_install_dir_args_include_prefix_relative_defaults() { + let args = cmake_install_dir_args(&BuildFlags::default(), "/usr"); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SBINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib")); assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); assert!( args.iter() @@ -730,23 +811,17 @@ mod tests { ); assert!( args.iter() - .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=/usr/include") + .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include") ); assert!( args.iter() - .any(|a| a == "-DCMAKE_INSTALL_DATAROOTDIR=/usr/share") + .any(|a| a == "-DCMAKE_INSTALL_DATAROOTDIR=share") ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); assert!( args.iter() - .any(|a| a == "-DCMAKE_INSTALL_DATADIR=/usr/share") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_MANDIR=/usr/share/man") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_INFODIR=/usr/share/info") + .any(|a| a == "-DCMAKE_INSTALL_INFODIR=share/info") ); } @@ -757,15 +832,9 @@ mod tests { ..BuildFlags::default() }; - let args = cmake_install_dir_args(&flags); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_LIBDIR=/usr/lib32") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=/usr/lib32") - ); + let args = cmake_install_dir_args(&flags, "/usr"); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib32")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib32")); } #[test] @@ -832,7 +901,7 @@ mod tests { ..BuildFlags::default() }; - let args = cmake_install_dir_args(&flags); + let args = cmake_install_dir_args(&flags, "/usr"); assert!( !args .iter() @@ -848,7 +917,44 @@ mod tests { .iter() .any(|a| a.starts_with("-DCMAKE_INSTALL_DATADIR=")) ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); + } + + #[test] + fn test_cmake_install_dir_args_convert_custom_prefix_owned_dirs() { + let flags = BuildFlags { + prefix: "/opt/soundtouch".into(), + bindir: "/opt/soundtouch/bin".into(), + libdir: "/opt/soundtouch/lib64".into(), + includedir: "/opt/soundtouch/include/soundtouch".into(), + datadir: "/opt/soundtouch/share".into(), + mandir: "/opt/soundtouch/share/man".into(), + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib64")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include/soundtouch") + ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); + } + + #[test] + fn test_cmake_install_dir_args_keep_absolute_dirs_outside_prefix() { + let flags = BuildFlags { + prefix: "/opt/soundtouch".into(), + bindir: "/usr/bin".into(), + sysconfdir: "/etc".into(), + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=/usr/bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); } #[test] diff --git a/src/builder/meson.rs b/src/builder/meson.rs index 990b990..2fb8fc0 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -65,6 +65,9 @@ pub fn build( for arg in meson_setup_args(flags, cross_file.as_deref(), &env_vars) { meson_cmd.arg(arg); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::Meson)? { + meson_cmd.arg(arg); + } crate::builder::prepare_tool_command(&mut meson_cmd, &env_vars); @@ -229,6 +232,9 @@ pub(crate) fn ensure_host_build( for arg in meson_setup_args(flags, None, &env_vars) { meson_cmd.arg(arg); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::Meson)? { + meson_cmd.arg(arg); + } crate::builder::prepare_tool_command(&mut meson_cmd, &env_vars); diff --git a/src/builder/mod.rs b/src/builder/mod.rs index bcba999..79a962e 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -184,6 +184,47 @@ pub(crate) fn host_build_spec(spec: &PackageSpec) -> PackageSpec { host_spec } +pub(crate) fn requested_static_build() -> Result> { + crate::build_options::requested_static_build() +} + +pub(crate) fn static_build_args_for(build_type: BuildType) -> Result> { + let Some(enabled) = requested_static_build()? else { + return Ok(Vec::new()); + }; + + let args = match build_type { + BuildType::Autotools => vec![if enabled { + "--enable-static".to_string() + } else { + "--disable-static".to_string() + }], + BuildType::CMake => vec![format!( + "-DBUILD_SHARED_LIBS={}", + if enabled { "OFF" } else { "ON" } + )], + BuildType::Meson => vec![format!( + "-Ddefault_library={}", + if enabled { "static" } else { "shared" } + )], + BuildType::Perl => vec![format!( + "LINKTYPE={}", + if enabled { "static" } else { "dynamic" } + )], + _ => Vec::new(), + }; + + Ok(args) +} + +pub(crate) fn build_tool_package_option(build_type: BuildType) -> Option<&'static str> { + crate::build_options::build_tool_package_option(build_type) +} + +pub(crate) fn requested_build_tool_package(build_type: BuildType) -> Option { + crate::build_options::requested_build_tool_package(build_type) +} + fn configured_install_dir(value: &str, default: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -889,6 +930,19 @@ mod tests { ); } + #[test] + fn test_build_tool_package_option_maps_supported_builders() { + assert_eq!( + build_tool_package_option(BuildType::Meson), + Some("DEPOT_MESON_PACKAGE") + ); + assert_eq!( + build_tool_package_option(BuildType::CMake), + Some("DEPOT_CMAKE_PACKAGE") + ); + assert_eq!(build_tool_package_option(BuildType::Bin), None); + } + #[test] fn test_standard_build_env_exports_native_linker_and_cpp() { let mut spec = mk_spec(Vec::new(), Vec::new()); diff --git a/src/builder/perl.rs b/src/builder/perl.rs index 981eced..6133b27 100644 --- a/src/builder/perl.rs +++ b/src/builder/perl.rs @@ -59,6 +59,9 @@ pub fn build( for arg in &flags.configure { configure_cmd.arg(spec.expand_vars(arg)); } + for arg in crate::builder::static_build_args_for(crate::package::BuildType::Perl)? { + configure_cmd.arg(arg); + } crate::builder::prepare_tool_command(&mut configure_cmd, &env_vars); let status = command_status_with_sh_fallback(&mut configure_cmd).with_context(|| { diff --git a/src/commands.rs b/src/commands.rs index be51a42..1d5ca32 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,7 +1,6 @@ use crate::cli::{ BuildArgs, Cli, Commands, ConfigArgs, ConvertArgs, InfoArgs, InstallArgs, InternalCommands, - ListArgs, OwnsArgs, RemoveArgs, RepoArgs, RepoCommands, RepoKindArg, SearchArgs, SignArgs, - UpdateArgs, + ListArgs, OwnsArgs, RemoveArgs, RepoCommands, RepoKindArg, SearchArgs, SignArgs, UpdateArgs, }; use crate::{ builder, cli_assets, config, cross, db, deps, index, install, locking, package, planner, @@ -19,6 +18,40 @@ use std::time::Duration; use url::Url; use walkdir::WalkDir; +use build_cmd::support::{ + automatic_tests_disabled_for_outputs, build_lib32_companion_package, clean_build_workspace, + effective_lib32_only, make_lib32_package_spec, maybe_disable_tests_for_missing_deps, + maybe_prompt_to_skip_tests_for_missing_requested_deps, merge_missing_dependencies, + requested_outputs, should_install_test_deps, +}; +use install_cmd::archive::{ + extract_package_archive_to_staging, load_package_archive_into_staging, + load_package_spec_from_staging_or_repo_record, +}; +use repo::groups::{binary_arch_from_filename, human_bytes}; +use update::candidates::{collect_update_candidates, compare_completed_at}; +use update::versions::compare_versions_for_updates; + +#[cfg(test)] +use build_cmd::support::{build_type_runs_automatic_tests, make_lib32_build_spec}; +#[cfg(test)] +use install_cmd::archive::{package_spec_from_repo_record, staging_temp_root}; +#[cfg(test)] +use misc::internal::run_internal_command; +#[cfg(test)] +use repo::groups::{expand_install_requests_for_groups, expand_installed_group_targets}; +#[cfg(test)] +use update::candidates::{ + SourceUpdateCandidate, UpdateCandidate, UpdateOrigin, collect_best_source_update_candidates, + collect_missing_update_dependencies, select_update_candidate, +}; +#[cfg(test)] +use update::versions::{ + ArchiveListingProbe, VersionPattern, archive_listing_probe, best_newer_version, + candidate_versions_from_listing, candidate_versions_from_refs, extract_version_patterns, + list_archive_versions, remote_git_repository_from_source_url, +}; + fn rootfs_is_system_root(rootfs: &Path) -> bool { if rootfs == Path::new("/") { return true; @@ -103,6 +136,16 @@ fn should_delegate_live_rootfs_installs(rootfs: &Path) -> bool { !crate::fakeroot::is_root() && rootfs_is_system_root(rootfs) } +fn compare_package_release( + left_version: &str, + left_revision: u32, + right_version: &str, + right_revision: u32, +) -> Ordering { + compare_versions_for_updates(left_version, right_version) + .then_with(|| left_revision.cmp(&right_revision)) +} + const DEPOT_INSTALL_CONTEXT_ENV: &str = "DEPOT_INSTALL_CONTEXT"; const INSTALL_CONTEXT_UPDATE: &str = "update"; const INSTALL_CONTEXT_PLANNED: &str = "planned"; @@ -570,520 +613,6 @@ fn parse_keep_list(metadata: &toml::Value) -> Vec { Vec::new() } -fn current_process_env_vars() -> Vec<(String, String)> { - const ALLOWED_ENV_VARS: &[&str] = &[ - "AR", - "CARCH", - "CBUILD", - "CC", - "CHOST", - "CPP", - "CROSS_COMPILE", - "CROSS_PREFIX", - "CFLAGS", - "CXX", - "CXXFLAGS", - "DEPOT_ROOTFS", - "DEPOT_SPECDIR", - "DESTDIR", - "LD", - "LDFLAGS", - "LTOFLAGS", - "MAKEFLAGS", - "NM", - "PREFIX", - "PYTHONDONTWRITEBYTECODE", - "PYTHONNOUSERSITE", - "RANLIB", - "RUSTFLAGS", - "RUSTLTOFLAGS", - "SETUPTOOLS_USE_DISTUTILS", - "STRIP", - ]; - - ALLOWED_ENV_VARS - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect() -} - -fn run_internal_command(command: InternalCommands) -> Result<()> { - match command { - InternalCommands::PythonBuild { - src_dir, - dist_dir, - config_settings, - } => { - let env_vars = current_process_env_vars(); - crate::builder::python::build_wheels(&src_dir, &dist_dir, &env_vars, &config_settings) - } - InternalCommands::PythonInstall { - dist_dir, - wheels, - prefix, - } => { - let env_vars = current_process_env_vars(); - let wheel_paths = if wheels.is_empty() { - crate::builder::python::collect_wheels(&dist_dir)? - } else { - wheels - }; - let destdir = std::env::var("DESTDIR") - .context("DESTDIR must be set for internal python-install")?; - crate::builder::python::install_wheels( - &wheel_paths, - Path::new(&destdir), - &prefix, - &env_vars, - ) - } - InternalCommands::Clone { repo, dest } => { - let (base, rev) = crate::source::split_git_url(&repo).with_context(|| { - format!("Unsupported repository URL for internal clone: {}", repo) - })?; - let dest = if let Some(dest) = dest { - dest - } else { - let cwd = - std::env::current_dir().context("Failed to determine current directory")?; - cwd.join(crate::source::git_default_checkout_dir_name(&base)) - }; - if dest.exists() { - anyhow::bail!("Clone destination already exists: {}", dest.display()); - } - let cache_root = - tempfile::tempdir().context("Failed to create temporary git cache for clone")?; - let label = dest - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("clone"); - crate::source::git_checkout( - &base, - &rev, - &dest, - &cache_root.path().join("git"), - label, - &[], - ) - } - } -} - -fn package_spec_from_archive_metadata(metadata: &toml::Value) -> package::PackageSpec { - let mut spec = package::PackageSpec { - package: package::PackageInfo { - name: metadata - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - real_name: metadata - .get("real_name") - .and_then(|v| v.as_str()) - .map(String::from), - version: metadata - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - revision: metadata - .get("revision") - .and_then(|v| v.as_integer()) - .unwrap_or(1) as u32, - description: metadata - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - homepage: metadata - .get("homepage") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - abi_breaking: metadata - .get("abi_breaking") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - license: parse_licenses_from_toml(metadata), - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags { - keep: parse_keep_list(metadata), - ..package::BuildFlags::default() - }, - }, - dependencies: package::Dependencies { - build: Vec::new(), - runtime: parse_dependency_list(metadata, "runtime"), - test: Vec::new(), - optional: parse_dependency_list(metadata, "optional"), - groups: parse_dependency_list(metadata, "groups"), - lib32: None, - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { - spec.alternatives.provides = provides - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(conflicts) = metadata.get("conflicts").and_then(|v| v.as_array()) { - spec.alternatives.conflicts = conflicts - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(replaces) = metadata.get("replaces").and_then(|v| v.as_array()) { - spec.alternatives.replaces = replaces - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - - spec -} - -fn load_package_spec_from_staging(staged_dir: &Path) -> Result { - let metadata_path = staged_dir.join(".metadata.toml"); - let metadata_content = fs::read_to_string(&metadata_path) - .with_context(|| format!("Failed to read {}", metadata_path.display()))?; - let metadata: toml::Value = toml::from_str(&metadata_content) - .with_context(|| format!("Failed to parse {}", metadata_path.display()))?; - Ok(package_spec_from_archive_metadata(&metadata)) -} - -fn parse_license_list_from_repo(license: &Option) -> Vec { - let Some(raw) = license.as_ref() else { - return Vec::new(); - }; - raw.split(',') - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .map(String::from) - .collect() -} - -fn package_spec_from_repo_record( - record: &db::repo::BinaryRepoPackageRecord, -) -> package::PackageSpec { - package::PackageSpec { - package: package::PackageInfo { - name: record.name.clone(), - real_name: record.real_name.clone(), - version: record.version.clone(), - revision: record.revision, - description: record.description.clone().unwrap_or_default(), - homepage: record.homepage.clone().unwrap_or_default(), - abi_breaking: record.abi_breaking, - license: parse_license_list_from_repo(&record.license), - }, - packages: Vec::new(), - alternatives: package::Alternatives { - provides: record.provides.clone(), - conflicts: record.conflicts.clone(), - replaces: record.replaces.clone(), - lib32: None, - }, - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies { - build: Vec::new(), - runtime: record.runtime_dependencies.clone(), - test: Vec::new(), - optional: record.optional_dependencies.clone(), - groups: Vec::new(), - lib32: None, - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } -} - -fn load_package_spec_from_staging_or_repo_record( - staged_dir: &Path, - record: &db::repo::BinaryRepoPackageRecord, -) -> Result { - let metadata_path = staged_dir.join(".metadata.toml"); - if metadata_path.exists() { - load_package_spec_from_staging(staged_dir) - } else { - Ok(package_spec_from_repo_record(record)) - } -} - -fn staging_temp_root(config: &config::Config) -> PathBuf { - config.build_dir.join("staging") -} - -fn create_archive_staging_dir( - config: &config::Config, - archive_path: &Path, -) -> Result { - let staging_root = staging_temp_root(config); - fs::create_dir_all(&staging_root) - .with_context(|| format!("Failed to create staging root {}", staging_root.display()))?; - tempfile::Builder::new() - .prefix("archive-") - .tempdir_in(&staging_root) - .with_context(|| { - format!( - "Failed to create staging dir for {} under {}", - archive_path.display(), - staging_root.display() - ) - }) -} - -fn load_package_archive_into_staging( - config: &config::Config, - archive_path: &Path, -) -> Result<(package::PackageSpec, tempfile::TempDir)> { - let tmp_dir = create_archive_staging_dir(config, archive_path).with_context(|| { - format!( - "Failed to create staging dir for {}", - archive_path.display() - ) - })?; - let extract_dir = tmp_dir.path().to_path_buf(); - - let file = fs::File::open(archive_path) - .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; - let zstd_decoder = zstd::stream::read::Decoder::new(file) - .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; - let mut archive = tar::Archive::new(zstd_decoder); - - let mut metadata_content = String::new(); - for entry in archive.entries().with_context(|| { - format!( - "Failed to read archive entries from {}", - archive_path.display() - ) - })? { - crate::interrupts::check()?; - let mut entry = entry.with_context(|| { - format!( - "Failed to read archive entry from {}", - archive_path.display() - ) - })?; - if entry.path()?.to_string_lossy() == ".metadata.toml" { - use std::io::Read; - entry - .read_to_string(&mut metadata_content) - .with_context(|| { - format!( - "Failed to read .metadata.toml in {}", - archive_path.display() - ) - })?; - break; - } - } - - if metadata_content.is_empty() { - anyhow::bail!( - "Package archive does not contain .metadata.toml: {}", - archive_path.display() - ); - } - - let file = fs::File::open(archive_path) - .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; - let zstd_decoder = zstd::stream::read::Decoder::new(file) - .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; - let mut archive = tar::Archive::new(zstd_decoder); - archive.set_preserve_permissions(true); - crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { - format!( - "Failed to extract package archive {} into {}", - archive_path.display(), - extract_dir.display() - ) - })?; - - let metadata: toml::Value = toml::from_str(&metadata_content).with_context(|| { - format!( - "Failed to parse .metadata.toml in {}", - archive_path.display() - ) - })?; - - Ok((package_spec_from_archive_metadata(&metadata), tmp_dir)) -} - -fn extract_package_archive_to_staging( - config: &config::Config, - archive_path: &Path, -) -> Result { - let tmp_dir = create_archive_staging_dir(config, archive_path).with_context(|| { - format!( - "Failed to create staging dir for {}", - archive_path.display() - ) - })?; - let extract_dir = tmp_dir.path().to_path_buf(); - - let file = fs::File::open(archive_path) - .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; - let zstd_decoder = zstd::stream::read::Decoder::new(file) - .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; - let mut archive = tar::Archive::new(zstd_decoder); - archive.set_preserve_permissions(true); - crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { - format!( - "Failed to extract package archive {} into {}", - archive_path.display(), - extract_dir.display() - ) - })?; - Ok(tmp_dir) -} - -fn build_type_runs_automatic_tests(spec: &package::PackageSpec) -> bool { - matches!( - spec.build.build_type, - package::BuildType::Autotools - | package::BuildType::CMake - | package::BuildType::Meson - | package::BuildType::Perl - ) -} - -fn automatic_tests_disabled_for_outputs( - pkg_spec: &package::PackageSpec, - requested_outputs: deps::RequestedOutputs, -) -> bool { - pkg_spec.should_skip_automatic_tests() || requested_outputs.includes_lib32() -} - -fn maybe_disable_tests_for_missing_deps( - pkg_spec: &mut package::PackageSpec, - db_path: &Path, - requested_outputs: deps::RequestedOutputs, -) -> Result<()> { - if automatic_tests_disabled_for_outputs(pkg_spec, requested_outputs) - || !build_type_runs_automatic_tests(pkg_spec) - || deps::declared_test_deps(pkg_spec, requested_outputs).is_empty() - { - return Ok(()); - } - - let missing_test = deps::check_test_deps_for_outputs(pkg_spec, db_path, requested_outputs)?; - if !missing_test.is_empty() { - ui::warn(format!( - "Missing test dependencies: {}. Tests will be skipped.", - missing_test.join(", ") - )); - pkg_spec.build.flags.skip_tests = true; - } - - Ok(()) -} - -fn maybe_prompt_to_skip_tests_for_missing_requested_deps( - pkg_spec: &mut package::PackageSpec, - missing_test: &[String], - reason: &str, -) -> Result { - if pkg_spec.should_skip_automatic_tests() - || !build_type_runs_automatic_tests(pkg_spec) - || missing_test.is_empty() - { - return Ok(false); - } - - ui::warn(format!("{reason}: {}", missing_test.join(", "))); - if ui::prompt_yes_no("Continue without tests?", false)? { - pkg_spec.build.flags.skip_tests = true; - ui::warn("Tests will be skipped for this build."); - return Ok(true); - } - - Ok(false) -} - -fn requested_outputs(pkg_spec: &package::PackageSpec, lib32_only: bool) -> deps::RequestedOutputs { - if effective_lib32_only(pkg_spec, lib32_only) { - deps::RequestedOutputs::Lib32Only - } else if pkg_spec.builds_lib32_output() { - deps::RequestedOutputs::PrimaryAndLib32 - } else { - deps::RequestedOutputs::PrimaryOnly - } -} - -fn effective_lib32_only(pkg_spec: &package::PackageSpec, cli_lib32_only: bool) -> bool { - cli_lib32_only || pkg_spec.builds_only_lib32_output() -} - -fn should_install_test_deps( - pkg_spec: &package::PackageSpec, - install_test_deps: bool, - requested_outputs: deps::RequestedOutputs, -) -> bool { - install_test_deps - && !automatic_tests_disabled_for_outputs(pkg_spec, requested_outputs) - && !deps::declared_test_deps(pkg_spec, requested_outputs).is_empty() -} - -fn clean_build_workspace(config: &config::Config) -> Result<()> { - if config.build_dir.exists() { - fs::remove_dir_all(&config.build_dir).with_context(|| { - format!("Failed to clean build dir: {}", config.build_dir.display()) - })?; - ui::success(format!( - "Cleaned build workspace: {}", - config.build_dir.display() - )); - } - if config.cache_dir.exists() { - fs::remove_dir_all(&config.cache_dir).with_context(|| { - format!( - "Failed to clean source cache dir: {}", - config.cache_dir.display() - ) - })?; - ui::success(format!( - "Cleaned source cache: {}", - config.cache_dir.display() - )); - } - Ok(()) -} - -fn warn_if_running_as_root_for_build(command: &str, rootfs: &Path) { - if crate::fakeroot::is_root() { - 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.", - ); - 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() @@ -1152,129 +681,6 @@ fn staged_output_specs( Ok(outputs) } -fn lib32_package_name(name: &str) -> String { - format!("lib32-{name}") -} - -fn make_lib32_build_spec(base: &package::PackageSpec) -> package::PackageSpec { - let mut spec = base.clone(); - let flags = &mut spec.build.flags; - flags.lib32_variant = true; - flags.cflags = flags.cflags_lib32.clone(); - flags.replace_cflags = flags.replace_cflags_lib32.clone(); - flags.cxxflags = flags.cxxflags_lib32.clone(); - flags.replace_cxxflags = flags.replace_cxxflags_lib32.clone(); - if !flags.configure_lib32.is_empty() { - flags.configure = flags.configure_lib32.clone(); - } - if !flags.post_configure_lib32.is_empty() { - flags.post_configure = flags.post_configure_lib32.clone(); - } - if !flags.post_compile_lib32.is_empty() { - flags.post_compile = flags.post_compile_lib32.clone(); - } - if !flags.post_install_lib32.is_empty() { - flags.post_install = flags.post_install_lib32.clone(); - } - - flags.cc = format!("{} -m32", flags.cc.trim()); - flags.cxx = format!("{} -m32", flags.cxx.trim()); - flags.build_dir = Some(match flags.build_dir.as_deref() { - Some(dir) if !dir.trim().is_empty() => format!("{}-lib32", dir.trim()), - _ => "build-lib32".to_string(), - }); - - spec -} - -fn make_lib32_package_spec(base: &package::PackageSpec) -> package::PackageSpec { - let mut spec = base.clone(); - let lib32_name = lib32_package_name(&base.package.name); - spec.package.name = lib32_name.clone(); - // The lib32 pass currently emits a single companion package. - spec.packages.clear(); - spec.alternatives = base.alternatives_for_output(&lib32_name); - spec.dependencies = base.dependencies_for_output(&lib32_name); - spec -} - -fn build_lib32_companion_package( - pkg_spec: &package::PackageSpec, - src_dir: &Path, - config: &config::Config, - cross_config: Option<&cross::CrossConfig>, - export_compiler_flags: bool, - force: bool, -) -> Result> { - if !pkg_spec.builds_lib32_output() && !force { - return Ok(None); - } - if pkg_spec.is_metapackage() { - crate::log_warn!( - "Ignoring build.flags.build-32 for metapackage {}", - pkg_spec.package.name - ); - return Ok(None); - } - - crate::log_info!("Running separate lib32 build pass..."); - let host_build_dir = builder::ensure_host_build( - pkg_spec, - src_dir, - cross_config, - export_compiler_flags, - builder::TargetBuildKind::Lib32, - )?; - let mut lib32_input = pkg_spec.clone(); - lib32_input.build.flags.build_32 = true; - let mut lib32_build_spec = make_lib32_build_spec(&lib32_input); - if let Some(host_dir) = host_build_dir.as_ref() { - lib32_build_spec.build.flags.host_build_dir = Some(host_dir.to_string_lossy().into_owned()); - } - let lib32_pkg_spec = make_lib32_package_spec(pkg_spec); - let lib32_destdir = config - .build_dir - .join("destdir") - .join(&lib32_pkg_spec.package.name); - if lib32_destdir.exists() { - fs::remove_dir_all(&lib32_destdir).with_context(|| { - format!("Failed to clean lib32 destdir: {}", lib32_destdir.display()) - })?; - } - fs::create_dir_all(&lib32_destdir).with_context(|| { - format!( - "Failed to create lib32 destdir: {}", - lib32_destdir.display() - ) - })?; - - builder::build( - &lib32_build_spec, - src_dir, - &lib32_destdir, - cross_config, - export_compiler_flags, - host_build_dir.as_deref(), - )?; - - let lib32_src = lib32_destdir.join("usr/lib32"); - if !lib32_src.exists() { - anyhow::bail!( - "lib32 build completed but did not install usr/lib32 into {}", - lib32_destdir.display() - ); - } - install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; - staging::process(&lib32_destdir, &lib32_pkg_spec)?; - staging::symlink_package_license( - &lib32_destdir, - &lib32_pkg_spec.package.name, - &pkg_spec.package.name, - )?; - - Ok(Some((lib32_pkg_spec, lib32_destdir))) -} - #[derive(Debug, Clone)] struct PlannedStagedInstall { is_update: bool, @@ -1959,2219 +1365,6 @@ fn install_package_outputs_to_rootfs( Ok(installed) } -fn repo_kind_label(kind: RepoKindArg) -> &'static str { - match kind { - RepoKindArg::Source => "source", - RepoKindArg::Binary => "binary", - } -} - -fn resolve_repo_kind_for_name( - repos: &config::RepoConfigFile, - name: &str, - kind: Option, -) -> Result { - if let Some(kind) = kind { - return Ok(kind); - } - - let in_source = repos.source.contains_key(name); - let in_binary = repos.binary.contains_key(name); - match (in_source, in_binary) { - (true, false) => Ok(RepoKindArg::Source), - (false, true) => Ok(RepoKindArg::Binary), - (true, true) => anyhow::bail!( - "Repo '{}' exists as both source and binary; rerun with --kind source|binary", - name - ), - (false, false) => anyhow::bail!("Repo '{}' not found in repos.toml", name), - } -} - -fn print_repo_list(config: &config::Config) { - if config.source_repos.is_empty() && config.binary_repos.is_empty() { - ui::info("No repos configured in /etc/depot.d/repos.toml"); - if !config.mirrors.is_empty() { - ui::info("Legacy mirrors.toml entries are loaded as source repos at runtime."); - } - return; - } - - ui::info(format!( - "Repo settings: prefer_binary={}", - config.repo_settings.prefer_binary - )); - - if config.source_repos.is_empty() { - ui::info("Source repos: none"); - } else { - ui::info("Source repos:"); - for (name, repo) in &config.source_repos { - let subdirs = if repo.subdirs.is_empty() { - "(all)".to_string() - } else { - repo.subdirs.join(", ") - }; - ui::info(format!( - " {} [{}] priority={} subdirs={} url={}", - name, - if repo.enabled { "enabled" } else { "disabled" }, - repo.priority, - subdirs, - repo.url - )); - } - } - - if config.binary_repos.is_empty() { - ui::info("Binary repos: none"); - } else { - ui::info("Binary repos:"); - let host_arch = std::env::consts::ARCH; - for (name, repo) in &config.binary_repos { - let arch_keys = if repo.arch.is_empty() { - "(any)".to_string() - } else { - repo.arch.keys().cloned().collect::>().join(",") - }; - ui::info(format!( - " {} [{}] priority={} arches={} host_match={} repo_db={}{} url={}", - name, - if repo.enabled { "enabled" } else { "disabled" }, - repo.priority, - arch_keys, - if repo.supports_arch(host_arch) { - "yes" - } else { - "no" - }, - repo.repo_db, - if repo.allow_unsigned { - " allow_unsigned=true" - } else { - "" - }, - repo.url - )); - } - } -} - -fn selected_source_repos( - config: &config::Config, - name: Option<&str>, -) -> Result> { - let mut mirrors = config.enabled_source_mirror_map(); - if let Some(name) = name { - if let Some(url) = mirrors.remove(name) { - let mut only = std::collections::HashMap::new(); - only.insert(name.to_string(), url); - return Ok(only); - } - anyhow::bail!("Enabled source repo '{}' not found", name); - } - Ok(mirrors) -} - -fn source_search_hit_allowed(config: &config::Config, hit: &index::SourceSearchHit) -> bool { - let path = &hit.path; - - if path.starts_with(Path::new("packages")) { - return true; - } - - if config.source_repos.is_empty() { - return true; - } - - for (repo_name, repo) in &config.source_repos { - if !repo.enabled { - continue; - } - let repo_root = config.repo_clone_dir.join(repo_name); - if repo.subdirs.is_empty() { - if path.starts_with(&repo_root) { - return true; - } - } else { - for subdir in &repo.subdirs { - if path.starts_with(repo_root.join(subdir)) { - return true; - } - } - } - } - - false -} - -fn source_hit_origin(config: &config::Config, path: &Path) -> String { - if let Ok(rel) = path.strip_prefix(&config.repo_clone_dir) - && let Some(first) = rel.components().next() - { - return format!("source:{}", first.as_os_str().to_string_lossy()); - } - "source:local".to_string() -} - -fn run_search_command( - query: &str, - files: bool, - config: &config::Config, - rootfs: &Path, -) -> Result<()> { - let mut any = false; - let host_arch = std::env::consts::ARCH; - - let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); - let source_hits: Vec<_> = pkg_index - .search(query) - .into_iter() - .filter(|hit| source_search_hit_allowed(config, hit)) - .collect(); - if !source_hits.is_empty() { - any = true; - ui::info("Source matches:"); - for hit in source_hits { - let provides = if hit.provides.is_empty() { - String::new() - } else { - format!(" provides={}", hit.provides.join(",")) - }; - let replaces = if hit.replaces.is_empty() { - String::new() - } else { - format!(" replaces={}", hit.replaces.join(",")) - }; - ui::info(format!( - " {} [{}] {}{}{}", - hit.name, - source_hit_origin(config, &hit.path), - hit.path.display(), - provides, - replaces - )); - } - } - - let mut binary_repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - if !binary_repos.is_empty() { - ui::info("Binary matches:"); - } - let mut binary_hits_total = 0usize; - for (name, repo) in binary_repos { - match db::repo::search_binary_repo(name, repo, rootfs, &config.package_cache_dir, query) { - Ok(hits) => { - for hit in hits { - any = true; - binary_hits_total += 1; - let provides = if hit.provides.is_empty() { - String::new() - } else { - format!(" provides={}", hit.provides.join(",")) - }; - ui::info(format!( - " {} [binary:{}] {}-{} size={} file={}{}{}", - hit.name, - hit.repo_name, - hit.version, - hit.revision, - hit.size, - hit.filename, - hit.description - .as_ref() - .map(|d| format!(" desc={}", d)) - .unwrap_or_default(), - provides - )); - } - } - Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), - } - } - if !config.binary_repos.is_empty() && binary_hits_total == 0 { - ui::info(" (no binary matches)"); - } - - if files && !config.binary_repos.is_empty() { - ui::info("Binary file matches:"); - let mut file_hits_total = 0usize; - let mut binary_repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - for (name, repo) in binary_repos { - match db::repo::search_binary_repo_files( - name, - repo, - rootfs, - &config.package_cache_dir, - query, - ) { - Ok(hits) => { - for hit in hits { - any = true; - file_hits_total += 1; - ui::info(format!( - " {} [binary:{}] {}-{} size={} owns={}", - hit.package_name, - hit.repo_name, - hit.version, - hit.revision, - hit.size, - hit.path - )); - } - } - Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), - } - } - if file_hits_total == 0 { - ui::info(" (no binary file matches)"); - } - } - - if !any { - ui::warn(format!("No matches found for '{}'", query)); - } - Ok(()) -} - -fn scan_package_specs(dir: &Path) -> Result> { - let root = dir - .canonicalize() - .with_context(|| format!("Failed to resolve scan root {}", dir.display()))?; - if !root.is_dir() { - anyhow::bail!("Scan root is not a directory: {}", root.display()); - } - - let mut specs = Vec::new(); - for entry in WalkDir::new(&root) - .follow_links(false) - .into_iter() - .filter_entry(|entry| entry.file_name() != std::ffi::OsStr::new(".git")) - { - crate::interrupts::check()?; - let entry = entry.with_context(|| format!("Failed to walk {}", root.display()))?; - if !entry.file_type().is_file() { - continue; - } - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("toml") { - continue; - } - let candidate = path.to_path_buf(); - if package::PackageSpec::from_file(&candidate).is_ok() { - specs.push(candidate); - } - } - specs.sort(); - Ok(specs) -} - -fn spec_group_package_names(spec: &package::PackageSpec, group: &str) -> Vec { - let mut names = Vec::new(); - for output in spec.outputs() { - if spec - .dependencies_for_output(&output.name) - .groups - .iter() - .any(|candidate| candidate.eq_ignore_ascii_case(group)) - { - names.push(output.name); - } - } - if spec.builds_lib32_output() { - let lib32_name = spec.lib32_package_name(); - if spec - .dependencies_for_output(&lib32_name) - .groups - .iter() - .any(|candidate| candidate.eq_ignore_ascii_case(group)) - { - names.push(lib32_name); - } - } - names.sort(); - names.dedup(); - names -} - -fn collect_source_group_package_names(config: &config::Config, group: &str) -> Result> { - if !config.repo_clone_dir.exists() { - return Ok(Vec::new()); - } - - let mut packages = BTreeSet::new(); - for spec_path in scan_package_specs(&config.repo_clone_dir)? { - let spec = package::PackageSpec::from_file(&spec_path) - .with_context(|| format!("Failed to parse spec {}", spec_path.display()))?; - for package_name in spec_group_package_names(&spec, group) { - packages.insert(package_name); - } - } - Ok(packages.into_iter().collect()) -} - -fn collect_binary_group_package_names( - config: &config::Config, - rootfs: &Path, - group: &str, -) -> Result> { - let host_arch = std::env::consts::ARCH; - let mut binary_repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - let mut packages = BTreeSet::new(); - for (repo_name, repo_cfg) in binary_repos { - let matches = db::repo::find_binary_repo_packages_by_group( - repo_name, - repo_cfg, - rootfs, - &config.package_cache_dir, - group, - )?; - for record in matches { - packages.insert(record.name); - } - } - Ok(packages.into_iter().collect()) -} - -fn collect_install_group_package_names( - config: &config::Config, - rootfs: &Path, - group: &str, -) -> Result> { - let mut packages = BTreeSet::new(); - for package_name in collect_source_group_package_names(config, group)? { - packages.insert(package_name); - } - for package_name in collect_binary_group_package_names(config, rootfs, group)? { - packages.insert(package_name); - } - Ok(packages.into_iter().collect()) -} - -fn expand_install_requests_for_groups( - config: &config::Config, - rootfs: &Path, - requests: &[PathBuf], -) -> Result<(Vec, Vec)> { - let mut expanded = Vec::new(); - let mut explicit_groups = Vec::new(); - let mut seen = HashSet::new(); - - for request in requests { - if request.exists() { - let key = request.to_string_lossy().to_string(); - if seen.insert(key) { - expanded.push(request.clone()); - } - continue; - } - - let request_name = request.to_string_lossy().to_string(); - let group_packages = collect_install_group_package_names(config, rootfs, &request_name)?; - if group_packages.is_empty() { - if seen.insert(request_name.clone()) { - expanded.push(request.clone()); - } - continue; - } - - ui::info(format!( - "Expanding group '{}' ({} packages)...", - request_name, - group_packages.len() - )); - explicit_groups.push(request_name); - for package_name in group_packages { - if seen.insert(package_name.clone()) { - expanded.push(PathBuf::from(package_name)); - } - } - } - - Ok((expanded, explicit_groups)) -} - -fn expand_installed_group_targets( - db_path: &Path, - requests: &[String], -) -> Result<(Vec, Vec)> { - let mut expanded = Vec::new(); - let mut explicit_groups = Vec::new(); - let mut seen = HashSet::new(); - - for request in requests { - if db::is_installed_group(db_path, request)? { - let group_packages = db::get_packages_in_installed_group(db_path, request)?; - if !group_packages.is_empty() { - ui::info(format!( - "Expanding group '{}' ({} packages)...", - request, - group_packages.len() - )); - explicit_groups.push(request.clone()); - for package_name in group_packages { - if seen.insert(package_name.clone()) { - expanded.push(package_name); - } - } - continue; - } - } - - if seen.insert(request.clone()) { - expanded.push(request.clone()); - } - } - - Ok((expanded, explicit_groups)) -} - -fn human_bytes(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - let mut value = bytes as f64; - let mut unit = 0usize; - while value >= 1024.0 && unit + 1 < UNITS.len() { - value /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[unit]) - } else { - format!("{value:.1} {}", UNITS[unit]) - } -} - -fn binary_arch_from_filename(filename: &str) -> String { - let stem = filename - .strip_suffix(".depot.pkg.tar.zst") - .unwrap_or(filename); - let mut parts = stem.rsplitn(4, '-'); - parts - .next() - .filter(|s| !s.is_empty()) - .unwrap_or(std::env::consts::ARCH) - .to_string() -} - -fn merge_missing_dependencies(mut base: Vec, extra: Vec) -> Vec { - for dep in extra { - if !base.contains(&dep) { - base.push(dep); - } - } - base -} - -#[derive(Clone, Copy)] -struct UpdateCommandOptions<'a> { - rootfs: &'a Path, - no_deps: bool, - no_flags: bool, - cross_prefix: Option<&'a str>, - clean: bool, - dry_run: bool, - assume_yes: bool, - install_test_deps: bool, -} - -#[derive(Debug, Clone)] -enum UpdateOrigin { - Source { - repo_name: String, - path: PathBuf, - }, - Binary { - repo_name: String, - record: Box, - }, -} - -#[derive(Debug, Clone)] -struct UpdateCandidate { - installed_package: String, - candidate_package: String, - replaces_installed: bool, - installed_version: String, - installed_revision: u32, - installed_completed_at: Option, - candidate_version: String, - candidate_revision: u32, - candidate_completed_at: Option, - runtime_dependencies: Vec, - provides: Vec, - conflicts: Vec, - repo_priority: i32, - origin: UpdateOrigin, -} - -#[derive(Debug, Clone)] -struct SourceUpdateCandidate { - repo_name: String, - repo_priority: i32, - path: PathBuf, - completed_at: Option, - spec: package::PackageSpec, -} - -fn source_update_candidate_is_better( - candidate: &SourceUpdateCandidate, - current: &SourceUpdateCandidate, -) -> bool { - match compare_package_release( - &candidate.spec.package.version, - candidate.spec.package.revision, - ¤t.spec.package.version, - current.spec.package.revision, - ) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => match compare_completed_at(candidate.completed_at, current.completed_at) - { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => { - if candidate.repo_priority != current.repo_priority { - candidate.repo_priority < current.repo_priority - } else if candidate.repo_name != current.repo_name { - candidate.repo_name < current.repo_name - } else { - candidate.path < current.path - } - } - }, - } -} - -fn binary_update_candidate_is_better( - repo_name: &str, - repo_priority: i32, - record: &db::repo::BinaryRepoPackageRecord, - current_priority: i32, - current: &db::repo::BinaryRepoPackageRecord, -) -> bool { - match compare_package_release( - &record.version, - record.revision, - ¤t.version, - current.revision, - ) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => match compare_completed_at(record.completed_at, current.completed_at) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => { - if repo_priority != current_priority { - repo_priority < current_priority - } else if repo_name != current.repo_name { - repo_name < current.repo_name.as_str() - } else { - record.filename < current.filename - } - } - }, - } -} - -fn compare_package_release( - left_version: &str, - left_revision: u32, - right_version: &str, - right_revision: u32, -) -> Ordering { - compare_versions_for_updates(left_version, right_version) - .then_with(|| left_revision.cmp(&right_revision)) -} - -fn compare_completed_at(left: Option, right: Option) -> Ordering { - match (left, right) { - (Some(left), Some(right)) => left.cmp(&right), - (Some(_), None) => Ordering::Greater, - (None, Some(_)) => Ordering::Less, - (None, None) => Ordering::Equal, - } -} - -fn update_candidate_is_newer_than_installed( - candidate_version: &str, - candidate_revision: u32, - candidate_completed_at: Option, - installed_version: &str, - installed_revision: u32, - installed_completed_at: Option, -) -> bool { - match compare_package_release( - candidate_version, - candidate_revision, - installed_version, - installed_revision, - ) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => { - compare_completed_at(candidate_completed_at, installed_completed_at) - == Ordering::Greater - } - } -} - -fn path_modified_unix_timestamp(path: &Path) -> Result> { - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for {}", path.display()))?; - let modified = metadata - .modified() - .with_context(|| format!("Failed to read modification time for {}", path.display()))?; - Ok(Some(crate::metadata_time::system_time_to_unix(modified)?)) -} - -fn installed_package_completed_at( - installed: &db::InstalledPackageRecord, - db_path: &Path, - rootfs: &Path, -) -> Result> { - if installed.completed_at.is_some() { - return Ok(installed.completed_at); - } - - let mut latest = None; - for relative_path in db::get_package_files(db_path, &installed.name)? { - let path = rootfs.join(&relative_path); - if !path.exists() { - continue; - } - if let Some(modified) = path_modified_unix_timestamp(&path)? { - latest = Some(latest.map_or(modified, |current: i64| current.max(modified))); - } - } - - Ok(latest) -} - -fn update_origin_is_binary(origin: &UpdateOrigin) -> bool { - matches!(origin, UpdateOrigin::Binary { .. }) -} - -fn update_origin_label(origin: &UpdateOrigin) -> String { - match origin { - UpdateOrigin::Source { repo_name, path } => { - format!("source:{repo_name}:{}", path.display()) - } - UpdateOrigin::Binary { repo_name, record } => { - format!("binary:{repo_name}:{}", record.filename) - } - } -} - -fn update_candidate_is_preferred( - candidate: &UpdateCandidate, - current: &UpdateCandidate, - prefer_binary: bool, -) -> bool { - match compare_package_release( - &candidate.candidate_version, - candidate.candidate_revision, - ¤t.candidate_version, - current.candidate_revision, - ) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => match compare_completed_at( - candidate.candidate_completed_at, - current.candidate_completed_at, - ) { - Ordering::Greater => true, - Ordering::Less => false, - Ordering::Equal => match ( - update_origin_is_binary(&candidate.origin), - update_origin_is_binary(¤t.origin), - ) { - (true, false) => prefer_binary, - (false, true) => !prefer_binary, - _ => { - if candidate.repo_priority != current.repo_priority { - candidate.repo_priority < current.repo_priority - } else { - update_origin_label(&candidate.origin) - < update_origin_label(¤t.origin) - } - } - }, - }, - } -} - -fn candidate_request_path( - candidate: &UpdateCandidate, - config: &config::Config, - rootfs: &Path, -) -> Result { - match &candidate.origin { - UpdateOrigin::Source { path, .. } => Ok(path.clone()), - UpdateOrigin::Binary { repo_name, record } => { - let repo_cfg = config - .binary_repos - .get(repo_name) - .with_context(|| format!("Binary repo '{}' not found in config", repo_name))?; - db::repo::fetch_binary_package_archive( - repo_name, - repo_cfg, - rootfs, - record, - &config.package_cache_dir, - ) - } - } -} - -fn sync_source_repositories_for_update(config: &config::Config) -> Result<()> { - if config.repo_settings.prefer_binary { - return Ok(()); - } - - let mirrors = config.enabled_source_mirror_map(); - if mirrors.is_empty() { - return Ok(()); - } - - let mut repo_lock = locking::open_lock(config)?; - let repo_lock_path = locking::lock_path(config); - let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "update sync")?; - db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; - Ok(()) -} - -fn configured_source_scan_roots(config: &config::Config) -> Vec<(String, i32, PathBuf)> { - let mut roots = Vec::new(); - let mut repos: Vec<_> = config - .source_repos - .iter() - .filter(|(_, repo)| repo.enabled) - .collect(); - repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - for (repo_name, repo) in repos { - let repo_root = config.repo_clone_dir.join(repo_name); - if repo.subdirs.is_empty() { - roots.push((repo_name.clone(), repo.priority, repo_root)); - } else { - for subdir in &repo.subdirs { - roots.push((repo_name.clone(), repo.priority, repo_root.join(subdir))); - } - } - } - - roots -} - -fn collect_best_source_update_candidates( - config: &config::Config, - target_real_names: &HashSet, -) -> Result> { - let mut best: HashMap = HashMap::new(); - - for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { - if !root.exists() { - continue; - } - - for spec_path in scan_package_specs(&root)? { - let mut spec = package::PackageSpec::from_file(&spec_path)?; - spec.apply_config(config); - let stream_name = spec.package.effective_real_name().to_string(); - if !target_real_names.contains(&stream_name) { - continue; - } - - let candidate = SourceUpdateCandidate { - repo_name: repo_name.clone(), - repo_priority, - path: spec_path.clone(), - completed_at: path_modified_unix_timestamp(&spec_path)?, - spec, - }; - - let replace = match best.get(&stream_name) { - Some(current) => source_update_candidate_is_better(&candidate, current), - None => true, - }; - - if replace { - best.insert(stream_name, candidate); - } - } - } - - Ok(best) -} - -fn collect_best_source_replacement_candidates( - config: &config::Config, - target_packages: &HashSet, -) -> Result> { - let mut best: HashMap = HashMap::new(); - - for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { - if !root.exists() { - continue; - } - - for spec_path in scan_package_specs(&root)? { - let mut spec = package::PackageSpec::from_file(&spec_path)?; - spec.apply_config(config); - let candidate = SourceUpdateCandidate { - repo_name: repo_name.clone(), - repo_priority, - path: spec_path.clone(), - completed_at: path_modified_unix_timestamp(&spec_path)?, - spec, - }; - - for replaced in &candidate.spec.alternatives.replaces { - if !target_packages.contains(replaced) { - continue; - } - let replace = match best.get(replaced) { - Some(current) => source_update_candidate_is_better(&candidate, current), - None => true, - }; - if replace { - best.insert(replaced.clone(), candidate.clone()); - } - } - } - } - - Ok(best) -} - -fn collect_best_binary_update_candidates( - config: &config::Config, - rootfs: &Path, - target_real_names: &HashSet, -) -> Result> { - let mut best: HashMap = HashMap::new(); - let host_arch = std::env::consts::ARCH; - let mut repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - for (repo_name, repo_cfg) in repos { - let records = db::repo::list_binary_repo_packages( - repo_name, - repo_cfg, - rootfs, - &config.package_cache_dir, - ) - .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; - - for record in records { - let stream_name = record.effective_real_name().to_string(); - if !target_real_names.contains(&stream_name) { - continue; - } - - let replace = match best.get(&stream_name) { - Some((current_priority, current)) => binary_update_candidate_is_better( - repo_name, - repo_cfg.priority, - &record, - *current_priority, - current, - ), - None => true, - }; - - if replace { - best.insert(stream_name, (repo_cfg.priority, record)); - } - } - } - - Ok(best) -} - -fn collect_best_binary_replacement_candidates( - config: &config::Config, - rootfs: &Path, - target_packages: &HashSet, -) -> Result> { - let mut best: HashMap = HashMap::new(); - let host_arch = std::env::consts::ARCH; - let mut repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - for (repo_name, repo_cfg) in repos { - let records = db::repo::list_binary_repo_packages( - repo_name, - repo_cfg, - rootfs, - &config.package_cache_dir, - ) - .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; - - for record in records { - for replaced in &record.replaces { - if !target_packages.contains(replaced) { - continue; - } - let replace = match best.get(replaced) { - Some((current_priority, current)) => binary_update_candidate_is_better( - repo_name, - repo_cfg.priority, - &record, - *current_priority, - current, - ), - None => true, - }; - if replace { - best.insert(replaced.clone(), (repo_cfg.priority, record.clone())); - } - } - } - } - - Ok(best) -} - -fn select_update_candidate( - installed: &db::InstalledPackageRecord, - installed_completed_at: Option, - source_replacement_candidates: &HashMap, - binary_replacement_candidates: &HashMap, - source_candidates: &HashMap, - binary_candidates: &HashMap, - prefer_binary: bool, -) -> Option { - let mut best: Option = None; - let stream_name = installed.effective_real_name(); - - if let Some(candidate) = source_replacement_candidates.get(&installed.name) { - best = Some(UpdateCandidate { - installed_package: installed.name.clone(), - candidate_package: candidate.spec.package.name.clone(), - replaces_installed: true, - installed_version: installed.version.clone(), - installed_revision: installed.revision, - installed_completed_at, - candidate_version: candidate.spec.package.version.clone(), - candidate_revision: candidate.spec.package.revision, - candidate_completed_at: candidate.completed_at, - runtime_dependencies: candidate.spec.dependencies.runtime.clone(), - provides: candidate.spec.alternatives.provides.clone(), - conflicts: candidate.spec.alternatives.conflicts.clone(), - repo_priority: candidate.repo_priority, - origin: UpdateOrigin::Source { - repo_name: candidate.repo_name.clone(), - path: candidate.path.clone(), - }, - }); - } - - if let Some((repo_priority, record)) = binary_replacement_candidates.get(&installed.name) { - let binary_candidate = UpdateCandidate { - installed_package: installed.name.clone(), - candidate_package: record.name.clone(), - replaces_installed: true, - installed_version: installed.version.clone(), - installed_revision: installed.revision, - installed_completed_at, - candidate_version: record.version.clone(), - candidate_revision: record.revision, - candidate_completed_at: record.completed_at, - runtime_dependencies: record.runtime_dependencies.clone(), - provides: record.provides.clone(), - conflicts: record.conflicts.clone(), - repo_priority: *repo_priority, - origin: UpdateOrigin::Binary { - repo_name: record.repo_name.clone(), - record: Box::new(record.clone()), - }, - }; - - if best.as_ref().is_none_or(|current| { - update_candidate_is_preferred(&binary_candidate, current, prefer_binary) - }) { - best = Some(binary_candidate); - } - } - - if best.is_some() { - return best; - } - - if let Some(candidate) = source_candidates.get(stream_name) - && update_candidate_is_newer_than_installed( - &candidate.spec.package.version, - candidate.spec.package.revision, - candidate.completed_at, - &installed.version, - installed.revision, - installed_completed_at, - ) - { - best = Some(UpdateCandidate { - installed_package: installed.name.clone(), - candidate_package: candidate.spec.package.name.clone(), - replaces_installed: false, - installed_version: installed.version.clone(), - installed_revision: installed.revision, - installed_completed_at, - candidate_version: candidate.spec.package.version.clone(), - candidate_revision: candidate.spec.package.revision, - candidate_completed_at: candidate.completed_at, - runtime_dependencies: candidate.spec.dependencies.runtime.clone(), - provides: candidate.spec.alternatives.provides.clone(), - conflicts: candidate.spec.alternatives.conflicts.clone(), - repo_priority: candidate.repo_priority, - origin: UpdateOrigin::Source { - repo_name: candidate.repo_name.clone(), - path: candidate.path.clone(), - }, - }); - } - - if let Some((repo_priority, record)) = binary_candidates.get(stream_name) - && update_candidate_is_newer_than_installed( - &record.version, - record.revision, - record.completed_at, - &installed.version, - installed.revision, - installed_completed_at, - ) - { - let binary_candidate = UpdateCandidate { - installed_package: installed.name.clone(), - candidate_package: record.name.clone(), - replaces_installed: false, - installed_version: installed.version.clone(), - installed_revision: installed.revision, - installed_completed_at, - candidate_version: record.version.clone(), - candidate_revision: record.revision, - candidate_completed_at: record.completed_at, - runtime_dependencies: record.runtime_dependencies.clone(), - provides: record.provides.clone(), - conflicts: record.conflicts.clone(), - repo_priority: *repo_priority, - origin: UpdateOrigin::Binary { - repo_name: record.repo_name.clone(), - record: Box::new(record.clone()), - }, - }; - - if best.as_ref().is_none_or(|current| { - update_candidate_is_preferred(&binary_candidate, current, prefer_binary) - }) { - best = Some(binary_candidate); - } - } - - best -} - -fn collect_update_candidates( - config: &config::Config, - rootfs: &Path, - requested_packages: &[String], -) -> Result> { - let db_path = config.installed_db_path(rootfs); - let installed = db::list_installed_package_records(&db_path)?; - if installed.is_empty() { - return Ok(Vec::new()); - } - - let mut installed_by_real_name: HashMap> = - HashMap::new(); - for record in &installed { - installed_by_real_name - .entry(record.effective_real_name().to_string()) - .or_default() - .push(record); - } - - let active_by_real_name: HashMap = installed_by_real_name - .iter() - .filter_map(|(real_name, records)| { - select_primary_installed_record(records.iter().copied()) - .cloned() - .map(|record| (real_name.clone(), record)) - }) - .collect(); - - let target_real_names: HashSet = if requested_packages.is_empty() { - active_by_real_name.keys().cloned().collect() - } else { - let mut targets = HashSet::new(); - for package in requested_packages { - if let Some(record) = installed.iter().find(|record| record.name == *package) { - targets.insert(record.effective_real_name().to_string()); - } else if active_by_real_name.contains_key(package) { - targets.insert(package.clone()); - } else { - ui::warn(format!("Package '{}' is not installed; skipping", package)); - } - } - targets - }; - let target_package_names: HashSet = if requested_packages.is_empty() { - active_by_real_name - .values() - .map(|record| record.name.clone()) - .collect() - } else { - let mut targets = HashSet::new(); - for package in requested_packages { - if let Some(record) = installed.iter().find(|record| record.name == *package) { - targets.insert(record.name.clone()); - } else if let Some(record) = active_by_real_name.get(package) { - targets.insert(record.name.clone()); - } - } - targets - }; - - let source_candidates = if config.repo_settings.prefer_binary { - HashMap::new() - } else { - collect_best_source_update_candidates(config, &target_real_names)? - }; - let source_replacement_candidates = if config.repo_settings.prefer_binary { - HashMap::new() - } else { - collect_best_source_replacement_candidates(config, &target_package_names)? - }; - let binary_candidates = - collect_best_binary_update_candidates(config, rootfs, &target_real_names)?; - let binary_replacement_candidates = - collect_best_binary_replacement_candidates(config, rootfs, &target_package_names)?; - - let mut updates = Vec::new(); - let mut targets: Vec<_> = target_real_names.into_iter().collect(); - targets.sort(); - for target in targets { - let Some(installed) = active_by_real_name.get(&target) else { - continue; - }; - let installed_completed_at = installed_package_completed_at(installed, &db_path, rootfs)?; - if let Some(candidate) = select_update_candidate( - installed, - installed_completed_at, - &source_replacement_candidates, - &binary_replacement_candidates, - &source_candidates, - &binary_candidates, - config.repo_settings.prefer_binary, - ) { - updates.push(candidate); - } - } - - Ok(updates) -} - -fn collect_missing_update_dependencies( - candidates: &[UpdateCandidate], - db_path: &Path, -) -> Result> { - let mut planned_provides = HashSet::new(); - for candidate in candidates { - planned_provides.insert(candidate.candidate_package.clone()); - for provide in &candidate.provides { - planned_provides.insert(provide.clone()); - } - } - - let mut missing = Vec::new(); - for candidate in candidates { - for dep in &candidate.runtime_dependencies { - if planned_provides.contains(deps::dep_name(dep)) { - continue; - } - if deps::is_dep_satisfied_in_db(dep, db_path)? { - continue; - } - if !missing.contains(dep) { - missing.push(dep.clone()); - } - } - } - - Ok(missing) -} - -fn run_update_install_command( - program: &Path, - request: &Path, - options: UpdateCommandOptions<'_>, -) -> Result<()> { - run_install_command_with_program( - program, - &[request.to_path_buf()], - options.rootfs, - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - lib32_only: false, - install_test_deps: options.install_test_deps, - install_context: Some(INSTALL_CONTEXT_UPDATE), - dep_chain: None, - }, - ) -} - -fn confirm_update_replacements(candidates: &[UpdateCandidate]) -> Result<()> { - for candidate in candidates { - if !candidate.replaces_installed { - continue; - } - if !ui::prompt_yes_no( - &format!( - "replace {} with {}?", - candidate.installed_package, candidate.candidate_package - ), - true, - )? { - anyhow::bail!( - "Replacement declined: {} -> {}", - candidate.installed_package, - candidate.candidate_package - ); - } - } - Ok(()) -} - -fn run_update_command( - packages: &[String], - config: &config::Config, - options: UpdateCommandOptions<'_>, -) -> Result<()> { - let updates = collect_update_candidates(config, options.rootfs, packages)?; - if updates.is_empty() { - ui::info("All installed packages are up to date."); - return Ok(()); - } - - let targets: Vec = updates - .iter() - .map(|candidate| { - let summary = format!( - "{} v{}-{} -> {} v{}-{}", - candidate.installed_package, - candidate.installed_version, - candidate.installed_revision, - candidate.candidate_package, - candidate.candidate_version, - candidate.candidate_revision - ); - if candidate.installed_version == candidate.candidate_version - && candidate.installed_revision == candidate.candidate_revision - && compare_completed_at( - candidate.candidate_completed_at, - candidate.installed_completed_at, - ) == Ordering::Greater - { - format!("{summary} (newer UTC build timestamp)") - } else { - summary - } - }) - .collect(); - - let conflict_subjects: Vec<_> = updates - .iter() - .map(|candidate| InstallConflictSubject { - package: candidate.candidate_package.clone(), - provides: candidate.provides.clone(), - conflicts: candidate.conflicts.clone(), - }) - .collect(); - validate_no_transaction_conflicts(&conflict_subjects)?; - - ui::info(format!("{} package(s) can be updated:", updates.len())); - for target in &targets { - ui::info(format!(" {}", target)); - } - - let db_path = config.installed_db_path(options.rootfs); - let missing_deps = if options.no_deps { - Vec::new() - } else { - collect_missing_update_dependencies(&updates, &db_path)? - }; - if !missing_deps.is_empty() { - ui::info(format!( - "New dependencies required by updates: {}", - missing_deps.join(", ") - )); - } - - if !options.dry_run && !ui::prompt_package_action("update", &targets, true)? { - anyhow::bail!("Aborted"); - } - - if !options.dry_run { - confirm_update_replacements(&updates)?; - } - - resolve_installed_conflicts_for_subjects( - &conflict_subjects, - options.rootfs, - config, - options.dry_run, - )?; - - if !missing_deps.is_empty() { - let dep_plan = planner::build_dependency_install_plan( - config, - options.rootfs, - &missing_deps, - planner::PlannerOptions { - assume_yes: options.assume_yes, - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root: None, - include_test_deps: options.install_test_deps, - lib32_only_requested_specs: false, - }, - )?; - print_plan_summary(&dep_plan); - - if options.dry_run { - ui::info("Dry run enabled, stopping before dependency installation/update."); - return Ok(()); - } - - execute_install_plan_with_child_commands( - &dep_plan, - options.rootfs, - config, - InstallPlanExecutionOptions { - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - dry_run: false, - confirm_installation: false, - lib32_only_requested_specs: false, - install_test_deps: options.install_test_deps, - }, - )?; - } else if options.dry_run { - ui::info("Dry run enabled, stopping before update."); - return Ok(()); - } - - let exe = std::env::current_exe().context("Failed to locate depot executable")?; - for (idx, candidate) in updates.iter().enumerate() { - ui::info(format!( - "[{}/{}] updating {} v{}-{} -> {} v{}-{}", - idx + 1, - updates.len(), - candidate.installed_package, - candidate.installed_version, - candidate.installed_revision, - candidate.candidate_package, - candidate.candidate_version, - candidate.candidate_revision - )); - let request = candidate_request_path(candidate, config, options.rootfs)?; - run_update_install_command(&exe, &request, options)?; - } - - if options.clean { - clean_build_workspace(config)?; - } - - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct VersionPattern { - prefix: String, - suffix: String, -} - -#[derive(Debug, Clone)] -enum CheckStatus { - UpdateAvailable { latest: String, source: String }, - UpToDate { source: String }, - Unknown { reason: String }, -} - -#[derive(Debug, Clone)] -struct ArchiveListingProbe { - listing_url: String, - patterns: Vec, -} - -fn strip_known_archive_suffixes(input: &str) -> &str { - for suffix in [ - ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".zip", ".tar", - ".git", - ] { - if let Some(stripped) = input.strip_suffix(suffix) { - return stripped; - } - } - input -} - -fn version_pattern_from_template( - template: &str, - strip_archive_suffix: bool, -) -> Option { - let (prefix, suffix) = template.split_once("$version")?; - let suffix = if strip_archive_suffix { - strip_known_archive_suffixes(suffix) - } else { - suffix - }; - Some(VersionPattern { - prefix: prefix.to_string(), - suffix: suffix.to_string(), - }) -} - -fn extract_version_patterns(raw: &str) -> Vec { - let mut patterns = HashSet::new(); - let mut start = 0usize; - - while let Some(rel_idx) = raw[start..].find("$version") { - let idx = start + rel_idx; - let prefix_start = raw[..idx] - .rfind(['/', '#', '?', '&', '=']) - .map(|pos| pos + 1) - .unwrap_or(0); - let suffix_end = raw[idx + "$version".len()..] - .find(['/', '#', '?', '&', '=']) - .map(|pos| idx + "$version".len() + pos) - .unwrap_or(raw.len()); - if let Some(pattern) = version_pattern_from_template(&raw[prefix_start..suffix_end], true) { - patterns.insert(pattern); - } - start = idx + "$version".len(); - } - - let mut out: Vec<_> = patterns.into_iter().collect(); - out.sort_by(|a, b| { - a.prefix - .cmp(&b.prefix) - .then_with(|| a.suffix.cmp(&b.suffix)) - }); - out -} - -fn is_version_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '+' | '-') -} - -fn looks_like_version(candidate: &str) -> bool { - !candidate.is_empty() - && candidate.len() <= 64 - && candidate.chars().all(is_version_char) - && candidate.chars().any(|ch| ch.is_ascii_digit()) -} - -fn match_version_pattern<'a>(value: &'a str, pattern: &VersionPattern) -> Option<&'a str> { - if !value.starts_with(&pattern.prefix) || !value.ends_with(&pattern.suffix) { - return None; - } - - let start = pattern.prefix.len(); - let end = value.len().saturating_sub(pattern.suffix.len()); - if end <= start { - return None; - } - - let candidate = &value[start..end]; - looks_like_version(candidate).then_some(candidate) -} - -fn compare_version_fallback(left: &str, right: &str) -> Ordering { - let left = left.as_bytes(); - let right = right.as_bytes(); - let mut li = 0usize; - let mut ri = 0usize; - - while li < left.len() && ri < right.len() { - let lch = left[li] as char; - let rch = right[ri] as char; - let l_digit = lch.is_ascii_digit(); - let r_digit = rch.is_ascii_digit(); - - if l_digit && r_digit { - let l_start = li; - let r_start = ri; - while li < left.len() && (left[li] as char).is_ascii_digit() { - li += 1; - } - while ri < right.len() && (right[ri] as char).is_ascii_digit() { - ri += 1; - } - - let l_raw = std::str::from_utf8(&left[l_start..li]).unwrap_or_default(); - let r_raw = std::str::from_utf8(&right[r_start..ri]).unwrap_or_default(); - let l_trimmed = l_raw.trim_start_matches('0'); - let r_trimmed = r_raw.trim_start_matches('0'); - - let l_cmp = if l_trimmed.is_empty() { "0" } else { l_trimmed }; - let r_cmp = if r_trimmed.is_empty() { "0" } else { r_trimmed }; - match l_cmp - .len() - .cmp(&r_cmp.len()) - .then_with(|| l_cmp.cmp(r_cmp)) - .then_with(|| l_raw.len().cmp(&r_raw.len())) - .then_with(|| l_raw.cmp(r_raw)) - { - Ordering::Equal => {} - non_eq => return non_eq, - } - continue; - } - - match lch - .to_ascii_lowercase() - .cmp(&rch.to_ascii_lowercase()) - .then_with(|| lch.cmp(&rch)) - { - Ordering::Equal => { - li += 1; - ri += 1; - } - non_eq => return non_eq, - } - } - - left.len().cmp(&right.len()) -} - -fn canonical_update_version(raw: &str) -> &str { - raw.strip_prefix('v') - .filter(|rest| rest.chars().next().is_some_and(|ch| ch.is_ascii_digit())) - .unwrap_or(raw) -} - -fn collapse_date_like_components(value: &str) -> String { - let parts: Vec<_> = value.split('.').collect(); - if parts.len() < 3 { - return value.to_string(); - } - if !parts[0].chars().all(|ch| ch.is_ascii_digit()) - || !parts[1].chars().all(|ch| ch.is_ascii_digit()) - || !parts[2].chars().all(|ch| ch.is_ascii_digit()) - || parts[0].len() != 4 - || parts[1].len() != 2 - || parts[2].len() != 2 - { - return value.to_string(); - } - - let mut collapsed = vec![format!("{}{}{}", parts[0], parts[1], parts[2])]; - collapsed.extend(parts.into_iter().skip(3).map(str::to_string)); - collapsed.join(".") -} - -fn normalize_comparable_version(raw: &str) -> String { - let mut value = canonical_update_version(raw).trim().to_ascii_lowercase(); - if let Some(first_digit) = value.find(|ch: char| ch.is_ascii_digit()) - && first_digit > 0 - && value[..first_digit].chars().all(|ch| !ch.is_ascii_digit()) - { - value = value[first_digit..].to_string(); - } - - let mut normalized = String::with_capacity(value.len()); - let mut last_was_dot = false; - for ch in value.chars() { - let mapped = match ch { - '_' | '-' | '+' => '.', - ch if ch.is_ascii_alphanumeric() || ch == '.' => ch, - _ => continue, - }; - if mapped == '.' { - if normalized.is_empty() || last_was_dot { - continue; - } - last_was_dot = true; - } else { - last_was_dot = false; - } - normalized.push(mapped); - } - while normalized.ends_with('.') { - normalized.pop(); - } - - collapse_date_like_components(&normalized) -} - -fn numeric_component_count(value: &str) -> usize { - let mut count = 0usize; - let mut in_digits = false; - for ch in value.chars() { - if ch.is_ascii_digit() { - if !in_digits { - count += 1; - in_digits = true; - } - } else { - in_digits = false; - } - } - count -} - -fn first_numeric_component_len(value: &str) -> Option { - let start = value.find(|ch: char| ch.is_ascii_digit())?; - Some( - value[start..] - .chars() - .take_while(|ch| ch.is_ascii_digit()) - .count(), - ) -} - -fn is_prerelease_version(value: &str) -> bool { - let normalized = normalize_comparable_version(value); - [ - "alpha", "beta", "rc", "pre", "preview", "snapshot", "nightly", "dev", - ] - .into_iter() - .any(|marker| normalized.contains(marker)) -} - -fn normalize_candidate_version(current: &str, candidate: &str) -> Option { - let normalized = normalize_comparable_version(candidate); - if !looks_like_version(&normalized) { - return None; - } - if !is_prerelease_version(current) && is_prerelease_version(candidate) { - return None; - } - - let current_normalized = normalize_comparable_version(current); - if numeric_component_count(¤t_normalized) >= 2 - && numeric_component_count(&normalized) == 1 - && first_numeric_component_len(¤t_normalized).is_some_and(|len| len <= 4) - { - return None; - } - - Some(normalized) -} - -fn compare_versions_for_updates(left: &str, right: &str) -> Ordering { - let left = normalize_comparable_version(left); - let right = normalize_comparable_version(right); - if let (Ok(left), Ok(right)) = ( - semver::Version::parse(&left), - semver::Version::parse(&right), - ) { - match left.cmp(&right) { - Ordering::Equal => {} - non_eq => return non_eq, - } - } - - if left.len() == 8 - && right.len() == 8 - && left.chars().all(|ch| ch.is_ascii_digit()) - && right.chars().all(|ch| ch.is_ascii_digit()) - { - return left.cmp(&right); - } - - compare_version_fallback(&left, &right) -} - -fn best_newer_version<'a>( - current: &str, - candidates: impl IntoIterator, -) -> Option { - let mut best: Option = None; - for candidate in candidates { - let Some(candidate) = normalize_candidate_version(current, candidate) else { - continue; - }; - if compare_versions_for_updates(&candidate, current) != Ordering::Greater { - continue; - } - if let Some(existing) = best.as_deref() - && compare_versions_for_updates(&candidate, existing) != Ordering::Greater - { - continue; - } - best = Some(candidate); - } - best -} - -fn remote_git_repository_from_source_url(expanded_url: &str) -> Option { - if let Some((base, _)) = source_git_url_parts(expanded_url) { - return Some(base); - } - - let parsed = Url::parse(expanded_url).ok()?; - let host = parsed.host_str()?; - let segments: Vec<_> = parsed.path_segments()?.collect(); - if segments.len() < 3 { - return None; - } - - let keyword_idx = segments - .iter() - .position(|segment| matches!(*segment, "releases" | "archive"))?; - let repo_segments = if keyword_idx > 0 && segments.get(keyword_idx - 1) == Some(&"-") { - &segments[..keyword_idx - 1] - } else { - &segments[..keyword_idx] - }; - if repo_segments.len() < 2 { - return None; - } - - Some(format!( - "{}://{}/{}.git", - parsed.scheme(), - host, - repo_segments - .iter() - .enumerate() - .map(|(idx, segment)| { - if idx + 1 == repo_segments.len() { - segment.strip_suffix(".git").unwrap_or(segment) - } else { - segment - } - }) - .collect::>() - .join("/") - )) -} - -fn source_git_url_parts(url: &str) -> Option<(String, String)> { - if let Some((base, rev)) = url.split_once('#') { - let lower = base.to_ascii_lowercase(); - let is_archive = lower.ends_with(".tar.gz") - || lower.ends_with(".tgz") - || lower.ends_with(".tar.xz") - || lower.ends_with(".txz") - || lower.ends_with(".tar.bz2") - || lower.ends_with(".tbz2") - || lower.ends_with(".zip") - || lower.ends_with(".tar"); - if is_archive { - return None; - } - let resolved_rev = if rev.trim().is_empty() { "HEAD" } else { rev }; - return Some((base.to_string(), resolved_rev.to_string())); - } - - url.to_ascii_lowercase() - .ends_with(".git") - .then(|| (url.to_string(), "HEAD".to_string())) -} - -fn list_remote_refs(url: &str) -> Result> { - crate::interrupts::check()?; - let mut remote = git2::Remote::create_detached(url) - .with_context(|| format!("Failed to create detached git remote for {}", url))?; - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.sideband_progress(|_| !crate::interrupts::was_interrupted()); - callbacks.transfer_progress(|_| !crate::interrupts::was_interrupted()); - - let refs = { - let connection = match remote.connect_auth(Direction::Fetch, Some(callbacks), None) { - Ok(connection) => connection, - Err(_err) if crate::interrupts::was_interrupted() => { - anyhow::bail!("Interrupted by Ctrl-C while checking {}", url); - } - Err(err) => { - return Err(err) - .with_context(|| format!("Failed to connect to git remote {}", url)); - } - }; - crate::interrupts::check()?; - let refs = connection - .list() - .with_context(|| format!("Failed to list refs for git remote {}", url))? - .iter() - .map(|head| head.name().trim_end_matches("^{}").to_string()) - .collect(); - drop(connection); - refs - }; - remote.disconnect().ok(); - Ok(refs) -} - -fn candidate_versions_from_refs(refs: &[String], patterns: &[VersionPattern]) -> Vec { - let mut versions = HashSet::new(); - - for name in refs { - let Some(short) = name.strip_prefix("refs/tags/") else { - continue; - }; - - for pattern in patterns { - if let Some(candidate) = match_version_pattern(short, pattern) { - versions.insert(candidate.to_string()); - } - } - } - - let mut out: Vec<_> = versions.into_iter().collect(); - out.sort_by(|a, b| compare_versions_for_updates(a, b)); - out -} - -fn archive_listing_probe(raw_url: &str, expanded_url: &str) -> Option { - let raw = Url::parse(raw_url).ok()?; - let expanded = Url::parse(expanded_url).ok()?; - if !matches!(expanded.scheme(), "http" | "https") { - return None; - } - - let raw_segments: Vec<_> = raw.path_segments()?.collect(); - let expanded_segments: Vec<_> = expanded.path_segments()?.collect(); - if raw_segments.len() != expanded_segments.len() { - return None; - } - - let first_version_idx = raw_segments - .iter() - .position(|segment| segment.contains("$version"))?; - let pattern = version_pattern_from_template(raw_segments[first_version_idx], false)?; - - let mut listing_url = expanded.clone(); - let listing_path = if first_version_idx == 0 { - "/".to_string() - } else { - format!("/{}/", expanded_segments[..first_version_idx].join("/")) - }; - listing_url.set_path(&listing_path); - listing_url.set_query(None); - listing_url.set_fragment(None); - - Some(ArchiveListingProbe { - listing_url: listing_url.to_string(), - patterns: vec![pattern], - }) -} - -fn archive_listing_tokens(body: &str) -> HashSet { - let mut tokens = HashSet::new(); - for token in body.split(|ch: char| { - ch.is_ascii_whitespace() || matches!(ch, '"' | '\'' | '<' | '>' | '(' | ')' | '[' | ']') - }) { - let token = token - .split_once('?') - .map(|(value, _)| value) - .unwrap_or(token) - .split_once('#') - .map(|(value, _)| value) - .unwrap_or(token) - .trim_matches(|ch: char| matches!(ch, ',' | ';' | '=')) - .trim_end_matches('/'); - if token.is_empty() { - continue; - } - let basename = token.rsplit('/').next().unwrap_or(token); - if !basename.is_empty() { - tokens.insert(basename.to_string()); - } - } - tokens -} - -fn candidate_versions_from_listing(body: &str, patterns: &[VersionPattern]) -> Vec { - let mut versions = HashSet::new(); - for token in archive_listing_tokens(body) { - for pattern in patterns { - if let Some(candidate) = match_version_pattern(&token, pattern) { - versions.insert(candidate.to_string()); - } - } - } - - let mut out: Vec<_> = versions.into_iter().collect(); - out.sort_by(|a, b| compare_versions_for_updates(a, b)); - out -} - -fn list_archive_versions(probe: &ArchiveListingProbe) -> Result> { - crate::interrupts::check()?; - let client = source::build_blocking_client( - &format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Some(Duration::from_secs(20)), - )?; - let response = client - .get(&probe.listing_url) - .send() - .with_context(|| format!("Failed to fetch archive index {}", probe.listing_url))?; - if !response.status().is_success() { - anyhow::bail!( - "archive index {} returned {}", - probe.listing_url, - response.status() - ); - } - let body = response - .text() - .with_context(|| format!("Failed to read archive index {}", probe.listing_url))?; - crate::interrupts::check()?; - - let candidates = candidate_versions_from_listing(&body, &probe.patterns); - if candidates.is_empty() { - anyhow::bail!("no matching archive entries found in {}", probe.listing_url); - } - Ok(candidates) -} - -fn source_check_status( - spec: &package::PackageSpec, - source: &package::Source, -) -> Result { - let patterns = extract_version_patterns(&source.url); - if patterns.is_empty() { - anyhow::bail!("source URL does not contain $version"); - } - - let expanded_url = spec.expand_vars(&source.url); - let mut reasons = Vec::new(); - - let (candidates, source_label) = if let Some(repo_url) = - remote_git_repository_from_source_url(&expanded_url) - { - match list_remote_refs(&repo_url).map(|refs| candidate_versions_from_refs(&refs, &patterns)) - { - Ok(candidates) if !candidates.is_empty() => { - (candidates, format!("git tags {}", repo_url)) - } - Ok(_) => { - reasons.push(format!("no matching git tags found in {}", repo_url)); - if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { - let candidates = list_archive_versions(&probe)?; - (candidates, format!("archive index {}", probe.listing_url)) - } else { - anyhow::bail!("{}", reasons.remove(0)); - } - } - Err(err) => { - reasons.push(err.to_string()); - if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { - match list_archive_versions(&probe) { - Ok(candidates) => { - (candidates, format!("archive index {}", probe.listing_url)) - } - Err(archive_err) => { - reasons.push(archive_err.to_string()); - anyhow::bail!("{}", reasons.join("; ")); - } - } - } else { - anyhow::bail!("{}", reasons.remove(0)); - } - } - } - } else if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { - let candidates = list_archive_versions(&probe)?; - (candidates, format!("archive index {}", probe.listing_url)) - } else { - anyhow::bail!( - "could not derive a git remote or archive index from {}", - expanded_url - ); - }; - - if let Some(latest) = - best_newer_version(&spec.package.version, candidates.iter().map(String::as_str)) - { - Ok(CheckStatus::UpdateAvailable { - latest, - source: source_label, - }) - } else { - Ok(CheckStatus::UpToDate { - source: source_label, - }) - } -} - -fn check_package_spec(spec_path: &Path) -> CheckStatus { - let spec = match package::PackageSpec::from_file(spec_path) { - Ok(spec) => spec, - Err(err) => { - return CheckStatus::Unknown { - reason: err.to_string(), - }; - } - }; - - let mut best_update: Option<(String, String)> = None; - let mut last_up_to_date_source: Option = None; - let mut reasons = Vec::new(); - - for source in spec.sources() { - if let Err(err) = crate::interrupts::check() { - return CheckStatus::Unknown { - reason: err.to_string(), - }; - } - match source_check_status(&spec, source) { - Ok(CheckStatus::UpdateAvailable { latest, source }) => { - let replace = match &best_update { - Some((current_best, _)) => { - compare_versions_for_updates(&latest, current_best) == Ordering::Greater - } - None => true, - }; - if replace { - best_update = Some((latest, source)); - } - } - Ok(CheckStatus::UpToDate { source }) => { - if last_up_to_date_source.is_none() { - last_up_to_date_source = Some(source); - } - } - Ok(CheckStatus::Unknown { reason }) => reasons.push(reason), - Err(err) => reasons.push(err.to_string()), - } - } - - if let Some((latest, source)) = best_update { - return CheckStatus::UpdateAvailable { latest, source }; - } - if let Some(source) = last_up_to_date_source { - return CheckStatus::UpToDate { source }; - } - - CheckStatus::Unknown { - reason: reasons - .into_iter() - .next() - .unwrap_or_else(|| "no versioned sources found".to_string()), - } -} - -fn run_check_command(dir: &Path) -> Result<()> { - let scan_root = dir - .canonicalize() - .with_context(|| format!("Failed to resolve check root {}", dir.display()))?; - let specs = scan_package_specs(&scan_root)?; - if specs.is_empty() { - ui::info(format!( - "No depot package specs found under {}", - scan_root.display() - )); - return Ok(()); - } - - let verbose = std::env::var_os("DEPOT_CHECK_VERBOSE").is_some(); - let mut updates = 0usize; - let mut up_to_date = 0usize; - let mut skipped = Vec::new(); - - for spec_path in specs { - crate::interrupts::check()?; - let spec = match package::PackageSpec::from_file(&spec_path) { - Ok(spec) => spec, - Err(err) => { - skipped.push(format!( - "{} could not be loaded: {}", - spec_path.display(), - err, - )); - continue; - } - }; - match check_package_spec(&spec_path) { - CheckStatus::UpdateAvailable { latest, source } => { - updates += 1; - ui::warn(format!( - "{} {} -> {} [{}] ({})", - spec.package.name, - spec.package.version, - latest, - source, - spec_path.display() - )); - } - CheckStatus::UpToDate { source } => { - up_to_date += 1; - if verbose { - ui::info(format!( - "{} {} is up to date [{}] ({})", - spec.package.name, - spec.package.version, - source, - spec_path.display() - )); - } - } - CheckStatus::Unknown { reason } => { - skipped.push(format!( - "{} {} could not be checked: {} ({})", - spec.package.name, - spec.package.version, - reason, - spec_path.display() - )); - } - } - } - - if verbose { - for entry in &skipped { - ui::warn(entry); - } - } else if !skipped.is_empty() { - ui::warn(format!( - "Skipped {} package(s) that could not be checked; set DEPOT_CHECK_VERBOSE=1 for per-package reasons.", - skipped.len() - )); - } - - ui::info(format!( - "Check summary: {} package(s), {} update(s), {} up to date, {} skipped", - updates + up_to_date + skipped.len(), - updates, - up_to_date, - skipped.len() - )); - - Ok(()) -} - fn print_plan_summary(plan: &planner::ExecutionPlan) { let summary = plan.summary(); ui::info(format!( @@ -5211,6 +2404,13 @@ fn run_direct_install_request( Ok(true) } +mod build_cmd; +mod check; +mod install_cmd; +mod misc; +mod repo; +mod update; + pub fn run(cli: Cli) -> Result<()> { crate::interrupts::install()?; crate::interrupts::reset(); @@ -5237,4196 +2437,28 @@ pub fn run(cli: Cli) -> Result<()> { | Commands::MakeSpec(_) | Commands::Internal(_) => false, }; + match cli.command { - Commands::Install(InstallArgs { - rootfs_args, - prompt_args, - build_exec_args, - lib32_args, - spec_or_archive, - spec, - }) => { - let rootfs = rootfs_args.rootfs; - let yes = prompt_args.yes; - let no_deps = build_exec_args.no_deps; - let no_flags = build_exec_args.no_flags; - let cross_prefix = build_exec_args.cross_prefix; - let clean = build_exec_args.clean; - let dry_run = build_exec_args.dry_run; - let lib32_only = lib32_args.lib32_only; - let install_requests = match spec { - Some(spec_path) => vec![spec_path], - None => spec_or_archive, - }; - - // 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_requests, explicit_groups) = - expand_install_requests_for_groups(&config, &rootfs, &install_requests)?; - 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(); - let mut direct_requests = Vec::new(); - - if no_deps { - direct_requests = install_requests; - } else { - for request in install_requests { - if is_archive_install_request(&request) { - direct_requests.push(request); - continue; - } - if request.exists() { - planned_spec_paths.push(request.clone()); - planned_targets.push(planner::InstallTarget::SpecPath(request)); - } else { - planned_targets.push(planner::InstallTarget::PackageName( - request.to_string_lossy().to_string(), - )); - } - } - } - - let mut ran_plan_mode = false; - if !planned_targets.is_empty() { - ran_plan_mode = true; - let planner_opts = planner::PlannerOptions { - assume_yes: yes, - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root: shared_local_sibling_root(&planned_spec_paths), - include_test_deps: install_test_deps, - lib32_only_requested_specs: lib32_only, - }; - let plan = if planned_targets.len() == 1 { - planner::build_install_plan( - &config, - &rootfs, - planned_targets[0].clone(), - planner_opts, - )? - } else { - planner::build_install_plan_for_targets( - &config, - &rootfs, - &planned_targets, - planner_opts, - )? - }; - print_plan_summary(&plan); - execute_install_plan_with_child_commands( - &plan, - &rootfs, - &config, - InstallPlanExecutionOptions { - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - confirm_installation: true, - lib32_only_requested_specs: lib32_only, - install_test_deps, - }, - )?; - } - - let mut ran_direct_install = false; - let direct_install_options = DirectInstallOptions { - rootfs: &rootfs, - no_deps, - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - lib32_only, - install_test_deps, - }; - if direct_requests.len() > 1 - && direct_requests - .iter() - .all(|request| is_archive_install_request(request)) - { - ran_direct_install |= run_direct_archive_install_requests( - direct_install_options, - &config, - &direct_requests, - true, - )?; - } else { - for request in direct_requests { - ran_direct_install |= - run_direct_install_request(direct_install_options, &config, request)?; - } - } - if ran_direct_install { - install::scripts::run_deferred_hooks_if_possible(&rootfs)?; - } - - if !dry_run && !explicit_groups.is_empty() { - db::record_installed_groups(&config.installed_db_path(&rootfs), &explicit_groups)?; - } - - if clean && (ran_plan_mode || ran_direct_install) { - clean_build_workspace(&config)?; - } - } - Commands::Remove(RemoveArgs { - rootfs_args, - package, - .. - }) => { - let rootfs = rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let mut remove_lock = locking::open_lock(&config)?; - let remove_lock_path = locking::lock_path(&config); - let _remove_lock_guard = - locking::try_write(&mut remove_lock, &remove_lock_path, "remove")?; - let db_path = config.installed_db_path(&rootfs); - let (removal_targets, explicit_groups) = - expand_installed_group_targets(&db_path, std::slice::from_ref(&package))?; - if !ui::prompt_package_action("removal", &removal_targets, true)? { - anyhow::bail!("Aborted"); - } - for target in &removal_targets { - remove_installed_package_with_hooks(target, &rootfs, &config)?; - } - for group in explicit_groups { - db::remove_installed_group(&db_path, &group)?; - } - } - Commands::Build(BuildArgs { - rootfs_args, - prompt_args, - build_exec_args, - lib32_args, - spec_pos, - spec, - install, - install_deps, - cleanup_deps, - }) => { - let rootfs = rootfs_args.rootfs; - let yes = prompt_args.yes; - let no_deps = build_exec_args.no_deps; - let no_flags = build_exec_args.no_flags; - let cross_prefix = build_exec_args.cross_prefix; - let clean = build_exec_args.clean; - 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 install_test_deps = install_test_deps_enabled(cli_test_deps, &config); - let interrupt_watcher = if cleanup_deps { - Some(InterruptWatcher::install()?) - } else { - None - }; - let mut auto_installed_deps = AutoInstalledDependencyTracker::default(); - let build_result: Result<()> = (|| { - // Apply system overrides - pkg_spec.apply_config(&config); - let lib32_only = effective_lib32_only(&pkg_spec, cli_lib32_only); - let requested_outputs = requested_outputs(&pkg_spec, lib32_only); - - let build_targets = vec![format!( - "{} v{}-{}", - pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision - )]; - if !ui::prompt_package_action("build", &build_targets, true)? { - anyhow::bail!("Aborted"); - } - - // 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.installed_db_path(&rootfs); - - if no_deps - && should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) - { - let missing_test = - deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if !missing_test.is_empty() - && !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies are missing", - )? - { - anyhow::bail!("Missing test dependencies: {}", missing_test.join(", ")); - } - } else if no_deps - || !should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) - { - maybe_disable_tests_for_missing_deps( - &mut pkg_spec, - &db_path, - requested_outputs, - )?; - } - - if !no_deps { - deps::print_dep_status_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - let missing_build = - deps::check_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - let missing_runtime = deps::check_runtime_deps_for_outputs( - &pkg_spec, - &db_path, - requested_outputs, - )?; - let missing_required = - merge_missing_dependencies(missing_build.clone(), missing_runtime.clone()); - if !missing_required.is_empty() { - ui::warn(format!( - "Missing dependencies: {}", - missing_required.join(", ") - )); - if !install_deps { - if dry_run { - ui::info( - "Dry run enabled, stopping before dependency installation/build.", - ); - return Ok(()); - } - anyhow::bail!( - "Missing dependencies: {}. Re-run with --install-deps to install them automatically, or install them manually.", - missing_required.join(", ") - ); - } - - let local_sibling_root = spec_path - .parent() - .and_then(|p| p.parent()) - .map(Path::to_path_buf); - let dep_plan = planner::build_dependency_install_plan( - &config, - &rootfs, - &missing_required, - planner::PlannerOptions { - assume_yes: yes, - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root, - include_test_deps: install_test_deps, - lib32_only_requested_specs: false, - }, - )?; - if cleanup_deps { - auto_installed_deps.record_plan( - &dep_plan, - &missing_build, - AutoInstalledDependencyKind::Build, - ); - auto_installed_deps.record_plan( - &dep_plan, - &missing_runtime, - AutoInstalledDependencyKind::Runtime, - ); - } - print_plan_summary(&dep_plan); - if dry_run { - ui::info( - "Dry run enabled, stopping before dependency installation/build.", - ); - return Ok(()); - } - execute_install_plan_with_child_commands( - &dep_plan, - &rootfs, - &config, - InstallPlanExecutionOptions { - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - confirm_installation: false, - lib32_only_requested_specs: false, - install_test_deps, - }, - )?; - } - deps::require_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - deps::require_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { - let missing_test = deps::check_test_deps_for_outputs( - &pkg_spec, - &db_path, - requested_outputs, - )?; - if !missing_test.is_empty() { - let local_sibling_root = spec_path - .parent() - .and_then(|p| p.parent()) - .map(Path::to_path_buf); - let dep_plan = match planner::build_dependency_install_plan( - &config, - &rootfs, - &missing_test, - planner::PlannerOptions { - assume_yes: yes, - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root, - include_test_deps: install_test_deps, - lib32_only_requested_specs: false, - }, - ) { - Ok(plan) => plan, - Err(_err) - if maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies could not be resolved", - )? => - { - planner::ExecutionPlan { steps: Vec::new() } - } - Err(err) => return Err(err), - }; - - if cleanup_deps - && !automatic_tests_disabled_for_outputs( - &pkg_spec, - requested_outputs, - ) - { - auto_installed_deps.record_plan( - &dep_plan, - &missing_test, - AutoInstalledDependencyKind::Test, - ); - } - - if !automatic_tests_disabled_for_outputs(&pkg_spec, requested_outputs) - && !dep_plan.steps.is_empty() - { - ui::warn(format!( - "Missing test dependencies: {}", - missing_test.join(", ") - )); - print_plan_summary(&dep_plan); - if !install_deps { - if dry_run { - ui::info( - "Dry run enabled, stopping before dependency installation/build.", - ); - return Ok(()); - } - if !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies were not installed", - )? { - anyhow::bail!( - "Missing test dependencies: {}. Re-run with --install-deps to install them automatically, or install them manually.", - missing_test.join(", ") - ); - } - } else if !dry_run { - execute_install_plan_with_child_commands( - &dep_plan, - &rootfs, - &config, - InstallPlanExecutionOptions { - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - confirm_installation: false, - lib32_only_requested_specs: false, - install_test_deps, - }, - )?; - } - } - - if should_install_test_deps( - &pkg_spec, - install_test_deps, - requested_outputs, - ) { - let missing_test = deps::check_test_deps_for_outputs( - &pkg_spec, - &db_path, - requested_outputs, - )?; - if !missing_test.is_empty() - && !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies are still missing", - )? - { - deps::require_test_deps_for_outputs( - &pkg_spec, - &db_path, - requested_outputs, - )?; - } - } - } - } - } else if dry_run { - ui::info("Dry run enabled, stopping before build."); - return Ok(()); - } - - let mut build_lock = locking::open_lock(&config)?; - let build_lock_path = locking::lock_path(&config); - let _build_lock_guard = - locking::try_write(&mut build_lock, &build_lock_path, "build")?; - - if dry_run { - ui::info("Dry run enabled, stopping before fetch/build."); - return Ok(()); - } - - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - - let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - - let destdir = config - .build_dir - .join("destdir") - .join(&pkg_spec.package.name); - let cross_config = cross_prefix - .as_ref() - .map(|p| cross::CrossConfig::from_prefix(p)) - .transpose()?; - let host_build_dir = builder::ensure_host_build( - &pkg_spec, - &src_dir, - cross_config.as_ref(), - !no_flags, - builder::TargetBuildKind::Primary, - )?; - if let Some(host_dir) = host_build_dir.as_ref() { - pkg_spec.build.flags.host_build_dir = - Some(host_dir.to_string_lossy().into_owned()); - } - if !lib32_only { - builder::build( - &pkg_spec, - &src_dir, - &destdir, - cross_config.as_ref(), - !no_flags, - host_build_dir.as_deref(), - )?; - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - } - - if !lib32_only { - staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; - install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; - staging::process(&destdir, &pkg_spec)?; - staging::stage_split_package_licenses(&src_dir, &destdir, &pkg_spec)?; - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - } - - let arch = cross_prefix.as_deref().unwrap_or(std::env::consts::ARCH); - - let mut created_files = Vec::new(); - let staged_outputs = if !lib32_only { - staged_output_specs(&pkg_spec, &destdir)? - } else { - Vec::new() - }; - if !lib32_only { - for (spec_for_out, out_destdir) in &staged_outputs { - let packager = package::Packager::new( - spec_for_out.clone(), - out_destdir.clone(), - config.clone(), - ); - let pkg_file = packager.create_package(Path::new("."), arch)?; - created_files.push(pkg_file); - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - } - } - - let mut lib32_install_bundle: Option<(package::PackageSpec, PathBuf)> = None; - if let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( - &pkg_spec, - &src_dir, - &config, - cross_config.as_ref(), - !no_flags, - lib32_only, - )? { - let packager = package::Packager::new( - lib32_spec.clone(), - lib32_destdir.clone(), - config.clone(), - ); - let pkg_file = packager.create_package(Path::new("."), arch)?; - created_files.push(pkg_file); - lib32_install_bundle = Some((lib32_spec, lib32_destdir)); - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - } - - for f in &created_files { - ui::success(format!("Build complete. Package created: {}", f.display())); - } - - for sig_path in signing::auto_sign_zst_files_detached(&rootfs, &created_files)? { - ui::success(format!( - "Created detached signature: {}", - sig_path.display() - )); - } - - if install { - let mut install_targets = Vec::new(); - if !lib32_only { - for (spec_for_out, _) in &staged_outputs { - let out = &spec_for_out.package; - install_targets - .push(format!("{} v{}-{}", out.name, out.version, out.revision)); - } - } - if let Some((lib32_spec, _)) = &lib32_install_bundle { - install_targets.push(format!( - "{} v{}-{}", - lib32_spec.package.name, - lib32_spec.package.version, - lib32_spec.package.revision - )); - } - if ui::prompt_package_action("installation", &install_targets, false)? { - if let Some(watcher) = interrupt_watcher.as_ref() { - watcher.check()?; - } - if should_delegate_live_rootfs_installs(&rootfs) { - run_child_install_command( - &created_files, - &rootfs, - InstallPlanExecutionOptions { - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - confirm_installation: false, - lib32_only_requested_specs: false, - install_test_deps, - }, - )?; - return Ok(()); - } - - let mut transaction_plans = Vec::new(); - if !lib32_only { - let output_plans = plan_package_outputs_for_install( - &pkg_spec, &destdir, &rootfs, &config, - )?; - transaction_plans.extend(output_plans); - } - if let Some((lib32_spec, lib32_destdir)) = &lib32_install_bundle { - let staged = - plan_staged_install(lib32_spec, lib32_destdir, &rootfs, &config)?; - transaction_plans.push(PlannedPackageInstall { - spec: lib32_spec.clone(), - destdir: lib32_destdir.clone(), - staged, - }); - } - - run_transaction_hooks_for_plans( - &rootfs, - install::hooks::HookPhase::Pre, - &transaction_plans, - )?; - install_planned_packages_to_rootfs(&transaction_plans, &rootfs, &config)?; - run_transaction_hooks_for_plans( - &rootfs, - install::hooks::HookPhase::Post, - &transaction_plans, - )?; - - install::scripts::run_deferred_hooks_if_possible(&rootfs)?; - } else { - if !lib32_only { - for (spec_for_out, _) in &staged_outputs { - let out = &spec_for_out.package; - ui::success(format!( - "Built successfully: {}-{}-{}", - out.name, out.version, out.revision - )); - } - } - if let Some((lib32_spec, _)) = &lib32_install_bundle { - ui::success(format!( - "Built successfully: {}-{}-{}", - lib32_spec.package.name, - lib32_spec.package.version, - lib32_spec.package.revision - )); - } - } - } - - Ok(()) - })(); - - let interrupted = interrupt_watcher - .as_ref() - .is_some_and(InterruptWatcher::was_interrupted); - match build_result { - Ok(()) => { - if cleanup_deps && !auto_installed_deps.is_empty() { - cleanup_auto_installed_dependencies( - &auto_installed_deps, - &rootfs, - &config, - !install, - false, - )?; - } - if clean { - clean_build_workspace(&config)?; - } - } - Err(err) => { - if cleanup_deps && !auto_installed_deps.is_empty() { - if interrupted { - ui::warn("Build interrupted by Ctrl-C."); - } - if let Err(clean_err) = cleanup_auto_installed_dependencies( - &auto_installed_deps, - &rootfs, - &config, - !install, - interrupted, - ) { - ui::warn(format!( - "Failed to remove auto-installed dependencies: {}", - clean_err - )); - } - } - if interrupted { - anyhow::bail!("Build interrupted by Ctrl-C"); - } - return Err(err); - } - } - } - Commands::Update(UpdateArgs { - rootfs_args, - prompt_args, - build_exec_args, - packages, - }) => { - let rootfs = rootfs_args.rootfs; - let yes = prompt_args.yes; - let no_deps = build_exec_args.no_deps; - let no_flags = build_exec_args.no_flags; - let cross_prefix = build_exec_args.cross_prefix; - 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)?; - let expanded_packages = if packages.is_empty() { - Vec::new() - } else { - expand_installed_group_targets(&config.installed_db_path(&rootfs), &packages)?.0 - }; - if !is_explicit_depot_self_update_request(&expanded_packages) { - ensure_depot_self_update_not_required(&config, &rootfs)?; - } - run_update_command( - &expanded_packages, - &config, - UpdateCommandOptions { - rootfs: &rootfs, - no_deps, - no_flags, - cross_prefix: cross_prefix.as_deref(), - clean, - dry_run, - assume_yes: yes, - install_test_deps: install_test_deps_enabled(cli_test_deps, &config), - }, - )?; - } - Commands::Check(args) => { - let dir = args.dir; - run_check_command(&dir)?; - } - Commands::Info(InfoArgs { - rootfs_args, - package, - }) => { - let rootfs = rootfs_args.rootfs; - // Try as file first, then as installed package name - let path = PathBuf::from(&package); - if path.exists() { - let config = config::Config::for_rootfs(&rootfs); - let info_lock = locking::open_lock(&config)?; - let info_lock_path = locking::lock_path(&config); - let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; - let pkg_spec = package::PackageSpec::from_file(&path)?; - println!("{}", pkg_spec); - - // Also show dependency status - let db_path = config.installed_db_path(&rootfs); - deps::print_dep_status(&pkg_spec, &db_path)?; - } else { - let config = config::Config::for_rootfs(&rootfs); - let info_lock = locking::open_lock(&config)?; - let info_lock_path = locking::lock_path(&config); - let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; - let db_path = config.installed_db_path(&rootfs); - db::show_package_info(&db_path, &package)?; - } - } - Commands::Search(SearchArgs { - rootfs_args, - query, - files, - }) => { - let rootfs = rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let search_lock = locking::open_lock(&config)?; - let search_lock_path = locking::lock_path(&config); - let _search_lock_guard = locking::try_read(&search_lock, &search_lock_path, "search")?; - run_search_command(&query, files, &config, &rootfs)?; - } - Commands::Owns(OwnsArgs { rootfs_args, path }) => { - let rootfs = rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let owns_lock = locking::open_lock(&config)?; - let owns_lock_path = locking::lock_path(&config); - let _owns_lock_guard = locking::try_read(&owns_lock, &owns_lock_path, "owns")?; - let db_path = config.installed_db_path(&rootfs); - match db::owns_path(&db_path, &path)? { - Some(owner) => ui::info(format!("{} is owned by {}", path.display(), owner)), - None => ui::warn(format!("No installed package owns {}", path.display())), - } - } - Commands::List(ListArgs { rootfs_args }) => { - let rootfs = rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let list_lock = locking::open_lock(&config)?; - let list_lock_path = locking::lock_path(&config); - let _list_lock_guard = locking::try_read(&list_lock, &list_lock_path, "list")?; - let db_path = config.installed_db_path(&rootfs); - db::list_packages(&db_path)?; - } - Commands::Sign(SignArgs { rootfs_args, files }) => { - let rootfs = rootfs_args.rootfs; - let sig_paths = signing::sign_zst_files_detached(&rootfs, &files)?; - for sig_path in sig_paths { - ui::success(format!( - "Created detached signature: {}", - sig_path.display() - )); - } - } - Commands::Repo(RepoArgs { command }) => match command { - RepoCommands::Create { args, dir } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo create")?; - let repo = db::repo::RepoManager::new(dir); - let db_path = repo.create_repo_db()?; - if let Some(sig_path) = signing::auto_sign_zst_file_detached(&rootfs, &db_path)? { - ui::success(format!( - "Created detached signature: {}", - sig_path.display() - )); - } - ui::success(format!( - "Created repository database: {}", - db_path.display() - )); - } - RepoCommands::Sync { args } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo sync")?; - // Only root may run sync - if !crate::fakeroot::is_root() { - anyhow::bail!("The 'repo sync' command must be run as root"); - } - let config = cfg; - let mirrors = config.enabled_source_mirror_map(); - if mirrors.is_empty() { - ui::info("No enabled source repos configured"); - } else { - db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; - ui::success(format!( - "Source repos synchronized into {}", - config.repo_clone_dir.display() - )); - } - } - RepoCommands::Update { args, name } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo update")?; - if !crate::fakeroot::is_root() { - anyhow::bail!("The 'repo update' command must be run as root"); - } - let config = cfg; - let mirrors = selected_source_repos(&config, name.as_deref())?; - if mirrors.is_empty() { - ui::info("No enabled source repos configured"); - } else { - db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; - if let Some(name) = name { - ui::success(format!( - "Source repo '{}' synchronized into {}", - name, - config.repo_clone_dir.display() - )); - } else { - ui::success(format!( - "Source repos synchronized into {}", - config.repo_clone_dir.display() - )); - } - } - } - RepoCommands::Index { args, dir, subdirs } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo index")?; - let stats = index::create_source_repo_index(&dir, &subdirs).with_context(|| { - format!("Failed to create source index for {}", dir.display()) - })?; - ui::success(format!( - "Wrote source index: {}", - stats.index_path.display() - )); - ui::info(format!( - "Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} conflicts rows={} dependency rows={} ignored_toml={}", - stats.specs_indexed, - stats.toml_files_scanned, - stats.package_rows, - stats.provides_rows, - stats.conflicts_rows, - stats.dependency_rows, - stats.ignored_toml_files - )); - } - RepoCommands::List { args } => { - let rootfs = args.rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let repo_lock = locking::open_lock(&config)?; - let repo_lock_path = locking::lock_path(&config); - let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo list")?; - print_repo_list(&config); - } - RepoCommands::Add { - args, - name, - url, - kind, - subdirs, - priority, - disabled, - arch, - repo_db, - allow_unsigned, - } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo add")?; - let mut repos = config::load_repos_config_file(&rootfs)?; - match kind { - RepoKindArg::Source => { - if let Some(existing) = repos.source.get(&name) - && (existing.url != url - || existing.subdirs != subdirs - || existing.priority != priority - || existing.enabled == disabled) - && !ui::prompt_yes_no( - &format!("Source repo '{}' already exists. Overwrite it?", name), - false, - )? - { - anyhow::bail!("Aborted"); - } - repos.source.insert( - name.clone(), - config::SourceRepo { - url, - enabled: !disabled, - priority, - subdirs, - }, - ); - } - RepoKindArg::Binary => { - let arch_name = arch.unwrap_or_else(|| std::env::consts::ARCH.to_string()); - let mut candidate = repos.binary.get(&name).cloned().unwrap_or_default(); - candidate.url = url.clone(); - candidate.enabled = !disabled; - candidate.priority = priority; - candidate.repo_db = repo_db.clone(); - candidate.allow_unsigned = allow_unsigned; - candidate.arch.entry(arch_name.clone()).or_default().enabled = true; - - if let Some(existing) = repos.binary.get(&name) - && (*existing != candidate) - && !ui::prompt_yes_no( - &format!("Binary repo '{}' already exists. Overwrite it?", name), - false, - )? - { - anyhow::bail!("Aborted"); - } - repos.binary.insert(name.clone(), candidate); - } - } - let path = config::save_repos_config_file(&rootfs, &repos)?; - ui::success(format!( - "Saved {} repo '{}' to {}", - repo_kind_label(kind), - name, - path.display() - )); - } - RepoCommands::Remove { args, name, kind } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo remove")?; - let mut repos = config::load_repos_config_file(&rootfs)?; - let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; - if !ui::prompt_yes_no( - &format!("Remove {} repo '{}'?", repo_kind_label(kind), name), - false, - )? { - anyhow::bail!("Aborted"); - } - - match kind { - RepoKindArg::Source => { - repos.source.remove(&name); - } - RepoKindArg::Binary => { - repos.binary.remove(&name); - } - } - let path = config::save_repos_config_file(&rootfs, &repos)?; - ui::success(format!( - "Removed {} repo '{}' from {}", - repo_kind_label(kind), - name, - path.display() - )); - } - RepoCommands::Enable { args, name, kind } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo enable")?; - let mut repos = config::load_repos_config_file(&rootfs)?; - let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; - match kind { - RepoKindArg::Source => { - let repo = repos - .source - .get_mut(&name) - .with_context(|| format!("Source repo '{}' not found", name))?; - if repo.enabled { - ui::info(format!("Source repo '{}' is already enabled", name)); - } else { - repo.enabled = true; - let path = config::save_repos_config_file(&rootfs, &repos)?; - ui::success(format!( - "Enabled source repo '{}' in {}", - name, - path.display() - )); - } - } - RepoKindArg::Binary => { - let repo = repos - .binary - .get_mut(&name) - .with_context(|| format!("Binary repo '{}' not found", name))?; - if repo.enabled { - ui::info(format!("Binary repo '{}' is already enabled", name)); - } else { - repo.enabled = true; - let path = config::save_repos_config_file(&rootfs, &repos)?; - ui::success(format!( - "Enabled binary repo '{}' in {}", - name, - path.display() - )); - } - } - } - } - RepoCommands::Disable { args, name, kind } => { - let rootfs = args.rootfs_args.rootfs; - let cfg = config::Config::for_rootfs(&rootfs); - let mut repo_lock = locking::open_lock(&cfg)?; - let repo_lock_path = locking::lock_path(&cfg); - let _repo_lock_guard = - locking::try_write(&mut repo_lock, &repo_lock_path, "repo disable")?; - let mut repos = config::load_repos_config_file(&rootfs)?; - let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; - if !ui::prompt_yes_no( - &format!("Disable {} repo '{}'?", repo_kind_label(kind), name), - true, - )? { - anyhow::bail!("Aborted"); - } - match kind { - RepoKindArg::Source => { - let repo = repos - .source - .get_mut(&name) - .with_context(|| format!("Source repo '{}' not found", name))?; - repo.enabled = false; - } - RepoKindArg::Binary => { - let repo = repos - .binary - .get_mut(&name) - .with_context(|| format!("Binary repo '{}' not found", name))?; - repo.enabled = false; - } - } - let path = config::save_repos_config_file(&rootfs, &repos)?; - ui::success(format!( - "Disabled {} repo '{}' in {}", - repo_kind_label(kind), - name, - path.display() - )); - } - RepoCommands::Owns { args, path } => { - let rootfs = args.rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let repo_lock = locking::open_lock(&config)?; - let repo_lock_path = locking::lock_path(&config); - let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo owns")?; - let host_arch = std::env::consts::ARCH; - let mut any = false; - let mut binary_repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - binary_repos - .sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - for (name, repo) in binary_repos { - match db::repo::binary_repo_owns_path( - name, - repo, - &rootfs, - &config.package_cache_dir, - &path.to_string_lossy(), - ) { - Ok(hits) => { - for hit in hits { - any = true; - ui::info(format!( - "{} [binary:{}] {}-{} size={} owns={}", - hit.package_name, - hit.repo_name, - hit.version, - hit.revision, - hit.size, - hit.path - )); - } - } - Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), - } - } - if !any { - ui::warn(format!( - "No binary repo metadata entry owns {}", - path.display() - )); - } - } - RepoCommands::Status { args } => { - let rootfs = args.rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let repo_lock = locking::open_lock(&config)?; - let repo_lock_path = locking::lock_path(&config); - let _repo_lock_guard = - locking::try_read(&repo_lock, &repo_lock_path, "repo status")?; - let mirrors = config.enabled_source_mirror_map(); - if mirrors.is_empty() { - ui::info("No enabled source repos configured"); - } else { - db::repo::mirrors_status(&config.repo_clone_dir, &mirrors)?; - } - if config.binary_repos.is_empty() { - ui::info("No binary repos configured"); - } else { - ui::info("Binary repo configuration:"); - let host_arch = std::env::consts::ARCH; - for (name, repo) in &config.binary_repos { - let arch_keys = if repo.arch.is_empty() { - "(any)".to_string() - } else { - repo.arch.keys().cloned().collect::>().join(",") - }; - ui::info(format!( - " {} [{}] url={} repo_db={} arches={} host_match={}", - name, - if repo.enabled { "enabled" } else { "disabled" }, - repo.url, - repo.repo_db, - arch_keys, - if repo.supports_arch(host_arch) { - "yes" - } else { - "no" - } - )); - } - } - } - }, - Commands::GenerateArtifacts(args) => { - let out_dir = args.out_dir; - cli_assets::generate_cli_assets(&out_dir)?; - ui::success(format!("Generated CLI assets in {}", out_dir.display())); - } - Commands::Config(ConfigArgs { rootfs_args }) => { - let rootfs = rootfs_args.rootfs; - let config = config::Config::for_rootfs(&rootfs); - let config_lock = locking::open_lock(&config)?; - let config_lock_path = locking::lock_path(&config); - let _config_lock_guard = locking::try_read(&config_lock, &config_lock_path, "config")?; - println!("Cache Directory: {}", config.cache_dir.display()); - println!( - "Package Cache Directory: {}", - config.package_cache_dir.display() - ); - println!("Build Directory: {}", config.build_dir.display()); - println!("Database Directory: {}", config.db_dir.display()); - println!("Repo Clone Directory: {}", config.repo_clone_dir.display()); - println!("Install Test Deps: {}", config.install_test_deps); - println!( - "Configured Repos: {} source, {} binary", - config.source_repos.len(), - config.binary_repos.len() - ); - println!("\nBuild Overrides: {}", config.build_overrides); - println!("Package Overrides: {}", config.package_overrides); - if !config.appends.is_empty() { - println!("\nAppends:"); - for (k, v) in &config.appends { - println!(" {} = {:?}", k, v); - } - } - } - Commands::MakeSpec(args) => { - let output = args.output; - let spec = package::create_interactive()?; - // Produce a minimal TOML for interactive-created specs (omit defaults) - let toml_string = package::spec_to_minimal_toml(&spec)?; - - let output_path = - output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); - - if output_path.exists() { - 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)?; - ui::success(format!( - "Package specification saved to {}", - output_path.display() - )); - } - Commands::Convert(ConvertArgs { input, output }) => { - let converted = package::convert_starbuild_file(&input, output.as_deref())?; - let mut outputs = vec![converted.output_path.clone()]; - if let Some(build_script_path) = &converted.build_script_path { - outputs.push(build_script_path.clone()); - } - - let existing: Vec<_> = outputs - .iter() - .filter(|path| path.exists()) - .map(|path| path.display().to_string()) - .collect(); - if !existing.is_empty() { - ui::warn(format!( - "Generated files already exist: {}", - existing.join(", ") - )); - if !ui::prompt_yes_no("Overwrite them?", false)? { - anyhow::bail!("Aborted"); - } - } - - fs::write(&converted.output_path, converted.toml) - .with_context(|| format!("Failed to write {}", converted.output_path.display()))?; - if let (Some(build_script), Some(build_script_path)) = - (converted.build_script, converted.build_script_path) - { - fs::write(&build_script_path, build_script) - .with_context(|| format!("Failed to write {}", build_script_path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&build_script_path) - .with_context(|| format!("Failed to stat {}", build_script_path.display()))? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(&build_script_path, perms).with_context(|| { - format!("Failed to chmod {}", build_script_path.display()) - })?; - } - ui::success(format!( - "Converted STARBUILD into {} and {}", - converted.output_path.display(), - build_script_path.display() - )); - } else { - ui::success(format!( - "Converted STARBUILD into {}", - converted.output_path.display() - )); - } - } - Commands::Internal(args) => { - let command = args.command; - run_internal_command(command)?; - } + Commands::Install(args) => install_cmd::run_install(args, cli_test_deps)?, + Commands::Remove(args) => install_cmd::run_remove(args)?, + Commands::Build(args) => build_cmd::run_build(args, cli_test_deps)?, + Commands::Update(args) => update::run_update(args, cli_test_deps)?, + Commands::Check(args) => check::run_check(args)?, + Commands::Info(args) => misc::run_info(args)?, + Commands::Search(args) => repo::run_search(args)?, + Commands::Owns(args) => misc::run_owns(args)?, + Commands::List(args) => misc::run_list(args)?, + Commands::Sign(args) => misc::run_sign(args)?, + Commands::Repo(args) => repo::run_repo(args.command)?, + Commands::GenerateArtifacts(args) => misc::run_generate_artifacts(args)?, + Commands::Config(args) => misc::run_config(args)?, + Commands::MakeSpec(args) => misc::run_make_spec(args)?, + Commands::Convert(args) => misc::run_convert(args)?, + Commands::Internal(args) => misc::run_internal(args)?, } Ok(()) } #[cfg(test)] -mod tests { - use super::*; - use crate::cli::{ - BuildArgs, BuildExecArgs, Cli, InstallArgs, Lib32Args, PromptArgs, RootfsArgs, SearchArgs, - UpdateArgs, - }; - use crate::test_support::TestEnv; - use git2::{Oid, Repository}; - use std::path::Path; - use std::sync::Mutex; - - static ASSUME_YES_TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn rootfs_args(rootfs: impl Into) -> RootfsArgs { - RootfsArgs { - rootfs: rootfs.into(), - } - } - - fn prompt_args(yes: bool) -> PromptArgs { - PromptArgs { yes } - } - - fn build_exec_args() -> BuildExecArgs { - BuildExecArgs { - no_deps: false, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - test_deps: false, - } - } - - fn lib32_args() -> Lib32Args { - Lib32Args { lib32_only: false } - } - - fn test_binary_repo_record(name: &str, filename: &str) -> db::repo::BinaryRepoPackageRecord { - db::repo::BinaryRepoPackageRecord { - repo_name: "core".into(), - name: name.into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - completed_at: None, - filename: filename.into(), - size: 1, - sha256: "sha256".into(), - sha512: "sha512".into(), - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - } - } - - fn test_package_spec( - build_type: package::BuildType, - make_test_target: Option<&str>, - make_test_targets: &[&str], - ) -> package::PackageSpec { - let mut flags = package::BuildFlags::default(); - if let Some(target) = make_test_target { - flags.make_test_target = target.to_string(); - } - flags.make_test_targets = make_test_targets - .iter() - .map(|target| (*target).to_string()) - .collect(); - - package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: vec![package::Source { - url: "https://example.test/pkg.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "pkg".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: package::Build { build_type, flags }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - fn commit_git_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { - let full_path = workdir.join(rel); - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&full_path, data).unwrap(); - - let mut index = repo.index().unwrap(); - index.add_path(Path::new(rel)).unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); - let mut parents = Vec::new(); - if let Ok(head) = repo.head() - && let Some(oid) = head.target() - { - parents.push(repo.find_commit(oid).unwrap()); - } - let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); - - repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) - .unwrap() - } - - fn make_remote_git_repo() -> (tempfile::TempDir, String, Oid) { - let tmp = tempfile::tempdir().unwrap(); - let remote_dir = tmp.path().join("origin.git"); - let workdir = tmp.path().join("work"); - - Repository::init_bare(&remote_dir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - let tagged = commit_git_file(&repo, &workdir, "README", "tagged\n"); - let tag_target = repo.find_object(tagged, None).unwrap(); - repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); - - let branch_ref = repo.head().unwrap().name().unwrap().to_string(); - let mut remote = repo.remote("origin", remote_dir.to_str().unwrap()).unwrap(); - let push_specs = [ - format!("{branch_ref}:{branch_ref}"), - "refs/tags/v1.0.0:refs/tags/v1.0.0".to_string(), - ]; - let push_spec_refs: Vec<&String> = push_specs.iter().collect(); - remote.push(&push_spec_refs, None).unwrap(); - - let remote_url = url::Url::from_file_path(&remote_dir).unwrap().to_string(); - (tmp, remote_url, tagged) - } - - #[test] - fn run_internal_clone_checks_out_git_revision() { - let (_tmp, remote_url, tagged) = make_remote_git_repo(); - let clone_root = tempfile::tempdir().unwrap(); - let dest = clone_root.path().join("cloned-src"); - - run_internal_command(InternalCommands::Clone { - repo: format!("{remote_url}#v1.0.0"), - dest: Some(dest.clone()), - }) - .unwrap(); - - let repo = Repository::open(&dest).unwrap(); - assert_eq!(repo.head().unwrap().target().unwrap(), tagged); - assert_eq!( - std::fs::read_to_string(dest.join("README")).unwrap(), - "tagged\n" - ); - } - - 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; - - #[test] - fn clean_build_workspace_removes_build_and_source_cache_dirs() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - fs::create_dir_all(&cfg.build_dir) - .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; - fs::create_dir_all(&cfg.cache_dir) - .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; - - let mut build_file = fs::File::create(cfg.build_dir.join("artifact.txt"))?; - build_file.write_all(b"build data")?; - build_file.flush()?; - - let mut source_file = fs::File::create(cfg.cache_dir.join("source.tar.zst"))?; - source_file.write_all(b"source data")?; - source_file.flush()?; - - clean_build_workspace(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(!cfg.cache_dir.exists()); - Ok(()) - } - - #[test] - fn clean_build_workspace_noops_when_dirs_are_missing() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - clean_build_workspace(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(!cfg.cache_dir.exists()); - Ok(()) - } - - #[test] - fn binary_install_path_uses_repo_record_metadata_without_archive_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); - - // Build an archive that intentionally does not contain .metadata.toml. - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"hello"; - let mut header = tar::Header::new_gnu(); - header.set_path("usr/bin/hello").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - let record = db::repo::BinaryRepoPackageRecord { - repo_name: "core".into(), - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - completed_at: None, - filename: archive_path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or_default() - .to_string(), - size: payload.len() as u64, - sha256: String::new(), - sha512: String::new(), - description: Some("test package".into()), - homepage: Some("https://example.test".into()), - license: Some("MIT".into()), - provides: vec!["pkg-virtual".into()], - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: vec!["glibc".into()], - optional_dependencies: vec!["manpages".into()], - groups: vec!["base".into()], - }; - let spec = package_spec_from_repo_record(&record); - let installed = - install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - assert_eq!(installed[0].package.name, "pkg"); - assert!(rootfs.path().join("usr/bin/hello").exists()); - - let db_path = cfg.db_dir.join("packages.db"); - assert_eq!( - db::get_package_version(&db_path, "pkg")?, - Some("1.0".into()) - ); - Ok(()) - } - - #[test] - fn binary_archive_staging_uses_config_build_dir_instead_of_process_tmpdir() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"hello"; - let mut header = tar::Header::new_gnu(); - header.set_path("usr/bin/hello").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - - assert!(staged.path().starts_with(staging_temp_root(&cfg))); - assert!(staged.path().join("usr/bin/hello").exists()); - Ok(()) - } - - #[test] - fn direct_archive_install_requests_batch_multiple_archives() -> Result<()> { - fn write_archive( - archive_path: &Path, - package_name: &str, - conflicts: &[&str], - payload_path: &str, - payload: &[u8], - ) -> Result<()> { - let file = fs::File::create(archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = zstd::stream::write::Encoder::new(file, 3) - .context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path(payload_path)?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, payload)?; - - let conflicts_toml = if conflicts.is_empty() { - String::new() - } else { - format!( - "conflicts = [{}]\n", - conflicts - .iter() - .map(|conflict| format!("\"{conflict}\"")) - .collect::>() - .join(", ") - ) - }; - let metadata = format!( - "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" - ); - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, metadata.as_bytes())?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - Ok(()) - } - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); - let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?; - write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?; - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let installed = run_direct_archive_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[archive_a, archive_b], - false, - )?; - - assert!(installed); - assert!(rootfs.path().join("usr/bin/alpha").exists()); - assert!(rootfs.path().join("usr/bin/beta").exists()); - let db_path = cfg.installed_db_path(rootfs.path()); - assert_eq!( - db::get_package_version(&db_path, "alpha")?, - Some("1.0".into()) - ); - assert_eq!( - db::get_package_version(&db_path, "beta")?, - Some("1.0".into()) - ); - Ok(()) - } - - #[test] - fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> { - fn write_archive( - archive_path: &Path, - package_name: &str, - conflicts: &[&str], - ) -> Result<()> { - let file = fs::File::create(archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = zstd::stream::write::Encoder::new(file, 3) - .context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = package_name.as_bytes(); - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path(format!("usr/bin/{package_name}"))?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, payload)?; - - let conflicts_toml = if conflicts.is_empty() { - String::new() - } else { - format!( - "conflicts = [{}]\n", - conflicts - .iter() - .map(|conflict| format!("\"{conflict}\"")) - .collect::>() - .join(", ") - ) - }; - let metadata = format!( - "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" - ); - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, metadata.as_bytes())?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - Ok(()) - } - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); - let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - write_archive(&archive_a, "alpha", &["beta"])?; - write_archive(&archive_b, "beta", &[])?; - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let err = run_direct_archive_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[archive_a, archive_b], - false, - ) - .expect_err("conflicting archives should be rejected"); - - assert!( - err.to_string() - .contains("Cannot install conflicting packages in the same transaction") - ); - Ok(()) - } - - #[test] - fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> { - let removals = collect_conflicting_installed_packages( - &[InstallConflictSubject { - package: "beta".into(), - provides: Vec::new(), - conflicts: vec!["alpha".into(), "editor".into()], - }], - &[InstalledConflictPackage { - name: "alpha".into(), - provides: vec!["editor".into()], - }], - )?; - - assert_eq!( - removals.get("alpha"), - Some(&BTreeSet::from(["beta".to_string()])) - ); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn binary_archive_install_preserves_setuid_permissions() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("sudo-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"sudo"; - let mut header = tar::Header::new_gnu(); - header.set_path("bin/sudo").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o4755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - let staged_mode = fs::metadata(staged.path().join("bin/sudo"))? - .permissions() - .mode() - & 0o7777; - assert_eq!(staged_mode, 0o4755); - - let record = db::repo::BinaryRepoPackageRecord { - repo_name: "core".into(), - name: "sudo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - completed_at: None, - filename: archive_path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or_default() - .to_string(), - size: payload.len() as u64, - sha256: String::new(), - sha512: String::new(), - description: Some("sudo".into()), - homepage: Some("https://example.test".into()), - license: Some("ISC".into()), - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }; - let spec = package_spec_from_repo_record(&record); - let installed = - install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - let root_mode = fs::metadata(rootfs.path().join("bin/sudo"))? - .permissions() - .mode() - & 0o7777; - assert_eq!(root_mode, 0o4755); - Ok(()) - } - - #[test] - fn binary_archive_install_honors_keep_paths_from_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir - .path() - .join("filesystem-1.0-3-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = b"package-fstab"; - let mut fstab_header = tar::Header::new_gnu(); - fstab_header.set_path("etc/fstab").unwrap(); - fstab_header.set_size(payload.len() as u64); - fstab_header.set_mode(0o644); - fstab_header.set_cksum(); - tar.append(&fstab_header, &payload[..]).unwrap(); - - let metadata = br#"name = "filesystem" -version = "1.0.1" -revision = 3 -description = "Base filesystem" -homepage = "https://example.test" -license = "Unlicense" -keep = ["etc/fstab"] - -[dependencies] -runtime = [] -optional = [] -"#; - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml").unwrap(); - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, &metadata[..]).unwrap(); - - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - fs::create_dir_all(rootfs.path().join("etc"))?; - fs::write(rootfs.path().join("etc/fstab"), "existing-fstab")?; - - let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; - assert_eq!(spec.build.flags.keep, vec!["etc/fstab".to_string()]); - - let installed = - install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - assert_eq!(installed.len(), 1); - assert_eq!( - fs::read_to_string(rootfs.path().join("etc/fstab"))?, - "existing-fstab" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("etc/fstab.depotnew"))?, - "package-fstab" - ); - Ok(()) - } - - #[test] - fn binary_archive_install_honors_replaces_from_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("vx-0.1.0-1-x86_64.depot.pkg.tar.zst"); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "diffutils".into(), - real_name: None, - version: "3.12".into(), - revision: 1, - description: "diffutils".into(), - homepage: "https://example.test/diffutils".into(), - abi_breaking: false, - license: vec!["GPL-3.0-or-later".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/bin"))?; - fs::write(old_dest.join("usr/bin/diff"), "old-diff")?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = b"vx-diff"; - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path("usr/bin/diff")?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, &payload[..])?; - - let metadata = br#"name = "vx" -version = "0.1.0" -revision = 1 -description = "vertex utils" -homepage = "https://example.test/vx" -license = "MIT" -replaces = ["diffutils"] - -[dependencies] -runtime = [] -optional = [] -"#; - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, &metadata[..])?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - - let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; - assert_eq!(spec.alternatives.replaces, vec!["diffutils".to_string()]); - - let installed = - install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - assert!(installed[0].is_update); - assert_eq!(installed[0].package.name, "vx"); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/diff"))?, - "vx-diff" - ); - - let db_path = cfg.installed_db_path(rootfs.path()); - assert_eq!(db::get_package_version(&db_path, "diffutils")?, None); - assert_eq!( - db::get_package_version(&db_path, "vx")?, - Some("0.1.0".into()) - ); - Ok(()) - } - - #[test] - fn plan_staged_install_reads_updates_from_rootfs_installed_db() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.db_dir = rootfs.path().join("home/vertex/.local/share/depot"); - - let installed_db = cfg.installed_db_path(rootfs.path()); - fs::create_dir_all( - installed_db - .parent() - .context("Installed DB path should have a parent")?, - )?; - - let existing_dest = rootfs.path().join("installed"); - fs::create_dir_all(existing_dest.join("usr/bin"))?; - fs::write(existing_dest.join("usr/bin/tool"), "old")?; - - let spec = package::PackageSpec { - package: package::PackageInfo { - name: "filesystem".into(), - real_name: None, - version: "1.0.1".into(), - revision: 3, - description: "Base filesystem".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - license: vec!["Unlicense".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - db::register_package(&installed_db, &spec, &existing_dest)?; - - let staged_dest = rootfs.path().join("staged"); - fs::create_dir_all(staged_dest.join("usr/bin"))?; - fs::write(staged_dest.join("usr/bin/tool"), "new")?; - - let plan = plan_staged_install(&spec, &staged_dest, rootfs.path(), &cfg)?; - assert!(plan.is_update); - Ok(()) - } - - #[test] - fn renamed_abi_updates_keep_versioned_shared_libraries() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "libxml214".into(), - real_name: Some("libxml2".into()), - version: "2.14.9".into(), - revision: 1, - description: "libxml2 2.14".into(), - homepage: "https://example.test/libxml2".into(), - abi_breaking: true, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/lib/pkgconfig"))?; - fs::write(old_dest.join("usr/lib/libxml2.so.14.9.0"), "old-real")?; - std::os::unix::fs::symlink("libxml2.so.14.9.0", old_dest.join("usr/lib/libxml2.so.14"))?; - std::os::unix::fs::symlink("libxml2.so.14", old_dest.join("usr/lib/libxml2.so"))?; - fs::write( - old_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), - "old-pkgconfig", - )?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let new_spec = package::PackageSpec { - package: package::PackageInfo { - name: "libxml215".into(), - real_name: Some("libxml2".into()), - version: "2.15.1".into(), - revision: 1, - description: "libxml2 2.15".into(), - homepage: "https://example.test/libxml2".into(), - abi_breaking: true, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let new_dest = rootfs.path().join("new-dest"); - fs::create_dir_all(new_dest.join("usr/lib/pkgconfig"))?; - fs::write(new_dest.join("usr/lib/libxml2.so.15.1.0"), "new-real")?; - std::os::unix::fs::symlink("libxml2.so.15.1.0", new_dest.join("usr/lib/libxml2.so.15"))?; - std::os::unix::fs::symlink("libxml2.so.15", new_dest.join("usr/lib/libxml2.so"))?; - fs::write( - new_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), - "new-pkgconfig", - )?; - - let installed = - install_package_outputs_to_rootfs(&new_spec, &new_dest, rootfs.path(), &cfg)?; - assert_eq!(installed.len(), 1); - assert!(installed[0].is_update); - assert_eq!(installed[0].package.name, "libxml215"); - - assert!(rootfs.path().join("usr/lib/libxml2.so.14.9.0").exists()); - assert!(rootfs.path().join("usr/lib/libxml2.so.14").exists()); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/lib/libxml2.so.15.1.0"))?, - "new-real" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/lib/pkgconfig/libxml-2.0.pc"))?, - "new-pkgconfig" - ); - - let db_path = cfg.installed_db_path(rootfs.path()); - let old_files = db::get_package_files(&db_path, "libxml214")?; - assert_eq!( - old_files, - vec![ - "usr/lib/libxml2.so.14".to_string(), - "usr/lib/libxml2.so.14.9.0".to_string(), - ] - ); - - let new_files = db::get_package_files(&db_path, "libxml215")?; - assert!(new_files.contains(&"usr/lib/libxml2.so".to_string())); - assert!(new_files.contains(&"usr/lib/libxml2.so.15".to_string())); - assert!(new_files.contains(&"usr/lib/libxml2.so.15.1.0".to_string())); - assert!(new_files.contains(&"usr/lib/pkgconfig/libxml-2.0.pc".to_string())); - Ok(()) - } - - #[test] - fn merge_missing_dependencies_preserves_order_and_uniqueness() { - let merged = merge_missing_dependencies( - vec!["make".into(), "pkgconf".into(), "glibc".into()], - vec![ - "glibc".into(), - "openssl".into(), - "pkgconf".into(), - "zlib".into(), - ], - ); - assert_eq!(merged, vec!["make", "pkgconf", "glibc", "openssl", "zlib"]); - } - - #[test] - fn collect_update_candidates_matches_renamed_packages_by_real_name() -> Result<()> { - let temp = tempfile::tempdir()?; - 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(&rootfs)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - fs::create_dir_all(&db_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.clone(); - config.repo_settings.prefer_binary = false; - config.binary_repos.clear(); - config.source_repos.insert( - "private".into(), - config::SourceRepo { - url: "https://example.test/private.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - let installed_spec = package::PackageSpec { - package: package::PackageInfo { - name: "icu78".into(), - real_name: Some("icu".into()), - version: "78.2".into(), - revision: 1, - description: "icu78".into(), - homepage: "https://example.test/icu".into(), - abi_breaking: true, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/lib"))?; - fs::write(dest.join("usr/lib/libicuuc.so.78.2"), "icu78")?; - db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; - - let repo_root = repo_clones.join("private"); - fs::create_dir_all(&repo_root)?; - fs::write( - repo_root.join("icu79.toml"), - r#"[package] -name = "icu79" -real_name = "icu" -version = "79.1" -revision = 1 -description = "icu79" -homepage = "https://example.test/icu" -abi_breaking = true -license = "MIT" - -[build] -type = "meta" - -[dependencies] -runtime = [] -optional = [] -"#, - )?; - - let installed_records = - db::list_installed_package_records(&config.installed_db_path(&rootfs))?; - assert_eq!(installed_records.len(), 1); - assert_eq!(installed_records[0].real_name.as_deref(), Some("icu")); - - let source_candidates = - collect_best_source_update_candidates(&config, &HashSet::from([String::from("icu")]))?; - assert!(source_candidates.contains_key("icu")); - let selected = select_update_candidate( - &installed_records[0], - installed_records[0].completed_at, - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &HashMap::new(), - false, - ); - assert!(selected.is_some()); - - let updates = collect_update_candidates(&config, &rootfs, &["icu78".into()])?; - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].installed_package, "icu78"); - assert_eq!(updates[0].candidate_package, "icu79"); - assert_eq!(updates[0].candidate_version, "79.1"); - Ok(()) - } - - #[test] - fn update_candidate_prefers_binary_when_versions_match_and_config_does() { - let installed = db::InstalledPackageRecord { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - abi_breaking: false, - completed_at: None, - }; - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.1.0".into(), - revision: 1, - description: "test".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: vec![package::Source { - url: "https://example.test/pkg-$version.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "pkg-$version".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let source_candidates = HashMap::from([( - "pkg".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/pkg.toml"), - completed_at: None, - spec: source_spec, - }, - )]); - let binary_candidates = HashMap::from([( - "pkg".to_string(), - ( - 0, - db::repo::BinaryRepoPackageRecord { - repo_name: "binary".into(), - name: "pkg".into(), - real_name: None, - version: "1.1.0".into(), - revision: 1, - abi_breaking: false, - completed_at: None, - filename: "pkg-1.1.0-1-x86_64.depot.pkg.tar.zst".into(), - size: 1, - sha256: String::new(), - sha512: String::new(), - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }, - ), - )]); - - let selected = select_update_candidate( - &installed, - None, - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &binary_candidates, - true, - ) - .expect("expected update candidate"); - assert!(matches!(selected.origin, UpdateOrigin::Binary { .. })); - } - - #[test] - fn select_update_candidate_uses_newer_timestamp_when_versions_match() { - let installed = db::InstalledPackageRecord { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - abi_breaking: false, - completed_at: Some(100), - }; - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - description: "test".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let source_candidates = HashMap::from([( - "pkg".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/pkg.toml"), - completed_at: Some(200), - spec: source_spec, - }, - )]); - - let selected = select_update_candidate( - &installed, - Some(100), - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &HashMap::new(), - true, - ) - .expect("expected update candidate"); - assert_eq!(selected.candidate_version, "1.0.0"); - assert_eq!(selected.candidate_completed_at, Some(200)); - } - - #[test] - fn select_update_candidate_prefers_replacement_candidate() { - let installed = db::InstalledPackageRecord { - name: "findutils".into(), - real_name: None, - version: "4.9.0".into(), - revision: 1, - abi_breaking: false, - completed_at: Some(100), - }; - - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "findutils".into(), - real_name: None, - version: "5.0.0".into(), - revision: 1, - description: "findutils".into(), - homepage: "https://example.test/findutils".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let replacement_spec = package::PackageSpec { - package: package::PackageInfo { - name: "busybox".into(), - real_name: None, - version: "1.36.1".into(), - revision: 1, - description: "busybox".into(), - homepage: "https://example.test/busybox".into(), - abi_breaking: false, - license: vec!["GPL-2.0-only".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives { - provides: Vec::new(), - conflicts: Vec::new(), - replaces: vec!["findutils".into()], - lib32: None, - }, - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let source_candidates = HashMap::from([( - "findutils".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/findutils.toml"), - completed_at: Some(200), - spec: source_spec, - }, - )]); - let source_replacement_candidates = HashMap::from([( - "findutils".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 0, - path: PathBuf::from("/tmp/busybox.toml"), - completed_at: Some(150), - spec: replacement_spec, - }, - )]); - - let selected = select_update_candidate( - &installed, - installed.completed_at, - &source_replacement_candidates, - &HashMap::new(), - &source_candidates, - &HashMap::new(), - false, - ) - .expect("expected replacement update candidate"); - - assert!(selected.replaces_installed); - assert_eq!(selected.installed_package, "findutils"); - assert_eq!(selected.candidate_package, "busybox"); - } - - #[test] - fn install_planned_packages_to_rootfs_runs_post_hooks_after_batch_install() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "findutils".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "findutils".into(), - homepage: "https://example.test/findutils".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/bin"))?; - fs::write(old_dest.join("usr/bin/find"), "old-find")?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let alpha_spec = package::PackageSpec { - package: package::PackageInfo { - name: "alpha".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "alpha".into(), - homepage: "https://example.test/alpha".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let alpha_dest = rootfs.path().join("alpha-dest"); - fs::create_dir_all(alpha_dest.join("usr/bin"))?; - fs::create_dir_all(alpha_dest.join("scripts"))?; - fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?; - fs::write( - alpha_dest.join("scripts/post_install"), - "cat \"$DEPOT_ROOTFS/usr/bin/find\" > \"$DEPOT_ROOTFS/alpha-marker\"\n", - )?; - - let replacement_spec = package::PackageSpec { - package: package::PackageInfo { - name: "busybox".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "busybox".into(), - homepage: "https://example.test/busybox".into(), - abi_breaking: false, - license: vec!["GPL-2.0-only".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives { - provides: Vec::new(), - conflicts: Vec::new(), - replaces: vec!["findutils".into()], - lib32: None, - }, - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let replacement_dest = rootfs.path().join("replacement-dest"); - fs::create_dir_all(replacement_dest.join("usr/bin"))?; - fs::write(replacement_dest.join("usr/bin/find"), "new-find")?; - - let mut plans = Vec::new(); - plans.extend(plan_package_outputs_for_install( - &alpha_spec, - &alpha_dest, - rootfs.path(), - &cfg, - )?); - plans.extend(plan_package_outputs_for_install( - &replacement_spec, - &replacement_dest, - rootfs.path(), - &cfg, - )?); - - install_planned_packages_to_rootfs(&plans, rootfs.path(), &cfg)?; - - assert_eq!( - fs::read_to_string(rootfs.path().join("alpha-marker"))?, - "new-find" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/find"))?, - "new-find" - ); - assert!( - db::get_package_version(&cfg.installed_db_path(rootfs.path()), "findutils")?.is_none() - ); - assert_eq!( - db::get_package_version(&cfg.installed_db_path(rootfs.path()), "busybox")?, - Some("1.0".into()) - ); - Ok(()) - } - - #[test] - fn collect_update_candidates_skips_source_when_prefer_binary_is_enabled() -> 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.clone(); - config.repo_settings.prefer_binary = true; - config.binary_repos.clear(); - config.source_repos.insert( - "private".into(), - config::SourceRepo { - url: "https://example.test/private.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - let installed_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - description: "pkg".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/bin"))?; - fs::write(dest.join("usr/bin/pkg"), "pkg")?; - db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; - - let updates = collect_update_candidates(&config, &rootfs, &[])?; - assert!(updates.is_empty()); - 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 depot_self_update_check_is_skipped_for_nested_update_install_context() -> 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 mut env = TestEnv::new(); - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_UPDATE); - - ensure_depot_self_update_not_required(&config, &rootfs)?; - Ok(()) - } - - #[test] - fn collect_missing_update_dependencies_skips_planned_provides_and_installed_deps() -> Result<()> - { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let db_path = temp.path().join("packages.db"); - - let libc_spec = package::PackageSpec { - package: package::PackageInfo { - name: "glibc".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "glibc".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/lib"))?; - fs::write(dest.join("usr/lib/libc.so"), "glibc")?; - db::register_package(&db_path, &libc_spec, &dest)?; - - let missing = collect_missing_update_dependencies( - &[ - UpdateCandidate { - installed_package: "pkg".into(), - candidate_package: "pkg".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()], - provides: Vec::new(), - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/pkg.toml"), - }, - }, - UpdateCandidate { - installed_package: "helper".into(), - candidate_package: "helper".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: Vec::new(), - provides: vec!["helper-virtual".into()], - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/helper.toml"), - }, - }, - UpdateCandidate { - installed_package: "tool".into(), - candidate_package: "tool".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: vec!["newdep".into()], - provides: Vec::new(), - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/tool.toml"), - }, - }, - ], - &db_path, - )?; - - assert_eq!(missing, vec!["newdep".to_string()]); - Ok(()) - } - - #[test] - fn validate_no_transaction_conflicts_rejects_conflicting_updates() { - let err = validate_no_transaction_conflicts(&[ - InstallConflictSubject { - package: "alpha".into(), - provides: Vec::new(), - conflicts: vec!["beta".into()], - }, - InstallConflictSubject { - package: "beta".into(), - provides: Vec::new(), - conflicts: Vec::new(), - }, - ]) - .expect_err("conflicting update set should be rejected"); - - assert!( - err.to_string() - .contains("Cannot install conflicting packages in the same transaction") - ); - } - - #[test] - fn compare_versions_for_updates_handles_semver_and_date_versions() { - assert_eq!( - compare_versions_for_updates("10.8.4", "10.8.3"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("20260202", "20251231"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("1.10", "1.9"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("v1.0.0", "1.0.0"), - Ordering::Equal - ); - assert_eq!( - compare_versions_for_updates("lts_2027_01_01", "20260107.1"), - Ordering::Greater - ); - } - - #[test] - fn compare_versions_for_updates_is_transitive_for_mixed_formats() { - let versions = [ - "01", - "1a", - "1.0.0", - "1.2.0", - "1.2.0rc2", - "v1.0.0", - "1.0.0+meta", - "20260107.1", - "lts_2026_01_07", - ]; - - for left in versions { - for middle in versions { - for right in versions { - let left_middle = compare_versions_for_updates(left, middle); - let middle_right = compare_versions_for_updates(middle, right); - let left_right = compare_versions_for_updates(left, right); - - if left_middle == Ordering::Less && middle_right == Ordering::Less { - assert_eq!( - left_right, - Ordering::Less, - "expected transitive ordering for {left} < {middle} < {right}" - ); - } - - if left_middle == Ordering::Greater && middle_right == Ordering::Greater { - assert_eq!( - left_right, - Ordering::Greater, - "expected transitive ordering for {left} > {middle} > {right}" - ); - } - - if left_middle == Ordering::Equal && middle_right == Ordering::Equal { - assert_eq!( - left_right, - Ordering::Equal, - "expected transitive equality for {left} == {middle} == {right}" - ); - } - } - } - } - } - - #[test] - fn extract_version_patterns_handles_git_and_release_urls() { - let git_patterns = - extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); - assert!(git_patterns.contains(&VersionPattern { - prefix: "v".into(), - suffix: String::new(), - })); - - let release_patterns = extract_version_patterns( - "https://github.com/Mic92/iana-etc/releases/download/$version/iana-etc-$version.tar.gz", - ); - assert!(release_patterns.contains(&VersionPattern { - prefix: String::new(), - suffix: String::new(), - })); - } - - #[test] - fn candidate_versions_from_refs_matches_version_patterns() { - let refs = vec![ - "refs/tags/v10.8.3".to_string(), - "refs/tags/v10.8.4".to_string(), - "refs/heads/main".to_string(), - ]; - let patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); - let candidates = candidate_versions_from_refs(&refs, &patterns); - - assert_eq!(candidates, vec!["10.8.3".to_string(), "10.8.4".to_string()]); - assert_eq!( - best_newer_version("10.8.3", candidates.iter().map(String::as_str)), - Some("10.8.4".to_string()) - ); - } - - #[test] - fn best_newer_version_skips_branches_and_prereleases() { - let candidates = ["2", "1.10.0rc1", "1.10.0", "release-0.13"]; - assert_eq!( - best_newer_version("1.9.5", candidates.into_iter()), - Some("1.10.0".to_string()) - ); - } - - #[test] - fn best_newer_version_normalizes_date_style_tags() { - let candidates = ["lts_2026_01_07", "lts_2027_02_03"]; - assert_eq!( - best_newer_version("20260107.1", candidates.into_iter()), - Some("20270203".to_string()) - ); - } - - #[test] - fn remote_git_repository_from_github_release_url_maps_to_repo_git_url() { - let repo_url = remote_git_repository_from_source_url( - "https://github.com/Mic92/iana-etc/releases/download/20260202/iana-etc-20260202.tar.gz", - ); - assert_eq!( - repo_url, - Some("https://github.com/Mic92/iana-etc.git".to_string()) - ); - } - - #[test] - fn remote_git_repository_from_gitlab_archive_url_maps_to_repo_git_url() { - let repo_url = remote_git_repository_from_source_url( - "https://gitlab.com/graphviz/graphviz/-/archive/14.1.4/graphviz-14.1.4.tar.gz", - ); - assert_eq!( - repo_url, - Some("https://gitlab.com/graphviz/graphviz.git".to_string()) - ); - } - - #[test] - fn archive_listing_probe_uses_parent_of_first_version_segment() { - let probe = archive_listing_probe( - "https://downloads.example.test/dav1d/$version/dav1d-$version.tar.xz", - "https://downloads.example.test/dav1d/1.5.3/dav1d-1.5.3.tar.xz", - ) - .expect("archive probe"); - assert_eq!(probe.listing_url, "https://downloads.example.test/dav1d/"); - assert_eq!( - probe.patterns, - vec![VersionPattern { - prefix: String::new(), - suffix: String::new(), - }] - ); - } - - #[test] - fn candidate_versions_from_listing_matches_archive_entries() { - let patterns = vec![VersionPattern { - prefix: "alsa-lib-".into(), - suffix: ".tar.bz2".into(), - }]; - let html = r#" - alsa-lib-1.2.15.3.tar.bz2 - alsa-lib-1.2.16.tar.bz2 - "#; - assert_eq!( - candidate_versions_from_listing(html, &patterns), - vec!["1.2.15.3".to_string(), "1.2.16".to_string()] - ); - } - - #[test] - fn list_archive_versions_reads_simple_http_index() -> Result<()> { - use std::io::{BufRead, BufReader, Write}; - use std::net::TcpListener; - use std::thread; - - let listener = TcpListener::bind("127.0.0.1:0").context("bind test listener")?; - let addr = listener.local_addr().context("listener addr")?; - let server = thread::spawn(move || -> Result<()> { - let (mut stream, _) = listener.accept().context("accept request")?; - let mut reader = BufReader::new(stream.try_clone().context("clone stream")?); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .context("read request line")?; - loop { - let mut line = String::new(); - reader.read_line(&mut line).context("read header line")?; - if line == "\r\n" || line.is_empty() { - break; - } - } - assert!(request_line.starts_with("GET /pub/lib/ HTTP/1.1")); - let body = r#" - - alsa-lib-1.2.15.3.tar.bz2 - alsa-lib-1.2.16.tar.bz2 - - "#; - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .context("write response")?; - stream.flush().context("flush response")?; - Ok(()) - }); - - let probe = ArchiveListingProbe { - listing_url: format!("http://{addr}/pub/lib/"), - patterns: vec![VersionPattern { - prefix: "alsa-lib-".into(), - suffix: ".tar.bz2".into(), - }], - }; - let versions = list_archive_versions(&probe)?; - server.join().expect("join server")?; - assert_eq!(versions, vec!["1.2.15.3".to_string(), "1.2.16".to_string()]); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn child_install_command_batches_multiple_requests_in_one_invocation() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-child-install.sh"); - let args_path = temp.path().join("args.txt"); - let env_path = temp.path().join("env.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s' \"${{DEPOT_DEPCHAIN:-}}\" > \"{}\"\n", - args_path.display(), - env_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - let requests = vec![ - PathBuf::from("/tmp/pkg-a.toml"), - PathBuf::from("/tmp/pkg-b.toml"), - ]; - let rootfs = Path::new("/"); - run_install_command_with_program( - &script_path, - &requests, - rootfs, - ChildInstallCommandOptions { - no_deps: false, - assume_yes: false, - no_flags: true, - cross_prefix: Some("x86_64-linux-musl"), - clean: true, - lib32_only: false, - install_test_deps: true, - install_context: None, - dep_chain: Some("parent"), - }, - )?; - - let captured_args = fs::read_to_string(&args_path) - .with_context(|| format!("Failed to read {}", args_path.display()))?; - assert_eq!( - captured_args.lines().collect::>(), - vec![ - "install", - "-r", - "/", - "--no-flags", - "--cross-prefix", - "x86_64-linux-musl", - "--clean", - "--test-deps", - "/tmp/pkg-a.toml", - "/tmp/pkg-b.toml", - ] - ); - assert_eq!(fs::read_to_string(&env_path)?, "parent"); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn child_install_command_includes_lib32_only_flag_when_requested() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-lib32-child-install.sh"); - let args_path = temp.path().join("args.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\n", - args_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - run_install_command_with_program( - &script_path, - &[PathBuf::from("/tmp/pkg.toml")], - Path::new("/"), - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: false, - cross_prefix: None, - clean: false, - lib32_only: true, - install_test_deps: false, - install_context: None, - dep_chain: None, - }, - )?; - - let captured_args = fs::read_to_string(&args_path) - .with_context(|| format!("Failed to read {}", args_path.display()))?; - assert!(captured_args.lines().any(|line| line == "--lib32-only")); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn child_install_command_propagates_install_context_env() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-child-install-context.sh"); - let env_path = temp.path().join("context.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s' \"${{{}:-}}\" > \"{}\"\n", - DEPOT_INSTALL_CONTEXT_ENV, - env_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - run_install_command_with_program( - &script_path, - &[PathBuf::from("/tmp/pkg.toml")], - Path::new("/"), - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: false, - cross_prefix: None, - clean: false, - lib32_only: false, - install_test_deps: false, - install_context: Some(INSTALL_CONTEXT_UPDATE), - dep_chain: None, - }, - )?; - - assert_eq!(fs::read_to_string(&env_path)?, INSTALL_CONTEXT_UPDATE); - Ok(()) - } - - #[test] - fn direct_install_checks_manual_sources_before_dependency_resolution() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let spec_dir = tempfile::tempdir().context("Failed to create temp spec dir")?; - let spec_path = spec_dir.path().join("demo.toml"); - fs::write( - &spec_path, - r#"[package] -name = "demo" -version = "1.0.0" -revision = 1 -description = "demo" -homepage = "https://example.test/demo" -license = "MIT" - -[build] -type = "custom" - -[dependencies] -runtime = ["definitely-missing-dep"] -optional = [] - -[[manual_sources]] -file = "missing.patch" -"#, - )?; - - let mut config = config::Config::for_rootfs(rootfs.path()); - config.build_dir = rootfs.path().join("var/cache/depot/build"); - config.cache_dir = rootfs.path().join("var/cache/depot/sources"); - config.db_dir = rootfs.path().join("var/lib/depot"); - - ui::set_assume_yes(true); - let result = run_direct_install_request( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: false, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &config, - spec_path, - ); - ui::set_assume_yes(false); - - let err = result.expect_err("missing manual source should fail before dependency install"); - assert!( - err.to_string() - .contains("Manual source not found: missing.patch") - ); - assert!( - !err.to_string() - .contains("Could not find package spec for dependency") - ); - Ok(()) - } - - #[test] - fn suppress_nested_install_output_for_planned_context() { - let mut env = TestEnv::new(); - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); - - assert!(suppress_nested_install_output()); - assert_eq!( - current_install_invocation_context(), - InstallInvocationContext::Planned - ); - } - - #[test] - fn sudo_preserve_env_arg_only_includes_present_depot_env_vars() { - let mut env = TestEnv::new(); - assert_eq!(sudo_preserve_env_arg(), None); - - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); - assert_eq!( - sudo_preserve_env_arg(), - Some(format!("--preserve-env={}", DEPOT_INSTALL_CONTEXT_ENV)) - ); - - env.set_var("DEPOT_DEPCHAIN", "parent"); - assert_eq!( - sudo_preserve_env_arg(), - Some(format!( - "--preserve-env={},DEPOT_DEPCHAIN", - DEPOT_INSTALL_CONTEXT_ENV - )) - ); - } - - #[test] - fn plan_dependency_closure_tracks_requested_dependency_roots() { - let plan = planner::ExecutionPlan { - steps: vec![ - planner::PlannedStep { - package: "zlib".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/zlib/zlib.toml"), - local_sibling: false, - }, - requested_by: vec!["cmake needs zlib".into()], - }, - planner::PlannedStep { - package: "cmake".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/cmake/cmake.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency cmake".into()], - }, - planner::PlannedStep { - package: "libffi".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/libffi/libffi.toml"), - local_sibling: false, - }, - requested_by: vec!["python needs libffi".into()], - }, - planner::PlannedStep { - package: "python".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/python/python.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency python".into()], - }, - ], - }; - - let cmake_closure = plan_dependency_closure_for_requested_deps(&plan, &["cmake".into()]); - assert_eq!( - cmake_closure, - HashSet::from(["cmake".to_string(), "zlib".to_string()]) - ); - - let python_closure = plan_dependency_closure_for_requested_deps(&plan, &["python".into()]); - assert_eq!( - python_closure, - HashSet::from(["python".to_string(), "libffi".to_string()]) - ); - } - - #[test] - fn cleanup_targets_keep_runtime_dependencies_for_build_install() { - let plan = planner::ExecutionPlan { - steps: vec![ - planner::PlannedStep { - package: "zlib".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/zlib/zlib.toml"), - local_sibling: false, - }, - requested_by: vec!["cmake needs zlib".into(), "llvm-runtime needs zlib".into()], - }, - planner::PlannedStep { - package: "cmake".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/cmake/cmake.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency cmake".into()], - }, - planner::PlannedStep { - package: "llvm-runtime".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/llvm-runtime/llvm-runtime.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency llvm-runtime".into()], - }, - ], - }; - let mut tracker = AutoInstalledDependencyTracker::default(); - tracker.record_plan(&plan, &["cmake".into()], AutoInstalledDependencyKind::Build); - tracker.record_plan( - &plan, - &["llvm-runtime".into()], - AutoInstalledDependencyKind::Runtime, - ); - - assert_eq!(tracker.cleanup_targets(false), vec!["cmake".to_string()]); - assert_eq!( - tracker.cleanup_targets(true), - vec![ - "llvm-runtime".to_string(), - "cmake".to_string(), - "zlib".to_string() - ] - ); - } - - #[test] - fn build_type_runs_automatic_tests_matches_builder_behavior() { - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Autotools, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Perl, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Meson, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::CMake, - None, - &[] - ))); - } - - #[test] - fn requested_test_deps_prompt_can_disable_tests() -> Result<()> { - let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(prompted); - assert!(spec.build.flags.skip_tests); - Ok(()) - } - - #[test] - fn requested_test_deps_prompt_is_ignored_for_non_automatic_test_builders() -> Result<()> { - let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(!prompted); - assert!(!spec.build.flags.skip_tests); - Ok(()) - } - - #[test] - fn requested_test_deps_prompt_is_ignored_for_multilib_builds() -> Result<()> { - let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.build.flags.build_32 = true; - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(!prompted); - assert!(!spec.build.flags.skip_tests); - Ok(()) - } - - #[test] - fn should_not_install_test_deps_for_cli_lib32_only_builds() { - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.dependencies.lib32 = Some(package::DependencyGroup { - build: Vec::new(), - runtime: Vec::new(), - test: vec!["lib32-pytest".into()], - optional: Vec::new(), - groups: Vec::new(), - }); - - assert!(!should_install_test_deps( - &spec, - true, - deps::RequestedOutputs::Lib32Only - )); - } - - #[test] - fn rootfs_is_system_root_detects_live_rootfs() { - assert!(rootfs_is_system_root(Path::new("/"))); - assert!(!rootfs_is_system_root(Path::new("/tmp/depot-test-rootfs"))); - } - - #[test] - fn command_requires_live_root_for_install_remove_and_update() { - assert!(command_requires_live_root(&Commands::Install( - InstallArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_or_archive: vec![PathBuf::from("foo")], - spec: None, - } - ))); - assert!(command_requires_live_root(&Commands::Remove(RemoveArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - package: "foo".to_string(), - }))); - assert!(command_requires_live_root(&Commands::Update(UpdateArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - packages: vec!["foo".to_string()], - }))); - assert!(!command_requires_live_root(&Commands::Build(BuildArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_pos: Some(PathBuf::from("foo.toml")), - spec: None, - install: false, - install_deps: false, - cleanup_deps: false, - }))); - assert!(!command_requires_live_root(&Commands::Search(SearchArgs { - rootfs_args: rootfs_args("/"), - query: "foo".to_string(), - files: false, - }))); - } - - #[test] - fn should_delegate_live_rootfs_installs_only_for_live_root_when_non_root() { - assert_eq!( - should_delegate_live_rootfs_installs(Path::new("/")), - !crate::fakeroot::is_root() - ); - assert!(!should_delegate_live_rootfs_installs(Path::new( - "/tmp/depot-test-rootfs" - ))); - } - - #[test] - fn live_rootfs_child_install_batches_group_consecutive_binary_steps() -> Result<()> { - let source_path = PathBuf::from("/tmp/requested.toml"); - let expat_archive = PathBuf::from("/tmp/expat.pkg.tar.zst"); - let python_archive = PathBuf::from("/tmp/python.pkg.tar.zst"); - let compiler_rt_archive = PathBuf::from("/tmp/lib32-compiler-rt.pkg.tar.zst"); - - let expat_record = test_binary_repo_record("expat", "expat-1.0-1-x86_64.depot.pkg.tar.zst"); - let python_record = - test_binary_repo_record("python", "python-1.0-1-x86_64.depot.pkg.tar.zst"); - let compiler_rt_record = test_binary_repo_record( - "lib32-compiler-rt", - "lib32-compiler-rt-1.0-1-x86_64.depot.pkg.tar.zst", - ); - - let steps = [ - planner::PlannedStep { - package: "expat".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(expat_record.clone()), - }, - requested_by: vec!["pkg needs expat".into()], - }, - planner::PlannedStep { - package: "python".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(python_record.clone()), - }, - requested_by: vec!["pkg needs python".into()], - }, - planner::PlannedStep { - package: "pkg".into(), - action: planner::PlanAction::BuildAndInstall, - origin: planner::PlanOrigin::Source { - path: source_path.clone(), - local_sibling: false, - }, - requested_by: vec!["requested spec".into()], - }, - planner::PlannedStep { - package: "lib32-compiler-rt".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(compiler_rt_record.clone()), - }, - requested_by: vec!["pkg needs lib32-compiler-rt".into()], - }, - ]; - let step_refs = steps.iter().collect::>(); - - let mut binary_archives = HashMap::new(); - binary_archives.insert( - ("core".to_string(), expat_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: expat_archive.clone(), - signature_path: PathBuf::from("/tmp/expat.sig"), - }, - ); - binary_archives.insert( - ("core".to_string(), python_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: python_archive.clone(), - signature_path: PathBuf::from("/tmp/python.sig"), - }, - ); - binary_archives.insert( - ("core".to_string(), compiler_rt_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: compiler_rt_archive.clone(), - signature_path: PathBuf::from("/tmp/lib32-compiler-rt.sig"), - }, - ); - - let options = InstallPlanExecutionOptions { - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - confirm_installation: false, - lib32_only_requested_specs: true, - install_test_deps: false, - }; - - let batches = - build_live_rootfs_child_install_batches(&step_refs, &options, &binary_archives)?; - - assert_eq!( - batches, - vec![ - ChildInstallBatch { - requests: vec![expat_archive, python_archive], - lib32_only: false, - }, - ChildInstallBatch { - requests: vec![source_path], - lib32_only: true, - }, - ChildInstallBatch { - requests: vec![compiler_rt_archive], - lib32_only: false, - }, - ] - ); - Ok(()) - } - - #[test] - fn build_command_requires_install_deps_flag_for_missing_dependencies() -> Result<()> { - let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_root = temp.path().join("packages"); - let app_dir = repo_root.join("app"); - let dep_dir = repo_root.join("dep"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&app_dir)?; - fs::create_dir_all(&dep_dir)?; - - let app_spec = app_dir.join("app.toml"); - fs::write( - &app_spec, - r#"[package] -name = "app" -version = "1.0.0" -revision = 1 -description = "app" -homepage = "https://example.test/app" -license = "MIT" - -[[source]] -url = "https://example.test/app-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "app-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = ["dep"] -runtime = [] -optional = [] -"#, - ) - .with_context(|| format!("Failed to write {}", app_spec.display()))?; - - let dep_spec = dep_dir.join("dep.toml"); - fs::write( - &dep_spec, - r#"[package] -name = "dep" -version = "1.0.0" -revision = 1 -description = "dep" -homepage = "https://example.test/dep" -license = "MIT" - -[[source]] -url = "https://example.test/dep-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "dep-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = [] -runtime = [] -optional = [] -"#, - ) - .with_context(|| format!("Failed to write {}", dep_spec.display()))?; - - let result = run(Cli { - command: Commands::Build(BuildArgs { - rootfs_args: rootfs_args(rootfs.clone()), - prompt_args: prompt_args(true), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_pos: Some(app_spec), - spec: None, - install: false, - install_deps: false, - cleanup_deps: false, - }), - }); - ui::set_assume_yes(false); - - let err = result.expect_err("build should require --install-deps when deps are missing"); - assert!(err.to_string().contains("Re-run with --install-deps")); - Ok(()) - } - - #[test] - fn make_lib32_build_spec_uses_only_lib32_flag_rules() { - let mut base = test_package_spec(package::BuildType::Custom, None, &[]); - base.build.flags.cflags = vec!["-O2".into()]; - base.build.flags.replace_cflags = vec!["-O2=>-O3".into()]; - base.build.flags.cflags_lib32 = vec!["-m32".into()]; - base.build.flags.replace_cflags_lib32 = vec!["-m32=>-mstackrealign".into()]; - base.build.flags.cxxflags = vec!["-O2".into()]; - base.build.flags.replace_cxxflags = vec!["-O2=>-O3".into()]; - base.build.flags.cxxflags_lib32 = vec!["-fno-rtti".into()]; - base.build.flags.replace_cxxflags_lib32 = vec!["-fno-rtti=>-fno-exceptions".into()]; - - let lib32 = make_lib32_build_spec(&base); - - assert!(lib32.build.flags.lib32_variant); - assert_eq!(lib32.build.flags.cflags, vec!["-m32"]); - assert_eq!( - lib32.build.flags.replace_cflags, - vec!["-m32=>-mstackrealign"] - ); - assert_eq!(lib32.build.flags.cxxflags, vec!["-fno-rtti"]); - assert_eq!( - lib32.build.flags.replace_cxxflags, - vec!["-fno-rtti=>-fno-exceptions"] - ); - } - - #[test] - fn make_lib32_package_spec_uses_lib32_dependency_override() { - let mut base = test_package_spec(package::BuildType::Custom, None, &[]); - base.dependencies.runtime = vec!["zlib".into()]; - base.dependencies.lib32 = Some(package::DependencyGroup { - build: vec!["gcc-multilib".into()], - runtime: vec!["lib32-zlib".into()], - test: Vec::new(), - optional: vec!["lib32-gtk-doc".into()], - groups: Vec::new(), - }); - - let lib32 = make_lib32_package_spec(&base); - - assert_eq!(lib32.package.name, "lib32-pkg"); - assert_eq!(lib32.dependencies.build, vec!["gcc-multilib"]); - assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib", "pkg"]); - assert_eq!(lib32.dependencies.optional, vec!["lib32-gtk-doc"]); - } - - #[test] - fn requested_outputs_prefers_lib32_only_spec_flag() { - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.build.flags.lib32_only = true; - - assert_eq!( - requested_outputs(&spec, false), - deps::RequestedOutputs::Lib32Only - ); - } - - #[test] - fn expand_install_requests_for_groups_uses_source_specs() -> Result<()> { - let temp = tempfile::tempdir()?; - let rootfs = temp.path().join("rootfs"); - let repo_root = temp.path().join("repos"); - let core = repo_root.join("core").join("foo"); - let desktop = repo_root.join("desktop").join("bar"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&core)?; - fs::create_dir_all(&desktop)?; - - let foo_spec = core.join("foo.toml"); - fs::write( - &foo_spec, - r#"[package] -name = "foo" -version = "1.0.0" -revision = 1 -description = "foo" -homepage = "https://example.test/foo" -license = "MIT" - -[[source]] -url = "https://example.test/foo-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "foo-1.0.0" - -[build] -type = "custom" - -[dependencies] -groups = ["base"] -runtime = [] -optional = [] -"#, - )?; - - let bar_spec = desktop.join("bar.toml"); - fs::write( - &bar_spec, - r#"[package] -name = "bar" -version = "1.0.0" -revision = 1 -description = "bar" -homepage = "https://example.test/bar" -license = "MIT" - -[[source]] -url = "https://example.test/bar-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "bar-1.0.0" - -[build] -type = "custom" - -[dependencies] -groups = ["base", "desktop"] -runtime = [] -optional = [] -"#, - )?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_root; - config.binary_repos.clear(); - - let (expanded, groups) = - expand_install_requests_for_groups(&config, &rootfs, &[PathBuf::from("base")])?; - - assert_eq!(groups, vec!["base".to_string()]); - assert_eq!(expanded, vec![PathBuf::from("bar"), PathBuf::from("foo")]); - Ok(()) - } - - #[test] - fn expand_installed_group_targets_uses_installed_group_membership() -> Result<()> { - let temp = tempfile::tempdir()?; - let rootfs = temp.path().join("rootfs"); - fs::create_dir_all(&rootfs)?; - let config = config::Config::for_rootfs(&rootfs); - let db_path = config.installed_db_path(&rootfs); - - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/bin"))?; - fs::write(dest.join("usr/bin/foo"), "foo")?; - - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.package.name = "foo".into(); - spec.dependencies.groups = vec!["base".into()]; - db::register_package(&db_path, &spec, &dest)?; - db::record_installed_groups(&db_path, &[String::from("base")])?; - - let (expanded, groups) = expand_installed_group_targets(&db_path, &[String::from("base")])?; - assert_eq!(groups, vec!["base".to_string()]); - assert_eq!(expanded, vec!["foo".to_string()]); - Ok(()) - } -} +mod tests; diff --git a/src/commands/build_cmd.rs b/src/commands/build_cmd.rs new file mode 100644 index 0000000..21922d8 --- /dev/null +++ b/src/commands/build_cmd.rs @@ -0,0 +1,533 @@ +use super::*; + +pub(crate) mod support; + +use self::support::{ + RequestedBuildToolPackageInstall, automatic_tests_disabled_for_outputs, + build_lib32_companion_package, clean_build_workspace, effective_lib32_only, + ensure_requested_build_tool_package_installed, maybe_disable_tests_for_missing_deps, + maybe_prompt_to_skip_tests_for_missing_requested_deps, merge_missing_dependencies, + requested_outputs, should_install_test_deps, warn_if_running_as_root_for_build, +}; + +pub(super) fn run_build(args: BuildArgs, cli_test_deps: bool) -> Result<()> { + let BuildArgs { + rootfs_args, + prompt_args, + build_exec_args, + lib32_args, + spec_pos, + spec, + install, + install_deps, + cleanup_deps, + } = args; + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + let clean = build_exec_args.clean; + 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 install_test_deps = install_test_deps_enabled(cli_test_deps, &config); + let interrupt_watcher = if cleanup_deps { + Some(InterruptWatcher::install()?) + } else { + None + }; + let mut auto_installed_deps = AutoInstalledDependencyTracker::default(); + let build_result: Result<()> = (|| { + pkg_spec.apply_config(&config); + let lib32_only = effective_lib32_only(&pkg_spec, cli_lib32_only); + let requested_outputs = requested_outputs(&pkg_spec, lib32_only); + + let build_targets = vec![format!( + "{} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )]; + if !ui::prompt_package_action("build", &build_targets, true)? { + anyhow::bail!("Aborted"); + } + + std::fs::create_dir_all(&config.db_dir).with_context(|| { + format!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + let db_path = config.installed_db_path(&rootfs); + + if no_deps && should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { + let missing_test = + deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if !missing_test.is_empty() + && !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies are missing", + )? + { + anyhow::bail!("Missing test dependencies: {}", missing_test.join(", ")); + } + } else if no_deps + || !should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) + { + maybe_disable_tests_for_missing_deps(&mut pkg_spec, &db_path, requested_outputs)?; + } + + if !no_deps { + deps::print_dep_status_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + let missing_build = + deps::check_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + let missing_runtime = + deps::check_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + let missing_required = + merge_missing_dependencies(missing_build.clone(), missing_runtime.clone()); + if !missing_required.is_empty() { + ui::warn(format!( + "Missing dependencies: {}", + missing_required.join(", ") + )); + if !install_deps { + if dry_run { + ui::info("Dry run enabled, stopping before dependency installation/build."); + return Ok(()); + } + anyhow::bail!( + "Missing dependencies: {}. Re-run with --install-deps to install them automatically, or install them manually.", + missing_required.join(", ") + ); + } + + let local_sibling_root = spec_path + .parent() + .and_then(|p| p.parent()) + .map(Path::to_path_buf); + let dep_plan = planner::build_dependency_install_plan( + &config, + &rootfs, + &missing_required, + planner::PlannerOptions { + assume_yes: yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root, + include_test_deps: install_test_deps, + lib32_only_requested_specs: false, + }, + )?; + if cleanup_deps { + auto_installed_deps.record_plan( + &dep_plan, + &missing_build, + AutoInstalledDependencyKind::Build, + ); + auto_installed_deps.record_plan( + &dep_plan, + &missing_runtime, + AutoInstalledDependencyKind::Runtime, + ); + } + print_plan_summary(&dep_plan); + if dry_run { + ui::info("Dry run enabled, stopping before dependency installation/build."); + return Ok(()); + } + execute_install_plan_with_child_commands( + &dep_plan, + &rootfs, + &config, + InstallPlanExecutionOptions { + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + confirm_installation: false, + lib32_only_requested_specs: false, + install_test_deps, + }, + )?; + } + deps::require_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + deps::require_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { + let missing_test = + deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if !missing_test.is_empty() { + let local_sibling_root = spec_path + .parent() + .and_then(|p| p.parent()) + .map(Path::to_path_buf); + let dep_plan = match planner::build_dependency_install_plan( + &config, + &rootfs, + &missing_test, + planner::PlannerOptions { + assume_yes: yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root, + include_test_deps: install_test_deps, + lib32_only_requested_specs: false, + }, + ) { + Ok(plan) => plan, + Err(_err) + if maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies could not be resolved", + )? => + { + planner::ExecutionPlan { steps: Vec::new() } + } + Err(err) => return Err(err), + }; + + if cleanup_deps + && !automatic_tests_disabled_for_outputs(&pkg_spec, requested_outputs) + { + auto_installed_deps.record_plan( + &dep_plan, + &missing_test, + AutoInstalledDependencyKind::Test, + ); + } + + if !automatic_tests_disabled_for_outputs(&pkg_spec, requested_outputs) + && !dep_plan.steps.is_empty() + { + ui::warn(format!( + "Missing test dependencies: {}", + missing_test.join(", ") + )); + print_plan_summary(&dep_plan); + if !install_deps { + if dry_run { + ui::info( + "Dry run enabled, stopping before dependency installation/build.", + ); + return Ok(()); + } + if !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies were not installed", + )? { + anyhow::bail!( + "Missing test dependencies: {}. Re-run with --install-deps to install them automatically, or install them manually.", + missing_test.join(", ") + ); + } + } else if !dry_run { + execute_install_plan_with_child_commands( + &dep_plan, + &rootfs, + &config, + InstallPlanExecutionOptions { + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + confirm_installation: false, + lib32_only_requested_specs: false, + install_test_deps, + }, + )?; + } + } + + if should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { + let missing_test = deps::check_test_deps_for_outputs( + &pkg_spec, + &db_path, + requested_outputs, + )?; + if !missing_test.is_empty() + && !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies are still missing", + )? + { + deps::require_test_deps_for_outputs( + &pkg_spec, + &db_path, + requested_outputs, + )?; + } + } + } + } + } + + ensure_requested_build_tool_package_installed(RequestedBuildToolPackageInstall { + build_type: pkg_spec.build.build_type, + rootfs: &rootfs, + config: &config, + db_path: &db_path, + spec_path: &spec_path, + execution: InstallPlanExecutionOptions { + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + confirm_installation: false, + lib32_only_requested_specs: false, + install_test_deps, + }, + assume_yes: yes, + })?; + + if dry_run { + ui::info("Dry run enabled, stopping before fetch/build."); + return Ok(()); + } + + let mut build_lock = locking::open_lock(&config)?; + let build_lock_path = locking::lock_path(&config); + let _build_lock_guard = locking::try_write(&mut build_lock, &build_lock_path, "build")?; + + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + + let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + + let destdir = config + .build_dir + .join("destdir") + .join(&pkg_spec.package.name); + let cross_config = cross_prefix + .as_ref() + .map(|p| cross::CrossConfig::from_prefix(p)) + .transpose()?; + let host_build_dir = builder::ensure_host_build( + &pkg_spec, + &src_dir, + cross_config.as_ref(), + !no_flags, + builder::TargetBuildKind::Primary, + )?; + if let Some(host_dir) = host_build_dir.as_ref() { + pkg_spec.build.flags.host_build_dir = Some(host_dir.to_string_lossy().into_owned()); + } + if !lib32_only { + builder::build( + &pkg_spec, + &src_dir, + &destdir, + cross_config.as_ref(), + !no_flags, + host_build_dir.as_deref(), + )?; + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + } + + if !lib32_only { + staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + staging::process(&destdir, &pkg_spec)?; + staging::stage_split_package_licenses(&src_dir, &destdir, &pkg_spec)?; + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + } + + let arch = cross_prefix.as_deref().unwrap_or(std::env::consts::ARCH); + + let mut created_files = Vec::new(); + let staged_outputs = if !lib32_only { + staged_output_specs(&pkg_spec, &destdir)? + } else { + Vec::new() + }; + if !lib32_only { + for (spec_for_out, out_destdir) in &staged_outputs { + let packager = package::Packager::new( + spec_for_out.clone(), + out_destdir.clone(), + config.clone(), + ); + let pkg_file = packager.create_package(Path::new("."), arch)?; + created_files.push(pkg_file); + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + } + } + + let mut lib32_install_bundle: Option<(package::PackageSpec, PathBuf)> = None; + if let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( + &pkg_spec, + &src_dir, + &config, + cross_config.as_ref(), + !no_flags, + lib32_only, + )? { + let packager = + package::Packager::new(lib32_spec.clone(), lib32_destdir.clone(), config.clone()); + let pkg_file = packager.create_package(Path::new("."), arch)?; + created_files.push(pkg_file); + lib32_install_bundle = Some((lib32_spec, lib32_destdir)); + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + } + + for f in &created_files { + ui::success(format!("Build complete. Package created: {}", f.display())); + } + + for sig_path in signing::auto_sign_zst_files_detached(&rootfs, &created_files)? { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } + + if install { + let mut install_targets = Vec::new(); + if !lib32_only { + for (spec_for_out, _) in &staged_outputs { + let out = &spec_for_out.package; + install_targets.push(format!("{} v{}-{}", out.name, out.version, out.revision)); + } + } + if let Some((lib32_spec, _)) = &lib32_install_bundle { + install_targets.push(format!( + "{} v{}-{}", + lib32_spec.package.name, + lib32_spec.package.version, + lib32_spec.package.revision + )); + } + if ui::prompt_package_action("installation", &install_targets, false)? { + if let Some(watcher) = interrupt_watcher.as_ref() { + watcher.check()?; + } + if should_delegate_live_rootfs_installs(&rootfs) { + run_child_install_command( + &created_files, + &rootfs, + InstallPlanExecutionOptions { + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + confirm_installation: false, + lib32_only_requested_specs: false, + install_test_deps, + }, + )?; + return Ok(()); + } + + let mut transaction_plans = Vec::new(); + if !lib32_only { + let output_plans = + plan_package_outputs_for_install(&pkg_spec, &destdir, &rootfs, &config)?; + transaction_plans.extend(output_plans); + } + if let Some((lib32_spec, lib32_destdir)) = &lib32_install_bundle { + let staged = plan_staged_install(lib32_spec, lib32_destdir, &rootfs, &config)?; + transaction_plans.push(PlannedPackageInstall { + spec: lib32_spec.clone(), + destdir: lib32_destdir.clone(), + staged, + }); + } + + run_transaction_hooks_for_plans( + &rootfs, + install::hooks::HookPhase::Pre, + &transaction_plans, + )?; + install_planned_packages_to_rootfs(&transaction_plans, &rootfs, &config)?; + run_transaction_hooks_for_plans( + &rootfs, + install::hooks::HookPhase::Post, + &transaction_plans, + )?; + + install::scripts::run_deferred_hooks_if_possible(&rootfs)?; + } else { + if !lib32_only { + for (spec_for_out, _) in &staged_outputs { + let out = &spec_for_out.package; + ui::success(format!( + "Built successfully: {}-{}-{}", + out.name, out.version, out.revision + )); + } + } + if let Some((lib32_spec, _)) = &lib32_install_bundle { + ui::success(format!( + "Built successfully: {}-{}-{}", + lib32_spec.package.name, + lib32_spec.package.version, + lib32_spec.package.revision + )); + } + } + } + + Ok(()) + })(); + + let interrupted = interrupt_watcher + .as_ref() + .is_some_and(InterruptWatcher::was_interrupted); + match build_result { + Ok(()) => { + if cleanup_deps && !auto_installed_deps.is_empty() { + cleanup_auto_installed_dependencies( + &auto_installed_deps, + &rootfs, + &config, + !install, + false, + )?; + } + if clean { + clean_build_workspace(&config)?; + } + } + Err(err) => { + if cleanup_deps && !auto_installed_deps.is_empty() { + if interrupted { + ui::warn("Build interrupted by Ctrl-C."); + } + if let Err(clean_err) = cleanup_auto_installed_dependencies( + &auto_installed_deps, + &rootfs, + &config, + !install, + interrupted, + ) { + ui::warn(format!( + "Failed to remove auto-installed dependencies: {}", + clean_err + )); + } + } + if interrupted { + anyhow::bail!("Build interrupted by Ctrl-C"); + } + return Err(err); + } + } + + Ok(()) +} diff --git a/src/commands/build_cmd/support.rs b/src/commands/build_cmd/support.rs new file mode 100644 index 0000000..eafe472 --- /dev/null +++ b/src/commands/build_cmd/support.rs @@ -0,0 +1,325 @@ +use super::*; + +pub(crate) fn build_type_runs_automatic_tests(spec: &package::PackageSpec) -> bool { + matches!( + spec.build.build_type, + package::BuildType::Autotools + | package::BuildType::CMake + | package::BuildType::Meson + | package::BuildType::Perl + ) +} + +pub(crate) fn automatic_tests_disabled_for_outputs( + pkg_spec: &package::PackageSpec, + requested_outputs: deps::RequestedOutputs, +) -> bool { + pkg_spec.should_skip_automatic_tests() || requested_outputs.includes_lib32() +} + +pub(crate) fn maybe_disable_tests_for_missing_deps( + pkg_spec: &mut package::PackageSpec, + db_path: &Path, + requested_outputs: deps::RequestedOutputs, +) -> Result<()> { + if automatic_tests_disabled_for_outputs(pkg_spec, requested_outputs) + || !build_type_runs_automatic_tests(pkg_spec) + || deps::declared_test_deps(pkg_spec, requested_outputs).is_empty() + { + return Ok(()); + } + + let missing_test = deps::check_test_deps_for_outputs(pkg_spec, db_path, requested_outputs)?; + if !missing_test.is_empty() { + ui::warn(format!( + "Missing test dependencies: {}. Tests will be skipped.", + missing_test.join(", ") + )); + pkg_spec.build.flags.skip_tests = true; + } + + Ok(()) +} + +pub(crate) fn maybe_prompt_to_skip_tests_for_missing_requested_deps( + pkg_spec: &mut package::PackageSpec, + missing_test: &[String], + reason: &str, +) -> Result { + if pkg_spec.should_skip_automatic_tests() + || !build_type_runs_automatic_tests(pkg_spec) + || missing_test.is_empty() + { + return Ok(false); + } + + ui::warn(format!("{reason}: {}", missing_test.join(", "))); + if ui::prompt_yes_no("Continue without tests?", false)? { + pkg_spec.build.flags.skip_tests = true; + ui::warn("Tests will be skipped for this build."); + return Ok(true); + } + + Ok(false) +} + +pub(crate) fn requested_outputs( + pkg_spec: &package::PackageSpec, + lib32_only: bool, +) -> deps::RequestedOutputs { + if effective_lib32_only(pkg_spec, lib32_only) { + deps::RequestedOutputs::Lib32Only + } else if pkg_spec.builds_lib32_output() { + deps::RequestedOutputs::PrimaryAndLib32 + } else { + deps::RequestedOutputs::PrimaryOnly + } +} + +pub(crate) fn effective_lib32_only(pkg_spec: &package::PackageSpec, cli_lib32_only: bool) -> bool { + cli_lib32_only || pkg_spec.builds_only_lib32_output() +} + +pub(crate) fn should_install_test_deps( + pkg_spec: &package::PackageSpec, + install_test_deps: bool, + requested_outputs: deps::RequestedOutputs, +) -> bool { + install_test_deps + && !automatic_tests_disabled_for_outputs(pkg_spec, requested_outputs) + && !deps::declared_test_deps(pkg_spec, requested_outputs).is_empty() +} + +pub(crate) fn clean_build_workspace(config: &config::Config) -> Result<()> { + if config.build_dir.exists() { + fs::remove_dir_all(&config.build_dir).with_context(|| { + format!("Failed to clean build dir: {}", config.build_dir.display()) + })?; + ui::success(format!( + "Cleaned build workspace: {}", + config.build_dir.display() + )); + } + if config.cache_dir.exists() { + fs::remove_dir_all(&config.cache_dir).with_context(|| { + format!( + "Failed to clean source cache dir: {}", + config.cache_dir.display() + ) + })?; + ui::success(format!( + "Cleaned source cache: {}", + config.cache_dir.display() + )); + } + Ok(()) +} + +pub(crate) fn warn_if_running_as_root_for_build(command: &str, rootfs: &Path) { + if crate::fakeroot::is_root() { + 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.", + ); + ui::warn("Recommendation: use a non-root build user and only install as root."); + ui::warn(format!("Current rootfs target: {}", rootfs.display())); + } +} + +pub(crate) fn merge_missing_dependencies(mut base: Vec, extra: Vec) -> Vec { + for dep in extra { + if !base.contains(&dep) { + base.push(dep); + } + } + base +} + +pub(crate) fn make_lib32_build_spec(base: &package::PackageSpec) -> package::PackageSpec { + let mut spec = base.clone(); + let flags = &mut spec.build.flags; + flags.lib32_variant = true; + flags.cflags = flags.cflags_lib32.clone(); + flags.replace_cflags = flags.replace_cflags_lib32.clone(); + flags.cxxflags = flags.cxxflags_lib32.clone(); + flags.replace_cxxflags = flags.replace_cxxflags_lib32.clone(); + if !flags.configure_lib32.is_empty() { + flags.configure = flags.configure_lib32.clone(); + } + if !flags.post_configure_lib32.is_empty() { + flags.post_configure = flags.post_configure_lib32.clone(); + } + if !flags.post_compile_lib32.is_empty() { + flags.post_compile = flags.post_compile_lib32.clone(); + } + if !flags.post_install_lib32.is_empty() { + flags.post_install = flags.post_install_lib32.clone(); + } + + flags.cc = format!("{} -m32", flags.cc.trim()); + flags.cxx = format!("{} -m32", flags.cxx.trim()); + flags.build_dir = Some(match flags.build_dir.as_deref() { + Some(dir) if !dir.trim().is_empty() => format!("{}-lib32", dir.trim()), + _ => "build-lib32".to_string(), + }); + + spec +} + +pub(crate) fn make_lib32_package_spec(base: &package::PackageSpec) -> package::PackageSpec { + let mut spec = base.clone(); + let lib32_name = format!("lib32-{}", base.package.name); + spec.package.name = lib32_name.clone(); + spec.packages.clear(); + spec.alternatives = base.alternatives_for_output(&lib32_name); + spec.dependencies = base.dependencies_for_output(&lib32_name); + spec +} + +pub(crate) struct RequestedBuildToolPackageInstall<'a> { + pub(crate) build_type: package::BuildType, + pub(crate) rootfs: &'a Path, + pub(crate) config: &'a config::Config, + pub(crate) db_path: &'a Path, + pub(crate) spec_path: &'a Path, + pub(crate) execution: InstallPlanExecutionOptions<'a>, + pub(crate) assume_yes: bool, +} + +pub(crate) fn ensure_requested_build_tool_package_installed( + request: RequestedBuildToolPackageInstall<'_>, +) -> Result<()> { + let Some(package_name) = builder::requested_build_tool_package(request.build_type) else { + return Ok(()); + }; + let build_tool_option = builder::build_tool_package_option(request.build_type); + + if deps::is_dep_satisfied_in_db(&package_name, request.db_path)? { + return Ok(()); + } + + ui::warn(format!( + "Missing required build tool package for {:?} builds{}: {}", + request.build_type, + build_tool_option + .map(|option| format!(" ({option})")) + .unwrap_or_default(), + package_name + )); + + let local_sibling_root = request + .spec_path + .parent() + .and_then(|path| path.parent()) + .map(Path::to_path_buf); + let plan = planner::build_dependency_install_plan( + request.config, + request.rootfs, + std::slice::from_ref(&package_name), + planner::PlannerOptions { + assume_yes: request.assume_yes, + prefer_binary: request.config.repo_settings.prefer_binary, + local_sibling_root, + include_test_deps: request.execution.install_test_deps, + lib32_only_requested_specs: false, + }, + ) + .with_context(|| { + format!( + "Failed to resolve required build tool package '{}'", + package_name + ) + })?; + + if plan.steps.is_empty() { + anyhow::bail!( + "Required build tool package '{}' is not installed and no install plan could be created", + package_name + ); + } + + super::print_plan_summary(&plan); + super::execute_install_plan_with_child_commands( + &plan, + request.rootfs, + request.config, + request.execution, + ) +} + +pub(crate) fn build_lib32_companion_package( + pkg_spec: &package::PackageSpec, + src_dir: &Path, + config: &config::Config, + cross_config: Option<&cross::CrossConfig>, + export_compiler_flags: bool, + force: bool, +) -> Result> { + if !pkg_spec.builds_lib32_output() && !force { + return Ok(None); + } + if pkg_spec.is_metapackage() { + crate::log_warn!( + "Ignoring build.flags.build-32 for metapackage {}", + pkg_spec.package.name + ); + return Ok(None); + } + + crate::log_info!("Running separate lib32 build pass..."); + let host_build_dir = builder::ensure_host_build( + pkg_spec, + src_dir, + cross_config, + export_compiler_flags, + builder::TargetBuildKind::Lib32, + )?; + let mut lib32_input = pkg_spec.clone(); + lib32_input.build.flags.build_32 = true; + let mut lib32_build_spec = make_lib32_build_spec(&lib32_input); + if let Some(host_dir) = host_build_dir.as_ref() { + lib32_build_spec.build.flags.host_build_dir = Some(host_dir.to_string_lossy().into_owned()); + } + let lib32_pkg_spec = make_lib32_package_spec(pkg_spec); + let lib32_destdir = config + .build_dir + .join("destdir") + .join(&lib32_pkg_spec.package.name); + if lib32_destdir.exists() { + fs::remove_dir_all(&lib32_destdir).with_context(|| { + format!("Failed to clean lib32 destdir: {}", lib32_destdir.display()) + })?; + } + fs::create_dir_all(&lib32_destdir).with_context(|| { + format!( + "Failed to create lib32 destdir: {}", + lib32_destdir.display() + ) + })?; + + builder::build( + &lib32_build_spec, + src_dir, + &lib32_destdir, + cross_config, + export_compiler_flags, + host_build_dir.as_deref(), + )?; + + let lib32_src = lib32_destdir.join("usr/lib32"); + if !lib32_src.exists() { + anyhow::bail!( + "lib32 build completed but did not install usr/lib32 into {}", + lib32_destdir.display() + ); + } + install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; + staging::process(&lib32_destdir, &lib32_pkg_spec)?; + staging::symlink_package_license( + &lib32_destdir, + &lib32_pkg_spec.package.name, + &pkg_spec.package.name, + )?; + + Ok(Some((lib32_pkg_spec, lib32_destdir))) +} diff --git a/src/commands/check.rs b/src/commands/check.rs new file mode 100644 index 0000000..dc1e78c --- /dev/null +++ b/src/commands/check.rs @@ -0,0 +1,7 @@ +use super::*; +use crate::commands::update::check::run_check_command; + +pub(super) fn run_check(args: crate::cli::CheckArgs) -> Result<()> { + let dir = args.dir; + run_check_command(&dir) +} diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs new file mode 100644 index 0000000..103064a --- /dev/null +++ b/src/commands/install_cmd.rs @@ -0,0 +1,163 @@ +use super::*; +use crate::commands::repo::groups::{ + expand_install_requests_for_groups, expand_installed_group_targets, +}; + +pub(crate) mod archive; + +pub(super) fn run_install(args: InstallArgs, cli_test_deps: bool) -> Result<()> { + let InstallArgs { + rootfs_args, + prompt_args, + build_exec_args, + lib32_args, + spec_or_archive, + spec, + } = args; + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + let clean = build_exec_args.clean; + let dry_run = build_exec_args.dry_run; + let lib32_only = lib32_args.lib32_only; + let install_requests = match spec { + Some(spec_path) => vec![spec_path], + None => spec_or_archive, + }; + + let config = config::Config::for_rootfs(&rootfs); + ensure_depot_self_update_not_required(&config, &rootfs)?; + let (install_requests, explicit_groups) = + expand_install_requests_for_groups(&config, &rootfs, &install_requests)?; + 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(); + let mut direct_requests = Vec::new(); + + if no_deps { + direct_requests = install_requests; + } else { + for request in install_requests { + if is_archive_install_request(&request) { + direct_requests.push(request); + continue; + } + if request.exists() { + planned_spec_paths.push(request.clone()); + planned_targets.push(planner::InstallTarget::SpecPath(request)); + } else { + planned_targets.push(planner::InstallTarget::PackageName( + request.to_string_lossy().to_string(), + )); + } + } + } + + let mut ran_plan_mode = false; + if !planned_targets.is_empty() { + ran_plan_mode = true; + let planner_opts = planner::PlannerOptions { + assume_yes: yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root: shared_local_sibling_root(&planned_spec_paths), + include_test_deps: install_test_deps, + lib32_only_requested_specs: lib32_only, + }; + let plan = if planned_targets.len() == 1 { + planner::build_install_plan(&config, &rootfs, planned_targets[0].clone(), planner_opts)? + } else { + planner::build_install_plan_for_targets( + &config, + &rootfs, + &planned_targets, + planner_opts, + )? + }; + print_plan_summary(&plan); + execute_install_plan_with_child_commands( + &plan, + &rootfs, + &config, + InstallPlanExecutionOptions { + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + confirm_installation: true, + lib32_only_requested_specs: lib32_only, + install_test_deps, + }, + )?; + } + + let mut ran_direct_install = false; + let direct_install_options = DirectInstallOptions { + rootfs: &rootfs, + no_deps, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + lib32_only, + install_test_deps, + }; + if direct_requests.len() > 1 + && direct_requests + .iter() + .all(|request| is_archive_install_request(request)) + { + ran_direct_install |= run_direct_archive_install_requests( + direct_install_options, + &config, + &direct_requests, + true, + )?; + } else { + for request in direct_requests { + ran_direct_install |= + run_direct_install_request(direct_install_options, &config, request)?; + } + } + if ran_direct_install { + install::scripts::run_deferred_hooks_if_possible(&rootfs)?; + } + + if !dry_run && !explicit_groups.is_empty() { + db::record_installed_groups(&config.installed_db_path(&rootfs), &explicit_groups)?; + } + + if clean && (ran_plan_mode || ran_direct_install) { + clean_build_workspace(&config)?; + } + + Ok(()) +} + +pub(super) fn run_remove(args: RemoveArgs) -> Result<()> { + let RemoveArgs { + rootfs_args, + package, + .. + } = args; + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); + let mut remove_lock = locking::open_lock(&config)?; + let remove_lock_path = locking::lock_path(&config); + let _remove_lock_guard = locking::try_write(&mut remove_lock, &remove_lock_path, "remove")?; + let db_path = config.installed_db_path(&rootfs); + let (removal_targets, explicit_groups) = + expand_installed_group_targets(&db_path, std::slice::from_ref(&package))?; + if !ui::prompt_package_action("removal", &removal_targets, true)? { + anyhow::bail!("Aborted"); + } + for target in &removal_targets { + remove_installed_package_with_hooks(target, &rootfs, &config)?; + } + for group in explicit_groups { + db::remove_installed_group(&db_path, &group)?; + } + + Ok(()) +} diff --git a/src/commands/install_cmd/archive.rs b/src/commands/install_cmd/archive.rs new file mode 100644 index 0000000..6361371 --- /dev/null +++ b/src/commands/install_cmd/archive.rs @@ -0,0 +1,288 @@ +use super::*; + +fn package_spec_from_archive_metadata(metadata: &toml::Value) -> package::PackageSpec { + let mut spec = package::PackageSpec { + package: package::PackageInfo { + name: metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + real_name: metadata + .get("real_name") + .and_then(|v| v.as_str()) + .map(String::from), + version: metadata + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + revision: metadata + .get("revision") + .and_then(|v| v.as_integer()) + .unwrap_or(1) as u32, + description: metadata + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + homepage: metadata + .get("homepage") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + abi_breaking: metadata + .get("abi_breaking") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + license: super::super::parse_licenses_from_toml(metadata), + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags { + keep: super::super::parse_keep_list(metadata), + ..package::BuildFlags::default() + }, + }, + dependencies: package::Dependencies { + build: Vec::new(), + runtime: super::super::parse_dependency_list(metadata, "runtime"), + test: Vec::new(), + optional: super::super::parse_dependency_list(metadata, "optional"), + groups: super::super::parse_dependency_list(metadata, "groups"), + lib32: None, + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { + spec.alternatives.provides = provides + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(conflicts) = metadata.get("conflicts").and_then(|v| v.as_array()) { + spec.alternatives.conflicts = conflicts + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(replaces) = metadata.get("replaces").and_then(|v| v.as_array()) { + spec.alternatives.replaces = replaces + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + + spec +} + +fn load_package_spec_from_staging(staged_dir: &Path) -> Result { + let metadata_path = staged_dir.join(".metadata.toml"); + let metadata_content = fs::read_to_string(&metadata_path) + .with_context(|| format!("Failed to read {}", metadata_path.display()))?; + let metadata: toml::Value = toml::from_str(&metadata_content) + .with_context(|| format!("Failed to parse {}", metadata_path.display()))?; + Ok(package_spec_from_archive_metadata(&metadata)) +} + +fn parse_license_list_from_repo(license: &Option) -> Vec { + let Some(raw) = license.as_ref() else { + return Vec::new(); + }; + raw.split(',') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .map(String::from) + .collect() +} + +pub(crate) fn package_spec_from_repo_record( + record: &db::repo::BinaryRepoPackageRecord, +) -> package::PackageSpec { + package::PackageSpec { + package: package::PackageInfo { + name: record.name.clone(), + real_name: record.real_name.clone(), + version: record.version.clone(), + revision: record.revision, + description: record.description.clone().unwrap_or_default(), + homepage: record.homepage.clone().unwrap_or_default(), + abi_breaking: record.abi_breaking, + license: parse_license_list_from_repo(&record.license), + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: record.provides.clone(), + conflicts: record.conflicts.clone(), + replaces: record.replaces.clone(), + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies { + build: Vec::new(), + runtime: record.runtime_dependencies.clone(), + test: Vec::new(), + optional: record.optional_dependencies.clone(), + groups: Vec::new(), + lib32: None, + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +pub(crate) fn load_package_spec_from_staging_or_repo_record( + staged_dir: &Path, + record: &db::repo::BinaryRepoPackageRecord, +) -> Result { + let metadata_path = staged_dir.join(".metadata.toml"); + if metadata_path.exists() { + load_package_spec_from_staging(staged_dir) + } else { + Ok(package_spec_from_repo_record(record)) + } +} + +pub(crate) fn staging_temp_root(config: &config::Config) -> PathBuf { + config.build_dir.join("staging") +} + +fn create_archive_staging_dir( + config: &config::Config, + archive_path: &Path, +) -> Result { + let staging_root = staging_temp_root(config); + fs::create_dir_all(&staging_root) + .with_context(|| format!("Failed to create staging root {}", staging_root.display()))?; + tempfile::Builder::new() + .prefix("archive-") + .tempdir_in(&staging_root) + .with_context(|| { + format!( + "Failed to create staging dir for {} under {}", + archive_path.display(), + staging_root.display() + ) + }) +} + +pub(crate) fn load_package_archive_into_staging( + config: &config::Config, + archive_path: &Path, +) -> Result<(package::PackageSpec, tempfile::TempDir)> { + let tmp_dir = create_archive_staging_dir(config, archive_path).with_context(|| { + format!( + "Failed to create staging dir for {}", + archive_path.display() + ) + })?; + let extract_dir = tmp_dir.path().to_path_buf(); + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + + let mut metadata_content = String::new(); + for entry in archive.entries().with_context(|| { + format!( + "Failed to read archive entries from {}", + archive_path.display() + ) + })? { + crate::interrupts::check()?; + let mut entry = entry.with_context(|| { + format!( + "Failed to read archive entry from {}", + archive_path.display() + ) + })?; + if entry.path()?.to_string_lossy() == ".metadata.toml" { + use std::io::Read; + entry + .read_to_string(&mut metadata_content) + .with_context(|| { + format!( + "Failed to read .metadata.toml in {}", + archive_path.display() + ) + })?; + break; + } + } + + if metadata_content.is_empty() { + anyhow::bail!( + "Package archive does not contain .metadata.toml: {}", + archive_path.display() + ); + } + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + archive.set_preserve_permissions(true); + crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { + format!( + "Failed to extract package archive {} into {}", + archive_path.display(), + extract_dir.display() + ) + })?; + + let metadata: toml::Value = toml::from_str(&metadata_content).with_context(|| { + format!( + "Failed to parse .metadata.toml in {}", + archive_path.display() + ) + })?; + + Ok((package_spec_from_archive_metadata(&metadata), tmp_dir)) +} + +pub(crate) fn extract_package_archive_to_staging( + config: &config::Config, + archive_path: &Path, +) -> Result { + let tmp_dir = create_archive_staging_dir(config, archive_path).with_context(|| { + format!( + "Failed to create staging dir for {}", + archive_path.display() + ) + })?; + let extract_dir = tmp_dir.path().to_path_buf(); + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + archive.set_preserve_permissions(true); + crate::interrupts::unpack_tar_archive(&mut archive, &extract_dir).with_context(|| { + format!( + "Failed to extract package archive {} into {}", + archive_path.display(), + extract_dir.display() + ) + })?; + Ok(tmp_dir) +} diff --git a/src/commands/misc.rs b/src/commands/misc.rs new file mode 100644 index 0000000..8e6c4a8 --- /dev/null +++ b/src/commands/misc.rs @@ -0,0 +1,194 @@ +use super::*; + +pub(crate) mod internal; + +pub(super) fn run_info(args: InfoArgs) -> Result<()> { + let InfoArgs { + rootfs_args, + package, + } = args; + let rootfs = rootfs_args.rootfs; + let path = PathBuf::from(&package); + if path.exists() { + let config = config::Config::for_rootfs(&rootfs); + let info_lock = locking::open_lock(&config)?; + let info_lock_path = locking::lock_path(&config); + let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; + let pkg_spec = package::PackageSpec::from_file(&path)?; + println!("{}", pkg_spec); + + let db_path = config.installed_db_path(&rootfs); + deps::print_dep_status(&pkg_spec, &db_path)?; + } else { + let config = config::Config::for_rootfs(&rootfs); + let info_lock = locking::open_lock(&config)?; + let info_lock_path = locking::lock_path(&config); + let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; + let db_path = config.installed_db_path(&rootfs); + db::show_package_info(&db_path, &package)?; + } + + Ok(()) +} + +pub(super) fn run_owns(args: OwnsArgs) -> Result<()> { + let OwnsArgs { rootfs_args, path } = args; + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); + let owns_lock = locking::open_lock(&config)?; + let owns_lock_path = locking::lock_path(&config); + let _owns_lock_guard = locking::try_read(&owns_lock, &owns_lock_path, "owns")?; + let db_path = config.installed_db_path(&rootfs); + match db::owns_path(&db_path, &path)? { + Some(owner) => ui::info(format!("{} is owned by {}", path.display(), owner)), + None => ui::warn(format!("No installed package owns {}", path.display())), + } + + Ok(()) +} + +pub(super) fn run_list(args: ListArgs) -> Result<()> { + let ListArgs { rootfs_args } = args; + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); + let list_lock = locking::open_lock(&config)?; + let list_lock_path = locking::lock_path(&config); + let _list_lock_guard = locking::try_read(&list_lock, &list_lock_path, "list")?; + let db_path = config.installed_db_path(&rootfs); + db::list_packages(&db_path)?; + Ok(()) +} + +pub(super) fn run_sign(args: SignArgs) -> Result<()> { + let SignArgs { rootfs_args, files } = args; + let rootfs = rootfs_args.rootfs; + let sig_paths = signing::sign_zst_files_detached(&rootfs, &files)?; + for sig_path in sig_paths { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } + Ok(()) +} + +pub(super) fn run_generate_artifacts(args: crate::cli::GenerateArtifactsArgs) -> Result<()> { + let out_dir = args.out_dir; + cli_assets::generate_cli_assets(&out_dir)?; + ui::success(format!("Generated CLI assets in {}", out_dir.display())); + Ok(()) +} + +pub(super) fn run_config(args: ConfigArgs) -> Result<()> { + let ConfigArgs { rootfs_args } = args; + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); + let config_lock = locking::open_lock(&config)?; + let config_lock_path = locking::lock_path(&config); + let _config_lock_guard = locking::try_read(&config_lock, &config_lock_path, "config")?; + println!("Cache Directory: {}", config.cache_dir.display()); + println!( + "Package Cache Directory: {}", + config.package_cache_dir.display() + ); + println!("Build Directory: {}", config.build_dir.display()); + println!("Database Directory: {}", config.db_dir.display()); + println!("Repo Clone Directory: {}", config.repo_clone_dir.display()); + println!("Install Test Deps: {}", config.install_test_deps); + println!( + "Configured Repos: {} source, {} binary", + config.source_repos.len(), + config.binary_repos.len() + ); + println!("\nBuild Overrides: {}", config.build_overrides); + println!("Package Overrides: {}", config.package_overrides); + if !config.appends.is_empty() { + println!("\nAppends:"); + for (k, v) in &config.appends { + println!(" {} = {:?}", k, v); + } + } + + Ok(()) +} + +pub(super) fn run_make_spec(args: crate::cli::MakeSpecArgs) -> Result<()> { + let output = args.output; + let spec = package::create_interactive()?; + let toml_string = package::spec_to_minimal_toml(&spec)?; + let output_path = + output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); + + if output_path.exists() { + 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)?; + ui::success(format!( + "Package specification saved to {}", + output_path.display() + )); + Ok(()) +} + +pub(super) fn run_convert(args: ConvertArgs) -> Result<()> { + let ConvertArgs { input, output } = args; + let converted = package::convert_starbuild_file(&input, output.as_deref())?; + let mut outputs = vec![converted.output_path.clone()]; + if let Some(build_script_path) = &converted.build_script_path { + outputs.push(build_script_path.clone()); + } + + let existing: Vec<_> = outputs + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if !existing.is_empty() { + ui::warn(format!( + "Generated files already exist: {}", + existing.join(", ") + )); + if !ui::prompt_yes_no("Overwrite them?", false)? { + anyhow::bail!("Aborted"); + } + } + + fs::write(&converted.output_path, converted.toml) + .with_context(|| format!("Failed to write {}", converted.output_path.display()))?; + if let (Some(build_script), Some(build_script_path)) = + (converted.build_script, converted.build_script_path) + { + fs::write(&build_script_path, build_script) + .with_context(|| format!("Failed to write {}", build_script_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&build_script_path) + .with_context(|| format!("Failed to stat {}", build_script_path.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&build_script_path, perms) + .with_context(|| format!("Failed to chmod {}", build_script_path.display()))?; + } + ui::success(format!( + "Converted STARBUILD into {} and {}", + converted.output_path.display(), + build_script_path.display() + )); + } else { + ui::success(format!( + "Converted STARBUILD into {}", + converted.output_path.display() + )); + } + + Ok(()) +} + +pub(super) fn run_internal(args: crate::cli::InternalArgs) -> Result<()> { + internal::run_internal_command(args.command) +} diff --git a/src/commands/misc/internal.rs b/src/commands/misc/internal.rs new file mode 100644 index 0000000..b4e27a3 --- /dev/null +++ b/src/commands/misc/internal.rs @@ -0,0 +1,105 @@ +use super::*; + +fn current_process_env_vars() -> Vec<(String, String)> { + const ALLOWED_ENV_VARS: &[&str] = &[ + "AR", + "CARCH", + "CBUILD", + "CC", + "CHOST", + "CPP", + "CROSS_COMPILE", + "CROSS_PREFIX", + "CFLAGS", + "CXX", + "CXXFLAGS", + "DEPOT_ROOTFS", + "DEPOT_SPECDIR", + "DESTDIR", + "LD", + "LDFLAGS", + "LTOFLAGS", + "MAKEFLAGS", + "NM", + "PREFIX", + "PYTHONDONTWRITEBYTECODE", + "PYTHONNOUSERSITE", + "RANLIB", + "RUSTFLAGS", + "RUSTLTOFLAGS", + "SETUPTOOLS_USE_DISTUTILS", + "STRIP", + ]; + + ALLOWED_ENV_VARS + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect() +} + +pub(crate) fn run_internal_command(command: InternalCommands) -> Result<()> { + match command { + InternalCommands::PythonBuild { + src_dir, + dist_dir, + config_settings, + } => { + let env_vars = current_process_env_vars(); + crate::builder::python::build_wheels(&src_dir, &dist_dir, &env_vars, &config_settings) + } + InternalCommands::PythonInstall { + dist_dir, + wheels, + prefix, + } => { + let env_vars = current_process_env_vars(); + let wheel_paths = if wheels.is_empty() { + crate::builder::python::collect_wheels(&dist_dir)? + } else { + wheels + }; + let destdir = std::env::var("DESTDIR") + .context("DESTDIR must be set for internal python-install")?; + crate::builder::python::install_wheels( + &wheel_paths, + Path::new(&destdir), + &prefix, + &env_vars, + ) + } + InternalCommands::Clone { repo, dest } => { + let (base, rev) = crate::source::split_git_url(&repo).with_context(|| { + format!("Unsupported repository URL for internal clone: {}", repo) + })?; + let dest = if let Some(dest) = dest { + dest + } else { + let cwd = + std::env::current_dir().context("Failed to determine current directory")?; + cwd.join(crate::source::git_default_checkout_dir_name(&base)) + }; + if dest.exists() { + anyhow::bail!("Clone destination already exists: {}", dest.display()); + } + let cache_root = + tempfile::tempdir().context("Failed to create temporary git cache for clone")?; + let label = dest + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("clone"); + crate::source::git_checkout( + &base, + &rev, + &dest, + &cache_root.path().join("git"), + label, + &[], + ) + } + } +} diff --git a/src/commands/repo.rs b/src/commands/repo.rs new file mode 100644 index 0000000..bd09661 --- /dev/null +++ b/src/commands/repo.rs @@ -0,0 +1,410 @@ +use super::*; + +pub(crate) mod config; +pub(crate) mod groups; +mod search; + +use self::config::{ + print_repo_list, repo_kind_label, resolve_repo_kind_for_name, selected_source_repos, +}; +use self::search::run_search_command; + +pub(super) fn run_search(args: SearchArgs) -> Result<()> { + let SearchArgs { + rootfs_args, + query, + files, + } = args; + let rootfs = rootfs_args.rootfs; + let config = crate::config::Config::for_rootfs(&rootfs); + let search_lock = locking::open_lock(&config)?; + let search_lock_path = locking::lock_path(&config); + let _search_lock_guard = locking::try_read(&search_lock, &search_lock_path, "search")?; + run_search_command(&query, files, &config, &rootfs) +} + +pub(super) fn run_repo(command: RepoCommands) -> Result<()> { + match command { + RepoCommands::Create { args, dir } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo create")?; + let repo = db::repo::RepoManager::new(dir); + let db_path = repo.create_repo_db()?; + if let Some(sig_path) = signing::auto_sign_zst_file_detached(&rootfs, &db_path)? { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } + ui::success(format!( + "Created repository database: {}", + db_path.display() + )); + } + RepoCommands::Sync { args } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo sync")?; + if !crate::fakeroot::is_root() { + anyhow::bail!("The 'repo sync' command must be run as root"); + } + let config = cfg; + let mirrors = config.enabled_source_mirror_map(); + if mirrors.is_empty() { + ui::info("No enabled source repos configured"); + } else { + db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; + ui::success(format!( + "Source repos synchronized into {}", + config.repo_clone_dir.display() + )); + } + } + RepoCommands::Update { args, name } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo update")?; + if !crate::fakeroot::is_root() { + anyhow::bail!("The 'repo update' command must be run as root"); + } + let config = cfg; + let mirrors = selected_source_repos(&config, name.as_deref())?; + if mirrors.is_empty() { + ui::info("No enabled source repos configured"); + } else { + db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; + if let Some(name) = name { + ui::success(format!( + "Source repo '{}' synchronized into {}", + name, + config.repo_clone_dir.display() + )); + } else { + ui::success(format!( + "Source repos synchronized into {}", + config.repo_clone_dir.display() + )); + } + } + } + RepoCommands::Index { args, dir, subdirs } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo index")?; + let stats = index::create_source_repo_index(&dir, &subdirs) + .with_context(|| format!("Failed to create source index for {}", dir.display()))?; + ui::success(format!( + "Wrote source index: {}", + stats.index_path.display() + )); + ui::info(format!( + "Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} conflicts rows={} dependency rows={} ignored_toml={}", + stats.specs_indexed, + stats.toml_files_scanned, + stats.package_rows, + stats.provides_rows, + stats.conflicts_rows, + stats.dependency_rows, + stats.ignored_toml_files + )); + } + RepoCommands::List { args } => { + let rootfs = args.rootfs_args.rootfs; + let config = crate::config::Config::for_rootfs(&rootfs); + let repo_lock = locking::open_lock(&config)?; + let repo_lock_path = locking::lock_path(&config); + let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo list")?; + print_repo_list(&config); + } + RepoCommands::Add { + args, + name, + url, + kind, + subdirs, + priority, + disabled, + arch, + repo_db, + allow_unsigned, + } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo add")?; + let mut repos = crate::config::load_repos_config_file(&rootfs)?; + match kind { + RepoKindArg::Source => { + if let Some(existing) = repos.source.get(&name) + && (existing.url != url + || existing.subdirs != subdirs + || existing.priority != priority + || existing.enabled == disabled) + && !ui::prompt_yes_no( + &format!("Source repo '{}' already exists. Overwrite it?", name), + false, + )? + { + anyhow::bail!("Aborted"); + } + repos.source.insert( + name.clone(), + crate::config::SourceRepo { + url, + enabled: !disabled, + priority, + subdirs, + }, + ); + } + RepoKindArg::Binary => { + let arch_name = arch.unwrap_or_else(|| std::env::consts::ARCH.to_string()); + let mut candidate = repos.binary.get(&name).cloned().unwrap_or_default(); + candidate.url = url.clone(); + candidate.enabled = !disabled; + candidate.priority = priority; + candidate.repo_db = repo_db.clone(); + candidate.allow_unsigned = allow_unsigned; + candidate.arch.entry(arch_name.clone()).or_default().enabled = true; + + if let Some(existing) = repos.binary.get(&name) + && (*existing != candidate) + && !ui::prompt_yes_no( + &format!("Binary repo '{}' already exists. Overwrite it?", name), + false, + )? + { + anyhow::bail!("Aborted"); + } + repos.binary.insert(name.clone(), candidate); + } + } + let path = crate::config::save_repos_config_file(&rootfs, &repos)?; + ui::success(format!( + "Saved {} repo '{}' to {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Remove { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo remove")?; + let mut repos = crate::config::load_repos_config_file(&rootfs)?; + let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; + if !ui::prompt_yes_no( + &format!("Remove {} repo '{}'?", repo_kind_label(kind), name), + false, + )? { + anyhow::bail!("Aborted"); + } + + match kind { + RepoKindArg::Source => { + repos.source.remove(&name); + } + RepoKindArg::Binary => { + repos.binary.remove(&name); + } + } + let path = crate::config::save_repos_config_file(&rootfs, &repos)?; + ui::success(format!( + "Removed {} repo '{}' from {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Enable { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo enable")?; + let mut repos = crate::config::load_repos_config_file(&rootfs)?; + let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; + match kind { + RepoKindArg::Source => { + let repo = repos + .source + .get_mut(&name) + .with_context(|| format!("Source repo '{}' not found", name))?; + if repo.enabled { + ui::info(format!("Source repo '{}' is already enabled", name)); + } else { + repo.enabled = true; + let path = crate::config::save_repos_config_file(&rootfs, &repos)?; + ui::success(format!( + "Enabled source repo '{}' in {}", + name, + path.display() + )); + } + } + RepoKindArg::Binary => { + let repo = repos + .binary + .get_mut(&name) + .with_context(|| format!("Binary repo '{}' not found", name))?; + if repo.enabled { + ui::info(format!("Binary repo '{}' is already enabled", name)); + } else { + repo.enabled = true; + let path = crate::config::save_repos_config_file(&rootfs, &repos)?; + ui::success(format!( + "Enabled binary repo '{}' in {}", + name, + path.display() + )); + } + } + } + } + RepoCommands::Disable { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = crate::config::Config::for_rootfs(&rootfs); + let mut repo_lock = locking::open_lock(&cfg)?; + let repo_lock_path = locking::lock_path(&cfg); + let _repo_lock_guard = + locking::try_write(&mut repo_lock, &repo_lock_path, "repo disable")?; + let mut repos = crate::config::load_repos_config_file(&rootfs)?; + let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; + if !ui::prompt_yes_no( + &format!("Disable {} repo '{}'?", repo_kind_label(kind), name), + true, + )? { + anyhow::bail!("Aborted"); + } + match kind { + RepoKindArg::Source => { + let repo = repos + .source + .get_mut(&name) + .with_context(|| format!("Source repo '{}' not found", name))?; + repo.enabled = false; + } + RepoKindArg::Binary => { + let repo = repos + .binary + .get_mut(&name) + .with_context(|| format!("Binary repo '{}' not found", name))?; + repo.enabled = false; + } + } + let path = crate::config::save_repos_config_file(&rootfs, &repos)?; + ui::success(format!( + "Disabled {} repo '{}' in {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Owns { args, path } => { + let rootfs = args.rootfs_args.rootfs; + let config = crate::config::Config::for_rootfs(&rootfs); + let repo_lock = locking::open_lock(&config)?; + let repo_lock_path = locking::lock_path(&config); + let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo owns")?; + let host_arch = std::env::consts::ARCH; + let mut any = false; + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + for (name, repo) in binary_repos { + match db::repo::binary_repo_owns_path( + name, + repo, + &rootfs, + &config.package_cache_dir, + &path.to_string_lossy(), + ) { + Ok(hits) => { + for hit in hits { + any = true; + ui::info(format!( + "{} [binary:{}] {}-{} size={} owns={}", + hit.package_name, + hit.repo_name, + hit.version, + hit.revision, + hit.size, + hit.path + )); + } + } + Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), + } + } + if !any { + ui::warn(format!( + "No binary repo metadata entry owns {}", + path.display() + )); + } + } + RepoCommands::Status { args } => { + let rootfs = args.rootfs_args.rootfs; + let config = crate::config::Config::for_rootfs(&rootfs); + let repo_lock = locking::open_lock(&config)?; + let repo_lock_path = locking::lock_path(&config); + let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo status")?; + let mirrors = config.enabled_source_mirror_map(); + if mirrors.is_empty() { + ui::info("No enabled source repos configured"); + } else { + db::repo::mirrors_status(&config.repo_clone_dir, &mirrors)?; + } + if config.binary_repos.is_empty() { + ui::info("No binary repos configured"); + } else { + ui::info("Binary repo configuration:"); + let host_arch = std::env::consts::ARCH; + for (name, repo) in &config.binary_repos { + let arch_keys = if repo.arch.is_empty() { + "(any)".to_string() + } else { + repo.arch.keys().cloned().collect::>().join(",") + }; + ui::info(format!( + " {} [{}] url={} repo_db={} arches={} host_match={}", + name, + if repo.enabled { "enabled" } else { "disabled" }, + repo.url, + repo.repo_db, + arch_keys, + if repo.supports_arch(host_arch) { + "yes" + } else { + "no" + } + )); + } + } + } + } + + Ok(()) +} diff --git a/src/commands/repo/config.rs b/src/commands/repo/config.rs new file mode 100644 index 0000000..9ccdfe7 --- /dev/null +++ b/src/commands/repo/config.rs @@ -0,0 +1,116 @@ +use super::*; +use crate::config::{Config, RepoConfigFile}; + +pub(crate) fn repo_kind_label(kind: RepoKindArg) -> &'static str { + match kind { + RepoKindArg::Source => "source", + RepoKindArg::Binary => "binary", + } +} + +pub(crate) fn resolve_repo_kind_for_name( + repos: &RepoConfigFile, + name: &str, + kind: Option, +) -> Result { + if let Some(kind) = kind { + return Ok(kind); + } + + let in_source = repos.source.contains_key(name); + let in_binary = repos.binary.contains_key(name); + match (in_source, in_binary) { + (true, false) => Ok(RepoKindArg::Source), + (false, true) => Ok(RepoKindArg::Binary), + (true, true) => anyhow::bail!( + "Repo '{}' exists as both source and binary; rerun with --kind source|binary", + name + ), + (false, false) => anyhow::bail!("Repo '{}' not found in repos.toml", name), + } +} + +pub(crate) fn print_repo_list(config: &Config) { + if config.source_repos.is_empty() && config.binary_repos.is_empty() { + ui::info("No repos configured in /etc/depot.d/repos.toml"); + if !config.mirrors.is_empty() { + ui::info("Legacy mirrors.toml entries are loaded as source repos at runtime."); + } + return; + } + + ui::info(format!( + "Repo settings: prefer_binary={}", + config.repo_settings.prefer_binary + )); + + if config.source_repos.is_empty() { + ui::info("Source repos: none"); + } else { + ui::info("Source repos:"); + for (name, repo) in &config.source_repos { + let subdirs = if repo.subdirs.is_empty() { + "(all)".to_string() + } else { + repo.subdirs.join(", ") + }; + ui::info(format!( + " {} [{}] priority={} subdirs={} url={}", + name, + if repo.enabled { "enabled" } else { "disabled" }, + repo.priority, + subdirs, + repo.url + )); + } + } + + if config.binary_repos.is_empty() { + ui::info("Binary repos: none"); + } else { + ui::info("Binary repos:"); + let host_arch = std::env::consts::ARCH; + for (name, repo) in &config.binary_repos { + let arch_keys = if repo.arch.is_empty() { + "(any)".to_string() + } else { + repo.arch.keys().cloned().collect::>().join(",") + }; + ui::info(format!( + " {} [{}] priority={} arches={} host_match={} repo_db={}{} url={}", + name, + if repo.enabled { "enabled" } else { "disabled" }, + repo.priority, + arch_keys, + if repo.supports_arch(host_arch) { + "yes" + } else { + "no" + }, + repo.repo_db, + if repo.allow_unsigned { + " allow_unsigned=true" + } else { + "" + }, + repo.url + )); + } + } +} + +pub(crate) fn selected_source_repos( + config: &Config, + name: Option<&str>, +) -> Result> { + let mut mirrors = config.enabled_source_mirror_map(); + if let Some(name) = name { + if let Some(url) = mirrors.remove(name) { + let mut only = std::collections::HashMap::new(); + only.insert(name.to_string(), url); + return Ok(only); + } + anyhow::bail!("Enabled source repo '{}' not found", name); + } + Ok(mirrors) +} diff --git a/src/commands/repo/groups.rs b/src/commands/repo/groups.rs new file mode 100644 index 0000000..98ed05c --- /dev/null +++ b/src/commands/repo/groups.rs @@ -0,0 +1,227 @@ +use super::*; +use crate::config::Config; + +pub(crate) fn scan_package_specs(dir: &Path) -> Result> { + let root = dir + .canonicalize() + .with_context(|| format!("Failed to resolve scan root {}", dir.display()))?; + if !root.is_dir() { + anyhow::bail!("Scan root is not a directory: {}", root.display()); + } + + let mut specs = Vec::new(); + for entry in WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| entry.file_name() != std::ffi::OsStr::new(".git")) + { + crate::interrupts::check()?; + let entry = entry.with_context(|| format!("Failed to walk {}", root.display()))?; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("toml") { + continue; + } + let candidate = path.to_path_buf(); + if package::PackageSpec::from_file(&candidate).is_ok() { + specs.push(candidate); + } + } + specs.sort(); + Ok(specs) +} + +fn spec_group_package_names(spec: &package::PackageSpec, group: &str) -> Vec { + let mut names = Vec::new(); + for output in spec.outputs() { + if spec + .dependencies_for_output(&output.name) + .groups + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(group)) + { + names.push(output.name); + } + } + if spec.builds_lib32_output() { + let lib32_name = spec.lib32_package_name(); + if spec + .dependencies_for_output(&lib32_name) + .groups + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(group)) + { + names.push(lib32_name); + } + } + names.sort(); + names.dedup(); + names +} + +fn collect_source_group_package_names(config: &Config, group: &str) -> Result> { + if !config.repo_clone_dir.exists() { + return Ok(Vec::new()); + } + + let mut packages = BTreeSet::new(); + for spec_path in scan_package_specs(&config.repo_clone_dir)? { + let spec = package::PackageSpec::from_file(&spec_path) + .with_context(|| format!("Failed to parse spec {}", spec_path.display()))?; + for package_name in spec_group_package_names(&spec, group) { + packages.insert(package_name); + } + } + Ok(packages.into_iter().collect()) +} + +fn collect_binary_group_package_names( + config: &Config, + rootfs: &Path, + group: &str, +) -> Result> { + let host_arch = std::env::consts::ARCH; + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + let mut packages = BTreeSet::new(); + for (repo_name, repo_cfg) in binary_repos { + let matches = db::repo::find_binary_repo_packages_by_group( + repo_name, + repo_cfg, + rootfs, + &config.package_cache_dir, + group, + )?; + for record in matches { + packages.insert(record.name); + } + } + Ok(packages.into_iter().collect()) +} + +fn collect_install_group_package_names( + config: &Config, + rootfs: &Path, + group: &str, +) -> Result> { + let mut packages = BTreeSet::new(); + for package_name in collect_source_group_package_names(config, group)? { + packages.insert(package_name); + } + for package_name in collect_binary_group_package_names(config, rootfs, group)? { + packages.insert(package_name); + } + Ok(packages.into_iter().collect()) +} + +pub(crate) fn expand_install_requests_for_groups( + config: &Config, + rootfs: &Path, + requests: &[PathBuf], +) -> Result<(Vec, Vec)> { + let mut expanded = Vec::new(); + let mut explicit_groups = Vec::new(); + let mut seen = HashSet::new(); + + for request in requests { + if request.exists() { + let key = request.to_string_lossy().to_string(); + if seen.insert(key) { + expanded.push(request.clone()); + } + continue; + } + + let request_name = request.to_string_lossy().to_string(); + let group_packages = collect_install_group_package_names(config, rootfs, &request_name)?; + if group_packages.is_empty() { + if seen.insert(request_name.clone()) { + expanded.push(request.clone()); + } + continue; + } + + ui::info(format!( + "Expanding group '{}' ({} packages)...", + request_name, + group_packages.len() + )); + explicit_groups.push(request_name); + for package_name in group_packages { + if seen.insert(package_name.clone()) { + expanded.push(PathBuf::from(package_name)); + } + } + } + + Ok((expanded, explicit_groups)) +} + +pub(crate) fn expand_installed_group_targets( + db_path: &Path, + requests: &[String], +) -> Result<(Vec, Vec)> { + let mut expanded = Vec::new(); + let mut explicit_groups = Vec::new(); + let mut seen = HashSet::new(); + + for request in requests { + if db::is_installed_group(db_path, request)? { + let group_packages = db::get_packages_in_installed_group(db_path, request)?; + if !group_packages.is_empty() { + ui::info(format!( + "Expanding group '{}' ({} packages)...", + request, + group_packages.len() + )); + explicit_groups.push(request.clone()); + for package_name in group_packages { + if seen.insert(package_name.clone()) { + expanded.push(package_name); + } + } + continue; + } + } + + if seen.insert(request.clone()) { + expanded.push(request.clone()); + } + } + + Ok((expanded, explicit_groups)) +} + +pub(crate) fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +pub(crate) fn binary_arch_from_filename(filename: &str) -> String { + let stem = filename + .strip_suffix(".depot.pkg.tar.zst") + .unwrap_or(filename); + let mut parts = stem.rsplitn(4, '-'); + parts + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(std::env::consts::ARCH) + .to_string() +} diff --git a/src/commands/repo/search.rs b/src/commands/repo/search.rs new file mode 100644 index 0000000..5081ed3 --- /dev/null +++ b/src/commands/repo/search.rs @@ -0,0 +1,174 @@ +use super::*; +use crate::config::Config; + +fn source_search_hit_allowed(config: &Config, hit: &index::SourceSearchHit) -> bool { + let path = &hit.path; + + if path.starts_with(Path::new("packages")) { + return true; + } + + if config.source_repos.is_empty() { + return true; + } + + for (repo_name, repo) in &config.source_repos { + if !repo.enabled { + continue; + } + let repo_root = config.repo_clone_dir.join(repo_name); + if repo.subdirs.is_empty() { + if path.starts_with(&repo_root) { + return true; + } + } else { + for subdir in &repo.subdirs { + if path.starts_with(repo_root.join(subdir)) { + return true; + } + } + } + } + + false +} + +fn source_hit_origin(config: &Config, path: &Path) -> String { + if let Ok(rel) = path.strip_prefix(&config.repo_clone_dir) + && let Some(first) = rel.components().next() + { + return format!("source:{}", first.as_os_str().to_string_lossy()); + } + "source:local".to_string() +} + +pub(super) fn run_search_command( + query: &str, + files: bool, + config: &Config, + rootfs: &Path, +) -> Result<()> { + let mut any = false; + let host_arch = std::env::consts::ARCH; + + let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + let source_hits: Vec<_> = pkg_index + .search(query) + .into_iter() + .filter(|hit| source_search_hit_allowed(config, hit)) + .collect(); + if !source_hits.is_empty() { + any = true; + ui::info("Source matches:"); + for hit in source_hits { + let provides = if hit.provides.is_empty() { + String::new() + } else { + format!(" provides={}", hit.provides.join(",")) + }; + let replaces = if hit.replaces.is_empty() { + String::new() + } else { + format!(" replaces={}", hit.replaces.join(",")) + }; + ui::info(format!( + " {} [{}] {}{}{}", + hit.name, + source_hit_origin(config, &hit.path), + hit.path.display(), + provides, + replaces + )); + } + } + + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + if !binary_repos.is_empty() { + ui::info("Binary matches:"); + } + let mut binary_hits_total = 0usize; + for (name, repo) in binary_repos { + match db::repo::search_binary_repo(name, repo, rootfs, &config.package_cache_dir, query) { + Ok(hits) => { + for hit in hits { + any = true; + binary_hits_total += 1; + let provides = if hit.provides.is_empty() { + String::new() + } else { + format!(" provides={}", hit.provides.join(",")) + }; + ui::info(format!( + " {} [binary:{}] {}-{} size={} file={}{}{}", + hit.name, + hit.repo_name, + hit.version, + hit.revision, + hit.size, + hit.filename, + hit.description + .as_ref() + .map(|d| format!(" desc={}", d)) + .unwrap_or_default(), + provides + )); + } + } + Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), + } + } + if !config.binary_repos.is_empty() && binary_hits_total == 0 { + ui::info(" (no binary matches)"); + } + + if files && !config.binary_repos.is_empty() { + ui::info("Binary file matches:"); + let mut file_hits_total = 0usize; + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + for (name, repo) in binary_repos { + match db::repo::search_binary_repo_files( + name, + repo, + rootfs, + &config.package_cache_dir, + query, + ) { + Ok(hits) => { + for hit in hits { + any = true; + file_hits_total += 1; + ui::info(format!( + " {} [binary:{}] {}-{} size={} owns={}", + hit.package_name, + hit.repo_name, + hit.version, + hit.revision, + hit.size, + hit.path + )); + } + } + Err(e) => crate::log_warn!("Binary repo '{}': {}", name, e), + } + } + if file_hits_total == 0 { + ui::info(" (no binary file matches)"); + } + } + + if !any { + ui::warn(format!("No matches found for '{}'", query)); + } + Ok(()) +} diff --git a/src/commands/tests.rs b/src/commands/tests.rs new file mode 100644 index 0000000..94198c5 --- /dev/null +++ b/src/commands/tests.rs @@ -0,0 +1,2881 @@ +use super::*; +use crate::cli::{ + BuildArgs, BuildExecArgs, Cli, InstallArgs, Lib32Args, PromptArgs, RootfsArgs, SearchArgs, + UpdateArgs, +}; +use crate::test_support::TestEnv; +use git2::{Oid, Repository}; +use std::path::Path; +use std::sync::Mutex; + +static ASSUME_YES_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn rootfs_args(rootfs: impl Into) -> RootfsArgs { + RootfsArgs { + rootfs: rootfs.into(), + } +} + +fn prompt_args(yes: bool) -> PromptArgs { + PromptArgs { yes } +} + +fn build_exec_args() -> BuildExecArgs { + BuildExecArgs { + no_deps: false, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + test_deps: false, + } +} + +fn lib32_args() -> Lib32Args { + Lib32Args { lib32_only: false } +} + +fn test_binary_repo_record(name: &str, filename: &str) -> db::repo::BinaryRepoPackageRecord { + db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: name.into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + completed_at: None, + filename: filename.into(), + size: 1, + sha256: "sha256".into(), + sha512: "sha512".into(), + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + } +} + +fn test_package_spec( + build_type: package::BuildType, + make_test_target: Option<&str>, + make_test_targets: &[&str], +) -> package::PackageSpec { + let mut flags = package::BuildFlags::default(); + if let Some(target) = make_test_target { + flags.make_test_target = target.to_string(); + } + flags.make_test_targets = make_test_targets + .iter() + .map(|target| (*target).to_string()) + .collect(); + + package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![package::Source { + url: "https://example.test/pkg.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "pkg".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: package::Build { build_type, flags }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +fn commit_git_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { + let full_path = workdir.join(rel); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&full_path, data).unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new(rel)).unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); + let mut parents = Vec::new(); + if let Ok(head) = repo.head() + && let Some(oid) = head.target() + { + parents.push(repo.find_commit(oid).unwrap()); + } + let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); + + repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) + .unwrap() +} + +fn make_remote_git_repo() -> (tempfile::TempDir, String, Oid) { + let tmp = tempfile::tempdir().unwrap(); + let remote_dir = tmp.path().join("origin.git"); + let workdir = tmp.path().join("work"); + + Repository::init_bare(&remote_dir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + let tagged = commit_git_file(&repo, &workdir, "README", "tagged\n"); + let tag_target = repo.find_object(tagged, None).unwrap(); + repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); + + let branch_ref = repo.head().unwrap().name().unwrap().to_string(); + let mut remote = repo.remote("origin", remote_dir.to_str().unwrap()).unwrap(); + let push_specs = [ + format!("{branch_ref}:{branch_ref}"), + "refs/tags/v1.0.0:refs/tags/v1.0.0".to_string(), + ]; + let push_spec_refs: Vec<&String> = push_specs.iter().collect(); + remote.push(&push_spec_refs, None).unwrap(); + + let remote_url = url::Url::from_file_path(&remote_dir).unwrap().to_string(); + (tmp, remote_url, tagged) +} + +#[test] +fn run_internal_clone_checks_out_git_revision() { + let (_tmp, remote_url, tagged) = make_remote_git_repo(); + let clone_root = tempfile::tempdir().unwrap(); + let dest = clone_root.path().join("cloned-src"); + + run_internal_command(InternalCommands::Clone { + repo: format!("{remote_url}#v1.0.0"), + dest: Some(dest.clone()), + }) + .unwrap(); + + let repo = Repository::open(&dest).unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap(), tagged); + assert_eq!( + std::fs::read_to_string(dest.join("README")).unwrap(), + "tagged\n" + ); +} + +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; + +#[test] +fn clean_build_workspace_removes_build_and_source_cache_dirs() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + fs::create_dir_all(&cfg.build_dir) + .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; + fs::create_dir_all(&cfg.cache_dir) + .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; + + let mut build_file = fs::File::create(cfg.build_dir.join("artifact.txt"))?; + build_file.write_all(b"build data")?; + build_file.flush()?; + + let mut source_file = fs::File::create(cfg.cache_dir.join("source.tar.zst"))?; + source_file.write_all(b"source data")?; + source_file.flush()?; + + clean_build_workspace(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(!cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn clean_build_workspace_noops_when_dirs_are_missing() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + clean_build_workspace(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(!cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn binary_install_path_uses_repo_record_metadata_without_archive_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); + + // Build an archive that intentionally does not contain .metadata.toml. + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"hello"; + let mut header = tar::Header::new_gnu(); + header.set_path("usr/bin/hello").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + let record = db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + completed_at: None, + filename: archive_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(), + size: payload.len() as u64, + sha256: String::new(), + sha512: String::new(), + description: Some("test package".into()), + homepage: Some("https://example.test".into()), + license: Some("MIT".into()), + provides: vec!["pkg-virtual".into()], + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: vec!["glibc".into()], + optional_dependencies: vec!["manpages".into()], + groups: vec!["base".into()], + }; + let spec = package_spec_from_repo_record(&record); + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].package.name, "pkg"); + assert!(rootfs.path().join("usr/bin/hello").exists()); + + let db_path = cfg.db_dir.join("packages.db"); + assert_eq!( + db::get_package_version(&db_path, "pkg")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +fn binary_archive_staging_uses_config_build_dir_instead_of_process_tmpdir() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"hello"; + let mut header = tar::Header::new_gnu(); + header.set_path("usr/bin/hello").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + + assert!(staged.path().starts_with(staging_temp_root(&cfg))); + assert!(staged.path().join("usr/bin/hello").exists()); + Ok(()) +} + +#[test] +fn direct_archive_install_requests_batch_multiple_archives() -> Result<()> { + fn write_archive( + archive_path: &Path, + package_name: &str, + conflicts: &[&str], + payload_path: &str, + payload: &[u8], + ) -> Result<()> { + let file = fs::File::create(archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path(payload_path)?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, payload)?; + + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; + let metadata = format!( + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" + ); + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, metadata.as_bytes())?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + Ok(()) + } + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?; + write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let installed = run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[archive_a, archive_b], + false, + )?; + + assert!(installed); + assert!(rootfs.path().join("usr/bin/alpha").exists()); + assert!(rootfs.path().join("usr/bin/beta").exists()); + let db_path = cfg.installed_db_path(rootfs.path()); + assert_eq!( + db::get_package_version(&db_path, "alpha")?, + Some("1.0".into()) + ); + assert_eq!( + db::get_package_version(&db_path, "beta")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> { + fn write_archive(archive_path: &Path, package_name: &str, conflicts: &[&str]) -> Result<()> { + let file = fs::File::create(archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = package_name.as_bytes(); + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path(format!("usr/bin/{package_name}"))?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, payload)?; + + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; + let metadata = format!( + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" + ); + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, metadata.as_bytes())?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + Ok(()) + } + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + write_archive(&archive_a, "alpha", &["beta"])?; + write_archive(&archive_b, "beta", &[])?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let err = run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[archive_a, archive_b], + false, + ) + .expect_err("conflicting archives should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); + Ok(()) +} + +#[test] +fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> { + let removals = collect_conflicting_installed_packages( + &[InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: vec!["alpha".into(), "editor".into()], + }], + &[InstalledConflictPackage { + name: "alpha".into(), + provides: vec!["editor".into()], + }], + )?; + + assert_eq!( + removals.get("alpha"), + Some(&BTreeSet::from(["beta".to_string()])) + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn binary_archive_install_preserves_setuid_permissions() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("sudo-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"sudo"; + let mut header = tar::Header::new_gnu(); + header.set_path("bin/sudo").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o4755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + let staged_mode = fs::metadata(staged.path().join("bin/sudo"))? + .permissions() + .mode() + & 0o7777; + assert_eq!(staged_mode, 0o4755); + + let record = db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: "sudo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + completed_at: None, + filename: archive_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(), + size: payload.len() as u64, + sha256: String::new(), + sha512: String::new(), + description: Some("sudo".into()), + homepage: Some("https://example.test".into()), + license: Some("ISC".into()), + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }; + let spec = package_spec_from_repo_record(&record); + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + let root_mode = fs::metadata(rootfs.path().join("bin/sudo"))? + .permissions() + .mode() + & 0o7777; + assert_eq!(root_mode, 0o4755); + Ok(()) +} + +#[test] +fn binary_archive_install_honors_keep_paths_from_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir + .path() + .join("filesystem-1.0-3-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = b"package-fstab"; + let mut fstab_header = tar::Header::new_gnu(); + fstab_header.set_path("etc/fstab").unwrap(); + fstab_header.set_size(payload.len() as u64); + fstab_header.set_mode(0o644); + fstab_header.set_cksum(); + tar.append(&fstab_header, &payload[..]).unwrap(); + + let metadata = br#"name = "filesystem" +version = "1.0.1" +revision = 3 +description = "Base filesystem" +homepage = "https://example.test" +license = "Unlicense" +keep = ["etc/fstab"] + +[dependencies] +runtime = [] +optional = [] +"#; + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml").unwrap(); + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, &metadata[..]).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + fs::create_dir_all(rootfs.path().join("etc"))?; + fs::write(rootfs.path().join("etc/fstab"), "existing-fstab")?; + + let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; + assert_eq!(spec.build.flags.keep, vec!["etc/fstab".to_string()]); + + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + assert_eq!(installed.len(), 1); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/fstab"))?, + "existing-fstab" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/fstab.depotnew"))?, + "package-fstab" + ); + Ok(()) +} + +#[test] +fn binary_archive_install_honors_replaces_from_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("vx-0.1.0-1-x86_64.depot.pkg.tar.zst"); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "diffutils".into(), + real_name: None, + version: "3.12".into(), + revision: 1, + description: "diffutils".into(), + homepage: "https://example.test/diffutils".into(), + abi_breaking: false, + license: vec!["GPL-3.0-or-later".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/bin"))?; + fs::write(old_dest.join("usr/bin/diff"), "old-diff")?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = b"vx-diff"; + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path("usr/bin/diff")?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, &payload[..])?; + + let metadata = br#"name = "vx" +version = "0.1.0" +revision = 1 +description = "vertex utils" +homepage = "https://example.test/vx" +license = "MIT" +replaces = ["diffutils"] + +[dependencies] +runtime = [] +optional = [] +"#; + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, &metadata[..])?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + + let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; + assert_eq!(spec.alternatives.replaces, vec!["diffutils".to_string()]); + + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + assert!(installed[0].is_update); + assert_eq!(installed[0].package.name, "vx"); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/diff"))?, + "vx-diff" + ); + + let db_path = cfg.installed_db_path(rootfs.path()); + assert_eq!(db::get_package_version(&db_path, "diffutils")?, None); + assert_eq!( + db::get_package_version(&db_path, "vx")?, + Some("0.1.0".into()) + ); + Ok(()) +} + +#[test] +fn plan_staged_install_reads_updates_from_rootfs_installed_db() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.db_dir = rootfs.path().join("home/vertex/.local/share/depot"); + + let installed_db = cfg.installed_db_path(rootfs.path()); + fs::create_dir_all( + installed_db + .parent() + .context("Installed DB path should have a parent")?, + )?; + + let existing_dest = rootfs.path().join("installed"); + fs::create_dir_all(existing_dest.join("usr/bin"))?; + fs::write(existing_dest.join("usr/bin/tool"), "old")?; + + let spec = package::PackageSpec { + package: package::PackageInfo { + name: "filesystem".into(), + real_name: None, + version: "1.0.1".into(), + revision: 3, + description: "Base filesystem".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + license: vec!["Unlicense".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + db::register_package(&installed_db, &spec, &existing_dest)?; + + let staged_dest = rootfs.path().join("staged"); + fs::create_dir_all(staged_dest.join("usr/bin"))?; + fs::write(staged_dest.join("usr/bin/tool"), "new")?; + + let plan = plan_staged_install(&spec, &staged_dest, rootfs.path(), &cfg)?; + assert!(plan.is_update); + Ok(()) +} + +#[test] +fn renamed_abi_updates_keep_versioned_shared_libraries() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "libxml214".into(), + real_name: Some("libxml2".into()), + version: "2.14.9".into(), + revision: 1, + description: "libxml2 2.14".into(), + homepage: "https://example.test/libxml2".into(), + abi_breaking: true, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/lib/pkgconfig"))?; + fs::write(old_dest.join("usr/lib/libxml2.so.14.9.0"), "old-real")?; + std::os::unix::fs::symlink("libxml2.so.14.9.0", old_dest.join("usr/lib/libxml2.so.14"))?; + std::os::unix::fs::symlink("libxml2.so.14", old_dest.join("usr/lib/libxml2.so"))?; + fs::write( + old_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), + "old-pkgconfig", + )?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let new_spec = package::PackageSpec { + package: package::PackageInfo { + name: "libxml215".into(), + real_name: Some("libxml2".into()), + version: "2.15.1".into(), + revision: 1, + description: "libxml2 2.15".into(), + homepage: "https://example.test/libxml2".into(), + abi_breaking: true, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let new_dest = rootfs.path().join("new-dest"); + fs::create_dir_all(new_dest.join("usr/lib/pkgconfig"))?; + fs::write(new_dest.join("usr/lib/libxml2.so.15.1.0"), "new-real")?; + std::os::unix::fs::symlink("libxml2.so.15.1.0", new_dest.join("usr/lib/libxml2.so.15"))?; + std::os::unix::fs::symlink("libxml2.so.15", new_dest.join("usr/lib/libxml2.so"))?; + fs::write( + new_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), + "new-pkgconfig", + )?; + + let installed = install_package_outputs_to_rootfs(&new_spec, &new_dest, rootfs.path(), &cfg)?; + assert_eq!(installed.len(), 1); + assert!(installed[0].is_update); + assert_eq!(installed[0].package.name, "libxml215"); + + assert!(rootfs.path().join("usr/lib/libxml2.so.14.9.0").exists()); + assert!(rootfs.path().join("usr/lib/libxml2.so.14").exists()); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/lib/libxml2.so.15.1.0"))?, + "new-real" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/lib/pkgconfig/libxml-2.0.pc"))?, + "new-pkgconfig" + ); + + let db_path = cfg.installed_db_path(rootfs.path()); + let old_files = db::get_package_files(&db_path, "libxml214")?; + assert_eq!( + old_files, + vec![ + "usr/lib/libxml2.so.14".to_string(), + "usr/lib/libxml2.so.14.9.0".to_string(), + ] + ); + + let new_files = db::get_package_files(&db_path, "libxml215")?; + assert!(new_files.contains(&"usr/lib/libxml2.so".to_string())); + assert!(new_files.contains(&"usr/lib/libxml2.so.15".to_string())); + assert!(new_files.contains(&"usr/lib/libxml2.so.15.1.0".to_string())); + assert!(new_files.contains(&"usr/lib/pkgconfig/libxml-2.0.pc".to_string())); + Ok(()) +} + +#[test] +fn merge_missing_dependencies_preserves_order_and_uniqueness() { + let merged = merge_missing_dependencies( + vec!["make".into(), "pkgconf".into(), "glibc".into()], + vec![ + "glibc".into(), + "openssl".into(), + "pkgconf".into(), + "zlib".into(), + ], + ); + assert_eq!(merged, vec!["make", "pkgconf", "glibc", "openssl", "zlib"]); +} + +#[test] +fn collect_update_candidates_matches_renamed_packages_by_real_name() -> Result<()> { + let temp = tempfile::tempdir()?; + 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(&rootfs)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + fs::create_dir_all(&db_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.clone(); + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.insert( + "private".into(), + config::SourceRepo { + url: "https://example.test/private.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + let installed_spec = package::PackageSpec { + package: package::PackageInfo { + name: "icu78".into(), + real_name: Some("icu".into()), + version: "78.2".into(), + revision: 1, + description: "icu78".into(), + homepage: "https://example.test/icu".into(), + abi_breaking: true, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/lib"))?; + fs::write(dest.join("usr/lib/libicuuc.so.78.2"), "icu78")?; + db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; + + let repo_root = repo_clones.join("private"); + fs::create_dir_all(&repo_root)?; + fs::write( + repo_root.join("icu79.toml"), + r#"[package] +name = "icu79" +real_name = "icu" +version = "79.1" +revision = 1 +description = "icu79" +homepage = "https://example.test/icu" +abi_breaking = true +license = "MIT" + +[build] +type = "meta" + +[dependencies] +runtime = [] +optional = [] +"#, + )?; + + let installed_records = db::list_installed_package_records(&config.installed_db_path(&rootfs))?; + assert_eq!(installed_records.len(), 1); + assert_eq!(installed_records[0].real_name.as_deref(), Some("icu")); + + let source_candidates = + collect_best_source_update_candidates(&config, &HashSet::from([String::from("icu")]))?; + assert!(source_candidates.contains_key("icu")); + let selected = select_update_candidate( + &installed_records[0], + installed_records[0].completed_at, + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &HashMap::new(), + false, + ); + assert!(selected.is_some()); + + let updates = collect_update_candidates(&config, &rootfs, &["icu78".into()])?; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].installed_package, "icu78"); + assert_eq!(updates[0].candidate_package, "icu79"); + assert_eq!(updates[0].candidate_version, "79.1"); + Ok(()) +} + +#[test] +fn update_candidate_prefers_binary_when_versions_match_and_config_does() { + let installed = db::InstalledPackageRecord { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + abi_breaking: false, + completed_at: None, + }; + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.1.0".into(), + revision: 1, + description: "test".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: vec![package::Source { + url: "https://example.test/pkg-$version.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "pkg-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let source_candidates = HashMap::from([( + "pkg".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/pkg.toml"), + completed_at: None, + spec: source_spec, + }, + )]); + let binary_candidates = HashMap::from([( + "pkg".to_string(), + ( + 0, + db::repo::BinaryRepoPackageRecord { + repo_name: "binary".into(), + name: "pkg".into(), + real_name: None, + version: "1.1.0".into(), + revision: 1, + abi_breaking: false, + completed_at: None, + filename: "pkg-1.1.0-1-x86_64.depot.pkg.tar.zst".into(), + size: 1, + sha256: String::new(), + sha512: String::new(), + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + ), + )]); + + let selected = select_update_candidate( + &installed, + None, + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &binary_candidates, + true, + ) + .expect("expected update candidate"); + assert!(matches!(selected.origin, UpdateOrigin::Binary { .. })); +} + +#[test] +fn select_update_candidate_uses_newer_timestamp_when_versions_match() { + let installed = db::InstalledPackageRecord { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + abi_breaking: false, + completed_at: Some(100), + }; + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + description: "test".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let source_candidates = HashMap::from([( + "pkg".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/pkg.toml"), + completed_at: Some(200), + spec: source_spec, + }, + )]); + + let selected = select_update_candidate( + &installed, + Some(100), + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &HashMap::new(), + true, + ) + .expect("expected update candidate"); + assert_eq!(selected.candidate_version, "1.0.0"); + assert_eq!(selected.candidate_completed_at, Some(200)); +} + +#[test] +fn select_update_candidate_prefers_replacement_candidate() { + let installed = db::InstalledPackageRecord { + name: "findutils".into(), + real_name: None, + version: "4.9.0".into(), + revision: 1, + abi_breaking: false, + completed_at: Some(100), + }; + + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "5.0.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.36.1".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let source_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/findutils.toml"), + completed_at: Some(200), + spec: source_spec, + }, + )]); + let source_replacement_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 0, + path: PathBuf::from("/tmp/busybox.toml"), + completed_at: Some(150), + spec: replacement_spec, + }, + )]); + + let selected = select_update_candidate( + &installed, + installed.completed_at, + &source_replacement_candidates, + &HashMap::new(), + &source_candidates, + &HashMap::new(), + false, + ) + .expect("expected replacement update candidate"); + + assert!(selected.replaces_installed); + assert_eq!(selected.installed_package, "findutils"); + assert_eq!(selected.candidate_package, "busybox"); +} + +#[test] +fn install_planned_packages_to_rootfs_runs_post_hooks_after_batch_install() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/bin"))?; + fs::write(old_dest.join("usr/bin/find"), "old-find")?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let alpha_spec = package::PackageSpec { + package: package::PackageInfo { + name: "alpha".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "alpha".into(), + homepage: "https://example.test/alpha".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let alpha_dest = rootfs.path().join("alpha-dest"); + fs::create_dir_all(alpha_dest.join("usr/bin"))?; + fs::create_dir_all(alpha_dest.join("scripts"))?; + fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?; + fs::write( + alpha_dest.join("scripts/post_install"), + "cat \"$DEPOT_ROOTFS/usr/bin/find\" > \"$DEPOT_ROOTFS/alpha-marker\"\n", + )?; + + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_dest = rootfs.path().join("replacement-dest"); + fs::create_dir_all(replacement_dest.join("usr/bin"))?; + fs::write(replacement_dest.join("usr/bin/find"), "new-find")?; + + let mut plans = Vec::new(); + plans.extend(plan_package_outputs_for_install( + &alpha_spec, + &alpha_dest, + rootfs.path(), + &cfg, + )?); + plans.extend(plan_package_outputs_for_install( + &replacement_spec, + &replacement_dest, + rootfs.path(), + &cfg, + )?); + + install_planned_packages_to_rootfs(&plans, rootfs.path(), &cfg)?; + + assert_eq!( + fs::read_to_string(rootfs.path().join("alpha-marker"))?, + "new-find" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/find"))?, + "new-find" + ); + assert!(db::get_package_version(&cfg.installed_db_path(rootfs.path()), "findutils")?.is_none()); + assert_eq!( + db::get_package_version(&cfg.installed_db_path(rootfs.path()), "busybox")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +fn collect_update_candidates_skips_source_when_prefer_binary_is_enabled() -> 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.clone(); + config.repo_settings.prefer_binary = true; + config.binary_repos.clear(); + config.source_repos.insert( + "private".into(), + config::SourceRepo { + url: "https://example.test/private.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + let installed_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + description: "pkg".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin/pkg"), "pkg")?; + db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; + + let updates = collect_update_candidates(&config, &rootfs, &[])?; + assert!(updates.is_empty()); + 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 depot_self_update_check_is_skipped_for_nested_update_install_context() -> 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 mut env = TestEnv::new(); + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_UPDATE); + + ensure_depot_self_update_not_required(&config, &rootfs)?; + Ok(()) +} + +#[test] +fn collect_missing_update_dependencies_skips_planned_provides_and_installed_deps() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let db_path = temp.path().join("packages.db"); + + let libc_spec = package::PackageSpec { + package: package::PackageInfo { + name: "glibc".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "glibc".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/lib"))?; + fs::write(dest.join("usr/lib/libc.so"), "glibc")?; + db::register_package(&db_path, &libc_spec, &dest)?; + + let missing = collect_missing_update_dependencies( + &[ + UpdateCandidate { + installed_package: "pkg".into(), + candidate_package: "pkg".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()], + provides: Vec::new(), + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/pkg.toml"), + }, + }, + UpdateCandidate { + installed_package: "helper".into(), + candidate_package: "helper".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: Vec::new(), + provides: vec!["helper-virtual".into()], + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/helper.toml"), + }, + }, + UpdateCandidate { + installed_package: "tool".into(), + candidate_package: "tool".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: vec!["newdep".into()], + provides: Vec::new(), + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/tool.toml"), + }, + }, + ], + &db_path, + )?; + + assert_eq!(missing, vec!["newdep".to_string()]); + Ok(()) +} + +#[test] +fn validate_no_transaction_conflicts_rejects_conflicting_updates() { + let err = validate_no_transaction_conflicts(&[ + InstallConflictSubject { + package: "alpha".into(), + provides: Vec::new(), + conflicts: vec!["beta".into()], + }, + InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: Vec::new(), + }, + ]) + .expect_err("conflicting update set should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); +} + +#[test] +fn compare_versions_for_updates_handles_semver_and_date_versions() { + assert_eq!( + compare_versions_for_updates("10.8.4", "10.8.3"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("20260202", "20251231"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("1.10", "1.9"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("v1.0.0", "1.0.0"), + Ordering::Equal + ); + assert_eq!( + compare_versions_for_updates("lts_2027_01_01", "20260107.1"), + Ordering::Greater + ); +} + +#[test] +fn compare_versions_for_updates_is_transitive_for_mixed_formats() { + let versions = [ + "01", + "1a", + "1.0.0", + "1.2.0", + "1.2.0rc2", + "v1.0.0", + "1.0.0+meta", + "20260107.1", + "lts_2026_01_07", + ]; + + for left in versions { + for middle in versions { + for right in versions { + let left_middle = compare_versions_for_updates(left, middle); + let middle_right = compare_versions_for_updates(middle, right); + let left_right = compare_versions_for_updates(left, right); + + if left_middle == Ordering::Less && middle_right == Ordering::Less { + assert_eq!( + left_right, + Ordering::Less, + "expected transitive ordering for {left} < {middle} < {right}" + ); + } + + if left_middle == Ordering::Greater && middle_right == Ordering::Greater { + assert_eq!( + left_right, + Ordering::Greater, + "expected transitive ordering for {left} > {middle} > {right}" + ); + } + + if left_middle == Ordering::Equal && middle_right == Ordering::Equal { + assert_eq!( + left_right, + Ordering::Equal, + "expected transitive equality for {left} == {middle} == {right}" + ); + } + } + } + } +} + +#[test] +fn extract_version_patterns_handles_git_and_release_urls() { + let git_patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); + assert!(git_patterns.contains(&VersionPattern { + prefix: "v".into(), + suffix: String::new(), + })); + + let release_patterns = extract_version_patterns( + "https://github.com/Mic92/iana-etc/releases/download/$version/iana-etc-$version.tar.gz", + ); + assert!(release_patterns.contains(&VersionPattern { + prefix: String::new(), + suffix: String::new(), + })); +} + +#[test] +fn candidate_versions_from_refs_matches_version_patterns() { + let refs = vec![ + "refs/tags/v10.8.3".to_string(), + "refs/tags/v10.8.4".to_string(), + "refs/heads/main".to_string(), + ]; + let patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); + let candidates = candidate_versions_from_refs(&refs, &patterns); + + assert_eq!(candidates, vec!["10.8.3".to_string(), "10.8.4".to_string()]); + assert_eq!( + best_newer_version("10.8.3", candidates.iter().map(String::as_str)), + Some("10.8.4".to_string()) + ); +} + +#[test] +fn best_newer_version_skips_branches_and_prereleases() { + let candidates = ["2", "1.10.0rc1", "1.10.0", "release-0.13"]; + assert_eq!( + best_newer_version("1.9.5", candidates.into_iter()), + Some("1.10.0".to_string()) + ); +} + +#[test] +fn best_newer_version_normalizes_date_style_tags() { + let candidates = ["lts_2026_01_07", "lts_2027_02_03"]; + assert_eq!( + best_newer_version("20260107.1", candidates.into_iter()), + Some("20270203".to_string()) + ); +} + +#[test] +fn remote_git_repository_from_github_release_url_maps_to_repo_git_url() { + let repo_url = remote_git_repository_from_source_url( + "https://github.com/Mic92/iana-etc/releases/download/20260202/iana-etc-20260202.tar.gz", + ); + assert_eq!( + repo_url, + Some("https://github.com/Mic92/iana-etc.git".to_string()) + ); +} + +#[test] +fn remote_git_repository_from_gitlab_archive_url_maps_to_repo_git_url() { + let repo_url = remote_git_repository_from_source_url( + "https://gitlab.com/graphviz/graphviz/-/archive/14.1.4/graphviz-14.1.4.tar.gz", + ); + assert_eq!( + repo_url, + Some("https://gitlab.com/graphviz/graphviz.git".to_string()) + ); +} + +#[test] +fn archive_listing_probe_uses_parent_of_first_version_segment() { + let probe = archive_listing_probe( + "https://downloads.example.test/dav1d/$version/dav1d-$version.tar.xz", + "https://downloads.example.test/dav1d/1.5.3/dav1d-1.5.3.tar.xz", + ) + .expect("archive probe"); + assert_eq!(probe.listing_url, "https://downloads.example.test/dav1d/"); + assert_eq!( + probe.patterns, + vec![VersionPattern { + prefix: String::new(), + suffix: String::new(), + }] + ); +} + +#[test] +fn candidate_versions_from_listing_matches_archive_entries() { + let patterns = vec![VersionPattern { + prefix: "alsa-lib-".into(), + suffix: ".tar.bz2".into(), + }]; + let html = r#" + alsa-lib-1.2.15.3.tar.bz2 + alsa-lib-1.2.16.tar.bz2 + "#; + assert_eq!( + candidate_versions_from_listing(html, &patterns), + vec!["1.2.15.3".to_string(), "1.2.16".to_string()] + ); +} + +#[test] +fn list_archive_versions_reads_simple_http_index() -> Result<()> { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").context("bind test listener")?; + let addr = listener.local_addr().context("listener addr")?; + let server = thread::spawn(move || -> Result<()> { + let (mut stream, _) = listener.accept().context("accept request")?; + let mut reader = BufReader::new(stream.try_clone().context("clone stream")?); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .context("read request line")?; + loop { + let mut line = String::new(); + reader.read_line(&mut line).context("read header line")?; + if line == "\r\n" || line.is_empty() { + break; + } + } + assert!(request_line.starts_with("GET /pub/lib/ HTTP/1.1")); + let body = r#" + + alsa-lib-1.2.15.3.tar.bz2 + alsa-lib-1.2.16.tar.bz2 + + "#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .context("write response")?; + stream.flush().context("flush response")?; + Ok(()) + }); + + let probe = ArchiveListingProbe { + listing_url: format!("http://{addr}/pub/lib/"), + patterns: vec![VersionPattern { + prefix: "alsa-lib-".into(), + suffix: ".tar.bz2".into(), + }], + }; + let versions = list_archive_versions(&probe)?; + server.join().expect("join server")?; + assert_eq!(versions, vec!["1.2.15.3".to_string(), "1.2.16".to_string()]); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_batches_multiple_requests_in_one_invocation() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-child-install.sh"); + let args_path = temp.path().join("args.txt"); + let env_path = temp.path().join("env.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s' \"${{DEPOT_DEPCHAIN:-}}\" > \"{}\"\n", + args_path.display(), + env_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + let requests = vec![ + PathBuf::from("/tmp/pkg-a.toml"), + PathBuf::from("/tmp/pkg-b.toml"), + ]; + let rootfs = Path::new("/"); + run_install_command_with_program( + &script_path, + &requests, + rootfs, + ChildInstallCommandOptions { + no_deps: false, + assume_yes: false, + no_flags: true, + cross_prefix: Some("x86_64-linux-musl"), + clean: true, + lib32_only: false, + install_test_deps: true, + install_context: None, + dep_chain: Some("parent"), + }, + )?; + + let captured_args = fs::read_to_string(&args_path) + .with_context(|| format!("Failed to read {}", args_path.display()))?; + assert_eq!( + captured_args.lines().collect::>(), + vec![ + "install", + "-r", + "/", + "--no-flags", + "--cross-prefix", + "x86_64-linux-musl", + "--clean", + "--test-deps", + "/tmp/pkg-a.toml", + "/tmp/pkg-b.toml", + ] + ); + assert_eq!(fs::read_to_string(&env_path)?, "parent"); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_includes_lib32_only_flag_when_requested() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-lib32-child-install.sh"); + let args_path = temp.path().join("args.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\n", + args_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + run_install_command_with_program( + &script_path, + &[PathBuf::from("/tmp/pkg.toml")], + Path::new("/"), + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: false, + cross_prefix: None, + clean: false, + lib32_only: true, + install_test_deps: false, + install_context: None, + dep_chain: None, + }, + )?; + + let captured_args = fs::read_to_string(&args_path) + .with_context(|| format!("Failed to read {}", args_path.display()))?; + assert!(captured_args.lines().any(|line| line == "--lib32-only")); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_propagates_install_context_env() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-child-install-context.sh"); + let env_path = temp.path().join("context.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s' \"${{{}:-}}\" > \"{}\"\n", + DEPOT_INSTALL_CONTEXT_ENV, + env_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + run_install_command_with_program( + &script_path, + &[PathBuf::from("/tmp/pkg.toml")], + Path::new("/"), + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: false, + cross_prefix: None, + clean: false, + lib32_only: false, + install_test_deps: false, + install_context: Some(INSTALL_CONTEXT_UPDATE), + dep_chain: None, + }, + )?; + + assert_eq!(fs::read_to_string(&env_path)?, INSTALL_CONTEXT_UPDATE); + Ok(()) +} + +#[test] +fn direct_install_checks_manual_sources_before_dependency_resolution() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let spec_dir = tempfile::tempdir().context("Failed to create temp spec dir")?; + let spec_path = spec_dir.path().join("demo.toml"); + fs::write( + &spec_path, + r#"[package] +name = "demo" +version = "1.0.0" +revision = 1 +description = "demo" +homepage = "https://example.test/demo" +license = "MIT" + +[build] +type = "custom" + +[dependencies] +runtime = ["definitely-missing-dep"] +optional = [] + +[[manual_sources]] +file = "missing.patch" +"#, + )?; + + let mut config = config::Config::for_rootfs(rootfs.path()); + config.build_dir = rootfs.path().join("var/cache/depot/build"); + config.cache_dir = rootfs.path().join("var/cache/depot/sources"); + config.db_dir = rootfs.path().join("var/lib/depot"); + + ui::set_assume_yes(true); + let result = run_direct_install_request( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: false, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &config, + spec_path, + ); + ui::set_assume_yes(false); + + let err = result.expect_err("missing manual source should fail before dependency install"); + assert!( + err.to_string() + .contains("Manual source not found: missing.patch") + ); + assert!( + !err.to_string() + .contains("Could not find package spec for dependency") + ); + Ok(()) +} + +#[test] +fn suppress_nested_install_output_for_planned_context() { + let mut env = TestEnv::new(); + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); + + assert!(suppress_nested_install_output()); + assert_eq!( + current_install_invocation_context(), + InstallInvocationContext::Planned + ); +} + +#[test] +fn sudo_preserve_env_arg_only_includes_present_depot_env_vars() { + let mut env = TestEnv::new(); + assert_eq!(sudo_preserve_env_arg(), None); + + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); + assert_eq!( + sudo_preserve_env_arg(), + Some(format!("--preserve-env={}", DEPOT_INSTALL_CONTEXT_ENV)) + ); + + env.set_var("DEPOT_DEPCHAIN", "parent"); + assert_eq!( + sudo_preserve_env_arg(), + Some(format!( + "--preserve-env={},DEPOT_DEPCHAIN", + DEPOT_INSTALL_CONTEXT_ENV + )) + ); +} + +#[test] +fn plan_dependency_closure_tracks_requested_dependency_roots() { + let plan = planner::ExecutionPlan { + steps: vec![ + planner::PlannedStep { + package: "zlib".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/zlib/zlib.toml"), + local_sibling: false, + }, + requested_by: vec!["cmake needs zlib".into()], + }, + planner::PlannedStep { + package: "cmake".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/cmake/cmake.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency cmake".into()], + }, + planner::PlannedStep { + package: "libffi".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/libffi/libffi.toml"), + local_sibling: false, + }, + requested_by: vec!["python needs libffi".into()], + }, + planner::PlannedStep { + package: "python".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/python/python.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency python".into()], + }, + ], + }; + + let cmake_closure = plan_dependency_closure_for_requested_deps(&plan, &["cmake".into()]); + assert_eq!( + cmake_closure, + HashSet::from(["cmake".to_string(), "zlib".to_string()]) + ); + + let python_closure = plan_dependency_closure_for_requested_deps(&plan, &["python".into()]); + assert_eq!( + python_closure, + HashSet::from(["python".to_string(), "libffi".to_string()]) + ); +} + +#[test] +fn cleanup_targets_keep_runtime_dependencies_for_build_install() { + let plan = planner::ExecutionPlan { + steps: vec![ + planner::PlannedStep { + package: "zlib".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/zlib/zlib.toml"), + local_sibling: false, + }, + requested_by: vec!["cmake needs zlib".into(), "llvm-runtime needs zlib".into()], + }, + planner::PlannedStep { + package: "cmake".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/cmake/cmake.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency cmake".into()], + }, + planner::PlannedStep { + package: "llvm-runtime".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/llvm-runtime/llvm-runtime.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency llvm-runtime".into()], + }, + ], + }; + let mut tracker = AutoInstalledDependencyTracker::default(); + tracker.record_plan(&plan, &["cmake".into()], AutoInstalledDependencyKind::Build); + tracker.record_plan( + &plan, + &["llvm-runtime".into()], + AutoInstalledDependencyKind::Runtime, + ); + + assert_eq!(tracker.cleanup_targets(false), vec!["cmake".to_string()]); + assert_eq!( + tracker.cleanup_targets(true), + vec![ + "llvm-runtime".to_string(), + "cmake".to_string(), + "zlib".to_string() + ] + ); +} + +#[test] +fn build_type_runs_automatic_tests_matches_builder_behavior() { + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Autotools, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Perl, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Meson, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::CMake, + None, + &[] + ))); +} + +#[test] +fn requested_test_deps_prompt_can_disable_tests() -> Result<()> { + let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(prompted); + assert!(spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn requested_test_deps_prompt_is_ignored_for_non_automatic_test_builders() -> Result<()> { + let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(!prompted); + assert!(!spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn requested_test_deps_prompt_is_ignored_for_multilib_builds() -> Result<()> { + let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.build.flags.build_32 = true; + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(!prompted); + assert!(!spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn should_not_install_test_deps_for_cli_lib32_only_builds() { + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.dependencies.lib32 = Some(package::DependencyGroup { + build: Vec::new(), + runtime: Vec::new(), + test: vec!["lib32-pytest".into()], + optional: Vec::new(), + groups: Vec::new(), + }); + + assert!(!should_install_test_deps( + &spec, + true, + deps::RequestedOutputs::Lib32Only + )); +} + +#[test] +fn rootfs_is_system_root_detects_live_rootfs() { + assert!(rootfs_is_system_root(Path::new("/"))); + assert!(!rootfs_is_system_root(Path::new("/tmp/depot-test-rootfs"))); +} + +#[test] +fn command_requires_live_root_for_install_remove_and_update() { + assert!(command_requires_live_root(&Commands::Install( + InstallArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_or_archive: vec![PathBuf::from("foo")], + spec: None, + } + ))); + assert!(command_requires_live_root(&Commands::Remove(RemoveArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + package: "foo".to_string(), + }))); + assert!(command_requires_live_root(&Commands::Update(UpdateArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + packages: vec!["foo".to_string()], + }))); + assert!(!command_requires_live_root(&Commands::Build(BuildArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_pos: Some(PathBuf::from("foo.toml")), + spec: None, + install: false, + install_deps: false, + cleanup_deps: false, + }))); + assert!(!command_requires_live_root(&Commands::Search(SearchArgs { + rootfs_args: rootfs_args("/"), + query: "foo".to_string(), + files: false, + }))); +} + +#[test] +fn should_delegate_live_rootfs_installs_only_for_live_root_when_non_root() { + assert_eq!( + should_delegate_live_rootfs_installs(Path::new("/")), + !crate::fakeroot::is_root() + ); + assert!(!should_delegate_live_rootfs_installs(Path::new( + "/tmp/depot-test-rootfs" + ))); +} + +#[test] +fn live_rootfs_child_install_batches_group_consecutive_binary_steps() -> Result<()> { + let source_path = PathBuf::from("/tmp/requested.toml"); + let expat_archive = PathBuf::from("/tmp/expat.pkg.tar.zst"); + let python_archive = PathBuf::from("/tmp/python.pkg.tar.zst"); + let compiler_rt_archive = PathBuf::from("/tmp/lib32-compiler-rt.pkg.tar.zst"); + + let expat_record = test_binary_repo_record("expat", "expat-1.0-1-x86_64.depot.pkg.tar.zst"); + let python_record = test_binary_repo_record("python", "python-1.0-1-x86_64.depot.pkg.tar.zst"); + let compiler_rt_record = test_binary_repo_record( + "lib32-compiler-rt", + "lib32-compiler-rt-1.0-1-x86_64.depot.pkg.tar.zst", + ); + + let steps = [ + planner::PlannedStep { + package: "expat".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(expat_record.clone()), + }, + requested_by: vec!["pkg needs expat".into()], + }, + planner::PlannedStep { + package: "python".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(python_record.clone()), + }, + requested_by: vec!["pkg needs python".into()], + }, + planner::PlannedStep { + package: "pkg".into(), + action: planner::PlanAction::BuildAndInstall, + origin: planner::PlanOrigin::Source { + path: source_path.clone(), + local_sibling: false, + }, + requested_by: vec!["requested spec".into()], + }, + planner::PlannedStep { + package: "lib32-compiler-rt".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(compiler_rt_record.clone()), + }, + requested_by: vec!["pkg needs lib32-compiler-rt".into()], + }, + ]; + let step_refs = steps.iter().collect::>(); + + let mut binary_archives = HashMap::new(); + binary_archives.insert( + ("core".to_string(), expat_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: expat_archive.clone(), + signature_path: PathBuf::from("/tmp/expat.sig"), + }, + ); + binary_archives.insert( + ("core".to_string(), python_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: python_archive.clone(), + signature_path: PathBuf::from("/tmp/python.sig"), + }, + ); + binary_archives.insert( + ("core".to_string(), compiler_rt_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: compiler_rt_archive.clone(), + signature_path: PathBuf::from("/tmp/lib32-compiler-rt.sig"), + }, + ); + + let options = InstallPlanExecutionOptions { + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + confirm_installation: false, + lib32_only_requested_specs: true, + install_test_deps: false, + }; + + let batches = build_live_rootfs_child_install_batches(&step_refs, &options, &binary_archives)?; + + assert_eq!( + batches, + vec![ + ChildInstallBatch { + requests: vec![expat_archive, python_archive], + lib32_only: false, + }, + ChildInstallBatch { + requests: vec![source_path], + lib32_only: true, + }, + ChildInstallBatch { + requests: vec![compiler_rt_archive], + lib32_only: false, + }, + ] + ); + Ok(()) +} + +#[test] +fn build_command_requires_install_deps_flag_for_missing_dependencies() -> Result<()> { + let _guard = ASSUME_YES_TEST_LOCK.lock().expect("assume-yes test lock"); + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_root = temp.path().join("packages"); + let app_dir = repo_root.join("app"); + let dep_dir = repo_root.join("dep"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&app_dir)?; + fs::create_dir_all(&dep_dir)?; + + let app_spec = app_dir.join("app.toml"); + fs::write( + &app_spec, + r#"[package] +name = "app" +version = "1.0.0" +revision = 1 +description = "app" +homepage = "https://example.test/app" +license = "MIT" + +[[source]] +url = "https://example.test/app-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "app-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = ["dep"] +runtime = [] +optional = [] +"#, + ) + .with_context(|| format!("Failed to write {}", app_spec.display()))?; + + let dep_spec = dep_dir.join("dep.toml"); + fs::write( + &dep_spec, + r#"[package] +name = "dep" +version = "1.0.0" +revision = 1 +description = "dep" +homepage = "https://example.test/dep" +license = "MIT" + +[[source]] +url = "https://example.test/dep-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "dep-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = [] +runtime = [] +optional = [] +"#, + ) + .with_context(|| format!("Failed to write {}", dep_spec.display()))?; + + let result = run(Cli { + command: Commands::Build(BuildArgs { + rootfs_args: rootfs_args(rootfs.clone()), + prompt_args: prompt_args(true), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_pos: Some(app_spec), + spec: None, + install: false, + install_deps: false, + cleanup_deps: false, + }), + }); + ui::set_assume_yes(false); + + let err = result.expect_err("build should require --install-deps when deps are missing"); + assert!(err.to_string().contains("Re-run with --install-deps")); + Ok(()) +} + +#[test] +fn make_lib32_build_spec_uses_only_lib32_flag_rules() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.build.flags.cflags = vec!["-O2".into()]; + base.build.flags.replace_cflags = vec!["-O2=>-O3".into()]; + base.build.flags.cflags_lib32 = vec!["-m32".into()]; + base.build.flags.replace_cflags_lib32 = vec!["-m32=>-mstackrealign".into()]; + base.build.flags.cxxflags = vec!["-O2".into()]; + base.build.flags.replace_cxxflags = vec!["-O2=>-O3".into()]; + base.build.flags.cxxflags_lib32 = vec!["-fno-rtti".into()]; + base.build.flags.replace_cxxflags_lib32 = vec!["-fno-rtti=>-fno-exceptions".into()]; + + let lib32 = make_lib32_build_spec(&base); + + assert!(lib32.build.flags.lib32_variant); + assert_eq!(lib32.build.flags.cflags, vec!["-m32"]); + assert_eq!( + lib32.build.flags.replace_cflags, + vec!["-m32=>-mstackrealign"] + ); + assert_eq!(lib32.build.flags.cxxflags, vec!["-fno-rtti"]); + assert_eq!( + lib32.build.flags.replace_cxxflags, + vec!["-fno-rtti=>-fno-exceptions"] + ); +} + +#[test] +fn make_lib32_package_spec_uses_lib32_dependency_override() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.dependencies.runtime = vec!["zlib".into()]; + base.dependencies.lib32 = Some(package::DependencyGroup { + build: vec!["gcc-multilib".into()], + runtime: vec!["lib32-zlib".into()], + test: Vec::new(), + optional: vec!["lib32-gtk-doc".into()], + groups: Vec::new(), + }); + + let lib32 = make_lib32_package_spec(&base); + + assert_eq!(lib32.package.name, "lib32-pkg"); + assert_eq!(lib32.dependencies.build, vec!["gcc-multilib"]); + assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib", "pkg"]); + assert_eq!(lib32.dependencies.optional, vec!["lib32-gtk-doc"]); +} + +#[test] +fn make_lib32_package_spec_does_not_inherit_primary_alternatives() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.alternatives.provides = vec!["editor".into()]; + base.alternatives.conflicts = vec!["nano".into()]; + base.alternatives.replaces = vec!["vi".into()]; + + let lib32 = make_lib32_package_spec(&base); + + assert_eq!(lib32.package.name, "lib32-pkg"); + assert!(lib32.alternatives.provides.is_empty()); + assert!(lib32.alternatives.conflicts.is_empty()); + assert!(lib32.alternatives.replaces.is_empty()); +} + +#[test] +fn requested_outputs_prefers_lib32_only_spec_flag() { + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.build.flags.lib32_only = true; + + assert_eq!( + requested_outputs(&spec, false), + deps::RequestedOutputs::Lib32Only + ); +} + +#[test] +fn expand_install_requests_for_groups_uses_source_specs() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + let repo_root = temp.path().join("repos"); + let core = repo_root.join("core").join("foo"); + let desktop = repo_root.join("desktop").join("bar"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&core)?; + fs::create_dir_all(&desktop)?; + + let foo_spec = core.join("foo.toml"); + fs::write( + &foo_spec, + r#"[package] +name = "foo" +version = "1.0.0" +revision = 1 +description = "foo" +homepage = "https://example.test/foo" +license = "MIT" + +[[source]] +url = "https://example.test/foo-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "foo-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base"] +runtime = [] +optional = [] +"#, + )?; + + let bar_spec = desktop.join("bar.toml"); + fs::write( + &bar_spec, + r#"[package] +name = "bar" +version = "1.0.0" +revision = 1 +description = "bar" +homepage = "https://example.test/bar" +license = "MIT" + +[[source]] +url = "https://example.test/bar-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "bar-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base", "desktop"] +runtime = [] +optional = [] +"#, + )?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_root; + config.binary_repos.clear(); + + let (expanded, groups) = + expand_install_requests_for_groups(&config, &rootfs, &[PathBuf::from("base")])?; + + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec![PathBuf::from("bar"), PathBuf::from("foo")]); + Ok(()) +} + +#[test] +fn expand_installed_group_targets_uses_installed_group_membership() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + fs::create_dir_all(&rootfs)?; + let config = config::Config::for_rootfs(&rootfs); + let db_path = config.installed_db_path(&rootfs); + + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin/foo"), "foo")?; + + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.package.name = "foo".into(); + spec.dependencies.groups = vec!["base".into()]; + db::register_package(&db_path, &spec, &dest)?; + db::record_installed_groups(&db_path, &[String::from("base")])?; + + let (expanded, groups) = expand_installed_group_targets(&db_path, &[String::from("base")])?; + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec!["foo".to_string()]); + Ok(()) +} diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..9260614 --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,50 @@ +use super::*; +use crate::commands::repo::groups::expand_installed_group_targets; + +pub(crate) mod candidates; +pub(crate) mod check; +pub(crate) mod versions; + +use self::candidates::{ + UpdateCommandOptions, run_update_command, sync_source_repositories_for_update, +}; + +pub(super) fn run_update(args: UpdateArgs, cli_test_deps: bool) -> Result<()> { + let UpdateArgs { + rootfs_args, + prompt_args, + build_exec_args, + packages, + } = args; + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + 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)?; + let expanded_packages = if packages.is_empty() { + Vec::new() + } else { + expand_installed_group_targets(&config.installed_db_path(&rootfs), &packages)?.0 + }; + if !is_explicit_depot_self_update_request(&expanded_packages) { + ensure_depot_self_update_not_required(&config, &rootfs)?; + } + run_update_command( + &expanded_packages, + &config, + UpdateCommandOptions { + rootfs: &rootfs, + no_deps, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + assume_yes: yes, + install_test_deps: install_test_deps_enabled(cli_test_deps, &config), + }, + ) +} diff --git a/src/commands/update/candidates.rs b/src/commands/update/candidates.rs new file mode 100644 index 0000000..c80e049 --- /dev/null +++ b/src/commands/update/candidates.rs @@ -0,0 +1,936 @@ +use super::*; +use crate::commands::repo::groups::scan_package_specs; + +#[derive(Clone, Copy)] +pub(crate) struct UpdateCommandOptions<'a> { + pub(crate) rootfs: &'a Path, + pub(crate) no_deps: bool, + pub(crate) no_flags: bool, + pub(crate) cross_prefix: Option<&'a str>, + pub(crate) clean: bool, + pub(crate) dry_run: bool, + pub(crate) assume_yes: bool, + pub(crate) install_test_deps: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum UpdateOrigin { + Source { + repo_name: String, + path: PathBuf, + }, + Binary { + repo_name: String, + record: Box, + }, +} + +#[derive(Debug, Clone)] +pub(crate) struct UpdateCandidate { + pub(crate) installed_package: String, + pub(crate) candidate_package: String, + pub(crate) replaces_installed: bool, + pub(crate) installed_version: String, + pub(crate) installed_revision: u32, + pub(crate) installed_completed_at: Option, + pub(crate) candidate_version: String, + pub(crate) candidate_revision: u32, + pub(crate) candidate_completed_at: Option, + pub(crate) runtime_dependencies: Vec, + pub(crate) provides: Vec, + pub(crate) conflicts: Vec, + pub(crate) repo_priority: i32, + pub(crate) origin: UpdateOrigin, +} + +#[derive(Debug, Clone)] +pub(crate) struct SourceUpdateCandidate { + pub(crate) repo_name: String, + pub(crate) repo_priority: i32, + pub(crate) path: PathBuf, + pub(crate) completed_at: Option, + pub(crate) spec: package::PackageSpec, +} + +pub(crate) fn source_update_candidate_is_better( + candidate: &SourceUpdateCandidate, + current: &SourceUpdateCandidate, +) -> bool { + match compare_package_release( + &candidate.spec.package.version, + candidate.spec.package.revision, + ¤t.spec.package.version, + current.spec.package.revision, + ) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => match compare_completed_at(candidate.completed_at, current.completed_at) + { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => { + if candidate.repo_priority != current.repo_priority { + candidate.repo_priority < current.repo_priority + } else if candidate.repo_name != current.repo_name { + candidate.repo_name < current.repo_name + } else { + candidate.path < current.path + } + } + }, + } +} + +fn binary_update_candidate_is_better( + repo_name: &str, + repo_priority: i32, + record: &db::repo::BinaryRepoPackageRecord, + current_priority: i32, + current: &db::repo::BinaryRepoPackageRecord, +) -> bool { + match compare_package_release( + &record.version, + record.revision, + ¤t.version, + current.revision, + ) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => match compare_completed_at(record.completed_at, current.completed_at) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => { + if repo_priority != current_priority { + repo_priority < current_priority + } else if repo_name != current.repo_name { + repo_name < current.repo_name.as_str() + } else { + record.filename < current.filename + } + } + }, + } +} + +fn compare_package_release( + left_version: &str, + left_revision: u32, + right_version: &str, + right_revision: u32, +) -> Ordering { + super::compare_versions_for_updates(left_version, right_version) + .then_with(|| left_revision.cmp(&right_revision)) +} + +pub(crate) fn compare_completed_at(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.cmp(&right), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } +} + +fn update_candidate_is_newer_than_installed( + candidate_version: &str, + candidate_revision: u32, + candidate_completed_at: Option, + installed_version: &str, + installed_revision: u32, + installed_completed_at: Option, +) -> bool { + match compare_package_release( + candidate_version, + candidate_revision, + installed_version, + installed_revision, + ) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => { + compare_completed_at(candidate_completed_at, installed_completed_at) + == Ordering::Greater + } + } +} + +fn path_modified_unix_timestamp(path: &Path) -> Result> { + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for {}", path.display()))?; + let modified = metadata + .modified() + .with_context(|| format!("Failed to read modification time for {}", path.display()))?; + Ok(Some(crate::metadata_time::system_time_to_unix(modified)?)) +} + +fn installed_package_completed_at( + installed: &db::InstalledPackageRecord, + db_path: &Path, + rootfs: &Path, +) -> Result> { + if installed.completed_at.is_some() { + return Ok(installed.completed_at); + } + + let mut latest = None; + for relative_path in db::get_package_files(db_path, &installed.name)? { + let path = rootfs.join(&relative_path); + if !path.exists() { + continue; + } + if let Some(modified) = path_modified_unix_timestamp(&path)? { + latest = Some(latest.map_or(modified, |current: i64| current.max(modified))); + } + } + + Ok(latest) +} + +fn update_origin_is_binary(origin: &UpdateOrigin) -> bool { + matches!(origin, UpdateOrigin::Binary { .. }) +} + +fn update_origin_label(origin: &UpdateOrigin) -> String { + match origin { + UpdateOrigin::Source { repo_name, path } => { + format!("source:{repo_name}:{}", path.display()) + } + UpdateOrigin::Binary { repo_name, record } => { + format!("binary:{repo_name}:{}", record.filename) + } + } +} + +pub(crate) fn update_candidate_is_preferred( + candidate: &UpdateCandidate, + current: &UpdateCandidate, + prefer_binary: bool, +) -> bool { + match compare_package_release( + &candidate.candidate_version, + candidate.candidate_revision, + ¤t.candidate_version, + current.candidate_revision, + ) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => match compare_completed_at( + candidate.candidate_completed_at, + current.candidate_completed_at, + ) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => match ( + update_origin_is_binary(&candidate.origin), + update_origin_is_binary(¤t.origin), + ) { + (true, false) => prefer_binary, + (false, true) => !prefer_binary, + _ => { + if candidate.repo_priority != current.repo_priority { + candidate.repo_priority < current.repo_priority + } else { + update_origin_label(&candidate.origin) + < update_origin_label(¤t.origin) + } + } + }, + }, + } +} + +fn candidate_request_path( + candidate: &UpdateCandidate, + config: &config::Config, + rootfs: &Path, +) -> Result { + match &candidate.origin { + UpdateOrigin::Source { path, .. } => Ok(path.clone()), + UpdateOrigin::Binary { repo_name, record } => { + let repo_cfg = config + .binary_repos + .get(repo_name) + .with_context(|| format!("Binary repo '{}' not found in config", repo_name))?; + db::repo::fetch_binary_package_archive( + repo_name, + repo_cfg, + rootfs, + record, + &config.package_cache_dir, + ) + } + } +} + +pub(crate) fn sync_source_repositories_for_update(config: &config::Config) -> Result<()> { + if config.repo_settings.prefer_binary { + return Ok(()); + } + + let mirrors = config.enabled_source_mirror_map(); + if mirrors.is_empty() { + return Ok(()); + } + + let mut repo_lock = locking::open_lock(config)?; + let repo_lock_path = locking::lock_path(config); + let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "update sync")?; + db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; + Ok(()) +} + +fn configured_source_scan_roots(config: &config::Config) -> Vec<(String, i32, PathBuf)> { + let mut roots = Vec::new(); + let mut repos: Vec<_> = config + .source_repos + .iter() + .filter(|(_, repo)| repo.enabled) + .collect(); + repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + for (repo_name, repo) in repos { + let repo_root = config.repo_clone_dir.join(repo_name); + if repo.subdirs.is_empty() { + roots.push((repo_name.clone(), repo.priority, repo_root)); + } else { + for subdir in &repo.subdirs { + roots.push((repo_name.clone(), repo.priority, repo_root.join(subdir))); + } + } + } + + roots +} + +pub(crate) fn collect_best_source_update_candidates( + config: &config::Config, + target_real_names: &HashSet, +) -> Result> { + let mut best: HashMap = HashMap::new(); + + for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { + if !root.exists() { + continue; + } + + for spec_path in scan_package_specs(&root)? { + let mut spec = package::PackageSpec::from_file(&spec_path)?; + spec.apply_config(config); + let stream_name = spec.package.effective_real_name().to_string(); + if !target_real_names.contains(&stream_name) { + continue; + } + + let candidate = SourceUpdateCandidate { + repo_name: repo_name.clone(), + repo_priority, + path: spec_path.clone(), + completed_at: path_modified_unix_timestamp(&spec_path)?, + spec, + }; + + let replace = match best.get(&stream_name) { + Some(current) => source_update_candidate_is_better(&candidate, current), + None => true, + }; + + if replace { + best.insert(stream_name, candidate); + } + } + } + + Ok(best) +} + +fn collect_best_source_replacement_candidates( + config: &config::Config, + target_packages: &HashSet, +) -> Result> { + let mut best: HashMap = HashMap::new(); + + for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { + if !root.exists() { + continue; + } + + for spec_path in scan_package_specs(&root)? { + let mut spec = package::PackageSpec::from_file(&spec_path)?; + spec.apply_config(config); + let candidate = SourceUpdateCandidate { + repo_name: repo_name.clone(), + repo_priority, + path: spec_path.clone(), + completed_at: path_modified_unix_timestamp(&spec_path)?, + spec, + }; + + for replaced in &candidate.spec.alternatives.replaces { + if !target_packages.contains(replaced) { + continue; + } + let replace = match best.get(replaced) { + Some(current) => source_update_candidate_is_better(&candidate, current), + None => true, + }; + if replace { + best.insert(replaced.clone(), candidate.clone()); + } + } + } + } + + Ok(best) +} + +fn collect_best_binary_update_candidates( + config: &config::Config, + rootfs: &Path, + target_real_names: &HashSet, +) -> Result> { + let mut best: HashMap = HashMap::new(); + let host_arch = std::env::consts::ARCH; + let mut repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + for (repo_name, repo_cfg) in repos { + let records = db::repo::list_binary_repo_packages( + repo_name, + repo_cfg, + rootfs, + &config.package_cache_dir, + ) + .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; + + for record in records { + let stream_name = record.effective_real_name().to_string(); + if !target_real_names.contains(&stream_name) { + continue; + } + + let replace = match best.get(&stream_name) { + Some((current_priority, current)) => binary_update_candidate_is_better( + repo_name, + repo_cfg.priority, + &record, + *current_priority, + current, + ), + None => true, + }; + + if replace { + best.insert(stream_name, (repo_cfg.priority, record)); + } + } + } + + Ok(best) +} + +fn collect_best_binary_replacement_candidates( + config: &config::Config, + rootfs: &Path, + target_packages: &HashSet, +) -> Result> { + let mut best: HashMap = HashMap::new(); + let host_arch = std::env::consts::ARCH; + let mut repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + for (repo_name, repo_cfg) in repos { + let records = db::repo::list_binary_repo_packages( + repo_name, + repo_cfg, + rootfs, + &config.package_cache_dir, + ) + .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; + + for record in records { + for replaced in &record.replaces { + if !target_packages.contains(replaced) { + continue; + } + let replace = match best.get(replaced) { + Some((current_priority, current)) => binary_update_candidate_is_better( + repo_name, + repo_cfg.priority, + &record, + *current_priority, + current, + ), + None => true, + }; + if replace { + best.insert(replaced.clone(), (repo_cfg.priority, record.clone())); + } + } + } + } + + Ok(best) +} + +pub(crate) fn select_update_candidate( + installed: &db::InstalledPackageRecord, + installed_completed_at: Option, + source_replacement_candidates: &HashMap, + binary_replacement_candidates: &HashMap, + source_candidates: &HashMap, + binary_candidates: &HashMap, + prefer_binary: bool, +) -> Option { + let mut best: Option = None; + let stream_name = installed.effective_real_name(); + + if let Some(candidate) = source_replacement_candidates.get(&installed.name) { + best = Some(UpdateCandidate { + installed_package: installed.name.clone(), + candidate_package: candidate.spec.package.name.clone(), + replaces_installed: true, + installed_version: installed.version.clone(), + installed_revision: installed.revision, + installed_completed_at, + candidate_version: candidate.spec.package.version.clone(), + candidate_revision: candidate.spec.package.revision, + candidate_completed_at: candidate.completed_at, + runtime_dependencies: candidate.spec.dependencies.runtime.clone(), + provides: candidate.spec.alternatives.provides.clone(), + conflicts: candidate.spec.alternatives.conflicts.clone(), + repo_priority: candidate.repo_priority, + origin: UpdateOrigin::Source { + repo_name: candidate.repo_name.clone(), + path: candidate.path.clone(), + }, + }); + } + + if let Some((repo_priority, record)) = binary_replacement_candidates.get(&installed.name) { + let binary_candidate = UpdateCandidate { + installed_package: installed.name.clone(), + candidate_package: record.name.clone(), + replaces_installed: true, + installed_version: installed.version.clone(), + installed_revision: installed.revision, + installed_completed_at, + candidate_version: record.version.clone(), + candidate_revision: record.revision, + candidate_completed_at: record.completed_at, + runtime_dependencies: record.runtime_dependencies.clone(), + provides: record.provides.clone(), + conflicts: record.conflicts.clone(), + repo_priority: *repo_priority, + origin: UpdateOrigin::Binary { + repo_name: record.repo_name.clone(), + record: Box::new(record.clone()), + }, + }; + + if best.as_ref().is_none_or(|current| { + update_candidate_is_preferred(&binary_candidate, current, prefer_binary) + }) { + best = Some(binary_candidate); + } + } + + if best.is_some() { + return best; + } + + if let Some(candidate) = source_candidates.get(stream_name) + && update_candidate_is_newer_than_installed( + &candidate.spec.package.version, + candidate.spec.package.revision, + candidate.completed_at, + &installed.version, + installed.revision, + installed_completed_at, + ) + { + best = Some(UpdateCandidate { + installed_package: installed.name.clone(), + candidate_package: candidate.spec.package.name.clone(), + replaces_installed: false, + installed_version: installed.version.clone(), + installed_revision: installed.revision, + installed_completed_at, + candidate_version: candidate.spec.package.version.clone(), + candidate_revision: candidate.spec.package.revision, + candidate_completed_at: candidate.completed_at, + runtime_dependencies: candidate.spec.dependencies.runtime.clone(), + provides: candidate.spec.alternatives.provides.clone(), + conflicts: candidate.spec.alternatives.conflicts.clone(), + repo_priority: candidate.repo_priority, + origin: UpdateOrigin::Source { + repo_name: candidate.repo_name.clone(), + path: candidate.path.clone(), + }, + }); + } + + if let Some((repo_priority, record)) = binary_candidates.get(stream_name) + && update_candidate_is_newer_than_installed( + &record.version, + record.revision, + record.completed_at, + &installed.version, + installed.revision, + installed_completed_at, + ) + { + let binary_candidate = UpdateCandidate { + installed_package: installed.name.clone(), + candidate_package: record.name.clone(), + replaces_installed: false, + installed_version: installed.version.clone(), + installed_revision: installed.revision, + installed_completed_at, + candidate_version: record.version.clone(), + candidate_revision: record.revision, + candidate_completed_at: record.completed_at, + runtime_dependencies: record.runtime_dependencies.clone(), + provides: record.provides.clone(), + conflicts: record.conflicts.clone(), + repo_priority: *repo_priority, + origin: UpdateOrigin::Binary { + repo_name: record.repo_name.clone(), + record: Box::new(record.clone()), + }, + }; + + if best.as_ref().is_none_or(|current| { + update_candidate_is_preferred(&binary_candidate, current, prefer_binary) + }) { + best = Some(binary_candidate); + } + } + + best +} + +pub(crate) fn collect_update_candidates( + config: &config::Config, + rootfs: &Path, + requested_packages: &[String], +) -> Result> { + let db_path = config.installed_db_path(rootfs); + let installed = db::list_installed_package_records(&db_path)?; + if installed.is_empty() { + return Ok(Vec::new()); + } + + let mut installed_by_real_name: HashMap> = + HashMap::new(); + for record in &installed { + installed_by_real_name + .entry(record.effective_real_name().to_string()) + .or_default() + .push(record); + } + + let active_by_real_name: HashMap = installed_by_real_name + .iter() + .filter_map(|(real_name, records)| { + super::super::select_primary_installed_record(records.iter().copied()) + .cloned() + .map(|record| (real_name.clone(), record)) + }) + .collect(); + + let target_real_names: HashSet = if requested_packages.is_empty() { + active_by_real_name.keys().cloned().collect() + } else { + let mut targets = HashSet::new(); + for package in requested_packages { + if let Some(record) = installed.iter().find(|record| record.name == *package) { + targets.insert(record.effective_real_name().to_string()); + } else if active_by_real_name.contains_key(package) { + targets.insert(package.clone()); + } else { + ui::warn(format!("Package '{}' is not installed; skipping", package)); + } + } + targets + }; + let target_package_names: HashSet = if requested_packages.is_empty() { + active_by_real_name + .values() + .map(|record| record.name.clone()) + .collect() + } else { + let mut targets = HashSet::new(); + for package in requested_packages { + if let Some(record) = installed.iter().find(|record| record.name == *package) { + targets.insert(record.name.clone()); + } else if let Some(record) = active_by_real_name.get(package) { + targets.insert(record.name.clone()); + } + } + targets + }; + + let source_candidates = if config.repo_settings.prefer_binary { + HashMap::new() + } else { + collect_best_source_update_candidates(config, &target_real_names)? + }; + let source_replacement_candidates = if config.repo_settings.prefer_binary { + HashMap::new() + } else { + collect_best_source_replacement_candidates(config, &target_package_names)? + }; + let binary_candidates = + collect_best_binary_update_candidates(config, rootfs, &target_real_names)?; + let binary_replacement_candidates = + collect_best_binary_replacement_candidates(config, rootfs, &target_package_names)?; + + let mut updates = Vec::new(); + let mut targets: Vec<_> = target_real_names.into_iter().collect(); + targets.sort(); + for target in targets { + let Some(installed) = active_by_real_name.get(&target) else { + continue; + }; + let installed_completed_at = installed_package_completed_at(installed, &db_path, rootfs)?; + if let Some(candidate) = select_update_candidate( + installed, + installed_completed_at, + &source_replacement_candidates, + &binary_replacement_candidates, + &source_candidates, + &binary_candidates, + config.repo_settings.prefer_binary, + ) { + updates.push(candidate); + } + } + + Ok(updates) +} + +pub(crate) fn collect_missing_update_dependencies( + candidates: &[UpdateCandidate], + db_path: &Path, +) -> Result> { + let mut planned_provides = HashSet::new(); + for candidate in candidates { + planned_provides.insert(candidate.candidate_package.clone()); + for provide in &candidate.provides { + planned_provides.insert(provide.clone()); + } + } + + let mut missing = Vec::new(); + for candidate in candidates { + for dep in &candidate.runtime_dependencies { + if planned_provides.contains(deps::dep_name(dep)) { + continue; + } + if deps::is_dep_satisfied_in_db(dep, db_path)? { + continue; + } + if !missing.contains(dep) { + missing.push(dep.clone()); + } + } + } + + Ok(missing) +} + +fn run_update_install_command( + program: &Path, + request: &Path, + options: UpdateCommandOptions<'_>, +) -> Result<()> { + super::super::run_install_command_with_program( + program, + &[request.to_path_buf()], + options.rootfs, + super::super::ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + lib32_only: false, + install_test_deps: options.install_test_deps, + install_context: Some(super::super::INSTALL_CONTEXT_UPDATE), + dep_chain: None, + }, + ) +} + +fn confirm_update_replacements(candidates: &[UpdateCandidate]) -> Result<()> { + for candidate in candidates { + if !candidate.replaces_installed { + continue; + } + if !ui::prompt_yes_no( + &format!( + "replace {} with {}?", + candidate.installed_package, candidate.candidate_package + ), + true, + )? { + anyhow::bail!( + "Replacement declined: {} -> {}", + candidate.installed_package, + candidate.candidate_package + ); + } + } + Ok(()) +} + +pub(crate) fn run_update_command( + packages: &[String], + config: &config::Config, + options: UpdateCommandOptions<'_>, +) -> Result<()> { + let updates = collect_update_candidates(config, options.rootfs, packages)?; + if updates.is_empty() { + ui::info("All installed packages are up to date."); + return Ok(()); + } + + let targets: Vec = updates + .iter() + .map(|candidate| { + let summary = format!( + "{} v{}-{} -> {} v{}-{}", + candidate.installed_package, + candidate.installed_version, + candidate.installed_revision, + candidate.candidate_package, + candidate.candidate_version, + candidate.candidate_revision + ); + if candidate.installed_version == candidate.candidate_version + && candidate.installed_revision == candidate.candidate_revision + && compare_completed_at( + candidate.candidate_completed_at, + candidate.installed_completed_at, + ) == Ordering::Greater + { + format!("{summary} (newer UTC build timestamp)") + } else { + summary + } + }) + .collect(); + + let conflict_subjects: Vec<_> = updates + .iter() + .map(|candidate| super::super::InstallConflictSubject { + package: candidate.candidate_package.clone(), + provides: candidate.provides.clone(), + conflicts: candidate.conflicts.clone(), + }) + .collect(); + super::super::validate_no_transaction_conflicts(&conflict_subjects)?; + + ui::info(format!("{} package(s) can be updated:", updates.len())); + for target in &targets { + ui::info(format!(" {}", target)); + } + + let db_path = config.installed_db_path(options.rootfs); + let missing_deps = if options.no_deps { + Vec::new() + } else { + collect_missing_update_dependencies(&updates, &db_path)? + }; + if !missing_deps.is_empty() { + ui::info(format!( + "New dependencies required by updates: {}", + missing_deps.join(", ") + )); + } + + if !options.dry_run && !ui::prompt_package_action("update", &targets, true)? { + anyhow::bail!("Aborted"); + } + + if !options.dry_run { + confirm_update_replacements(&updates)?; + } + + super::super::resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + + if !missing_deps.is_empty() { + let dep_plan = planner::build_dependency_install_plan( + config, + options.rootfs, + &missing_deps, + planner::PlannerOptions { + assume_yes: options.assume_yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root: None, + include_test_deps: options.install_test_deps, + lib32_only_requested_specs: false, + }, + )?; + crate::commands::print_plan_summary(&dep_plan); + + if options.dry_run { + ui::info("Dry run enabled, stopping before dependency installation/update."); + return Ok(()); + } + + crate::commands::execute_install_plan_with_child_commands( + &dep_plan, + options.rootfs, + config, + crate::commands::InstallPlanExecutionOptions { + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + dry_run: false, + confirm_installation: false, + lib32_only_requested_specs: false, + install_test_deps: options.install_test_deps, + }, + )?; + } else if options.dry_run { + ui::info("Dry run enabled, stopping before update."); + return Ok(()); + } + + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + for (idx, candidate) in updates.iter().enumerate() { + ui::info(format!( + "[{}/{}] updating {} v{}-{} -> {} v{}-{}", + idx + 1, + updates.len(), + candidate.installed_package, + candidate.installed_version, + candidate.installed_revision, + candidate.candidate_package, + candidate.candidate_version, + candidate.candidate_revision + )); + let request = candidate_request_path(candidate, config, options.rootfs)?; + run_update_install_command(&exe, &request, options)?; + } + + if options.clean { + crate::commands::clean_build_workspace(config)?; + } + + Ok(()) +} diff --git a/src/commands/update/check.rs b/src/commands/update/check.rs new file mode 100644 index 0000000..7c319dc --- /dev/null +++ b/src/commands/update/check.rs @@ -0,0 +1,222 @@ +use super::*; +use crate::commands::update::versions::{ + CheckStatus, archive_listing_probe, best_newer_version, candidate_versions_from_refs, + compare_versions_for_updates, extract_version_patterns, list_archive_versions, + remote_git_repository_from_source_url, +}; + +fn source_check_status( + spec: &package::PackageSpec, + source: &package::Source, +) -> Result { + let patterns = extract_version_patterns(&source.url); + if patterns.is_empty() { + anyhow::bail!("source URL does not contain $version"); + } + + let expanded_url = spec.expand_vars(&source.url); + let mut reasons = Vec::new(); + + let (candidates, source_label) = + if let Some(repo_url) = remote_git_repository_from_source_url(&expanded_url) { + match super::versions::list_remote_refs(&repo_url) + .map(|refs| candidate_versions_from_refs(&refs, &patterns)) + { + Ok(candidates) if !candidates.is_empty() => { + (candidates, format!("git tags {}", repo_url)) + } + Ok(_) => { + reasons.push(format!("no matching git tags found in {}", repo_url)); + if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { + let candidates = list_archive_versions(&probe)?; + (candidates, format!("archive index {}", probe.listing_url)) + } else { + anyhow::bail!("{}", reasons.remove(0)); + } + } + Err(err) => { + reasons.push(err.to_string()); + if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { + match list_archive_versions(&probe) { + Ok(candidates) => { + (candidates, format!("archive index {}", probe.listing_url)) + } + Err(archive_err) => { + reasons.push(archive_err.to_string()); + anyhow::bail!("{}", reasons.join("; ")); + } + } + } else { + anyhow::bail!("{}", reasons.remove(0)); + } + } + } + } else if let Some(probe) = archive_listing_probe(&source.url, &expanded_url) { + let candidates = list_archive_versions(&probe)?; + (candidates, format!("archive index {}", probe.listing_url)) + } else { + anyhow::bail!( + "could not derive a git remote or archive index from {}", + expanded_url + ); + }; + + if let Some(latest) = + best_newer_version(&spec.package.version, candidates.iter().map(String::as_str)) + { + Ok(CheckStatus::UpdateAvailable { + latest, + source: source_label, + }) + } else { + Ok(CheckStatus::UpToDate { + source: source_label, + }) + } +} + +fn check_package_spec(spec_path: &Path) -> CheckStatus { + let spec = match package::PackageSpec::from_file(spec_path) { + Ok(spec) => spec, + Err(err) => { + return CheckStatus::Unknown { + reason: err.to_string(), + }; + } + }; + + let mut best_update: Option<(String, String)> = None; + let mut last_up_to_date_source: Option = None; + let mut reasons = Vec::new(); + + for source in spec.sources() { + if let Err(err) = crate::interrupts::check() { + return CheckStatus::Unknown { + reason: err.to_string(), + }; + } + match source_check_status(&spec, source) { + Ok(CheckStatus::UpdateAvailable { latest, source }) => { + let replace = match &best_update { + Some((current_best, _)) => { + compare_versions_for_updates(&latest, current_best) == Ordering::Greater + } + None => true, + }; + if replace { + best_update = Some((latest, source)); + } + } + Ok(CheckStatus::UpToDate { source }) => { + if last_up_to_date_source.is_none() { + last_up_to_date_source = Some(source); + } + } + Ok(CheckStatus::Unknown { reason }) => reasons.push(reason), + Err(err) => reasons.push(err.to_string()), + } + } + + if let Some((latest, source)) = best_update { + return CheckStatus::UpdateAvailable { latest, source }; + } + if let Some(source) = last_up_to_date_source { + return CheckStatus::UpToDate { source }; + } + + CheckStatus::Unknown { + reason: reasons + .into_iter() + .next() + .unwrap_or_else(|| "no versioned sources found".to_string()), + } +} + +pub(crate) fn run_check_command(dir: &Path) -> Result<()> { + let scan_root = dir + .canonicalize() + .with_context(|| format!("Failed to resolve check root {}", dir.display()))?; + let specs = crate::commands::repo::groups::scan_package_specs(&scan_root)?; + if specs.is_empty() { + ui::info(format!( + "No depot package specs found under {}", + scan_root.display() + )); + return Ok(()); + } + + let verbose = std::env::var_os("DEPOT_CHECK_VERBOSE").is_some(); + let mut updates = 0usize; + let mut up_to_date = 0usize; + let mut skipped = Vec::new(); + + for spec_path in specs { + crate::interrupts::check()?; + let spec = match package::PackageSpec::from_file(&spec_path) { + Ok(spec) => spec, + Err(err) => { + skipped.push(format!( + "{} could not be loaded: {}", + spec_path.display(), + err + )); + continue; + } + }; + match check_package_spec(&spec_path) { + CheckStatus::UpdateAvailable { latest, source } => { + updates += 1; + ui::warn(format!( + "{} {} -> {} [{}] ({})", + spec.package.name, + spec.package.version, + latest, + source, + spec_path.display() + )); + } + CheckStatus::UpToDate { source } => { + up_to_date += 1; + if verbose { + ui::info(format!( + "{} {} is up to date [{}] ({})", + spec.package.name, + spec.package.version, + source, + spec_path.display() + )); + } + } + CheckStatus::Unknown { reason } => { + skipped.push(format!( + "{} {} could not be checked: {} ({})", + spec.package.name, + spec.package.version, + reason, + spec_path.display() + )); + } + } + } + + if verbose { + for entry in &skipped { + ui::warn(entry); + } + } else if !skipped.is_empty() { + ui::warn(format!( + "Skipped {} package(s) that could not be checked; set DEPOT_CHECK_VERBOSE=1 for per-package reasons.", + skipped.len() + )); + } + + ui::info(format!( + "Check summary: {} package(s), {} update(s), {} up to date, {} skipped", + updates + up_to_date + skipped.len(), + updates, + up_to_date, + skipped.len() + )); + + Ok(()) +} diff --git a/src/commands/update/versions.rs b/src/commands/update/versions.rs new file mode 100644 index 0000000..9d9c80a --- /dev/null +++ b/src/commands/update/versions.rs @@ -0,0 +1,553 @@ +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct VersionPattern { + pub(crate) prefix: String, + pub(crate) suffix: String, +} + +#[derive(Debug, Clone)] +pub(crate) enum CheckStatus { + UpdateAvailable { latest: String, source: String }, + UpToDate { source: String }, + Unknown { reason: String }, +} + +#[derive(Debug, Clone)] +pub(crate) struct ArchiveListingProbe { + pub(crate) listing_url: String, + pub(crate) patterns: Vec, +} + +fn strip_known_archive_suffixes(input: &str) -> &str { + for suffix in [ + ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".zip", ".tar", + ".git", + ] { + if let Some(stripped) = input.strip_suffix(suffix) { + return stripped; + } + } + input +} + +fn version_pattern_from_template( + template: &str, + strip_archive_suffix: bool, +) -> Option { + let (prefix, suffix) = template.split_once("$version")?; + let suffix = if strip_archive_suffix { + strip_known_archive_suffixes(suffix) + } else { + suffix + }; + Some(VersionPattern { + prefix: prefix.to_string(), + suffix: suffix.to_string(), + }) +} + +pub(crate) fn extract_version_patterns(raw: &str) -> Vec { + let mut patterns = HashSet::new(); + let mut start = 0usize; + + while let Some(rel_idx) = raw[start..].find("$version") { + let idx = start + rel_idx; + let prefix_start = raw[..idx] + .rfind(['/', '#', '?', '&', '=']) + .map(|pos| pos + 1) + .unwrap_or(0); + let suffix_end = raw[idx + "$version".len()..] + .find(['/', '#', '?', '&', '=']) + .map(|pos| idx + "$version".len() + pos) + .unwrap_or(raw.len()); + if let Some(pattern) = version_pattern_from_template(&raw[prefix_start..suffix_end], true) { + patterns.insert(pattern); + } + start = idx + "$version".len(); + } + + let mut out: Vec<_> = patterns.into_iter().collect(); + out.sort_by(|a, b| { + a.prefix + .cmp(&b.prefix) + .then_with(|| a.suffix.cmp(&b.suffix)) + }); + out +} + +fn is_version_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '+' | '-') +} + +fn looks_like_version(candidate: &str) -> bool { + !candidate.is_empty() + && candidate.len() <= 64 + && candidate.chars().all(is_version_char) + && candidate.chars().any(|ch| ch.is_ascii_digit()) +} + +fn match_version_pattern<'a>(value: &'a str, pattern: &VersionPattern) -> Option<&'a str> { + if !value.starts_with(&pattern.prefix) || !value.ends_with(&pattern.suffix) { + return None; + } + + let start = pattern.prefix.len(); + let end = value.len().saturating_sub(pattern.suffix.len()); + if end <= start { + return None; + } + + let candidate = &value[start..end]; + looks_like_version(candidate).then_some(candidate) +} + +fn compare_version_fallback(left: &str, right: &str) -> Ordering { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut li = 0usize; + let mut ri = 0usize; + + while li < left.len() && ri < right.len() { + let lch = left[li] as char; + let rch = right[ri] as char; + let l_digit = lch.is_ascii_digit(); + let r_digit = rch.is_ascii_digit(); + + if l_digit && r_digit { + let l_start = li; + let r_start = ri; + while li < left.len() && (left[li] as char).is_ascii_digit() { + li += 1; + } + while ri < right.len() && (right[ri] as char).is_ascii_digit() { + ri += 1; + } + + let l_raw = std::str::from_utf8(&left[l_start..li]).unwrap_or_default(); + let r_raw = std::str::from_utf8(&right[r_start..ri]).unwrap_or_default(); + let l_trimmed = l_raw.trim_start_matches('0'); + let r_trimmed = r_raw.trim_start_matches('0'); + + let l_cmp = if l_trimmed.is_empty() { "0" } else { l_trimmed }; + let r_cmp = if r_trimmed.is_empty() { "0" } else { r_trimmed }; + match l_cmp + .len() + .cmp(&r_cmp.len()) + .then_with(|| l_cmp.cmp(r_cmp)) + .then_with(|| l_raw.len().cmp(&r_raw.len())) + .then_with(|| l_raw.cmp(r_raw)) + { + Ordering::Equal => {} + non_eq => return non_eq, + } + continue; + } + + match lch + .to_ascii_lowercase() + .cmp(&rch.to_ascii_lowercase()) + .then_with(|| lch.cmp(&rch)) + { + Ordering::Equal => { + li += 1; + ri += 1; + } + non_eq => return non_eq, + } + } + + left.len().cmp(&right.len()) +} + +fn canonical_update_version(raw: &str) -> &str { + raw.strip_prefix('v') + .filter(|rest| rest.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + .unwrap_or(raw) +} + +fn collapse_date_like_components(value: &str) -> String { + let parts: Vec<_> = value.split('.').collect(); + if parts.len() < 3 { + return value.to_string(); + } + if !parts[0].chars().all(|ch| ch.is_ascii_digit()) + || !parts[1].chars().all(|ch| ch.is_ascii_digit()) + || !parts[2].chars().all(|ch| ch.is_ascii_digit()) + || parts[0].len() != 4 + || parts[1].len() != 2 + || parts[2].len() != 2 + { + return value.to_string(); + } + + let mut collapsed = vec![format!("{}{}{}", parts[0], parts[1], parts[2])]; + collapsed.extend(parts.into_iter().skip(3).map(str::to_string)); + collapsed.join(".") +} + +fn normalize_comparable_version(raw: &str) -> String { + let mut value = canonical_update_version(raw).trim().to_ascii_lowercase(); + if let Some(first_digit) = value.find(|ch: char| ch.is_ascii_digit()) + && first_digit > 0 + && value[..first_digit].chars().all(|ch| !ch.is_ascii_digit()) + { + value = value[first_digit..].to_string(); + } + + let mut normalized = String::with_capacity(value.len()); + let mut last_was_dot = false; + for ch in value.chars() { + let mapped = match ch { + '_' | '-' | '+' => '.', + ch if ch.is_ascii_alphanumeric() || ch == '.' => ch, + _ => continue, + }; + if mapped == '.' { + if normalized.is_empty() || last_was_dot { + continue; + } + last_was_dot = true; + } else { + last_was_dot = false; + } + normalized.push(mapped); + } + while normalized.ends_with('.') { + normalized.pop(); + } + + collapse_date_like_components(&normalized) +} + +fn numeric_component_count(value: &str) -> usize { + let mut count = 0usize; + let mut in_digits = false; + for ch in value.chars() { + if ch.is_ascii_digit() { + if !in_digits { + count += 1; + in_digits = true; + } + } else { + in_digits = false; + } + } + count +} + +fn first_numeric_component_len(value: &str) -> Option { + let start = value.find(|ch: char| ch.is_ascii_digit())?; + Some( + value[start..] + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .count(), + ) +} + +fn is_prerelease_version(value: &str) -> bool { + let normalized = normalize_comparable_version(value); + [ + "alpha", "beta", "rc", "pre", "preview", "snapshot", "nightly", "dev", + ] + .into_iter() + .any(|marker| normalized.contains(marker)) +} + +fn normalize_candidate_version(current: &str, candidate: &str) -> Option { + let normalized = normalize_comparable_version(candidate); + if !looks_like_version(&normalized) { + return None; + } + if !is_prerelease_version(current) && is_prerelease_version(candidate) { + return None; + } + + let current_normalized = normalize_comparable_version(current); + if numeric_component_count(¤t_normalized) >= 2 + && numeric_component_count(&normalized) == 1 + && first_numeric_component_len(¤t_normalized).is_some_and(|len| len <= 4) + { + return None; + } + + Some(normalized) +} + +pub(crate) fn compare_versions_for_updates(left: &str, right: &str) -> Ordering { + let left = normalize_comparable_version(left); + let right = normalize_comparable_version(right); + if let (Ok(left), Ok(right)) = ( + semver::Version::parse(&left), + semver::Version::parse(&right), + ) { + match left.cmp(&right) { + Ordering::Equal => {} + non_eq => return non_eq, + } + } + + if left.len() == 8 + && right.len() == 8 + && left.chars().all(|ch| ch.is_ascii_digit()) + && right.chars().all(|ch| ch.is_ascii_digit()) + { + return left.cmp(&right); + } + + compare_version_fallback(&left, &right) +} + +pub(crate) fn best_newer_version<'a>( + current: &str, + candidates: impl IntoIterator, +) -> Option { + let mut best: Option = None; + for candidate in candidates { + let Some(candidate) = normalize_candidate_version(current, candidate) else { + continue; + }; + if compare_versions_for_updates(&candidate, current) != Ordering::Greater { + continue; + } + if let Some(existing) = best.as_deref() + && compare_versions_for_updates(&candidate, existing) != Ordering::Greater + { + continue; + } + best = Some(candidate); + } + best +} + +pub(crate) fn remote_git_repository_from_source_url(expanded_url: &str) -> Option { + if let Some((base, _)) = source_git_url_parts(expanded_url) { + return Some(base); + } + + let parsed = Url::parse(expanded_url).ok()?; + let host = parsed.host_str()?; + let segments: Vec<_> = parsed.path_segments()?.collect(); + if segments.len() < 3 { + return None; + } + + let keyword_idx = segments + .iter() + .position(|segment| matches!(*segment, "releases" | "archive"))?; + let repo_segments = if keyword_idx > 0 && segments.get(keyword_idx - 1) == Some(&"-") { + &segments[..keyword_idx - 1] + } else { + &segments[..keyword_idx] + }; + if repo_segments.len() < 2 { + return None; + } + + Some(format!( + "{}://{}/{}.git", + parsed.scheme(), + host, + repo_segments + .iter() + .enumerate() + .map(|(idx, segment)| { + if idx + 1 == repo_segments.len() { + segment.strip_suffix(".git").unwrap_or(segment) + } else { + segment + } + }) + .collect::>() + .join("/") + )) +} + +fn source_git_url_parts(url: &str) -> Option<(String, String)> { + if let Some((base, rev)) = url.split_once('#') { + let lower = base.to_ascii_lowercase(); + let is_archive = lower.ends_with(".tar.gz") + || lower.ends_with(".tgz") + || lower.ends_with(".tar.xz") + || lower.ends_with(".txz") + || lower.ends_with(".tar.bz2") + || lower.ends_with(".tbz2") + || lower.ends_with(".zip") + || lower.ends_with(".tar"); + if is_archive { + return None; + } + let resolved_rev = if rev.trim().is_empty() { "HEAD" } else { rev }; + return Some((base.to_string(), resolved_rev.to_string())); + } + + url.to_ascii_lowercase() + .ends_with(".git") + .then(|| (url.to_string(), "HEAD".to_string())) +} + +pub(super) fn list_remote_refs(url: &str) -> Result> { + crate::interrupts::check()?; + let mut remote = git2::Remote::create_detached(url) + .with_context(|| format!("Failed to create detached git remote for {}", url))?; + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.sideband_progress(|_| !crate::interrupts::was_interrupted()); + callbacks.transfer_progress(|_| !crate::interrupts::was_interrupted()); + + let refs = { + let connection = match remote.connect_auth(Direction::Fetch, Some(callbacks), None) { + Ok(connection) => connection, + Err(_err) if crate::interrupts::was_interrupted() => { + anyhow::bail!("Interrupted by Ctrl-C while checking {}", url); + } + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to connect to git remote {}", url)); + } + }; + crate::interrupts::check()?; + let refs = connection + .list() + .with_context(|| format!("Failed to list refs for git remote {}", url))? + .iter() + .map(|head| head.name().trim_end_matches("^{}").to_string()) + .collect(); + drop(connection); + refs + }; + remote.disconnect().ok(); + Ok(refs) +} + +pub(crate) fn candidate_versions_from_refs( + refs: &[String], + patterns: &[VersionPattern], +) -> Vec { + let mut versions = HashSet::new(); + + for name in refs { + let Some(short) = name.strip_prefix("refs/tags/") else { + continue; + }; + + for pattern in patterns { + if let Some(candidate) = match_version_pattern(short, pattern) { + versions.insert(candidate.to_string()); + } + } + } + + let mut out: Vec<_> = versions.into_iter().collect(); + out.sort_by(|a, b| compare_versions_for_updates(a, b)); + out +} + +pub(crate) fn archive_listing_probe( + raw_url: &str, + expanded_url: &str, +) -> Option { + let raw = Url::parse(raw_url).ok()?; + let expanded = Url::parse(expanded_url).ok()?; + if !matches!(expanded.scheme(), "http" | "https") { + return None; + } + + let raw_segments: Vec<_> = raw.path_segments()?.collect(); + let expanded_segments: Vec<_> = expanded.path_segments()?.collect(); + if raw_segments.len() != expanded_segments.len() { + return None; + } + + let first_version_idx = raw_segments + .iter() + .position(|segment| segment.contains("$version"))?; + let pattern = version_pattern_from_template(raw_segments[first_version_idx], false)?; + + let mut listing_url = expanded.clone(); + let listing_path = if first_version_idx == 0 { + "/".to_string() + } else { + format!("/{}/", expanded_segments[..first_version_idx].join("/")) + }; + listing_url.set_path(&listing_path); + listing_url.set_query(None); + listing_url.set_fragment(None); + + Some(ArchiveListingProbe { + listing_url: listing_url.to_string(), + patterns: vec![pattern], + }) +} + +fn archive_listing_tokens(body: &str) -> HashSet { + let mut tokens = HashSet::new(); + for token in body.split(|ch: char| { + ch.is_ascii_whitespace() || matches!(ch, '"' | '\'' | '<' | '>' | '(' | ')' | '[' | ']') + }) { + let token = token + .split_once('?') + .map(|(value, _)| value) + .unwrap_or(token) + .split_once('#') + .map(|(value, _)| value) + .unwrap_or(token) + .trim_matches(|ch: char| matches!(ch, ',' | ';' | '=')) + .trim_end_matches('/'); + if token.is_empty() { + continue; + } + let basename = token.rsplit('/').next().unwrap_or(token); + if !basename.is_empty() { + tokens.insert(basename.to_string()); + } + } + tokens +} + +pub(crate) fn candidate_versions_from_listing( + body: &str, + patterns: &[VersionPattern], +) -> Vec { + let mut versions = HashSet::new(); + for token in archive_listing_tokens(body) { + for pattern in patterns { + if let Some(candidate) = match_version_pattern(&token, pattern) { + versions.insert(candidate.to_string()); + } + } + } + + let mut out: Vec<_> = versions.into_iter().collect(); + out.sort_by(|a, b| compare_versions_for_updates(a, b)); + out +} + +pub(crate) fn list_archive_versions(probe: &ArchiveListingProbe) -> Result> { + crate::interrupts::check()?; + let client = source::build_blocking_client( + &format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), + Some(Duration::from_secs(20)), + )?; + let response = client + .get(&probe.listing_url) + .send() + .with_context(|| format!("Failed to fetch archive index {}", probe.listing_url))?; + if !response.status().is_success() { + anyhow::bail!( + "archive index {} returned {}", + probe.listing_url, + response.status() + ); + } + let body = response + .text() + .with_context(|| format!("Failed to read archive index {}", probe.listing_url))?; + crate::interrupts::check()?; + + let candidates = candidate_versions_from_listing(&body, &probe.patterns); + if candidates.is_empty() { + anyhow::bail!("no matching archive entries found in {}", probe.listing_url); + } + Ok(candidates) +} diff --git a/src/config.rs b/src/config.rs index fc7d6a0..6eae5ed 100755 --- a/src/config.rs +++ b/src/config.rs @@ -159,6 +159,34 @@ fn repo_settings_is_default(settings: &RepoSettings) -> bool { !settings.prefer_binary } +fn normalize_append_key(raw: &str) -> String { + raw.split('.') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| { + let stripped = if (part.starts_with('"') && part.ends_with('"')) + || (part.starts_with('\'') && part.ends_with('\'')) + { + &part[1..part.len() - 1] + } else { + part + }; + stripped.trim().to_ascii_lowercase() + }) + .collect::>() + .join(".") +} + +fn scope_append_key(scope: &str, key: &str) -> String { + let key = normalize_append_key(key); + let scope = normalize_append_key(scope); + if key == scope || key.starts_with(&format!("{scope}.")) { + key + } else { + format!("{scope}.{key}") + } +} + /// Return the canonical `repos.toml` path for the given rootfs. pub fn repos_toml_path(rootfs: &Path) -> PathBuf { resolve_rootfs_base(rootfs).join("etc/depot.d/repos.toml") @@ -345,7 +373,7 @@ impl Config { let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.build_overrides, &val); for (k, v) in appends { - self.appends.insert(format!("build.{}", k), v); + self.appends.insert(scope_append_key("build", &k), v); } } @@ -359,7 +387,7 @@ impl Config { let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.build_overrides, &val); for (k, v) in appends { - self.appends.insert(format!("build.{}", k), v); + self.appends.insert(scope_append_key("build", &k), v); } } @@ -376,7 +404,7 @@ impl Config { let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.package_overrides, &val); for (k, v) in appends { - self.appends.insert(format!("package.{}", k), v); + self.appends.insert(scope_append_key("package", &k), v); } } @@ -390,7 +418,7 @@ impl Config { let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.package_overrides, &val); for (k, v) in appends { - self.appends.insert(format!("package.{}", k), v); + self.appends.insert(scope_append_key("package", &k), v); } } @@ -482,9 +510,25 @@ impl Config { ) -> Result<(toml::Value, HashMap>)> { let mut base_text = String::new(); let mut appends = HashMap::new(); + let mut current_table: Option = None; + let mut in_array_table = false; for line in input.lines() { let trimmed = line.trim(); + if trimmed.starts_with("[[") && trimmed.ends_with("]]") && trimmed.len() >= 4 { + current_table = Some(normalize_append_key(&trimmed[2..trimmed.len() - 2])); + in_array_table = true; + base_text.push_str(line); + base_text.push('\n'); + continue; + } + if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + current_table = Some(normalize_append_key(&trimmed[1..trimmed.len() - 1])); + in_array_table = false; + base_text.push_str(line); + base_text.push('\n'); + continue; + } if trimmed.is_empty() || trimmed.starts_with('#') { base_text.push_str(line); base_text.push('\n'); @@ -492,7 +536,13 @@ impl Config { } if let Some(plus_idx) = trimmed.find("+=") { - let key = trimmed[..plus_idx].trim().to_string(); + if in_array_table { + anyhow::bail!( + "'+=' is not supported inside array-of-table sections ({})", + current_table.as_deref().unwrap_or("") + ); + } + let key = normalize_append_key(trimmed[..plus_idx].trim()); let val_str = trimmed[plus_idx + 2..].trim(); let val: toml::Value = toml::from_str::(&format!("v = {}", val_str)) .context("Failed to parse append value")? @@ -500,7 +550,16 @@ impl Config { .cloned() .unwrap(); - appends.entry(key).or_insert_with(Vec::new).push(val); + let full_key = if key.contains('.') { + key + } else if let Some(table) = current_table.as_deref() { + format!("{table}.{key}") + } else { + key + }; + + appends.entry(full_key).or_insert_with(Vec::new).push(val); + base_text.push('\n'); } else { base_text.push_str(line); base_text.push('\n'); @@ -574,16 +633,16 @@ ldflags += "-L/usr/local/lib" ); // Appends should be captured - assert!(appends.contains_key("cflags")); - assert_eq!(appends.get("cflags").unwrap().len(), 1); + assert!(appends.contains_key("flags.cflags")); + assert_eq!(appends.get("flags.cflags").unwrap().len(), 1); assert_eq!( - appends.get("cflags").unwrap()[0].as_array().unwrap()[0].as_str(), + appends.get("flags.cflags").unwrap()[0].as_array().unwrap()[0].as_str(), Some("-DDEBUG") ); - assert!(appends.contains_key("ldflags")); + assert!(appends.contains_key("flags.ldflags")); assert_eq!( - appends.get("ldflags").unwrap()[0].as_str(), + appends.get("flags.ldflags").unwrap()[0].as_str(), Some("-L/usr/local/lib") ); } @@ -615,9 +674,9 @@ cflags += ["-g"] .map(|a| a.len()), Some(1) ); - assert!(config.appends.contains_key("build.cflags")); + assert!(config.appends.contains_key("build.flags.cflags")); assert_eq!( - config.appends.get("build.cflags").unwrap()[0] + config.appends.get("build.flags.cflags").unwrap()[0] .as_array() .unwrap()[0] .as_str(), diff --git a/src/db/repo.rs b/src/db/repo.rs index 6561e71..9dc306a 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -54,8 +54,8 @@ impl HashingReader { fn finalize_hex(self) -> (String, String) { ( - format!("{:x}", self.sha256.finalize()), - format!("{:x}", self.sha512.finalize()), + crate::hex::encode_lower(self.sha256.finalize()), + crate::hex::encode_lower(self.sha512.finalize()), ) } } @@ -2151,7 +2151,7 @@ fn verify_hex_digest(path: &Path, algorithm: &str, expected_hex: &str) -> Result } h.update(&buf[..n]); } - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) } "sha512" => { use sha2::{Digest, Sha512}; @@ -2163,7 +2163,7 @@ fn verify_hex_digest(path: &Path, algorithm: &str, expected_hex: &str) -> Result } h.update(&buf[..n]); } - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) } _ => anyhow::bail!("Unsupported checksum algorithm: {}", algorithm), }; @@ -2988,12 +2988,12 @@ revision = 1 let sha256 = { let mut h = Sha256::new(); h.update(b"payload"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha512 = { let mut h = Sha512::new(); h.update(b"payload"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let rec = BinaryRepoPackageRecord { @@ -3028,12 +3028,12 @@ revision = 1 let sha256 = { let mut h = Sha256::new(); h.update(payload); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha512 = { let mut h = Sha512::new(); h.update(payload); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; BinaryRepoPackageRecord { diff --git a/src/hex.rs b/src/hex.rs new file mode 100644 index 0000000..a276c1e --- /dev/null +++ b/src/hex.rs @@ -0,0 +1,9 @@ +pub(crate) fn encode_lower(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} diff --git a/src/install/hooks.rs b/src/install/hooks.rs index 79c796c..4caf4ee 100644 --- a/src/install/hooks.rs +++ b/src/install/hooks.rs @@ -24,6 +24,7 @@ use crate::fakeroot; use anyhow::{Context, Result, bail}; +use std::collections::HashSet; use std::fs; use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; @@ -225,7 +226,8 @@ pub fn run_transaction_hooks(rootfs: &Path, ctx: &HookExecutionContext<'_>) -> R /// Load and execute hooks for a batch of transaction contexts in stable order. /// /// Hooks are discovered and parsed once, then matched against each context in -/// input order. Matching hooks run in hook-file order for each context. +/// input order. Each matching hook runs once per hook file and operation, with +/// affected paths aggregated across all matching contexts for that batch. /// /// Returns the number of hook commands executed. pub fn run_transaction_hooks_batch( @@ -251,34 +253,80 @@ pub fn run_transaction_hooks_batch( #[derive(Clone, Copy)] struct ScheduledHookRun { hook_idx: usize, - ctx_idx: usize, + operation: HookOperation, + first_ctx_idx: usize, + } + + fn push_unique_normalized_paths( + out: &mut Vec, + seen: &mut HashSet, + paths: &[String], + ) { + for path in normalize_affected_paths(paths) { + if seen.insert(path.clone()) { + out.push(path); + } + } } let mut scheduled = Vec::new(); - for (ctx_idx, ctx_owned) in contexts.iter().enumerate() { - let normalized_paths = normalize_affected_paths(&ctx_owned.affected_paths); - let ctx = HookExecutionContext { - phase, - operation: ctx_owned.operation, - package: &ctx_owned.package, - affected_paths: &ctx_owned.affected_paths, - }; - for (hook_idx, hook) in hooks.iter().enumerate() { - if hook.matches(&ctx, &normalized_paths) { - scheduled.push(ScheduledHookRun { hook_idx, ctx_idx }); + for (hook_idx, hook) in hooks.iter().enumerate() { + let mut matched_operations = Vec::new(); + for (ctx_idx, ctx_owned) in contexts.iter().enumerate() { + let normalized_paths = normalize_affected_paths(&ctx_owned.affected_paths); + let ctx = HookExecutionContext { + phase, + operation: ctx_owned.operation, + package: &ctx_owned.package, + affected_paths: &ctx_owned.affected_paths, + }; + if hook.matches(&ctx, &normalized_paths) + && !matched_operations.contains(&ctx_owned.operation) + { + matched_operations.push(ctx_owned.operation); + scheduled.push(ScheduledHookRun { + hook_idx, + operation: ctx_owned.operation, + first_ctx_idx: ctx_idx, + }); } } } let total = scheduled.len(); for (run_idx, run) in scheduled.into_iter().enumerate() { - let ctx_owned = &contexts[run.ctx_idx]; - let normalized_paths = normalize_affected_paths(&ctx_owned.affected_paths); + let primary_ctx = &contexts[run.first_ctx_idx]; + let mut normalized_paths = Vec::new(); + let mut seen_paths = HashSet::new(); + push_unique_normalized_paths( + &mut normalized_paths, + &mut seen_paths, + &primary_ctx.affected_paths, + ); + for ctx_owned in contexts.iter().skip(run.first_ctx_idx + 1) { + if ctx_owned.operation != run.operation { + continue; + } + let ctx = HookExecutionContext { + phase, + operation: ctx_owned.operation, + package: &ctx_owned.package, + affected_paths: &ctx_owned.affected_paths, + }; + let ctx_paths = normalize_affected_paths(&ctx_owned.affected_paths); + if hooks[run.hook_idx].matches(&ctx, &ctx_paths) { + push_unique_normalized_paths( + &mut normalized_paths, + &mut seen_paths, + &ctx_owned.affected_paths, + ); + } + } let ctx = HookExecutionContext { phase, - operation: ctx_owned.operation, - package: &ctx_owned.package, - affected_paths: &ctx_owned.affected_paths, + operation: primary_ctx.operation, + package: &primary_ctx.package, + affected_paths: &primary_ctx.affected_paths, }; run_hook_command( rootfs, @@ -758,7 +806,8 @@ phase = "post" operation = ["install"] [exec] -command = "printf '%s\n' \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/batch.out\"" +command = "cat >> \"$DEPOT_ROOTFS/batch.out\"" +needs_paths = true "#, ); @@ -766,18 +815,61 @@ command = "printf '%s\n' \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/batch.out\"" HookExecutionContextOwned { operation: HookOperation::Install, package: "foo".to_string(), - affected_paths: Vec::new(), + affected_paths: vec!["usr/bin/foo".to_string()], }, HookExecutionContextOwned { operation: HookOperation::Install, package: "bar".to_string(), - affected_paths: Vec::new(), + affected_paths: vec!["usr/share/doc/bar".to_string()], + }, + ]; + + let ran = run_transaction_hooks_batch(tmp.path(), HookPhase::Post, &contexts).unwrap(); + assert_eq!(ran, 1); + let out = std::fs::read_to_string(tmp.path().join("batch.out")).unwrap(); + assert_eq!(out, "usr/bin/foo\nusr/share/doc/bar\n"); + } + + #[test] + fn run_transaction_hooks_batch_runs_once_per_operation() { + let tmp = tempfile::tempdir().unwrap(); + write_hook( + tmp.path(), + "batch.toml", + r#" +[hook] +name = "batch" + +[when] +phase = "post" +operation = ["install", "update"] + +[exec] +command = "printf '%s:%s\n' \"$DEPOT_ACTION\" \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/batch.out\"" +"#, + ); + + let contexts = vec![ + HookExecutionContextOwned { + operation: HookOperation::Install, + package: "foo".to_string(), + affected_paths: vec!["usr/bin/foo".to_string()], + }, + HookExecutionContextOwned { + operation: HookOperation::Install, + package: "bar".to_string(), + affected_paths: vec!["usr/share/doc/bar".to_string()], + }, + HookExecutionContextOwned { + operation: HookOperation::Update, + package: "baz".to_string(), + affected_paths: vec!["usr/bin/baz".to_string()], }, ]; let ran = run_transaction_hooks_batch(tmp.path(), HookPhase::Post, &contexts).unwrap(); assert_eq!(ran, 2); let out = std::fs::read_to_string(tmp.path().join("batch.out")).unwrap(); - assert_eq!(out, "foo\nbar\n"); + assert_eq!(out, "install:foo\nupdate:baz\n"); } } diff --git a/src/main.rs b/src/main.rs index a5d8a83..835fac6 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ //! Depot - Not Your Average Package Manager //! A source-based package manager for Linux +mod build_options; mod builder; mod cli; mod cli_assets; @@ -10,6 +11,7 @@ mod cross; mod db; mod deps; mod fakeroot; +mod hex; mod index; mod install; mod interrupts; diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 6e6d116..588f223 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -44,7 +44,7 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { } hasher.update(&buf[..n]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex::encode_lower(hasher.finalize())) } // Try to parse as URL first; if parsing fails, treat as local path. @@ -2261,7 +2261,7 @@ mod tests { let expected = { let mut h = TestSha256::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; // plain path diff --git a/src/package/spec.rs b/src/package/spec.rs index efedcb5..5551ca2 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -100,6 +100,7 @@ impl PackageSpec { appends: &std::collections::HashMap>, ) -> Result<()> { for (key, values) in appends { + let key = normalize_append_key(key); if let Some(subkey) = key.strip_prefix("build.flags.") { self.apply_append(subkey, values); continue; @@ -109,7 +110,7 @@ impl PackageSpec { continue; } if !key.contains('.') { - self.apply_append(key, values); + self.apply_append(&key, values); continue; } anyhow::bail!("Unsupported '+=' key in package spec: {}", key); @@ -323,11 +324,8 @@ impl PackageSpec { .package_alternatives .get(pkg_name) .cloned() - .unwrap_or_else(|| { - self.alternatives - .lib32_alternatives() - .unwrap_or_else(|| self.alternatives.primary_alternatives()) - }); + .or_else(|| self.alternatives.lib32_alternatives()) + .unwrap_or_default(); } if self.docs_parent_output_name(pkg_name).is_some() { @@ -351,8 +349,11 @@ impl PackageSpec { // Apply appends from /etc/depot.d/build.toml (e.g. build.flags.cflags += ["-O3"]) for (key, values) in &config.appends { + let key = normalize_append_key(key); if let Some(subkey) = key.strip_prefix("build.flags.") { self.apply_append(subkey, values); + } else if let Some(subkey) = key.strip_prefix("build.") { + self.apply_append(subkey, values); } } } @@ -981,7 +982,8 @@ impl PackageSpec { } fn apply_append(&mut self, key: &str, values: &[toml::Value]) { - match key { + let key = normalize_append_key(key); + match key.as_str() { "cflags" => { for v in values { if let Some(arr) = v.as_array() { @@ -1675,14 +1677,14 @@ fn preprocess_spec_toml_appends( let trimmed = line.trim(); if trimmed.starts_with("[[") && trimmed.ends_with("]]") && trimmed.len() >= 4 { - current_table = Some(trimmed[2..trimmed.len() - 2].trim().to_string()); + current_table = Some(normalize_append_key(trimmed[2..trimmed.len() - 2].trim())); in_array_table = true; base_text.push_str(line); base_text.push('\n'); continue; } if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { - current_table = Some(trimmed[1..trimmed.len() - 1].trim().to_string()); + current_table = Some(normalize_append_key(trimmed[1..trimmed.len() - 1].trim())); in_array_table = false; base_text.push_str(line); base_text.push('\n'); @@ -1702,7 +1704,7 @@ fn preprocess_spec_toml_appends( current_table.as_deref().unwrap_or("") ); } - let key = trimmed[..plus_idx].trim(); + let key = normalize_append_key(trimmed[..plus_idx].trim()); let val_str = trimmed[plus_idx + 2..].trim(); let val: toml::Value = toml::from_str::(&format!("v = {}", val_str)) .context("Failed to parse append value")? @@ -1711,11 +1713,11 @@ fn preprocess_spec_toml_appends( .unwrap(); let full_key = if key.contains('.') { - key.to_string() + key } else if let Some(table) = current_table.as_deref() { format!("{}.{}", table, key) } else { - key.to_string() + key }; appends.entry(full_key).or_insert_with(Vec::new).push(val); @@ -1731,6 +1733,24 @@ fn preprocess_spec_toml_appends( Ok((base_text, appends)) } +fn normalize_append_key(raw: &str) -> String { + raw.split('.') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| { + let stripped = if (part.starts_with('"') && part.ends_with('"')) + || (part.starts_with('\'') && part.ends_with('\'')) + { + &part[1..part.len() - 1] + } else { + part + }; + stripped.trim().to_ascii_lowercase() + }) + .collect::>() + .join(".") +} + #[cfg(test)] mod spec_tests { use super::*; @@ -2119,6 +2139,48 @@ replaces = ["lib32-clang"] ); } + #[test] + fn lib32_output_does_not_fallback_to_primary_alternatives() { + 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/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +build_32 = true + +[alternatives] +provides = ["toolchain"] +conflicts = ["gcc"] +replaces = ["clang"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + let lib32 = spec.alternatives_for_output("lib32-llvm"); + + assert!(lib32.provides.is_empty()); + assert!(lib32.conflicts.is_empty()); + assert!(lib32.replaces.is_empty()); + } + #[test] fn parse_python_build_type() { let tmp = tempfile::tempdir().unwrap(); @@ -3419,6 +3481,102 @@ replace_rustflags += "opt-level=3=>opt-level=z" ); } + #[test] + fn parse_build_flags_appends_accepts_quoted_and_uppercase_keys() { + 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] +"cflags" += ["-fPIC"] +CXXFLAGS += ["-stdlib=libc++"] +"LDFLAGS" += "-Wl,--as-needed" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.cflags, vec!["-fPIC".to_string()]); + assert_eq!( + spec.build.flags.cxxflags, + vec!["-stdlib=libc++".to_string()] + ); + assert_eq!( + spec.build.flags.ldflags, + vec!["-Wl,--as-needed".to_string()] + ); + } + + #[test] + fn apply_config_reads_build_flag_appends_from_rootfs_build_toml() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("etc/depot.d/build.toml"); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + r#" +[flags] +cflags += ["-g"] +CXXFLAGS += ["-stdlib=libc++"] +LDFLAGS += "-Wl,--as-needed" +"#, + ) + .unwrap(); + + let config = crate::config::Config::for_rootfs(tmp.path()); + assert_eq!( + config.appends.get("build.flags.cflags").unwrap()[0] + .as_array() + .unwrap()[0] + .as_str(), + Some("-g") + ); + assert_eq!( + config.appends.get("build.flags.cxxflags").unwrap()[0] + .as_array() + .unwrap()[0] + .as_str(), + Some("-stdlib=libc++") + ); + assert_eq!( + config.appends.get("build.flags.ldflags").unwrap()[0].as_str(), + Some("-Wl,--as-needed") + ); + let mut spec = mk_spec("foo", "1.0"); + spec.apply_config(&config); + + assert!(spec.build.flags.cflags.contains(&"-g".to_string())); + assert!( + spec.build + .flags + .cxxflags + .contains(&"-stdlib=libc++".to_string()) + ); + assert!( + spec.build + .flags + .ldflags + .contains(&"-Wl,--as-needed".to_string()) + ); + } + #[test] fn parse_passthrough_env_from_spec() { let tmp = tempfile::tempdir().unwrap(); @@ -3934,16 +4092,6 @@ pub struct Alternatives { } impl Alternatives { - /// Return the top-level alternatives set without any nested output-specific overrides. - pub fn primary_alternatives(&self) -> Alternatives { - Alternatives { - provides: self.provides.clone(), - conflicts: self.conflicts.clone(), - replaces: self.replaces.clone(), - lib32: None, - } - } - /// Return the optional lib32-specific alternatives override set. pub fn lib32_alternatives(&self) -> Option { self.lib32.as_ref().map(|group| Alternatives { diff --git a/src/source/fetcher.rs b/src/source/fetcher.rs index aa8ecad..203d928 100755 --- a/src/source/fetcher.rs +++ b/src/source/fetcher.rs @@ -12,6 +12,10 @@ use url::Url; const MAX_MIRROR_RETRIES: usize = 8; +fn scheme_uses_http_transport(scheme: &str) -> bool { + matches!(scheme, "http" | "https") +} + /// Fetch an archive source tarball, returning path to downloaded file. pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> Result { let url = spec.expand_vars(&source.url); @@ -32,8 +36,17 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R // Parse URL early so we can handle non-HTTP schemes (FTP support) let parsed_url = Url::parse(&url).with_context(|| format!("Invalid URL: {}", url))?; + let pb = ProgressBar::new(0); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})", + ) + .unwrap() + .progress_chars("#>-"), + ); - // If this is an FTP URL, fetch via suppaftp into the cache and continue + // If this is an FTP URL, fetch via suppaftp and skip the HTTP client path. if parsed_url.scheme() == "ftp" { // Connect and login (anonymous fallback) let host = parsed_url.host_str().context("FTP URL missing host")?; @@ -86,65 +99,66 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R if !retrieved { bail!("FTP error fetching {}", url); } - } - - // Download with progress bar - // Use a sensible default User-Agent so servers that reject empty/unknown agents (e.g. IANA) - // will accept requests. Include package name/version at compile time. - let ua = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - let client = - super::build_blocking_client(&ua, None).with_context(|| "Failed to build HTTP client")?; - - let mut response = client - .get(&url) - .send() - .with_context(|| format!("Failed to fetch: {}", url))?; - // If the server returned a non-success status, read a short body preview and fail early. - // This prevents saving HTML error pages (which then fail checksum) and gives a clearer - // diagnostic to the user. - let status = response.status(); - if !status.is_success() { - let mut preview_bytes = Vec::new(); - // read up to 1 KiB for a preview (ignore errors while reading preview) - let _ = response.take(1024).read_to_end(&mut preview_bytes); - let preview = String::from_utf8_lossy(&preview_bytes); - bail!( - "HTTP error fetching {}: {}{}", - url, - status, - if preview.trim().is_empty() { - "".to_string() - } else { - format!(" — preview: {}", preview.trim()) - } - ); - } - let total_size = response.content_length().unwrap_or(0); - let pb = ProgressBar::new(total_size); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") - .unwrap() - .progress_chars("#>-"), - ); - - let mut file = File::create(&dest_path) - .with_context(|| format!("Failed to create: {}", dest_path.display()))?; - - let mut buffer = [0u8; 8192]; - let mut downloaded = 0u64; - - loop { - let bytes_read = response.read(&mut buffer)?; - if bytes_read == 0 { - break; + } else { + if !scheme_uses_http_transport(parsed_url.scheme()) { + bail!( + "Unsupported URL scheme for source fetch: {}", + parsed_url.scheme() + ); } - file.write_all(&buffer[..bytes_read])?; - downloaded += bytes_read as u64; - pb.set_position(downloaded); - } - pb.finish_with_message("Download complete"); + // Download with progress bar + // Use a sensible default User-Agent so servers that reject empty/unknown agents (e.g. IANA) + // will accept requests. Include package name/version at compile time. + let ua = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + let client = super::build_blocking_client(&ua, None) + .with_context(|| "Failed to build HTTP client")?; + + let mut response = client + .get(&url) + .send() + .with_context(|| format!("Failed to fetch: {}", url))?; + // If the server returned a non-success status, read a short body preview and fail early. + // This prevents saving HTML error pages (which then fail checksum) and gives a clearer + // diagnostic to the user. + let status = response.status(); + if !status.is_success() { + let mut preview_bytes = Vec::new(); + // read up to 1 KiB for a preview (ignore errors while reading preview) + let _ = response.take(1024).read_to_end(&mut preview_bytes); + let preview = String::from_utf8_lossy(&preview_bytes); + bail!( + "HTTP error fetching {}: {}{}", + url, + status, + if preview.trim().is_empty() { + "".to_string() + } else { + format!(" — preview: {}", preview.trim()) + } + ); + } + let total_size = response.content_length().unwrap_or(0); + pb.set_length(total_size); + + let mut file = File::create(&dest_path) + .with_context(|| format!("Failed to create: {}", dest_path.display()))?; + + let mut buffer = [0u8; 8192]; + let mut downloaded = 0u64; + + loop { + let bytes_read = response.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read])?; + downloaded += bytes_read as u64; + pb.set_position(downloaded); + } + + pb.finish_with_message("Download complete"); + } // Quick validation: ensure the downloaded file looks like the expected // archive (detect obvious HTML error pages or wrong formats by magic). @@ -320,7 +334,7 @@ pub(crate) fn derive_filename_from_url(url: &str) -> String { let mut hasher = Sha256::new(); hasher.update(url.as_bytes()); let h = hasher.finalize(); - let hex = format!("{:x}", h); + let hex = crate::hex::encode_lower(h); format!("source-{}.download", &hex[..12]) } @@ -589,6 +603,14 @@ fn classify_archive_magic(head: &[u8], lower_filename: &str, path: &Path) -> boo mod tests { use super::*; + #[test] + fn scheme_uses_http_transport_only_for_http_and_https() { + assert!(scheme_uses_http_transport("http")); + assert!(scheme_uses_http_transport("https")); + assert!(!scheme_uses_http_transport("ftp")); + assert!(!scheme_uses_http_transport("file")); + } + #[test] fn filename_from_simple_url() { assert_eq!( @@ -746,18 +768,18 @@ mod tests { let sha256_hex = { let mut h = Sha256::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha512_hex = { let mut h = Sha512::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha1_hex = { let mut h = Sha1::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let md5_hex = format!("{:x}", md5::compute(b"abc")); diff --git a/src/source/git.rs b/src/source/git.rs index 7f0d342..2aa637e 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -202,7 +202,7 @@ fn mirror_key(url: &str) -> String { let mut hasher = Sha256::new(); hasher.update(url.as_bytes()); let digest = hasher.finalize(); - format!("{:x}", digest) + crate::hex::encode_lower(digest) } fn ensure_mirror( diff --git a/src/source/hooks.rs b/src/source/hooks.rs index 0c698df..a1b992a 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -13,10 +13,10 @@ use crate::builder::state::{BuildStep, StateTracker}; fn hook_env_vars( spec: &PackageSpec, shell_helpers: &crate::shell_helpers::ShellHelpers, -) -> crate::builder::EnvVars { +) -> Result { let mut env_vars = crate::builder::standard_build_env(spec, None, true, true); shell_helpers.apply_to_env_vars(&mut env_vars); - env_vars + Ok(env_vars) } /// Apply patches and run `post_extract` commands in the extracted source tree. @@ -96,7 +96,7 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path ); let helper_root = tempfile::tempdir().context("Failed to create post-extract helper root")?; let shell_helpers = crate::shell_helpers::ShellHelpers::new(helper_root.path())?; - let env_vars = hook_env_vars(spec, &shell_helpers); + let env_vars = hook_env_vars(spec, &shell_helpers)?; for cmd in &source.post_extract { let cmd_str = spec.expand_vars(cmd); @@ -132,7 +132,7 @@ pub fn run_post_configure_commands( crate::log_info!("Running {} post-configure command(s)...", commands.len()); let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; - let mut env_vars = hook_env_vars(spec, &shell_helpers); + let mut env_vars = hook_env_vars(spec, &shell_helpers)?; crate::builder::set_env_var( &mut env_vars, "DESTDIR", @@ -168,7 +168,7 @@ pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &P crate::log_info!("Running {} post-compile command(s)...", commands.len()); let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; - let mut env_vars = hook_env_vars(spec, &shell_helpers); + let mut env_vars = hook_env_vars(spec, &shell_helpers)?; crate::builder::set_env_var( &mut env_vars, "DESTDIR", @@ -208,7 +208,7 @@ pub fn run_post_install_commands_in_dir( crate::log_info!("Running {} post-install command(s)...", commands.len()); let shell_helpers = crate::shell_helpers::ShellHelpers::new(destdir)?; - let mut env_vars = hook_env_vars(spec, &shell_helpers); + let mut env_vars = hook_env_vars(spec, &shell_helpers)?; crate::builder::set_env_var( &mut env_vars, "DESTDIR", diff --git a/src/source/mod.rs b/src/source/mod.rs index 0089b7b..b967751 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -412,7 +412,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { } hasher.update(&buf[..n]); } - let actual = format!("{:x}", hasher.finalize()); + let actual = crate::hex::encode_lower(hasher.finalize()); Ok(actual == hex) } "sha512" => { @@ -427,7 +427,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { } hasher.update(&buf[..n]); } - let actual = format!("{:x}", hasher.finalize()); + let actual = crate::hex::encode_lower(hasher.finalize()); Ok(actual == hex) } "sha1" => { @@ -442,7 +442,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { } hasher.update(&buf[..n]); } - let actual = format!("{:x}", hasher.finalize()); + let actual = crate::hex::encode_lower(hasher.finalize()); Ok(actual == hex) } "md5" => { @@ -457,7 +457,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { ctx.consume(&buf[..n]); } let digest = ctx.finalize(); - let actual = format!("{:x}", digest); + let actual = crate::hex::encode_lower(*digest); Ok(actual == hex) } "b2" | "b2sum" => { @@ -994,17 +994,17 @@ mod tests { let sha256_hex = { let mut h = Sha256::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha512_hex = { let mut h = Sha512::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let sha1_hex = { let mut h = Sha1::new(); h.update(b"abc"); - format!("{:x}", h.finalize()) + crate::hex::encode_lower(h.finalize()) }; let md5_hex = format!("{:x}", md5::compute(b"abc")); let b2_hex = b2sum_rust::Blake2bSum::new(64) diff --git a/tools/meson/cargo-build.sh b/tools/meson/cargo-build.sh index 7c2582b..8c795b0 100644 --- a/tools/meson/cargo-build.sh +++ b/tools/meson/cargo-build.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -if [ "$#" -ne 6 ]; then - echo "usage: $0 " >&2 +if [ "$#" -ne 15 ]; then + echo "usage: $0 " >&2 exit 2 fi @@ -11,7 +11,16 @@ src_root="$2" build_root="$3" profile="$4" release_flag="$5" -output="$6" +build_static="$6" +autotools_pkg="$7" +cmake_pkg="$8" +meson_pkg="$9" +perl_pkg="${10}" +custom_pkg="${11}" +python_pkg="${12}" +rust_pkg="${13}" +makefile_pkg="${14}" +output="${15}" cargo_home="$build_root/cargo-home" cargo_target_dir="$build_root/cargo-target" @@ -20,6 +29,15 @@ mkdir -p "$cargo_home" "$cargo_target_dir" export CARGO_HOME="$cargo_home" export CARGO_TARGET_DIR="$cargo_target_dir" +export BUILD_DEPOT_STATIC="$build_static" +export DEPOT_AUTOTOOLS_PACKAGE="$autotools_pkg" +export DEPOT_CMAKE_PACKAGE="$cmake_pkg" +export DEPOT_MESON_PACKAGE="$meson_pkg" +export DEPOT_PERL_PACKAGE="$perl_pkg" +export DEPOT_CUSTOM_PACKAGE="$custom_pkg" +export DEPOT_PYTHON_PACKAGE="$python_pkg" +export DEPOT_RUST_PACKAGE="$rust_pkg" +export DEPOT_MAKEFILE_PACKAGE="$makefile_pkg" if [ "$release_flag" = "1" ]; then "$cargo_bin" build --locked --manifest-path "$src_root/Cargo.toml" --release diff --git a/tools/meson/cargo-test.sh b/tools/meson/cargo-test.sh index 290e21c..91185dd 100644 --- a/tools/meson/cargo-test.sh +++ b/tools/meson/cargo-test.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -if [ "$#" -ne 5 ]; then - echo "usage: $0 " >&2 +if [ "$#" -ne 14 ]; then + echo "usage: $0 " >&2 exit 2 fi @@ -11,6 +11,15 @@ src_root="$2" build_root="$3" profile="$4" release_flag="$5" +build_static="$6" +autotools_pkg="$7" +cmake_pkg="$8" +meson_pkg="$9" +perl_pkg="${10}" +custom_pkg="${11}" +python_pkg="${12}" +rust_pkg="${13}" +makefile_pkg="${14}" cargo_home="$build_root/cargo-home" cargo_target_dir="$build_root/cargo-target" @@ -19,6 +28,15 @@ mkdir -p "$cargo_home" "$cargo_target_dir" export CARGO_HOME="$cargo_home" export CARGO_TARGET_DIR="$cargo_target_dir" +export BUILD_DEPOT_STATIC="$build_static" +export DEPOT_AUTOTOOLS_PACKAGE="$autotools_pkg" +export DEPOT_CMAKE_PACKAGE="$cmake_pkg" +export DEPOT_MESON_PACKAGE="$meson_pkg" +export DEPOT_PERL_PACKAGE="$perl_pkg" +export DEPOT_CUSTOM_PACKAGE="$custom_pkg" +export DEPOT_PYTHON_PACKAGE="$python_pkg" +export DEPOT_RUST_PACKAGE="$rust_pkg" +export DEPOT_MAKEFILE_PACKAGE="$makefile_pkg" if [ "$release_flag" = "1" ]; then exec "$cargo_bin" test --locked --manifest-path "$src_root/Cargo.toml" --profile "$profile"