diff --git a/Cargo.lock b/Cargo.lock index 1e30fa5..d70e101 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -421,6 +421,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "ct-codecs" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" + [[package]] name = "deflate64" version = "0.1.10" @@ -435,14 +441,17 @@ dependencies = [ "ar", "bzip2", "clap", + "fd-lock", "flate2", "git2", "indicatif", "inquire", "libz-sys", "md5", + "minisign", "nix", "openssl-sys", + "petgraph", "reqwest", "rusqlite", "semver", @@ -584,6 +593,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "filetime" version = "0.2.26" @@ -602,6 +622,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -631,6 +657,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -923,6 +964,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1117,9 +1174,9 @@ dependencies = [ [[package]] name = "inquire" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979f5ab9760427ada4fa5762b2d905e5b12704fb1fada07b6bfa66aeaa586f87" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ "bitflags", "crossterm", @@ -1271,7 +1328,6 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -1292,9 +1348,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" dependencies = [ "cc", "libc", @@ -1380,6 +1436,18 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6bf96cef396a17a96f7600281aa4da9229860b7a082601b1f6db6eaa5f99ee5" +dependencies = [ + "ct-codecs", + "getrandom 0.3.4", + "rpassword", + "scrypt", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1402,6 +1470,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.31.1" @@ -1441,6 +1526,32 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -1453,15 +1564,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" -[[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.111" @@ -1470,7 +1572,6 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -1520,6 +1621,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1758,10 +1871,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1770,6 +1885,7 @@ dependencies = [ "rustls-platform-verifier", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -1794,6 +1910,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.38.0" @@ -1860,7 +1997,7 @@ dependencies = [ "openssl-probe 0.2.0", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -1888,7 +2025,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -1918,6 +2055,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1942,6 +2088,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -2338,6 +2508,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2837,6 +3017,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index c27f2c0..0bf724d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,19 +11,19 @@ anyhow = "1.0.102" ar = "0.9.0" bzip2 = "0.6.1" clap = { version = "4.5.60", features = ["derive"] } -flate2 = { version = "1.1.9", default-features = false, features = ["miniz_oxide"] } +flate2 = "1.1.9" git2 = { version = "0.20.4", features = ["vendored-libgit2"] } indicatif = "0.18.4" nix = { version = "0.31.1", features = ["user"] } -reqwest = { version = "0.13.2", features = ["blocking", "rustls"] } -rusqlite = { version = "0.38.0", features = ["bundled"] } -openssl-sys = { version = "0.9", features = ["vendored"] } +reqwest = { version = "0.13.2", features = ["blocking", "native-tls"] } +rusqlite = "0.38.0" +openssl-sys = "0.9.111" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } yaml-rust2 = "0.11.0" sha2 = "0.10.9" tar = "0.4.44" -tempfile = "3.25.0" +tempfile = "=3.25.0" thiserror = "2.0.18" toml = "0.9.8" url = "2.5.8" @@ -31,18 +31,13 @@ walkdir = "2.5.0" xz2 = "0.1.7" zip = "8.1.0" zstd = { version = "0.13.3", features = ["zstdmt"] } -libz-sys = { version = "1.1.23", default-features = false, features = ["stock-zlib", "static"] } -inquire = "0.9.3" +libz-sys = "1.1.24" +inquire = "0.9.4" md5 = "0.8.0" suppaftp = "8.0.2" +minisign = "0.8.0" +petgraph = "0.8.3" +fd-lock = "4.0.4" [dev-dependencies] -tempfile = "3.25.0" - -[profile.release] -opt-level = 3 -lto = "thin" -codegen-units = 1 -panic = "abort" -strip = "symbols" -debug = false +tempfile = "=3.25.0" diff --git a/README.md b/README.md index db4fbcd..d51d2a2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # depot -Depot is a source-based package manager for Linux, designed for Linux. It focuses on reproducibility, atomic installations, and ease of cross-compilation. +Depot is a source-based package manager designed for Linux. It focuses on reproducibility, atomic installations, and ease of cross-compilation. ## Features @@ -36,10 +36,18 @@ depot install zlib-1.2.11-1-x86_64.depot.pkg.tar.zst ## Command Reference - `install `: Build and install a package. + - Resolves a full dependency plan first (binary repos and/or source specs), then executes in dependency order. + - Use `--yes` for non-interactive confirmation/provider selection. + - Use `--dry-run` to print the plan without performing work. - `remove `: Remove an installed package. - `build `: Build a package and create an archive without installing. + - Resolves and offers to install missing build/test dependencies before fetching/building. - `info `: Show information about a package. +- `search `: Search enabled source/binary repos by package name and provided features. + - Use `--files` to search binary repo metadata file lists. +- `owns `: Show which installed package owns a path. - `list`: List all installed packages. +- `repo owns `: Query binary repo metadata for path ownership. - `repo create [DIR]`: Create a repository database from a directory of packages. - `config`: Show current configuration and overrides. diff --git a/contrib/README.md b/contrib/README.md index 63b237a..404e39e 100644 --- a/contrib/README.md +++ b/contrib/README.md @@ -1,7 +1,7 @@ Contrib: example system and user configuration files for Depot Place these files on your system to provide sensible defaults and examples -for `/etc/depot.toml`, `/etc/depot.d/build.toml`, or user-level configs. +for `/etc/depot.toml`, `/etc/depot.d/*.toml`, or user-level configs. Recommended installation: @@ -18,3 +18,5 @@ Notes - These files are examples only. Review and adapt to your distribution and security policies before deploying. - The config loader supports append syntax (e.g. `cflags += ["-g"]`). + - `contrib/depot.d/repos.toml` shows the new source/binary repo configuration + format used by `depot repo ...` commands. diff --git a/contrib/depot.d/repos.toml b/contrib/depot.d/repos.toml new file mode 100644 index 0000000..61dcd7f --- /dev/null +++ b/contrib/depot.d/repos.toml @@ -0,0 +1,26 @@ +[settings] +prefer_binary = true + +[source.vertex] +url = "https://gitlab.com/vertex-linux/packages.git" +enabled = true +priority = 50 +subdirs = ["core", "extra"] + +[binary.core] +url = "https://repo.sfgos.net/core" +enabled = true +priority = 10 +repo_db = "repo.db.zst" + +[binary.core.arch.x86_64] +enabled = true + +[binary.extra] +url = "https://repo.sfgos.net/extra" +enabled = true +priority = 20 +repo_db = "repo.db.zst" + +[binary.extra.arch.x86_64] +enabled = true diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..710ddcd --- /dev/null +++ b/meson.build @@ -0,0 +1,127 @@ +project( + 'depot', + version: '0.1.0', + meson_version: '>=0.60.0', +) + +fs = import('fs') + +cargo = find_program('cargo', required: true) +find_prog = find_program('find', required: true) +sh = find_program('sh', required: true) + +src_root = meson.project_source_root() +build_root = meson.project_build_root() +src_root_prefix = src_root + '/' + +cargo_profile = 'debug' +cargo_release = false +if get_option('buildtype') != 'debug' + cargo_profile = 'release' + cargo_release = true +endif + +cargo_inputs = files('Cargo.toml') +if fs.is_file('Cargo.lock') + cargo_inputs += files('Cargo.lock') +endif + +cargo_src_list = run_command( + find_prog, + join_paths(src_root, 'src'), + '-type', + 'f', + '(', + '-name', + '*.rs', + '-o', + '-name', + '*.toml', + ')', + check: true, + capture: true, +).stdout().strip() +foreach rel : cargo_src_list.split('\n') + if rel == '' + continue + endif + if rel.startswith(src_root_prefix) + rel = rel.replace(src_root_prefix, '') + endif + cargo_inputs += files(rel) +endforeach + +depot_bin = custom_target( + 'depot-bin', + input: cargo_inputs, + output: 'depot', + command: [ + sh, + join_paths(src_root, 'tools', 'meson', 'cargo-build.sh'), + cargo, + src_root, + build_root, + cargo_profile, + cargo_release ? '1' : '0', + '@OUTPUT@', + ], + console: true, + build_by_default: true, + install: true, + install_dir: get_option('bindir'), +) + +test( + 'cargo-test', + sh, + args: [ + join_paths(src_root, 'tools', 'meson', 'cargo-test.sh'), + cargo.full_path(), + src_root, + build_root, + ], + suite: ['cargo'], +) + +# System configuration examples installed under the actual runtime lookup paths. +install_data( + 'contrib/depot.toml', + install_dir: get_option('sysconfdir'), +) + +example_sysconf_dir = join_paths(get_option('sysconfdir'), 'depot.d') +example_depotd_list = run_command( + find_prog, + join_paths(src_root, 'contrib', 'depot.d'), + '-maxdepth', + '1', + '-type', + 'f', + '-name', + '*.toml', + check: true, + capture: true, +).stdout().strip() +example_depotd_files = [] +foreach rel : example_depotd_list.split('\n') + if rel == '' + continue + endif + if rel.startswith(src_root_prefix) + rel = rel.replace(src_root_prefix, '') + endif + example_depotd_files += files(rel) +endforeach +if example_depotd_files.length() > 0 + install_data(example_depotd_files, install_dir: example_sysconf_dir) +endif + +# Per-user example is documentation, not a system config file. +install_data( + 'contrib/user.depot.toml.example', + install_dir: join_paths(get_option('datadir'), 'doc', meson.project_name(), 'examples'), +) +install_data( + 'contrib/README.md', + install_dir: join_paths(get_option('datadir'), 'doc', meson.project_name()), +) diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index 19eeb1a..2ed49c3 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -60,7 +60,7 @@ pub fn build( let mut configure_cmd = Command::new(&configure_path); configure_cmd.current_dir(&build_dir); - crate::builder::prepare_command(&mut configure_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut configure_cmd, &env_vars); configure_cmd.arg(format!("--prefix={}", flags.prefix)); @@ -143,7 +143,7 @@ pub fn build( make_cmd.arg(target); } - crate::builder::prepare_command(&mut make_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut make_cmd, &env_vars); let status = make_cmd.status().with_context(|| { format!("Failed to run {} in {}", make_exec, make_dir.display()) @@ -194,7 +194,7 @@ pub fn build( for test_target in &test_targets { test_cmd.arg(test_target); } - crate::builder::prepare_command(&mut test_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut test_cmd, &env_vars); let test_targets_display = test_targets.join(" "); let status = test_cmd.status().with_context(|| { @@ -305,7 +305,7 @@ pub fn build( "DESTDIR".to_string(), destdir.to_string_lossy().into_owned(), )); - crate::builder::prepare_command(&mut install_cmd, &install_env); + crate::builder::prepare_tool_command(&mut install_cmd, &install_env); let status = install_cmd.status().with_context(|| { format!( diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 44b4665..0ad30f1 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -82,7 +82,7 @@ pub fn build( cmake_cmd.arg(&expanded); } - crate::builder::prepare_command(&mut cmake_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut cmake_cmd, &env_vars); let status = cmake_cmd.status().context("Failed to run cmake")?; if !status.success() { @@ -109,7 +109,7 @@ pub fn build( } } - crate::builder::prepare_command(&mut build_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut build_cmd, &env_vars); let status = build_cmd .status() @@ -131,7 +131,7 @@ pub fn build( for target in &test_targets { test_cmd.arg(target); } - crate::builder::prepare_command(&mut test_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut test_cmd, &env_vars); let status = test_cmd.status().with_context(|| { format!( @@ -182,7 +182,7 @@ pub fn build( "DESTDIR".to_string(), destdir.to_string_lossy().into_owned(), )); - crate::builder::prepare_command(&mut install_cmd, &install_env); + crate::builder::prepare_tool_command(&mut install_cmd, &install_env); let status = install_cmd .status() diff --git a/src/builder/custom.rs b/src/builder/custom.rs index f329888..ee65e78 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -118,7 +118,7 @@ pub fn build( }; cmd.current_dir(&build_dir); - crate::builder::prepare_command(&mut cmd, &env_vars); + crate::builder::prepare_tool_command(&mut cmd, &env_vars); // Run the command and include the OS error on spawn failures for clearer diagnostics let status = cmd.status().map_err(|e| { @@ -176,9 +176,16 @@ fn build_function_mode_command( ) -> Result { let mut wrapper = String::new(); wrapper.push_str("set -eu\n"); + wrapper.push_str("depot_has_function() {\n"); + wrapper.push_str(" case \"$(type \"$1\" 2>/dev/null || :)\" in\n"); + wrapper.push_str(" *function*) return 0 ;;\n"); + wrapper.push_str(" *) return 1 ;;\n"); + wrapper.push_str(" esac\n"); + wrapper.push_str("}\n"); + wrapper.push_str("depot_build_ran=0\n"); wrapper.push_str(". \"$1\"\n"); - wrapper.push_str("if command -v depot_build >/dev/null 2>&1; then depot_build;\n"); - wrapper.push_str("elif command -v build >/dev/null 2>&1; then build;\n"); + wrapper.push_str("if depot_has_function depot_build; then depot_build; depot_build_ran=1;\n"); + wrapper.push_str("elif depot_has_function build; then build; depot_build_ran=1;\n"); wrapper.push_str("fi\n"); let primary = &spec.package.name; @@ -195,18 +202,28 @@ fn build_function_mode_command( wrapper.push_str(&format!( "DEPOT_OUTPUT_NAME='{q_name}'; DEPOT_OUTPUT_DESTDIR='{q_dest}'; DESTDIR=\"$DEPOT_OUTPUT_DESTDIR\"; export DEPOT_OUTPUT_NAME DEPOT_OUTPUT_DESTDIR DESTDIR\n" )); + wrapper.push_str("depot_output_installed=0\n"); wrapper.push_str(&format!( - "if command -v depot_install_{fn_suffix} >/dev/null 2>&1; then depot_install_{fn_suffix};\n" + "if depot_has_function depot_install_{fn_suffix}; then depot_install_{fn_suffix}; depot_output_installed=1;\n" )); wrapper.push_str(&format!( - "elif command -v install_{fn_suffix} >/dev/null 2>&1; then install_{fn_suffix};\n" + "elif depot_has_function install_{fn_suffix}; then install_{fn_suffix}; depot_output_installed=1;\n" )); if out.name == *primary { wrapper - .push_str("elif command -v depot_install >/dev/null 2>&1; then depot_install;\n"); - wrapper.push_str("elif command -v install >/dev/null 2>&1; then install;\n"); + .push_str("elif depot_has_function depot_install; then depot_install; depot_output_installed=1;\n"); + wrapper.push_str( + "elif depot_has_function install; then install; depot_output_installed=1;\n", + ); + wrapper.push_str("elif [ \"$depot_build_ran\" = 1 ]; then depot_output_installed=1;\n"); } wrapper.push_str("fi\n"); + wrapper.push_str("if [ \"$depot_output_installed\" != 1 ]; then\n"); + wrapper.push_str( + " echo \"depot: missing install handler for output '$DEPOT_OUTPUT_NAME' in custom function mode\" >&2\n", + ); + wrapper.push_str(" exit 1\n"); + wrapper.push_str("fi\n"); } let mut cmd = fakeroot::wrap_install_command("sh", destdir); @@ -363,4 +380,46 @@ depot_install_dev_pkg() { ); Ok(()) } + + #[test] + fn test_build_function_mode_errors_when_output_handler_missing() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + + let build_sh = tmp_src.path().join("build.sh"); + std::fs::write( + &build_sh, + r#"#!/bin/sh +depot_build() { + : +} +depot_install() { + mkdir -p "$DESTDIR/usr/share" + echo primary > "$DESTDIR/usr/share/primary.txt" +} +"#, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&build_sh)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&build_sh, perms)?; + } + + let mut spec = mk_spec("demo", "1.0"); + spec.packages.push(crate::package::PackageInfo { + name: "dev-pkg".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }); + + let err = build(&spec, tmp_src.path(), tmp_dest.path(), None, true) + .expect_err("missing per-output install handler should fail"); + assert!(err.to_string().contains("Custom build script failed")); + Ok(()) + } } diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index afa5c1f..d1fed7e 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -43,7 +43,7 @@ pub fn build( let mut cmd = Command::new("sh"); cmd.arg("-c").arg(&cmd_str); cmd.current_dir(src_dir); - crate::builder::prepare_command(&mut cmd, &env_vars); + crate::builder::prepare_tool_command(&mut cmd, &env_vars); let status = cmd .status() @@ -84,7 +84,7 @@ pub fn build( "DESTDIR".to_string(), destdir.to_string_lossy().into_owned(), )); - crate::builder::prepare_command(&mut cmd, &install_env); + crate::builder::prepare_tool_command(&mut cmd, &install_env); let status = cmd .status() diff --git a/src/builder/meson.rs b/src/builder/meson.rs index 279368b..59c2ebe 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -51,7 +51,7 @@ pub fn build( meson_cmd.arg(arg); } - crate::builder::prepare_command(&mut meson_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut meson_cmd, &env_vars); let status = meson_cmd.status().context("Failed to run meson setup")?; if !status.success() { @@ -71,7 +71,7 @@ pub fn build( ninja_cmd.current_dir(&build_dir); ninja_cmd.arg("-j").arg(num_cpus().to_string()); - crate::builder::prepare_command(&mut ninja_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut ninja_cmd, &env_vars); let status = ninja_cmd .status() @@ -106,7 +106,7 @@ pub fn build( "DESTDIR".to_string(), destdir.to_string_lossy().into_owned(), )); - crate::builder::prepare_command(&mut install_cmd, &install_env); + crate::builder::prepare_tool_command(&mut install_cmd, &install_env); let status = install_cmd .status() diff --git a/src/builder/mod.rs b/src/builder/mod.rs index fb8cfc2..1eaba33 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -13,8 +13,9 @@ pub mod state; use crate::cross::CrossConfig; use crate::package::{BuildType, PackageSpec}; use anyhow::Result; +use std::ffi::OsString; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; pub type EnvVars = Vec<(String, String)>; @@ -41,6 +42,9 @@ pub fn standard_build_env( if !flags.cflags.is_empty() { set_env_var(&mut env_vars, "CFLAGS", flags.cflags.join(" ")); } + if !flags.cxxflags.is_empty() { + set_env_var(&mut env_vars, "CXXFLAGS", flags.cxxflags.join(" ")); + } let ldflags = if !flags.ldflags.is_empty() || !flags.libc.is_empty() { if flags.libc.is_empty() { @@ -123,8 +127,29 @@ pub fn standard_build_env( /// Prepare a Command with a hermetic environment and some essential variables preserved. pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) { cmd.env_clear(); + + if let Some(path) = sanitized_build_path() { + cmd.env("PATH", path); + } + // Preserve essential environment variables - for var in &["PATH", "LANG", "HOME", "DESTDIR", "DEPOT_ROOTFS"] { + for var in &[ + "LANG", + "HOME", + "DESTDIR", + "DEPOT_ROOTFS", + "CARGO_HOME", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + "RUSTC", + "RUSTDOC", + "TERM", + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "FORCE_COLOR", + ] { if let Ok(val) = std::env::var(var) { cmd.env(var, val); } @@ -139,6 +164,35 @@ pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) { } } +fn sanitized_build_path() -> Option { + use std::path::PathBuf; + + let mut parts: Vec = std::env::var_os("PATH") + .map(|raw| std::env::split_paths(&raw).collect()) + .unwrap_or_default(); + + for dir in ["/bin", "/usr/bin", "/sbin", "/usr/sbin"] { + let path = PathBuf::from(dir); + if path.exists() && !parts.iter().any(|p| p == &path) { + parts.push(path); + } + } + + if parts.is_empty() { + return None; + } + + std::env::join_paths(parts).ok() +} + +/// Prepare a Command for interactive tool execution with live terminal output. +pub fn prepare_tool_command(cmd: &mut Command, env_vars: &EnvVars) { + prepare_command(cmd, env_vars); + cmd.stdin(Stdio::inherit()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); +} + /// Build a package using the appropriate build system pub fn build( spec: &PackageSpec, @@ -230,6 +284,8 @@ mod tests { std::env::set_var("HOME", "/home/test"); std::env::set_var("SHELL", "/bin/zsh"); std::env::set_var("DEPOT_ROOTFS", "/my/rootfs"); + std::env::set_var("TERM", "xterm-256color"); + std::env::set_var("CLICOLOR_FORCE", "1"); } prepare_command(&mut cmd, &vec![("MYVAR".to_string(), "myval".to_string())]); @@ -251,6 +307,16 @@ mod tests { envs.get(OsStr::new("DEPOT_ROOTFS")), Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str())) ); + assert_eq!( + envs.get(OsStr::new("TERM")), + Some(&Some( + std::ffi::OsString::from("xterm-256color").as_os_str() + )) + ); + assert_eq!( + envs.get(OsStr::new("CLICOLOR_FORCE")), + Some(&Some(std::ffi::OsString::from("1").as_os_str())) + ); } #[test] @@ -267,15 +333,45 @@ mod tests { ); } + #[test] + fn test_prepare_command_preserves_rust_toolchain_homes() { + let mut cmd = std::process::Command::new("ls"); + unsafe { + std::env::set_var("CARGO_HOME", "/var/cache/cargo-home"); + std::env::set_var("RUSTUP_HOME", "/var/cache/rustup-home"); + } + prepare_command(&mut cmd, &Vec::new()); + let envs: HashMap<_, _> = cmd.get_envs().collect(); + assert_eq!( + envs.get(OsStr::new("CARGO_HOME")), + Some(&Some( + std::ffi::OsString::from("/var/cache/cargo-home").as_os_str() + )) + ); + assert_eq!( + envs.get(OsStr::new("RUSTUP_HOME")), + Some(&Some( + std::ffi::OsString::from("/var/cache/rustup-home").as_os_str() + )) + ); + } + #[test] fn test_standard_build_env_respects_export_compiler_flags_toggle() { - let spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + spec.build.flags.cxxflags = vec!["-O2".into(), "-fno-exceptions".into()]; let enabled = standard_build_env(&spec, None, true, true); assert!( enabled.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), "expected CFLAGS to be exported when enabled" ); + assert!( + enabled + .iter() + .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions"), + "expected CXXFLAGS to be exported when enabled" + ); assert!( enabled .iter() @@ -304,6 +400,10 @@ mod tests { !disabled_env.iter().any(|(k, _)| k == "CFLAGS"), "expected CFLAGS to be omitted when no_flags is set in spec" ); + assert!( + !disabled_env.iter().any(|(k, _)| k == "CXXFLAGS"), + "expected CXXFLAGS to be omitted when no_flags is set in spec" + ); assert!( !disabled_env.iter().any(|(k, _)| k == "LDFLAGS"), "expected LDFLAGS to be omitted when no_flags is set in spec" diff --git a/src/builder/python.rs b/src/builder/python.rs index d637597..63c2cec 100644 --- a/src/builder/python.rs +++ b/src/builder/python.rs @@ -245,7 +245,7 @@ fn build_wheel_setup_py( .arg("bdist_wheel") .arg("--dist-dir") .arg(dist_dir); - crate::builder::prepare_command(&mut cmd, &env_vars.to_vec()); + crate::builder::prepare_tool_command(&mut cmd, &env_vars.to_vec()); let status = cmd .status() diff --git a/src/builder/rust.rs b/src/builder/rust.rs index 0c5c6fe..6938abd 100755 --- a/src/builder/rust.rs +++ b/src/builder/rust.rs @@ -99,7 +99,7 @@ pub fn build( } // Set environment - crate::builder::prepare_command(&mut cargo_cmd, &env_vars); + crate::builder::prepare_tool_command(&mut cargo_cmd, &env_vars); let status = cargo_cmd .status() diff --git a/src/config.rs b/src/config.rs index b932e05..d964fdf 100755 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,203 @@ //! Global configuration for Depot use anyhow::{Context, Result}; -use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; +fn default_true() -> bool { + true +} + +fn default_repo_db_filename() -> String { + "repo.db.zst".to_string() +} + +fn resolve_rootfs_base(rootfs: &Path) -> PathBuf { + if rootfs.exists() { + rootfs.canonicalize().unwrap_or_else(|_| { + std::env::current_dir() + .map(|cwd| cwd.join(rootfs)) + .unwrap_or_else(|_| rootfs.to_path_buf()) + }) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(rootfs)) + .unwrap_or_else(|_| rootfs.to_path_buf()) + } +} + +/// Global repo behavior settings loaded from `repos.toml`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RepoSettings { + /// Prefer binary repos over source repos when both can satisfy a request. + #[serde(default)] + pub prefer_binary: bool, +} + +impl Default for RepoSettings { + fn default() -> Self { + Self { + prefer_binary: false, + } + } +} + +/// Source repository configuration entry loaded from `repos.toml`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceRepo { + /// Git URL for the package repo. + pub url: String, + /// Whether the repo is enabled for lookups/sync. + #[serde(default = "default_true")] + pub enabled: bool, + /// Lower numbers are higher priority. + #[serde(default)] + pub priority: i32, + /// Optional subdirectories to scan/index inside the git checkout. + #[serde(default)] + pub subdirs: Vec, +} + +/// Binary repository configuration entry loaded from `repos.toml`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BinaryRepoArchEntry { + /// Whether this architecture is enabled for this repo. + #[serde(default = "default_true")] + pub enabled: bool, + /// Optional URL override for this architecture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Optional repo DB path override for this architecture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_db: Option, +} + +/// Binary repository configuration entry loaded from `repos.toml`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BinaryRepo { + /// Base URL for the binary repository. + pub url: String, + /// Whether the repo is enabled for lookups. + #[serde(default = "default_true")] + pub enabled: bool, + /// Lower numbers are higher priority. + #[serde(default)] + pub priority: i32, + /// Repo database filename/path relative to `url`. + #[serde(default = "default_repo_db_filename")] + pub repo_db: String, + /// Architecture-specific overrides/enablement. + /// + /// Example: + /// `[binary.core.arch.x86_64]` + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub arch: BTreeMap, + /// Allow unsigned repo metadata for this repo. + #[serde(default)] + pub allow_unsigned: bool, +} + +impl Default for BinaryRepo { + fn default() -> Self { + Self { + url: String::new(), + enabled: true, + priority: 0, + repo_db: default_repo_db_filename(), + arch: BTreeMap::new(), + allow_unsigned: false, + } + } +} + +impl BinaryRepo { + /// Return true if this repo is enabled for the requested machine architecture. + pub fn supports_arch(&self, machine_arch: &str) -> bool { + if self.arch.is_empty() { + return true; + } + self.arch + .get(machine_arch) + .map(|entry| entry.enabled) + .unwrap_or(false) + } + + /// Return the effective base URL for `machine_arch`, including arch overrides. + pub fn effective_url_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> { + if self.arch.is_empty() { + return Some(self.url.as_str()); + } + let entry = self.arch.get(machine_arch)?; + if !entry.enabled { + return None; + } + Some(entry.url.as_deref().unwrap_or(self.url.as_str())) + } + + /// Return the effective repo DB path for `machine_arch`, including arch overrides. + pub fn effective_repo_db_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> { + if self.arch.is_empty() { + return Some(self.repo_db.as_str()); + } + let entry = self.arch.get(machine_arch)?; + if !entry.enabled { + return None; + } + Some(entry.repo_db.as_deref().unwrap_or(self.repo_db.as_str())) + } +} + +/// Parsed contents of `/etc/depot.d/repos.toml`. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct RepoConfigFile { + /// Global repo behavior settings. + #[serde(default, skip_serializing_if = "repo_settings_is_default")] + pub settings: RepoSettings, + /// Source repos keyed by repo name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub source: BTreeMap, + /// Binary repos keyed by repo name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub binary: BTreeMap, +} + +fn repo_settings_is_default(settings: &RepoSettings) -> bool { + !settings.prefer_binary +} + +/// 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") +} + +/// Load `repos.toml` for the given rootfs. Missing file returns defaults. +pub fn load_repos_config_file(rootfs: &Path) -> Result { + let path = repos_toml_path(rootfs); + if !path.exists() { + return Ok(RepoConfigFile::default()); + } + + let content = + fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; + let parsed: RepoConfigFile = + toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?; + Ok(parsed) +} + +/// Save `repos.toml` for the given rootfs, creating `/etc/depot.d` as needed. +pub fn save_repos_config_file(rootfs: &Path, repos: &RepoConfigFile) -> Result { + let path = repos_toml_path(rootfs); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + let content = toml::to_string_pretty(repos).context("Failed to serialize repos.toml")?; + fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(path) +} + /// Global configuration settings #[derive(Clone)] pub struct Config { @@ -20,43 +213,43 @@ pub struct Config { pub package_overrides: toml::Value, /// Appends found in system TOML files (key -> values to append) pub appends: HashMap>, + /// Parsed repo settings from `/etc/depot.d/repos.toml`. + pub repo_settings: RepoSettings, + /// Source repos from `/etc/depot.d/repos.toml` (and legacy mirrors fallback). + pub source_repos: BTreeMap, + /// Binary repos from `/etc/depot.d/repos.toml`. + pub binary_repos: BTreeMap, /// Mirrors mapping read from /etc/depot.d/mirrors.toml (reponame -> git url) pub mirrors: std::collections::HashMap, /// Directory where git mirrors are cloned (absolute path). Defaults to /// /usr/src/depot unless overridden in depot.toml pub repo_clone_dir: PathBuf, + /// Cache directory for binary packages and repo metadata. + pub package_cache_dir: PathBuf, } impl Config { /// Create config with paths relative to the given rootfs pub fn for_rootfs(rootfs: &Path) -> Self { - let abs_rootfs = if rootfs.exists() { - rootfs.canonicalize().unwrap_or_else(|_| { - std::env::current_dir() - .map(|cwd| cwd.join(rootfs)) - .unwrap_or_else(|_| rootfs.to_path_buf()) - }) - } else { - std::env::current_dir() - .map(|cwd| cwd.join(rootfs)) - .unwrap_or_else(|_| rootfs.to_path_buf()) - }; + let abs_rootfs = resolve_rootfs_base(rootfs); let is_system_root = abs_rootfs == Path::new("/") || abs_rootfs.as_os_str() == "/"; let is_root = crate::fakeroot::is_root(); - let (cache_dir, build_dir, db_dir) = if is_system_root && !is_root { + let (cache_dir, package_cache_dir, build_dir, db_dir) = if is_system_root && !is_root { let home = std::env::var("HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")); ( home.join(".cache/depot/sources"), + home.join(".cache/depot/packages"), home.join(".cache/depot/build"), home.join(".local/share/depot"), ) } else { ( abs_rootfs.join("var/cache/depot/sources"), + abs_rootfs.join("var/cache/depot/packages"), abs_rootfs.join("var/cache/depot/build"), abs_rootfs.join("var/lib/depot"), ) @@ -69,8 +262,12 @@ impl Config { build_overrides: toml::Value::Table(toml::map::Map::new()), package_overrides: toml::Value::Table(toml::map::Map::new()), appends: HashMap::new(), + repo_settings: RepoSettings::default(), + source_repos: BTreeMap::new(), + binary_repos: BTreeMap::new(), mirrors: std::collections::HashMap::new(), repo_clone_dir: abs_rootfs.join("usr/src/depot"), + package_cache_dir, }; if let Err(e) = config.load_system(&abs_rootfs) { @@ -185,7 +382,27 @@ impl Config { } } - // Load mirrors file: /etc/depot.d/mirrors.toml + // Load new repos file: /etc/depot.d/repos.toml (host fallback then rootfs override). + let host_repos_path = PathBuf::from("/etc/depot.d/repos.toml"); + let repos_path = rootfs.join("etc/depot.d/repos.toml"); + for path in [host_repos_path, repos_path] { + if !path.exists() { + continue; + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read repos config: {}", path.display()))?; + let parsed: RepoConfigFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse repos config: {}", path.display()))?; + self.repo_settings = parsed.settings; + for (name, repo) in parsed.source { + self.source_repos.insert(name, repo); + } + for (name, repo) in parsed.binary { + self.binary_repos.insert(name, repo); + } + } + + // Load legacy mirrors file: /etc/depot.d/mirrors.toml let mirrors_path = rootfs.join("etc/depot.d/mirrors.toml"); if mirrors_path.exists() { let content = fs::read_to_string(&mirrors_path).with_context(|| { @@ -196,6 +413,12 @@ impl Config { for (k, v) in table { if let Some(s) = v.as_str() { self.mirrors.insert(k.clone(), s.to_string()); + self.source_repos.entry(k.clone()).or_insert(SourceRepo { + url: s.to_string(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }); } } } @@ -213,6 +436,17 @@ impl Config { Ok(()) } + + /// Return enabled source repos as `name -> git URL` pairs for legacy sync/status code. + pub fn enabled_source_mirror_map(&self) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + for (name, repo) in &self.source_repos { + if repo.enabled { + out.insert(name.clone(), repo.url.clone()); + } + } + out + } } fn merge_toml_values(base: &mut toml::Value, over: &toml::Value) { @@ -460,4 +694,87 @@ cflags = ["-O3"] Some(1) ); } + + #[test] + fn test_load_repos_toml() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let etc = root.join("etc/depot.d"); + fs::create_dir_all(&etc).unwrap(); + + fs::write( + etc.join("repos.toml"), + r#" +[settings] +prefer_binary = true + +[source.vertex] +url = "https://gitlab.com/vertex-linux/packages.git" +enabled = true +priority = 10 +subdirs = ["core", "extra"] + +[binary.vertex] +url = "https://repo.example.invalid" +enabled = false +priority = 5 +repo_db = "repo.db.zst" + +[binary.vertex.arch.x86_64] +enabled = true +"#, + ) + .unwrap(); + + let config = Config::for_rootfs(root); + assert!(config.repo_settings.prefer_binary); + let src = config.source_repos.get("vertex").unwrap(); + assert_eq!(src.url, "https://gitlab.com/vertex-linux/packages.git"); + assert_eq!(src.subdirs, vec!["core".to_string(), "extra".to_string()]); + let bin = config.binary_repos.get("vertex").unwrap(); + assert_eq!(bin.url, "https://repo.example.invalid"); + assert!(!bin.enabled); + assert!(bin.arch.contains_key("x86_64")); + assert!(bin.supports_arch("x86_64")); + assert!(!bin.supports_arch("aarch64")); + } + + #[test] + fn test_save_and_load_repos_config_file_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let mut repos = RepoConfigFile::default(); + repos.source.insert( + "vertex".to_string(), + SourceRepo { + url: "https://gitlab.com/vertex-linux/packages.git".to_string(), + enabled: true, + priority: 1, + subdirs: vec!["core".to_string()], + }, + ); + repos.binary.insert( + "vertex".to_string(), + BinaryRepo { + url: "https://repo.example.invalid".to_string(), + enabled: true, + priority: 2, + repo_db: "repo.db.zst".to_string(), + arch: { + let mut map = BTreeMap::new(); + map.insert("x86_64".to_string(), BinaryRepoArchEntry::default()); + map + }, + allow_unsigned: false, + }, + ); + + let path = save_repos_config_file(root, &repos).unwrap(); + assert!(path.exists()); + + let loaded = load_repos_config_file(root).unwrap(); + assert!(loaded.source.contains_key("vertex")); + assert!(loaded.binary.contains_key("vertex")); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 4894b6e..a9159f9 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -751,3 +751,33 @@ pub fn get_package_version(db_path: &Path, name: &str) -> Result> .ok(); Ok(version) } + +/// Find the installed package that owns a filesystem path from the local DB. +pub fn owns_path(db_path: &Path, path: &Path) -> Result> { + if !db_path.exists() { + return Ok(None); + } + + let normalized = path + .to_string_lossy() + .trim_start_matches('/') + .trim_start_matches("./") + .to_string(); + if normalized.is_empty() { + return Ok(None); + } + + let conn = Connection::open(db_path)?; + let owner = conn + .query_row( + "SELECT p.name + FROM files f + JOIN packages p ON p.id = f.package_id + WHERE f.path = ?1 + LIMIT 1", + params![normalized], + |row| row.get(0), + ) + .ok(); + Ok(owner) +} diff --git a/src/db/repo.rs b/src/db/repo.rs index ffe5063..fd9f4cd 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use std::fs; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use zstd::stream::write::Encoder; @@ -27,6 +28,47 @@ pub struct RepoManager { pub repo_dir: PathBuf, } +/// Search hit returned from a cached binary repository database. +#[derive(Debug, Clone)] +pub struct BinaryRepoSearchHit { + pub repo_name: String, + pub name: String, + pub version: String, + pub revision: u32, + pub description: Option, + pub filename: String, + pub size: u64, + pub provides: Vec, +} + +/// Exact package record from a binary repository database, including checksums +/// used to verify the downloaded package archive. +#[derive(Debug, Clone)] +pub struct BinaryRepoPackageRecord { + pub repo_name: String, + pub name: String, + pub version: String, + pub revision: u32, + pub filename: String, + pub size: u64, + pub sha256: String, + pub sha512: String, + pub description: Option, + pub provides: Vec, + pub runtime_dependencies: Vec, +} + +/// File search hit returned from a cached binary repo database. +#[derive(Debug, Clone)] +pub struct BinaryRepoFileSearchHit { + pub repo_name: String, + pub package_name: String, + pub version: String, + pub revision: u32, + pub path: String, + pub size: u64, +} + impl RepoManager { pub fn new(repo_dir: PathBuf) -> Self { Self { repo_dir } @@ -79,15 +121,30 @@ impl RepoManager { license TEXT, filename TEXT NOT NULL, size INTEGER NOT NULL, - sha256 TEXT NOT NULL + sha256 TEXT NOT NULL, + sha512 TEXT NOT NULL ); CREATE TABLE provides ( package_id INTEGER, name TEXT NOT NULL, FOREIGN KEY(package_id) REFERENCES packages(id) ); + CREATE TABLE dependencies ( + package_id INTEGER, + kind TEXT NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE files ( + package_id INTEGER, + path TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); CREATE INDEX idx_packages_name ON packages(name); - CREATE INDEX idx_provides_name ON provides(name);", + CREATE INDEX idx_provides_name ON provides(name); + CREATE INDEX idx_dependencies_name ON dependencies(name); + CREATE INDEX idx_dependencies_kind ON dependencies(kind); + CREATE INDEX idx_repo_files_path ON files(path);", ) .context("Failed to initialize repo schema")?; Ok(()) @@ -98,7 +155,7 @@ impl RepoManager { let filename = pkg_path.file_name().unwrap().to_string_lossy(); let size = pkg_path.metadata()?.len(); - let sha256 = self.calculate_sha256(pkg_path)?; + let (sha256, sha512) = self.calculate_hashes(pkg_path)?; // Read .metadata.toml from archive let file = fs::File::open(pkg_path)?; @@ -112,11 +169,14 @@ impl RepoManager { let mut homepage = None; let mut license = None; let mut provides = Vec::new(); + let mut runtime_dependencies = Vec::new(); + let mut archive_files = Vec::new(); for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?; - if path.to_string_lossy() == ".metadata.toml" { + let path_str = path.to_string_lossy().to_string(); + if path_str == ".metadata.toml" { let mut content = String::new(); use std::io::Read; entry.read_to_string(&mut content)?; @@ -155,7 +215,26 @@ impl RepoManager { .map(String::from) .collect(); } - break; + if let Some(runtime_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("runtime")) + .and_then(|v| v.as_array()) + { + runtime_dependencies = runtime_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + continue; + } + + if entry.header().entry_type().is_file() { + let normalized = path_str.trim_start_matches("./").to_string(); + if normalized == ".metadata.toml" { + continue; + } + archive_files.push(normalized); } } @@ -175,8 +254,8 @@ impl RepoManager { // Insert into database conn.execute( - "INSERT INTO packages (name, version, revision, description, homepage, license, filename, size, sha256) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO packages (name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ name, version, @@ -186,7 +265,8 @@ impl RepoManager { license, filename, size as i64, - sha256 + sha256, + sha512 ], )?; @@ -200,15 +280,41 @@ impl RepoManager { )?; } + for dep in runtime_dependencies { + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'runtime', ?2)", + params![package_id, dep], + )?; + } + + for file_path in archive_files { + conn.execute( + "INSERT INTO files (package_id, path) VALUES (?1, ?2)", + params![package_id, file_path], + )?; + } + Ok(()) } - fn calculate_sha256(&self, path: &Path) -> Result { - use sha2::{Digest, Sha256}; + fn calculate_hashes(&self, path: &Path) -> Result<(String, String)> { + use sha2::{Digest, Sha256, Sha512}; let mut file = fs::File::open(path)?; - let mut hasher = Sha256::new(); - std::io::copy(&mut file, &mut hasher)?; - Ok(format!("{:x}", hasher.finalize())) + let mut sha256 = Sha256::new(); + let mut sha512 = Sha512::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + sha256.update(&buf[..n]); + sha512.update(&buf[..n]); + } + Ok(( + format!("{:x}", sha256.finalize()), + format!("{:x}", sha512.finalize()), + )) } fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> { @@ -222,6 +328,641 @@ impl RepoManager { } } +fn binary_repo_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { + package_cache_dir.join("repos").join(repo_name) +} + +fn binary_repo_packages_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { + binary_repo_cache_dir(package_cache_dir, repo_name).join("packages") +} + +fn join_repo_url(base: &str, rel: &str) -> Result { + let base = if base.ends_with('/') { + base.to_string() + } else { + format!("{base}/") + }; + let url = url::Url::parse(&base).with_context(|| format!("Invalid repo URL: {base}"))?; + Ok(url + .join(rel) + .with_context(|| format!("Invalid repo db path '{}'", rel))? + .to_string()) +} + +fn decompress_zstd_file(src: &Path, dst: &Path) -> Result<()> { + let mut input = + fs::File::open(src).with_context(|| format!("Failed to open {}", src.display()))?; + let mut decoder = zstd::stream::read::Decoder::new(&mut input) + .with_context(|| format!("Failed to open zstd decoder for {}", src.display()))?; + let tmp = dst.with_extension("tmp"); + let mut output = + fs::File::create(&tmp).with_context(|| format!("Failed to create {}", tmp.display()))?; + std::io::copy(&mut decoder, &mut output) + .with_context(|| format!("Failed to decompress {}", src.display()))?; + output + .flush() + .with_context(|| format!("Failed to flush {}", tmp.display()))?; + fs::rename(&tmp, dst) + .with_context(|| format!("Failed to move {} to {}", tmp.display(), dst.display()))?; + Ok(()) +} + +/// Fetch (or refresh) a binary repo `repo.db.zst` into the configured package cache. +/// +/// Returns the path to the decompressed SQLite database file. +pub fn fetch_binary_repo_db( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, +) -> Result { + let machine_arch = std::env::consts::ARCH; + let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + let repo_db_rel = repo + .effective_repo_db_for_arch(machine_arch) + .with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + + let cache_dir = binary_repo_cache_dir(package_cache_dir, repo_name); + fs::create_dir_all(&cache_dir) + .with_context(|| format!("Failed to create {}", cache_dir.display()))?; + + let repo_db_zst = cache_dir.join("repo.db.zst"); + let repo_db_sig = cache_dir.join("repo.db.zst.sig"); + let repo_db_sqlite = cache_dir.join("repo.db"); + let tmp_zst = cache_dir.join("repo.db.zst.tmp"); + let tmp_sig = cache_dir.join("repo.db.zst.sig.tmp"); + + let repo_db_url = join_repo_url(base_url, repo_db_rel)?; + let repo_sig_url = join_repo_url(base_url, &format!("{}.sig", repo_db_rel))?; + crate::log_info!( + "Fetching binary repo DB for '{}' from {}", + repo_name, + repo_db_url + ); + + let client = reqwest::blocking::Client::builder() + .build() + .context("Failed to build HTTP client for binary repo fetch")?; + let resp = client + .get(&repo_db_url) + .send() + .with_context(|| format!("Failed to fetch {}", repo_db_url))?; + + if !resp.status().is_success() { + if repo_db_sqlite.exists() { + crate::log_warn!( + "Failed to refresh binary repo '{}' (HTTP {}), using cached DB", + repo_name, + resp.status() + ); + return Ok(repo_db_sqlite); + } + anyhow::bail!("Failed to fetch {}: HTTP {}", repo_db_url, resp.status()); + } + + let mut resp = resp; + let mut out = fs::File::create(&tmp_zst) + .with_context(|| format!("Failed to create {}", tmp_zst.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", tmp_zst.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", tmp_zst.display()))?; + + let sig_resp = client + .get(&repo_sig_url) + .send() + .with_context(|| format!("Failed to fetch {}", repo_sig_url))?; + let sig_downloaded = if sig_resp.status().is_success() { + let mut sig_resp = sig_resp; + let mut sig_out = fs::File::create(&tmp_sig) + .with_context(|| format!("Failed to create {}", tmp_sig.display()))?; + std::io::copy(&mut sig_resp, &mut sig_out) + .with_context(|| format!("Failed to save {}", tmp_sig.display()))?; + sig_out + .flush() + .with_context(|| format!("Failed to flush {}", tmp_sig.display()))?; + true + } else { + if !repo.allow_unsigned { + anyhow::bail!( + "Failed to fetch detached signature for binary repo '{}' (HTTP {}): {}", + repo_name, + sig_resp.status(), + repo_sig_url + ); + } + crate::log_warn!( + "Binary repo '{}' has no detached signature (HTTP {}) for {}; allow_unsigned=true so continuing", + repo_name, + sig_resp.status(), + repo_db_url + ); + false + }; + + if sig_downloaded { + let keys = crate::signing::locate_keys(rootfs)?; + if keys.public_key.is_none() { + if !repo.allow_unsigned { + anyhow::bail!( + "No minisign public key found for verifying binary repo '{}' (checked rootfs and host)", + repo_name + ); + } + crate::log_warn!( + "No minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true", + repo_name + ); + } else { + crate::signing::verify_zst_file_detached(rootfs, &tmp_zst, &tmp_sig).with_context( + || { + format!( + "Failed to verify detached signature for binary repo '{}'", + repo_name + ) + }, + )?; + crate::log_info!( + "Verified detached signature for binary repo '{}'", + repo_name + ); + } + } + + fs::rename(&tmp_zst, &repo_db_zst).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_zst.display(), + repo_db_zst.display() + ) + })?; + if sig_downloaded { + fs::rename(&tmp_sig, &repo_db_sig).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_sig.display(), + repo_db_sig.display() + ) + })?; + } else if repo_db_sig.exists() { + let _ = fs::remove_file(&repo_db_sig); + } + + decompress_zstd_file(&repo_db_zst, &repo_db_sqlite)?; + Ok(repo_db_sqlite) +} + +/// Search a cached binary repository SQLite DB by package name or provided feature. +pub fn search_cached_binary_repo_db( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let like = format!("%{}%", query.to_ascii_lowercase()); + let mut stmt = conn.prepare( + "SELECT + p.name, + p.version, + p.revision, + p.description, + p.filename, + p.size, + GROUP_CONCAT(DISTINCT pr_all.name) + FROM packages p + LEFT JOIN provides pr_all ON pr_all.package_id = p.id + WHERE lower(p.name) LIKE ?1 + OR EXISTS ( + SELECT 1 FROM provides pr + WHERE pr.package_id = p.id + AND lower(pr.name) LIKE ?1 + ) + GROUP BY p.id + ORDER BY + CASE + WHEN lower(p.name) = lower(?2) THEN 0 + WHEN lower(p.name) LIKE lower(?3) THEN 1 + ELSE 2 + END, + p.name ASC", + )?; + + let starts = format!("{}%", query.to_ascii_lowercase()); + let rows = stmt.query_map(params![like, query, starts], |row| { + let provides_csv: Option = row.get(6)?; + Ok(BinaryRepoSearchHit { + repo_name: repo_name.to_string(), + name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + description: row.get(3)?, + filename: row.get(4)?, + size: row.get::<_, i64>(5)? as u64, + provides: provides_csv + .map(|s| { + s.split(',') + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect::>() + }) + .unwrap_or_default(), + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Fetch and search a binary repo by name or provide. +pub fn search_binary_repo( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + search_cached_binary_repo_db(repo_name, &db_path, query) +} + +/// Search a cached binary repo DB by file path substring. +pub fn search_cached_binary_repo_files( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_files_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_files_table { + return Ok(Vec::new()); + } + + let like = format!("%{}%", query.to_ascii_lowercase()); + let mut stmt = conn.prepare( + "SELECT p.name, p.version, p.revision, f.path, p.size + FROM files f + JOIN packages p ON p.id = f.package_id + WHERE lower(f.path) LIKE ?1 + ORDER BY p.name ASC, f.path ASC", + )?; + let rows = stmt.query_map(params![like], |row| { + Ok(BinaryRepoFileSearchHit { + repo_name: repo_name.to_string(), + package_name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + path: row.get(3)?, + size: row.get::<_, i64>(4)? as u64, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Fetch and search a binary repo by file path substring. +pub fn search_binary_repo_files( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + search_cached_binary_repo_files(repo_name, &db_path, query) +} + +/// Find the package(s) that own a file path in a cached binary repo DB. +pub fn cached_binary_repo_owns_path( + repo_name: &str, + db_path: &Path, + path: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_files_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_files_table { + return Ok(Vec::new()); + } + + let normalized = path.trim_start_matches('/').trim_start_matches("./"); + let mut stmt = conn.prepare( + "SELECT p.name, p.version, p.revision, f.path, p.size + FROM files f + JOIN packages p ON p.id = f.package_id + WHERE f.path = ?1 + ORDER BY p.name ASC", + )?; + let rows = stmt.query_map(params![normalized], |row| { + Ok(BinaryRepoFileSearchHit { + repo_name: repo_name.to_string(), + package_name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + path: row.get(3)?, + size: row.get::<_, i64>(4)? as u64, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Fetch repo metadata and resolve file ownership in a binary repo. +pub fn binary_repo_owns_path( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + path: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + cached_binary_repo_owns_path(repo_name, &db_path, path) +} + +fn query_package_provides(conn: &Connection, package_id: i64) -> Result> { + let mut stmt = conn.prepare("SELECT name FROM provides WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +fn query_package_runtime_deps(conn: &Connection, package_id: i64) -> Result> { + let has_dependencies_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_dependencies_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare( + "SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'runtime' ORDER BY name", + )?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +fn find_cached_binary_repo_packages( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let mut stmt = conn.prepare( + "SELECT + p.id, + p.name, + p.version, + p.revision, + p.filename, + p.size, + p.sha256, + p.sha512, + p.description + FROM packages p + WHERE lower(p.name) = lower(?1) + OR EXISTS ( + SELECT 1 FROM provides pr + WHERE pr.package_id = p.id + AND lower(pr.name) = lower(?1) + ) + ORDER BY + CASE WHEN lower(p.name) = lower(?1) THEN 0 ELSE 1 END, + p.name ASC", + )?; + + let rows = stmt.query_map(params![query], |row| { + let package_id = row.get::<_, i64>(0)?; + Ok(( + package_id, + BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: row.get(1)?, + version: row.get(2)?, + revision: row.get::<_, i64>(3)? as u32, + filename: row.get(4)?, + size: row.get::<_, i64>(5)? as u64, + sha256: row.get(6)?, + sha512: row.get(7)?, + description: row.get(8)?, + provides: Vec::new(), + runtime_dependencies: Vec::new(), + }, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (package_id, mut rec) = row?; + rec.provides = query_package_provides(&conn, package_id)?; + rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + out.push(rec); + } + Ok(out) +} + +/// Resolve an exact package name/provide match from a binary repo after verifying +/// and caching its signed `repo.db.zst`. +pub fn find_binary_repo_package( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + let mut matches = find_cached_binary_repo_packages(repo_name, &db_path, query)?; + if matches.len() > 1 { + crate::log_warn!( + "Multiple binary packages matched '{}' in repo '{}'; using the first match", + query, + repo_name + ); + } + Ok(matches.drain(..).next()) +} + +/// Resolve exact package name/provide matches from a binary repo. +pub fn find_binary_repo_packages( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + find_cached_binary_repo_packages(repo_name, &db_path, query) +} + +fn verify_hex_digest(path: &Path, algorithm: &str, expected_hex: &str) -> Result { + let expected = expected_hex.trim().to_ascii_lowercase(); + if expected.is_empty() { + return Ok(false); + } + + let mut file = + fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let mut buf = [0u8; 64 * 1024]; + + let actual = match algorithm { + "sha256" => { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + format!("{:x}", h.finalize()) + } + "sha512" => { + use sha2::{Digest, Sha512}; + let mut h = Sha512::new(); + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + format!("{:x}", h.finalize()) + } + _ => anyhow::bail!("Unsupported checksum algorithm: {}", algorithm), + }; + + Ok(actual == expected) +} + +fn verify_binary_package_record_checksums( + path: &Path, + rec: &BinaryRepoPackageRecord, +) -> Result<()> { + if !verify_hex_digest(path, "sha256", &rec.sha256)? { + anyhow::bail!( + "SHA-256 mismatch for {} from repo '{}'", + path.display(), + rec.repo_name + ); + } + if !verify_hex_digest(path, "sha512", &rec.sha512)? { + anyhow::bail!( + "SHA-512 mismatch for {} from repo '{}'", + path.display(), + rec.repo_name + ); + } + Ok(()) +} + +/// Download a binary package archive and verify it against checksums from the +/// signed repository database metadata. +pub fn fetch_binary_package_archive( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, +) -> Result { + let machine_arch = std::env::consts::ARCH; + let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + + let cache_dir = binary_repo_packages_cache_dir(package_cache_dir, repo_name); + fs::create_dir_all(&cache_dir) + .with_context(|| format!("Failed to create {}", cache_dir.display()))?; + let dest_path = cache_dir.join(&rec.filename); + let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename)); + + if dest_path.exists() { + verify_binary_package_record_checksums(&dest_path, rec).with_context(|| { + format!( + "Cached binary package failed checksum verification: {}", + dest_path.display() + ) + })?; + crate::log_info!("Using cached binary package: {}", dest_path.display()); + return Ok(dest_path); + } + + let pkg_url = join_repo_url(base_url, &rec.filename)?; + crate::log_info!("Fetching binary package: {}", pkg_url); + + let client = reqwest::blocking::Client::builder() + .build() + .context("Failed to build HTTP client for binary package fetch")?; + let mut resp = client + .get(&pkg_url) + .send() + .with_context(|| format!("Failed to fetch {}", pkg_url))?; + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch {}: HTTP {}", pkg_url, resp.status()); + } + + let mut out = fs::File::create(&tmp_path) + .with_context(|| format!("Failed to create {}", tmp_path.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", tmp_path.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; + + verify_binary_package_record_checksums(&tmp_path, rec).with_context(|| { + format!( + "Downloaded binary package failed checksum verification: {}", + rec.filename + ) + })?; + + fs::rename(&tmp_path, &dest_path).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_path.display(), + dest_path.display() + ) + })?; + Ok(dest_path) +} + /// Synchronize git mirrors into /usr/src/depot/ pub fn sync_mirrors( repo_dir: &std::path::Path, @@ -402,6 +1143,18 @@ mod tests { ) .unwrap(); assert!(exists); + + let has_sha512: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'sha512'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap(); + assert!(has_sha512); } #[test] @@ -443,16 +1196,18 @@ runtime = [] manager.init_repo_schema(&mut conn).unwrap(); manager.index_package(&mut conn, &pkg_path).unwrap(); - let (name, version, revision, desc, home, lic): ( + let (name, version, revision, desc, home, lic, sha256, sha512): ( String, String, i64, Option, Option, Option, + String, + String, ) = conn .query_row( - "SELECT name, version, revision, description, homepage, license FROM packages", + "SELECT name, version, revision, description, homepage, license, sha256, sha512 FROM packages", [], |r| { Ok(( @@ -462,6 +1217,8 @@ runtime = [] r.get(3)?, r.get(4)?, r.get(5)?, + r.get(6)?, + r.get(7)?, )) }, ) @@ -473,6 +1230,8 @@ runtime = [] assert_eq!(desc, Some("test description".to_string())); assert_eq!(home, Some("https://example.com".to_string())); assert_eq!(lic, Some("MIT".to_string())); + assert_eq!(sha256.len(), 64); + assert_eq!(sha512.len(), 128); let provides_count: i64 = conn .query_row("SELECT count(*) FROM provides", [], |r| r.get(0)) @@ -516,4 +1275,105 @@ license = ["MIT", "Apache-2.0"] .unwrap(); assert_eq!(lic, Some("MIT, Apache-2.0".to_string())); } + + #[test] + fn test_search_cached_binary_repo_db_matches_name_and_provides() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO provides (package_id, name) VALUES (1, 'libfoo.so')", + [], + ) + .unwrap(); + drop(conn); + + let name_hits = search_cached_binary_repo_db("testrepo", &db_path, "foo").unwrap(); + assert_eq!(name_hits.len(), 1); + assert_eq!(name_hits[0].name, "foo"); + assert_eq!(name_hits[0].repo_name, "testrepo"); + assert!(name_hits[0].provides.iter().any(|p| p == "libfoo.so")); + + let provide_hits = search_cached_binary_repo_db("testrepo", &db_path, "libfoo").unwrap(); + assert_eq!(provide_hits.len(), 1); + assert_eq!(provide_hits[0].name, "foo"); + } + + #[test] + fn test_find_cached_binary_repo_package_prefers_exact_name() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.0', 1, NULL, NULL, NULL, 'foo-1.0-1.depot.pkg.tar.zst', 10, 'aa', 'bb')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (2, 'bar', '1.0', 1, NULL, NULL, NULL, 'bar-1.0-1.depot.pkg.tar.zst', 10, 'cc', 'dd')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO provides (package_id, name) VALUES (2, 'foo')", + [], + ) + .unwrap(); + drop(conn); + + let recs = find_cached_binary_repo_packages("repo", &db_path, "foo").unwrap(); + let rec = recs.first().expect("expected a match"); + assert_eq!(rec.name, "foo"); + assert_eq!(rec.filename, "foo-1.0-1.depot.pkg.tar.zst"); + } + + #[test] + fn test_verify_binary_package_record_checksums_accepts_valid_hashes() { + use sha2::{Digest, Sha256, Sha512}; + + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("pkg.depot.pkg.tar.zst"); + fs::write(&pkg, b"payload").unwrap(); + + let sha256 = { + let mut h = Sha256::new(); + h.update(b"payload"); + format!("{:x}", h.finalize()) + }; + let sha512 = { + let mut h = Sha512::new(); + h.update(b"payload"); + format!("{:x}", h.finalize()) + }; + + let rec = BinaryRepoPackageRecord { + repo_name: "repo".into(), + name: "pkg".into(), + version: "1.0".into(), + revision: 1, + filename: "pkg.depot.pkg.tar.zst".into(), + size: 7, + sha256, + sha512, + description: None, + provides: Vec::new(), + runtime_dependencies: Vec::new(), + }; + + verify_binary_package_record_checksums(&pkg, &rec).unwrap(); + } } diff --git a/src/deps.rs b/src/deps.rs index 5396d66..3fddef9 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -78,6 +78,11 @@ fn parse_dep(dep: &str) -> ParsedDep<'_> { } } +/// Return the dependency name portion without any version/operator suffix. +pub fn dep_name(dep: &str) -> &str { + parse_dep(dep).name +} + /// Compare two version strings using semver if possible, fallback to string compare fn compare_versions(installed: &str, required: &str, op: VersionOp) -> bool { // Try semver comparison first @@ -134,6 +139,17 @@ fn is_dep_satisfied( } } +/// Check whether a dependency expression is satisfied by the installed package DB. +pub fn is_dep_satisfied_in_db(dep: &str, db_path: &Path) -> Result { + if !db_path.exists() { + return Ok(false); + } + + let installed = db::get_installed_packages(db_path)?; + let provides = db::get_all_provides(db_path)?; + is_dep_satisfied(dep, &installed, &provides, db_path) +} + /// Check if all build dependencies are satisfied pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result> { let mut missing = Vec::new(); diff --git a/src/index.rs b/src/index.rs index 3365bdd..21ff1f7 100755 --- a/src/index.rs +++ b/src/index.rs @@ -16,11 +16,18 @@ pub struct PackageIndex { by_provides: HashMap>, } +/// Source package search result from `PackageIndex`. +#[derive(Debug, Clone)] +pub struct SourceSearchHit { + pub name: String, + pub path: PathBuf, + pub provides: Vec, +} + impl PackageIndex { /// Build index by scanning packages/*/*.toml and configured repo dir. /// /// Use `build_with_repo_dir` to provide an explicit repo dir. - /// Build index scanning the local `packages/` directory and an optional /// system repo dir (e.g., /usr/src/depot). If `repo_dir` is None, the /// default `/usr/src/depot` is used. @@ -116,4 +123,40 @@ impl PackageIndex { None } + + /// Return all source specs that provide the requested feature/package name. + pub fn find_providers(&self, name: &str) -> Vec { + self.by_provides.get(name).cloned().unwrap_or_default() + } + + /// Search indexed specs by package name or provided feature. + pub fn search(&self, query: &str) -> Vec { + let q = query.to_ascii_lowercase(); + let mut provides_by_path: HashMap> = HashMap::new(); + for (provided, paths) in &self.by_provides { + for path in paths { + provides_by_path + .entry(path.clone()) + .or_default() + .push(provided.clone()); + } + } + + let mut hits = Vec::new(); + for (name, path) in &self.by_name { + let provides = provides_by_path.remove(path).unwrap_or_default(); + let name_match = name.to_ascii_lowercase().contains(&q); + let provides_match = provides.iter().any(|p| p.to_ascii_lowercase().contains(&q)); + if name_match || provides_match { + hits.push(SourceSearchHit { + name: name.clone(), + path: path.clone(), + provides, + }); + } + } + + hits.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path))); + hits + } } diff --git a/src/locking.rs b/src/locking.rs new file mode 100644 index 0000000..704d675 --- /dev/null +++ b/src/locking.rs @@ -0,0 +1,76 @@ +//! Rootfs-scoped advisory locking helpers. + +use crate::config::Config; +use anyhow::{Context, Result}; +use fd_lock::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::fs::{self, File, OpenOptions}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +/// Return the rootfs-scoped lock file path. +pub(crate) fn lock_path(config: &Config) -> PathBuf { + config.db_dir.join("lock") +} + +/// Open the rootfs-scoped lock file as an fd-lock reader/writer. +pub(crate) fn open_lock(config: &Config) -> Result> { + let path = lock_path(config); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create lock dir {}", parent.display()))?; + } + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("Failed to open lock file {}", path.display()))?; + Ok(RwLock::new(file)) +} + +/// Acquire a shared/read lock without blocking. +pub(crate) fn try_read<'a>( + lock: &'a RwLock, + path: &Path, + command_name: &str, +) -> Result> { + lock.try_read().map_err(|e| { + if e.kind() == ErrorKind::WouldBlock { + anyhow::anyhow!( + "Depot is busy (lock held by another process). Command '{}' needs a shared lock on {}", + command_name, + path.display() + ) + } else { + anyhow::anyhow!(e).context(format!( + "Failed to acquire shared lock for '{}' on {}", + command_name, + path.display() + )) + } + }) +} + +/// Acquire an exclusive/write lock without blocking. +pub(crate) fn try_write<'a>( + lock: &'a mut RwLock, + path: &Path, + command_name: &str, +) -> Result> { + lock.try_write().map_err(|e| { + if e.kind() == ErrorKind::WouldBlock { + anyhow::anyhow!( + "Depot is busy (lock held by another process). Command '{}' needs an exclusive lock on {}", + command_name, + path.display() + ) + } else { + anyhow::anyhow!(e).context(format!( + "Failed to acquire exclusive lock for '{}' on {}", + command_name, + path.display() + )) + } + }) +} diff --git a/src/main.rs b/src/main.rs index 248e1c4..9f97b63 100755 --- a/src/main.rs +++ b/src/main.rs @@ -9,14 +9,17 @@ mod deps; mod fakeroot; mod index; mod install; +mod locking; mod package; +mod planner; mod shell_helpers; +mod signing; mod source; mod staging; mod ui; use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use std::fs; use std::path::{Path, PathBuf}; @@ -153,6 +156,434 @@ fn install_staged_to_rootfs( Ok(()) } +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(",")) + }; + ui::info(format!( + " {} [{}] {}{}", + hit.name, + source_hit_origin(config, &hit.path), + hit.path.display(), + provides + )); + } + } + + 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 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 print_plan_summary(plan: &planner::ExecutionPlan) { + let summary = plan.summary(); + ui::info(format!( + "Plan summary: packages={} actions={} (binary_install={}, source_build_install={}, skip_installed={}) known_download={}", + summary.total_packages, + summary.binary_installs + summary.source_build_installs, + summary.binary_installs, + summary.source_build_installs, + summary.skipped_installed, + human_bytes(summary.known_download_bytes) + )); + for step in &plan.steps { + let (action, origin) = match &step.action { + planner::PlanAction::SkipInstalled => ("skip", "installed".to_string()), + planner::PlanAction::BuildAndInstall => match &step.origin { + planner::PlanOrigin::Source { + path, + local_sibling, + } => ( + "build+install", + if *local_sibling { + format!("source:local-sibling ({})", path.display()) + } else { + format!("source ({})", path.display()) + }, + ), + _ => ("build+install", "source".to_string()), + }, + planner::PlanAction::InstallBinary => match &step.origin { + planner::PlanOrigin::Binary { repo_name, record } => ( + "install", + format!( + "binary:{} {}-{} size={}", + repo_name, + record.version, + record.revision, + human_bytes(record.size) + ), + ), + _ => ("install", "binary".to_string()), + }, + }; + ui::info(format!(" {} [{}] {}", step.package, action, origin)); + } +} + +fn execute_install_plan_with_child_commands( + plan: &planner::ExecutionPlan, + rootfs: &Path, + no_flags: bool, + cross_prefix: Option<&str>, + clean: bool, + dry_run: bool, + config: &config::Config, +) -> Result<()> { + let summary = plan.summary(); + if summary.source_build_installs > 0 + && !ui::prompt_yes_no( + &format!( + "Plan will build {} package(s) from source before install. Continue?", + summary.source_build_installs + ), + true, + )? + { + anyhow::bail!("Aborted"); + } + + if plan.actionable_steps().next().is_none() { + ui::info("Nothing to do."); + return Ok(()); + } + + if !ui::prompt_yes_no("Proceed with executing install plan?", true)? { + anyhow::bail!("Aborted"); + } + + if dry_run { + ui::info("Dry run enabled, no install/build actions executed."); + return Ok(()); + } + + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + + for step in plan.actionable_steps() { + let input_path = match &step.origin { + planner::PlanOrigin::Source { path, .. } => path.clone(), + planner::PlanOrigin::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, + record, + &config.package_cache_dir, + )? + } + planner::PlanOrigin::Installed => continue, + }; + + ui::info(format!( + "Executing planned step: {} ({})", + step.package, + input_path.display() + )); + + let mut cmd = std::process::Command::new(&exe); + cmd.arg("-r").arg(rootfs); + cmd.arg("--no-deps"); + cmd.arg("--yes"); + if no_flags { + cmd.arg("--no-flags"); + } + if let Some(p) = cross_prefix { + cmd.arg("--cross-prefix").arg(p); + } + if clean { + cmd.arg("--clean"); + } + cmd.arg("install").arg(input_path); + + let status = cmd + .status() + .context("Failed to spawn planned install step")?; + if !status.success() { + anyhow::bail!("Planned install step for '{}' failed", step.package); + } + } + Ok(()) +} + #[derive(Parser)] #[command(name = "Depot")] #[command(about = "Depot - Source-based package manager for Linux", long_about = None)] @@ -185,6 +616,14 @@ struct Cli { #[arg(long, global = true)] clean: bool, + /// Automatically answer yes to prompts and pick the default provider choice + #[arg(long, short = 'y', global = true)] + yes: bool, + + /// Show what would happen without performing builds/installs + #[arg(long, global = true)] + dry_run: bool, + #[command(subcommand)] command: Commands, } @@ -225,8 +664,26 @@ enum Commands { /// Path to package spec or installed package name package: String, }, + /// Search configured source and binary repos by package name or provides + Search { + /// Search query + query: String, + /// Search repository file lists (binary repo metadata) by path substring + #[arg(long)] + files: bool, + }, + /// Show which installed package owns a filesystem path + Owns { + /// Path to query (absolute or relative to rootfs) + path: PathBuf, + }, /// List installed packages List, + /// Create a detached minisign signature for a .zst file + Sign { + /// Path to the .zst file to sign + file: PathBuf, + }, /// Repository management Repo { #[command(subcommand)] @@ -252,12 +709,83 @@ enum RepoCommands { }, /// Sync git mirrors configured in /etc/depot.d/mirrors.toml into /usr/src/depot Sync, + /// Sync source repos configured in /etc/depot.d/repos.toml into /usr/src/depot + Update { + /// Update only one source repo by name + name: Option, + }, + /// List configured source and binary repos + List, + /// Add or update a repo entry in /etc/depot.d/repos.toml + Add { + /// Repo name (e.g. vertex) + name: String, + /// Source git URL or binary repo base URL + url: String, + /// Repo kind + #[arg(long, value_enum, default_value_t = RepoKindArg::Source)] + kind: RepoKindArg, + /// Optional source repo subdirectory to index (repeatable) + #[arg(long = "subdir")] + subdirs: Vec, + /// Repo priority (lower = higher priority) + #[arg(long, default_value_t = 0)] + priority: i32, + /// Add repo as disabled + #[arg(long)] + disabled: bool, + /// Binary repo architecture table entry to add/update (defaults to this machine's arch) + #[arg(long)] + arch: Option, + /// Binary repo DB filename/path (relative to repo URL) + #[arg(long = "repo-db", default_value = "repo.db.zst")] + repo_db: String, + /// Allow unsigned repo metadata for this binary repo + #[arg(long)] + allow_unsigned: bool, + }, + /// Remove a repo entry from /etc/depot.d/repos.toml + Remove { + /// Repo name + name: String, + /// Repo kind (auto-detect if unique) + #[arg(long)] + kind: Option, + }, + /// Enable a repo entry in /etc/depot.d/repos.toml + Enable { + /// Repo name + name: String, + /// Repo kind (auto-detect if unique) + #[arg(long)] + kind: Option, + }, + /// Disable a repo entry in /etc/depot.d/repos.toml + Disable { + /// Repo name + name: String, + /// Repo kind (auto-detect if unique) + #[arg(long)] + kind: Option, + }, + /// Query binary repo metadata for the package that owns a file path + Owns { + /// Path to query (absolute or relative install path) + path: PathBuf, + }, /// Show status of configured git mirrors Status, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum RepoKindArg { + Source, + Binary, +} + fn main() -> Result<()> { let cli = Cli::parse(); + ui::set_assume_yes(cli.yes); match cli.command { Commands::Install { @@ -270,6 +798,55 @@ fn main() -> Result<()> { // Load configuration early so we can use the configured repo clone dir let config = config::Config::for_rootfs(&cli.rootfs); + if !cli.no_deps { + let request_path_like = spec_path.exists(); + let is_archive_request = request_path_like + && spec_path + .to_string_lossy() + .to_ascii_lowercase() + .ends_with(".tar.zst"); + if !is_archive_request { + let target = if request_path_like { + planner::InstallTarget::SpecPath(spec_path.clone()) + } else { + planner::InstallTarget::PackageName(spec_path.to_string_lossy().to_string()) + }; + let local_sibling_root = spec_path + .parent() + .and_then(|p| p.parent()) + .map(Path::to_path_buf); + let plan = planner::build_install_plan( + &config, + &cli.rootfs, + target, + planner::PlannerOptions { + assume_yes: cli.yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root, + }, + )?; + print_plan_summary(&plan); + execute_install_plan_with_child_commands( + &plan, + &cli.rootfs, + cli.no_flags, + cli.cross_prefix.as_deref(), + cli.clean, + cli.dry_run, + &config, + )?; + if cli.clean { + clean_build_workspace(&config)?; + } + return Ok(()); + } + } + + let mut install_lock = locking::open_lock(&config)?; + let install_lock_path = locking::lock_path(&config); + let _install_lock_guard = + locking::try_write(&mut install_lock, &install_lock_path, "install")?; + // Repo clone dir is available via `config.repo_clone_dir` and // is passed explicitly to index builders below. @@ -282,6 +859,54 @@ fn main() -> Result<()> { index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); if let Some(found) = pkg_index.find(&name) { spec_path = found; + } else { + 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))); + + for (repo_name, repo_cfg) in binary_repos { + match db::repo::find_binary_repo_package( + repo_name, + repo_cfg, + &cli.rootfs, + &config.package_cache_dir, + &name, + ) { + Ok(Some(rec)) => { + let archive = db::repo::fetch_binary_package_archive( + repo_name, + repo_cfg, + &rec, + &config.package_cache_dir, + )?; + ui::info(format!( + "Resolved '{}' from binary repo '{}' as {}-{} (package {}) ({} bytes){} -> {}", + name, + repo_name, + rec.version, + rec.revision, + rec.name, + rec.size, + rec.description + .as_ref() + .map(|d| format!(" [{}]", d)) + .unwrap_or_default(), + archive.display() + )); + spec_path = archive; + break; + } + Ok(None) => {} + Err(e) => { + crate::log_warn!("Binary repo '{}': {}", repo_name, e); + } + } + } } } @@ -413,6 +1038,23 @@ fn main() -> Result<()> { pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision )); + if cli.dry_run { + ui::info("Dry run enabled, stopping before install/build work."); + return Ok(()); + } + + if !ui::prompt_yes_no( + &format!( + "Proceed with install for {} v{}-{}?", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + ), + true, + )? { + anyhow::bail!("Aborted"); + } + + // TODO(snapper): create pre-install snapshot before install work starts. + // Ensure database directory exists std::fs::create_dir_all(&config.db_dir).with_context(|| { format!( @@ -575,6 +1217,7 @@ fn main() -> Result<()> { "Successfully installed {} v{}", spec_for_out.package.name, spec_for_out.package.version )); + // TODO(snapper): create post-install snapshot after install commit succeeds. } if cli.clean { @@ -584,6 +1227,10 @@ fn main() -> Result<()> { Commands::Remove { package } => { ui::info(format!("Removing package: {}", package)); let config = config::Config::for_rootfs(&cli.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.db_dir.join("packages.db"); let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package); let _ = install::scripts::run_hook_if_present( @@ -633,12 +1280,80 @@ fn main() -> Result<()> { let needs_test_deps = matches!(pkg_spec.build.build_type, package::BuildType::Autotools); deps::print_dep_status(&pkg_spec, &db_path)?; + let mut missing = deps::check_build_deps(&pkg_spec, &db_path)?; + if needs_test_deps { + for dep in deps::check_test_deps(&pkg_spec, &db_path)? { + if !missing.contains(&dep) { + missing.push(dep); + } + } + } + if !missing.is_empty() { + ui::warn(format!( + "Missing build dependencies: {}", + missing.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, + &cli.rootfs, + &missing, + planner::PlannerOptions { + assume_yes: cli.yes, + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root, + }, + )?; + print_plan_summary(&dep_plan); + if !ui::prompt_yes_no("Install missing build dependencies first?", true)? { + anyhow::bail!("Aborted"); + } + if cli.dry_run { + ui::info("Dry run enabled, stopping before dependency installation/build."); + return Ok(()); + } + execute_install_plan_with_child_commands( + &dep_plan, + &cli.rootfs, + cli.no_flags, + cli.cross_prefix.as_deref(), + cli.clean, + cli.dry_run, + &config, + )?; + } deps::require_build_deps(&pkg_spec, &db_path)?; if needs_test_deps { deps::require_test_deps(&pkg_spec, &db_path)?; } + } else if cli.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 !ui::prompt_yes_no( + &format!( + "Proceed with build for {} v{}-{}?", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + ), + true, + )? { + anyhow::bail!("Aborted"); + } + + if cli.dry_run { + ui::info("Dry run enabled, stopping before fetch/build."); + return Ok(()); + } + + // TODO(snapper): create pre-build snapshot before fetch/build starts. let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; let destdir = config @@ -682,14 +1397,26 @@ fn main() -> Result<()> { let packager = package::Packager::new(spec_for_out.clone(), out_destdir, config.clone()); let pkg_file = packager.create_package(Path::new("."), arch)?; + if let Some(sig_path) = + signing::auto_sign_zst_file_detached(&cli.rootfs, &pkg_file)? + { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } created_files.push(pkg_file); } for f in &created_files { ui::success(format!("Build complete. Package created: {}", f.display())); } + // TODO(snapper): create post-build snapshot after package build completes. if install { + if !ui::prompt_yes_no("Install built package(s) to rootfs now?", true)? { + anyhow::bail!("Aborted"); + } for out in pkg_spec.outputs() { let mut spec_for_out = pkg_spec.clone(); let output_name = out.name.clone(); @@ -703,6 +1430,7 @@ fn main() -> Result<()> { "Successfully installed {} v{}", spec_for_out.package.name, spec_for_out.package.version )); + // TODO(snapper): create post-install snapshot after --install commit succeeds. } } @@ -714,63 +1442,427 @@ fn main() -> Result<()> { // Try as file first, then as installed package name let path = PathBuf::from(&package); if path.exists() { + let config = config::Config::for_rootfs(&cli.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 config = config::Config::for_rootfs(&cli.rootfs); let db_path = config.db_dir.join("packages.db"); deps::print_dep_status(&pkg_spec, &db_path)?; } else { let config = config::Config::for_rootfs(&cli.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.db_dir.join("packages.db"); db::show_package_info(&db_path, &package)?; } } + Commands::Search { query, files } => { + let config = config::Config::for_rootfs(&cli.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, &cli.rootfs)?; + } + Commands::Owns { path } => { + let config = config::Config::for_rootfs(&cli.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.db_dir.join("packages.db"); + 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 => { let config = config::Config::for_rootfs(&cli.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.db_dir.join("packages.db"); db::list_packages(&db_path)?; } + Commands::Sign { file } => { + let sig_path = signing::sign_zst_file_detached(&cli.rootfs, &file)?; + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } Commands::Repo { command } => match command { RepoCommands::Create { dir } => { + let cfg = config::Config::for_rootfs(&cli.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(&cli.rootfs, &db_path)? + { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } ui::success(format!( "Created repository database: {}", db_path.display() )); } RepoCommands::Sync => { + let cfg = config::Config::for_rootfs(&cli.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 = config::Config::for_rootfs(&cli.rootfs); - if config.mirrors.is_empty() { - ui::info("No mirrors configured in /etc/depot.d/mirrors.toml"); + 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, &config.mirrors)?; + db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; ui::success(format!( - "Mirrors synchronized into {}", + "Source repos synchronized into {}", config.repo_clone_dir.display() )); } } + RepoCommands::Update { name } => { + let cfg = config::Config::for_rootfs(&cli.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::List => { + let config = config::Config::for_rootfs(&cli.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 { + name, + url, + kind, + subdirs, + priority, + disabled, + arch, + repo_db, + allow_unsigned, + } => { + let cfg = config::Config::for_rootfs(&cli.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(&cli.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(&cli.rootfs, &repos)?; + ui::success(format!( + "Saved {} repo '{}' to {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Remove { name, kind } => { + let cfg = config::Config::for_rootfs(&cli.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(&cli.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(&cli.rootfs, &repos)?; + ui::success(format!( + "Removed {} repo '{}' from {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Enable { name, kind } => { + let cfg = config::Config::for_rootfs(&cli.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(&cli.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(&cli.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(&cli.rootfs, &repos)?; + ui::success(format!( + "Enabled binary repo '{}' in {}", + name, + path.display() + )); + } + } + } + } + RepoCommands::Disable { name, kind } => { + let cfg = config::Config::for_rootfs(&cli.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(&cli.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(&cli.rootfs, &repos)?; + ui::success(format!( + "Disabled {} repo '{}' in {}", + repo_kind_label(kind), + name, + path.display() + )); + } + RepoCommands::Owns { path } => { + let config = config::Config::for_rootfs(&cli.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, + &cli.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 => { let config = config::Config::for_rootfs(&cli.rootfs); - if config.mirrors.is_empty() { - ui::info("No mirrors configured in /etc/depot.d/mirrors.toml"); + 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, &config.mirrors)?; + 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::Config => { let config = config::Config::for_rootfs(&cli.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!( + "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() { diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 04bdc3b..9ba67fa 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -651,6 +651,19 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.cxxflags.is_empty() { + flags_tbl.insert( + "cxxflags".into(), + Value::Array( + spec.build + .flags + .cxxflags + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !spec.build.flags.ldflags.is_empty() { flags_tbl.insert( "ldflags".into(), @@ -1213,6 +1226,7 @@ mod tests { flags.makefile_install_commands = vec!["make DESTDIR=$DESTDIR install".into()]; flags.cargs = vec!["--locked".into()]; flags.rustflags = vec!["-Ctarget-cpu=native".into()]; + flags.cxxflags = vec!["-O2".into(), "-fno-rtti".into()]; flags.target = "x86_64-unknown-linux-gnu".into(); flags.keep = vec!["etc/locale.gen".into()]; flags.no_flags = true; @@ -1266,6 +1280,7 @@ mod tests { assert!(toml.contains("makefile_install_commands = [")); assert!(toml.contains("cargs = [")); assert!(toml.contains("rustflags = [")); + assert!(toml.contains("cxxflags = [")); assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\"")); assert!(toml.contains("keep = [")); assert!(toml.contains("\"etc/locale.gen\"")); diff --git a/src/package/spec.rs b/src/package/spec.rs index 8b38dbe..a2449b2 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -58,12 +58,17 @@ impl PackageSpec { let content = fs::read_to_string(&abs_path) .with_context(|| format!("Failed to read package spec: {}", abs_path.display()))?; - let mut spec: PackageSpec = toml::from_str(&content) + let (base_content, appends) = + preprocess_spec_toml_appends(&content).with_context(|| { + format!("Failed to preprocess package spec: {}", abs_path.display()) + })?; + let mut spec: PackageSpec = toml::from_str(&base_content) .with_context(|| format!("Failed to parse package spec: {}", abs_path.display()))?; spec.spec_dir = abs_path .parent() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")); + spec.apply_spec_appends(&appends)?; // Require at least one source (remote or manual) if spec.source.is_empty() && spec.manual_sources.is_empty() { @@ -74,6 +79,28 @@ impl PackageSpec { Ok(spec) } + fn apply_spec_appends( + &mut self, + appends: &std::collections::HashMap>, + ) -> Result<()> { + for (key, values) in appends { + if let Some(subkey) = key.strip_prefix("build.flags.") { + self.apply_append(subkey, values); + continue; + } + if let Some(subkey) = key.strip_prefix("flags.") { + self.apply_append(subkey, values); + continue; + } + if !key.contains('.') { + self.apply_append(key, values); + continue; + } + anyhow::bail!("Unsupported '+=' key in package spec: {}", key); + } + Ok(()) + } + fn validate_manual_sources(&self) -> Result<()> { for (idx, manual) in self.manual_sources.iter().enumerate() { let has_file = manual @@ -206,6 +233,17 @@ impl PackageSpec { self.build.flags.cflags = vec![s.to_string()]; } } + "cxxflags" => { + if let Some(arr) = v.as_array() { + self.build.flags.cxxflags = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.cxxflags = vec![s.to_string()]; + } + } "ldflags" => { if let Some(arr) = v.as_array() { self.build.flags.ldflags = arr @@ -485,6 +523,18 @@ impl PackageSpec { } } } + "cxxflags" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .cxxflags + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.cxxflags.push(s.to_string()); + } + } + } "ldflags" => { for v in values { if let Some(arr) = v.as_array() { @@ -815,6 +865,74 @@ impl PackageSpec { } } +fn preprocess_spec_toml_appends( + input: &str, +) -> Result<(String, std::collections::HashMap>)> { + let mut base_text = String::new(); + let mut appends = std::collections::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(trimmed[2..trimmed.len() - 2].trim().to_string()); + 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()); + 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'); + continue; + } + + if let Some(plus_idx) = trimmed.find("+=") { + if in_array_table { + anyhow::bail!( + "'+=' is not supported inside array-of-table sections ({})", + current_table.as_deref().unwrap_or("") + ); + } + let 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")? + .get("v") + .cloned() + .unwrap(); + + let full_key = if key.contains('.') { + key.to_string() + } else if let Some(table) = current_table.as_deref() { + format!("{}.{}", table, key) + } else { + key.to_string() + }; + + appends.entry(full_key).or_insert_with(Vec::new).push(val); + // Preserve line numbering for parser diagnostics. + base_text.push('\n'); + continue; + } + + base_text.push_str(line); + base_text.push('\n'); + } + + Ok((base_text, appends)) +} + #[cfg(test)] mod spec_tests { use super::*; @@ -1223,6 +1341,7 @@ type = "custom" [flags] cc = "my-cc" cflags = ["-O2"] +cxxflags = ["-O2", "-pipe"] passthrough_env = ["RUSTFLAGS"] make_vars = ["V=1"] make_dirs = ["lib"] @@ -1242,6 +1361,10 @@ post_configure = ["echo configured"] "build.flags.cflags".to_string(), vec![toml::Value::String("-g".to_string())], ); + config.appends.insert( + "build.flags.cxxflags".to_string(), + vec![toml::Value::String("-stdlib=libc++".to_string())], + ); config.appends.insert( "build.flags.rustflags".to_string(), vec![toml::Value::Array(vec![ @@ -1301,6 +1424,14 @@ post_configure = ["echo configured"] assert_eq!(spec.build.flags.cc, "my-cc"); assert!(spec.build.flags.cflags.contains(&"-O2".to_string())); assert!(spec.build.flags.cflags.contains(&"-g".to_string())); + assert!(spec.build.flags.cxxflags.contains(&"-O2".to_string())); + assert!(spec.build.flags.cxxflags.contains(&"-pipe".to_string())); + assert!( + spec.build + .flags + .cxxflags + .contains(&"-stdlib=libc++".to_string()) + ); assert!(spec.build.flags.rustflags.contains(&"-C".to_string())); assert!( spec.build @@ -1656,6 +1787,51 @@ keep = ["etc/locale.gen", "etc/resolv.conf"] ); } + #[test] + fn parse_build_flags_appends_from_spec_file() { + 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] +cxxflags = ["-O2"] +cxxflags += [ "-Wno-gnu-statement-expression-from-macro-expansion" ] +ldflags += "-Wl,--as-needed" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.cxxflags, + vec![ + "-O2".to_string(), + "-Wno-gnu-statement-expression-from-macro-expansion".to_string() + ] + ); + assert_eq!( + spec.build.flags.ldflags, + vec!["-Wl,--as-needed".to_string()] + ); + } + #[test] fn parse_passthrough_env_from_spec() { let tmp = tempfile::tempdir().unwrap(); @@ -1829,8 +2005,12 @@ cbuild = "x86_64-pc-linux-gnu" .unwrap(), package_overrides: toml::Value::Table(toml::map::Map::new()), appends: std::collections::HashMap::new(), + repo_settings: crate::config::RepoSettings::default(), + source_repos: std::collections::BTreeMap::new(), + binary_repos: std::collections::BTreeMap::new(), mirrors: std::collections::HashMap::new(), repo_clone_dir: PathBuf::from("/tmp"), + package_cache_dir: PathBuf::from("/tmp"), }; spec.apply_config(&config); @@ -2124,6 +2304,9 @@ pub enum BuildType { pub struct BuildFlags { #[serde(default, deserialize_with = "deserialize_string_or_array")] pub cflags: Vec, + /// Extra flags exported to `CXXFLAGS`. + #[serde(default, deserialize_with = "deserialize_string_or_array")] + pub cxxflags: Vec, #[serde(default, deserialize_with = "deserialize_string_or_array")] pub ldflags: Vec, /// Keep existing files and install package-provided replacement as `.depotnew`. @@ -2328,6 +2511,7 @@ impl Default for BuildFlags { fn default() -> Self { BuildFlags { cflags: Vec::new(), + cxxflags: Vec::new(), ldflags: Vec::new(), keep: Vec::new(), no_flags: false, diff --git a/src/planner.rs b/src/planner.rs new file mode 100644 index 0000000..7710519 --- /dev/null +++ b/src/planner.rs @@ -0,0 +1,710 @@ +//! Dependency resolver and execution planner. +//! +//! Builds a recursive dependency closure for install/build operations, resolves +//! dependencies by exact package name or provided feature, and returns a +//! topologically sorted execution plan. + +use crate::config::Config; +use crate::db; +use crate::deps; +use crate::index::PackageIndex; +use crate::package::{BuildType, PackageSpec}; +use crate::ui; +use anyhow::{Context, Result}; +use petgraph::algo::toposort; +use petgraph::graph::{DiGraph, NodeIndex}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) enum PlanOrigin { + Installed, + Source { + path: PathBuf, + local_sibling: bool, + }, + Binary { + repo_name: String, + record: Box, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum PlanAction { + SkipInstalled, + BuildAndInstall, + InstallBinary, +} + +#[derive(Debug, Clone)] +pub(crate) struct PlannedStep { + pub package: String, + pub action: PlanAction, + pub origin: PlanOrigin, + pub requested_by: Vec, +} + +impl PlannedStep { + pub(crate) fn download_size(&self) -> Option { + match &self.origin { + PlanOrigin::Binary { record, .. } => Some(record.size), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PlanSummary { + pub total_packages: usize, + pub skipped_installed: usize, + pub binary_installs: usize, + pub source_build_installs: usize, + pub known_download_bytes: u64, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExecutionPlan { + pub steps: Vec, +} + +impl ExecutionPlan { + pub(crate) fn summary(&self) -> PlanSummary { + let mut out = PlanSummary { + total_packages: self.steps.len(), + ..PlanSummary::default() + }; + for step in &self.steps { + match step.action { + PlanAction::SkipInstalled => out.skipped_installed += 1, + PlanAction::InstallBinary => out.binary_installs += 1, + PlanAction::BuildAndInstall => out.source_build_installs += 1, + } + if let Some(n) = step.download_size() { + out.known_download_bytes = out.known_download_bytes.saturating_add(n); + } + } + out + } + + pub(crate) fn actionable_steps(&self) -> impl Iterator { + self.steps + .iter() + .filter(|s| !matches!(s.action, PlanAction::SkipInstalled)) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum InstallTarget { + PackageName(String), + SpecPath(PathBuf), +} + +#[derive(Debug, Clone)] +pub(crate) struct PlannerOptions { + pub assume_yes: bool, + pub prefer_binary: bool, + pub local_sibling_root: Option, +} + +#[derive(Debug, Clone)] +enum CandidateKind { + Source { + path: PathBuf, + local_sibling: bool, + }, + Binary { + repo_name: String, + record: Box, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum MatchKind { + Exact, + Provides, +} + +#[derive(Debug, Clone)] +struct Candidate { + package: String, + kind: CandidateKind, + match_kind: MatchKind, + sort_repo_priority: i32, + sort_label: String, +} + +#[derive(Debug, Clone)] +struct NodeData { + step: PlannedStep, +} + +#[derive(Debug, Clone)] +struct LocalSpecHit { + spec_name: String, + path: PathBuf, + provides: Vec, +} + +struct Resolver<'a> { + config: &'a Config, + rootfs: &'a Path, + db_path: PathBuf, + opts: PlannerOptions, + pkg_index: PackageIndex, + graph: DiGraph, + by_package: HashMap, + spec_cache: HashMap, + local_sibling_cache: BTreeMap>, + stack: Vec, + emitted_installed_roots: HashSet, +} + +impl<'a> Resolver<'a> { + fn new(config: &'a Config, rootfs: &'a Path, opts: PlannerOptions) -> Self { + let db_path = config.db_dir.join("packages.db"); + let pkg_index = PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + Self { + config, + rootfs, + db_path, + opts, + pkg_index, + graph: DiGraph::new(), + by_package: HashMap::new(), + spec_cache: HashMap::new(), + local_sibling_cache: BTreeMap::new(), + stack: Vec::new(), + emitted_installed_roots: HashSet::new(), + } + } + + fn plan_for_install_target(mut self, target: InstallTarget) -> Result { + match target { + InstallTarget::PackageName(name) => { + if deps::is_dep_satisfied_in_db(&name, &self.db_path)? { + if self.emitted_installed_roots.insert(name.clone()) { + self.add_installed_root_step(name, "requested package".to_string()); + } + } else { + let root = + self.resolve_dep_node(&name, None, "requested package".to_string())?; + if let Some(root_idx) = root { + self.mark_requested_by(root_idx, "requested package".to_string()); + } + } + } + InstallTarget::SpecPath(path) => { + let root_idx = + self.ensure_source_spec_node(&path, true, true, "requested spec".to_string())?; + self.mark_requested_by(root_idx, "requested spec".to_string()); + } + } + self.finish_plan() + } + + fn plan_for_deps(mut self, deps_to_install: &[String]) -> Result { + for dep in deps_to_install { + if deps::is_dep_satisfied_in_db(dep, &self.db_path)? { + continue; + } + if let Some(idx) = self.resolve_dep_node(dep, None, format!("dependency {}", dep))? { + self.mark_requested_by(idx, format!("dependency {}", dep)); + } + } + self.finish_plan() + } + + fn finish_plan(self) -> Result { + let order = toposort(&self.graph, None) + .map_err(|_| anyhow::anyhow!("Dependency cycle detected in plan graph"))?; + let mut steps = Vec::with_capacity(order.len()); + for idx in order { + let node = self + .graph + .node_weight(idx) + .with_context(|| format!("Missing plan node {idx:?}"))?; + steps.push(node.step.clone()); + } + Ok(ExecutionPlan { steps }) + } + + fn add_installed_root_step(&mut self, package: String, requested_by: String) { + let step = PlannedStep { + package, + action: PlanAction::SkipInstalled, + origin: PlanOrigin::Installed, + requested_by: vec![requested_by], + }; + self.graph.add_node(NodeData { step }); + } + + fn mark_requested_by(&mut self, idx: NodeIndex, reason: String) { + if let Some(node) = self.graph.node_weight_mut(idx) + && !node.step.requested_by.contains(&reason) + { + node.step.requested_by.push(reason); + } + } + + fn ensure_source_spec_node( + &mut self, + path: &Path, + allow_local_sibling_fallback: bool, + local_sibling: bool, + requested_by: String, + ) -> Result { + let (package_name, deps_needed) = { + let spec = self.load_spec(path)?; + (spec.package.name.clone(), source_deps_for_install(spec)) + }; + if let Some(&idx) = self.by_package.get(&package_name) { + self.mark_requested_by(idx, requested_by); + return Ok(idx); + } + + self.push_stack(&package_name)?; + let idx = self.graph.add_node(NodeData { + step: PlannedStep { + package: package_name.clone(), + action: PlanAction::BuildAndInstall, + origin: PlanOrigin::Source { + path: path.to_path_buf(), + local_sibling, + }, + requested_by: vec![requested_by], + }, + }); + self.by_package.insert(package_name.clone(), idx); + + for dep in deps_needed { + if let Some(dep_idx) = self.resolve_dep_node( + &dep, + allow_local_sibling_fallback.then_some(path), + format!("{} needs {}", package_name, dep), + )? { + self.graph.add_edge(dep_idx, idx, ()); + } + } + + self.pop_stack(&package_name); + Ok(idx) + } + + fn resolve_dep_node( + &mut self, + dep: &str, + requester_spec_path: Option<&Path>, + requested_by: String, + ) -> Result> { + if deps::is_dep_satisfied_in_db(dep, &self.db_path)? { + return Ok(None); + } + + let dep_name = deps::dep_name(dep); + let candidates = self.collect_candidates(dep_name, requester_spec_path)?; + if candidates.is_empty() { + if let Some(spec_path) = requester_spec_path { + anyhow::bail!( + "Could not resolve dependency '{}' (from {}). Checked binary repos, source repos, and local sibling specs", + dep, + spec_path.display() + ); + } + anyhow::bail!( + "Could not resolve dependency '{}'. Checked binary repos and source repos", + dep + ); + } + + let chosen = self.choose_candidate(dep, &candidates)?; + let idx = match chosen.kind { + CandidateKind::Source { + ref path, + local_sibling, + } => self.ensure_source_spec_node(path, true, local_sibling, requested_by)?, + CandidateKind::Binary { + ref repo_name, + ref record, + } => self.ensure_binary_node(repo_name, (**record).clone(), requested_by)?, + }; + Ok(Some(idx)) + } + + fn ensure_binary_node( + &mut self, + repo_name: &str, + record: db::repo::BinaryRepoPackageRecord, + requested_by: String, + ) -> Result { + if let Some(&idx) = self.by_package.get(&record.name) { + self.mark_requested_by(idx, requested_by); + return Ok(idx); + } + + self.push_stack(&record.name)?; + + let idx = self.graph.add_node(NodeData { + step: PlannedStep { + package: record.name.clone(), + action: PlanAction::InstallBinary, + origin: PlanOrigin::Binary { + repo_name: repo_name.to_string(), + record: Box::new(record.clone()), + }, + requested_by: vec![requested_by], + }, + }); + self.by_package.insert(record.name.clone(), idx); + + for dep in &record.runtime_dependencies { + if let Some(dep_idx) = + self.resolve_dep_node(dep, None, format!("{} needs {}", record.name, dep))? + { + self.graph.add_edge(dep_idx, idx, ()); + } + } + + self.pop_stack(&record.name); + Ok(idx) + } + + fn choose_candidate(&self, dep: &str, candidates: &[Candidate]) -> Result { + let mut sorted = candidates.to_vec(); + sorted.sort_by(|a, b| { + candidate_sort_key(a, self.opts.prefer_binary) + .cmp(&candidate_sort_key(b, self.opts.prefer_binary)) + }); + + if sorted.len() == 1 { + return Ok(sorted.remove(0)); + } + + if self.opts.assume_yes { + let chosen = sorted.remove(0); + ui::info(format!( + "Multiple providers matched '{}' - using {} ({}) due to --yes", + dep, chosen.package, chosen.sort_label + )); + return Ok(chosen); + } + + let options: Vec = sorted.iter().map(format_candidate_label).collect(); + let prompt = format!("Multiple packages satisfy '{}'. Choose one", dep); + let choice = ui::prompt_select_index(&prompt, &options, 0)?; + Ok(sorted.remove(choice)) + } + + fn collect_candidates( + &mut self, + dep_name: &str, + requester_spec_path: Option<&Path>, + ) -> Result> { + let mut out = Vec::new(); + let mut seen = HashSet::::new(); + + // Binary repos + let host_arch = std::env::consts::ARCH; + let mut binary_repos: Vec<_> = self + .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 (repo_name, repo_cfg) in binary_repos { + match db::repo::find_binary_repo_packages( + repo_name, + repo_cfg, + self.rootfs, + &self.config.package_cache_dir, + dep_name, + ) { + Ok(records) => { + for rec in records { + let match_kind = if rec.name.eq_ignore_ascii_case(dep_name) { + MatchKind::Exact + } else { + MatchKind::Provides + }; + let key = format!("bin:{}:{}", repo_name, rec.name); + if !seen.insert(key) { + continue; + } + out.push(Candidate { + package: rec.name.clone(), + kind: CandidateKind::Binary { + repo_name: repo_name.clone(), + record: Box::new(rec.clone()), + }, + match_kind, + sort_repo_priority: repo_cfg.priority, + sort_label: format!("binary:{}", repo_name), + }); + } + } + Err(e) => crate::log_warn!("Binary repo '{}': {}", repo_name, e), + } + } + + // Global source index + if let Some(path) = self.pkg_index.find(dep_name) { + let spec = self.load_spec(&path)?; + let match_kind = if spec.package.name.eq_ignore_ascii_case(dep_name) { + MatchKind::Exact + } else { + MatchKind::Provides + }; + let local_sibling = false; + let key = format!("src:{}", path.display()); + if seen.insert(key) { + out.push(Candidate { + package: spec.package.name.clone(), + kind: CandidateKind::Source { + path: path.clone(), + local_sibling, + }, + match_kind, + sort_repo_priority: 0, + sort_label: format!("source:{}", source_label_for_path(self.config, &path)), + }); + } + } + + // Additional source providers from index (for provider prompt) + for path in self.pkg_index.find_providers(dep_name) { + let spec = self.load_spec(&path)?; + let key = format!("src:{}", path.display()); + if !seen.insert(key) { + continue; + } + out.push(Candidate { + package: spec.package.name.clone(), + kind: CandidateKind::Source { + path: path.clone(), + local_sibling: false, + }, + match_kind: if spec.package.name.eq_ignore_ascii_case(dep_name) { + MatchKind::Exact + } else { + MatchKind::Provides + }, + sort_repo_priority: 0, + sort_label: format!("source:{}", source_label_for_path(self.config, &path)), + }); + } + + // Local sibling fallback (e.g. ../foo/*.toml when building from a local tree) + let local_sibling_root = requester_spec_path + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(Path::to_path_buf) + .or_else(|| self.opts.local_sibling_root.clone()); + if let Some(root) = local_sibling_root { + for hit in self.local_sibling_hits(&root)? { + let exact = hit.spec_name.eq_ignore_ascii_case(dep_name); + let provides = hit + .provides + .iter() + .any(|p| p.eq_ignore_ascii_case(dep_name)); + if !(exact || provides) { + continue; + } + let key = format!("src:{}", hit.path.display()); + if !seen.insert(key) { + continue; + } + out.push(Candidate { + package: hit.spec_name.clone(), + kind: CandidateKind::Source { + path: hit.path.clone(), + local_sibling: true, + }, + match_kind: if exact { + MatchKind::Exact + } else { + MatchKind::Provides + }, + sort_repo_priority: -10, + sort_label: "source:local-sibling".to_string(), + }); + } + } + + Ok(out) + } + + fn load_spec(&mut self, path: &Path) -> Result<&PackageSpec> { + if !self.spec_cache.contains_key(path) { + let mut spec = PackageSpec::from_file(path) + .with_context(|| format!("Failed to parse spec {}", path.display()))?; + spec.apply_config(self.config); + self.spec_cache.insert(path.to_path_buf(), spec); + } + self.spec_cache + .get(path) + .with_context(|| format!("Spec cache entry missing for {}", path.display())) + } + + fn local_sibling_hits(&mut self, root: &Path) -> Result<&[LocalSpecHit]> { + if !self.local_sibling_cache.contains_key(root) { + let mut hits = Vec::new(); + for entry in walkdir::WalkDir::new(root).min_depth(1).max_depth(4) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + if !entry.file_type().is_file() { + continue; + } + if path.extension().is_none_or(|ext| ext != "toml") { + continue; + } + if let Ok(mut spec) = PackageSpec::from_file(path) { + spec.apply_config(self.config); + hits.push(LocalSpecHit { + spec_name: spec.package.name.clone(), + path: path.to_path_buf(), + provides: spec.alternatives.provides.clone(), + }); + } + } + self.local_sibling_cache.insert(root.to_path_buf(), hits); + } + Ok(self + .local_sibling_cache + .get(root) + .map(Vec::as_slice) + .unwrap_or(&[])) + } + + fn push_stack(&mut self, pkg: &str) -> Result<()> { + if self.stack.iter().any(|s| s == pkg) { + let mut chain = self.stack.join(" -> "); + if !chain.is_empty() { + chain.push_str(" -> "); + } + chain.push_str(pkg); + anyhow::bail!("Dependency cycle detected: {}", chain); + } + self.stack.push(pkg.to_string()); + Ok(()) + } + + fn pop_stack(&mut self, pkg: &str) { + if matches!(self.stack.last(), Some(last) if last == pkg) { + self.stack.pop(); + } + } +} + +fn source_deps_for_install(spec: &PackageSpec) -> Vec { + let mut deps_all = Vec::new(); + for dep in &spec.dependencies.build { + push_unique(&mut deps_all, dep.clone()); + } + for dep in &spec.dependencies.runtime { + push_unique(&mut deps_all, dep.clone()); + } + if matches!(spec.build.build_type, BuildType::Autotools) { + for dep in &spec.dependencies.test { + push_unique(&mut deps_all, dep.clone()); + } + } + + for out in spec.outputs() { + let deps = spec.dependencies_for_output(&out.name); + for dep in deps.runtime { + push_unique(&mut deps_all, dep); + } + } + deps_all +} + +fn push_unique(v: &mut Vec, item: String) { + if !v.contains(&item) { + v.push(item); + } +} + +fn source_label_for_path(config: &Config, path: &Path) -> String { + if let Ok(rel) = path.strip_prefix(&config.repo_clone_dir) + && let Some(repo) = rel.components().next() + { + return repo.as_os_str().to_string_lossy().into_owned(); + } + "local".to_string() +} + +fn format_candidate_label(c: &Candidate) -> String { + let match_label = match c.match_kind { + MatchKind::Exact => "exact", + MatchKind::Provides => "provides", + }; + match &c.kind { + CandidateKind::Source { + path, + local_sibling, + } => format!( + "{} [{}] {} {}", + c.package, + if *local_sibling { + "source:local-sibling" + } else { + &c.sort_label + }, + match_label, + path.display() + ), + CandidateKind::Binary { repo_name, record } => format!( + "{} [binary:{}] {} {}-{} size={} file={}", + c.package, + repo_name, + match_label, + record.version, + record.revision, + record.size, + record.filename + ), + } +} + +fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, String, String) { + let is_binary = matches!(c.kind, CandidateKind::Binary { .. }); + let kind_rank = match (prefer_binary, is_binary) { + (true, true) => 0, + (true, false) => 1, + (false, false) => 0, + (false, true) => 1, + }; + let match_rank = match c.match_kind { + MatchKind::Exact => 0, + MatchKind::Provides => 1, + }; + ( + kind_rank, + match_rank, + c.sort_repo_priority, + c.package.clone(), + c.sort_label.clone(), + ) +} + +pub(crate) fn build_install_plan( + config: &Config, + rootfs: &Path, + target: InstallTarget, + opts: PlannerOptions, +) -> Result { + Resolver::new(config, rootfs, opts).plan_for_install_target(target) +} + +pub(crate) fn build_dependency_install_plan( + config: &Config, + rootfs: &Path, + deps_to_install: &[String], + opts: PlannerOptions, +) -> Result { + Resolver::new(config, rootfs, opts).plan_for_deps(deps_to_install) +} diff --git a/src/signing.rs b/src/signing.rs new file mode 100644 index 0000000..9c95e26 --- /dev/null +++ b/src/signing.rs @@ -0,0 +1,320 @@ +//! Minisign-based detached signature helpers. + +use anyhow::{Context, Result}; +use minisign::{PublicKey, SecretKeyBox, SignatureBox}; +use std::fs; +use std::path::{Path, PathBuf}; + +const PUBLIC_KEYS_DIR_REL: &str = "usr/share/depot/keys/public"; +const SIGN_KEYS_DIR_REL: &str = "usr/share/depot/keys/sign"; + +#[derive(Debug, Clone, Default)] +pub struct KeyLocations { + pub public_key: Option, + pub signing_key: Option, +} + +fn is_zst_file(path: &Path) -> bool { + path.file_name() + .map(|n| n.to_string_lossy().ends_with(".zst")) + .unwrap_or(false) +} + +fn candidate_roots(rootfs: &Path, host_root: &Path) -> Vec { + let mut out = Vec::new(); + out.push(rootfs.to_path_buf()); + if rootfs != host_root { + out.push(host_root.to_path_buf()); + } + out +} + +fn key_dir_candidates(rootfs: &Path, host_root: &Path, rel: &str) -> Vec { + candidate_roots(rootfs, host_root) + .into_iter() + .map(|root| root.join(rel)) + .collect() +} + +fn pick_key_file(dir: &Path, is_public: bool) -> Result> { + if !dir.exists() { + return Ok(None); + } + if !dir.is_dir() { + anyhow::bail!("Key path is not a directory: {}", dir.display()); + } + + let mut files: Vec = fs::read_dir(dir) + .with_context(|| format!("Failed to read {}", dir.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect(); + if files.is_empty() { + return Ok(None); + } + files.sort(); + + let preferred = if is_public { + ["depot.pub", "depot.minisign.pub", "minisign.pub"] + } else { + ["depot.key", "depot.minisign.key", "minisign.key"] + }; + for name in preferred { + if let Some(found) = files + .iter() + .find(|p| p.file_name().is_some_and(|n| n == name)) + .cloned() + { + return Ok(Some(found)); + } + } + + let ext = if is_public { "pub" } else { "key" }; + let ext_matches: Vec = files + .iter() + .filter(|p| p.extension().is_some_and(|e| e == ext)) + .cloned() + .collect(); + if ext_matches.len() == 1 { + return Ok(Some(ext_matches[0].clone())); + } + if files.len() == 1 { + return Ok(Some(files[0].clone())); + } + + anyhow::bail!( + "Ambiguous {} key directory {} (multiple key files found)", + if is_public { "public" } else { "signing" }, + dir.display() + ); +} + +fn locate_keys_in_roots(rootfs: &Path, host_root: &Path) -> Result { + let mut keys = KeyLocations::default(); + + for dir in key_dir_candidates(rootfs, host_root, PUBLIC_KEYS_DIR_REL) { + if let Some(path) = pick_key_file(&dir, true)? { + keys.public_key = Some(path); + break; + } + } + for dir in key_dir_candidates(rootfs, host_root, SIGN_KEYS_DIR_REL) { + if let Some(path) = pick_key_file(&dir, false)? { + keys.signing_key = Some(path); + break; + } + } + Ok(keys) +} + +/// Locate public/signing keys by checking both `rootfs` and the host `/`. +pub fn locate_keys(rootfs: &Path) -> Result { + locate_keys_in_roots(rootfs, Path::new("/")) +} + +fn detached_sig_path(input: &Path) -> PathBuf { + PathBuf::from(format!("{}.sig", input.display())) +} + +fn sign_detached_with_key_paths(input: &Path, sig_path: &Path, keys: &KeyLocations) -> Result<()> { + if !input.exists() { + anyhow::bail!("File not found: {}", input.display()); + } + if !is_zst_file(input) { + anyhow::bail!( + "Signing command currently only supports .zst files: {}", + input.display() + ); + } + + let signing_key_path = keys.signing_key.as_ref().with_context( + || "No minisign signing key found in /usr/share/depot/keys/sign (checked rootfs and host)", + )?; + + let secret_key_text = fs::read_to_string(signing_key_path).with_context(|| { + format!( + "Failed to read minisign signing key: {}", + signing_key_path.display() + ) + })?; + let secret_key_box = SecretKeyBox::from_string(&secret_key_text).with_context(|| { + format!( + "Invalid minisign signing key file: {}", + signing_key_path.display() + ) + })?; + let secret_key = match secret_key_box.clone().into_unencrypted_secret_key() { + Ok(sk) => sk, + Err(_) => secret_key_box.into_secret_key(None).with_context(|| { + format!( + "Failed to load minisign signing key: {}", + signing_key_path.display() + ) + })?, + }; + let public_key = if let Some(path) = &keys.public_key { + Some( + PublicKey::from_file(path).with_context(|| { + format!("Failed to load minisign public key: {}", path.display()) + })?, + ) + } else { + crate::log_warn!( + "No minisign public key found in /usr/share/depot/keys/public (checked rootfs and host)" + ); + None + }; + + let file = + fs::File::open(input).with_context(|| format!("Failed to open {}", input.display()))?; + let sig = minisign::sign( + public_key.as_ref(), + &secret_key, + file, + None, + Some(&format!("depot signature for {}", input.display())), + ) + .with_context(|| format!("Failed to sign {}", input.display()))?; + + fs::write(sig_path, sig.to_bytes()) + .with_context(|| format!("Failed to write detached signature {}", sig_path.display()))?; + Ok(()) +} + +fn verify_detached_with_key_paths( + input: &Path, + sig_path: &Path, + keys: &KeyLocations, +) -> Result<()> { + if !input.exists() { + anyhow::bail!("File not found: {}", input.display()); + } + if !sig_path.exists() { + anyhow::bail!("Detached signature not found: {}", sig_path.display()); + } + if !is_zst_file(input) { + anyhow::bail!( + "Verification currently only supports .zst files: {}", + input.display() + ); + } + + let public_key_path = keys.public_key.as_ref().with_context( + || "No minisign public key found in /usr/share/depot/keys/public (checked rootfs and host)", + )?; + let public_key = PublicKey::from_file(public_key_path).with_context(|| { + format!( + "Failed to load minisign public key: {}", + public_key_path.display() + ) + })?; + let sig = SignatureBox::from_file(sig_path) + .with_context(|| format!("Failed to load detached signature: {}", sig_path.display()))?; + let mut file = + fs::File::open(input).with_context(|| format!("Failed to open {}", input.display()))?; + minisign::verify(&public_key, &sig, &mut file, true, false, false).with_context(|| { + format!( + "Detached signature verification failed for {}", + input.display() + ) + })?; + Ok(()) +} + +/// Sign a `.zst` file with a detached minisign signature written to `.sig`. +pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result { + let keys = locate_keys(rootfs)?; + let sig_path = detached_sig_path(input); + sign_detached_with_key_paths(input, &sig_path, &keys)?; + Ok(sig_path) +} + +/// Verify a `.zst` file using a detached minisign signature. +pub fn verify_zst_file_detached(rootfs: &Path, input: &Path, sig_path: &Path) -> Result<()> { + let keys = locate_keys(rootfs)?; + verify_detached_with_key_paths(input, sig_path, &keys) +} + +/// Attempt to sign a `.zst` file using discovered keys; skip if no signing key exists. +pub fn auto_sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result> { + if !is_zst_file(input) { + return Ok(None); + } + let keys = locate_keys(rootfs)?; + if keys.signing_key.is_none() { + crate::log_info!( + "No minisign signing key found; skipping detached signature for {}", + input.display() + ); + return Ok(None); + } + let sig_path = detached_sig_path(input); + sign_detached_with_key_paths(input, &sig_path, &keys)?; + Ok(Some(sig_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use minisign::KeyPair; + use std::io::Write; + + fn write_test_keys(root: &Path) -> Result<(PathBuf, PathBuf)> { + let public_dir = root.join(PUBLIC_KEYS_DIR_REL); + let sign_dir = root.join(SIGN_KEYS_DIR_REL); + fs::create_dir_all(&public_dir)?; + fs::create_dir_all(&sign_dir)?; + + let pair = KeyPair::generate_unencrypted_keypair().context("Failed to generate keypair")?; + let pub_path = public_dir.join("depot.pub"); + let sign_path = sign_dir.join("depot.key"); + fs::write(&pub_path, pair.pk.to_box()?.to_bytes())?; + fs::write(&sign_path, pair.sk.to_box(Some("test"))?.to_bytes())?; + Ok((pub_path, sign_path)) + } + + #[test] + fn locate_keys_checks_rootfs_before_host() -> Result<()> { + let rootfs = tempfile::tempdir()?; + let host = tempfile::tempdir()?; + let (rootfs_pub, rootfs_sign) = write_test_keys(rootfs.path())?; + let _ = write_test_keys(host.path())?; + + let keys = locate_keys_in_roots(rootfs.path(), host.path())?; + assert_eq!(keys.public_key, Some(rootfs_pub)); + assert_eq!(keys.signing_key, Some(rootfs_sign)); + Ok(()) + } + + #[test] + fn sign_zst_file_writes_detached_signature() -> Result<()> { + let rootfs = tempfile::tempdir()?; + let host = tempfile::tempdir()?; + let (pub_path, sign_path) = write_test_keys(rootfs.path())?; + + let file = rootfs.path().join("artifact.tar.zst"); + let mut f = fs::File::create(&file)?; + f.write_all(b"test payload")?; + f.flush()?; + + let keys = KeyLocations { + public_key: Some(pub_path.clone()), + signing_key: Some(sign_path.clone()), + }; + let sig_path = PathBuf::from(format!("{}.sig", file.display())); + sign_detached_with_key_paths(&file, &sig_path, &keys)?; + assert!(sig_path.exists()); + + let pk = PublicKey::from_file(pub_path)?; + let sig_box = SignatureBox::from_file(&sig_path)?; + let mut reader = fs::File::open(&file)?; + minisign::verify(&pk, &sig_box, &mut reader, true, false, false) + .context("signature verification should succeed")?; + verify_detached_with_key_paths(&file, &sig_path, &keys)?; + + // Also make sure host/rootfs lookup path version works without touching /. + let _ = locate_keys_in_roots(rootfs.path(), host.path())?; + Ok(()) + } +} diff --git a/src/source/git.rs b/src/source/git.rs index 3c92590..7e24349 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -1,9 +1,11 @@ //! Git source support via libgit2 (git2 crate) use anyhow::{Context, Result}; -use git2::{Cred, FetchOptions, Oid, RemoteCallbacks, Repository}; +use git2::{Cred, CredentialType, FetchOptions, Oid, RemoteCallbacks, Repository}; +use inquire::Password; use sha2::{Digest, Sha256}; use std::fs; +use std::io::{self, IsTerminal, Write}; use std::path::Path; /// Checkout a repository URL at a specific revision into `checkout_dir`. @@ -115,15 +117,144 @@ fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> { fn remote_callbacks() -> RemoteCallbacks<'static> { let mut callbacks = RemoteCallbacks::new(); + let mut credential_state = CredentialState::default(); - // Try SSH agent / default key locations. - callbacks.credentials(|_url, username_from_url, _allowed| { - if let Some(name) = username_from_url { - Cred::ssh_key_from_agent(name) - } else { - Cred::default() - } + callbacks.credentials(move |url, username_from_url, allowed| { + credential_state.provide(url, username_from_url, allowed) }); callbacks } + +#[derive(Default)] +struct CredentialState { + username: Option, + prompted_userpass: bool, +} + +impl CredentialState { + fn provide( + &mut self, + url: &str, + username_from_url: Option<&str>, + allowed: CredentialType, + ) -> std::result::Result { + if allowed.contains(CredentialType::USERNAME) + && !allowed.intersects( + CredentialType::SSH_KEY + | CredentialType::USER_PASS_PLAINTEXT + | CredentialType::DEFAULT, + ) + { + let username = self.username(url, username_from_url)?; + return Cred::username(&username); + } + + if allowed.contains(CredentialType::SSH_KEY) { + let ssh_username = username_from_url + .or(self.username.as_deref()) + .unwrap_or("git"); + if let Ok(cred) = Cred::ssh_key_from_agent(ssh_username) { + return Ok(cred); + } + } + + if allowed.contains(CredentialType::DEFAULT) + && let Ok(cred) = Cred::default() + { + return Ok(cred); + } + + if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) { + let (username, password) = self.userpass(url, username_from_url)?; + return Cred::userpass_plaintext(&username, &password); + } + + if allowed.contains(CredentialType::USERNAME) { + let username = self.username(url, username_from_url)?; + return Cred::username(&username); + } + + Err(git2::Error::from_str( + "Unsupported authentication method requested by git remote", + )) + } + + fn username( + &mut self, + url: &str, + username_from_url: Option<&str>, + ) -> std::result::Result { + if let Some(username) = username_from_url + && !username.trim().is_empty() + { + let username = username.trim().to_string(); + self.username.get_or_insert_with(|| username.clone()); + return Ok(username); + } + if let Some(username) = self.username.as_ref() { + return Ok(username.clone()); + } + + ensure_prompt_terminal(url)?; + crate::log_warn!("Git remote requires credentials: {}", url); + + let mut input = String::new(); + loop { + print!("Git username for {}: ", url); + io::stdout() + .flush() + .map_err(|e| git2::Error::from_str(&format!("Failed to flush prompt: {e}")))?; + input.clear(); + io::stdin() + .read_line(&mut input) + .map_err(|e| git2::Error::from_str(&format!("Failed to read username: {e}")))?; + let trimmed = input.trim(); + if !trimmed.is_empty() { + let username = trimmed.to_string(); + self.username = Some(username.clone()); + return Ok(username); + } + crate::log_warn!("Username cannot be empty."); + } + } + + fn userpass( + &mut self, + url: &str, + username_from_url: Option<&str>, + ) -> std::result::Result<(String, String), git2::Error> { + if self.prompted_userpass { + return Err(git2::Error::from_str( + "Git credentials were rejected by the remote", + )); + } + + let username = self.username(url, username_from_url)?; + ensure_prompt_terminal(url)?; + self.prompted_userpass = true; + + let prompt = format!("Git password/token for {} ({}):", url, username); + let password = Password::new(&prompt) + .without_confirmation() + .prompt() + .map_err(|e| { + git2::Error::from_str(&format!("Failed to read git password/token: {e}")) + })?; + if password.is_empty() { + return Err(git2::Error::from_str("Git password/token cannot be empty")); + } + + Ok((username, password)) + } +} + +fn ensure_prompt_terminal(url: &str) -> std::result::Result<(), git2::Error> { + if io::stdin().is_terminal() && io::stdout().is_terminal() { + return Ok(()); + } + + Err(git2::Error::from_str(&format!( + "Authentication required for {url}, but no interactive terminal is available" + ))) +} diff --git a/src/staging/mod.rs b/src/staging/mod.rs index fab546e..0f47dcc 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -219,7 +219,7 @@ fn auto_strip_elf_files(destdir: &Path) -> Result { } let status = Command::new("strip") - .arg("--strip-unneeded") + .arg("--strip-debug") .arg(path) .status() .with_context(|| { @@ -278,7 +278,7 @@ fn compress_manpages_zstd(destdir: &Path) -> Result { let out_file = fs::File::create(&tmp_path) .with_context(|| format!("Failed to create {}", tmp_path.display()))?; - let mut encoder = zstd::stream::write::Encoder::new(out_file, 22) + let mut encoder = zstd::stream::write::Encoder::new(out_file, 19) .with_context(|| format!("Failed to start zstd encoder for {}", path.display()))?; io::copy(&mut input, &mut encoder) .with_context(|| format!("Failed to compress {}", path.display()))?; diff --git a/src/ui.rs b/src/ui.rs index a64327f..2314ffb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,6 +2,9 @@ use anyhow::{Context, Result}; use std::io::{self, IsTerminal, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; + +static ASSUME_YES: AtomicBool = AtomicBool::new(false); #[derive(Clone, Copy)] enum Stream { @@ -88,7 +91,17 @@ fn parse_yes_no_input(input: &str, default_yes: bool) -> Option { } } +/// Configure global prompt behavior for the current process. +pub fn set_assume_yes(assume_yes: bool) { + ASSUME_YES.store(assume_yes, Ordering::Relaxed); +} + pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { + if ASSUME_YES.load(Ordering::Relaxed) { + info(format!("{} [auto-yes]", prompt)); + return Ok(true); + } + let default_hint = if default_yes { "Y/n" } else { "y/N" }; loop { print!( @@ -114,6 +127,59 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { } } +/// Prompt the user to choose one option by index. +pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize) -> Result { + if options.is_empty() { + anyhow::bail!("No options available for selection"); + } + let default_idx = default_idx.min(options.len() - 1); + if ASSUME_YES.load(Ordering::Relaxed) { + info(format!( + "{} [auto-select {}: {}]", + prompt, + default_idx + 1, + options[default_idx] + )); + return Ok(default_idx); + } + + loop { + println!("{} {}", label(Stream::Stdout, "?", "35"), prompt); + for (idx, option) in options.iter().enumerate() { + println!( + " {} {}{}", + idx + 1, + option, + if idx == default_idx { " [default]" } else { "" } + ); + } + print!( + "{} Select an option [1-{}] (default {}): ", + label(Stream::Stdout, "?", "35"), + options.len(), + default_idx + 1 + ); + io::stdout() + .flush() + .context("Failed to flush prompt to stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read user input from stdin")?; + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(default_idx); + } + if let Ok(num) = trimmed.parse::() + && (1..=options.len()).contains(&num) + { + return Ok(num - 1); + } + warn("Please enter a valid option number."); + } +} + #[cfg(test)] mod tests { use super::parse_yes_no_input; diff --git a/tools/meson/cargo-build.sh b/tools/meson/cargo-build.sh new file mode 100644 index 0000000..7c2582b --- /dev/null +++ b/tools/meson/cargo-build.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 6 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +cargo_bin="$1" +src_root="$2" +build_root="$3" +profile="$4" +release_flag="$5" +output="$6" + +cargo_home="$build_root/cargo-home" +cargo_target_dir="$build_root/cargo-target" + +mkdir -p "$cargo_home" "$cargo_target_dir" + +export CARGO_HOME="$cargo_home" +export CARGO_TARGET_DIR="$cargo_target_dir" + +if [ "$release_flag" = "1" ]; then + "$cargo_bin" build --locked --manifest-path "$src_root/Cargo.toml" --release +else + "$cargo_bin" build --locked --manifest-path "$src_root/Cargo.toml" +fi + +src_bin="$cargo_target_dir/$profile/depot" +if [ ! -f "$src_bin" ]; then + echo "cargo build finished but binary not found: $src_bin" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$output")" +cp "$src_bin" "$output" +chmod 0755 "$output" diff --git a/tools/meson/cargo-test.sh b/tools/meson/cargo-test.sh new file mode 100644 index 0000000..1d13200 --- /dev/null +++ b/tools/meson/cargo-test.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +cargo_bin="$1" +src_root="$2" +build_root="$3" + +cargo_home="$build_root/cargo-home" +cargo_target_dir="$build_root/cargo-target" + +mkdir -p "$cargo_home" "$cargo_target_dir" + +export CARGO_HOME="$cargo_home" +export CARGO_TARGET_DIR="$cargo_target_dir" + +exec "$cargo_bin" test --locked --manifest-path "$src_root/Cargo.toml"