diff --git a/Cargo.lock b/Cargo.lock index f29b062..1e30fa5 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,11 +21,20 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.5.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "memchr 0.1.11", + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", ] [[package]] @@ -80,9 +89,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar" @@ -204,12 +213,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.2.25" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9213f7cd7c27e95c2b57c49f0e69b1ea65b27138da84a170133fd21b07659c00" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "num", - "time 0.1.45", + "iana-time-zone", + "num-traits", + "windows-link", ] [[package]] @@ -224,9 +234,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -234,9 +244,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -284,7 +294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", - "memchr 2.7.6", + "memchr", ] [[package]] @@ -389,7 +399,7 @@ dependencies = [ "rustix", "signal-hook", "signal-hook-mio", - "winapi 0.3.9", + "winapi", ] [[package]] @@ -398,7 +408,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ - "winapi 0.3.9", + "winapi", ] [[package]] @@ -426,17 +436,19 @@ dependencies = [ "bzip2", "clap", "flate2", - "ftp", "git2", "indicatif", "inquire", + "libz-sys", "md5", "nix", + "openssl-sys", "reqwest", "rusqlite", "semver", "serde", "sha2", + "suppaftp", "tar", "tempfile", "thiserror 2.0.18", @@ -634,17 +646,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "ftp" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542951aad0071952c27409e3bd7cb62d1a3ad419c4e7314106bf994e0083ad5d" -dependencies = [ - "chrono", - "lazy_static", - "regex", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -667,6 +668,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -689,7 +703,7 @@ dependencies = [ "futures-io", "futures-sink", "futures-task", - "memchr 2.7.6", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -701,7 +715,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" dependencies = [ - "thread_local 1.1.9", + "thread_local", ] [[package]] @@ -723,7 +737,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -935,6 +949,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1103,7 +1141,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ - "memchr 2.7.6", + "memchr", "serde", ] @@ -1162,20 +1200,27 @@ dependencies = [ ] [[package]] -name = "kernel32-sys" -version = "0.2.2" +name = "lazy-regex" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" dependencies = [ - "winapi 0.2.8", - "winapi-build", + "lazy-regex-proc_macros", + "once_cell", + "regex", ] [[package]] -name = "lazy_static" -version = "0.1.16" +name = "lazy-regex-proc_macros" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf186d1a8aa5f5bee5fd662bc9c1b949e0259e1bcc379d1f006847b0080c7417" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] [[package]] name = "leb128fmt" @@ -1226,6 +1271,7 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -1322,15 +1368,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" -[[package]] -name = "memchr" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20" -dependencies = [ - "libc", -] - [[package]] name = "memchr" version = "2.7.6" @@ -1361,7 +1398,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1377,43 +1414,12 @@ dependencies = [ "libc", ] -[[package]] -name = "num" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bdb1fb680e609c2e0930c1866cafdd0be7e7c7a1ecf92aec71ed8d99d3e133" -dependencies = [ - "num-integer", - "num-iter", - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1447,6 +1453,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.111" @@ -1455,10 +1470,17 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1691,22 +1713,32 @@ dependencies = [ [[package]] name = "regex" -version = "0.1.80" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd4ace6a8cf7860714a2c2280d6c1f7e6a413486c13298bbc86fd3da019402f" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", - "memchr 0.1.11", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", - "thread_local 0.2.7", - "utf8-ranges", ] [[package]] name = "regex-syntax" -version = "0.3.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -1976,7 +2008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "memchr 2.7.6", + "memchr", "serde", "serde_core", "zmij", @@ -2109,6 +2141,19 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "suppaftp" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3da253d7e9993de86df41eb89e8cb1b6f567abe215798645651fca4148d0aa" +dependencies = [ + "chrono", + "futures-lite", + "lazy-regex", + "log", + "thiserror 2.0.18", +] + [[package]] name = "syn" version = "2.0.114" @@ -2225,25 +2270,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thread-id" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9539db560102d1cef46b8b78ce737ff0bb64e7e18d35b2a5688f7d097d0ff03" -dependencies = [ - "kernel32-sys", - "libc", -] - -[[package]] -name = "thread_local" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8576dbbfcaef9641452d5cf0df9b0e7eeab7694956dd33bb61515fb8f18cfdd5" -dependencies = [ - "thread-id", -] - [[package]] name = "thread_local" version = "1.1.9" @@ -2253,17 +2279,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi 0.3.9", -] - [[package]] name = "time" version = "0.3.47" @@ -2515,12 +2530,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-ranges" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2564,12 +2573,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2715,12 +2718,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" - [[package]] name = "winapi" version = "0.3.9" @@ -2731,12 +2728,6 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu", ] -[[package]] -name = "winapi-build" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" - [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" @@ -2758,6 +2749,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -3284,11 +3310,11 @@ dependencies = [ "hmac", "indexmap", "lzma-rust2", - "memchr 2.7.6", + "memchr", "pbkdf2", "ppmd-rust", "sha1", - "time 0.3.47", + "time", "typed-path", "zeroize", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index dea6187..c27f2c0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,17 @@ edition = "2024" warnings = "deny" [dependencies] -anyhow = "1.0.101" +anyhow = "1.0.102" ar = "0.9.0" bzip2 = "0.6.1" -clap = { version = "4.5.59", features = ["derive"] } -flate2 = "1.1.9" -git2 = "0.20.4" +clap = { version = "4.5.60", features = ["derive"] } +flate2 = { version = "1.1.9", default-features = false, features = ["miniz_oxide"] } +git2 = { version = "0.20.4", features = ["vendored-libgit2"] } indicatif = "0.18.4" nix = { version = "0.31.1", features = ["user"] } -reqwest = { version = "0.13.2", features = ["blocking"] } -rusqlite = "0.38.0" +reqwest = { version = "0.13.2", features = ["blocking", "rustls"] } +rusqlite = { version = "0.38.0", features = ["bundled"] } +openssl-sys = { version = "0.9", features = ["vendored"] } semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } yaml-rust2 = "0.11.0" @@ -30,9 +31,10 @@ walkdir = "2.5.0" xz2 = "0.1.7" zip = "8.1.0" zstd = { version = "0.13.3", features = ["zstdmt"] } +libz-sys = { version = "1.1.23", default-features = false, features = ["stock-zlib", "static"] } inquire = "0.9.3" md5 = "0.8.0" -ftp = "3.0.1" +suppaftp = "8.0.2" [dev-dependencies] tempfile = "3.25.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..231004c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SFG545 and ReallyUnusual + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index e885e46..87deca8 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -14,78 +14,28 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; - - // Determine actual source directory (support source_subdir) - let actual_src = if !flags.source_subdir.is_empty() { - src_dir.join(&flags.source_subdir) - } else { - src_dir.to_path_buf() - }; - - if !actual_src.exists() { - anyhow::bail!( - "Source directory not found: {} (source_subdir: {})", - actual_src.display(), - flags.source_subdir - ); - } + let export_compiler_flags = export_compiler_flags && !flags.no_flags; + let actual_src = resolve_actual_src(spec, src_dir)?; // Create destdir fs::create_dir_all(destdir)?; // Build environment variables - let mut env_vars: Vec<(&str, String)> = vec![]; - - // Use cross-compilation tools if configured - let (cc, ar) = if let Some(cc_cfg) = cross { - (cc_cfg.cc.clone(), cc_cfg.ar.clone()) + let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); + let cc = if let Some(cc_cfg) = cross { + cc_cfg.cc.clone() } else { - (flags.cc.clone(), flags.ar.clone()) + flags.cc.clone() }; - if !flags.cflags.is_empty() { + if export_compiler_flags && !flags.cflags.is_empty() { // Expand shell command substitutions like $($CC -print-resource-dir) let cflags_str = flags.cflags.join(" "); let expanded = expand_shell_commands(&cflags_str, &cc)?; - env_vars.push(("CFLAGS", expanded)); - } - if !flags.ldflags.is_empty() { - env_vars.push(("LDFLAGS", flags.ldflags.join(" "))); - } - env_vars.push(("CC", cc.clone())); - env_vars.push(("AR", ar)); - - // CARCH support - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); - } - - // Export rootfs for build scripts - env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); - - // Add cross-compilation environment - if let Some(cc_cfg) = cross { - env_vars.push(("CXX", cc_cfg.cxx.clone())); - env_vars.push(("RANLIB", cc_cfg.ranlib.clone())); - env_vars.push(("STRIP", cc_cfg.strip.clone())); - env_vars.push(("LD", cc_cfg.ld.clone())); - env_vars.push(("NM", cc_cfg.nm.clone())); - } - - // Add dynamic loader flag if specified - if !flags.libc.is_empty() { - let ldflags = if flags.ldflags.is_empty() { - format!("-Wl,--dynamic-linker={}", flags.libc) - } else { - format!( - "{} -Wl,--dynamic-linker={}", - flags.ldflags.join(" "), - flags.libc - ) - }; - env_vars.push(("LDFLAGS", ldflags)); + crate::builder::set_env_var(&mut env_vars, "CFLAGS", expanded); } use crate::builder::state::{BuildStep, StateTracker}; @@ -117,18 +67,47 @@ pub fn build( configure_cmd.arg(format!("--prefix={}", flags.prefix)); - if !flags.chost.is_empty() { - configure_cmd.arg(format!("--host={}", flags.chost)); - } - if !flags.cbuild.is_empty() { - configure_cmd.arg(format!("--build={}", flags.cbuild)); + // 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); + let supports_host = help_text + .as_deref() + .map(|s| configure_help_supports_option(s, "--host")) + .unwrap_or(true); + let supports_build = help_text + .as_deref() + .map(|s| configure_help_supports_option(s, "--build")) + .unwrap_or(true); + + let requested_host = if let Some(cc_cfg) = cross { + Some(cc_cfg.host_triple().to_string()) + } else if !flags.chost.is_empty() { + Some(flags.chost.clone()) + } else { + None + }; + + let requested_build = if cross.is_some() { + CrossConfig::build_triple().ok() + } else if !flags.cbuild.is_empty() { + Some(flags.cbuild.clone()) + } else { + None + }; + + if let Some(host) = requested_host { + if supports_host { + configure_cmd.arg(format!("--host={}", host)); + } else { + println!(" configure does not support --host; skipping {}", host); + } } - // Add cross-compilation flags - if let Some(cc_cfg) = cross { - configure_cmd.arg(format!("--host={}", cc_cfg.host_triple())); - if let Ok(build) = CrossConfig::build_triple() { + if let Some(build) = requested_build { + if supports_build { configure_cmd.arg(format!("--build={}", build)); + } else { + println!(" configure does not support --build; skipping {}", build); } } @@ -154,6 +133,7 @@ pub fn build( let mut make_cmd = Command::new("make"); make_cmd.current_dir(&build_dir); make_cmd.arg("-j").arg(num_cpus().to_string()); + add_make_variable_overrides(&mut make_cmd, &flags.make_vars, "build")?; crate::builder::prepare_command(&mut make_cmd, &env_vars); @@ -165,6 +145,30 @@ pub fn build( anyhow::bail!("make failed with status: {}", status); } + if let Some(test_target) = maybe_find_autotools_test_target(&build_dir, flags.skip_tests)? { + println!("Running make {}...", test_target); + let mut test_cmd = Command::new("make"); + test_cmd.current_dir(&build_dir); + add_make_variable_overrides(&mut test_cmd, &flags.make_test_vars, "test")?; + test_cmd.arg(test_target); + crate::builder::prepare_command(&mut test_cmd, &env_vars); + + let status = test_cmd.status().with_context(|| { + format!( + "Failed to run make {} in {}", + test_target, + build_dir.display() + ) + })?; + if !status.success() { + anyhow::bail!("make {} failed with status: {}", test_target, status); + } + } else if flags.skip_tests { + println!("Skipping tests: disabled by build.flags.skip_tests"); + } else { + println!("Skipping tests: no 'check' or 'test' target in Makefile"); + } + // Run post-compile hooks (after make, before make install) hooks::run_post_compile_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; @@ -185,10 +189,17 @@ pub fn build( let mut install_cmd = fakeroot::wrap_install_command("make", destdir); install_cmd.current_dir(&build_dir); + if !has_make_variable_override(&flags.make_install_vars, "DESTDIR") { + install_cmd.arg(format!("DESTDIR={}", destdir.to_string_lossy())); + } + add_make_variable_overrides(&mut install_cmd, &flags.make_install_vars, "install")?; install_cmd.arg("install"); let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + install_env.push(( + "DESTDIR".to_string(), + destdir.to_string_lossy().into_owned(), + )); crate::builder::prepare_command(&mut install_cmd, &install_env); let status = install_cmd @@ -215,6 +226,155 @@ fn num_cpus() -> usize { .unwrap_or(1) } +fn resolve_actual_src(spec: &PackageSpec, src_dir: &Path) -> Result { + let flags = &spec.build.flags; + let source_subdir = spec.expand_vars(&flags.source_subdir); + + let actual_src = if source_subdir.is_empty() { + src_dir.to_path_buf() + } else { + src_dir.join(&source_subdir) + }; + + if !actual_src.exists() { + anyhow::bail!( + "Source directory not found: {} (source_subdir: {} -> {})", + actual_src.display(), + flags.source_subdir, + source_subdir + ); + } + + Ok(actual_src) +} + +fn configure_help_text( + configure_path: &str, + build_dir: &Path, + env_vars: &crate::builder::EnvVars, +) -> Option { + let mut help_cmd = Command::new(configure_path); + help_cmd.current_dir(build_dir); + help_cmd.arg("--help"); + crate::builder::prepare_command(&mut help_cmd, env_vars); + let output = help_cmd.output().ok()?; + if !output.status.success() { + return None; + } + let mut text = String::new(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + Some(text) +} + +fn configure_help_supports_option(help_text: &str, option: &str) -> bool { + let with_eq = format!("{}=", option); + let with_space = format!("{} ", option); + help_text.contains(&with_eq) || help_text.contains(&with_space) || help_text.contains(option) +} + +fn find_autotools_test_target(build_dir: &Path) -> Result> { + for target in ["check", "test"] { + if makefile_has_target(build_dir, target)? { + return Ok(Some(target)); + } + } + Ok(None) +} + +fn maybe_find_autotools_test_target( + build_dir: &Path, + skip_tests: bool, +) -> Result> { + if skip_tests { + return Ok(None); + } + find_autotools_test_target(build_dir) +} + +fn makefile_has_target(build_dir: &Path, target: &str) -> Result { + for name in ["GNUmakefile", "Makefile", "makefile"] { + let path = build_dir.join(name); + if !path.exists() { + continue; + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read makefile: {}", path.display()))?; + if makefile_content_has_target(&content, target) { + return Ok(true); + } + } + Ok(false) +} + +fn makefile_content_has_target(content: &str, target: &str) -> bool { + for raw in content.lines() { + let line = raw.trim_start(); + if line.is_empty() || line.starts_with('#') || line.starts_with('\t') { + continue; + } + + if let Some(rest) = line.strip_prefix(".PHONY:") { + if rest.split_whitespace().any(|t| t == target) { + return true; + } + continue; + } + + let Some(colon_pos) = line.find(':') else { + continue; + }; + let rhs = &line[colon_pos + 1..]; + if rhs.starts_with('=') { + // Variable assignment (e.g. FOO:=bar), not a make target. + continue; + } + + let lhs = &line[..colon_pos]; + if lhs.split_whitespace().any(|t| t == target) { + return true; + } + } + + false +} + +fn add_make_variable_overrides(cmd: &mut Command, vars: &[String], phase: &str) -> Result<()> { + for raw in vars { + let var = raw.trim(); + if var.is_empty() { + continue; + } + let Some((name, _value)) = var.split_once('=') else { + anyhow::bail!( + "Invalid make variable override '{}' for {} phase; expected NAME=VALUE", + var, + phase + ); + }; + let name = name.trim(); + if name.is_empty() || name.contains(char::is_whitespace) { + anyhow::bail!( + "Invalid make variable override '{}' for {} phase; expected NAME=VALUE", + var, + phase + ); + } + cmd.arg(var); + } + Ok(()) +} + +fn has_make_variable_override(vars: &[String], name: &str) -> bool { + vars.iter().any(|raw| { + let var = raw.trim(); + let Some((lhs, _rhs)) = var.split_once('=') else { + return false; + }; + lhs.trim() == name + }) +} + /// Expand shell command substitutions like $($CC -print-resource-dir) in a string fn expand_shell_commands(input: &str, cc: &str) -> Result { let mut result = input.to_string(); @@ -253,6 +413,9 @@ fn expand_shell_commands(input: &str, cc: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; + use std::path::PathBuf; + use tempfile::tempdir; #[test] fn test_expand_shell_commands_simple() -> Result<()> { @@ -266,7 +429,7 @@ mod tests { // The command contains $CC which should be replaced with provided cc let out = expand_shell_commands("start $($CC -v >/dev/null; echo OK) end", "mycc")?; // Since the inner command echoes OK, after replacing $CC it should run and include OK - assert!(out.contains("OK") || out.contains("") ); + assert!(out.contains("OK") || out.contains("")); Ok(()) } @@ -275,4 +438,130 @@ mod tests { let n = num_cpus(); assert!(n >= 1); } + + #[test] + fn test_configure_help_supports_host_build() { + let help = "Usage: configure [OPTION]...\n --host=HOST cross host\n --build=BUILD"; + assert!(configure_help_supports_option(help, "--host")); + assert!(configure_help_supports_option(help, "--build")); + assert!(!configure_help_supports_option(help, "--target")); + } + + #[test] + fn test_makefile_content_has_target_detects_check_and_test() { + let content = r#" +.PHONY: all check +all: + @echo all +check: + @echo check +"#; + assert!(makefile_content_has_target(content, "check")); + assert!(!makefile_content_has_target(content, "test")); + } + + #[test] + fn test_makefile_content_has_target_ignores_assignments() { + let content = r#" +TEST := value +VAR:=$(shell echo hi) +foo: bar + @true +"#; + assert!(!makefile_content_has_target(content, "TEST")); + assert!(!makefile_content_has_target(content, "VAR")); + assert!(!makefile_content_has_target(content, "check")); + } + + #[test] + fn test_maybe_find_autotools_test_target_respects_skip_tests() -> Result<()> { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("Makefile"), "check:\n\t@true\n").unwrap(); + + let skipped = maybe_find_autotools_test_target(tmp.path(), true)?; + assert_eq!(skipped, None); + + let detected = maybe_find_autotools_test_target(tmp.path(), false)?; + assert_eq!(detected, Some("check")); + Ok(()) + } + + #[test] + fn test_add_make_variable_overrides_accepts_valid_assignments() -> Result<()> { + let mut cmd = Command::new("make"); + add_make_variable_overrides( + &mut cmd, + &[ + "CC=clang".to_string(), + "V=1".to_string(), + " CFLAGS=-O2 -pipe ".to_string(), + ], + "build", + )?; + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + assert_eq!(args, vec!["CC=clang", "V=1", "CFLAGS=-O2 -pipe"]); + Ok(()) + } + + #[test] + fn test_add_make_variable_overrides_rejects_invalid_assignment() { + let mut cmd = Command::new("make"); + let err = add_make_variable_overrides(&mut cmd, &["not-an-assignment".to_string()], "test") + .expect_err("expected invalid assignment to fail"); + assert!(err.to_string().contains("expected NAME=VALUE")); + } + + #[test] + fn test_has_make_variable_override_detects_destdir() { + assert!(has_make_variable_override( + &["DESTDIR=/tmp/pkg".to_string()], + "DESTDIR" + )); + assert!(has_make_variable_override( + &[" DESTDIR =/tmp/pkg ".to_string()], + "DESTDIR" + )); + assert!(!has_make_variable_override( + &["V=1".to_string(), "PREFIX=/usr".to_string()], + "DESTDIR" + )); + } + + #[test] + fn test_resolve_actual_src_expands_source_subdir_vars() { + let tmp = tempdir().unwrap(); + let src_root = tmp.path().join("srcroot"); + let expanded = src_root.join("expect5.45.4").join("unix"); + std::fs::create_dir_all(&expanded).unwrap(); + + let spec = PackageSpec { + package: PackageInfo { + name: "expect".into(), + version: "5.45.4".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::Autotools, + flags: BuildFlags { + source_subdir: "$name$version/unix".into(), + ..BuildFlags::default() + }, + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let resolved = resolve_actual_src(&spec, &src_root).unwrap(); + assert_eq!(resolved, expanded); + } } diff --git a/src/builder/bin.rs b/src/builder/bin.rs index b57b83e..3c05e4c 100644 --- a/src/builder/bin.rs +++ b/src/builder/bin.rs @@ -1,12 +1,12 @@ //! Binary package "build" system — used when package supplies a prebuilt binary installer +use crate::cross::CrossConfig; +use crate::package::PackageSpec; use anyhow::{Context, Result}; use std::fs; use std::os::unix::fs as unix_fs; use std::path::Path; use walkdir::WalkDir; -use crate::package::PackageSpec; -use crate::cross::CrossConfig; /// For binary packages we simply copy the extracted files into DESTDIR (preserving /// directory structure). This is useful for .deb packages where extract step @@ -16,9 +16,16 @@ pub fn build( src_dir: &Path, destdir: &Path, _cross: Option<&CrossConfig>, + _export_compiler_flags: bool, ) -> Result<()> { - println!("Binary install: copying files from {} to {} (pkg type={})", src_dir.display(), destdir.display(), _spec.build.flags.binary_type); - fs::create_dir_all(destdir).with_context(|| format!("Failed to create destdir: {}", destdir.display()))?; + println!( + "Binary install: copying files from {} to {} (pkg type={})", + src_dir.display(), + destdir.display(), + _spec.build.flags.binary_type + ); + fs::create_dir_all(destdir) + .with_context(|| format!("Failed to create destdir: {}", destdir.display()))?; for entry in WalkDir::new(src_dir) { let entry = entry?; @@ -28,12 +35,18 @@ pub fn build( fs::create_dir_all(&target)?; } else if entry.file_type().is_symlink() { let link_target = fs::read_link(entry.path())?; - if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } // overwrite existing links/files - if target.exists() { let _ = fs::remove_file(&target); } + if target.exists() { + let _ = fs::remove_file(&target); + } unix_fs::symlink(link_target, &target)?; } else { - if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } fs::copy(entry.path(), &target)?; } } @@ -45,9 +58,9 @@ pub fn build( mod tests { use super::*; use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; - use tempfile::tempdir; use std::fs; use std::os::unix::fs as unix_fs; + use tempfile::tempdir; fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { @@ -57,7 +70,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Default::default(), @@ -96,7 +109,7 @@ mod tests { unix_fs::symlink(&target, src.join("usr/lib/libdummy.so.link"))?; let spec = mk_spec("bin-test", "1.0"); - build(&spec, src, dest, None)?; + build(&spec, src, dest, None, true)?; // Check copied file assert!(dest.join("usr/bin/hello").exists()); diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 3852e02..6b78aaa 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -13,13 +13,13 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; // Determine actual source directory (support source_subdir) let actual_src = resolve_actual_src(spec, src_dir)?; - let build_dir = actual_src.join("build"); // Create directories @@ -27,55 +27,7 @@ pub fn build( fs::create_dir_all(destdir)?; // Environment variables - let mut env_vars: Vec<(&str, String)> = vec![]; - - // Use cross-compilation tools if configured - let cc = if let Some(cc_cfg) = cross { - cc_cfg.cc.clone() - } else { - flags.cc.clone() - }; - - if !flags.cflags.is_empty() { - env_vars.push(("CFLAGS", flags.cflags.join(" "))); - } - if !flags.chost.is_empty() { - env_vars.push(("CHOST", flags.chost.clone())); - } - if !flags.cbuild.is_empty() { - env_vars.push(("CBUILD", flags.cbuild.clone())); - } - if !flags.ldflags.is_empty() { - let ldflags = if flags.libc.is_empty() { - flags.ldflags.join(" ") - } else { - format!( - "{} -Wl,--dynamic-linker={}", - flags.ldflags.join(" "), - flags.libc - ) - }; - env_vars.push(("LDFLAGS", ldflags)); - } - env_vars.push(("CC", cc)); - - // Ensure CXX is exported for configure flags that reference $CXX - // prefer cross-config CXX when cross-compiling (handled earlier), otherwise use flags.cxx - env_vars.push(("CXX", flags.cxx.clone())); - - // Export rootfs for build scripts - env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); - - // CARCH support - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); - } - - // Add cross-compilation env - if let Some(cc_cfg) = cross { - env_vars.push(("CXX", cc_cfg.cxx.clone())); - env_vars.push(("AR", cc_cfg.ar.clone())); - } + let env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); // Extract prefix from configure flags (cmake-style -DCMAKE_INSTALL_PREFIX=) let prefix = flags @@ -168,7 +120,10 @@ pub fn build( install_cmd.arg("--install").arg(&build_dir); let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + install_env.push(( + "DESTDIR".to_string(), + destdir.to_string_lossy().into_owned(), + )); crate::builder::prepare_command(&mut install_cmd, &install_env); let status = install_cmd @@ -199,7 +154,7 @@ fn expand_env_vars(input: &str) -> String { } /// Expand using a provided set of env vars (used to expand flags before spawning child). -fn expand_with_envs(input: &str, envs: &Vec<(&str, String)>) -> String { +fn expand_with_envs(input: &str, envs: &[(String, String)]) -> String { let mut result = input.to_string(); for (k, v) in envs { result = result.replace(&format!("${}", k), v); @@ -220,29 +175,38 @@ fn num_cpus() -> usize { /// - `src_dir/` -> use if exists /// - `spec.spec_dir/` -> use if exists (supports `../llvm`) /// - bare relative path (cwd) -fn resolve_actual_src(spec: &crate::package::PackageSpec, src_dir: &Path) -> anyhow::Result { +fn resolve_actual_src( + spec: &crate::package::PackageSpec, + src_dir: &Path, +) -> anyhow::Result { let flags = &spec.build.flags; - if flags.source_subdir.is_empty() { + let source_subdir = spec.expand_vars(&flags.source_subdir); + if source_subdir.is_empty() { return Ok(src_dir.to_path_buf()); } - let candidate = std::path::Path::new(&flags.source_subdir); + let candidate = std::path::Path::new(&source_subdir); // 1) absolute path -> use directly if candidate.is_absolute() { if candidate.exists() { return Ok(candidate.to_path_buf()); } - anyhow::bail!("Source directory not found: {} (source_subdir: {})", candidate.display(), flags.source_subdir); + anyhow::bail!( + "Source directory not found: {} (source_subdir: {} -> {})", + candidate.display(), + flags.source_subdir, + source_subdir + ); } // 2) src_dir/ - let under_src = src_dir.join(&flags.source_subdir); + let under_src = src_dir.join(&source_subdir); if under_src.exists() { return Ok(under_src); } // 3) spec_dir/ (useful for ../llvm relative to spec) - let spec_path = spec.spec_dir.join(&flags.source_subdir); + let spec_path = spec.spec_dir.join(&source_subdir); if spec_path.exists() { return Ok(spec_path); } @@ -254,7 +218,8 @@ fn resolve_actual_src(spec: &crate::package::PackageSpec, src_dir: &Path) -> any // fallback error anyhow::bail!( - "Source directory not found: {} (tried src_dir, spec_dir, and absolute path)", + "Source directory not found: {} (expanded from '{}'; tried src_dir, spec_dir, and absolute path)", + source_subdir, flags.source_subdir ); } @@ -277,7 +242,10 @@ mod tests { #[test] fn test_expand_with_envs_prefers_provided_envs() { - let envs = vec![("CXX", "my-cxx".to_string()), ("CC", "my-cc".to_string())]; + let envs = vec![ + ("CXX".to_string(), "my-cxx".to_string()), + ("CC".to_string(), "my-cc".to_string()), + ]; let s = "-DCMAKE_C_COMPILER=$CC -DCMAKE_CXX_COMPILER=${CXX} -DROOT=$HOME"; let out = expand_with_envs(s, &envs); assert!(out.contains("my-cc")); @@ -297,18 +265,33 @@ mod tests { let src_root = tmp.path().join("srcroot"); let spec_dir = tmp.path().join("specdir"); let external = tmp.path().join("external"); + let expanded = src_root.join("x-1.0").join("sub"); std::fs::create_dir_all(&src_root.join("sub")).unwrap(); + std::fs::create_dir_all(&expanded).unwrap(); // create directories for candidates std::fs::create_dir_all(&spec_dir.join("../llvm")).unwrap(); std::fs::create_dir_all(&external).unwrap(); let spec = PackageSpec { - package: PackageInfo { name: "x".into(), version: "1.0".into(), revision: 1, description: "".into(), homepage: "".into(), license: "MIT".into() }, + package: PackageInfo { + name: "x".into(), + version: "1.0".into(), + revision: 1, + description: "".into(), + homepage: "".into(), + license: vec!["MIT".into()], + }, packages: Vec::new(), alternatives: Default::default(), manual_sources: Vec::new(), source: Vec::new(), - build: Build { build_type: BuildType::CMake, flags: BuildFlags { source_subdir: "sub".into(), ..BuildFlags::default() } }, + build: Build { + build_type: BuildType::CMake, + flags: BuildFlags { + source_subdir: "sub".into(), + ..BuildFlags::default() + }, + }, dependencies: Default::default(), spec_dir: spec_dir.clone(), }; @@ -328,5 +311,11 @@ mod tests { spec3.build.flags.source_subdir = external.to_string_lossy().into_owned(); let p3 = resolve_actual_src(&spec3, &src_root).unwrap(); assert_eq!(p3, external); + + // case: variable expansion in source_subdir + let mut spec4 = spec.clone(); + spec4.build.flags.source_subdir = "$name-$version/sub".into(); + let p4 = resolve_actual_src(&spec4, &src_root).unwrap(); + assert_eq!(p4, expanded); } } diff --git a/src/builder/custom.rs b/src/builder/custom.rs index 14497fc..17d2505 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -12,13 +12,14 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; // Create destdir fs::create_dir_all(destdir)?; - let mut env_vars: Vec<(&str, String)> = vec![]; + let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); // For custom builds, look for a build.sh script in the source directory let build_script = src_dir.join("build.sh"); @@ -29,8 +30,12 @@ pub fn build( let spec_build = spec.spec_dir.join("build.sh"); if !build_script.exists() && spec_build.exists() { fs::create_dir_all(src_dir)?; - fs::copy(&spec_build, &build_script) - .with_context(|| format!("Failed to copy build.sh from spec dir: {}", spec_build.display()))?; + fs::copy(&spec_build, &build_script).with_context(|| { + format!( + "Failed to copy build.sh from spec dir: {}", + spec_build.display() + ) + })?; // Ensure executable bit #[cfg(unix)] { @@ -50,7 +55,7 @@ pub fn build( } use crate::builder::state::{BuildStep, StateTracker}; - let mut state = StateTracker::new(&src_dir)?; + let mut state = StateTracker::new(src_dir)?; if !state.is_done(BuildStep::PostInstallDone) { println!( @@ -70,7 +75,8 @@ pub fn build( src_dir.to_path_buf() }; - let mut cmd = fakeroot::wrap_install_command("bash", destdir); + // Use POSIX `sh` (more likely to be available in minimal/chroot environments) + let mut cmd = fakeroot::wrap_install_command("sh", destdir); cmd.current_dir(&build_dir); // Ensure build script path is absolute for when we are in a sub-build-dir @@ -81,62 +87,22 @@ pub fn build( }; cmd.arg(&abs_build_script); - if !flags.cflags.is_empty() { - env_vars.push(("CFLAGS", flags.cflags.join(" "))); - } - if !flags.ldflags.is_empty() { - let ldflags = if flags.libc.is_empty() { - flags.ldflags.join(" ") - } else { - format!( - "{} -Wl,--dynamic-linker={}", - flags.ldflags.join(" "), - flags.libc - ) - }; - env_vars.push(("LDFLAGS", ldflags)); - } - - env_vars.push(("DESTDIR", destdir.to_string_lossy().into_owned())); - env_vars.push(("PREFIX", spec.build.flags.prefix.clone())); - env_vars.push(("DEPOT_ROOTFS", spec.build.flags.rootfs.clone())); - env_vars.push(( - "DEPOT_SPECDIR", - spec.spec_dir.to_string_lossy().into_owned(), - )); - - if !flags.chost.is_empty() { - env_vars.push(("CHOST", flags.chost.clone())); - } - if !flags.cbuild.is_empty() { - env_vars.push(("CBUILD", flags.cbuild.clone())); - } - - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); - } - - // Use cross-compilation tools if configured - if let Some(cc_cfg) = cross { - env_vars.push(("CC", cc_cfg.cc.clone())); - env_vars.push(("CXX", cc_cfg.cxx.clone())); - env_vars.push(("AR", cc_cfg.ar.clone())); - env_vars.push(("RANLIB", cc_cfg.ranlib.clone())); - env_vars.push(("STRIP", cc_cfg.strip.clone())); - env_vars.push(("LD", cc_cfg.ld.clone())); - env_vars.push(("NM", cc_cfg.nm.clone())); - env_vars.push(("CROSS_PREFIX", cc_cfg.prefix.clone())); - env_vars.push(("CROSS_COMPILE", format!("{}-", cc_cfg.prefix))); - } else { - env_vars.push(("CC", flags.cc.clone())); - env_vars.push(("AR", flags.ar.clone())); - } + crate::builder::set_env_var( + &mut env_vars, + "DESTDIR", + destdir.to_string_lossy().into_owned(), + ); crate::builder::prepare_command(&mut cmd, &env_vars); - let status = cmd - .status() - .with_context(|| format!("Failed to run build script: {}", build_script.display()))?; + // Run the command and include the OS error on spawn failures for clearer diagnostics + let status = cmd.status().map_err(|e| { + anyhow::anyhow!( + "Failed to run build script {}: {}", + build_script.display(), + e + ) + })?; if !status.success() { anyhow::bail!("Custom build script failed with status: {}", status); @@ -163,7 +129,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Default::default(), @@ -191,7 +157,7 @@ mod tests { let spec = mk_spec("custom-no-build", "1.0"); - let res = build(&spec, tmp_src.path(), tmp_dest.path(), None); + let res = build(&spec, tmp_src.path(), tmp_dest.path(), None, true); assert!(res.is_err()); Ok(()) } @@ -217,7 +183,7 @@ mod tests { spec.spec_dir = spec_dir.path().to_path_buf(); // src_dir is empty; build() should copy build.sh from spec_dir and run it (no-op) - let _ = build(&spec, tmp_src.path(), tmp_dest.path(), None)?; + let _ = build(&spec, tmp_src.path(), tmp_dest.path(), None, true)?; // If we reached here, build() succeeded and build.sh was copied into src assert!(tmp_src.path().join("build.sh").exists()); Ok(()) diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index e8827c3..8a6b751 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -10,43 +10,21 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let mut state = StateTracker::new(src_dir)?; let flags = &spec.build.flags; - // Use cross-compilation tools if configured, otherwise use flags from spec - // Note: Makefile builds might rely on CC/CXX/AR being set, or might use make flags. - // We set them in env vars. - let (cc, ar) = if let Some(cc_cfg) = cross { - (cc_cfg.cc.as_str(), cc_cfg.ar.as_str()) - } else { - (flags.cc.as_str(), flags.ar.as_str()) - }; + let mut env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); - let mut env_vars: Vec<(&str, String)> = vec![("CC", cc.to_string()), ("AR", ar.to_string())]; - - let cflags; - if !flags.cflags.is_empty() { - cflags = flags.cflags.join(" "); - env_vars.push(("CFLAGS", cflags)); - } - - let ldflags; - if !flags.ldflags.is_empty() { - ldflags = flags.ldflags.join(" "); - env_vars.push(("LDFLAGS", ldflags)); - } - - // CARCH support - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); - } - - // Export PKG_CONFIG_SYSROOT_DIR for pkg-config and a general DEPOT_ROOTFS + // Export PKG_CONFIG_SYSROOT_DIR for pkg-config if !flags.rootfs.is_empty() && flags.rootfs != "/" { - env_vars.push(("PKG_CONFIG_SYSROOT_DIR", flags.rootfs.clone())); + crate::builder::set_env_var( + &mut env_vars, + "PKG_CONFIG_SYSROOT_DIR", + flags.rootfs.clone(), + ); } - env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); if !state.is_done(BuildStep::PostCompileDone) { println!("Running makefile build commands..."); @@ -95,7 +73,10 @@ pub fn build( cmd.current_dir(src_dir); let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + install_env.push(( + "DESTDIR".to_string(), + destdir.to_string_lossy().into_owned(), + )); crate::builder::prepare_command(&mut cmd, &install_env); let status = cmd @@ -130,7 +111,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Default::default(), @@ -168,7 +149,7 @@ mod tests { "cp built.txt $DESTDIR/usr/bin/installed.txt".into(), ]; - build(&spec, src_path, dest_path, None)?; + build(&spec, src_path, dest_path, None, true)?; // Verify build step let built_file = src_path.join("built.txt"); diff --git a/src/builder/meson.rs b/src/builder/meson.rs index a060b46..c62da89 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -13,6 +13,7 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; let build_dir = src_dir.join("builddir"); @@ -22,51 +23,7 @@ pub fn build( fs::create_dir_all(destdir)?; // Environment variables - let mut env_vars: Vec<(&str, String)> = vec![]; - - // Use cross-compilation tools if configured - let cc = if let Some(cc_cfg) = cross { - cc_cfg.cc.clone() - } else { - flags.cc.clone() - }; - - if !flags.cflags.is_empty() { - env_vars.push(("CFLAGS", flags.cflags.join(" "))); - } - if !flags.chost.is_empty() { - env_vars.push(("CHOST", flags.chost.clone())); - } - if !flags.cbuild.is_empty() { - env_vars.push(("CBUILD", flags.cbuild.clone())); - } - if !flags.ldflags.is_empty() { - let ldflags = if flags.libc.is_empty() { - flags.ldflags.join(" ") - } else { - format!( - "{} -Wl,--dynamic-linker={}", - flags.ldflags.join(" "), - flags.libc - ) - }; - env_vars.push(("LDFLAGS", ldflags)); - } - env_vars.push(("CC", cc)); - - // Export rootfs for build scripts - env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); - - // Add cross-compilation env - if let Some(cc_cfg) = cross { - env_vars.push(("CXX", cc_cfg.cxx.clone())); - env_vars.push(("AR", cc_cfg.ar.clone())); - } - - // CARCH support - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); - } + let env_vars = crate::builder::standard_build_env(spec, cross, true, export_compiler_flags); // Extract prefix from configure flags let prefix = flags @@ -150,7 +107,10 @@ pub fn build( install_cmd.arg("-C").arg(&build_dir); let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + install_env.push(( + "DESTDIR".to_string(), + destdir.to_string_lossy().into_owned(), + )); crate::builder::prepare_command(&mut install_cmd, &install_env); let status = install_cmd diff --git a/src/builder/mod.rs b/src/builder/mod.rs index a1b1845..0248742 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -15,8 +15,112 @@ use anyhow::Result; use std::path::Path; use std::process::Command; +pub type EnvVars = Vec<(String, String)>; + +pub fn set_env_var(env_vars: &mut EnvVars, key: &str, value: impl Into) { + let value = value.into(); + if let Some((_, existing)) = env_vars.iter_mut().find(|(k, _)| k == key) { + *existing = value; + } else { + env_vars.push((key.to_string(), value)); + } +} + +pub fn standard_build_env( + spec: &PackageSpec, + cross: Option<&CrossConfig>, + include_compiler_env: bool, + export_compiler_flags: bool, +) -> EnvVars { + let flags = &spec.build.flags; + let mut env_vars: EnvVars = Vec::new(); + let export_compiler_flags = export_compiler_flags && !flags.no_flags; + + if include_compiler_env && export_compiler_flags { + if !flags.cflags.is_empty() { + set_env_var(&mut env_vars, "CFLAGS", flags.cflags.join(" ")); + } + + let ldflags = if !flags.ldflags.is_empty() || !flags.libc.is_empty() { + if flags.libc.is_empty() { + flags.ldflags.join(" ") + } else if flags.ldflags.is_empty() { + format!("-Wl,--dynamic-linker={}", flags.libc) + } else { + format!( + "{} -Wl,--dynamic-linker={}", + flags.ldflags.join(" "), + flags.libc + ) + } + } else { + String::new() + }; + if !ldflags.is_empty() { + set_env_var(&mut env_vars, "LDFLAGS", ldflags); + } + } + + if !flags.chost.is_empty() { + set_env_var(&mut env_vars, "CHOST", flags.chost.clone()); + } + if !flags.cbuild.is_empty() { + set_env_var(&mut env_vars, "CBUILD", flags.cbuild.clone()); + } + if !flags.carch.is_empty() { + set_env_var(&mut env_vars, "CARCH", flags.carch.clone()); + } + if !flags.prefix.is_empty() { + set_env_var(&mut env_vars, "PREFIX", flags.prefix.clone()); + } + + set_env_var(&mut env_vars, "DEPOT_ROOTFS", flags.rootfs.clone()); + set_env_var( + &mut env_vars, + "DEPOT_SPECDIR", + spec.spec_dir.to_string_lossy().into_owned(), + ); + + if include_compiler_env { + if let Some(cc_cfg) = cross { + set_env_var(&mut env_vars, "CC", cc_cfg.cc.clone()); + set_env_var(&mut env_vars, "CXX", cc_cfg.cxx.clone()); + set_env_var(&mut env_vars, "AR", cc_cfg.ar.clone()); + set_env_var(&mut env_vars, "RANLIB", cc_cfg.ranlib.clone()); + set_env_var(&mut env_vars, "STRIP", cc_cfg.strip.clone()); + set_env_var(&mut env_vars, "LD", cc_cfg.ld.clone()); + set_env_var(&mut env_vars, "NM", cc_cfg.nm.clone()); + set_env_var(&mut env_vars, "CROSS_PREFIX", cc_cfg.prefix.clone()); + set_env_var( + &mut env_vars, + "CROSS_COMPILE", + format!("{}-", cc_cfg.prefix), + ); + } else { + set_env_var(&mut env_vars, "CC", flags.cc.clone()); + set_env_var(&mut env_vars, "CXX", flags.cxx.clone()); + set_env_var(&mut env_vars, "AR", flags.ar.clone()); + } + } + + for key in &flags.passthrough_env { + let key = key.trim(); + if key.is_empty() || key.contains('=') { + continue; + } + if env_vars.iter().any(|(existing, _)| existing == key) { + continue; + } + if let Ok(value) = std::env::var(key) { + set_env_var(&mut env_vars, key, value); + } + } + + env_vars +} + /// Prepare a Command with a hermetic environment and some essential variables preserved. -pub fn prepare_command(cmd: &mut Command, env_vars: &[(&str, String)]) { +pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) { cmd.env_clear(); // Preserve essential environment variables for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR", "DEPOT_ROOTFS"] { @@ -36,6 +140,7 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { if let Some(cc) = cross { println!( @@ -52,18 +157,58 @@ pub fn build( } match spec.build.build_type { - BuildType::Autotools => autotools::build(spec, src_dir, destdir, cross), - BuildType::CMake => cmake::build(spec, src_dir, destdir, cross), - BuildType::Meson => meson::build(spec, src_dir, destdir, cross), - BuildType::Custom => custom::build(spec, src_dir, destdir, cross), - BuildType::Rust => rust::build(spec, src_dir, destdir, cross), - BuildType::Bin => bin::build(spec, src_dir, destdir, cross), - BuildType::Makefile => makefile::build(spec, src_dir, destdir, cross), + BuildType::Autotools => { + autotools::build(spec, src_dir, destdir, cross, export_compiler_flags) + } + BuildType::CMake => cmake::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Meson => meson::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Custom => custom::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Rust => rust::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Bin => bin::build(spec, src_dir, destdir, cross, export_compiler_flags), + BuildType::Makefile => { + makefile::build(spec, src_dir, destdir, cross, export_compiler_flags) + } } } #[cfg(test)] mod tests { use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; + use std::collections::HashMap; + use std::ffi::OsStr; + use std::path::PathBuf; + + fn mk_spec(cflags: Vec<&str>, ldflags: Vec<&str>) -> PackageSpec { + let mut flags = BuildFlags::default(); + flags.cflags = cflags.into_iter().map(String::from).collect(); + flags.ldflags = ldflags.into_iter().map(String::from).collect(); + PackageSpec { + package: PackageInfo { + name: "env-test".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![crate::package::Source { + url: "https://example.test/src.tar.gz".into(), + sha256: "abc".into(), + extract_dir: "src".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags, + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + } + } #[test] fn test_prepare_command() { @@ -77,19 +222,19 @@ mod tests { std::env::set_var("DEPOT_ROOTFS", "/my/rootfs"); } - prepare_command(&mut cmd, &[("MYVAR", "myval".to_string())]); + prepare_command(&mut cmd, &vec![("MYVAR".to_string(), "myval".to_string())]); - let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - assert!(envs.get(std::ffi::OsStr::new("PATH")).is_some()); - assert!(envs.get(std::ffi::OsStr::new("HOME")).is_some()); - assert!(envs.get(std::ffi::OsStr::new("FORBIDDEN")).is_none()); + let envs: HashMap<_, _> = cmd.get_envs().collect(); + assert!(envs.get(OsStr::new("PATH")).is_some()); + assert!(envs.get(OsStr::new("HOME")).is_some()); + assert!(envs.get(OsStr::new("FORBIDDEN")).is_none()); assert_eq!( - envs.get(std::ffi::OsStr::new("MYVAR")), + envs.get(OsStr::new("MYVAR")), Some(&Some(std::ffi::OsString::from("myval").as_os_str())) ); // DEPOT_ROOTFS should be preserved from the parent environment assert_eq!( - envs.get(std::ffi::OsStr::new("DEPOT_ROOTFS")), + envs.get(OsStr::new("DEPOT_ROOTFS")), Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str())) ); } @@ -100,11 +245,88 @@ mod tests { unsafe { std::env::set_var("DESTDIR", "/tmp/dest"); } - prepare_command(&mut cmd, &[]); - let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + prepare_command(&mut cmd, &Vec::new()); + let envs: HashMap<_, _> = cmd.get_envs().collect(); assert_eq!( - envs.get(std::ffi::OsStr::new("DESTDIR")), + envs.get(OsStr::new("DESTDIR")), Some(&Some(std::ffi::OsString::from("/tmp/dest").as_os_str())) ); } + + #[test] + fn test_standard_build_env_respects_export_compiler_flags_toggle() { + let spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + + let enabled = standard_build_env(&spec, None, true, true); + assert!( + enabled.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), + "expected CFLAGS to be exported when enabled" + ); + assert!( + enabled + .iter() + .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed"), + "expected LDFLAGS to be exported when enabled" + ); + + let disabled = standard_build_env(&spec, None, true, false); + assert!( + !disabled.iter().any(|(k, _)| k == "CFLAGS"), + "expected CFLAGS to be omitted when disabled" + ); + assert!( + !disabled.iter().any(|(k, _)| k == "CXXFLAGS"), + "expected CXXFLAGS to be omitted when disabled" + ); + assert!( + !disabled.iter().any(|(k, _)| k == "LDFLAGS"), + "expected LDFLAGS to be omitted when disabled" + ); + + let mut disabled_by_spec = spec.clone(); + disabled_by_spec.build.flags.no_flags = true; + let disabled_env = standard_build_env(&disabled_by_spec, None, true, true); + assert!( + !disabled_env.iter().any(|(k, _)| k == "CFLAGS"), + "expected CFLAGS to be omitted when no_flags is set in spec" + ); + assert!( + !disabled_env.iter().any(|(k, _)| k == "LDFLAGS"), + "expected LDFLAGS to be omitted when no_flags is set in spec" + ); + } + + #[test] + fn test_standard_build_env_exports_passthrough_env() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.passthrough_env = vec!["RUSTFLAGS".into()]; + + unsafe { + std::env::set_var("RUSTFLAGS", "-C target-cpu=native"); + } + + let env = standard_build_env(&spec, None, false, true); + assert!( + env.iter() + .any(|(k, v)| k == "RUSTFLAGS" && v == "-C target-cpu=native"), + "expected RUSTFLAGS to be copied from parent environment" + ); + } + + #[test] + fn test_standard_build_env_passthrough_does_not_override_default_vars() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.cc = "spec-cc".to_string(); + spec.build.flags.passthrough_env = vec!["CC".into()]; + + unsafe { + std::env::set_var("CC", "host-cc"); + } + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter().any(|(k, v)| k == "CC" && v == "spec-cc"), + "expected default CC to take precedence over passthrough CC" + ); + } } diff --git a/src/builder/rust.rs b/src/builder/rust.rs index 80d6adc..7125059 100755 --- a/src/builder/rust.rs +++ b/src/builder/rust.rs @@ -13,6 +13,7 @@ pub fn build( src_dir: &Path, destdir: &Path, cross: Option<&CrossConfig>, + export_compiler_flags: bool, ) -> Result<()> { let flags = &spec.build.flags; @@ -45,16 +46,12 @@ pub fn build( }; // Build environment - let mut env_vars: Vec<(&str, String)> = vec![]; + let mut env_vars = + crate::builder::standard_build_env(spec, cross, false, export_compiler_flags); // RUSTFLAGS if !flags.rustflags.is_empty() { - env_vars.push(("RUSTFLAGS", flags.rustflags.join(" "))); - } - - // CARCH support - if !flags.carch.is_empty() { - env_vars.push(("CARCH", flags.carch.clone())); + crate::builder::set_env_var(&mut env_vars, "RUSTFLAGS", flags.rustflags.join(" ")); } // If cross-compiling, set linker via CARGO_TARGET_*_LINKER @@ -66,19 +63,16 @@ pub fn build( .to_uppercase() .replace('-', "_"); let linker_var = format!("CARGO_TARGET_{}_LINKER", target_env); - env_vars.push((linker_var.leak(), cc_cfg.cc.clone())); - env_vars.push(("CC".to_string().leak(), cc_cfg.cc.clone())); - env_vars.push(("AR".to_string().leak(), cc_cfg.ar.clone())); + crate::builder::set_env_var(&mut env_vars, &linker_var, cc_cfg.cc.clone()); + crate::builder::set_env_var(&mut env_vars, "CC", cc_cfg.cc.clone()); + crate::builder::set_env_var(&mut env_vars, "AR", cc_cfg.ar.clone()); } // Set default rustup toolchain if not already set if std::env::var("RUSTUP_TOOLCHAIN").is_err() { - env_vars.push(("RUSTUP_TOOLCHAIN", "stable".to_string())); + crate::builder::set_env_var(&mut env_vars, "RUSTUP_TOOLCHAIN", "stable"); } - // Export DEPOT_ROOTFS so build scripts can detect the target rootfs - env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); - // Run cargo build println!( "Running cargo build ({})...", diff --git a/src/config.rs b/src/config.rs index 32a79a4..4ffe1dc 100755 --- a/src/config.rs +++ b/src/config.rs @@ -128,7 +128,10 @@ impl Config { if host_build.exists() && host_build != build_path { let content = fs::read_to_string(&host_build).with_context(|| { - format!("Failed to read system build config: {}", host_build.display()) + format!( + "Failed to read system build config: {}", + host_build.display() + ) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.build_overrides, &val); @@ -139,7 +142,10 @@ impl Config { if build_path.exists() { let content = fs::read_to_string(&build_path).with_context(|| { - format!("Failed to read system build config: {}", build_path.display()) + format!( + "Failed to read system build config: {}", + build_path.display() + ) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.build_overrides, &val); @@ -153,7 +159,10 @@ impl Config { if host_package.exists() && host_package != package_path { let content = fs::read_to_string(&host_package).with_context(|| { - format!("Failed to read system package config: {}", host_package.display()) + format!( + "Failed to read system package config: {}", + host_package.display() + ) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.package_overrides, &val); @@ -164,7 +173,10 @@ impl Config { if package_path.exists() { let content = fs::read_to_string(&package_path).with_context(|| { - format!("Failed to read system package config: {}", package_path.display()) + format!( + "Failed to read system package config: {}", + package_path.display() + ) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.package_overrides, &val); @@ -372,7 +384,9 @@ cflags += ["-g"] // Save and restore the original value so other tests aren't affected. let orig_home = std::env::var_os("HOME"); let fake_home = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("HOME", &fake_home.path()); } + unsafe { + std::env::set_var("HOME", &fake_home.path()); + } let config = Config::for_rootfs(Path::new("/")); @@ -392,9 +406,13 @@ cflags += ["-g"] // restore original HOME if let Some(v) = orig_home { - unsafe { std::env::set_var("HOME", v); } + unsafe { + std::env::set_var("HOME", v); + } } else { - unsafe { std::env::remove_var("HOME"); } + unsafe { + std::env::remove_var("HOME"); + } } } diff --git a/src/cross.rs b/src/cross.rs index 12b7ba6..8cb63f0 100755 --- a/src/cross.rs +++ b/src/cross.rs @@ -203,12 +203,16 @@ endian = 'little' } } -/// Find a cross-compilation tool in PATH +/// Find a cross-compilation tool in PATH and return its absolute path fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result { for suffix in suffixes { let tool_name = format!("{}-{}", prefix, suffix); - if tool_exists(&tool_name) { - return Ok(tool_name); + // Use `which` to resolve to an absolute path (so callers get a usable path) + if let Ok(output) = Command::new("which").arg(&tool_name).output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(path); + } } } @@ -222,12 +226,3 @@ fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result Err(anyhow::anyhow!("Tool not found")) } - -/// Check if a tool exists in PATH -fn tool_exists(name: &str) -> bool { - Command::new("which") - .arg(name) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} diff --git a/src/db/mod.rs b/src/db/mod.rs index f8826ff..7c6dcf5 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -9,6 +9,10 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; +fn format_licenses(licenses: &[String]) -> String { + licenses.join(", ") +} + /// Initialize database and register a package pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> Result<()> { // Create parent directory (auto-create db dir if missing) @@ -40,7 +44,7 @@ pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> R spec.package.revision, spec.package.description, spec.package.homepage, - spec.package.license, + format_licenses(&spec.package.license), ], )?; @@ -405,16 +409,11 @@ fn init_db(conn: &Connection) -> Result<()> { } /// Decide whether a conflicting path is safe to auto-remove ownership for. -/// This covers shared index files (e.g. `usr/share/info/dir`) and common -/// language-shared trees such as Perl site/vendor/lib directories. +/// This covers common language-shared trees such as Perl site/vendor/lib +/// directories. fn is_auto_removable_path(path: &str) -> bool { let p = path.trim_start_matches('/'); - // Exact info index file (and compressed variants) - if p == "usr/share/info/dir" || p.starts_with("usr/share/info/dir.") { - return true; - } - // Perl shared trees (common multi-package locations) if p.starts_with("usr/lib/perl") || p.starts_with("usr/share/perl") @@ -475,7 +474,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives { @@ -578,27 +577,27 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); - // Install package 'alpha' owning usr/share/info/dir + // Install package 'alpha' owning a known shared Perl path let spec_a = mk_spec("alpha", "1.0"); let dest_a = tmp.path().join("dest_a"); - std::fs::create_dir_all(dest_a.join("usr/share/info")).unwrap(); - std::fs::write(dest_a.join("usr/share/info/dir"), "index").unwrap(); + std::fs::create_dir_all(dest_a.join("usr/share/perl5")).unwrap(); + std::fs::write(dest_a.join("usr/share/perl5/shared.pm"), "package A;").unwrap(); register_package(&db_path, &spec_a, &dest_a).unwrap(); - // Now install package 'beta' that also provides usr/share/info/dir -> should auto-clear + // Now install package 'beta' that also provides the same shared path -> should auto-clear let spec_b = mk_spec("beta", "1.0"); let dest_b = tmp.path().join("dest_b"); - std::fs::create_dir_all(dest_b.join("usr/share/info")).unwrap(); - std::fs::write(dest_b.join("usr/share/info/dir"), "index2").unwrap(); + std::fs::create_dir_all(dest_b.join("usr/share/perl5")).unwrap(); + std::fs::write(dest_b.join("usr/share/perl5/shared.pm"), "package B;").unwrap(); - // This should succeed and transfer ownership of the 'dir' path to beta + // This should succeed and transfer ownership of the shared path to beta register_package(&db_path, &spec_b, &dest_b).unwrap(); // Verify DB: alpha should no longer own the path, beta should let files_a = get_package_files(&db_path, "alpha").unwrap(); - assert!(!files_a.contains(&"usr/share/info/dir".to_string())); + assert!(!files_a.contains(&"usr/share/perl5/shared.pm".to_string())); let files_b = get_package_files(&db_path, "beta").unwrap(); - assert!(files_b.contains(&"usr/share/info/dir".to_string())); + assert!(files_b.contains(&"usr/share/perl5/shared.pm".to_string())); } #[test] @@ -668,7 +667,7 @@ mod tests { std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap(); register_package(&db_path, &spec_v1, &dest1).unwrap(); - let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[]).unwrap(); + let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[], &[]).unwrap(); assert!(rootfs.join("usr/bin/foo").exists()); assert!(rootfs.join("usr/bin/shared_dir/old_file").exists()); @@ -689,7 +688,8 @@ mod tests { vec!["usr/bin/shared_dir/old_file".to_string()] ); - let tx = crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths).unwrap(); + let tx = + crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); register_package(&db_path, &spec_v2, &dest2).unwrap(); tx.commit().unwrap(); diff --git a/src/db/repo.rs b/src/db/repo.rs index ee5f5b6..6c1f3b8 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -6,6 +6,23 @@ use std::fs; use std::path::{Path, PathBuf}; use zstd::stream::write::Encoder; +fn parse_license_text(metadata: &toml::Value) -> Option { + if let Some(s) = metadata.get("license").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + if let Some(arr) = metadata.get("license").and_then(|v| v.as_array()) { + let licenses: Vec = arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + if !licenses.is_empty() { + return Some(licenses.join(", ")); + } + } + None +} + pub struct RepoManager { pub repo_dir: PathBuf, } @@ -129,10 +146,7 @@ impl RepoManager { .get("homepage") .and_then(|v| v.as_str()) .map(String::from); - license = metadata - .get("license") - .and_then(|v| v.as_str()) - .map(String::from); + license = parse_license_text(&metadata); if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array()) { provides = provides_arr @@ -209,8 +223,11 @@ impl RepoManager { } /// Synchronize git mirrors into /usr/src/depot/ -pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap) -> Result<()> { - use git2::{Repository, FetchOptions, Cred, RemoteCallbacks, ResetType, build::RepoBuilder}; +pub fn sync_mirrors( + repo_dir: &std::path::Path, + mirrors: &std::collections::HashMap, +) -> Result<()> { + use git2::{Cred, FetchOptions, RemoteCallbacks, Repository, ResetType, build::RepoBuilder}; use std::os::unix::fs::PermissionsExt; let base = repo_dir.to_path_buf(); @@ -235,7 +252,9 @@ pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::Hash let mut builder = RepoBuilder::new(); builder.fetch_options(fo); - builder.clone(url, &target).with_context(|| format!("Failed to clone {}", url))?; + builder + .clone(url, &target) + .with_context(|| format!("Failed to clone {}", url))?; } else { println!("Updating mirror '{}' in {}", name, target.display()); // Open repository and fetch updates @@ -251,8 +270,11 @@ pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::Hash fo.remote_callbacks(cb); // Fetch from origin - let mut remote = repo.find_remote("origin").or_else(|_| repo.remote_anonymous(url))?; - remote.fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fo), None) + let mut remote = repo + .find_remote("origin") + .or_else(|_| repo.remote_anonymous(url))?; + remote + .fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fo), None) .with_context(|| format!("Failed to fetch updates for {}", url))?; // Try to fast-forward HEAD to origin/HEAD by resetting to FETCH_HEAD if present @@ -280,7 +302,10 @@ pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::Hash } /// Show status for each mirror repository: path, exists, branch/HEAD, latest commit, dirty -pub fn mirrors_status(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap) -> Result<()> { +pub fn mirrors_status( + repo_dir: &std::path::Path, + mirrors: &std::collections::HashMap, +) -> Result<()> { use git2::Repository; let base = repo_dir.to_path_buf(); @@ -308,7 +333,9 @@ pub fn mirrors_status(repo_dir: &std::path::Path, mirrors: &std::collections::Ha // Latest commit OID let oid = repo.refname_to_id("HEAD").ok(); - let short = oid.map(|o| format!("{}", o)).unwrap_or_else(|| "(unknown)".to_string()); + let short = oid + .map(|o| format!("{}", o)) + .unwrap_or_else(|| "(unknown)".to_string()); // Commit time (seconds since epoch) if available let mut commit_time = String::new(); @@ -327,7 +354,11 @@ pub fn mirrors_status(repo_dir: &std::path::Path, mirrors: &std::collections::Ha continue; } }; - let dirty = statuses.iter().any(|s| s.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW | git2::Status::WT_DELETED)); + let dirty = statuses.iter().any(|s| { + s.status().intersects( + git2::Status::WT_MODIFIED | git2::Status::WT_NEW | git2::Status::WT_DELETED, + ) + }); println!("Path: {}", target.display()); println!("Branch/HEAD: {}", branch); @@ -448,4 +479,41 @@ runtime = [] .unwrap(); assert_eq!(provides_count, 1); } + + #[test] + fn test_index_package_with_multiple_licenses() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path(); + let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&pkg_path).unwrap(); + let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); + let mut tar = tar::Builder::new(encoder); + + let metadata = r#" +name = "test" +version = "1.0" +revision = 1 +license = ["MIT", "Apache-2.0"] +"#; + let mut header = tar::Header::new_gnu(); + header.set_path(".metadata.toml").unwrap(); + header.set_size(metadata.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, metadata.as_bytes()).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut conn = Connection::open_in_memory().unwrap(); + let manager = RepoManager::new(repo_dir.to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + manager.index_package(&mut conn, &pkg_path).unwrap(); + + let lic: Option = conn + .query_row("SELECT license FROM packages", [], |r| r.get(0)) + .unwrap(); + assert_eq!(lic, Some("MIT, Apache-2.0".to_string())); + } } diff --git a/src/deps.rs b/src/deps.rs index c64580b..a8b14ab 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -173,10 +173,31 @@ pub fn check_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result Result> { + let mut missing = Vec::new(); + + if !db_path.exists() { + return Ok(spec.dependencies.test.clone()); + } + + let installed = db::get_installed_packages(db_path)?; + let provides = db::get_all_provides(db_path)?; + + for dep in &spec.dependencies.test { + if !is_dep_satisfied(dep, &installed, &provides, db_path)? { + missing.push(dep.clone()); + } + } + + Ok(missing) +} + /// Print dependency status pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> { let missing_build = check_build_deps(spec, db_path)?; let missing_runtime = check_runtime_deps(spec, db_path)?; + let missing_test = check_test_deps(spec, db_path)?; if !spec.dependencies.build.is_empty() { println!("Build dependencies: {}", spec.dependencies.build.join(", ")); @@ -195,6 +216,13 @@ pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> { } } + if !spec.dependencies.test.is_empty() { + println!("Test dependencies: {}", spec.dependencies.test.join(", ")); + if !missing_test.is_empty() { + println!(" Missing: {}", missing_test.join(", ")); + } + } + Ok(()) } @@ -212,6 +240,20 @@ 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: nyapm install ", + missing.join(", ") + ); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -260,4 +302,41 @@ mod tests { assert!(compare_versions("a", "b", VersionOp::Lt)); assert!(compare_versions("foo", "foo", VersionOp::Exact)); } + + #[test] + fn test_check_test_deps_returns_test_deps_when_db_missing() { + 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::new(), + 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::new(), + test: vec!["bats".into(), "python".into()], + }, + spec_dir: std::path::PathBuf::from("."), + }; + + let missing = check_test_deps(&spec, Path::new("/definitely/not/a/real/db")).unwrap(); + assert_eq!(missing, vec!["bats".to_string(), "python".to_string()]); + } } diff --git a/src/index.rs b/src/index.rs index 81b1968..4760535 100755 --- a/src/index.rs +++ b/src/index.rs @@ -45,7 +45,11 @@ impl PackageIndex { .by_name .insert(spec.package.name.clone(), path.clone()); for provided in &spec.alternatives.provides { - index.by_provides.entry(provided.clone()).or_default().push(path.clone()); + index + .by_provides + .entry(provided.clone()) + .or_default() + .push(path.clone()); } } } @@ -63,9 +67,15 @@ impl PackageIndex { let path = entry.path().to_path_buf(); if path.extension().map(|e| e == "toml").unwrap_or(false) { if let Ok(spec) = PackageSpec::from_file(&path) { - index.by_name.insert(spec.package.name.clone(), path.clone()); + index + .by_name + .insert(spec.package.name.clone(), path.clone()); for provided in &spec.alternatives.provides { - index.by_provides.entry(provided.clone()).or_default().push(path.clone()); + index + .by_provides + .entry(provided.clone()) + .or_default() + .push(path.clone()); } } } diff --git a/src/main.rs b/src/main.rs index d1e9158..1905d91 100755 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,86 @@ use clap::{Parser, Subcommand}; 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()) + })?; + println!("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() { + eprintln!( + "\x1b[33mWARNING: Running '{}' as root is discouraged.\x1b[0m", + command + ); + eprintln!( + "\x1b[33mA misconfigured build environment or malicious/buggy build file can overwrite or delete critical system files.\x1b[0m" + ); + eprintln!( + "\x1b[33mRecommendation: use a non-root build user and only install as root.\x1b[0m" + ); + eprintln!("\x1b[33mCurrent rootfs target: {}\x1b[0m", rootfs.display()); + } +} + +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 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, + )?; + + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + spec_for_out.package = out; + if let Err(e) = db::register_package(&db_path, &spec_for_out, destdir) { + let _ = tx.rollback(); + return Err(e); + } + } + tx.commit()?; + + Ok(()) +} + #[derive(Parser)] #[command(name = "Depot")] #[command(about = "Depot - Source-based package manager for Linux", long_about = None)] @@ -30,10 +110,25 @@ struct Cli { #[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, + #[command(subcommand)] command: Commands, } @@ -64,6 +159,10 @@ enum Commands { /// 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 { @@ -109,6 +208,7 @@ fn main() -> Result<()> { 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 @@ -122,7 +222,8 @@ fn main() -> Result<()> { if !spec_path.exists() { let name = spec_path.to_string_lossy().to_string(); println!("Looking up package '{}' in local indexes...", name); - let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + 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; } @@ -159,10 +260,6 @@ fn main() -> Result<()> { ); } - // We need to parse the metadata.toml but we don't have a direct "from_metadata" - // Let's implement a minimal reconstruction or use the metadata to fill a spec. - // Actually, PackageSpec needs a lot of fields. - // Let's extract the WHOLE archive to a temporary staging dir and use it. let file = fs::File::open(&spec_path)?; let zstd_decoder = zstd::stream::read::Decoder::new(file)?; let mut archive = tar::Archive::new(zstd_decoder); @@ -197,11 +294,7 @@ fn main() -> Result<()> { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - license: metadata - .get("license") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), + license: parse_licenses_from_toml(&metadata), }, packages: Vec::new(), alternatives: package::Alternatives::default(), @@ -225,6 +318,18 @@ fn main() -> Result<()> { } 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() + }, }, spec_dir: PathBuf::from("."), }; @@ -266,6 +371,8 @@ fn main() -> Result<()> { // 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) @@ -277,6 +384,14 @@ fn main() -> Result<()> { 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 @@ -301,7 +416,9 @@ fn main() -> Result<()> { if input == "y" || input == "yes" || input.is_empty() { // Build package index for fast lookups - let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + 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() { @@ -324,9 +441,15 @@ fn main() -> Result<()> { 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); @@ -348,6 +471,9 @@ fn main() -> Result<()> { // Enforce build dependencies (runtime deps are warnings only if not installed/prompt declined) deps::require_build_deps(&pkg_spec, &db_path)?; + if needs_test_deps { + deps::require_test_deps(&pkg_spec, &db_path)?; + } } // Ensure database directory exists @@ -377,7 +503,13 @@ fn main() -> Result<()> { .as_ref() .map(|p| cross::CrossConfig::from_prefix(p)) .transpose()?; - builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?; + 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)?; @@ -388,25 +520,8 @@ fn main() -> Result<()> { // 4. Stage (clean .la files, etc.) staging::process(&destdir, &pkg_spec)?; - // 5. Install/update to rootfs (atomic) - 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, &cli.rootfs, &tx_base, &remove_paths)?; - - // 6. Register in database (rollback install on DB error) - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - spec_for_out.package = out; - if let Err(e) = db::register_package(&db_path, &spec_for_out, &destdir) { - let _ = tx.rollback(); - return Err(e); - } - } - tx.commit()?; + // 5-6. Install/update to rootfs and register in DB + install_staged_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?; // 7. Check runtime dependencies (warn only) if !cli.no_deps { @@ -427,6 +542,10 @@ fn main() -> Result<()> { "Successfully installed {} v{}", pkg_spec.package.name, pkg_spec.package.version ); + + if cli.clean { + clean_build_workspace(&config)?; + } } Commands::Remove { package } => { println!("Removing package: {}", package); @@ -435,12 +554,12 @@ fn main() -> Result<()> { db::remove_package(&db_path, &package, &cli.rootfs)?; println!("Successfully removed {}", package); } - Commands::Build { spec_pos, spec } => { - if crate::fakeroot::is_root() { - anyhow::bail!( - "The 'build' command must be run as a non-root user to ensure a clean build environment." - ); - } + 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")?; println!("Building package from: {}", spec_path.display()); let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; @@ -461,8 +580,13 @@ fn main() -> Result<()> { // 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)?; deps::require_build_deps(&pkg_spec, &db_path)?; + if needs_test_deps { + deps::require_test_deps(&pkg_spec, &db_path)?; + } } let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?; @@ -477,7 +601,13 @@ fn main() -> Result<()> { .as_ref() .map(|p| cross::CrossConfig::from_prefix(p)) .transpose()?; - builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?; + builder::build( + &pkg_spec, + &src_dir, + &destdir, + cross_config.as_ref(), + !cli.no_flags, + )?; staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; @@ -493,7 +623,8 @@ fn main() -> Result<()> { for out in pkg_spec.outputs() { let mut spec_for_out = pkg_spec.clone(); spec_for_out.package = out; - let packager = package::Packager::new(spec_for_out.clone(), destdir.clone(), config.clone()); + let packager = + package::Packager::new(spec_for_out.clone(), destdir.clone(), config.clone()); let pkg_file = packager.create_package(Path::new("."), arch)?; created_files.push(pkg_file); } @@ -501,6 +632,18 @@ fn main() -> Result<()> { for f in &created_files { println!("Build complete. Package created: {}", f.display()); } + + if install { + install_staged_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?; + println!( + "Successfully installed {} v{}", + pkg_spec.package.name, pkg_spec.package.version + ); + } + + if cli.clean { + clean_build_workspace(&config)?; + } } Commands::Info { package } => { // Try as file first, then as installed package name @@ -540,7 +683,10 @@ fn main() -> Result<()> { println!("No mirrors configured in /etc/depot.d/mirrors.toml"); } else { db::repo::sync_mirrors(&config.repo_clone_dir, &config.mirrors)?; - println!("Mirrors synchronized into {}", config.repo_clone_dir.display()); + println!( + "Mirrors synchronized into {}", + config.repo_clone_dir.display() + ); } } RepoCommands::Status => { @@ -571,7 +717,8 @@ fn main() -> Result<()> { // 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))); + let output_path = + output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); if output_path.exists() { println!( diff --git a/src/package/interactive.rs b/src/package/interactive.rs index a6a1afb..b0edd00 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -35,7 +35,9 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { let mut buf = [0u8; 8192]; loop { let n = r.read(&mut buf)?; - if n == 0 { break; } + if n == 0 { + break; + } hasher.update(&buf[..n]); } Ok(format!("{:x}", hasher.finalize())) @@ -45,11 +47,14 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { if let Ok(parsed) = Url::parse(u) { match parsed.scheme() { "http" | "https" => { - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .with_context(|| "failed to build http client")?; - let mut resp = client.get(u).send().with_context(|| format!("failed to GET {}", u))?; + let ua = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + let client = + crate::source::build_blocking_client(&ua, Some(Duration::from_secs(20))) + .with_context(|| "failed to build http client")?; + let mut resp = client + .get(u) + .send() + .with_context(|| format!("failed to GET {}", u))?; if !resp.status().is_success() { anyhow::bail!("HTTP status {}", resp.status()); } @@ -59,11 +64,17 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { let host = parsed.host_str().context("ftp url missing host")?; let port = parsed.port_or_known_default().unwrap_or(21); let addr = format!("{}:{}", host, port); - let mut ftp_stream = ftp::FtpStream::connect(addr.as_str()) + let mut ftp_stream = suppaftp::FtpStream::connect(addr.as_str()) .with_context(|| format!("failed to connect to {}", addr))?; - let user = if parsed.username().is_empty() { "anonymous" } else { parsed.username() }; + let user = if parsed.username().is_empty() { + "anonymous" + } else { + parsed.username() + }; let pass = parsed.password().unwrap_or("anonymous@"); - ftp_stream.login(user, pass).with_context(|| "ftp login failed")?; + ftp_stream + .login(user, pass) + .with_context(|| "ftp login failed")?; let mut result_hex = None; let path = parsed.path(); let candidates = [path.to_string(), path.trim_start_matches('/').to_string()]; @@ -71,14 +82,20 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { if let Ok(res) = ftp_stream.retr(p, |reader| { // reuse hash_reader by adapting reader to trait object let mut r = reader; - hash_reader(&mut r).map_err(|e| ftp::FtpError::ConnectionError(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))) + hash_reader(&mut r).map_err(|e| { + suppaftp::FtpError::ConnectionError(std::io::Error::other( + e.to_string(), + )) + }) }) { result_hex = Some(res); break; } } ftp_stream.quit().ok(); - if let Some(h) = result_hex { return Ok(h); } + if let Some(h) = result_hex { + return Ok(h); + } anyhow::bail!("ftp retrieval failed") } "file" => { @@ -88,7 +105,7 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { } anyhow::bail!("invalid file URL") } - _ => anyhow::bail!("unsupported URL scheme") + _ => anyhow::bail!("unsupported URL scheme"), } } @@ -99,7 +116,64 @@ fn compute_sha256_for_url(u: &str) -> anyhow::Result { return hash_reader(&mut f); } - anyhow::bail!("could not compute sha256 for '{}': unsupported or unreachable", u) + anyhow::bail!( + "could not compute sha256 for '{}': unsupported or unreachable", + u + ) +} + +fn prompt_repeating_list(item_label: &str, help: &str) -> Result> { + let mut values = Vec::new(); + loop { + let prompt = format!("{} #{} (empty to finish):", item_label, values.len() + 1); + let entry = Text::new(&prompt).with_help_message(help).prompt()?; + let trimmed = entry.trim(); + if trimmed.is_empty() { + break; + } + values.push(trimmed.to_string()); + } + Ok(values) +} + +fn prompt_optional_text(prompt: &str, help: &str) -> Result { + Ok(Text::new(prompt) + .with_help_message(help) + .prompt()? + .trim() + .to_string()) +} + +fn parse_license_list(input: &str) -> Vec { + input + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +fn licenses_to_toml_value(licenses: &[String]) -> Option { + if licenses.is_empty() { + None + } else if licenses.len() == 1 { + Some(toml::Value::String(licenses[0].clone())) + } else { + Some(toml::Value::Array( + licenses + .iter() + .map(|license| toml::Value::String(license.clone())) + .collect(), + )) + } +} + +fn expand_known_package_vars(input: &str, name: &str, version: &str) -> String { + input + .replace("$name", name) + .replace("${name}", name) + .replace("$version", version) + .replace("${version}", version) } pub fn create_interactive() -> Result { @@ -148,18 +222,24 @@ pub fn create_interactive() -> Result { .with_default(homepage_default.as_str()) .prompt()?; - let license = Text::new("License:") - .with_help_message("e.g. MIT, GPL-3.0, Apache-2.0") + let license_input = Text::new("License(s):") + .with_help_message("Single SPDX id or comma-separated list, e.g. MIT, Apache-2.0") .with_placeholder("MIT") .prompt()?; + let license = parse_license_list(&license_input); + if license.is_empty() { + anyhow::bail!("At least one license is required"); + } - // Present core build system choices only (keep interactive concise) + // Present all supported build systems. let build_types = vec![ BuildType::Autotools, BuildType::CMake, BuildType::Meson, BuildType::Makefile, BuildType::Rust, + BuildType::Custom, + BuildType::Bin, ]; let build_type = Select::new("Build System:", build_types) @@ -184,62 +264,24 @@ pub fn create_interactive() -> Result { } } - // Ask whether to show advanced fields — if yes, prompt for them later. + // Ask whether to show advanced fields. let show_advanced = Confirm::new("Show advanced options?") - .with_help_message("Enable prompts for optional build flags and advanced fields") + .with_help_message("Includes source subdir, toolchain overrides, hooks, and build tuning") .with_default(false) .prompt()?; - let build_dir = if Confirm::new("Use separate build directory?") - .with_help_message("e.g., build/ or out/") - .with_default(false) - .prompt()? - { - Some( - Text::new("Build directory name:") - .with_default("build") - .prompt()?, - ) - } else { - None - }; - - // Advanced fields (only shown when requested) - let mut cflags = Vec::new(); - let mut ldflags = Vec::new(); - let mut chost = String::new(); - let mut cbuild = String::new(); - let mut carch = String::new(); - let mut bindir = String::new(); - - if show_advanced { - let c = Text::new("CFLAGS (optional, empty to skip):") - .with_help_message("Space-separated CFLAGS") - .prompt()?; - if !c.trim().is_empty() { - cflags = c.split_whitespace().map(String::from).collect(); - } - let l = Text::new("LDFLAGS (optional, empty to skip):") - .with_help_message("Space-separated LDFLAGS") - .prompt()?; - if !l.trim().is_empty() { - ldflags = l.split_whitespace().map(String::from).collect(); - } - chost = Text::new("CHOST (optional):").prompt()?; - cbuild = Text::new("CBUILD (optional):").prompt()?; - carch = Text::new("CARCH (optional):").prompt()?; - bindir = Text::new("Binary install dir (optional, default /usr/bin):") - .with_default("/usr/bin") - .prompt()?; - } - let source_url = Text::new("Source URL:") .with_help_message("URL to the source tarball or git repository") .with_default(gnu_source_default.as_str()) .prompt()?; + if source_url.trim().is_empty() { + anyhow::bail!("Source URL cannot be empty"); + } + // Attempt to compute SHA256 automatically when online — use as the default if available. - let computed_sha_default = match compute_sha256_for_url(&source_url) { + let checksum_source_url = expand_known_package_vars(&source_url, &name, &version); + let computed_sha_default = match compute_sha256_for_url(&checksum_source_url) { Ok(hex) => { // Use raw hex as the default so pressing Enter accepts it hex @@ -248,7 +290,9 @@ pub fn create_interactive() -> Result { }; let source_sha256 = Text::new("Source checksum:") - .with_help_message("Accepts sha256:, sha512:, md5:, or raw SHA256 hex (use 'skip' to bypass)") + .with_help_message( + "Accepts sha256:, sha512:, md5:, or raw SHA256 hex (use 'skip' to bypass)", + ) .with_default(computed_sha_default.as_str()) .prompt()?; @@ -257,35 +301,187 @@ pub fn create_interactive() -> Result { .with_default("$name-$version") .prompt()?; - let mut sources = Vec::new(); - if !source_url.is_empty() { - sources.push(Source { - url: source_url, - sha256: source_sha256, - extract_dir, - patches: Vec::new(), - post_extract: Vec::new(), - }); + let mut source = Source { + url: source_url, + sha256: source_sha256, + extract_dir, + patches: Vec::new(), + post_extract: Vec::new(), + }; + + if show_advanced { + source.patches = prompt_repeating_list( + "Patch path/URL", + "Patch file path (relative to spec dir) or direct URL", + )?; + source.post_extract = prompt_repeating_list( + "post_extract command", + "Runs in extracted source directory using sh -c", + )?; } - let mut runtime_deps = Vec::new(); - loop { - let dep = Text::new("Runtime Dependency (empty to finish):").prompt()?; - if dep.is_empty() { - break; - } - runtime_deps.push(dep); + let mut flags = BuildFlags::default(); + + if !matches!(build_type, BuildType::Bin) { + flags.prefix = Text::new("Install prefix:") + .with_help_message("Most packages should use /usr") + .with_default(flags.prefix.as_str()) + .prompt()?; } - let mut build_deps = Vec::new(); - loop { - let dep = Text::new("Build-time Dependency (empty to finish):").prompt()?; - if dep.is_empty() { - break; + let supports_separate_build_dir = matches!( + build_type, + BuildType::Autotools | BuildType::CMake | BuildType::Meson | BuildType::Custom + ); + if supports_separate_build_dir { + let default_bdir = if matches!(build_type, BuildType::Meson) { + "builddir" + } else { + "build" + }; + let default_enabled = matches!(build_type, BuildType::CMake | BuildType::Meson); + if Confirm::new("Use separate build directory?") + .with_help_message("Recommended for CMake/Meson and often useful for Autotools") + .with_default(default_enabled) + .prompt()? + { + flags.build_dir = Some( + Text::new("Build directory name:") + .with_default(default_bdir) + .prompt()?, + ); } - build_deps.push(dep); } + if show_advanced { + flags.source_subdir = prompt_optional_text( + "Source subdirectory (optional):", + "Use for monorepos (e.g. llvm-project/clang, src)", + )?; + flags.cc = Text::new("CC:") + .with_help_message("C compiler") + .with_default(flags.cc.as_str()) + .prompt()?; + flags.cxx = Text::new("CXX:") + .with_help_message("C++ compiler") + .with_default(flags.cxx.as_str()) + .prompt()?; + flags.ar = Text::new("AR:") + .with_help_message("Archiver tool") + .with_default(flags.ar.as_str()) + .prompt()?; + flags.chost = prompt_optional_text( + "CHOST target triple (optional):", + "Example: x86_64-sfg-linux-gnu", + )?; + flags.cbuild = prompt_optional_text( + "CBUILD build triple (optional):", + "Example: x86_64-pc-linux-gnu", + )?; + flags.carch = Text::new("CARCH:") + .with_help_message("CPU architecture short name") + .with_default(flags.carch.as_str()) + .prompt()?; + flags.cflags = prompt_repeating_list( + "CFLAG", + "One flag per entry, e.g. -O2, -fPIC, -D_GNU_SOURCE", + )?; + flags.ldflags = prompt_repeating_list("LDFLAG", "One flag per entry, e.g. -Wl,-z,relro")?; + } + + if matches!( + build_type, + BuildType::Autotools | BuildType::CMake | BuildType::Meson + ) { + let help = match build_type { + BuildType::Autotools => "Examples: --disable-static, --enable-nls, --with-zlib", + BuildType::CMake => "Examples: -DENABLE_TESTS=OFF, -DUSE_SYSTEM_LIBS=ON", + BuildType::Meson => "Examples: -Dtests=false, -Ddefault_library=static", + _ => "", + }; + if Confirm::new("Add configure/setup options?") + .with_help_message("Adds entries to build.flags.configure") + .with_default(show_advanced) + .prompt()? + { + flags.configure = prompt_repeating_list("Configure option", help)?; + } + } + + if show_advanced && matches!(build_type, BuildType::Autotools) { + flags.skip_tests = Confirm::new("Skip automatic tests (make check/test)?") + .with_help_message( + "If enabled, Depot will not auto-run detected Autotools test targets", + ) + .with_default(false) + .prompt()?; + } + + if matches!(build_type, BuildType::Makefile) { + println!("Define Makefile build and install commands."); + flags.makefile_commands = prompt_repeating_list( + "makefile build command", + "Examples: make -j$(nproc), make all", + )?; + if flags.makefile_commands.is_empty() { + anyhow::bail!("Makefile build type requires at least one build command"); + } + flags.makefile_install_commands = prompt_repeating_list( + "makefile install command", + "Examples: make DESTDIR=$DESTDIR install", + )?; + if flags.makefile_install_commands.is_empty() { + anyhow::bail!("Makefile build type requires at least one install command"); + } + } + + if matches!(build_type, BuildType::Rust) { + let profiles = vec!["release", "debug"]; + let profile = Select::new("Cargo profile:", profiles) + .with_help_message("Release is recommended for production packages") + .prompt()?; + flags.profile = profile.to_string(); + flags.target = prompt_optional_text( + "Rust target triple (optional):", + "Example: x86_64-unknown-linux-gnu", + )?; + if show_advanced { + flags.rustflags = + prompt_repeating_list("RUSTFLAG", "One flag per entry, e.g. -Ctarget-cpu=native")?; + flags.cargs = prompt_repeating_list( + "Extra cargo arg", + "Examples: --locked, --features, serde, --no-default-features", + )?; + } + flags.bindir = Text::new("Binary install dir:") + .with_help_message("Destination inside package image") + .with_default(flags.bindir.as_str()) + .prompt()?; + } + + if matches!(build_type, BuildType::Bin) { + flags.binary_type = prompt_optional_text( + "Binary type (optional):", + "Metadata only, e.g. deb, rpm, tarball", + )?; + } + + if show_advanced { + flags.post_compile = + prompt_repeating_list("post_compile command", "Runs after build, before install")?; + flags.post_install = + prompt_repeating_list("post_install command", "Runs after install step")?; + } + + let runtime_deps = + prompt_repeating_list("Runtime dependency", "Package name (e.g. zlib, openssl)")?; + let build_deps = + prompt_repeating_list("Build dependency", "Package needed only at build time")?; + let test_deps = prompt_repeating_list( + "Test dependency", + "Package needed only for running package test suites", + )?; + Ok(PackageSpec { package: PackageInfo { name, @@ -298,23 +494,12 @@ pub fn create_interactive() -> Result { packages: Vec::new(), alternatives: Alternatives::default(), manual_sources: Vec::new(), - source: sources, - build: Build { - build_type, - flags: BuildFlags { - build_dir, - cflags, - ldflags, - chost, - cbuild, - carch, - bindir: if bindir.is_empty() { BuildFlags::default().bindir } else { bindir }, - ..BuildFlags::default() - }, - }, + source: vec![source], + build: Build { build_type, flags }, dependencies: Dependencies { build: build_deps, runtime: runtime_deps, + test: test_deps, }, spec_dir: PathBuf::from("."), }) @@ -330,15 +515,24 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { // package block let mut pkg = Table::new(); pkg.insert("name".into(), Value::String(spec.package.name.clone())); - pkg.insert("version".into(), Value::String(spec.package.version.clone())); + pkg.insert( + "version".into(), + Value::String(spec.package.version.clone()), + ); if !spec.package.description.is_empty() { - pkg.insert("description".into(), Value::String(spec.package.description.clone())); + pkg.insert( + "description".into(), + Value::String(spec.package.description.clone()), + ); } if !spec.package.homepage.is_empty() { - pkg.insert("homepage".into(), Value::String(spec.package.homepage.clone())); + pkg.insert( + "homepage".into(), + Value::String(spec.package.homepage.clone()), + ); } - if !spec.package.license.is_empty() { - pkg.insert("license".into(), Value::String(spec.package.license.clone())); + if let Some(v) = licenses_to_toml_value(&spec.package.license) { + pkg.insert("license".into(), v); } root.insert("package".into(), Value::Table(pkg)); @@ -355,8 +549,8 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if !p.homepage.is_empty() { pt.insert("homepage".into(), Value::String(p.homepage.clone())); } - if !p.license.is_empty() { - pt.insert("license".into(), Value::String(p.license.clone())); + if let Some(v) = licenses_to_toml_value(&p.license) { + pt.insert("license".into(), v); } arr.push(Value::Table(pt)); } @@ -372,10 +566,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if !s.sha256.is_empty() && s.sha256.to_lowercase() != "skip" { st.insert("sha256".into(), Value::String(s.sha256.clone())); } - let default_extract = format!("{}-{}", spec.package.name, spec.package.version); - if s.extract_dir != default_extract { - st.insert("extract_dir".into(), Value::String(s.extract_dir.clone())); - } + st.insert("extract_dir".into(), Value::String(s.extract_dir.clone())); if !s.patches.is_empty() { st.insert( "patches".into(), @@ -385,7 +576,12 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { if !s.post_extract.is_empty() { st.insert( "post_extract".into(), - Value::Array(s.post_extract.iter().map(|p| Value::String(p.clone())).collect()), + Value::Array( + s.post_extract + .iter() + .map(|p| Value::String(p.clone())) + .collect(), + ), ); } arr.push(Value::Table(st)); @@ -395,35 +591,278 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { // build (only include set fields) let mut build_tbl = Table::new(); - build_tbl.insert("type".into(), Value::String(format!("{}", format!("{:?}", spec.build.build_type).to_lowercase()))); + let build_type = match spec.build.build_type { + BuildType::Autotools => "autotools", + BuildType::CMake => "cmake", + BuildType::Meson => "meson", + BuildType::Custom => "custom", + BuildType::Rust => "rust", + BuildType::Makefile => "makefile", + BuildType::Bin => "bin", + }; + build_tbl.insert("type".into(), Value::String(build_type.to_string())); + let defaults = BuildFlags::default(); let mut flags_tbl = Table::new(); if let Some(bdir) = &spec.build.flags.build_dir { flags_tbl.insert("build_dir".into(), Value::String(bdir.clone())); } + if !spec.build.flags.source_subdir.is_empty() { + flags_tbl.insert( + "source_subdir".into(), + Value::String(spec.build.flags.source_subdir.clone()), + ); + } if !spec.build.flags.cflags.is_empty() { flags_tbl.insert( "cflags".into(), - Value::Array(spec.build.flags.cflags.iter().map(|s| Value::String(s.clone())).collect()), + Value::Array( + spec.build + .flags + .cflags + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), ); } if !spec.build.flags.ldflags.is_empty() { flags_tbl.insert( "ldflags".into(), - Value::Array(spec.build.flags.ldflags.iter().map(|s| Value::String(s.clone())).collect()), + Value::Array( + spec.build + .flags + .ldflags + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), ); } + if !spec.build.flags.keep.is_empty() { + flags_tbl.insert( + "keep".into(), + Value::Array( + spec.build + .flags + .keep + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if spec.build.flags.no_flags != defaults.no_flags { + flags_tbl.insert("no_flags".into(), Value::Boolean(spec.build.flags.no_flags)); + } + if spec.build.flags.skip_tests != defaults.skip_tests { + flags_tbl.insert( + "skip_tests".into(), + Value::Boolean(spec.build.flags.skip_tests), + ); + } + if !spec.build.flags.configure.is_empty() { + flags_tbl.insert( + "configure".into(), + Value::Array( + spec.build + .flags + .configure + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if spec.build.flags.prefix != defaults.prefix { + flags_tbl.insert( + "prefix".into(), + Value::String(spec.build.flags.prefix.clone()), + ); + } + if spec.build.flags.cc != defaults.cc { + flags_tbl.insert("cc".into(), Value::String(spec.build.flags.cc.clone())); + } + if spec.build.flags.cxx != defaults.cxx { + flags_tbl.insert("cxx".into(), Value::String(spec.build.flags.cxx.clone())); + } + if spec.build.flags.ar != defaults.ar { + flags_tbl.insert("ar".into(), Value::String(spec.build.flags.ar.clone())); + } + if !spec.build.flags.libc.is_empty() { + flags_tbl.insert("libc".into(), Value::String(spec.build.flags.libc.clone())); + } if !spec.build.flags.chost.is_empty() { - flags_tbl.insert("chost".into(), Value::String(spec.build.flags.chost.clone())); + flags_tbl.insert( + "chost".into(), + Value::String(spec.build.flags.chost.clone()), + ); } if !spec.build.flags.cbuild.is_empty() { - flags_tbl.insert("cbuild".into(), Value::String(spec.build.flags.cbuild.clone())); + flags_tbl.insert( + "cbuild".into(), + Value::String(spec.build.flags.cbuild.clone()), + ); } - if !spec.build.flags.carch.is_empty() && spec.build.flags.carch != BuildFlags::default().carch { - flags_tbl.insert("carch".into(), Value::String(spec.build.flags.carch.clone())); + if !spec.build.flags.carch.is_empty() && spec.build.flags.carch != defaults.carch { + flags_tbl.insert( + "carch".into(), + Value::String(spec.build.flags.carch.clone()), + ); } - if spec.build.flags.bindir != BuildFlags::default().bindir { - flags_tbl.insert("bindir".into(), Value::String(spec.build.flags.bindir.clone())); + if !spec.build.flags.make_vars.is_empty() { + flags_tbl.insert( + "make_vars".into(), + Value::Array( + spec.build + .flags + .make_vars + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.make_test_vars.is_empty() { + flags_tbl.insert( + "make_test_vars".into(), + Value::Array( + spec.build + .flags + .make_test_vars + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.make_install_vars.is_empty() { + flags_tbl.insert( + "make_install_vars".into(), + Value::Array( + spec.build + .flags + .make_install_vars + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.passthrough_env.is_empty() { + flags_tbl.insert( + "passthrough_env".into(), + Value::Array( + spec.build + .flags + .passthrough_env + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.post_compile.is_empty() { + flags_tbl.insert( + "post_compile".into(), + Value::Array( + spec.build + .flags + .post_compile + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.post_install.is_empty() { + flags_tbl.insert( + "post_install".into(), + Value::Array( + spec.build + .flags + .post_install + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.makefile_commands.is_empty() { + flags_tbl.insert( + "makefile_commands".into(), + Value::Array( + spec.build + .flags + .makefile_commands + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.makefile_install_commands.is_empty() { + flags_tbl.insert( + "makefile_install_commands".into(), + Value::Array( + spec.build + .flags + .makefile_install_commands + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if spec.build.flags.profile != defaults.profile { + flags_tbl.insert( + "profile".into(), + Value::String(spec.build.flags.profile.clone()), + ); + } + if !spec.build.flags.target.is_empty() { + flags_tbl.insert( + "target".into(), + Value::String(spec.build.flags.target.clone()), + ); + } + if !spec.build.flags.rustflags.is_empty() { + flags_tbl.insert( + "rustflags".into(), + Value::Array( + spec.build + .flags + .rustflags + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.build.flags.cargs.is_empty() { + flags_tbl.insert( + "cargs".into(), + Value::Array( + spec.build + .flags + .cargs + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if spec.build.flags.bindir != defaults.bindir { + flags_tbl.insert( + "bindir".into(), + Value::String(spec.build.flags.bindir.clone()), + ); + } + if !spec.build.flags.binary_type.is_empty() { + flags_tbl.insert( + "binary_type".into(), + Value::String(spec.build.flags.binary_type.clone()), + ); } if !flags_tbl.is_empty() { @@ -432,18 +871,45 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { root.insert("build".into(), Value::Table(build_tbl)); // dependencies - if !spec.dependencies.build.is_empty() || !spec.dependencies.runtime.is_empty() { + if !spec.dependencies.build.is_empty() + || !spec.dependencies.runtime.is_empty() + || !spec.dependencies.test.is_empty() + { let mut dep_tbl = Table::new(); if !spec.dependencies.build.is_empty() { dep_tbl.insert( "build".into(), - Value::Array(spec.dependencies.build.iter().map(|s| Value::String(s.clone())).collect()), + Value::Array( + spec.dependencies + .build + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), ); } if !spec.dependencies.runtime.is_empty() { dep_tbl.insert( "runtime".into(), - Value::Array(spec.dependencies.runtime.iter().map(|s| Value::String(s.clone())).collect()), + Value::Array( + spec.dependencies + .runtime + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.dependencies.test.is_empty() { + dep_tbl.insert( + "test".into(), + Value::Array( + spec.dependencies + .test + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), ); } root.insert("dependencies".into(), Value::Table(dep_tbl)); @@ -466,7 +932,7 @@ mod tests { revision: 1, description: "A test".into(), homepage: "".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives::default(), @@ -506,7 +972,7 @@ mod tests { revision: 1, description: "A test".into(), homepage: "".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: vec![PackageInfo { name: "foo-dev".into(), @@ -514,7 +980,7 @@ mod tests { revision: 1, description: "dev files".into(), homepage: "".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }], alternatives: Alternatives::default(), manual_sources: Vec::new(), @@ -538,11 +1004,191 @@ mod tests { assert!(toml.contains("name = \"foo-dev\"")); } + #[test] + fn spec_to_minimal_toml_includes_multiple_licenses_as_array() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: vec!["MIT".into(), "Apache-2.0".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("license = [")); + assert!(toml.contains("\"MIT\"")); + assert!(toml.contains("\"Apache-2.0\"")); + } + + #[test] + fn spec_to_minimal_toml_includes_extended_build_flags() { + let mut flags = BuildFlags::default(); + flags.source_subdir = "project/subdir".into(); + flags.configure = vec!["--disable-static".into(), "--enable-foo".into()]; + flags.post_compile = vec!["make check".into()]; + flags.post_install = vec!["strip $DESTDIR/usr/bin/foo".into()]; + flags.makefile_commands = vec!["make".into()]; + flags.makefile_install_commands = vec!["make DESTDIR=$DESTDIR install".into()]; + flags.cargs = vec!["--locked".into()]; + flags.rustflags = vec!["-Ctarget-cpu=native".into()]; + flags.target = "x86_64-unknown-linux-gnu".into(); + flags.keep = vec!["etc/locale.gen".into()]; + flags.no_flags = true; + flags.skip_tests = true; + flags.make_vars = vec!["V=1".into()]; + flags.make_test_vars = vec!["TESTS=unit".into()]; + flags.make_install_vars = vec!["STRIPPROG=true".into()]; + + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: vec!["fix.patch".into()], + post_extract: vec!["autoreconf -fi".into()], + }], + build: Build { + build_type: BuildType::Autotools, + flags, + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("source_subdir = \"project/subdir\"")); + assert!(toml.contains("configure = [")); + assert!(toml.contains("post_compile = [")); + assert!(toml.contains("post_install = [")); + assert!(toml.contains("makefile_commands = [")); + assert!(toml.contains("makefile_install_commands = [")); + assert!(toml.contains("cargs = [")); + assert!(toml.contains("rustflags = [")); + assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\"")); + assert!(toml.contains("keep = [")); + assert!(toml.contains("\"etc/locale.gen\"")); + assert!(toml.contains("no_flags = true")); + assert!(toml.contains("skip_tests = true")); + assert!(toml.contains("make_vars = [")); + assert!(toml.contains("make_test_vars = [")); + assert!(toml.contains("make_install_vars = [")); + assert!(toml.contains("patches = [")); + assert!(toml.contains("post_extract = [")); + } + + #[test] + fn spec_to_minimal_toml_includes_extract_dir_for_variable_default() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "$name-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("extract_dir = \"$name-$version\"")); + } + + #[test] + fn spec_to_minimal_toml_includes_test_dependencies() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies { + build: vec![], + runtime: vec![], + test: vec!["python".into(), "bats".into()], + }, + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + let val: toml::Value = toml::from_str(&toml).unwrap(); + let test_deps = val + .get("dependencies") + .and_then(|d| d.get("test")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.test array"); + assert_eq!(test_deps.len(), 2); + assert_eq!(test_deps[0].as_str(), Some("python")); + assert_eq!(test_deps[1].as_str(), Some("bats")); + } + #[test] fn compute_sha256_for_local_path_and_file_url() { - use tempfile::NamedTempFile; - use sha2::Sha256 as TestSha256; use sha2::Digest as TestDigest; + use sha2::Sha256 as TestSha256; + use tempfile::NamedTempFile; let mut tmp = NamedTempFile::new().unwrap(); std::io::Write::write_all(&mut tmp, b"abc").unwrap(); @@ -560,5 +1206,14 @@ mod tests { let file_url = format!("file://{}", tmp.path().to_str().unwrap()); assert_eq!(compute_sha256_for_url(&file_url).unwrap(), expected); } -} + #[test] + fn expand_known_package_vars_replaces_name_and_version_patterns() { + let input = "https://example.com/$name/${name}-$version-${version}.tar.xz"; + let out = expand_known_package_vars(input, "python", "3.13.1"); + assert_eq!( + out, + "https://example.com/python/python-3.13.1-3.13.1.tar.xz" + ); + } +} diff --git a/src/package/packager.rs b/src/package/packager.rs index b6e5fd1..c18a68a 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -8,6 +8,19 @@ use std::path::{Path, PathBuf}; use tar::Builder; use zstd::stream::write::Encoder; +fn license_value(licenses: &[String]) -> toml::Value { + if licenses.len() == 1 { + toml::Value::String(licenses[0].clone()) + } else { + toml::Value::Array( + licenses + .iter() + .map(|license| toml::Value::String(license.clone())) + .collect(), + ) + } +} + pub struct Packager { pub spec: PackageSpec, pub destdir: PathBuf, @@ -119,7 +132,7 @@ impl Packager { ); map.insert( "license".to_string(), - toml::Value::String(self.spec.package.license.clone()), + license_value(&self.spec.package.license), ); // Add provides @@ -159,6 +172,17 @@ impl Packager { .collect(), ), ); + build_deps.insert( + "test".to_string(), + toml::Value::Array( + self.spec + .dependencies + .test + .iter() + .map(|s| toml::Value::String(s.clone())) + .collect(), + ), + ); map.insert("dependencies".to_string(), toml::Value::Table(build_deps)); let toml_str = toml::to_string(&toml::Value::Table(map)) @@ -229,7 +253,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives::default(), @@ -301,5 +325,28 @@ mod tests { 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()); + } + + #[test] + fn test_generate_metadata_toml_with_multiple_licenses() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path(); + + let mut packager = mk_packager(dest.to_path_buf()); + packager.spec.package.license = vec!["MIT".into(), "Apache-2.0".into()]; + packager.generate_metadata_toml().unwrap(); + + let meta_path = dest.join(".metadata.toml"); + let content = fs::read_to_string(meta_path).unwrap(); + let val: toml::Value = toml::from_str(&content).unwrap(); + let arr = val + .get("license") + .and_then(|v| v.as_array()) + .expect("license should be an array"); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].as_str(), Some("MIT")); + assert_eq!(arr[1].as_str(), Some("Apache-2.0")); } } diff --git a/src/package/spec.rs b/src/package/spec.rs index f6c3af2..f88cf60 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -3,6 +3,8 @@ use anyhow::{Context, Result}; use serde::Deserialize; use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; use std::fmt; use std::fs; use std::path::Path; @@ -52,10 +54,43 @@ impl PackageSpec { if spec.source.is_empty() && spec.manual_sources.is_empty() { anyhow::bail!("Package must have at least one source or manual_sources entry"); } + spec.validate_manual_sources()?; Ok(spec) } + fn validate_manual_sources(&self) -> Result<()> { + for (idx, manual) in self.manual_sources.iter().enumerate() { + let has_file = manual + .file + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let has_url = manual + .url + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + match (has_file, has_url) { + (true, false) | (false, true) => {} + (false, false) => { + anyhow::bail!( + "manual_sources[{}] must specify exactly one of 'file' or 'url'", + idx + ); + } + (true, true) => { + anyhow::bail!( + "manual_sources[{}] cannot specify both 'file' and 'url'", + idx + ); + } + } + } + Ok(()) + } + /// Expand variables like $name and $version in a string pub fn expand_vars(&self, input: &str) -> String { let specdir = self.spec_dir.to_string_lossy(); @@ -127,6 +162,17 @@ impl PackageSpec { self.build.flags.ldflags = vec![s.to_string()]; } } + "keep" => { + if let Some(arr) = v.as_array() { + self.build.flags.keep = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.keep = vec![s.to_string()]; + } + } "cc" => { if let Some(s) = v.as_str() { self.build.flags.cc = s.to_string(); @@ -162,6 +208,65 @@ impl PackageSpec { self.build.flags.carch = s.to_string(); } } + "make_vars" | "make-vars" | "make_build_vars" | "make-build-vars" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_vars = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_vars = + s.split_whitespace().map(String::from).collect(); + } + } + "make_test_vars" | "make-test-vars" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_test_vars = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_test_vars = + s.split_whitespace().map(String::from).collect(); + } + } + "make_install_vars" | "make-install-vars" => { + if let Some(arr) = v.as_array() { + self.build.flags.make_install_vars = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.make_install_vars = + s.split_whitespace().map(String::from).collect(); + } + } + "passthrough_env" | "passthrough-env" | "pass_env" | "pass-env" | "export_env" + | "export-env" => { + if let Some(arr) = v.as_array() { + self.build.flags.passthrough_env = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.passthrough_env = + s.split_whitespace().map(String::from).collect(); + } + } + "no_flags" | "no-flags" => { + if let Some(b) = v.as_bool() { + self.build.flags.no_flags = b; + } + } + "skip_tests" | "skip-tests" => { + if let Some(b) = v.as_bool() { + self.build.flags.skip_tests = b; + } + } // Add more fields as needed _ => {} } @@ -194,6 +299,18 @@ impl PackageSpec { } } } + "keep" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .keep + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.keep.push(s.to_string()); + } + } + } "configure" => { for v in values { if let Some(arr) = v.as_array() { @@ -260,6 +377,77 @@ impl PackageSpec { self.build.flags.carch = s.to_string(); } } + "make_vars" | "make-vars" | "make_build_vars" | "make-build-vars" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_vars + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_vars + .extend(s.split_whitespace().map(String::from)); + } + } + } + "make_test_vars" | "make-test-vars" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_test_vars + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_test_vars + .extend(s.split_whitespace().map(String::from)); + } + } + } + "make_install_vars" | "make-install-vars" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .make_install_vars + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .make_install_vars + .extend(s.split_whitespace().map(String::from)); + } + } + } + "passthrough_env" | "passthrough-env" | "pass_env" | "pass-env" | "export_env" + | "export-env" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .passthrough_env + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build + .flags + .passthrough_env + .extend(s.split_whitespace().map(String::from)); + } + } + } + "no_flags" | "no-flags" => { + if let Some(b) = values.last().and_then(|v| v.as_bool()) { + self.build.flags.no_flags = b; + } + } + "skip_tests" | "skip-tests" => { + if let Some(b) = values.last().and_then(|v| v.as_bool()) { + self.build.flags.skip_tests = b; + } + } _ => {} } } @@ -344,6 +532,39 @@ type = "custom" assert_eq!(spec.sources()[1].extract_dir, "bar"); } + #[test] + fn parse_multiple_licenses() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = ["MIT", "Apache-2.0"] + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.package.license, + vec!["MIT".to_string(), "Apache-2.0".to_string()] + ); + } + #[test] fn parse_rejects_empty_sources() { let tmp = tempfile::tempdir().unwrap(); @@ -371,6 +592,101 @@ type = "custom" assert!(PackageSpec::from_file(&path).is_err()); } + #[test] + fn parse_manual_source_with_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +url = "https://example.com/manual.patch" +sha256 = "skip" +dest = "patches/manual.patch" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.manual_sources.len(), 1); + assert_eq!( + spec.manual_sources[0].url.as_deref(), + Some("https://example.com/manual.patch") + ); + assert_eq!(spec.manual_sources[0].file, None); + } + + #[test] + fn parse_manual_source_rejects_missing_file_and_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +sha256 = "skip" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); + assert!(err.to_string().contains("exactly one of 'file' or 'url'")); + } + + #[test] + fn parse_manual_source_rejects_file_and_url_together() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +file = "manual.patch" +url = "https://example.com/manual.patch" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); + assert!( + err.to_string() + .contains("cannot specify both 'file' and 'url'") + ); + } + #[test] fn test_apply_config() { let mut spec = mk_spec("foo", "1.0"); @@ -382,6 +698,11 @@ type = "custom" [flags] cc = "my-cc" cflags = ["-O2"] +passthrough_env = ["RUSTFLAGS"] +make_vars = ["V=1"] +no_flags = true +skip_tests = true +keep = ["etc/locale.gen"] "#, ) .unwrap(); @@ -396,6 +717,24 @@ cflags = ["-O2"] toml::Value::String("opt-level=3".to_string()), ])], ); + config.appends.insert( + "build.flags.keep".to_string(), + vec![toml::Value::Array(vec![toml::Value::String( + "etc/locale.gen".to_string(), + )])], + ); + config.appends.insert( + "build.flags.passthrough_env".to_string(), + vec![toml::Value::String("CARGO_HOME".to_string())], + ); + config.appends.insert( + "build.flags.make_test_vars".to_string(), + vec![toml::Value::String("TESTS=smoke".to_string())], + ); + config.appends.insert( + "build.flags.make_install_vars".to_string(), + vec![toml::Value::String("DESTDIR=/tmp/pkg".to_string())], + ); spec.apply_config(&config); @@ -409,6 +748,326 @@ cflags = ["-O2"] .rustflags .contains(&"opt-level=3".to_string()) ); + assert!(spec.build.flags.no_flags); + assert!( + spec.build + .flags + .keep + .contains(&"etc/locale.gen".to_string()) + ); + assert!( + spec.build + .flags + .passthrough_env + .contains(&"RUSTFLAGS".to_string()) + ); + assert!( + spec.build + .flags + .passthrough_env + .contains(&"CARGO_HOME".to_string()) + ); + assert!(spec.build.flags.make_vars.contains(&"V=1".to_string())); + assert!(spec.build.flags.skip_tests); + assert!( + spec.build + .flags + .make_test_vars + .contains(&"TESTS=smoke".to_string()) + ); + assert!( + spec.build + .flags + .make_install_vars + .contains(&"DESTDIR=/tmp/pkg".to_string()) + ); + } + + #[test] + fn parse_no_flags_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +no_flags = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_flags); + } + + #[test] + fn parse_no_flags_alias_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +"no-flags" = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_flags); + } + + #[test] + fn parse_skip_tests_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +skip_tests = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.skip_tests); + } + + #[test] + fn parse_skip_tests_alias_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +"skip-tests" = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.skip_tests); + } + + #[test] + fn parse_keep_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +keep = ["etc/locale.gen", "etc/resolv.conf"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.keep, + vec!["etc/locale.gen".to_string(), "etc/resolv.conf".to_string()] + ); + } + + #[test] + fn parse_passthrough_env_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +passthrough_env = ["RUSTFLAGS", "CARGO_HOME"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.passthrough_env, + vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()] + ); + } + + #[test] + fn parse_test_dependencies_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[dependencies] +build = ["make"] +test = ["python", "bats"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.dependencies.test, + vec!["python".to_string(), "bats".to_string()] + ); + } + + #[test] + fn parse_make_var_overrides_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +make_vars = ["V=1", "CC=clang"] +make_test_vars = ["TESTS=unit"] +make_install_vars = ["STRIPPROG=true"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.make_vars, + vec!["V=1".to_string(), "CC=clang".to_string()] + ); + assert_eq!( + spec.build.flags.make_test_vars, + vec!["TESTS=unit".to_string()] + ); + assert_eq!( + spec.build.flags.make_install_vars, + vec!["STRIPPROG=true".to_string()] + ); } #[test] @@ -444,9 +1103,12 @@ cbuild = "x86_64-pc-linux-gnu" // Override via config let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); - config.build_overrides = toml::from_str(r#"[flags] + config.build_overrides = toml::from_str( + r#"[flags] carch = "armv7" -"#).unwrap(); +"#, + ) + .unwrap(); spec.apply_config(&config); assert_eq!(spec.build.flags.carch, "armv7"); } @@ -509,7 +1171,7 @@ type = "custom" revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives::default(), @@ -540,7 +1202,7 @@ impl fmt::Display for PackageSpec { )?; writeln!(f, "Description: {}", self.package.description)?; writeln!(f, "Homepage: {}", self.package.homepage)?; - writeln!(f, "License: {}", self.package.license)?; + writeln!(f, "License: {}", self.package.license.join(", "))?; writeln!(f, "Sources: {}", self.source.len())?; writeln!(f, "Build Type: {:?}", self.build.build_type)?; if !self.alternatives.provides.is_empty() { @@ -560,13 +1222,45 @@ pub struct PackageInfo { pub revision: u32, pub description: String, pub homepage: String, - pub license: String, + #[serde( + deserialize_with = "deserialize_licenses", + serialize_with = "serialize_licenses" + )] + pub license: Vec, } fn default_revision() -> u32 { 1 } +fn deserialize_licenses<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrArray { + String(String), + Array(Vec), + } + + match StringOrArray::deserialize(deserializer)? { + StringOrArray::String(s) => Ok(vec![s]), + StringOrArray::Array(v) => Ok(v), + } +} + +fn serialize_licenses(licenses: &[String], serializer: S) -> std::result::Result +where + S: Serializer, +{ + if licenses.len() == 1 { + serializer.serialize_str(&licenses[0]) + } else { + licenses.serialize(serializer) + } +} + impl PackageSpec { /// Generate the standard package filename: ---.depot.pkg.tar.zst pub fn package_filename(&self, arch: &str) -> String { @@ -613,15 +1307,20 @@ pub struct Source { pub post_extract: Vec, } -/// Manual (local) source file to copy before fetching remote sources. +/// Manual source copied before standard source fetching. #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ManualSource { - /// Filename in the spec directory - pub file: String, - /// SHA256 checksum (optional, use "skip" to bypass verification) + /// Filename in the spec directory (local manual source mode). + #[serde(default)] + pub file: Option, + /// Remote URL to fetch (remote manual source mode). + #[serde(default)] + pub url: Option, + /// Checksum (optional, use "skip" to bypass verification). #[serde(default)] pub sha256: Option, - /// Destination path relative to build work directory (default: same as file) + /// Destination path relative to build work directory. + /// Defaults to `file` for local mode or a derived filename for URL mode. #[serde(default)] pub dest: Option, } @@ -675,6 +1374,15 @@ pub struct BuildFlags { pub cflags: Vec, #[serde(default, deserialize_with = "deserialize_string_or_array")] pub ldflags: Vec, + /// Keep existing files and install package-provided replacement as `.depotnew`. + #[serde(default, deserialize_with = "deserialize_string_or_array")] + pub keep: Vec, + /// Disable exporting CFLAGS/CXXFLAGS/LDFLAGS for this package build. + #[serde(default, alias = "no-flags")] + pub no_flags: bool, + /// Skip automatic build-system test execution (e.g. Autotools `make check`/`make test`). + #[serde(default, alias = "skip-tests")] + pub skip_tests: bool, #[serde(default)] pub configure: Vec, /// C compiler @@ -721,6 +1429,41 @@ pub struct BuildFlags { /// CPU architecture short name (CARCH equivalent), e.g. "x86_64", "aarch64" #[serde(default = "default_carch")] pub carch: String, + /// Variable overrides passed directly to `make` (compile step), e.g. ["V=1", "CC=clang"]. + #[serde( + default, + alias = "make-vars", + alias = "make_build_vars", + alias = "make-build-vars", + deserialize_with = "deserialize_string_or_array" + )] + pub make_vars: Vec, + /// Variable overrides passed directly to `make check` / `make test`. + #[serde( + default, + alias = "make-test-vars", + deserialize_with = "deserialize_string_or_array" + )] + pub make_test_vars: Vec, + /// Variable overrides passed directly to `make install`. + #[serde( + default, + alias = "make-install-vars", + deserialize_with = "deserialize_string_or_array" + )] + pub make_install_vars: Vec, + /// Additional host environment variable names to export unchanged to build commands. + /// Example: ["RUSTFLAGS", "CARGO_HOME"]. + #[serde( + default, + alias = "passthrough-env", + alias = "pass_env", + alias = "pass-env", + alias = "export_env", + alias = "export-env", + deserialize_with = "deserialize_string_or_array" + )] + pub passthrough_env: Vec, // Rust-specific fields /// Rust build profile: "debug" or "release" (default: release) @@ -756,6 +1499,9 @@ impl Default for BuildFlags { BuildFlags { cflags: Vec::new(), ldflags: Vec::new(), + keep: Vec::new(), + no_flags: false, + skip_tests: false, configure: Vec::new(), cc: default_cc(), cxx: default_cxx(), @@ -770,6 +1516,10 @@ impl Default for BuildFlags { chost: String::new(), cbuild: String::new(), carch: default_carch(), + make_vars: Vec::new(), + make_test_vars: Vec::new(), + make_install_vars: Vec::new(), + passthrough_env: Vec::new(), profile: default_profile(), target: String::new(), rustflags: Vec::new(), @@ -852,8 +1602,13 @@ fn default_cxx() -> String { /// Package dependencies #[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] pub struct Dependencies { + /// Dependencies required for building packages. #[serde(default)] pub build: Vec, + /// Dependencies required at runtime. #[serde(default)] pub runtime: Vec, + /// Dependencies required to run package test suites. + #[serde(default)] + pub test: Vec, } diff --git a/src/source/extractor.rs b/src/source/extractor.rs index bbdd474..77070b5 100755 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -7,7 +7,7 @@ use std::fs::{self, File}; use std::io::{Cursor, Read, Write}; use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; -use tempfile::{tempdir, NamedTempFile}; +use tempfile::{NamedTempFile, tempdir}; use walkdir::WalkDir; use zstd::stream::read::Decoder as ZstdDecoder; @@ -85,21 +85,21 @@ fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> { // strip that top directory (source tarballs like foo-1.2.3/) or preserve // it (system-layout archives like usr/). Otherwise move all top-level // entries into `dest` so `dest` always contains the source root. - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) @@ -130,21 +130,21 @@ fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> { let mut archive = tar::Archive::new(decoder); archive.unpack(tmp.path())?; - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) @@ -175,21 +175,21 @@ fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> { let mut archive = tar::Archive::new(decoder); archive.unpack(tmp.path())?; - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) @@ -219,21 +219,21 @@ fn extract_tar(path: &Path, dest: &Path) -> Result<()> { let mut archive = tar::Archive::new(file); archive.unpack(tmp.path())?; - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) @@ -263,21 +263,21 @@ fn extract_zip(path: &Path, dest: &Path) -> Result<()> { let mut archive = zip::ZipArchive::new(file)?; archive.extract(tmp.path())?; - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) @@ -308,21 +308,21 @@ fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> { let mut archive = tar::Archive::new(decoder); archive.unpack(tmp.path())?; - let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + let top = fs::read_dir(tmp.path())? + .filter_map(|r| r.ok()) + .collect::>(); if top.len() == 1 && top[0].path().is_dir() { let top_name = top[0].file_name().to_string_lossy().to_string(); - let expected_basename = dest - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); // blacklist of system-layout directories we should NOT strip let sys_blacklist = [ - "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", - "dev", "proc", "sys", "boot", "srv", "home", + "usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev", + "proc", "sys", "boot", "srv", "home", ]; - let looks_like_versioned = |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); + let looks_like_versioned = + |s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit()); let should_strip = (!sys_blacklist.contains(&top_name.as_str())) && (top_name == expected_basename || (!expected_basename.is_empty() && top_name.contains(expected_basename)) diff --git a/src/source/fetcher.rs b/src/source/fetcher.rs index 9d2c0a4..3ec562c 100755 --- a/src/source/fetcher.rs +++ b/src/source/fetcher.rs @@ -4,11 +4,14 @@ use crate::package::{PackageSpec, Source}; use anyhow::{Context, Result, bail}; use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use url::Url; +const MAX_MIRROR_RETRIES: usize = 8; + /// Fetch an archive source tarball, returning path to downloaded file. pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> Result { let url = spec.expand_vars(&source.url); @@ -30,33 +33,48 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R // Parse URL early so we can handle non-HTTP schemes (FTP support) let parsed_url = Url::parse(&url).with_context(|| format!("Invalid URL: {}", url))?; - // If this is an FTP URL, fetch via the ftp crate into the cache and continue + // If this is an FTP URL, fetch via suppaftp into the cache and continue if parsed_url.scheme() == "ftp" { // Connect and login (anonymous fallback) let host = parsed_url.host_str().context("FTP URL missing host")?; let port = parsed_url.port_or_known_default().unwrap_or(21); let addr = format!("{}:{}", host, port); - let mut ftp_stream = ftp::FtpStream::connect(addr.as_str()) + let mut ftp_stream = suppaftp::FtpStream::connect(addr.as_str()) .with_context(|| format!("Failed to connect to FTP host: {}", addr))?; - let user = if parsed_url.username().is_empty() { "anonymous" } else { parsed_url.username() }; + let user = if parsed_url.username().is_empty() { + "anonymous" + } else { + parsed_url.username() + }; let pass = parsed_url.password().unwrap_or("anonymous@"); - ftp_stream.login(user, pass).with_context(|| format!("FTP login failed for {}", host))?; + ftp_stream + .login(user, pass) + .with_context(|| format!("FTP login failed for {}", host))?; // Retrieve the path (try with and without leading slash) let path = parsed_url.path(); let candidates = [path.to_string(), path.trim_start_matches('/').to_string()]; let mut retrieved = false; for p in candidates.iter().filter(|s| !s.is_empty()) { - match ftp_stream.retr(p, |reader: &mut dyn Read| -> std::result::Result<(), ftp::FtpError> { - let mut file = File::create(&dest_path).map_err(ftp::FtpError::ConnectionError)?; - let mut buffer = [0u8; 8192]; - loop { - let bytes_read = reader.read(&mut buffer).map_err(ftp::FtpError::ConnectionError)?; - if bytes_read == 0 { break; } - file.write_all(&buffer[..bytes_read]).map_err(ftp::FtpError::ConnectionError)?; - } - Ok(()) - }) { + match ftp_stream.retr( + p, + |reader: &mut dyn Read| -> std::result::Result<(), suppaftp::FtpError> { + let mut file = + File::create(&dest_path).map_err(suppaftp::FtpError::ConnectionError)?; + let mut buffer = [0u8; 8192]; + loop { + let bytes_read = reader + .read(&mut buffer) + .map_err(suppaftp::FtpError::ConnectionError)?; + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read]) + .map_err(suppaftp::FtpError::ConnectionError)?; + } + Ok(()) + }, + ) { Ok(_) => { retrieved = true; break; @@ -74,10 +92,8 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R // Use a sensible default User-Agent so servers that reject empty/unknown agents (e.g. IANA) // will accept requests. Include package name/version at compile time. let ua = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - let client = reqwest::blocking::Client::builder() - .user_agent(ua) - .build() - .with_context(|| "Failed to build HTTP client")?; + let client = + super::build_blocking_client(&ua, None).with_context(|| "Failed to build HTTP client")?; let mut response = client .get(&url) @@ -132,42 +148,71 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R // Quick validation: ensure the downloaded file looks like the expected // archive (detect obvious HTML error pages or wrong formats by magic). - // If validate_downloaded_archive returns an alternate URL (e.g. SourceForge - // mirror), retry the download with that URL once. - if let Some(alt) = validate_downloaded_archive(&dest_path, &filename, &url)? { + // If validation returns an alternate URL (e.g. SourceForge mirror), follow + // and retry a few times. + let mut next_alt = validate_downloaded_archive(&dest_path, &filename, &url)?; + let mut seen_alts: HashSet = HashSet::new(); + let mut retries = 0usize; + while let Some(alt) = next_alt { + retries += 1; + if retries > MAX_MIRROR_RETRIES { + bail!( + "Exceeded mirror retry limit ({}) while fetching {}", + MAX_MIRROR_RETRIES, + url + ); + } + if !seen_alts.insert(alt.clone()) { + bail!("Mirror retry loop detected for URL: {}", alt); + } println!("Retrying download from mirror: {}", alt); fs::remove_file(&dest_path).ok(); - // If mirror URL is FTP -> use ftp crate; otherwise use HTTP retry. + // If mirror URL is FTP -> use suppaftp; otherwise use HTTP retry. if let Ok(alt_url) = Url::parse(&alt) { if alt_url.scheme() == "ftp" { // FTP mirror retrieval let host = alt_url.host_str().context("FTP mirror URL missing host")?; let port = alt_url.port_or_known_default().unwrap_or(21); let addr = format!("{}:{}", host, port); - let mut ftp_stream = ftp::FtpStream::connect(addr.as_str()) + let mut ftp_stream = suppaftp::FtpStream::connect(addr.as_str()) .with_context(|| format!("Failed to connect to FTP host: {}", addr))?; - let user = if alt_url.username().is_empty() { "anonymous" } else { alt_url.username() }; + let user = if alt_url.username().is_empty() { + "anonymous" + } else { + alt_url.username() + }; let pass = alt_url.password().unwrap_or("anonymous@"); - ftp_stream.login(user, pass).with_context(|| format!("FTP login failed for {}", host))?; + ftp_stream + .login(user, pass) + .with_context(|| format!("FTP login failed for {}", host))?; let path = alt_url.path(); let candidates = [path.to_string(), path.trim_start_matches('/').to_string()]; let mut retrieved = false; for p in candidates.iter().filter(|s| !s.is_empty()) { - match ftp_stream.retr(p, |reader: &mut dyn Read| -> std::result::Result<(), ftp::FtpError> { - let mut file = File::create(&dest_path).map_err(ftp::FtpError::ConnectionError)?; - let mut buffer = [0u8; 8192]; - let mut downloaded = 0u64; - loop { - let bytes_read = reader.read(&mut buffer).map_err(ftp::FtpError::ConnectionError)?; - if bytes_read == 0 { break; } - file.write_all(&buffer[..bytes_read]).map_err(ftp::FtpError::ConnectionError)?; - downloaded += bytes_read as u64; - pb.set_position(downloaded); - } - Ok(()) - }) { + match ftp_stream.retr( + p, + |reader: &mut dyn Read| -> std::result::Result<(), suppaftp::FtpError> { + let mut file = File::create(&dest_path) + .map_err(suppaftp::FtpError::ConnectionError)?; + let mut buffer = [0u8; 8192]; + let mut downloaded = 0u64; + loop { + let bytes_read = reader + .read(&mut buffer) + .map_err(suppaftp::FtpError::ConnectionError)?; + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read]) + .map_err(suppaftp::FtpError::ConnectionError)?; + downloaded += bytes_read as u64; + pb.set_position(downloaded); + } + Ok(()) + }, + ) { Ok(_) => { retrieved = true; break; @@ -182,15 +227,30 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R } else { // HTTP(S) mirror retry (recreate client for retry) let ua = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - let client = reqwest::blocking::Client::builder() - .user_agent(ua) - .build() + let client = super::build_blocking_client(&ua, None) .with_context(|| "Failed to build HTTP client")?; let mut response = client .get(&alt) .send() .with_context(|| format!("Failed to fetch mirror URL: {}", alt))?; + let status = response.status(); + if !status.is_success() { + let mut preview_bytes = Vec::new(); + let _ = response.take(1024).read_to_end(&mut preview_bytes); + let preview = String::from_utf8_lossy(&preview_bytes); + bail!( + "HTTP error fetching {}: {}{}", + alt, + status, + if preview.trim().is_empty() { + "".to_string() + } else { + format!(" — preview: {}", preview.trim()) + } + ); + } + let mut file = File::create(&dest_path) .with_context(|| format!("Failed to create: {}", dest_path.display()))?; let mut buffer = [0u8; 8192]; @@ -211,9 +271,7 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R } pb.finish_with_message("Download complete (mirror)"); - - // Re-validate the mirrored file - validate_downloaded_archive(&dest_path, &filename, &alt)?; + next_alt = validate_downloaded_archive(&dest_path, &filename, &alt)?; } // Verify checksum @@ -239,13 +297,12 @@ fn verify_checksum(path: &Path, expected: &str) -> Result { super::verify_file_hash(path, expected) } - /// Derive a stable filename from a URL. /// /// Rules: /// - Parse as URL and use the last path segment if it looks like a filename (contains a dot) /// - Otherwise fall back to a stable hash-based name: source-{sha256(url)[..12]}.download -fn derive_filename_from_url(url: &str) -> String { +pub(crate) fn derive_filename_from_url(url: &str) -> String { // try to parse the URL if let Some(last) = Url::parse(url).ok().and_then(|parsed| { parsed @@ -267,7 +324,11 @@ fn derive_filename_from_url(url: &str) -> String { /// Validate downloaded file's magic header to make sure it is the expected /// archive format (avoids saving HTML pages or other unexpected content). -fn validate_downloaded_archive(path: &std::path::Path, filename: &str, orig_url: &str) -> Result> { +fn validate_downloaded_archive( + path: &std::path::Path, + filename: &str, + orig_url: &str, +) -> Result> { use std::io::Read; let mut f = std::fs::File::open(path)?; let mut buf = [0u8; 4096]; @@ -275,114 +336,251 @@ fn validate_downloaded_archive(path: &std::path::Path, filename: &str, orig_url: let head = &buf[..n.min(4096)]; // Detect obvious HTML error pages (case-insensitive) - let head_str = String::from_utf8_lossy(head).to_ascii_lowercase(); - if head_str.starts_with(" Option { - if let Some(start) = body[..pos].rfind("href=\"") { - let rest = &body[start + 6..]; - if let Some(end) = rest.find('"') { - return Some(rest[..end].to_string()); - } - } - if let Some(start) = body[..pos].rfind("href='") { - let rest = &body[start + 6..]; - if let Some(end) = rest.find('\'') { - return Some(rest[..end].to_string()); - } - } - None - }; - - // look for downloads.sourceforge.net links in HTML - if let Some(pos) = body.find("downloads.sourceforge.net") { - if let Some(href) = find_href_before(&body, pos) { - let alt = if href.starts_with("//") { - format!("https:{}", href) - } else if href.starts_with("/") { - format!("https://downloads.sourceforge.net{}", href) - } else { - href - }; - return Ok(Some(alt)); - } + if is_html_content(head) { + if url_contains_sourceforge_host(orig_url) { + let body = std::fs::read(path) + .map(|bytes| String::from_utf8_lossy(&bytes).to_string()) + .unwrap_or_else(|_| String::from_utf8_lossy(head).to_string()); + if let Some(alt) = sourceforge_alt_url_from_html(&body, orig_url) { + return Ok(Some(alt)); } - - // Also try to find '/download' link anywhere in the body - if let Some(pos) = body.find("/download") { - if let Some(href) = find_href_before(&body, pos) { - let alt = if href.starts_with("//") { - format!("https:{}", href) - } else { - href - }; - return Ok(Some(alt)); - } - } - - // Final fallback for SF project pages: append '/download' to the - // original URL (this normally triggers the mirror redirect). - return Ok(Some(format!("{}/download", orig_url.trim_end_matches('/')))); } - let preview = String::from_utf8_lossy(&head[..head.len().min(1024)]); anyhow::bail!( "Downloaded file '{}' looks like HTML (not an archive). Preview: {}", filename, - preview.trim() + html_preview(head) ); } // Validate by extension (best-effort) let lower = filename.to_ascii_lowercase(); - let is_ok = if lower.ends_with(".tar.xz") || lower.ends_with(".txz") || lower.ends_with(".xz") { + let is_ok = classify_archive_magic(head, &lower, path); + + if !is_ok { + anyhow::bail!( + "Downloaded file '{}' does not match expected archive magic; preview: {}", + filename, + html_preview(head) + ); + } + + Ok(None) +} + +fn sourceforge_alt_url_from_html(body: &str, orig_url: &str) -> Option { + let lower = body.to_ascii_lowercase(); + for href in extract_hrefs(body, &lower) { + if href.contains("downloads.sourceforge.net") + && let Some(url) = sourceforge_candidate_from_href(&href) + { + return Some(url); + } + } + for href in extract_hrefs(body, &lower) { + if href.contains("/download") + && let Some(url) = sourceforge_candidate_from_href(&href) + { + return Some(url); + } + } + sourceforge_download_fallback(orig_url) +} + +fn extract_hrefs(body: &str, lower: &str) -> Vec { + let mut out = Vec::new(); + let mut i = 0usize; + while let Some(rel) = lower[i..].find("href=") { + let start = i + rel + 5; + if start >= body.len() { + break; + } + let quote = body.as_bytes()[start] as char; + if quote != '"' && quote != '\'' { + i = start + 1; + continue; + } + let val_start = start + 1; + if val_start > body.len() { + break; + } + if let Some(end_rel) = body[val_start..].find(quote) { + out.push(body[val_start..val_start + end_rel].to_string()); + i = val_start + end_rel + 1; + } else { + break; + } + } + out +} + +fn normalize_downloads_sf_href(href: &str) -> String { + let href = href.replace("&", "&"); + if href.starts_with("//") { + format!("https:{}", href) + } else if href.starts_with('/') { + format!("https://downloads.sourceforge.net{}", href) + } else { + href + } +} + +fn normalize_sf_href(href: &str) -> String { + let href = href.replace("&", "&"); + if href.starts_with("//") { + format!("https:{}", href) + } else if href.starts_with('/') { + format!("https://sourceforge.net{}", href) + } else { + href + } +} + +fn sourceforge_candidate_from_href(href: &str) -> Option { + let normalized = if href.contains("downloads.sourceforge.net") { + normalize_downloads_sf_href(href) + } else { + normalize_sf_href(href) + }; + let parsed = Url::parse(&normalized).ok()?; + let host = parsed.host_str()?.to_ascii_lowercase(); + + if is_sourceforge_host(&host) || is_downloads_sourceforge_host(&host) { + return Some(normalized); + } + + // Some HTML pages use social share links that embed a real SourceForge URL + // in a query parameter (e.g. x.com/share?url=...). + if is_social_share_host(&host) { + for (k, v) in parsed.query_pairs() { + if (k.eq_ignore_ascii_case("url") || k.eq_ignore_ascii_case("u")) + && let Ok(inner) = Url::parse(v.as_ref()) + && let Some(inner_host) = inner.host_str() + { + let inner_host = inner_host.to_ascii_lowercase(); + if is_sourceforge_host(&inner_host) || is_downloads_sourceforge_host(&inner_host) { + return Some(inner.to_string()); + } + } + } + } + + None +} + +fn sourceforge_download_fallback(orig_url: &str) -> Option { + let parsed = Url::parse(orig_url).ok()?; + let host = parsed.host_str()?.to_ascii_lowercase(); + + if is_sourceforge_host(&host) || is_downloads_sourceforge_host(&host) { + return Some(format!("{}/download", orig_url.trim_end_matches('/'))); + } + + if is_social_share_host(&host) { + for (k, v) in parsed.query_pairs() { + if (k.eq_ignore_ascii_case("url") || k.eq_ignore_ascii_case("u")) + && let Ok(inner) = Url::parse(v.as_ref()) + && let Some(inner_host) = inner.host_str() + { + let inner_host = inner_host.to_ascii_lowercase(); + if is_sourceforge_host(&inner_host) || is_downloads_sourceforge_host(&inner_host) { + return Some(format!("{}/download", inner.as_str().trim_end_matches('/'))); + } + } + } + } + + None +} + +fn is_sourceforge_host(host: &str) -> bool { + host == "sourceforge.net" || host.ends_with(".sourceforge.net") +} + +fn is_downloads_sourceforge_host(host: &str) -> bool { + host == "downloads.sourceforge.net" || host.ends_with(".downloads.sourceforge.net") +} + +fn is_social_share_host(host: &str) -> bool { + matches!( + host, + "x.com" + | "www.x.com" + | "twitter.com" + | "www.twitter.com" + | "facebook.com" + | "www.facebook.com" + | "linkedin.com" + | "www.linkedin.com" + | "reddit.com" + | "www.reddit.com" + | "t.me" + | "telegram.me" + ) +} + +fn url_contains_sourceforge_host(url: &str) -> bool { + Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())) + .map(|h| is_sourceforge_host(&h) || is_downloads_sourceforge_host(&h)) + .unwrap_or_else(|| { + url.contains("://sourceforge.net/") + || url.contains("://downloads.sourceforge.net/") + || url.contains(".sourceforge.net/") + }) +} + +fn html_preview(head: &[u8]) -> String { + String::from_utf8_lossy(&head[..head.len().min(1024)]) + .trim() + .to_string() +} + +fn is_html_content(head: &[u8]) -> bool { + let head_str = String::from_utf8_lossy(head).to_ascii_lowercase(); + head_str.starts_with(" bool { + if lower_filename.ends_with(".tar.xz") + || lower_filename.ends_with(".txz") + || lower_filename.ends_with(".xz") + { head.starts_with(&[0xFD, b'7', b'z', b'X', b'Z', 0x00]) - } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".gz") { + } else if lower_filename.ends_with(".tar.gz") + || lower_filename.ends_with(".tgz") + || lower_filename.ends_with(".gz") + { head.starts_with(&[0x1F, 0x8B]) - } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") || lower.ends_with(".zst") { + } else if lower_filename.ends_with(".tar.zst") + || lower_filename.ends_with(".tzst") + || lower_filename.ends_with(".zst") + { head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) - } else if lower.ends_with(".zip") { + } else if lower_filename.ends_with(".zip") { head.starts_with(b"PK\x03\x04") - } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") { + } else if lower_filename.ends_with(".tar.bz2") || lower_filename.ends_with(".tbz2") { head.starts_with(&[0x42, 0x5A, 0x68]) - } else if lower.ends_with(".tar") { - // check for ustar magic at offset 257 + } else if lower_filename.ends_with(".tar") { if let Ok(mut f2) = std::fs::File::open(path) { let mut hdr = [0u8; 262]; if f2.read_exact(&mut hdr).is_ok() { &hdr[257..262] == b"ustar" } else { - true // can't validate; be permissive + true } } else { true } - } else if lower.ends_with(".deb") { - // ar archive starts with "!\n" + } else if lower_filename.ends_with(".deb") { head.starts_with(b"!") - } else if lower.ends_with(".rpm") { - // rpm contains cpio magic later; best-effort: accept - true } else { - // Unknown extension -> be permissive + // rpm/unknown extensions: keep permissive as before. true - }; - - if !is_ok { - let preview = String::from_utf8_lossy(&head[..head.len().min(1024)]); - anyhow::bail!( - "Downloaded file '{}' does not match expected archive magic; preview: {}", - filename, - preview.trim() - ); } - - Ok(None) } #[cfg(test)] @@ -413,7 +611,10 @@ mod tests { #[test] fn filename_from_ftp_url() { - assert_eq!(derive_filename_from_url("ftp://example.com/foo-1.2.3.tar.gz"), "foo-1.2.3.tar.gz"); + assert_eq!( + derive_filename_from_url("ftp://example.com/foo-1.2.3.tar.gz"), + "foo-1.2.3.tar.gz" + ); } #[test] @@ -426,7 +627,11 @@ mod tests { fn sourceforge_html_no_link_falls_back_to_download_suffix() { use std::io::Write; let mut tmp = tempfile::NamedTempFile::new().unwrap(); - write!(tmp, "No direct link").unwrap(); + write!( + tmp, + "No direct link" + ) + .unwrap(); let alt = validate_downloaded_archive( tmp.path(), "zsh-5.9.tar.xz", @@ -455,7 +660,73 @@ mod tests { .unwrap(); assert_eq!( alt, - Some("https://downloads.sourceforge.net/project/zsh/zsh/5.9/zsh-5.9.tar.xz?download".to_string()) + Some( + "https://downloads.sourceforge.net/project/zsh/zsh/5.9/zsh-5.9.tar.xz?download" + .to_string() + ) + ); + } + + #[test] + fn sourceforge_html_large_page_extracts_downloads_link_beyond_4k() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + "{}download", + "x".repeat(9000) + ) + .unwrap(); + let alt = validate_downloaded_archive( + tmp.path(), + "tcl8.6.17-src.tar.gz", + "https://sourceforge.net/projects/tcl/files/tcl8.6.17-src.tar.gz", + ) + .unwrap(); + assert_eq!( + alt, + Some( + "https://downloads.sourceforge.net/project/tcl/tcl8.6.17-src.tar.gz?download" + .to_string() + ) + ); + } + + #[test] + fn sourceforge_html_ignores_social_share_links_and_unwraps_url_param() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + "share" + ) + .unwrap(); + let alt = validate_downloaded_archive( + tmp.path(), + "tcl8.6.17-src.tar.gz", + "https://sourceforge.net/projects/tcl/files/Tcl/8.6.17/tcl8.6.17-src.tar.gz", + ) + .unwrap(); + assert_eq!( + alt, + Some( + "https://sourceforge.net/projects/tcl/files/Tcl/8.6.17/tcl8.6.17-src.tar.gz/download" + .to_string() + ) + ); + } + + #[test] + fn sourceforge_share_url_fallback_uses_embedded_sourceforge_url() { + let fallback = sourceforge_download_fallback( + "https://x.com/share?url=https://sourceforge.net/projects/tcl/files/Tcl/8.6.17/tcl8.6.17-src.tar.gz", + ); + assert_eq!( + fallback, + Some( + "https://sourceforge.net/projects/tcl/files/Tcl/8.6.17/tcl8.6.17-src.tar.gz/download" + .to_string() + ) ); } @@ -495,5 +766,3 @@ mod tests { assert!(!verify_checksum(tmp.path(), "md5:deadbeef").unwrap()); } } - - diff --git a/src/source/git.rs b/src/source/git.rs index 8d7fe98..50aa734 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -19,15 +19,23 @@ pub fn checkout( git_cache_dir: &Path, pkgname: &str, ) -> Result<()> { - fs::create_dir_all(git_cache_dir) - .with_context(|| format!("Failed to create git cache dir: {}", git_cache_dir.display()))?; + fs::create_dir_all(git_cache_dir).with_context(|| { + format!( + "Failed to create git cache dir: {}", + git_cache_dir.display() + ) + })?; let mirror_dir = git_cache_dir.join(mirror_key(url)); ensure_mirror(url, &mirror_dir, pkgname)?; if checkout_dir.exists() { - fs::remove_dir_all(checkout_dir) - .with_context(|| format!("Failed to remove existing checkout: {}", checkout_dir.display()))?; + fs::remove_dir_all(checkout_dir).with_context(|| { + format!( + "Failed to remove existing checkout: {}", + checkout_dir.display() + ) + })?; } // Clone from local mirror for speed. @@ -40,8 +48,7 @@ pub fn checkout( .with_context(|| format!("Failed to clone from mirror for {}", url))?; let repo = Repository::open(checkout_dir)?; - checkout_rev(&repo, rev) - .with_context(|| format!("Failed to checkout revision '{}'", rev))?; + checkout_rev(&repo, rev).with_context(|| format!("Failed to checkout revision '{}'", rev))?; Ok(()) } diff --git a/src/source/hooks.rs b/src/source/hooks.rs index 52f8ed5..a70bf64 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -253,7 +253,7 @@ mod tests { revision: 1, description: "d".into(), homepage: "h".into(), - license: "MIT".into(), + license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives::default(), diff --git a/src/source/mod.rs b/src/source/mod.rs index 7e5c6a2..3ca76b5 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -11,13 +11,15 @@ use sha2::{Digest, Sha256}; use std::fs; use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; +use url::Url; use walkdir::WalkDir; -/// Copy manual (local) sources to the build directory before fetching remote sources. +/// Copy manual sources to the build directory before fetching remote sources. /// -/// Manual sources are checked first, allowing local files like utility source code -/// to be available during the build process. -pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> { +/// Manual sources support: +/// - local mode: `file = "..."` (path relative to spec directory) +/// - remote mode: `url = "..."` (downloaded first, then copied) +pub fn copy_manual_sources(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result<()> { if spec.manual_sources.is_empty() { return Ok(()); } @@ -26,40 +28,83 @@ pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> { println!("Copying {} manual source(s)...", spec.manual_sources.len()); for manual in &spec.manual_sources { - let src_path = spec.spec_dir.join(&manual.file); - if !src_path.exists() { - bail!( - "Manual source not found: {} (expected at {})", - manual.file, - src_path.display() - ); - } + let (source_path, source_label, default_dest): (PathBuf, String, String) = + if let Some(file) = manual.file.as_ref() { + let file = spec.expand_vars(file); + let src_path = spec.spec_dir.join(&file); + if !src_path.exists() { + bail!( + "Manual source not found: {} (expected at {})", + file, + src_path.display() + ); + } - // Verify checksum if provided (supports `sha256:...`, `sha512:...`, `md5:...`, or raw SHA256). - if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") { - if !verify_file_hash(&src_path, expected_hash)? { - bail!( - "Checksum mismatch for {}: expected {}", - manual.file, - expected_hash - ); - } - } + // Verify checksum if provided. + if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") + && !verify_file_hash(&src_path, expected_hash)? + { + bail!("Checksum mismatch for {}: expected {}", file, expected_hash); + } + + (src_path, file.clone(), file) + } else if let Some(url_raw) = manual.url.as_ref() { + let expanded_url = spec.expand_vars(url_raw); + let parsed = Url::parse(&expanded_url) + .with_context(|| format!("Invalid URL: {}", expanded_url))?; + + if parsed.scheme() == "file" { + let src_path = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file URL: {}", expanded_url))?; + if !src_path.exists() { + bail!("Manual source file URL not found: {}", src_path.display()); + } + if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") + && !verify_file_hash(&src_path, expected_hash)? + { + bail!( + "Checksum mismatch for {}: expected {}", + expanded_url, + expected_hash + ); + } + let default_dest = fetcher::derive_filename_from_url(&expanded_url); + (src_path, expanded_url, default_dest) + } else { + let source = crate::package::Source { + url: expanded_url.clone(), + sha256: manual.sha256.clone().unwrap_or_else(|| "skip".to_string()), + extract_dir: "manual-source".to_string(), + patches: Vec::new(), + post_extract: Vec::new(), + }; + let fetched = fetcher::fetch_archive(spec, &source, &cache_dir.join("manual"))?; + let default_dest = fetcher::derive_filename_from_url(&expanded_url); + (fetched, expanded_url, default_dest) + } + } else { + bail!("Manual source must define either 'file' or 'url'"); + }; // Determine destination - let dest_name = manual.dest.as_deref().unwrap_or(&manual.file); - let dest_path = build_dir.join(dest_name); + let dest_name = if let Some(dest) = manual.dest.as_ref() { + spec.expand_vars(dest) + } else { + default_dest + }; + let dest_path = build_dir.join(&dest_name); // Create parent directories if needed if let Some(parent) = dest_path.parent() { fs::create_dir_all(parent)?; } - println!(" {} -> {}", manual.file, dest_path.display()); - fs::copy(&src_path, &dest_path).with_context(|| { + println!(" {} -> {}", source_label, dest_path.display()); + fs::copy(&source_path, &dest_path).with_context(|| { format!( "Failed to copy {} to {}", - src_path.display(), + source_path.display(), dest_path.display() ) })?; @@ -85,7 +130,11 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { let (alg, hex) = if let Some(pos) = exp.find(':') { let a = exp[..pos].trim().to_ascii_lowercase(); let h = exp[pos + 1..].trim().to_ascii_lowercase(); - let alg = if a.is_empty() { "sha256".to_string() } else { a }; + let alg = if a.is_empty() { + "sha256".to_string() + } else { + a + }; (alg, h.to_string()) } else { ("sha256".to_string(), exp.to_ascii_lowercase()) @@ -140,6 +189,22 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result { } } +/// Build a blocking reqwest HTTP client using the platform-default TLS backend. +/// Any error building the client is returned directly (no fallback). +pub(crate) fn build_blocking_client( + user_agent: &str, + timeout: Option, +) -> anyhow::Result { + let mut builder = reqwest::blocking::Client::builder().user_agent(user_agent.to_string()); + if let Some(t) = timeout { + builder = builder.timeout(t); + } + + builder + .build() + .map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e)) +} + /// Fetch + extract all sources. /// /// Returns the primary source directory (the first source entry, or work_dir for manual-only packages). @@ -148,12 +213,12 @@ pub fn prepare(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result if spec.sources().is_empty() { let work_dir = build_dir.join(&spec.package.name); fs::create_dir_all(&work_dir)?; - copy_manual_sources(spec, &work_dir)?; + copy_manual_sources(spec, cache_dir, &work_dir)?; return Ok(work_dir); } // Copy manual sources first (before any remote fetching) - copy_manual_sources(spec, build_dir)?; + copy_manual_sources(spec, cache_dir, build_dir)?; let mut primary: Option = None; @@ -311,6 +376,39 @@ fn split_git_url(url: &str) -> Option<(String, String)> { #[cfg(test)] mod tests { use super::*; + use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, + PackageSpec, Source, + }; + + fn mk_spec_with_manuals(spec_dir: PathBuf, manuals: Vec) -> 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::new(), + alternatives: Alternatives::default(), + manual_sources: manuals, + source: vec![Source { + url: "https://example.com/src.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "src".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir, + } + } #[test] fn split_git_url_accepts_git_with_rev() { @@ -365,5 +463,70 @@ mod tests { assert!(verify_file_hash(tmp.path(), &format!(":{}", sha256_hex)).unwrap()); assert!(!verify_file_hash(tmp.path(), "md5:deadbeef").unwrap()); } -} + #[test] + fn build_blocking_client_with_and_without_timeout() { + use std::time::Duration; + let ua = "depot/test"; + let c1 = build_blocking_client(ua, None).expect("client build failed"); + assert!(c1.get("https://example.com").build().is_ok()); + + let c2 = + build_blocking_client(ua, Some(Duration::from_secs(5))).expect("client build failed"); + assert!(c2.get("https://example.com").build().is_ok()); + } + + #[test] + fn copy_manual_sources_local_file_mode() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("manual.patch"), "patch-data").unwrap(); + + let spec = mk_spec_with_manuals( + spec_dir.clone(), + vec![ManualSource { + file: Some("manual.patch".into()), + url: None, + sha256: None, + dest: None, + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("manual.patch")).unwrap(), + "patch-data" + ); + } + + #[test] + fn copy_manual_sources_url_mode_file_scheme() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(&spec_dir).unwrap(); + let remote_file = tmp.path().join("remote-resource.txt"); + std::fs::write(&remote_file, "remote-data").unwrap(); + let url = format!("file://{}", remote_file.display()); + + let spec = mk_spec_with_manuals( + spec_dir, + vec![ManualSource { + file: None, + url: Some(url), + sha256: Some("skip".into()), + dest: Some("assets/manual.txt".into()), + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("assets/manual.txt")).unwrap(), + "remote-data" + ); + } +} diff --git a/src/staging/mod.rs b/src/staging/mod.rs index b3c113b..2e15723 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -2,12 +2,59 @@ use crate::package::PackageSpec; use anyhow::{Context, Result}; +use std::collections::HashSet; use std::fs; +use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use walkdir::WalkDir; +fn is_skipped_install_path(rel_path: &str) -> bool { + let p = rel_path.trim_start_matches('/'); + p == ".metadata.toml" + || p == ".files.yaml" + || p == "usr/share/info/dir" + || p.starts_with("usr/share/info/dir.") +} + +fn normalize_relative_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + anyhow::bail!("keep paths must not be empty"); + } + + let p = Path::new(trimmed); + if p.is_absolute() { + anyhow::bail!("keep paths must be relative: {}", trimmed); + } + + let mut normalized = PathBuf::new(); + for comp in p.components() { + match comp { + Component::Normal(seg) => normalized.push(seg), + Component::CurDir => {} + _ => { + anyhow::bail!( + "keep paths must not contain traversal or root components: {}", + trimmed + ); + } + } + } + + let s = normalized + .to_str() + .context("keep paths must be valid UTF-8")? + .to_string(); + + if s.is_empty() { + anyhow::bail!("keep paths must not resolve to empty paths: {}", trimmed); + } + + Ok(s) +} + /// Process staged files - remove .la files, strip binaries, etc. pub fn process(destdir: &Path, _spec: &PackageSpec) -> Result<()> { println!("Processing staged files..."); @@ -112,7 +159,7 @@ impl FsTransaction { for rel in &self.removed { let src = self.removed_backup_path(rel); let dst = self.rootfs.join(rel); - if src.exists() { + if src.symlink_metadata().is_ok() { if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; } @@ -132,7 +179,7 @@ impl FsTransaction { for rel in &self.backed_up { let src = self.backup_path(rel); let dst = self.rootfs.join(rel); - if src.exists() { + if src.symlink_metadata().is_ok() { if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; } @@ -174,7 +221,13 @@ pub fn install_atomic( rootfs: &Path, tx_base_dir: &Path, remove_paths: &[String], + keep_paths: &[String], ) -> Result { + let keep_set: HashSet = keep_paths + .iter() + .map(|p| normalize_relative_path(p)) + .collect::>>()?; + fs::create_dir_all(tx_base_dir) .with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?; @@ -236,7 +289,18 @@ pub fn install_atomic( .to_string_lossy() .to_string(); - let dest_path = rootfs.join(&rel_path); + if is_skipped_install_path(&rel_path) { + continue; + } + + let keep_as_depotnew = keep_set.contains(&rel_path) && rootfs.join(&rel_path).exists(); + let install_rel_path = if keep_as_depotnew { + format!("{}.depotnew", rel_path) + } else { + rel_path.clone() + }; + + let dest_path = rootfs.join(&install_rel_path); if let Some(parent) = dest_path.parent() { fs::create_dir_all(parent)?; @@ -245,7 +309,7 @@ pub fn install_atomic( if dest_path.symlink_metadata().is_ok() { // lexists checks existence without following symlinks // Backup existing - let backup_path = tx.backup_path(&rel_path); + let backup_path = tx.backup_path(&install_rel_path); if let Some(parent) = backup_path.parent() { fs::create_dir_all(parent)?; } @@ -259,9 +323,9 @@ pub fn install_atomic( fs::copy(&dest_path, &backup_path)?; } - tx.backed_up.push(rel_path.clone()); + tx.backed_up.push(install_rel_path.clone()); } else { - tx.created.push(rel_path.clone()); + tx.created.push(install_rel_path.clone()); } // Install new file/symlink @@ -277,10 +341,10 @@ pub fn install_atomic( if file_type.is_symlink() { let target = fs::read_link(src_path)?; std::os::unix::fs::symlink(target, &dest_path) - .with_context(|| format!("Failed to create symlink: {}", rel_path))?; + .with_context(|| format!("Failed to create symlink: {}", install_rel_path))?; } else { fs::copy(src_path, &dest_path) - .with_context(|| format!("Failed to install: {}", rel_path))?; + .with_context(|| format!("Failed to install: {}", install_rel_path))?; } } @@ -288,21 +352,38 @@ pub fn install_atomic( for rel in remove_paths { let rel = rel.as_str(); let dest_path = rootfs.join(rel); - if !dest_path.exists() { - continue; - } - if !dest_path.is_file() { - // Only track files for now. + let dest_meta = match dest_path.symlink_metadata() { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(e).with_context(|| { + format!("Failed to inspect obsolete path before removal: {}", rel) + }); + } + }; + + if dest_meta.file_type().is_dir() { + // Only obsolete files/symlinks are removed here. continue; } + let backup_path = tx.removed_backup_path(rel); if let Some(parent) = backup_path.parent() { fs::create_dir_all(parent)?; } - fs::copy(&dest_path, &backup_path) - .with_context(|| format!("Failed to backup removed file: {}", rel))?; + + if dest_meta.file_type().is_symlink() { + let target = fs::read_link(&dest_path) + .with_context(|| format!("Failed to read obsolete symlink target: {}", rel))?; + std::os::unix::fs::symlink(&target, &backup_path) + .with_context(|| format!("Failed to backup removed symlink: {}", rel))?; + } else { + fs::copy(&dest_path, &backup_path) + .with_context(|| format!("Failed to backup removed file: {}", rel))?; + } + fs::remove_file(&dest_path) - .with_context(|| format!("Failed to remove obsolete file: {}", rel))?; + .with_context(|| format!("Failed to remove obsolete file/symlink: {}", rel))?; tx.removed.push(rel.to_string()); } @@ -362,7 +443,7 @@ mod tests { std::fs::write(destdir.join("usr/bin/new_only"), "added").unwrap(); let remove_paths = vec!["usr/bin/old_only".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths).unwrap(); + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); // After install: updated + new present, obsolete removed assert_eq!( @@ -382,6 +463,94 @@ mod tests { assert!(rootfs.join("usr/bin/old_only").exists()); } + #[test] + fn install_atomic_keep_existing_installs_depotnew() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("etc")).unwrap(); + std::fs::create_dir_all(destdir.join("etc")).unwrap(); + + std::fs::write(rootfs.join("etc/locale.gen"), "existing").unwrap(); + std::fs::write(destdir.join("etc/locale.gen"), "from-package").unwrap(); + + let keep = vec!["etc/locale.gen".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); + + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), + "existing" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen.depotnew")).unwrap(), + "from-package" + ); + + tx.rollback().unwrap(); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), + "existing" + ); + assert!(!rootfs.join("etc/locale.gen.depotnew").exists()); + } + + #[test] + fn install_atomic_rejects_unsafe_keep_paths() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("etc")).unwrap(); + std::fs::write(destdir.join("etc/locale.gen"), "x").unwrap(); + + let keep = vec!["../etc/shadow".to_string()]; + let err = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep) + .expect_err("expected keep path traversal to be rejected"); + assert!( + err.to_string() + .contains("keep paths must not contain traversal") + ); + } + + #[test] + fn install_atomic_removes_obsolete_symlink_paths() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/lib")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/new"), "ok").unwrap(); + + std::os::unix::fs::symlink("../lib/libold.so", rootfs.join("usr/lib/libold.so.link")) + .unwrap(); + assert!( + rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .is_ok() + ); + + let remove_paths = vec!["usr/lib/libold.so.link".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + assert!( + rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .is_err() + ); + + tx.rollback().unwrap(); + let restored = rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .expect("symlink should be restored"); + assert!(restored.file_type().is_symlink()); + } + #[test] fn install_atomic_commit_removes_tx_dir() { let tmp = tempfile::tempdir().unwrap(); @@ -392,7 +561,7 @@ mod tests { std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); std::fs::write(destdir.join("usr/bin/foo"), "x").unwrap(); - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap(); + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); let tx_dir = tx.tx_dir.clone(); assert!(tx_dir.exists()); tx.commit().unwrap(); @@ -410,7 +579,7 @@ mod tests { // Create a symlink bin -> usr/bin in destdir std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap(); - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap(); + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); // Verify rootfs/bin is a symlink, not a directory let meta = rootfs @@ -442,6 +611,86 @@ mod tests { } } } + + #[test] + fn install_atomic_skips_info_dir_index() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); + std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir.gz"), "index gz").unwrap(); + std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join("usr/share/info/dir").exists()); + assert!(!rootfs.join("usr/share/info/dir.gz").exists()); + assert!(rootfs.join("usr/share/info/ok.info").exists()); + } + + #[test] + fn install_atomic_skips_package_metadata_files() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); + std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join(".metadata.toml").exists()); + assert!(!rootfs.join(".files.yaml").exists()); + assert!(rootfs.join("usr/bin/ok").exists()); + } + + #[test] + fn generate_manifest_skips_info_dir_index() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); + std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir.xz"), "index xz").unwrap(); + std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&"usr/share/info/dir".to_string())); + assert!( + !manifest + .files + .contains(&"usr/share/info/dir.xz".to_string()) + ); + assert!( + manifest + .files + .contains(&"usr/share/info/ok.info".to_string()) + ); + } + + #[test] + fn generate_manifest_skips_package_metadata_files() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); + std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&".metadata.toml".to_string())); + assert!(!manifest.files.contains(&".files.yaml".to_string())); + assert!(manifest.files.contains(&"usr/bin/ok".to_string())); + } } /// Manifest containing files and directories for a package @@ -468,6 +717,10 @@ pub fn generate_manifest_with_dirs(destdir: &Path) -> Result { continue; } + if is_skipped_install_path(&rel_path) { + continue; + } + let file_type = entry.file_type(); // Check for symlink first