From ce5fefa833ab999b5ea0e95b70074615892fdf94 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sat, 28 Feb 2026 00:54:08 -0600 Subject: [PATCH] refactor command flow and add signed package/hook safety - split CLI definitions and command execution out of main.rs into new cli.rs and commands.rs modules\n- add transaction hook support via /etc/depot.d/hooks/*.toml with pre/post phases, operation/package/path filters, wildcard negation, and optional affected-path stdin\n- execute transaction hooks around install/update/remove operations and add package-action prompts for install/build/remove flows\n- require and verify detached minisign signatures for binary package archives when allow_unsigned=false, including cache handling and tests\n- persist optional dependencies from package metadata into repo index data and include optional deps in produced metadata\n- update dependency/planner logic to ignore dependencies provided by local split outputs and stop pulling test deps into install planning\n- auto-disable tests when required test dependencies are missing instead of hard-failing build/install paths\n- improve autotools defaults by injecting standard install directory flags when supported and preserving user overrides\n- remove static .a archives during staging by default with new build.flags.no_delete_static escape hatch\n- preserve symlinks and file metadata (permissions/timestamps) in extractor fallback copy path and add regression tests\n- refresh README docs for detached package signatures, optional dependencies, and transaction hooks\n- adjust dependency set: add filetime, disable reqwest default features, and simplify git2/OpenSSL linkage --- Cargo.lock | 804 ++------------ Cargo.toml | 9 +- README.md | 28 +- src/builder/autotools.rs | 117 +- src/cli.rs | 205 ++++ src/commands.rs | 2003 +++++++++++++++++++++++++++++++++ src/db/repo.rs | 287 ++++- src/deps.rs | 101 +- src/install/hooks.rs | 615 +++++++++++ src/install/mod.rs | 1 + src/main.rs | 2140 +----------------------------------- src/package/interactive.rs | 37 +- src/package/packager.rs | 30 +- src/package/spec.rs | 60 +- src/planner.rs | 97 +- src/source/extractor.rs | 109 +- src/source/mod.rs | 2 +- src/staging/mod.rs | 105 +- src/ui.rs | 36 +- 19 files changed, 3863 insertions(+), 2923 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/commands.rs create mode 100644 src/install/hooks.rs diff --git a/Cargo.lock b/Cargo.lock index 5cc171d..fa1a7ac 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,28 +117,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -147,9 +125,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -162,15 +140,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" @@ -183,9 +161,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -193,12 +171,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -213,9 +185,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -272,31 +244,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "console" version = "0.16.2" @@ -335,16 +288,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -360,21 +303,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -429,9 +357,9 @@ checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" [[package]] name = "deflate64" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" @@ -442,15 +370,14 @@ dependencies = [ "bzip2", "clap", "fd-lock", + "filetime", "flate2", "git2", "indicatif", "inquire", - "libz-sys", "md5", "minisign", "nix", - "openssl-sys", "petgraph", "reqwest", "rusqlite", @@ -460,7 +387,7 @@ dependencies = [ "suppaftp", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror", "toml", "url", "walkdir", @@ -472,9 +399,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -532,12 +459,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -606,21 +527,20 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -639,12 +559,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -681,17 +595,11 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -699,15 +607,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -724,21 +632,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -746,7 +654,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -769,19 +676,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -821,30 +715,11 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "url", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -936,7 +811,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -948,22 +822,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -982,14 +840,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -999,11 +856,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1214,28 +1069,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - [[package]] name = "jobserver" version = "0.1.34" @@ -1248,9 +1081,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1293,9 +1126,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libgit2-sys" @@ -1319,7 +1152,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.0", + "redox_syscall 0.7.3", ] [[package]] @@ -1360,9 +1193,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1391,19 +1224,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rust2" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d673a11333485e7d8b93d62a9a5b07b22daf5e8a8655a44c1bb18aa4bf3d1524" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" dependencies = [ - "crc", "sha2", ] @@ -1426,15 +1252,9 @@ checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "minisign" @@ -1479,19 +1299,19 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "nix" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags", "cfg-if", @@ -1558,12 +1378,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-probe" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" - [[package]] name = "openssl-sys" version = "0.9.111" @@ -1635,9 +1449,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -1653,9 +1467,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" @@ -1678,15 +1492,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1699,74 +1504,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -1777,35 +1526,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1817,9 +1537,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] @@ -1849,9 +1569,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -1861,32 +1581,24 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", - "mime", "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", - "rustls-platform-verifier", "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1896,20 +1608,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rpassword" version = "7.4.0" @@ -1921,6 +1619,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + [[package]] name = "rtoolbox" version = "0.0.3" @@ -1946,12 +1654,6 @@ dependencies = [ "sqlite-wasm-rs", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1963,9 +1665,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1974,81 +1676,15 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.0", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.5.1", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -2106,20 +1742,7 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2127,9 +1750,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -2260,9 +1883,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2272,9 +1895,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -2282,14 +1905,13 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e98301bf8b0540c7de45ecd760539b9c62f5772aed172f08efba597c11cd5d" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" dependencies = [ "cc", - "hashbrown 0.16.1", "js-sys", - "thiserror 2.0.18", + "rsqlite-vfs", "wasm-bindgen", ] @@ -2321,14 +1943,14 @@ dependencies = [ "futures-lite", "lazy-regex", "log", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2355,27 +1977,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tar" version = "0.4.44" @@ -2400,33 +2001,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2479,21 +2060,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -2518,34 +2084,11 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", @@ -2567,9 +2110,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -2664,9 +2207,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -2692,12 +2235,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -2761,11 +2298,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen", ] [[package]] @@ -2774,14 +2311,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -2792,11 +2329,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -2805,9 +2343,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2815,9 +2353,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -2828,9 +2366,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -2871,9 +2409,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -2889,15 +2427,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2970,17 +2499,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -2999,15 +2517,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3044,21 +2553,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3092,12 +2586,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3110,12 +2598,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3128,12 +2610,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3158,12 +2634,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3176,12 +2646,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3194,12 +2658,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3212,12 +2670,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3236,12 +2688,6 @@ version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3389,26 +2835,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" @@ -3512,9 +2938,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index d90c68f..87eee7e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,10 @@ ar = "0.9.0" bzip2 = "0.6.1" clap = { version = "4.5.60", features = ["derive"] } flate2 = "1.1.9" -git2 = { version = "0.20.4", features = ["openssl-sys"] } +git2 = "0.20.4" indicatif = "0.18.4" -nix = { version = "0.31.1", features = ["user"] } -reqwest = { version = "0.13.2", features = ["blocking", "native-tls"] } +nix = { version = "0.31.2", features = ["user"] } 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" @@ -31,13 +29,14 @@ walkdir = "2.5.0" xz2 = "0.1.7" zip = "8.1.0" zstd = { version = "0.13.3", features = ["zstdmt"] } -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" +reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "native-tls"] } +filetime = "0.2.27" [dev-dependencies] tempfile = "=3.25.0" diff --git a/README.md b/README.md index d51d2a2..ad89432 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ depot install zlib-1.2.11-1-x86_64.depot.pkg.tar.zst - 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. + - Binary package installs verify both checksums and detached minisign signatures (`.sig`). - `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. + - Resolves and offers to install missing build dependencies before fetching/building. + - Missing test dependencies automatically disable test execution for that build. - `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. @@ -75,6 +77,7 @@ flags = { configure = ["--enable-feature"] } [dependencies] build = ["gcc", "make"] runtime = ["libc"] +optional = ["bash-completion"] ``` ## Configuration @@ -83,3 +86,26 @@ Depot can be configured via `/etc/depot.d/` (or relative to the rootfs). - `/etc/depot.d/build.toml`: System-wide build overrides and flag appends. - `/etc/depot.d/package.toml`: Package-specific overrides. +- `/etc/depot.d/hooks/*.toml`: Transaction hooks for `install`/`update`/`remove` pre/post phases. + +### Transaction Hooks + +Hook files are TOML and use Starpack-like sections: + +```toml +[hook] +name = "refresh-cache" + +[when] +phase = "post" +operation = ["install", "update"] +packages = ["glibc", "linux*"] +paths = ["usr/lib/*"] +negation = ["usr/lib/debug/*"] + +[exec] +command = "ldconfig" +needs_paths = true +``` + +`needs_paths = true` passes affected paths to the command via stdin (newline-separated). diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index de15f49..01cbe2f 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -65,11 +65,14 @@ pub fn build( crate::builder::prepare_tool_command(&mut configure_cmd, &env_vars); - configure_cmd.arg(format!("--prefix={}", flags.prefix)); - // Some projects use non-GNU configure scripts that reject --host/--build. // Probe support first and only add these options when advertised. let help_text = configure_help_text(&configure_path, &build_dir, &env_vars); + configure_cmd.arg(format!("--prefix={}", flags.prefix)); + for default_dir_arg in default_configure_install_dirs(flags, help_text.as_deref()) { + configure_cmd.arg(default_dir_arg); + } + let supports_host = configure_supports_option(help_text.as_deref(), "--host", &flags.configure_file); let supports_build = @@ -114,15 +117,6 @@ pub fn build( } } - if flags.lib32_variant { - if !has_configure_option_prefix(&flags.configure, "--libdir") { - configure_cmd.arg("--libdir=/usr/lib32"); - } - if !has_configure_option_prefix(&flags.configure, "--libexecdir") { - configure_cmd.arg("--libexecdir=/usr/lib32"); - } - } - for arg in &flags.configure { let expanded = expand_configure_arg(spec, arg, &env_vars); configure_cmd.arg(expanded); @@ -439,6 +433,41 @@ fn has_configure_option_prefix(args: &[String], option: &str) -> bool { }) } +fn default_configure_install_dirs( + flags: &crate::package::BuildFlags, + help_text: Option<&str>, +) -> Vec { + let libdir = if flags.lib32_variant { + "/usr/lib32" + } else { + "/usr/lib" + }; + let bindir = nonempty_trimmed(&flags.bindir).unwrap_or("/usr/bin"); + let defaults = [ + ("--bindir", bindir), + ("--sbindir", "/usr/bin"), + ("--libdir", libdir), + ("--libexecdir", libdir), + ("--sysconfdir", "/etc"), + ("--localstatedir", "/var"), + ("--sharedstatedir", "/var/lib"), + ("--includedir", "/usr/include"), + ("--datarootdir", "/usr/share"), + ("--datadir", "/usr/share"), + ("--mandir", "/usr/share/man"), + ("--infodir", "/usr/share/info"), + ]; + + defaults + .iter() + .filter(|(option, _)| { + help_text.is_some_and(|text| configure_help_supports_option(text, option)) + }) + .filter(|(option, _)| !has_configure_option_prefix(&flags.configure, option)) + .map(|(option, value)| format!("{option}={value}")) + .collect() +} + fn lib32_host_triple(host: &str) -> String { host.replace("x86_64", "i686") } @@ -803,6 +832,72 @@ mod tests { )); } + #[test] + fn test_default_configure_install_dirs_injects_expected_paths() { + let flags = BuildFlags::default(); + let help = "\ +--bindir=DIR +--sbindir=DIR +--libdir=DIR +--libexecdir=DIR +--sysconfdir=DIR +--localstatedir=DIR +--sharedstatedir=DIR +--includedir=DIR +--datarootdir=DIR +--datadir=DIR +--mandir=DIR +--infodir=DIR"; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--sbindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--sysconfdir=/etc")); + assert!(args.iter().any(|a| a == "--localstatedir=/var")); + assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib")); + assert!(args.iter().any(|a| a == "--includedir=/usr/include")); + assert!(args.iter().any(|a| a == "--datarootdir=/usr/share")); + assert!(args.iter().any(|a| a == "--datadir=/usr/share")); + assert!(args.iter().any(|a| a == "--mandir=/usr/share/man")); + assert!(args.iter().any(|a| a == "--infodir=/usr/share/info")); + } + + #[test] + fn test_default_configure_install_dirs_respects_explicit_user_overrides() { + let mut flags = BuildFlags::default(); + flags.configure = vec![ + "--sbindir=/sbin".to_string(), + "--libdir=/custom/lib".to_string(), + "--datadir=/custom/share".to_string(), + ]; + let help = "--bindir=DIR --sbindir=DIR --libdir=DIR --datadir=DIR"; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(!args.iter().any(|a| a.starts_with("--sbindir="))); + assert!(!args.iter().any(|a| a.starts_with("--libdir="))); + assert!(!args.iter().any(|a| a.starts_with("--datadir="))); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); + } + + #[test] + fn test_default_configure_install_dirs_lib32_uses_lib32_dirs() { + let help = "--libdir=DIR --libexecdir=DIR"; + let flags = BuildFlags { + lib32_variant: true, + ..BuildFlags::default() + }; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib32")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32")); + } + + #[test] + fn test_default_configure_install_dirs_skips_when_not_advertised() { + let flags = BuildFlags::default(); + let args = default_configure_install_dirs(&flags, Some("--prefix=PREFIX")); + assert!(args.is_empty()); + } + #[test] fn test_makefile_content_has_target_detects_check_and_test() { let content = r#" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b8a039d --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,205 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "Depot")] +#[command(about = "Depot - Source-based package manager for Linux", long_about = None)] +#[command(version)] +pub struct Cli { + /// Custom root filesystem path + #[arg(long, short = 'r', default_value = "/", global = true)] + pub rootfs: PathBuf, + + /// Skip dependency checks + #[arg(long, global = true)] + pub no_deps: bool, + + /// Do not export CFLAGS/CXXFLAGS/LDFLAGS to build commands + #[arg( + long, + global = true, + action = clap::ArgAction::Set, + num_args = 0..=1, + default_value_t = false, + default_missing_value = "true" + )] + pub no_flags: bool, + + /// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu) + #[arg(long, global = true)] + pub cross_prefix: Option, + + /// Clean build workspace after successful install/build + #[arg(long, global = true)] + pub clean: bool, + + /// Automatically answer yes to prompts and pick the default provider choice + #[arg(long, short = 'y', global = true)] + pub yes: bool, + + /// Show what would happen without performing builds/installs + #[arg(long, global = true)] + pub dry_run: bool, + + /// Build/install only the lib32 companion package path (skip primary package output) + #[arg(long, global = true)] + pub lib32_only: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Build and install a package from a spec file + Install { + /// Path to package spec (.toml) or package archive (.tar.zst) + #[arg(value_name = "SPEC_OR_ARCHIVE")] + spec_or_archive: PathBuf, + + /// Explicitly specify path to package spec (.toml file) + #[arg(short, long = "spec", visible_alias = "package", alias = "p")] + spec: Option, + }, + /// Remove an installed package + Remove { + /// Package name to remove + package: String, + }, + /// Build a package without installing + Build { + /// Path to package spec (.toml file) + #[arg(value_name = "SPEC")] + spec_pos: Option, + + /// Explicitly specify path to package spec (.toml file) + #[arg(short, long = "spec", visible_alias = "package", alias = "p")] + spec: Option, + + /// Install package to rootfs after creating package archive(s) + #[arg(long)] + install: bool, + }, + /// Show information about a package + Info { + /// 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)] + command: RepoCommands, + }, + /// Show current configuration + Config, + /// Create a new package specification interactively + MakeSpec { + /// Output file path (defaults to .toml) + #[arg(short, long)] + output: Option, + }, +} + +#[derive(Subcommand)] +pub enum RepoCommands { + /// Create a repository database from a directory of packages + Create { + /// Directory containing .depot.pkg.tar.zst files + #[arg(default_value = ".")] + dir: PathBuf, + }, + /// 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)] +pub enum RepoKindArg { + Source, + Binary, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..6948988 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,2003 @@ +use crate::cli::{Cli, Commands, RepoCommands, RepoKindArg}; +use crate::{ + builder, config, cross, db, deps, index, install, locking, package, planner, signing, source, + staging, ui, +}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn parse_licenses_from_toml(metadata: &toml::Value) -> Vec { + if let Some(s) = metadata.get("license").and_then(|v| v.as_str()) { + return vec![s.to_string()]; + } + if let Some(arr) = metadata.get("license").and_then(|v| v.as_array()) { + return arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + Vec::new() +} + +fn parse_dependency_list(metadata: &toml::Value, kind: &str) -> Vec { + metadata + .get("dependencies") + .and_then(|v| v.get(kind)) + .and_then(|v| v.as_array()) + .map(|deps| { + deps.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default() +} + +fn build_type_runs_automatic_tests(build_type: package::BuildType) -> bool { + matches!( + build_type, + package::BuildType::Autotools | package::BuildType::CMake + ) +} + +fn maybe_disable_tests_for_missing_deps( + pkg_spec: &mut package::PackageSpec, + db_path: &Path, +) -> Result<()> { + if pkg_spec.build.flags.skip_tests + || !build_type_runs_automatic_tests(pkg_spec.build.build_type) + || pkg_spec.dependencies.test.is_empty() + { + return Ok(()); + } + + let missing_test = deps::check_test_deps(pkg_spec, db_path)?; + if !missing_test.is_empty() { + ui::warn(format!( + "Missing test dependencies: {}. Tests will be skipped.", + missing_test.join(", ") + )); + pkg_spec.build.flags.skip_tests = true; + } + + Ok(()) +} + +fn clean_build_workspace(config: &config::Config) -> Result<()> { + if config.build_dir.exists() { + fs::remove_dir_all(&config.build_dir).with_context(|| { + format!("Failed to clean build dir: {}", config.build_dir.display()) + })?; + ui::success(format!( + "Cleaned build workspace: {}", + config.build_dir.display() + )); + } + Ok(()) +} + +fn warn_if_running_as_root_for_build(command: &str, rootfs: &Path) { + if crate::fakeroot::is_root() { + ui::warn(format!("Running '{}' as root is discouraged.", command)); + ui::warn( + "A misconfigured build environment or malicious/buggy build file can overwrite or delete critical system files.", + ); + ui::warn("Recommendation: use a non-root build user and only install as root."); + ui::warn(format!("Current rootfs target: {}", rootfs.display())); + } +} + +fn output_destdir_for(base_destdir: &Path, primary_pkg: &str, output_pkg: &str) -> PathBuf { + if output_pkg == primary_pkg { + base_destdir.to_path_buf() + } else { + staging::output_staging_dir(base_destdir, output_pkg) + } +} + +fn lib32_package_name(name: &str) -> String { + format!("lib32-{name}") +} + +fn lib32_arch_name(arch: &str) -> String { + arch.replace("x86_64", "i686") +} + +fn make_lib32_build_spec(base: &package::PackageSpec) -> package::PackageSpec { + let mut spec = base.clone(); + let flags = &mut spec.build.flags; + flags.lib32_variant = true; + + if !flags.cflags_lib32.is_empty() { + flags.cflags.extend(flags.cflags_lib32.clone()); + } + if !flags.cxxflags_lib32.is_empty() { + flags.cxxflags.extend(flags.cxxflags_lib32.clone()); + } + if !flags.configure_lib32.is_empty() { + flags.configure = flags.configure_lib32.clone(); + } + if !flags.post_configure_lib32.is_empty() { + flags.post_configure = flags.post_configure_lib32.clone(); + } + if !flags.post_compile_lib32.is_empty() { + flags.post_compile = flags.post_compile_lib32.clone(); + } + if !flags.post_install_lib32.is_empty() { + flags.post_install = flags.post_install_lib32.clone(); + } + + flags.cc = format!("{} -m32", flags.cc.trim()); + flags.cxx = format!("{} -m32", flags.cxx.trim()); + flags.build_dir = Some(match flags.build_dir.as_deref() { + Some(dir) if !dir.trim().is_empty() => format!("{}-lib32", dir.trim()), + _ => "build-lib32".to_string(), + }); + + spec +} + +fn make_lib32_package_spec(base: &package::PackageSpec) -> package::PackageSpec { + let mut spec = base.clone(); + spec.package.name = lib32_package_name(&base.package.name); + // The lib32 pass currently emits a single companion package from /usr/lib32. + spec.packages.clear(); + spec +} + +fn copy_tree_preserving_links(src: &Path, dst: &Path) -> Result<()> { + use walkdir::WalkDir; + + fs::create_dir_all(dst) + .with_context(|| format!("Failed to create destination dir: {}", dst.display()))?; + + for entry in WalkDir::new(src) { + let entry = entry?; + let rel = entry + .path() + .strip_prefix(src) + .with_context(|| format!("Failed to strip prefix: {}", src.display()))?; + let target = dst.join(rel); + + if entry.file_type().is_dir() { + fs::create_dir_all(&target) + .with_context(|| format!("Failed to create dir: {}", target.display()))?; + continue; + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create dir: {}", parent.display()))?; + } + + if entry.file_type().is_symlink() { + let link_target = fs::read_link(entry.path()) + .with_context(|| format!("Failed to read symlink: {}", entry.path().display()))?; + #[cfg(unix)] + { + use std::os::unix::fs as unix_fs; + unix_fs::symlink(&link_target, &target).with_context(|| { + format!( + "Failed to create symlink {} -> {}", + target.display(), + link_target.display() + ) + })?; + } + #[cfg(not(unix))] + { + anyhow::bail!( + "Symlink-preserving lib32 staging copy is only supported on unix hosts" + ); + } + } else { + fs::copy(entry.path(), &target).with_context(|| { + format!( + "Failed to copy {} to {}", + entry.path().display(), + target.display() + ) + })?; + } + } + + Ok(()) +} + +fn build_lib32_companion_package( + pkg_spec: &package::PackageSpec, + src_dir: &Path, + config: &config::Config, + cross_config: Option<&cross::CrossConfig>, + export_compiler_flags: bool, + force: bool, +) -> Result> { + if !pkg_spec.build.flags.build_32 && !force { + return Ok(None); + } + if pkg_spec.is_metapackage() { + crate::log_warn!( + "Ignoring build.flags.build-32 for metapackage {}", + pkg_spec.package.name + ); + return Ok(None); + } + + crate::log_info!("Running separate lib32 build pass..."); + let mut lib32_input = pkg_spec.clone(); + lib32_input.build.flags.build_32 = true; + let lib32_build_spec = make_lib32_build_spec(&lib32_input); + let lib32_install_destdir = config + .build_dir + .join("destdir") + .join(".lib32-build") + .join(&pkg_spec.package.name) + .join("lib32-dest"); + + builder::build( + &lib32_build_spec, + src_dir, + &lib32_install_destdir, + cross_config, + export_compiler_flags, + )?; + + let lib32_src = lib32_install_destdir.join("usr/lib32"); + if !lib32_src.exists() { + anyhow::bail!( + "lib32 build completed but did not install usr/lib32 into {}", + lib32_install_destdir.display() + ); + } + + let lib32_pkg_spec = make_lib32_package_spec(pkg_spec); + let lib32_destdir = config + .build_dir + .join("destdir") + .join(&lib32_pkg_spec.package.name); + if lib32_destdir.exists() { + fs::remove_dir_all(&lib32_destdir).with_context(|| { + format!("Failed to clean lib32 destdir: {}", lib32_destdir.display()) + })?; + } + fs::create_dir_all(&lib32_destdir).with_context(|| { + format!( + "Failed to create lib32 destdir: {}", + lib32_destdir.display() + ) + })?; + + copy_tree_preserving_links(&lib32_src, &lib32_destdir.join("usr/lib32"))?; + staging::add_licenses(src_dir, &lib32_destdir, &lib32_pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; + staging::process(&lib32_destdir, &lib32_pkg_spec)?; + + Ok(Some((lib32_pkg_spec, lib32_destdir))) +} + +fn install_staged_to_rootfs( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + std::fs::create_dir_all(&config.db_dir).with_context(|| { + format!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + let db_path = config.db_dir.join("packages.db"); + + let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some(); + let staged_scripts_dir = install::scripts::staged_scripts_dir(destdir); + let installed_scripts_dir = + install::scripts::installed_scripts_dir(rootfs, &pkg_spec.package.name); + + let new_files = staging::generate_manifest_with_dirs(destdir)?; + let remove_paths = + db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?; + let operation = if is_update { + install::hooks::HookOperation::Update + } else { + install::hooks::HookOperation::Install + }; + let mut affected_paths = new_files.files.clone(); + affected_paths.extend(remove_paths.iter().cloned()); + affected_paths.sort(); + affected_paths.dedup(); + + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Pre, + operation, + package: &pkg_spec.package.name, + affected_paths: &affected_paths, + }, + )?; + + if is_update { + let has_staged_pre = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + if !has_staged_pre { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + } + } else { + let _ = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreInstall, + rootfs, + &pkg_spec.package.name, + )?; + } + + let tx_base = config.build_dir.join("tx"); + let tx = staging::install_atomic( + destdir, + rootfs, + &tx_base, + &remove_paths, + &pkg_spec.build.flags.keep, + )?; + + if let Err(e) = db::register_package(&db_path, pkg_spec, destdir) { + let _ = tx.rollback(); + return Err(e); + } + tx.commit()?; + + install::scripts::sync_staged_scripts_to_rootfs( + &staged_scripts_dir, + rootfs, + &pkg_spec.package.name, + )?; + + if is_update { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PostUpdate, + rootfs, + &pkg_spec.package.name, + )?; + } else { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PostInstall, + rootfs, + &pkg_spec.package.name, + )?; + } + + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Post, + operation, + package: &pkg_spec.package.name, + affected_paths: &affected_paths, + }, + )?; + + 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 plan.actionable_steps().next().is_none() { + ui::info("Nothing to do."); + return Ok(()); + } + + if summary.source_build_installs > 0 { + ui::warn(format!( + "{} package(s) will be built from source before installation.", + summary.source_build_installs + )); + } + let planned_packages: Vec = plan + .actionable_steps() + .map(|step| step.package.clone()) + .collect(); + if !ui::prompt_package_action("installation", &planned_packages, 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, + rootfs, + 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(()) +} + +pub fn run(cli: Cli) -> Result<()> { + ui::set_assume_yes(cli.yes); + + match cli.command { + Commands::Install { + spec_or_archive, + spec, + } => { + warn_if_running_as_root_for_build("install", &cli.rootfs); + let mut spec_path = spec.unwrap_or(spec_or_archive); + + // 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. + + // If the provided path doesn't exist, treat it as a package name and + // try to locate a spec under configured repo dir or local packages/. + if !spec_path.exists() { + let name = spec_path.to_string_lossy().to_string(); + ui::info(format!("Looking up package '{}' in local indexes...", name)); + let pkg_index = + index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + if let Some(found) = pkg_index.find(&name) { + 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, + &cli.rootfs, + &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); + } + } + } + } + } + + ui::info(format!("Installing package from: {}", spec_path.display())); + + let (mut pkg_spec, staging_dir): (package::PackageSpec, Option) = + if spec_path.to_string_lossy().ends_with(".tar.zst") { + // Install from archive + ui::info(format!("Detected package archive: {}", spec_path.display())); + let tmp_dir = tempfile::TempDir::new()?; + let extract_dir = tmp_dir.path().to_path_buf(); + + // Extract metadata.toml first to get spec + let file = fs::File::open(&spec_path)?; + let zstd_decoder = zstd::stream::read::Decoder::new(file)?; + let mut archive = tar::Archive::new(zstd_decoder); + + let mut metadata_content = String::new(); + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.to_string_lossy() == ".metadata.toml" { + use std::io::Read; + entry.read_to_string(&mut metadata_content)?; + break; + } + } + + if metadata_content.is_empty() { + anyhow::bail!( + "Package archive does not contain .metadata.toml: {}", + spec_path.display() + ); + } + + let file = fs::File::open(&spec_path)?; + let zstd_decoder = zstd::stream::read::Decoder::new(file)?; + let mut archive = tar::Archive::new(zstd_decoder); + archive.unpack(&extract_dir)?; + + let metadata: toml::Value = toml::from_str(&metadata_content)?; + + // Create a minimal spec from metadata + let mut spec = package::PackageSpec { + package: package::PackageInfo { + name: metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + version: metadata + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + revision: metadata + .get("revision") + .and_then(|v| v.as_integer()) + .unwrap_or(1) as u32, + description: metadata + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + homepage: metadata + .get("homepage") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + license: parse_licenses_from_toml(&metadata), + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies { + build: Vec::new(), + runtime: parse_dependency_list(&metadata, "runtime"), + test: Vec::new(), + optional: parse_dependency_list(&metadata, "optional"), + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { + spec.alternatives.provides = provides + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + + (spec, Some(tmp_dir)) + } else { + // Install from spec (normal build) + let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; + pkg_spec.apply_config(&config); + (pkg_spec, None) + }; + + if cli.lib32_only && staging_dir.is_some() { + anyhow::bail!("--lib32-only is only supported when installing from a package spec"); + } + + ui::info(format!( + "Package: {} v{}-{}", + 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(()); + } + + let install_targets = vec![format!( + "{} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )]; + if !ui::prompt_package_action("installation", &install_targets, 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!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + let db_path = config.db_dir.join("packages.db"); + + if staging_dir.is_none() { + maybe_disable_tests_for_missing_deps(&mut pkg_spec, &db_path)?; + } + + // Check dependencies and prompt for auto-install if needed + if !cli.no_deps { + deps::print_dep_status(&pkg_spec, &db_path)?; + + // Collect all missing dependencies (build + runtime) + let mut missing = deps::check_build_deps(&pkg_spec, &db_path)?; + let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?; + + for dep in missing_runtime { + if !missing.contains(&dep) { + missing.push(dep); + } + } + if !missing.is_empty() { + // Check for dependency cycles via DEPOT_DEPCHAIN env var + let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default(); + let chain_set: std::collections::HashSet<&str> = + dep_chain.split(',').filter(|s| !s.is_empty()).collect(); + + if chain_set.contains(pkg_spec.package.name.as_str()) { + anyhow::bail!( + "Dependency cycle detected! {} is already in chain: {}", + pkg_spec.package.name, + dep_chain + ); + } + + ui::warn(format!("Missing dependencies: {}", missing.join(", "))); + if ui::prompt_package_action("dependency installation", &missing, true)? { + // Build package index for fast lookups + let pkg_index = index::PackageIndex::build_with_repo_dir(Some( + config.repo_clone_dir.clone(), + )); + + // Build new dep chain + let new_chain = if dep_chain.is_empty() { + pkg_spec.package.name.clone() + } else { + format!("{},{}", dep_chain, pkg_spec.package.name) + }; + + // Attempt to install missing deps + for dep in missing { + // Use package index for O(1) lookup + let candidate = pkg_index.find(&dep); + + if let Some(dep_spec_path) = candidate { + ui::info(format!("Installing dependency: {}...", dep)); + + let mut cmd = std::process::Command::new(std::env::current_exe()?); + cmd.arg("-r").arg(&cli.rootfs); + + if cli.no_deps { + cmd.arg("--no-deps"); + } + if cli.no_flags { + cmd.arg("--no-flags"); + } + if let Some(ref p) = cli.cross_prefix { + cmd.arg("--cross-prefix").arg(p); + } + if cli.clean { + cmd.arg("--clean"); + } + + cmd.arg("install").arg(&dep_spec_path); + cmd.env("DEPOT_DEPCHAIN", &new_chain); + + let status = cmd.status()?; + + if !status.success() { + anyhow::bail!("Failed to install dependency: {}", dep); + } + } else { + anyhow::bail!( + "Could not find package spec for dependency: {}", + dep + ); + } + } + } + } + + // Enforce required dependencies before building/installing. + deps::require_build_deps(&pkg_spec, &db_path)?; + deps::require_runtime_deps(&pkg_spec, &db_path)?; + } + + let cross_config = cli + .cross_prefix + .as_ref() + .map(|p| cross::CrossConfig::from_prefix(p)) + .transpose()?; + let mut built_src_dir: Option = None; + + let destdir = if let Some(dir) = &staging_dir { + dir.path().to_path_buf() + } else { + // 1-2. Fetch + extract sources (supports archives and git URL#rev) + let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; + built_src_dir = Some(src_dir.clone()); + + // 3. Build + let destdir = config + .build_dir + .join("destdir") + .join(&pkg_spec.package.name); + + if !cli.lib32_only { + builder::build( + &pkg_spec, + &src_dir, + &destdir, + cross_config.as_ref(), + !cli.no_flags, + )?; + + // 3.1 Copy license files into staged tree + staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + } + + destdir + }; + + if !cli.lib32_only { + // 4. Stage (clean .la files, etc.) + staging::process(&destdir, &pkg_spec)?; + + // 5-6. Install/update to rootfs and register in DB + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); + install_staged_to_rootfs(&spec_for_out, &out_destdir, &cli.rootfs, &config)?; + + ui::success(format!( + "Successfully installed {} v{}", + spec_for_out.package.name, spec_for_out.package.version + )); + // TODO(snapper): create post-install snapshot after install commit succeeds. + } + } + + if let Some(src_dir) = built_src_dir.as_deref() + && let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( + &pkg_spec, + src_dir, + &config, + cross_config.as_ref(), + !cli.no_flags, + cli.lib32_only, + )? + { + install_staged_to_rootfs(&lib32_spec, &lib32_destdir, &cli.rootfs, &config)?; + ui::success(format!( + "Successfully installed {} v{}", + lib32_spec.package.name, lib32_spec.package.version + )); + } + + if cli.clean { + clean_build_workspace(&config)?; + } + } + 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 removal_targets = vec![package.clone()]; + if !ui::prompt_package_action("removal", &removal_targets, true)? { + anyhow::bail!("Aborted"); + } + let affected_paths = db::get_package_files(&db_path, &package)?; + install::hooks::run_transaction_hooks( + &cli.rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Pre, + operation: install::hooks::HookOperation::Remove, + package: &package, + affected_paths: &affected_paths, + }, + )?; + let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package); + let _ = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PreRemove, + &cli.rootfs, + &package, + )?; + db::remove_package(&db_path, &package, &cli.rootfs)?; + let post_remove = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PostRemove, + &cli.rootfs, + &package, + ); + let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package); + post_remove?; + cleanup_scripts?; + install::hooks::run_transaction_hooks( + &cli.rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Post, + operation: install::hooks::HookOperation::Remove, + package: &package, + affected_paths: &affected_paths, + }, + )?; + ui::success(format!("Successfully removed {}", package)); + } + Commands::Build { + spec_pos, + spec, + install, + } => { + warn_if_running_as_root_for_build("build", &cli.rootfs); + let spec_path = spec.or(spec_pos).context("No spec file provided")?; + ui::info(format!("Building package from: {}", spec_path.display())); + let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; + + let config = config::Config::for_rootfs(&cli.rootfs); + + // Apply system overrides + pkg_spec.apply_config(&config); + + // Ensure database directory exists + std::fs::create_dir_all(&config.db_dir).with_context(|| { + format!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + let db_path = config.db_dir.join("packages.db"); + + maybe_disable_tests_for_missing_deps(&mut pkg_spec, &db_path)?; + + // Check build dependencies + if !cli.no_deps { + deps::print_dep_status(&pkg_spec, &db_path)?; + let missing = deps::check_build_deps(&pkg_spec, &db_path)?; + 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_package_action("dependency installation", &missing, 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)?; + } 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")?; + + let build_targets = vec![format!( + "{} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )]; + if !ui::prompt_package_action("build", &build_targets, true)? { + anyhow::bail!("Aborted"); + } + + 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 + .build_dir + .join("destdir") + .join(&pkg_spec.package.name); + // Build with optional cross-compilation + let cross_config = cli + .cross_prefix + .as_ref() + .map(|p| cross::CrossConfig::from_prefix(p)) + .transpose()?; + if !cli.lib32_only { + builder::build( + &pkg_spec, + &src_dir, + &destdir, + cross_config.as_ref(), + !cli.no_flags, + )?; + } + + if !cli.lib32_only { + staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + staging::process(&destdir, &pkg_spec)?; + } + + // Create package archive(s) — support multiple outputs from a single spec. + let arch = cli + .cross_prefix + .as_deref() + .unwrap_or(std::env::consts::ARCH); + + let mut created_files = Vec::new(); + if !cli.lib32_only { + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); + let packager = + package::Packager::new(spec_for_out.clone(), 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); + } + } + + let mut lib32_install_bundle: Option<(package::PackageSpec, PathBuf)> = None; + if let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( + &pkg_spec, + &src_dir, + &config, + cross_config.as_ref(), + !cli.no_flags, + cli.lib32_only, + )? { + let lib32_arch = lib32_arch_name(arch); + let packager = package::Packager::new( + lib32_spec.clone(), + lib32_destdir.clone(), + config.clone(), + ); + let pkg_file = packager.create_package(Path::new("."), &lib32_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); + lib32_install_bundle = Some((lib32_spec, lib32_destdir)); + } + + 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 { + let mut install_targets = Vec::new(); + if !cli.lib32_only { + for out in pkg_spec.outputs() { + install_targets + .push(format!("{} v{}-{}", out.name, out.version, out.revision)); + } + } + if let Some((lib32_spec, _)) = &lib32_install_bundle { + install_targets.push(format!( + "{} v{}-{}", + lib32_spec.package.name, + lib32_spec.package.version, + lib32_spec.package.revision + )); + } + if !ui::prompt_package_action("installation", &install_targets, true)? { + anyhow::bail!("Aborted"); + } + if !cli.lib32_only { + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = + output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); + install_staged_to_rootfs( + &spec_for_out, + &out_destdir, + &cli.rootfs, + &config, + )?; + ui::success(format!( + "Successfully installed {} v{}", + spec_for_out.package.name, spec_for_out.package.version + )); + // TODO(snapper): create post-install snapshot after --install commit succeeds. + } + } + if let Some((lib32_spec, lib32_destdir)) = &lib32_install_bundle { + install_staged_to_rootfs(lib32_spec, lib32_destdir, &cli.rootfs, &config)?; + ui::success(format!( + "Successfully installed {} v{}", + lib32_spec.package.name, lib32_spec.package.version + )); + } + } + + if cli.clean { + clean_build_workspace(&config)?; + } + } + Commands::Info { package } => { + // 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 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 = cfg; + let mirrors = config.enabled_source_mirror_map(); + if mirrors.is_empty() { + ui::info("No enabled source repos configured"); + } else { + db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; + ui::success(format!( + "Source repos synchronized into {}", + config.repo_clone_dir.display() + )); + } + } + RepoCommands::Update { 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); + let repo_lock = locking::open_lock(&config)?; + let repo_lock_path = locking::lock_path(&config); + let _repo_lock_guard = + locking::try_read(&repo_lock, &repo_lock_path, "repo status")?; + let mirrors = config.enabled_source_mirror_map(); + if mirrors.is_empty() { + ui::info("No enabled source repos configured"); + } else { + db::repo::mirrors_status(&config.repo_clone_dir, &mirrors)?; + } + if config.binary_repos.is_empty() { + ui::info("No binary repos configured"); + } else { + ui::info("Binary repo configuration:"); + let host_arch = std::env::consts::ARCH; + for (name, repo) in &config.binary_repos { + let arch_keys = if repo.arch.is_empty() { + "(any)".to_string() + } else { + repo.arch.keys().cloned().collect::>().join(",") + }; + ui::info(format!( + " {} [{}] url={} repo_db={} arches={} host_match={}", + name, + if repo.enabled { "enabled" } else { "disabled" }, + repo.url, + repo.repo_db, + arch_keys, + if repo.supports_arch(host_arch) { + "yes" + } else { + "no" + } + )); + } + } + } + }, + Commands::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() { + println!("\nAppends:"); + for (k, v) in &config.appends { + println!(" {} = {:?}", k, v); + } + } + } + Commands::MakeSpec { output } => { + let spec = package::create_interactive()?; + // Produce a minimal TOML for interactive-created specs (omit defaults) + let toml_string = package::spec_to_minimal_toml(&spec)?; + + let output_path = + output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); + + if output_path.exists() { + ui::warn(format!("File {} already exists.", output_path.display())); + if !ui::prompt_yes_no("Overwrite it?", false)? { + anyhow::bail!("Aborted"); + } + } + + fs::write(&output_path, toml_string)?; + ui::success(format!( + "Package specification saved to {}", + output_path.display() + )); + } + } + + Ok(()) +} diff --git a/src/db/repo.rs b/src/db/repo.rs index 72d1efc..72608bb 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -170,6 +170,7 @@ impl RepoManager { let mut license = None; let mut provides = Vec::new(); let mut runtime_dependencies = Vec::new(); + let mut optional_dependencies = Vec::new(); let mut archive_files = Vec::new(); for entry in archive.entries()? { @@ -226,6 +227,17 @@ impl RepoManager { .map(String::from) .collect(); } + if let Some(optional_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("optional")) + .and_then(|v| v.as_array()) + { + optional_dependencies = optional_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } continue; } @@ -286,6 +298,12 @@ impl RepoManager { params![package_id, dep], )?; } + for dep in optional_dependencies { + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)", + params![package_id, dep], + )?; + } for file_path in archive_files { conn.execute( @@ -1304,11 +1322,84 @@ fn verify_binary_package_record_checksums( Ok(()) } -/// Download a binary package archive and verify it against checksums from the -/// signed repository database metadata. +fn fetch_binary_package_signature( + repo_name: &str, + repo: &crate::config::BinaryRepo, + client: &reqwest::blocking::Client, + sig_url: &str, + sig_path: &Path, +) -> Result { + let found = fetch_url_to_path(client, sig_url, sig_path)?; + if !found { + if !repo.allow_unsigned { + anyhow::bail!( + "Failed to fetch detached signature for binary package in repo '{}' at {}", + repo_name, + sig_url + ); + } + crate::log_warn!( + "Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing", + repo_name, + sig_url + ); + } + Ok(found) +} + +fn verify_binary_package_signature( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + pkg_path: &Path, + sig_path: &Path, +) -> Result<()> { + if !sig_path.exists() { + if repo.allow_unsigned { + return Ok(()); + } + anyhow::bail!( + "Detached package signature required but missing for {}", + pkg_path.display() + ); + } + + let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?; + if trusted_keys.is_empty() { + if repo.allow_unsigned { + crate::log_warn!( + "No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true", + repo_name + ); + return Ok(()); + } + anyhow::bail!( + "No trusted minisign public key found for detached package signature verification in binary repo '{}'", + repo_name + ); + } + + let verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path) + .with_context(|| { + format!( + "Failed to verify detached package signature for {}", + pkg_path.display() + ) + })?; + crate::log_info!( + "Verified detached package signature for '{}' using {}", + pkg_path.display(), + verified_key.display() + ); + Ok(()) +} + +/// Download a binary package archive and verify it against detached signatures +/// and checksums from signed repository metadata. pub fn fetch_binary_package_archive( repo_name: &str, repo: &crate::config::BinaryRepo, + rootfs: &Path, rec: &BinaryRepoPackageRecord, package_cache_dir: &Path, ) -> Result { @@ -1324,7 +1415,15 @@ pub fn fetch_binary_package_archive( fs::create_dir_all(&cache_dir) .with_context(|| format!("Failed to create {}", cache_dir.display()))?; let dest_path = cache_dir.join(&rec.filename); + let dest_sig_path = cache_dir.join(format!("{}.sig", rec.filename)); let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename)); + let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename)); + let pkg_url = join_repo_url(base_url, &rec.filename)?; + let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?; + + let client = reqwest::blocking::Client::builder() + .build() + .context("Failed to build HTTP client for binary package fetch")?; if dest_path.exists() { verify_binary_package_record_checksums(&dest_path, rec).with_context(|| { @@ -1333,11 +1432,32 @@ pub fn fetch_binary_package_archive( dest_path.display() ) })?; + if !dest_sig_path.exists() { + let sig_downloaded = + fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?; + if sig_downloaded { + fs::rename(&tmp_sig_path, &dest_sig_path).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_sig_path.display(), + dest_sig_path.display() + ) + })?; + } else { + let _ = fs::remove_file(&dest_sig_path); + } + } + verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path) + .with_context(|| { + format!( + "Cached binary package failed signature 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); match copy_file_url_to_path(&pkg_url, &tmp_path)? { @@ -1346,9 +1466,6 @@ pub fn fetch_binary_package_archive( anyhow::bail!("Failed to fetch {}: local file not found", pkg_url); } FileUrlCopyOutcome::NotFileUrl => { - 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() @@ -1373,6 +1490,9 @@ pub fn fetch_binary_package_archive( ) })?; + let sig_downloaded = + fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?; + fs::rename(&tmp_path, &dest_path).with_context(|| { format!( "Failed to move {} to {}", @@ -1380,6 +1500,29 @@ pub fn fetch_binary_package_archive( dest_path.display() ) })?; + if sig_downloaded { + fs::rename(&tmp_sig_path, &dest_sig_path).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_sig_path.display(), + dest_sig_path.display() + ) + })?; + if let Err(err) = + verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path) + { + let _ = fs::remove_file(&dest_path); + let _ = fs::remove_file(&dest_sig_path); + return Err(err).with_context(|| { + format!( + "Downloaded binary package failed signature verification: {}", + rec.filename + ) + }); + } + } else { + let _ = fs::remove_file(&dest_sig_path); + } Ok(dest_path) } @@ -1599,8 +1742,8 @@ license = "MIT" provides = ["test-feature"] [dependencies] -build = [] runtime = [] +optional = [] "#; let mut header = tar::Header::new_gnu(); header.set_path(".metadata.toml").unwrap(); @@ -1798,6 +1941,136 @@ license = ["MIT", "Apache-2.0"] verify_binary_package_record_checksums(&pkg, &rec).unwrap(); } + fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord { + use sha2::{Digest, Sha256, Sha512}; + + let sha256 = { + let mut h = Sha256::new(); + h.update(payload); + format!("{:x}", h.finalize()) + }; + let sha512 = { + let mut h = Sha512::new(); + h.update(payload); + format!("{:x}", h.finalize()) + }; + + BinaryRepoPackageRecord { + repo_name: "repo".into(), + name: "pkg".into(), + version: "1.0".into(), + revision: 1, + filename: filename.to_string(), + size: payload.len() as u64, + sha256, + sha512, + description: None, + provides: Vec::new(), + runtime_dependencies: Vec::new(), + } + } + + #[test] + fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"package payload"; + std::fs::write(repo_dir.path().join(filename), payload).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: false, + ..Default::default() + }; + + let err = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .expect_err("missing detached signature should fail"); + assert!(err.to_string().to_ascii_lowercase().contains("signature")); + } + + #[test] + fn test_fetch_binary_package_archive_verifies_signature_and_checksum() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); + std::fs::create_dir_all(&trusted_dir).unwrap(); + + let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + std::fs::write( + trusted_dir.join("repo.pub"), + keypair.pk.to_box().unwrap().to_bytes(), + ) + .unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"signed package payload"; + let package_path = repo_dir.path().join(filename); + std::fs::write(&package_path, payload).unwrap(); + + let sig = minisign::sign( + Some(&keypair.pk), + &keypair.sk, + std::fs::File::open(&package_path).unwrap(), + None, + Some("test signature"), + ) + .unwrap(); + std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: false, + ..Default::default() + }; + + let fetched = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .unwrap(); + assert_eq!(std::fs::read(&fetched).unwrap(), payload); + assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists()); + } + + #[test] + fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"unsigned package payload"; + std::fs::write(repo_dir.path().join(filename), payload).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: true, + ..Default::default() + }; + + let fetched = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .unwrap(); + assert_eq!(std::fs::read(&fetched).unwrap(), payload); + assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists()); + } + #[test] fn test_copy_file_url_to_path_supports_file_scheme() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/deps.rs b/src/deps.rs index 3fddef9..00ee556 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -9,7 +9,7 @@ //! - `package<=1.2.3` - less than or equal to 1.2.3 use crate::db; -use crate::package::PackageSpec; +use crate::package::{BuildType, PackageSpec}; use crate::ui; use anyhow::Result; use std::path::Path; @@ -139,6 +139,13 @@ fn is_dep_satisfied( } } +fn build_type_runs_automatic_tests(spec: &PackageSpec) -> bool { + matches!( + spec.build.build_type, + BuildType::Autotools | BuildType::CMake + ) +} + /// 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() { @@ -173,15 +180,24 @@ pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result Result> { let mut missing = Vec::new(); + let local_provides = spec.local_dependency_provides(); if !db_path.exists() { - return Ok(spec.dependencies.runtime.clone()); + for dep in &spec.dependencies.runtime { + if !local_provides.contains(dep_name(dep)) { + missing.push(dep.clone()); + } + } + return Ok(missing); } let installed = db::get_installed_packages(db_path)?; let provides = db::get_all_provides(db_path)?; for dep in &spec.dependencies.runtime { + if local_provides.contains(dep_name(dep)) { + continue; + } if !is_dep_satisfied(dep, &installed, &provides, db_path)? { missing.push(dep.clone()); } @@ -244,11 +260,21 @@ pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> { "Test dependencies: {}", spec.dependencies.test.join(", ") )); - if !missing_test.is_empty() { + if !spec.build.flags.skip_tests + && build_type_runs_automatic_tests(spec) + && !missing_test.is_empty() + { ui::warn(format!("Test deps missing: {}", missing_test.join(", "))); } } + if !spec.dependencies.optional.is_empty() { + ui::info(format!( + "Optional dependencies: {}", + spec.dependencies.optional.join(", ") + )); + } + Ok(()) } @@ -266,20 +292,6 @@ pub fn require_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { Ok(()) } -/// Verify all test dependencies are installed, error if not -pub fn require_test_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { - let missing = check_test_deps(spec, db_path)?; - - if !missing.is_empty() { - anyhow::bail!( - "Missing test dependencies: {}\nInstall them first with: depot install ", - missing.join(", ") - ); - } - - Ok(()) -} - /// Verify all runtime dependencies are installed, error if not. pub fn require_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> { let missing = check_runtime_deps(spec, db_path)?; @@ -372,6 +384,7 @@ mod tests { build: Vec::new(), runtime: Vec::new(), test: vec!["bats".into(), "python".into()], + optional: Vec::new(), }, package_alternatives: Default::default(), package_dependencies: Default::default(), @@ -411,6 +424,7 @@ mod tests { build: Vec::new(), runtime: vec!["python".into()], test: Vec::new(), + optional: Vec::new(), }, package_alternatives: Default::default(), package_dependencies: Default::default(), @@ -421,4 +435,57 @@ mod tests { .expect_err("runtime deps should be required"); assert!(err.to_string().contains("Missing runtime dependencies")); } + + #[test] + fn test_check_runtime_deps_ignores_local_outputs_and_provides() { + let spec = PackageSpec { + package: crate::package::PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: vec![crate::package::PackageInfo { + name: "foo-libs".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }], + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "https://example.test/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: crate::package::Build { + build_type: crate::package::BuildType::Custom, + flags: crate::package::BuildFlags::default(), + }, + dependencies: crate::package::Dependencies { + build: Vec::new(), + runtime: vec!["foo-libs".into(), "libfoo".into(), "python".into()], + test: Vec::new(), + optional: Vec::new(), + }, + package_alternatives: std::collections::BTreeMap::from([( + "foo-libs".to_string(), + crate::package::Alternatives { + provides: vec!["libfoo".into()], + replaces: Vec::new(), + }, + )]), + package_dependencies: Default::default(), + spec_dir: std::path::PathBuf::from("."), + }; + + let missing = check_runtime_deps(&spec, Path::new("/definitely/not/a/real/db")).unwrap(); + assert_eq!(missing, vec!["python".to_string()]); + } } diff --git a/src/install/hooks.rs b/src/install/hooks.rs new file mode 100644 index 0000000..be195a9 --- /dev/null +++ b/src/install/hooks.rs @@ -0,0 +1,615 @@ +//! Transaction hook loading and execution. +//! +//! Hook files live under `/etc/depot.d/hooks/*.toml` and follow a +//! Starpack-inspired format: +//! +//! ```toml +//! [hook] +//! name = "refresh cache" +//! description = "Refreshes cache after updates" +//! +//! [when] +//! phase = "post" +//! operation = ["install", "update"] +//! packages = ["glibc", "linux*"] +//! paths = ["usr/lib/*"] +//! negation = ["usr/lib/debug/*"] +//! +//! [exec] +//! command = "ldconfig" +//! needs_paths = true +//! ``` +//! +//! The legacy section/key names `[Hook]`, `[When]`, and `[Exec]` are accepted. + +use crate::fakeroot; +use anyhow::{Context, Result, bail}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; + +const TRANSACTION_HOOKS_DIR_REL: &str = "etc/depot.d/hooks"; + +/// Hook phase within an install/update/remove transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookPhase { + /// Before files are changed. + Pre, + /// After files are changed. + Post, +} + +impl HookPhase { + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "pre" | "pretransaction" | "pre_transaction" | "pre-transaction" => { + Some(HookPhase::Pre) + } + "post" | "posttransaction" | "post_transaction" | "post-transaction" => { + Some(HookPhase::Post) + } + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + HookPhase::Pre => "pre", + HookPhase::Post => "post", + } + } +} + +/// Transaction operation for hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookOperation { + /// Package install. + Install, + /// Package update/upgrade. + Update, + /// Package removal. + Remove, +} + +impl HookOperation { + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "install" => Some(HookOperation::Install), + "update" | "upgrade" => Some(HookOperation::Update), + "remove" | "uninstall" => Some(HookOperation::Remove), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + HookOperation::Install => "install", + HookOperation::Update => "update", + HookOperation::Remove => "remove", + } + } +} + +/// Runtime context for transaction hook matching/execution. +#[derive(Debug, Clone)] +pub struct HookExecutionContext<'a> { + /// Current phase (`pre` or `post`). + pub phase: HookPhase, + /// Current operation (`install`, `update`, `remove`). + pub operation: HookOperation, + /// Package being processed. + pub package: &'a str, + /// Filesystem paths affected by this package action. + pub affected_paths: &'a [String], +} + +#[derive(Debug, Clone)] +struct TransactionHook { + file_path: PathBuf, + name: Option, + phase: HookPhase, + operations: Vec, + packages: Vec, + paths: Vec, + negations: Vec, + command: String, + needs_paths: bool, +} + +impl TransactionHook { + fn display_name(&self) -> String { + self.name.clone().unwrap_or_else(|| { + self.file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string() + }) + } + + fn matches(&self, ctx: &HookExecutionContext<'_>, normalized_paths: &[String]) -> bool { + if self.phase != ctx.phase { + return false; + } + + if !self.operations.is_empty() && !self.operations.contains(&ctx.operation) { + return false; + } + + if !self.packages.is_empty() { + let package = ctx.package.trim(); + let package_match = self + .packages + .iter() + .any(|pattern| wildcard_match(pattern.trim(), package)); + if !package_match { + return false; + } + } + + if !self.paths.is_empty() { + let positive_match = normalized_paths.iter().any(|path| { + self.paths.iter().any(|pattern| { + wildcard_match( + &normalize_match_target(pattern), + &normalize_match_target(path), + ) + }) + }); + if !positive_match { + return false; + } + } + + if !self.negations.is_empty() { + let negated = normalized_paths.iter().any(|path| { + self.negations.iter().any(|pattern| { + wildcard_match( + &normalize_match_target(pattern), + &normalize_match_target(path), + ) + }) + }); + if negated { + return false; + } + } + + true + } +} + +/// Return the transaction hook directory for a rootfs. +pub fn transaction_hooks_dir(rootfs: &Path) -> PathBuf { + rootfs.join(TRANSACTION_HOOKS_DIR_REL) +} + +/// Load and execute transaction hooks that match `ctx`. +/// +/// Returns the number of hooks that were executed. +pub fn run_transaction_hooks(rootfs: &Path, ctx: &HookExecutionContext<'_>) -> Result { + let hook_dir = transaction_hooks_dir(rootfs); + let hook_files = discover_hook_files(&hook_dir)?; + if hook_files.is_empty() { + return Ok(0); + } + + let normalized_paths: Vec = ctx + .affected_paths + .iter() + .map(|p| normalize_match_target(p)) + .collect(); + + let mut executed = 0usize; + for hook_file in hook_files { + let hook = parse_hook_file(&hook_file)?; + if !hook.matches(ctx, &normalized_paths) { + continue; + } + + run_hook_command(rootfs, &hook, ctx, &normalized_paths)?; + executed += 1; + } + + Ok(executed) +} + +fn discover_hook_files(hook_dir: &Path) -> Result> { + if !hook_dir.exists() { + return Ok(Vec::new()); + } + if !hook_dir.is_dir() { + bail!( + "Transaction hooks path exists but is not a directory: {}", + hook_dir.display() + ); + } + + let mut files = Vec::new(); + for entry in + fs::read_dir(hook_dir).with_context(|| format!("Failed to read {}", hook_dir.display()))? + { + let path = entry + .with_context(|| format!("Failed to list {}", hook_dir.display()))? + .path(); + if !path.is_file() { + continue; + } + let is_toml = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("toml")) + .unwrap_or(false); + if is_toml { + files.push(path); + } + } + + files.sort(); + Ok(files) +} + +fn parse_hook_file(path: &Path) -> Result { + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let root: toml::Value = + toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?; + + let root_table = root + .as_table() + .with_context(|| format!("Hook file is not a TOML table: {}", path.display()))?; + let hook_table = select_table(root_table, &["hook", "Hook"]); + let when_table = select_table(root_table, &["when", "When"]) + .with_context(|| format!("Missing [when] table in {}", path.display()))?; + let exec_table = select_table(root_table, &["exec", "Exec"]) + .with_context(|| format!("Missing [exec] table in {}", path.display()))?; + + let phase = get_required_string(when_table, &["phase", "Phase"]) + .with_context(|| format!("Missing when.phase in {}", path.display())) + .and_then(|raw| { + HookPhase::parse(&raw) + .with_context(|| format!("Invalid when.phase '{}' in {}", raw, path.display())) + })?; + + let operations = get_string_list(when_table, &["operation", "operations", "Operation"]) + .with_context(|| format!("Invalid when.operation in {}", path.display()))? + .into_iter() + .map(|raw| { + HookOperation::parse(&raw) + .with_context(|| format!("Invalid operation '{}' in {}", raw, path.display())) + }) + .collect::>>()?; + + let packages = get_string_list(when_table, &["package", "packages", "Package", "Packages"]) + .with_context(|| format!("Invalid when.packages in {}", path.display()))?; + let paths = get_string_list(when_table, &["path", "paths", "Paths"]) + .with_context(|| format!("Invalid when.paths in {}", path.display()))?; + let negations = get_string_list( + when_table, + &[ + "negation", + "negations", + "Negation", + "exclude_paths", + "not_paths", + ], + ) + .with_context(|| format!("Invalid when.negation in {}", path.display()))?; + + let command = get_required_string(exec_table, &["command", "Command"]) + .with_context(|| format!("Missing exec.command in {}", path.display()))?; + let needs_paths = get_optional_bool(exec_table, &["needs_paths", "NeedsPaths"]) + .with_context(|| format!("Invalid exec.needs_paths in {}", path.display()))? + .unwrap_or(false); + + Ok(TransactionHook { + file_path: path.to_path_buf(), + name: hook_table.and_then(|t| get_optional_string(t, &["name", "Name"])), + phase, + operations, + packages, + paths, + negations, + command, + needs_paths, + }) +} + +fn select_table<'a>( + root: &'a toml::map::Map, + keys: &[&str], +) -> Option<&'a toml::map::Map> { + keys.iter() + .find_map(|key| root.get(*key).and_then(toml::Value::as_table)) +} + +fn get_optional_string( + table: &toml::map::Map, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + table + .get(*key) + .and_then(toml::Value::as_str) + .map(str::to_string) + }) +} + +fn get_required_string( + table: &toml::map::Map, + keys: &[&str], +) -> Result { + get_optional_string(table, keys) + .filter(|value| !value.trim().is_empty()) + .with_context(|| format!("Missing required key '{}'", keys[0])) +} + +fn get_string_list( + table: &toml::map::Map, + keys: &[&str], +) -> Result> { + for key in keys { + let Some(value) = table.get(*key) else { + continue; + }; + return match value { + toml::Value::String(s) => Ok(vec![s.to_string()]), + toml::Value::Array(arr) => { + let mut out = Vec::new(); + for item in arr { + let value = item + .as_str() + .with_context(|| format!("Expected string elements for key '{}'", key))?; + out.push(value.to_string()); + } + Ok(out) + } + _ => bail!("Expected string or string array for key '{}'", key), + }; + } + Ok(Vec::new()) +} + +fn get_optional_bool( + table: &toml::map::Map, + keys: &[&str], +) -> Result> { + for key in keys { + let Some(value) = table.get(*key) else { + continue; + }; + return value + .as_bool() + .map(Some) + .with_context(|| format!("Expected boolean for key '{}'", key)); + } + Ok(None) +} + +fn run_hook_command( + rootfs: &Path, + hook: &TransactionHook, + ctx: &HookExecutionContext<'_>, + normalized_paths: &[String], +) -> Result<()> { + let hook_name = hook.display_name(); + crate::log_info!( + "Running transaction hook '{}' ({}) for {}:{}:{}", + hook_name, + hook.file_path.display(), + ctx.operation.as_str(), + ctx.phase.as_str(), + ctx.package + ); + + let stdin_payload = if hook.needs_paths { + let mut payload = normalized_paths.join("\n"); + if !payload.is_empty() { + payload.push('\n'); + } + Some(payload) + } else { + None + }; + + let mut command = if fakeroot::is_root() && rootfs.join("bin/sh").exists() { + let mut cmd = Command::new("chroot"); + cmd.arg(rootfs).arg("/bin/sh").arg("-lc").arg(&hook.command); + cmd + } else { + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-lc").arg(&hook.command).current_dir(rootfs); + cmd + }; + + command + .env("DEPOT_ACTION", ctx.operation.as_str()) + .env("DEPOT_PHASE", ctx.phase.as_str()) + .env("DEPOT_PACKAGE", ctx.package) + .env("DEPOT_ROOTFS", rootfs) + .env("DEPOT_HOOK_FILE", &hook.file_path) + .env("DEPOT_HOOK_NAME", &hook_name); + + let status = run_command_with_optional_stdin(&mut command, stdin_payload.as_deref()) + .with_context(|| { + format!( + "Failed to execute transaction hook '{}' ({})", + hook_name, + hook.file_path.display() + ) + })?; + + if !status.success() { + bail!( + "Transaction hook '{}' failed with status {}", + hook_name, + status + ); + } + + Ok(()) +} + +fn run_command_with_optional_stdin( + command: &mut Command, + stdin_payload: Option<&str>, +) -> Result { + if let Some(payload) = stdin_payload { + command.stdin(Stdio::piped()); + let mut child = command.spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(payload.as_bytes())?; + } + Ok(child.wait()?) + } else { + Ok(command.status()?) + } +} + +fn normalize_match_target(raw: &str) -> String { + raw.trim() + .trim_start_matches("./") + .trim_start_matches('/') + .to_string() +} + +fn wildcard_match(pattern: &str, text: &str) -> bool { + let p = pattern.as_bytes(); + let t = text.as_bytes(); + let mut dp = vec![vec![false; t.len() + 1]; p.len() + 1]; + dp[0][0] = true; + + for i in 1..=p.len() { + if p[i - 1] == b'*' { + dp[i][0] = dp[i - 1][0]; + } + } + + for i in 1..=p.len() { + for j in 1..=t.len() { + dp[i][j] = match p[i - 1] { + b'*' => dp[i - 1][j] || dp[i][j - 1], + b'?' => dp[i - 1][j - 1], + ch => dp[i - 1][j - 1] && ch == t[j - 1], + }; + } + } + + dp[p.len()][t.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_hook(rootfs: &Path, name: &str, content: &str) { + let dir = transaction_hooks_dir(rootfs); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(name), content).unwrap(); + } + + #[test] + fn wildcard_match_supports_star_and_question() { + assert!(wildcard_match("usr/lib/*", "usr/lib/libc.so")); + assert!(wildcard_match("lib??", "lib32")); + assert!(!wildcard_match("usr/bin/*", "usr/lib/libc.so")); + } + + #[test] + fn parse_hook_accepts_starpack_style_sections() { + let tmp = tempfile::tempdir().unwrap(); + write_hook( + tmp.path(), + "demo.toml", + r#" +[Hook] +Name = "demo" + +[When] +Phase = "PreTransaction" +Operation = "Install" +Package = "foo" + +[Exec] +Command = "true" +NeedsPaths = false +"#, + ); + + let path = transaction_hooks_dir(tmp.path()).join("demo.toml"); + let hook = parse_hook_file(&path).unwrap(); + assert_eq!(hook.name.as_deref(), Some("demo")); + assert_eq!(hook.phase, HookPhase::Pre); + assert_eq!(hook.operations, vec![HookOperation::Install]); + assert_eq!(hook.packages, vec!["foo".to_string()]); + } + + #[test] + fn run_transaction_hooks_executes_matching_hook() { + let tmp = tempfile::tempdir().unwrap(); + write_hook( + tmp.path(), + "record.toml", + r#" +[hook] +name = "record" + +[when] +phase = "pre" +operation = ["install"] +packages = ["foo*"] +paths = ["usr/bin/*"] + +[exec] +command = "printf '%s:%s:%s' \"$DEPOT_ACTION\" \"$DEPOT_PHASE\" \"$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\"" +needs_paths = true +"#, + ); + + let affected = vec!["usr/bin/foo".to_string()]; + let ctx = HookExecutionContext { + phase: HookPhase::Pre, + operation: HookOperation::Install, + package: "foo", + affected_paths: &affected, + }; + let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap(); + assert_eq!(ran, 1); + let out = std::fs::read_to_string(tmp.path().join("hook.out")).unwrap(); + assert_eq!(out, "install:pre:foo"); + } + + #[test] + fn run_transaction_hooks_respects_negation_and_filters_out() { + let tmp = tempfile::tempdir().unwrap(); + write_hook( + tmp.path(), + "skip.toml", + r#" +[hook] +name = "skip" + +[when] +phase = "pre" +operation = "remove" +paths = ["usr/lib/*"] +negation = ["usr/lib/debug/*"] + +[exec] +command = "touch \"$DEPOT_ROOTFS/should_not_exist\"" +"#, + ); + + let affected = vec!["usr/lib/debug/foo".to_string()]; + let ctx = HookExecutionContext { + phase: HookPhase::Pre, + operation: HookOperation::Remove, + package: "foo", + affected_paths: &affected, + }; + let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap(); + assert_eq!(ran, 0); + assert!(!tmp.path().join("should_not_exist").exists()); + } +} diff --git a/src/install/mod.rs b/src/install/mod.rs index 4ff53a6..c4bcebb 100644 --- a/src/install/mod.rs +++ b/src/install/mod.rs @@ -1,3 +1,4 @@ //! Installation helpers. +pub mod hooks; pub mod scripts; diff --git a/src/main.rs b/src/main.rs index 074ee9c..fadeffc 100755 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ //! A source-based package manager for Linux mod builder; +mod cli; +mod commands; mod config; mod cross; mod db; @@ -18,2140 +20,10 @@ mod source; mod staging; mod ui; -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand, ValueEnum}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn parse_licenses_from_toml(metadata: &toml::Value) -> Vec { - if let Some(s) = metadata.get("license").and_then(|v| v.as_str()) { - return vec![s.to_string()]; - } - if let Some(arr) = metadata.get("license").and_then(|v| v.as_array()) { - return arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - Vec::new() -} - -fn clean_build_workspace(config: &config::Config) -> Result<()> { - if config.build_dir.exists() { - fs::remove_dir_all(&config.build_dir).with_context(|| { - format!("Failed to clean build dir: {}", config.build_dir.display()) - })?; - ui::success(format!( - "Cleaned build workspace: {}", - config.build_dir.display() - )); - } - Ok(()) -} - -fn warn_if_running_as_root_for_build(command: &str, rootfs: &Path) { - if crate::fakeroot::is_root() { - ui::warn(format!("Running '{}' as root is discouraged.", command)); - ui::warn( - "A misconfigured build environment or malicious/buggy build file can overwrite or delete critical system files.", - ); - ui::warn("Recommendation: use a non-root build user and only install as root."); - ui::warn(format!("Current rootfs target: {}", rootfs.display())); - } -} - -fn output_destdir_for(base_destdir: &Path, primary_pkg: &str, output_pkg: &str) -> PathBuf { - if output_pkg == primary_pkg { - base_destdir.to_path_buf() - } else { - staging::output_staging_dir(base_destdir, output_pkg) - } -} - -fn lib32_package_name(name: &str) -> String { - format!("lib32-{name}") -} - -fn lib32_arch_name(arch: &str) -> String { - arch.replace("x86_64", "i686") -} - -fn make_lib32_build_spec(base: &package::PackageSpec) -> package::PackageSpec { - let mut spec = base.clone(); - let flags = &mut spec.build.flags; - flags.lib32_variant = true; - - if !flags.cflags_lib32.is_empty() { - flags.cflags.extend(flags.cflags_lib32.clone()); - } - if !flags.cxxflags_lib32.is_empty() { - flags.cxxflags.extend(flags.cxxflags_lib32.clone()); - } - if !flags.configure_lib32.is_empty() { - flags.configure = flags.configure_lib32.clone(); - } - if !flags.post_configure_lib32.is_empty() { - flags.post_configure = flags.post_configure_lib32.clone(); - } - if !flags.post_compile_lib32.is_empty() { - flags.post_compile = flags.post_compile_lib32.clone(); - } - if !flags.post_install_lib32.is_empty() { - flags.post_install = flags.post_install_lib32.clone(); - } - - flags.cc = format!("{} -m32", flags.cc.trim()); - flags.cxx = format!("{} -m32", flags.cxx.trim()); - flags.build_dir = Some(match flags.build_dir.as_deref() { - Some(dir) if !dir.trim().is_empty() => format!("{}-lib32", dir.trim()), - _ => "build-lib32".to_string(), - }); - - spec -} - -fn make_lib32_package_spec(base: &package::PackageSpec) -> package::PackageSpec { - let mut spec = base.clone(); - spec.package.name = lib32_package_name(&base.package.name); - // The lib32 pass currently emits a single companion package from /usr/lib32. - spec.packages.clear(); - spec -} - -fn copy_tree_preserving_links(src: &Path, dst: &Path) -> Result<()> { - use walkdir::WalkDir; - - fs::create_dir_all(dst) - .with_context(|| format!("Failed to create destination dir: {}", dst.display()))?; - - for entry in WalkDir::new(src) { - let entry = entry?; - let rel = entry - .path() - .strip_prefix(src) - .with_context(|| format!("Failed to strip prefix: {}", src.display()))?; - let target = dst.join(rel); - - if entry.file_type().is_dir() { - fs::create_dir_all(&target) - .with_context(|| format!("Failed to create dir: {}", target.display()))?; - continue; - } - - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create dir: {}", parent.display()))?; - } - - if entry.file_type().is_symlink() { - let link_target = fs::read_link(entry.path()) - .with_context(|| format!("Failed to read symlink: {}", entry.path().display()))?; - #[cfg(unix)] - { - use std::os::unix::fs as unix_fs; - unix_fs::symlink(&link_target, &target).with_context(|| { - format!( - "Failed to create symlink {} -> {}", - target.display(), - link_target.display() - ) - })?; - } - #[cfg(not(unix))] - { - anyhow::bail!( - "Symlink-preserving lib32 staging copy is only supported on unix hosts" - ); - } - } else { - fs::copy(entry.path(), &target).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.path().display(), - target.display() - ) - })?; - } - } - - Ok(()) -} - -fn build_lib32_companion_package( - pkg_spec: &package::PackageSpec, - src_dir: &Path, - config: &config::Config, - cross_config: Option<&cross::CrossConfig>, - export_compiler_flags: bool, - force: bool, -) -> Result> { - if !pkg_spec.build.flags.build_32 && !force { - return Ok(None); - } - if pkg_spec.is_metapackage() { - crate::log_warn!( - "Ignoring build.flags.build-32 for metapackage {}", - pkg_spec.package.name - ); - return Ok(None); - } - - crate::log_info!("Running separate lib32 build pass..."); - let mut lib32_input = pkg_spec.clone(); - lib32_input.build.flags.build_32 = true; - let lib32_build_spec = make_lib32_build_spec(&lib32_input); - let lib32_install_destdir = config - .build_dir - .join("destdir") - .join(".lib32-build") - .join(&pkg_spec.package.name) - .join("lib32-dest"); - - builder::build( - &lib32_build_spec, - src_dir, - &lib32_install_destdir, - cross_config, - export_compiler_flags, - )?; - - let lib32_src = lib32_install_destdir.join("usr/lib32"); - if !lib32_src.exists() { - anyhow::bail!( - "lib32 build completed but did not install usr/lib32 into {}", - lib32_install_destdir.display() - ); - } - - let lib32_pkg_spec = make_lib32_package_spec(pkg_spec); - let lib32_destdir = config - .build_dir - .join("destdir") - .join(&lib32_pkg_spec.package.name); - if lib32_destdir.exists() { - fs::remove_dir_all(&lib32_destdir).with_context(|| { - format!("Failed to clean lib32 destdir: {}", lib32_destdir.display()) - })?; - } - fs::create_dir_all(&lib32_destdir).with_context(|| { - format!( - "Failed to create lib32 destdir: {}", - lib32_destdir.display() - ) - })?; - - copy_tree_preserving_links(&lib32_src, &lib32_destdir.join("usr/lib32"))?; - staging::add_licenses(src_dir, &lib32_destdir, &lib32_pkg_spec.package.name)?; - install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; - staging::process(&lib32_destdir, &lib32_pkg_spec)?; - - Ok(Some((lib32_pkg_spec, lib32_destdir))) -} - -fn install_staged_to_rootfs( - pkg_spec: &package::PackageSpec, - destdir: &Path, - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - std::fs::create_dir_all(&config.db_dir).with_context(|| { - format!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - let db_path = config.db_dir.join("packages.db"); - - let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some(); - let staged_scripts_dir = install::scripts::staged_scripts_dir(destdir); - let installed_scripts_dir = - install::scripts::installed_scripts_dir(rootfs, &pkg_spec.package.name); - - if is_update { - let has_staged_pre = install::scripts::run_hook_if_present( - &staged_scripts_dir, - install::scripts::Hook::PreUpdate, - rootfs, - &pkg_spec.package.name, - )?; - if !has_staged_pre { - let _ = install::scripts::run_hook_if_present( - &installed_scripts_dir, - install::scripts::Hook::PreUpdate, - rootfs, - &pkg_spec.package.name, - )?; - } - } else { - let _ = install::scripts::run_hook_if_present( - &staged_scripts_dir, - install::scripts::Hook::PreInstall, - rootfs, - &pkg_spec.package.name, - )?; - } - - let new_files = staging::generate_manifest_with_dirs(destdir)?; - let remove_paths = - db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?; - - let tx_base = config.build_dir.join("tx"); - let tx = staging::install_atomic( - destdir, - rootfs, - &tx_base, - &remove_paths, - &pkg_spec.build.flags.keep, - )?; - - if let Err(e) = db::register_package(&db_path, pkg_spec, destdir) { - let _ = tx.rollback(); - return Err(e); - } - tx.commit()?; - - install::scripts::sync_staged_scripts_to_rootfs( - &staged_scripts_dir, - rootfs, - &pkg_spec.package.name, - )?; - - if is_update { - let _ = install::scripts::run_hook_if_present( - &installed_scripts_dir, - install::scripts::Hook::PostUpdate, - rootfs, - &pkg_spec.package.name, - )?; - } else { - let _ = install::scripts::run_hook_if_present( - &installed_scripts_dir, - install::scripts::Hook::PostInstall, - rootfs, - &pkg_spec.package.name, - )?; - } - - 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)] -#[command(version)] -struct Cli { - /// Custom root filesystem path - #[arg(long, short = 'r', default_value = "/", global = true)] - rootfs: PathBuf, - - /// Skip dependency checks - #[arg(long, global = true)] - no_deps: bool, - - /// Do not export CFLAGS/CXXFLAGS/LDFLAGS to build commands - #[arg( - long, - global = true, - action = clap::ArgAction::Set, - num_args = 0..=1, - default_value_t = false, - default_missing_value = "true" - )] - no_flags: bool, - - /// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu) - #[arg(long, global = true)] - cross_prefix: Option, - - /// Clean build workspace after successful install/build - #[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, - - /// Build/install only the lib32 companion package path (skip primary package output) - #[arg(long, global = true)] - lib32_only: bool, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Build and install a package from a spec file - Install { - /// Path to package spec (.toml) or package archive (.tar.zst) - #[arg(value_name = "SPEC_OR_ARCHIVE")] - spec_or_archive: PathBuf, - - /// Explicitly specify path to package spec (.toml file) - #[arg(short, long = "spec", visible_alias = "package", alias = "p")] - spec: Option, - }, - /// Remove an installed package - Remove { - /// Package name to remove - package: String, - }, - /// Build a package without installing - Build { - /// Path to package spec (.toml file) - #[arg(value_name = "SPEC")] - spec_pos: Option, - - /// Explicitly specify path to package spec (.toml file) - #[arg(short, long = "spec", visible_alias = "package", alias = "p")] - spec: Option, - - /// Install package to rootfs after creating package archive(s) - #[arg(long)] - install: bool, - }, - /// Show information about a package - Info { - /// 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)] - command: RepoCommands, - }, - /// Show current configuration - Config, - /// Create a new package specification interactively - MakeSpec { - /// Output file path (defaults to .toml) - #[arg(short, long)] - output: Option, - }, -} - -#[derive(Subcommand)] -enum RepoCommands { - /// Create a repository database from a directory of packages - Create { - /// Directory containing .depot.pkg.tar.zst files - #[arg(default_value = ".")] - dir: PathBuf, - }, - /// 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, -} +use anyhow::Result; +use clap::Parser; fn main() -> Result<()> { - let cli = Cli::parse(); - ui::set_assume_yes(cli.yes); - - match cli.command { - Commands::Install { - spec_or_archive, - spec, - } => { - warn_if_running_as_root_for_build("install", &cli.rootfs); - let mut spec_path = spec.unwrap_or(spec_or_archive); - - // 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. - - // If the provided path doesn't exist, treat it as a package name and - // try to locate a spec under configured repo dir or local packages/. - if !spec_path.exists() { - let name = spec_path.to_string_lossy().to_string(); - ui::info(format!("Looking up package '{}' in local indexes...", name)); - let pkg_index = - index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); - if let Some(found) = pkg_index.find(&name) { - 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); - } - } - } - } - } - - ui::info(format!("Installing package from: {}", spec_path.display())); - - let (pkg_spec, staging_dir): (package::PackageSpec, Option) = - if spec_path.to_string_lossy().ends_with(".tar.zst") { - // Install from archive - ui::info(format!("Detected package archive: {}", spec_path.display())); - let tmp_dir = tempfile::TempDir::new()?; - let extract_dir = tmp_dir.path().to_path_buf(); - - // Extract metadata.toml first to get spec - let file = fs::File::open(&spec_path)?; - let zstd_decoder = zstd::stream::read::Decoder::new(file)?; - let mut archive = tar::Archive::new(zstd_decoder); - - let mut metadata_content = String::new(); - for entry in archive.entries()? { - let mut entry = entry?; - if entry.path()?.to_string_lossy() == ".metadata.toml" { - use std::io::Read; - entry.read_to_string(&mut metadata_content)?; - break; - } - } - - if metadata_content.is_empty() { - anyhow::bail!( - "Package archive does not contain .metadata.toml: {}", - spec_path.display() - ); - } - - let file = fs::File::open(&spec_path)?; - let zstd_decoder = zstd::stream::read::Decoder::new(file)?; - let mut archive = tar::Archive::new(zstd_decoder); - archive.unpack(&extract_dir)?; - - let metadata: toml::Value = toml::from_str(&metadata_content)?; - - // Create a minimal spec from metadata - let mut spec = package::PackageSpec { - package: package::PackageInfo { - name: metadata - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - version: metadata - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - revision: metadata - .get("revision") - .and_then(|v| v.as_integer()) - .unwrap_or(1) as u32, - description: metadata - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - homepage: metadata - .get("homepage") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - license: parse_licenses_from_toml(&metadata), - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies { - build: Vec::new(), - runtime: if let Some(deps) = metadata - .get("dependencies") - .and_then(|v| v.get("runtime")) - .and_then(|v| v.as_array()) - { - deps.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect() - } else { - Vec::new() - }, - test: if let Some(deps) = metadata - .get("dependencies") - .and_then(|v| v.get("test")) - .and_then(|v| v.as_array()) - { - deps.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect() - } else { - Vec::new() - }, - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { - spec.alternatives.provides = provides - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - - (spec, Some(tmp_dir)) - } else { - // Install from spec (normal build) - let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; - pkg_spec.apply_config(&config); - (pkg_spec, None) - }; - - if cli.lib32_only && staging_dir.is_some() { - anyhow::bail!("--lib32-only is only supported when installing from a package spec"); - } - - ui::info(format!( - "Package: {} v{}-{}", - 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!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - let db_path = config.db_dir.join("packages.db"); - - // Check dependencies and prompt for auto-install if needed - if !cli.no_deps { - let needs_test_deps = - matches!(pkg_spec.build.build_type, package::BuildType::Autotools); - deps::print_dep_status(&pkg_spec, &db_path)?; - - // Collect all missing dependencies (build + runtime) - let mut missing = deps::check_build_deps(&pkg_spec, &db_path)?; - let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?; - - for dep in missing_runtime { - if !missing.contains(&dep) { - missing.push(dep); - } - } - if needs_test_deps { - let missing_test = deps::check_test_deps(&pkg_spec, &db_path)?; - for dep in missing_test { - if !missing.contains(&dep) { - missing.push(dep); - } - } - } - - if !missing.is_empty() { - // Check for dependency cycles via DEPOT_DEPCHAIN env var - let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default(); - let chain_set: std::collections::HashSet<&str> = - dep_chain.split(',').filter(|s| !s.is_empty()).collect(); - - if chain_set.contains(pkg_spec.package.name.as_str()) { - anyhow::bail!( - "Dependency cycle detected! {} is already in chain: {}", - pkg_spec.package.name, - dep_chain - ); - } - - ui::warn(format!("Missing dependencies: {}", missing.join(", "))); - if ui::prompt_yes_no("Attempt to install them now?", true)? { - // Build package index for fast lookups - let pkg_index = index::PackageIndex::build_with_repo_dir(Some( - config.repo_clone_dir.clone(), - )); - - // Build new dep chain - let new_chain = if dep_chain.is_empty() { - pkg_spec.package.name.clone() - } else { - format!("{},{}", dep_chain, pkg_spec.package.name) - }; - - // Attempt to install missing deps - for dep in missing { - // Use package index for O(1) lookup - let candidate = pkg_index.find(&dep); - - if let Some(dep_spec_path) = candidate { - ui::info(format!("Installing dependency: {}...", dep)); - - let mut cmd = std::process::Command::new(std::env::current_exe()?); - cmd.arg("-r").arg(&cli.rootfs); - - if cli.no_deps { - cmd.arg("--no-deps"); - } - if cli.no_flags { - cmd.arg("--no-flags"); - } - if let Some(ref p) = cli.cross_prefix { - cmd.arg("--cross-prefix").arg(p); - } - if cli.clean { - cmd.arg("--clean"); - } - - cmd.arg("install").arg(&dep_spec_path); - cmd.env("DEPOT_DEPCHAIN", &new_chain); - - let status = cmd.status()?; - - if !status.success() { - anyhow::bail!("Failed to install dependency: {}", dep); - } - } else { - anyhow::bail!( - "Could not find package spec for dependency: {}", - dep - ); - } - } - } - } - - // Enforce required dependencies before building/installing. - deps::require_build_deps(&pkg_spec, &db_path)?; - deps::require_runtime_deps(&pkg_spec, &db_path)?; - if needs_test_deps { - deps::require_test_deps(&pkg_spec, &db_path)?; - } - } - - let cross_config = cli - .cross_prefix - .as_ref() - .map(|p| cross::CrossConfig::from_prefix(p)) - .transpose()?; - let mut built_src_dir: Option = None; - - let destdir = if let Some(dir) = &staging_dir { - dir.path().to_path_buf() - } else { - // 1-2. Fetch + extract sources (supports archives and git URL#rev) - let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; - built_src_dir = Some(src_dir.clone()); - - // 3. Build - let destdir = config - .build_dir - .join("destdir") - .join(&pkg_spec.package.name); - - if !cli.lib32_only { - builder::build( - &pkg_spec, - &src_dir, - &destdir, - cross_config.as_ref(), - !cli.no_flags, - )?; - - // 3.1 Copy license files into staged tree - staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; - install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; - } - - destdir - }; - - if !cli.lib32_only { - // 4. Stage (clean .la files, etc.) - staging::process(&destdir, &pkg_spec)?; - - // 5-6. Install/update to rootfs and register in DB - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = - output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); - install_staged_to_rootfs(&spec_for_out, &out_destdir, &cli.rootfs, &config)?; - - ui::success(format!( - "Successfully installed {} v{}", - spec_for_out.package.name, spec_for_out.package.version - )); - // TODO(snapper): create post-install snapshot after install commit succeeds. - } - } - - if let Some(src_dir) = built_src_dir.as_deref() - && let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( - &pkg_spec, - src_dir, - &config, - cross_config.as_ref(), - !cli.no_flags, - cli.lib32_only, - )? - { - install_staged_to_rootfs(&lib32_spec, &lib32_destdir, &cli.rootfs, &config)?; - ui::success(format!( - "Successfully installed {} v{}", - lib32_spec.package.name, lib32_spec.package.version - )); - } - - if cli.clean { - clean_build_workspace(&config)?; - } - } - 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( - &script_dir, - install::scripts::Hook::PreRemove, - &cli.rootfs, - &package, - )?; - db::remove_package(&db_path, &package, &cli.rootfs)?; - let post_remove = install::scripts::run_hook_if_present( - &script_dir, - install::scripts::Hook::PostRemove, - &cli.rootfs, - &package, - ); - let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package); - post_remove?; - cleanup_scripts?; - ui::success(format!("Successfully removed {}", package)); - } - Commands::Build { - spec_pos, - spec, - install, - } => { - warn_if_running_as_root_for_build("build", &cli.rootfs); - let spec_path = spec.or(spec_pos).context("No spec file provided")?; - ui::info(format!("Building package from: {}", spec_path.display())); - let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; - - let config = config::Config::for_rootfs(&cli.rootfs); - - // Apply system overrides - pkg_spec.apply_config(&config); - - // Ensure database directory exists - std::fs::create_dir_all(&config.db_dir).with_context(|| { - format!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - let db_path = config.db_dir.join("packages.db"); - - // Check build dependencies - if !cli.no_deps { - 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 - .build_dir - .join("destdir") - .join(&pkg_spec.package.name); - // Build with optional cross-compilation - let cross_config = cli - .cross_prefix - .as_ref() - .map(|p| cross::CrossConfig::from_prefix(p)) - .transpose()?; - if !cli.lib32_only { - builder::build( - &pkg_spec, - &src_dir, - &destdir, - cross_config.as_ref(), - !cli.no_flags, - )?; - } - - if !cli.lib32_only { - staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; - install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; - staging::process(&destdir, &pkg_spec)?; - } - - // Create package archive(s) — support multiple outputs from a single spec. - let arch = cli - .cross_prefix - .as_deref() - .unwrap_or(std::env::consts::ARCH); - - let mut created_files = Vec::new(); - if !cli.lib32_only { - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = - output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); - let packager = - package::Packager::new(spec_for_out.clone(), 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); - } - } - - let mut lib32_install_bundle: Option<(package::PackageSpec, PathBuf)> = None; - if let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( - &pkg_spec, - &src_dir, - &config, - cross_config.as_ref(), - !cli.no_flags, - cli.lib32_only, - )? { - let lib32_arch = lib32_arch_name(arch); - let packager = package::Packager::new( - lib32_spec.clone(), - lib32_destdir.clone(), - config.clone(), - ); - let pkg_file = packager.create_package(Path::new("."), &lib32_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); - lib32_install_bundle = Some((lib32_spec, lib32_destdir)); - } - - 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"); - } - if !cli.lib32_only { - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = - output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); - install_staged_to_rootfs( - &spec_for_out, - &out_destdir, - &cli.rootfs, - &config, - )?; - ui::success(format!( - "Successfully installed {} v{}", - spec_for_out.package.name, spec_for_out.package.version - )); - // TODO(snapper): create post-install snapshot after --install commit succeeds. - } - } - if let Some((lib32_spec, lib32_destdir)) = &lib32_install_bundle { - install_staged_to_rootfs(lib32_spec, lib32_destdir, &cli.rootfs, &config)?; - ui::success(format!( - "Successfully installed {} v{}", - lib32_spec.package.name, lib32_spec.package.version - )); - } - } - - if cli.clean { - clean_build_workspace(&config)?; - } - } - Commands::Info { package } => { - // 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 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 = cfg; - let mirrors = config.enabled_source_mirror_map(); - if mirrors.is_empty() { - ui::info("No enabled source repos configured"); - } else { - db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; - ui::success(format!( - "Source repos synchronized into {}", - config.repo_clone_dir.display() - )); - } - } - RepoCommands::Update { 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); - let repo_lock = locking::open_lock(&config)?; - let repo_lock_path = locking::lock_path(&config); - let _repo_lock_guard = - locking::try_read(&repo_lock, &repo_lock_path, "repo status")?; - let mirrors = config.enabled_source_mirror_map(); - if mirrors.is_empty() { - ui::info("No enabled source repos configured"); - } else { - db::repo::mirrors_status(&config.repo_clone_dir, &mirrors)?; - } - if config.binary_repos.is_empty() { - ui::info("No binary repos configured"); - } else { - ui::info("Binary repo configuration:"); - let host_arch = std::env::consts::ARCH; - for (name, repo) in &config.binary_repos { - let arch_keys = if repo.arch.is_empty() { - "(any)".to_string() - } else { - repo.arch.keys().cloned().collect::>().join(",") - }; - ui::info(format!( - " {} [{}] url={} repo_db={} arches={} host_match={}", - name, - if repo.enabled { "enabled" } else { "disabled" }, - repo.url, - repo.repo_db, - arch_keys, - if repo.supports_arch(host_arch) { - "yes" - } else { - "no" - } - )); - } - } - } - }, - Commands::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() { - println!("\nAppends:"); - for (k, v) in &config.appends { - println!(" {} = {:?}", k, v); - } - } - } - Commands::MakeSpec { output } => { - let spec = package::create_interactive()?; - // Produce a minimal TOML for interactive-created specs (omit defaults) - let toml_string = package::spec_to_minimal_toml(&spec)?; - - let output_path = - output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); - - if output_path.exists() { - ui::warn(format!("File {} already exists.", output_path.display())); - if !ui::prompt_yes_no("Overwrite it?", false)? { - anyhow::bail!("Aborted"); - } - } - - fs::write(&output_path, toml_string)?; - ui::success(format!( - "Package specification saved to {}", - output_path.display() - )); - } - } - - Ok(()) + let cli = cli::Cli::parse(); + commands::run(cli) } diff --git a/src/package/interactive.rs b/src/package/interactive.rs index bb04686..13fbcd2 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -640,6 +640,10 @@ pub fn create_interactive() -> Result { "Test dependency", "Package needed only for running package test suites", )?; + let optional_deps = prompt_repeating_list( + "Optional dependency", + "Package that enables optional runtime functionality", + )?; Ok(PackageSpec { package: PackageInfo { @@ -659,6 +663,7 @@ pub fn create_interactive() -> Result { build: build_deps, runtime: runtime_deps, test: test_deps, + optional: optional_deps, }, package_alternatives: Default::default(), package_dependencies: Default::default(), @@ -875,6 +880,12 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if spec.build.flags.no_strip != defaults.no_strip { flags_tbl.insert("no_strip".into(), Value::Boolean(spec.build.flags.no_strip)); } + if spec.build.flags.no_delete_static != defaults.no_delete_static { + flags_tbl.insert( + "no_delete_static".into(), + Value::Boolean(spec.build.flags.no_delete_static), + ); + } if spec.build.flags.no_compress_man != defaults.no_compress_man { flags_tbl.insert( "no_compress_man".into(), @@ -1221,6 +1232,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if !spec.dependencies.build.is_empty() || !spec.dependencies.runtime.is_empty() || !spec.dependencies.test.is_empty() + || !spec.dependencies.optional.is_empty() { let mut dep_tbl = Table::new(); if !spec.dependencies.build.is_empty() { @@ -1259,6 +1271,18 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.dependencies.optional.is_empty() { + dep_tbl.insert( + "optional".into(), + Value::Array( + spec.dependencies + .optional + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } root.insert("dependencies".into(), Value::Table(dep_tbl)); } @@ -1412,6 +1436,7 @@ mod tests { flags.keep = vec!["etc/locale.gen".into()]; flags.no_flags = true; flags.no_strip = true; + flags.no_delete_static = true; flags.no_compress_man = true; flags.skip_tests = true; flags.make_vars = vec!["V=1".into()]; @@ -1467,6 +1492,7 @@ mod tests { assert!(toml.contains("\"etc/locale.gen\"")); assert!(toml.contains("no_flags = true")); assert!(toml.contains("no_strip = true")); + assert!(toml.contains("no_delete_static = true")); assert!(toml.contains("no_compress_man = true")); assert!(toml.contains("skip_tests = true")); assert!(toml.contains("make_vars = [")); @@ -1515,7 +1541,7 @@ mod tests { } #[test] - fn spec_to_minimal_toml_includes_test_dependencies() { + fn spec_to_minimal_toml_includes_test_and_optional_dependencies() { let spec = PackageSpec { package: PackageInfo { name: "foo".into(), @@ -1543,6 +1569,7 @@ mod tests { build: vec![], runtime: vec![], test: vec!["python".into(), "bats".into()], + optional: vec!["gtk-doc".into()], }, package_alternatives: Default::default(), package_dependencies: Default::default(), @@ -1559,6 +1586,13 @@ mod tests { assert_eq!(test_deps.len(), 2); assert_eq!(test_deps[0].as_str(), Some("python")); assert_eq!(test_deps[1].as_str(), Some("bats")); + let optional_deps = val + .get("dependencies") + .and_then(|d| d.get("optional")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.optional array"); + assert_eq!(optional_deps.len(), 1); + assert_eq!(optional_deps[0].as_str(), Some("gtk-doc")); } #[test] @@ -1584,6 +1618,7 @@ mod tests { build: Vec::new(), runtime: vec!["foo".into(), "bar".into()], test: Vec::new(), + optional: Vec::new(), }, package_alternatives: Default::default(), package_dependencies: Default::default(), diff --git a/src/package/packager.rs b/src/package/packager.rs index b3edba3..cd92569 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -159,20 +159,9 @@ impl Packager { ), ); - // Add dependencies if useful for repo indexing - let mut build_deps = toml::map::Map::new(); - build_deps.insert( - "build".to_string(), - toml::Value::Array( - self.spec - .dependencies - .build - .iter() - .map(|s| toml::Value::String(s.clone())) - .collect(), - ), - ); - build_deps.insert( + // Add install-relevant dependency kinds for repo/runtime consumers. + let mut deps = toml::map::Map::new(); + deps.insert( "runtime".to_string(), toml::Value::Array( self.spec @@ -183,18 +172,18 @@ impl Packager { .collect(), ), ); - build_deps.insert( - "test".to_string(), + deps.insert( + "optional".to_string(), toml::Value::Array( self.spec .dependencies - .test + .optional .iter() .map(|s| toml::Value::String(s.clone())) .collect(), ), ); - map.insert("dependencies".to_string(), toml::Value::Table(build_deps)); + map.insert("dependencies".to_string(), toml::Value::Table(deps)); let toml_str = toml::to_string(&toml::Value::Table(map)) .context("Failed to serialize metadata to TOML")?; @@ -357,9 +346,10 @@ mod tests { assert_eq!(val.get("license").and_then(|v| v.as_str()), Some("MIT")); let deps = val.get("dependencies").unwrap(); - assert!(deps.get("build").unwrap().as_array().unwrap().is_empty()); assert!(deps.get("runtime").unwrap().as_array().unwrap().is_empty()); - assert!(deps.get("test").unwrap().as_array().unwrap().is_empty()); + assert!(deps.get("optional").unwrap().as_array().unwrap().is_empty()); + assert!(deps.get("build").is_none()); + assert!(deps.get("test").is_none()); } #[test] diff --git a/src/package/spec.rs b/src/package/spec.rs index f30a93b..58ac62e 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::fs; use std::path::Path; @@ -192,6 +192,22 @@ impl PackageSpec { .unwrap_or_else(|| self.dependencies.clone()) } + /// Return all package names/provided features produced by this spec. + /// + /// This includes all output package names and per-output `provides` entries. + pub fn local_dependency_provides(&self) -> HashSet { + let mut names = HashSet::new(); + for output in self.outputs() { + let output_name = output.name.clone(); + names.insert(output_name.clone()); + let alternatives = self.alternatives_for_output(&output_name); + for provided in alternatives.provides { + names.insert(provided); + } + } + names + } + /// Return alternatives/provides for a specific output package name. /// /// If no per-output override exists, returns the top-level alternatives. @@ -481,6 +497,11 @@ impl PackageSpec { self.build.flags.no_strip = b; } } + "no_delete_static" | "no-delete-static" => { + if let Some(b) = v.as_bool() { + self.build.flags.no_delete_static = b; + } + } "no_compress_man" | "no-compress-man" | "no_compress_manpages" @@ -997,6 +1018,11 @@ impl PackageSpec { self.build.flags.no_strip = b; } } + "no_delete_static" | "no-delete-static" => { + if let Some(b) = values.last().and_then(|v| v.as_bool()) { + self.build.flags.no_delete_static = b; + } + } "no_compress_man" | "no-compress-man" | "no_compress_manpages" @@ -1535,6 +1561,7 @@ make_test_dirs = ["tests"] make_install_dirs = ["lib"] no_flags = true no_strip = true +no_delete_static = true no_compress_man = true skip_tests = true keep = ["etc/locale.gen"] @@ -1572,6 +1599,10 @@ post_configure = ["echo configured"] "build.flags.no_compress_man".to_string(), vec![toml::Value::Boolean(false)], ); + config.appends.insert( + "build.flags.no_delete_static".to_string(), + vec![toml::Value::Boolean(false)], + ); config.appends.insert( "build.flags.passthrough_env".to_string(), vec![toml::Value::String("CARGO_HOME".to_string())], @@ -1627,6 +1658,7 @@ post_configure = ["echo configured"] ); assert!(spec.build.flags.no_flags); assert!(!spec.build.flags.no_strip); + assert!(!spec.build.flags.no_delete_static); assert!(!spec.build.flags.no_compress_man); assert!( spec.build @@ -1735,7 +1767,7 @@ no_flags = true } #[test] - fn parse_no_strip_and_no_compress_man_from_spec() { + fn parse_no_strip_no_delete_static_and_no_compress_man_from_spec() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("pkg.toml"); @@ -1759,6 +1791,7 @@ type = "custom" [build.flags] no_strip = true +"no-delete-static" = true no-compress-man = true "#, ) @@ -1766,6 +1799,7 @@ no-compress-man = true let spec = PackageSpec::from_file(&path).unwrap(); assert!(spec.build.flags.no_strip); + assert!(spec.build.flags.no_delete_static); assert!(spec.build.flags.no_compress_man); } @@ -2128,10 +2162,11 @@ extract_dir = "foo" [build] type = "autotools" -[dependencies] -build = ["make"] -test = ["python", "bats"] -"#, + [dependencies] + build = ["make"] + test = ["python", "bats"] + optional = ["gtk-doc"] + "#, ) .unwrap(); @@ -2140,6 +2175,7 @@ test = ["python", "bats"] spec.dependencies.test, vec!["python".to_string(), "bats".to_string()] ); + assert_eq!(spec.dependencies.optional, vec!["gtk-doc".to_string()]); } #[test] @@ -2576,6 +2612,14 @@ pub struct BuildFlags { /// Disable automatic stripping of ELF files during staging. #[serde(default, alias = "no-strip")] pub no_strip: bool, + /// Disable automatic deletion of static libraries (`*.a`) during staging. + #[serde( + default, + alias = "no-delete-static", + alias = "no_remove_static", + alias = "no-remove-static" + )] + pub no_delete_static: bool, /// Disable automatic zstd compression of man pages during staging. #[serde( default, @@ -2814,6 +2858,7 @@ impl Default for BuildFlags { keep: Vec::new(), no_flags: false, no_strip: false, + no_delete_static: false, no_compress_man: false, skip_tests: false, build_32: false, @@ -2981,4 +3026,7 @@ pub struct Dependencies { /// Dependencies required to run package test suites. #[serde(default)] pub test: Vec, + /// Optional runtime integrations that enhance functionality when installed. + #[serde(default)] + pub optional: Vec, } diff --git a/src/planner.rs b/src/planner.rs index 83981e2..d453746 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -8,7 +8,7 @@ use crate::config::Config; use crate::db; use crate::deps; use crate::index::PackageIndex; -use crate::package::{BuildType, PackageSpec}; +use crate::package::PackageSpec; use crate::ui; use anyhow::{Context, Result}; use petgraph::algo::toposort; @@ -601,16 +601,14 @@ impl<'a> Resolver<'a> { fn source_deps_for_install(spec: &PackageSpec) -> Vec { let mut deps_all = Vec::new(); + let local_provides = spec.local_dependency_provides(); if !spec.is_metapackage() { 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 { + if !local_provides.contains(deps::dep_name(dep)) { push_unique(&mut deps_all, dep.clone()); } } @@ -618,7 +616,9 @@ fn source_deps_for_install(spec: &PackageSpec) -> Vec { for out in spec.outputs() { let deps = spec.dependencies_for_output(&out.name); for dep in deps.runtime { - push_unique(&mut deps_all, dep); + if !local_provides.contains(deps::dep_name(&dep)) { + push_unique(&mut deps_all, dep); + } } } deps_all @@ -710,3 +710,88 @@ pub(crate) fn build_dependency_install_plan( ) -> Result { Resolver::new(config, rootfs, opts).plan_for_deps(deps_to_install) } + +#[cfg(test)] +mod tests { + use super::source_deps_for_install; + use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, + }; + use std::collections::BTreeMap; + use std::path::PathBuf; + + fn mk_spec() -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: vec![PackageInfo { + name: "foo-libs".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }], + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.test/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies { + build: vec!["make".into()], + runtime: vec!["foo-libs".into(), "zlib".into()], + test: vec!["bats".into()], + optional: vec!["docs-viewer".into()], + }, + package_alternatives: BTreeMap::from([( + "foo-libs".into(), + Alternatives { + provides: vec!["libfoo".into()], + replaces: Vec::new(), + }, + )]), + package_dependencies: BTreeMap::from([( + "foo".into(), + Dependencies { + build: Vec::new(), + runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()], + test: Vec::new(), + optional: Vec::new(), + }, + )]), + spec_dir: PathBuf::from("."), + } + } + + #[test] + fn source_deps_for_install_excludes_local_runtime_outputs_and_provides() { + let spec = mk_spec(); + let deps = source_deps_for_install(&spec); + assert!(deps.contains(&"make".to_string())); + assert!(deps.contains(&"zlib".to_string())); + assert!(deps.contains(&"openssl".to_string())); + assert!(!deps.contains(&"foo-libs".to_string())); + assert!(!deps.contains(&"libfoo".to_string())); + } + + #[test] + fn source_deps_for_install_does_not_include_test_deps() { + let spec = mk_spec(); + let deps = source_deps_for_install(&spec); + assert!(!deps.contains(&"bats".to_string())); + } +} diff --git a/src/source/extractor.rs b/src/source/extractor.rs index a6ef4a8..e9852bf 100755 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -2,6 +2,7 @@ use crate::package::{PackageSpec, Source}; use anyhow::{Context, Result, bail}; +use filetime::FileTime; use flate2::read::GzDecoder; use std::fs::{self, File}; use std::io::{Cursor, Read, Write}; @@ -605,20 +606,61 @@ fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> { let entry = entry?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); + let file_type = entry.file_type()?; if fs::rename(&src_path, &dest_path).is_err() { // fallback to copy-and-remove across filesystems - if src_path.is_dir() { - copy_dir_recursive_local(&src_path, &dest_path)?; - fs::remove_dir_all(&src_path)?; - } else { - fs::copy(&src_path, &dest_path)?; - fs::remove_file(&src_path)?; - } + copy_entry_fallback(&src_path, &dest_path, file_type)?; } } Ok(()) } +fn copy_entry_fallback(src_path: &Path, dest_path: &Path, file_type: fs::FileType) -> Result<()> { + if file_type.is_dir() { + copy_dir_recursive_local(src_path, dest_path)?; + fs::remove_dir_all(src_path)?; + } else if file_type.is_symlink() { + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + let link_target = fs::read_link(src_path) + .with_context(|| format!("Failed to read symlink {}", src_path.display()))?; + unix_fs::symlink(&link_target, dest_path).with_context(|| { + format!( + "Failed to create symlink {} -> {}", + dest_path.display(), + link_target.display() + ) + })?; + fs::remove_file(src_path)?; + } else { + copy_file_preserve_metadata(src_path, dest_path)?; + fs::remove_file(src_path)?; + } + Ok(()) +} + +fn copy_file_preserve_metadata(src: &Path, dst: &Path) -> Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(src, dst) + .with_context(|| format!("Failed to copy {} -> {}", src.display(), dst.display()))?; + + let src_meta = fs::metadata(src) + .with_context(|| format!("Failed to read metadata for {}", src.display()))?; + fs::set_permissions(dst, src_meta.permissions()) + .with_context(|| format!("Failed to set permissions for {}", dst.display()))?; + + let atime = FileTime::from_last_access_time(&src_meta); + let mtime = FileTime::from_last_modification_time(&src_meta); + filetime::set_file_times(dst, atime, mtime) + .with_context(|| format!("Failed to preserve file timestamps for {}", dst.display()))?; + + Ok(()) +} + fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { for entry in WalkDir::new(src) { let entry = entry?; @@ -633,7 +675,7 @@ fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } - fs::copy(entry.path(), &target)?; + copy_file_preserve_metadata(entry.path(), &target)?; } } Ok(()) @@ -643,6 +685,7 @@ fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { mod tests { use super::*; use std::io::Write; + use std::time::{Duration, SystemTime}; use tempfile::tempdir; #[test] @@ -690,6 +733,56 @@ mod tests { assert!(extract_dir.join("usr/bin/hello-deb").exists()); } + #[test] + fn copy_file_preserve_metadata_keeps_mtime() { + let tmp = tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::write(&src, b"hello").unwrap(); + + let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); // 2000-01-01 UTC + let ts = FileTime::from_system_time(fixed); + filetime::set_file_times(&src, ts, ts).unwrap(); + + copy_file_preserve_metadata(&src, &dst).unwrap(); + let src_meta = std::fs::metadata(&src).unwrap(); + let dst_meta = std::fs::metadata(&dst).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(&dst_meta), + FileTime::from_last_modification_time(&src_meta) + ); + } + + #[test] + fn copy_entry_fallback_preserves_symlink_when_target_is_missing() { + let tmp = tempdir().unwrap(); + let src_dir = tmp.path().join("src"); + let dst_dir = tmp.path().join("dst"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + + let src_link = src_dir.join("RELEASE-NOTES"); + std::os::unix::fs::symlink("doc/RelNotes/v1.47.3.txt", &src_link).unwrap(); + + // Simulate target already moved/removed before copying this symlink entry. + let src_target_dir = src_dir.join("doc"); + std::fs::create_dir_all(src_target_dir.join("RelNotes")).unwrap(); + std::fs::write(src_target_dir.join("RelNotes/v1.47.3.txt"), "notes").unwrap(); + std::fs::remove_dir_all(&src_target_dir).unwrap(); + + let dst_link = dst_dir.join("RELEASE-NOTES"); + let file_type = std::fs::symlink_metadata(&src_link).unwrap().file_type(); + copy_entry_fallback(&src_link, &dst_link, file_type).unwrap(); + + assert!(std::fs::symlink_metadata(&src_link).is_err()); + let dst_meta = std::fs::symlink_metadata(&dst_link).unwrap(); + assert!(dst_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(&dst_link).unwrap(), + PathBuf::from("doc/RelNotes/v1.47.3.txt") + ); + } + fn write_cpio_newc_one_file(w: &mut Vec, name: &str, data: &[u8]) { // magic + 13 fields of 8 hex chars each fn h8(v: u64) -> String { diff --git a/src/source/mod.rs b/src/source/mod.rs index d63e846..c360c88 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -247,7 +247,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { } } -/// Build a blocking reqwest HTTP client using the platform-default TLS backend. +/// Build a blocking reqwest HTTP client using the configured Cargo TLS backend. /// Any error building the client is returned directly (no fallback). pub(crate) fn build_blocking_client( user_agent: &str, diff --git a/src/staging/mod.rs b/src/staging/mod.rs index 0f47dcc..ed14db7 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -240,6 +240,28 @@ fn auto_strip_elf_files(destdir: &Path) -> Result { Ok(stripped) } +fn auto_delete_static_archives(destdir: &Path) -> Result { + let mut removed = 0usize; + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "a") { + continue; + } + + fs::remove_file(path).with_context(|| { + format!( + "Failed to remove static library {} (disable with build.flags.no_delete_static = true)", + path.display() + ) + })?; + removed += 1; + } + Ok(removed) +} + fn compress_manpages_zstd(destdir: &Path) -> Result { crate::log_info!("Compressing man pages with zstd..."); let mut man_files = Vec::new(); @@ -356,11 +378,11 @@ fn compress_manpages_zstd(destdir: &Path) -> Result { Ok(compressed) } -/// Process staged files - remove .la files, strip binaries, etc. +/// Process staged files - remove .la files/static libs, strip binaries, etc. pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> { crate::log_info!("Processing staged files..."); - let mut removed_count = 0; + let mut removed_la_count = 0; for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); @@ -369,12 +391,23 @@ pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> { if path.extension().map(|e| e == "la").unwrap_or(false) { crate::log_info!(" Removing: {}", path.display()); fs::remove_file(path)?; - removed_count += 1; + removed_la_count += 1; } } - if removed_count > 0 { - crate::log_info!("Removed {} .la file(s)", removed_count); + if removed_la_count > 0 { + crate::log_info!("Removed {} .la file(s)", removed_la_count); + } + + if spec.build.flags.no_delete_static { + crate::log_info!( + "Skipping static library cleanup: disabled by build.flags.no_delete_static" + ); + } else { + let removed_static = auto_delete_static_archives(destdir)?; + if removed_static > 0 { + crate::log_info!("Removed {} static library archive(s)", removed_static); + } } if spec.build.flags.no_strip { @@ -736,6 +769,68 @@ pub fn install_atomic( #[cfg(test)] mod tests { use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; + + fn mk_spec_for_stage_processing() -> PackageSpec { + let mut flags = BuildFlags::default(); + flags.no_strip = true; + flags.no_compress_man = true; + PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Custom, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } + } + + #[test] + fn process_removes_static_archives_by_default() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.so"), "shared").unwrap(); + + let spec = mk_spec_for_stage_processing(); + process(&destdir, &spec).unwrap(); + + assert!(!destdir.join("usr/lib/libfoo.a").exists()); + assert!(!destdir.join("usr/lib/libfoo.la").exists()); + assert!(destdir.join("usr/lib/libfoo.so").exists()); + } + + #[test] + fn process_preserves_static_archives_when_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.build.flags.no_delete_static = true; + process(&destdir, &spec).unwrap(); + + assert!(destdir.join("usr/lib/libfoo.a").exists()); + assert!(!destdir.join("usr/lib/libfoo.la").exists()); + } #[test] fn add_licenses_copies_common_files() { diff --git a/src/ui.rs b/src/ui.rs index 2314ffb..9436b56 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -104,12 +104,7 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { let default_hint = if default_yes { "Y/n" } else { "y/N" }; loop { - print!( - "{} {} [{}] ", - label(Stream::Stdout, "?", "35"), - prompt, - default_hint - ); + print!("{prompt} [{default_hint}]: "); io::stdout() .flush() .context("Failed to flush prompt to stdout")?; @@ -123,10 +118,25 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { return Ok(answer); } - warn("Please answer with 'y' or 'n'."); + warn("Invalid choice. Please answer with 'y' or 'n'."); } } +/// Prompt for a package-oriented action with a Starpack-like layout. +pub fn prompt_package_action(action: &str, packages: &[String], default_yes: bool) -> Result { + if packages.is_empty() { + return prompt_yes_no( + &format!("No packages were selected for {action}. Continue?"), + default_yes, + ); + } + + println!(); + println!("The following packages will be processed for {}:", action); + println!(" {}", packages.join(" ")); + prompt_yes_no("Proceed?", default_yes) +} + /// 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() { @@ -144,18 +154,17 @@ pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize) } loop { - println!("{} {}", label(Stream::Stdout, "?", "35"), prompt); + println!("{}:", 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"), + "Choose option [1-{}] (Enter = {}): ", options.len(), default_idx + 1 ); @@ -176,7 +185,10 @@ pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize) { return Ok(num - 1); } - warn("Please enter a valid option number."); + warn(format!( + "Invalid choice. Please enter a number between 1 and {}.", + options.len() + )); } }