Refactor and enhance source management and installation processes

- Improved error handling and context messages in `checkout` function in `git.rs`.
- Updated license field in package specification to use a vector in `hooks.rs`.
- Enhanced `copy_manual_sources` function in `mod.rs` to support both local file and remote URL sources, including checksum verification.
- Added new utility functions for path normalization and skipping installation of specific files in `staging/mod.rs`.
- Implemented logic to handle existing files during installation, allowing for `.depotnew` suffix for kept files.
- Added tests for manual source copying, installation behavior, and path validation.
- Introduced a new LICENSE file with MIT License terms.
This commit is contained in:
2026-02-21 13:25:14 -06:00
parent af0571c33b
commit c9bed308e2
27 changed files with 3868 additions and 939 deletions
Generated
+171 -145
View File
@@ -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",
+9 -7
View File
@@ -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"
+21
View File
@@ -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.
+359 -70
View File
@@ -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<std::path::PathBuf> {
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<String> {
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<Option<&'static str>> {
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<Option<&'static str>> {
if skip_tests {
return Ok(None);
}
find_autotools_test_target(build_dir)
}
fn makefile_has_target(build_dir: &Path, target: &str) -> Result<bool> {
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<String> {
let mut result = input.to_string();
@@ -253,6 +413,9 @@ fn expand_shell_commands(input: &str, cc: &str) -> Result<String> {
#[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<String> = 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);
}
}
+23 -10
View File
@@ -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());
+51 -62
View File
@@ -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/<sub>` -> use if exists
/// - `spec.spec_dir/<sub>` -> use if exists (supports `../llvm`)
/// - bare relative path (cwd)
fn resolve_actual_src(spec: &crate::package::PackageSpec, src_dir: &Path) -> anyhow::Result<std::path::PathBuf> {
fn resolve_actual_src(
spec: &crate::package::PackageSpec,
src_dir: &Path,
) -> anyhow::Result<std::path::PathBuf> {
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/<source_subdir>
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/<source_subdir> (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);
}
}
+27 -61
View File
@@ -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(())
+14 -33
View File
@@ -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");
+6 -46
View File
@@ -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
+240 -18
View File
@@ -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<String>) {
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"
);
}
}
+8 -14
View File
@@ -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 ({})...",
+25 -7
View File
@@ -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");
}
}
}
+7 -12
View File
@@ -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<String> {
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<String>
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)
}
+20 -20
View File
@@ -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();
+80 -12
View File
@@ -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<String> {
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<String> = 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/<reponame>
pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap<String, String>) -> Result<()> {
use git2::{Repository, FetchOptions, Cred, RemoteCallbacks, ResetType, build::RepoBuilder};
pub fn sync_mirrors(
repo_dir: &std::path::Path,
mirrors: &std::collections::HashMap<String, String>,
) -> 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<String, String>) -> Result<()> {
pub fn mirrors_status(
repo_dir: &std::path::Path,
mirrors: &std::collections::HashMap<String, String>,
) -> 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<String> = conn
.query_row("SELECT license FROM packages", [], |r| r.get(0))
.unwrap();
assert_eq!(lic, Some("MIT, Apache-2.0".to_string()));
}
}
+79
View File
@@ -173,10 +173,31 @@ pub fn check_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<Stri
Ok(missing)
}
/// Check if all test dependencies are satisfied
pub fn check_test_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
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 <package>",
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()]);
}
}
+13 -3
View File
@@ -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());
}
}
}
+188 -41
View File
@@ -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<String> {
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<String>,
/// 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<PathBuf>,
/// 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!(
+784 -129
View File
File diff suppressed because it is too large Load Diff
+49 -2
View File
@@ -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"));
}
}
+765 -10
View File
@@ -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<String>,
}
fn default_revision() -> u32 {
1
}
fn deserialize_licenses<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrArray {
String(String),
Array(Vec<String>),
}
match StringOrArray::deserialize(deserializer)? {
StringOrArray::String(s) => Ok(vec![s]),
StringOrArray::Array(v) => Ok(v),
}
}
fn serialize_licenses<S>(licenses: &[String], serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
if licenses.len() == 1 {
serializer.serialize_str(&licenses[0])
} else {
licenses.serialize(serializer)
}
}
impl PackageSpec {
/// Generate the standard package filename: <name>-<version>-<revision>-<arch>.depot.pkg.tar.zst
pub fn package_filename(&self, arch: &str) -> String {
@@ -613,15 +1307,20 @@ pub struct Source {
pub post_extract: Vec<String>,
}
/// 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<String>,
/// Remote URL to fetch (remote manual source mode).
#[serde(default)]
pub url: Option<String>,
/// Checksum (optional, use "skip" to bypass verification).
#[serde(default)]
pub sha256: Option<String>,
/// 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<String>,
}
@@ -675,6 +1374,15 @@ pub struct BuildFlags {
pub cflags: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub ldflags: Vec<String>,
/// Keep existing files and install package-provided replacement as `<path>.depotnew`.
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub keep: Vec<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// Variable overrides passed directly to `make install`.
#[serde(
default,
alias = "make-install-vars",
deserialize_with = "deserialize_string_or_array"
)]
pub make_install_vars: Vec<String>,
/// 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<String>,
// 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<String>,
/// Dependencies required at runtime.
#[serde(default)]
pub runtime: Vec<String>,
/// Dependencies required to run package test suites.
#[serde(default)]
pub test: Vec<String>,
}
+49 -49
View File
@@ -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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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::<Vec<_>>();
let top = fs::read_dir(tmp.path())?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
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))
+400 -131
View File
@@ -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<PathBuf> {
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<String> = 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<bool> {
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<Option<String>> {
fn validate_downloaded_archive(
path: &std::path::Path,
filename: &str,
orig_url: &str,
) -> Result<Option<String>> {
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("<!doctype html") || head_str.starts_with("<html") || head_str.contains("<html") {
// If this came from SourceForge project URL, try to extract a direct
// downloads.sourceforge.net mirror link and return it for a retry.
let body = String::from_utf8_lossy(head).to_string();
if orig_url.contains("sourceforge.net") {
// helper: find nearest href attribute before `pos` and support
// both double- and single-quoted attributes.
let find_href_before = |body: &str, pos: usize| -> Option<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());
}
}
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<String> {
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<String> {
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("&amp;", "&");
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("&amp;", "&");
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<String> {
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<String> {
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("<!doctype html")
|| head_str.starts_with("<html")
|| head_str.contains("<html")
}
fn classify_archive_magic(head: &[u8], lower_filename: &str, path: &Path) -> 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 "!<arch>\n"
} else if lower_filename.ends_with(".deb") {
head.starts_with(b"!<arch>")
} 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, "<!doctype html><html><body>No direct link</body></html>").unwrap();
write!(
tmp,
"<!doctype html><html><body>No direct link</body></html>"
)
.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,
"<!doctype html><html><body>{}<a href='//downloads.sourceforge.net/project/tcl/tcl8.6.17-src.tar.gz?download'>download</a></body></html>",
"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,
"<!doctype html><a href='https://x.com/share?url=https://sourceforge.net/projects/tcl/files/Tcl/8.6.17/tcl8.6.17-src.tar.gz/download&amp;text=share'>share</a>"
)
.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());
}
}
+13 -6
View File
@@ -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(())
}
+1 -1
View File
@@ -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(),
+194 -31
View File
@@ -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<bool> {
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<bool> {
}
}
/// 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<std::time::Duration>,
) -> anyhow::Result<reqwest::blocking::Client> {
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<PathBuf> = 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<ManualSource>) -> 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"
);
}
}
+272 -19
View File
@@ -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<String> {
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<FsTransaction> {
let keep_set: HashSet<String> = keep_paths
.iter()
.map(|p| normalize_relative_path(p))
.collect::<Result<HashSet<_>>>()?;
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<Manifest> {
continue;
}
if is_skipped_install_path(&rel_path) {
continue;
}
let file_type = entry.file_type();
// Check for symlink first